Compare commits
502
Commits
7.0.0-beta.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00b8d04a1 | ||
|
|
0b6f329249 | ||
|
|
72f46a6468 | ||
|
|
a8a98f6ed0 | ||
|
|
455d009a83 | ||
|
|
3b0a8c03cf | ||
|
|
dd8f9a49c4 | ||
|
|
9ca7b42e80 | ||
|
|
5c2e42c133 | ||
|
|
e160a86f0d | ||
|
|
0c4f52fc96 | ||
|
|
ffab451b4a | ||
|
|
21177c8e17 | ||
|
|
f32729e20e | ||
|
|
f506c040c1 | ||
|
|
893c63c605 | ||
|
|
50007d5cc6 | ||
|
|
72fb12f267 | ||
|
|
b46396b079 | ||
|
|
bc0b47b12b | ||
|
|
e3ca585afd | ||
|
|
f41915124a | ||
|
|
7946410ba6 | ||
|
|
d8426c5341 | ||
|
|
98bce431f8 | ||
|
|
033d2e6d9b | ||
|
|
24f9df9de4 | ||
|
|
67cae00dd8 | ||
|
|
b79e082ca3 | ||
|
|
de18159986 | ||
|
|
d263f74cae | ||
|
|
0be081c997 | ||
|
|
9cf9c3ff8c | ||
|
|
8f072895ff | ||
|
|
3e41dc6075 | ||
|
|
9d3b15e300 | ||
|
|
f417488816 | ||
|
|
0833bd1f6a | ||
|
|
482faf00be | ||
|
|
c305b56248 | ||
|
|
381fcc4e0b | ||
|
|
b97deee43a | ||
|
|
1124fad027 | ||
|
|
8cf829f953 | ||
|
|
92923a5011 | ||
|
|
5d14d01d50 | ||
|
|
db8640df86 | ||
|
|
1e89335258 | ||
|
|
621cd28376 | ||
|
|
21fc114f37 | ||
|
|
d4439ea30a | ||
|
|
151a7ae945 | ||
|
|
de584ea93f | ||
|
|
cd189190c2 | ||
|
|
da5f76a9d8 | ||
|
|
3cb598ce5f | ||
|
|
3a0aba85bc | ||
|
|
eb7ff4ec6a | ||
|
|
6c63536f81 | ||
|
|
8328b1db77 | ||
|
|
11175d35d6 | ||
|
|
266b723b9d | ||
|
|
01d319e74b | ||
|
|
f094a5f3fc | ||
|
|
4d60b6cefb | ||
|
|
f9ef15f287 | ||
|
|
b6c5e1f323 | ||
|
|
46ada4dddb | ||
|
|
85e266d361 | ||
|
|
fc27a6edf6 | ||
|
|
b4ed33aa44 | ||
|
|
fe973f40af | ||
|
|
3945b6f1b3 | ||
|
|
72f2e94c1a | ||
|
|
0253c1aa68 | ||
|
|
0bb33e3baa | ||
|
|
5ba7a83597 | ||
|
|
56836a2b66 | ||
|
|
78bb192bb0 | ||
|
|
9ff363bd14 | ||
|
|
7b26c4beec | ||
|
|
28d6a7752c | ||
|
|
0bccd867a7 | ||
|
|
e7fa1b5535 | ||
|
|
40f62e79aa | ||
|
|
e17731d8cc | ||
|
|
6a8a537f10 | ||
|
|
acdff53792 | ||
|
|
1ba67ca7cf | ||
|
|
14eb2e39e4 | ||
|
|
ca4834bbb9 | ||
|
|
df918de2a6 | ||
|
|
494e581973 | ||
|
|
510e498fec | ||
|
|
0db8f9c5fb | ||
|
|
80d16c80bc | ||
|
|
03cbb99196 | ||
|
|
565f6c0117 | ||
|
|
d8ca281464 | ||
|
|
6d2677e7bd | ||
|
|
a7b7c64573 | ||
|
|
0e8b625436 | ||
|
|
8fa97bc8bd | ||
|
|
f0562a8553 | ||
|
|
5d58fb5c5b | ||
|
|
8bf40465df | ||
|
|
6a756bdec1 | ||
|
|
07c459f0ff | ||
|
|
932866da8f | ||
|
|
04f1066e66 | ||
|
|
4b986be0b2 | ||
|
|
a1e1706ddc | ||
|
|
0bb2ab9b49 | ||
|
|
b3d0da9355 | ||
|
|
ed985dd175 | ||
|
|
3ee37e8399 | ||
|
|
2fb452aa44 | ||
|
|
19424877e8 | ||
|
|
3ebbda5d86 | ||
|
|
60652fdc07 | ||
|
|
cf1d8ab52c | ||
|
|
2b5972437b | ||
|
|
febffb173e | ||
|
|
db49e8b9b8 | ||
|
|
e819db9d42 | ||
|
|
09a700e4c7 | ||
|
|
01526faac2 | ||
|
|
7f457a05b3 | ||
|
|
3ccfb24417 | ||
|
|
cfc2caba1b | ||
|
|
1d895a6428 | ||
|
|
71abdeaf60 | ||
|
|
cd84b63d2b | ||
|
|
b16c5c38a5 | ||
|
|
b33559e462 | ||
|
|
d970117149 | ||
|
|
d7ef20668a | ||
|
|
7e6f41cc53 | ||
|
|
071719e570 | ||
|
|
4e1ec6e001 | ||
|
|
49c155509e | ||
|
|
c341fe28a4 | ||
|
|
a5a7caf8ba | ||
|
|
057e002ad1 | ||
|
|
37fad55333 | ||
|
|
ea58c606a2 | ||
|
|
c128a6ea3a | ||
|
|
55bc9399e6 | ||
|
|
9131ee6bb7 | ||
|
|
d675c8ac9f | ||
|
|
7c10f45aaf | ||
|
|
b1b5bfa417 | ||
|
|
709fbff278 | ||
|
|
0bcd42ac04 | ||
|
|
2de257491f | ||
|
|
b44f2befd7 | ||
|
|
7765695339 | ||
|
|
95957bb167 | ||
|
|
c77ddee03b | ||
|
|
c09711f609 | ||
|
|
5ae34002f3 | ||
|
|
22175d42b0 | ||
|
|
284b38b16c | ||
|
|
5ca8f636e6 | ||
|
|
71ff784b90 | ||
|
|
a8d0d21316 | ||
|
|
84e37bfa8f | ||
|
|
152e3bfde7 | ||
|
|
24597c2310 | ||
|
|
623d9b4f9e | ||
|
|
28ca8c9cc5 | ||
|
|
6c55d1b2ca | ||
|
|
3b831c8523 | ||
|
|
4f074d19ef | ||
|
|
3f29f9fd1e | ||
|
|
2ba4a760fb | ||
|
|
1b0be674aa | ||
|
|
30ad72a3a1 | ||
|
|
d3a62a7e04 | ||
|
|
f70c41c9ee | ||
|
|
e21bff678f | ||
|
|
7f4e665827 | ||
|
|
46ff9c036b | ||
|
|
442474817d | ||
|
|
6490b339dd | ||
|
|
6510ce9d9c | ||
|
|
facbd98b14 | ||
|
|
a4632eb47e | ||
|
|
2ba0c2ba2d | ||
|
|
4060a91d28 | ||
|
|
3d0f260653 | ||
|
|
f9c4c025cb | ||
|
|
3715be5e51 | ||
|
|
4744e7bf85 | ||
|
|
e80be1d924 | ||
|
|
bf3d0d1df5 | ||
|
|
c9a73e7877 | ||
|
|
0207ae024e | ||
|
|
f496477ba0 | ||
|
|
6c8381f164 | ||
|
|
b07d6c8953 | ||
|
|
38a5cbe550 | ||
|
|
403154167a | ||
|
|
3f2674f1a7 | ||
|
|
faa79b0ae1 | ||
|
|
a9f85ff92b | ||
|
|
42eb21320b | ||
|
|
58560eefb1 | ||
|
|
eb724ed348 | ||
|
|
cd82fe3fb4 | ||
|
|
af1da5fe0b | ||
|
|
5b587d7119 | ||
|
|
26a349590e | ||
|
|
2b2856ed37 | ||
|
|
bd7b31feb8 | ||
|
|
7b859d4200 | ||
|
|
0d1105abfc | ||
|
|
74a0a54016 | ||
|
|
e619b21b2b | ||
|
|
4c62dbe649 | ||
|
|
41fe812f2c | ||
|
|
2ae1972a3b | ||
|
|
97ab99b7cd | ||
|
|
f9695df74a | ||
|
|
1de67731b7 | ||
|
|
4f6fced2db | ||
|
|
b5c0ec1178 | ||
|
|
f5b411d9a0 | ||
|
|
4b6adf4788 | ||
|
|
30867e0e0b | ||
|
|
1ce434e2da | ||
|
|
e426498fb3 | ||
|
|
65378fdeb6 | ||
|
|
60cccbf62d | ||
|
|
0ae3289c60 | ||
|
|
19789433df | ||
|
|
953616552f | ||
|
|
ca4fd02e99 | ||
|
|
6bde24bc75 | ||
|
|
ef2a77e0a0 | ||
|
|
9025f6d1aa | ||
|
|
b10e89bef0 | ||
|
|
5f4a733b3b | ||
|
|
be9befe023 | ||
|
|
95ffdf9eed | ||
|
|
1eedbbdda1 | ||
|
|
1866f0d566 | ||
|
|
ea9a14725b | ||
|
|
0f0ef8fa8d | ||
|
|
6b3903553a | ||
|
|
b127afcea7 | ||
|
|
bd215bc586 | ||
|
|
eca1232246 | ||
|
|
ac57df93bd | ||
|
|
4d50fa2cde | ||
|
|
11d3cc6445 | ||
|
|
2a036704f4 | ||
|
|
192000cfce | ||
|
|
a0cc36a9db | ||
|
|
d5b6f4b457 | ||
|
|
f1fcd48dc3 | ||
|
|
ffbbd9356a | ||
|
|
7b56d5f23a | ||
|
|
d17ea28da5 | ||
|
|
5d140dfbe6 | ||
|
|
023ab4f099 | ||
|
|
0a6a8d6968 | ||
|
|
40d3ce9639 | ||
|
|
49edacf5e0 | ||
|
|
f66f1a2f20 | ||
|
|
a706422401 | ||
|
|
4b5ec6ebd6 | ||
|
|
f49d939ec1 | ||
|
|
a1b30fd218 | ||
|
|
f2c11d2edb | ||
|
|
9dee9f5e35 | ||
|
|
8521428636 | ||
|
|
e8784854aa | ||
|
|
efe1a81cb8 | ||
|
|
e41f9c14c3 | ||
|
|
4e77d25926 | ||
|
|
a8a85ac0cf | ||
|
|
d4bb117b9b | ||
|
|
ca5c9a3438 | ||
|
|
8aa395c3a1 | ||
|
|
cd11fc60ce | ||
|
|
eb0c72c32b | ||
|
|
92f36e86b1 | ||
|
|
5e9d046812 | ||
|
|
11f1d79540 | ||
|
|
fc00e30fc4 | ||
|
|
17a95bb556 | ||
|
|
af43a7c41d | ||
|
|
73b05aa42f | ||
|
|
77df8c9a7c | ||
|
|
fd7e216639 | ||
|
|
a3495d8dc6 | ||
|
|
6549170bda | ||
|
|
b48ac91fec | ||
|
|
d03c731876 | ||
|
|
84555ea8eb | ||
|
|
da031804ae | ||
|
|
1c6217f90d | ||
|
|
ef13a1ddff | ||
|
|
0dc174f2dc | ||
|
|
3d8890b118 | ||
|
|
6b791679a6 | ||
|
|
0dfb45951b | ||
|
|
b505ac7ba7 | ||
|
|
b3efcb8366 | ||
|
|
abb40ad210 | ||
|
|
9dda22f8e4 | ||
|
|
cd19344f48 | ||
|
|
9bcd97098b | ||
|
|
65f1c70ffe | ||
|
|
05e81c6017 | ||
|
|
95008ee2af | ||
|
|
d6e1f98a4f | ||
|
|
3044119b37 | ||
|
|
327c433250 | ||
|
|
7e569efe4c | ||
|
|
072b3f50d9 | ||
|
|
15cd4452bf | ||
|
|
74df8a4b09 | ||
|
|
7f8fb6870f | ||
|
|
c001b30a87 | ||
|
|
a3b25b2756 | ||
|
|
cbf8ce1555 | ||
|
|
062a88ca04 | ||
|
|
91ec5f2d1f | ||
|
|
0cdb97862f | ||
|
|
8522245137 | ||
|
|
707919719c | ||
|
|
8fda5d31e1 | ||
|
|
124433148c | ||
|
|
3dd34104cc | ||
|
|
face022073 | ||
|
|
1629684156 | ||
|
|
1a6fca0d6b | ||
|
|
1c99547bc1 | ||
|
|
21f666e4b5 | ||
|
|
fcaacad8d1 | ||
|
|
8ca5e34a53 | ||
|
|
d5065f1bc9 | ||
|
|
8828c80b56 | ||
|
|
a45e233b97 | ||
|
|
7b7a175c07 | ||
|
|
a7340f21ca | ||
|
|
f157ed1cc9 | ||
|
|
0a6ff23132 | ||
|
|
9251af5c6a | ||
|
|
7598b76498 | ||
|
|
c0e0d82459 | ||
|
|
8b9bfd0c91 | ||
|
|
e896a47d4f | ||
|
|
10fe28a16d | ||
|
|
00ef91bda5 | ||
|
|
8fcb79d60f | ||
|
|
79b83626eb | ||
|
|
29c25376bc | ||
|
|
512869e407 | ||
|
|
ab4f05c1c0 | ||
|
|
ce8e55b390 | ||
|
|
4c73db2e91 | ||
|
|
47a2e815aa | ||
|
|
90f154ecc0 | ||
|
|
84ca40f0d8 | ||
|
|
c4beb116ad | ||
|
|
f848086fd6 | ||
|
|
cc705c2f21 | ||
|
|
1922003917 | ||
|
|
9be8faaea2 | ||
|
|
ae447c9cc4 | ||
|
|
57ac2b0b3b | ||
|
|
cd3f6ad2b7 | ||
|
|
a690fdd2c4 | ||
|
|
8cc40e0681 | ||
|
|
d85ff9971a | ||
|
|
793afb95dc | ||
|
|
68cbee0e83 | ||
|
|
bfaf551d41 | ||
|
|
8018c525b8 | ||
|
|
25c0ab46ec | ||
|
|
13e65131f4 | ||
|
|
a34a03eb40 | ||
|
|
b8ceb26cf3 | ||
|
|
feca0c7146 | ||
|
|
1ec9692cac | ||
|
|
b65c782447 | ||
|
|
2832db6ffd | ||
|
|
f432df9d9c | ||
|
|
edb4d13096 | ||
|
|
abe3fe85d4 | ||
|
|
809409f3cf | ||
|
|
12968b76ca | ||
|
|
051ed1e528 | ||
|
|
0f84657a5c | ||
|
|
0ff4b674ac | ||
|
|
e3c00addd8 | ||
|
|
0200f1d043 | ||
|
|
5f99b104c1 | ||
|
|
5f3b904aab | ||
|
|
45bf59472a | ||
|
|
2493bf424a | ||
|
|
c26b49a997 | ||
|
|
13aeb433cc | ||
|
|
168f439be3 | ||
|
|
56a78e1d55 | ||
|
|
70452745c4 | ||
|
|
79eb091351 | ||
|
|
848b408b60 | ||
|
|
49b21e788c | ||
|
|
dbbc343734 | ||
|
|
cb2b481caf | ||
|
|
487283b33a | ||
|
|
212ecb3577 | ||
|
|
6acc914100 | ||
|
|
e4f5319dad | ||
|
|
683ebdc396 | ||
|
|
3ab879ea9a | ||
|
|
e7bb07f80e | ||
|
|
93f6bd5b71 | ||
|
|
d0943d6cb6 | ||
|
|
88116de788 | ||
|
|
c6cab1f668 | ||
|
|
f744b816b6 | ||
|
|
b9355f051c | ||
|
|
ff45a19b4b | ||
|
|
cebe352e46 | ||
|
|
48f877bdcd | ||
|
|
efc8a7ae6b | ||
|
|
6505d85959 | ||
|
|
32b1b11930 | ||
|
|
cbc91117d7 | ||
|
|
94d2cd2c11 | ||
|
|
157485003d | ||
|
|
342486a20b | ||
|
|
e7e2936ea0 | ||
|
|
8429d8ada5 | ||
|
|
d18aaab1fd | ||
|
|
646efeb91c | ||
|
|
dee5a12ded | ||
|
|
5ee1d1f2b5 | ||
|
|
9d7bccdfb9 | ||
|
|
155b95989a | ||
|
|
b98a536162 | ||
|
|
98989e3c2c | ||
|
|
2473ca7242 | ||
|
|
ba2adcf748 | ||
|
|
8cf55ad88f | ||
|
|
4c4f90a091 | ||
|
|
bb654fb56c | ||
|
|
0de58baf46 | ||
|
|
b03042b6d7 | ||
|
|
d6baa1c299 | ||
|
|
4a90046d54 | ||
|
|
d6555c0e7f | ||
|
|
d466aae6eb | ||
|
|
c819820573 | ||
|
|
a958443d69 | ||
|
|
f267f21ea6 | ||
|
|
c59819645a | ||
|
|
1cdef4513a | ||
|
|
5a9ad97327 | ||
|
|
b428734a06 | ||
|
|
0e9bb39980 | ||
|
|
7c18adc09d | ||
|
|
a10200164c | ||
|
|
2a9f521487 | ||
|
|
a681908d54 | ||
|
|
710f8e9135 | ||
|
|
76f7300e8f | ||
|
|
022d629144 | ||
|
|
c08dae7cb0 | ||
|
|
f0d44989e1 | ||
|
|
9863ea9d3a | ||
|
|
deacbc7b66 | ||
|
|
239bdffa10 | ||
|
|
479d537dfb | ||
|
|
657f546efc | ||
|
|
4371f0c90e | ||
|
|
52ee8c855a | ||
|
|
61608ca341 | ||
|
|
eb2b35ef36 | ||
|
|
56d5200fcc | ||
|
|
ce8055e65c | ||
|
|
802e99bbcc | ||
|
|
27901dd254 | ||
|
|
68d2a68062 | ||
|
|
32251b2a1d | ||
|
|
917bfb603a | ||
|
|
c4ea9a6cd1 | ||
|
|
0e7fa04b03 | ||
|
|
c186d9a9c2 | ||
|
|
61f5874fd2 | ||
|
|
cc45c0b84c | ||
|
|
eb467aca79 | ||
|
|
1db039ff34 | ||
|
|
05db17887d | ||
|
|
d1e274165a | ||
|
|
5d4890d6fb | ||
|
|
fa8eba7a0d |
+61
-3
@@ -3,7 +3,17 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.1
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
|
||||
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
|
||||
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
|
||||
# TRUSTED_PROXIES=*
|
||||
|
||||
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
@@ -22,7 +32,9 @@ BCRYPT_ROUNDS=12
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
# 설치 직후 기본값은 운영 기준(error). 관리자 환경설정 > 고급에서 조정하며,
|
||||
# 그 값이 이 항목보다 우선합니다. debug 로 두면 정상 동작 기록까지 매 요청 쌓입니다.
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
|
||||
@@ -36,7 +48,9 @@ DB_WRITE_PASSWORD=
|
||||
# Database Table Prefix (optional)
|
||||
DB_PREFIX=g7_
|
||||
|
||||
# Read Database Connection (Optional - falls back to WRITE if not set)
|
||||
# Read Database Connection (Optional)
|
||||
# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php).
|
||||
# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요.
|
||||
DB_READ_HOST=
|
||||
DB_READ_PORT=
|
||||
DB_READ_DATABASE=
|
||||
@@ -96,10 +110,54 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
|
||||
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
|
||||
# AWS_URL=
|
||||
# AWS_ENDPOINT=
|
||||
# ATTACHMENT_DISK=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ── .env 키 단위 우선 (선택) ────────────────────────────────────────────────
|
||||
# 기본값은 관리자 환경설정(admin UI)이 운영 SSoT 이고, 저장된 값이 아래 항목들의
|
||||
# .env 값을 덮습니다. 이 스위치를 켜면 .env 에 **값이 명시된 항목만** 그 덮어쓰기에서
|
||||
# 빠져 .env 가 권위를 갖고, 관리자 화면의 해당 필드는 편집 불가로 표시됩니다.
|
||||
# 켜지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
|
||||
# 주의: 이 파일은 값을 채워 배포되므로, 켜는 즉시 APP_NAME·LOG_LEVEL·
|
||||
# G7_UPDATE_GITHUB_URL 같은 이미 채워진 항목이 함께 잠깁니다.
|
||||
# .env 만 편집한 뒤에는 php artisan config:cache 를 다시 실행해야 반영됩니다.
|
||||
# 대상 항목과 규약: docs/backend/admin-settings-access.md
|
||||
# G7_ENV_PRIORITY=true
|
||||
|
||||
# 코어 업데이트
|
||||
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
|
||||
G7_UPDATE_GITHUB_TOKEN=
|
||||
G7_UPDATE_PENDING_PATH=
|
||||
|
||||
# spawn 자식 프로세스 실패 시 동작 모드 (기본: abort)
|
||||
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
|
||||
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
|
||||
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
|
||||
|
||||
# 부트스트랩 리소스 정적 게시 (public/build/ext) — 끄면 전면 API 폴백(종전 동작).
|
||||
# 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」
|
||||
# G7_STATIC_CACHE=true
|
||||
|
||||
# SEO 봇 캐시 상한 (config/core.php seo_cache_limits). 봇 판정은 User-Agent 뿐이라 위장이
|
||||
# 가능하고, 캐시 키에 쿼리가 들어가므로 값만 바꾼 반복 요청이 매번 렌더·저장을 유발한다.
|
||||
# 렌더 예산을 넘긴 요청은 오류가 아니라 일반 SPA 응답(X-SEO-Cache: BYPASS)을 받는다.
|
||||
# G7_SEO_CACHE_MAX_QUERY_PARAMS=10
|
||||
# G7_SEO_CACHE_MAX_QUERY_LENGTH=512
|
||||
# G7_SEO_CACHE_MAX_VARIANTS_PER_PATH=50
|
||||
# G7_SEO_CACHE_MAX_ENTRIES=20000
|
||||
# G7_SEO_RENDER_MISSES_PER_MINUTE=60
|
||||
# G7_SEO_STATS_RECORDS_PER_MINUTE=300
|
||||
|
||||
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
|
||||
# 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다).
|
||||
# 기본값은 config/app.php 의 update 절과 같다.
|
||||
# G7_UPDATE_EXCLUDES=node_modules,.git,bootstrap/cache,build/ext
|
||||
# G7_UPDATE_TARGETS=app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,scripts,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vite.config.devdashboard.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled
|
||||
# G7_UPDATE_PROTECTED_PATHS= (기본 목록은 config/app.php 의 update.protected_paths — 재정의할 때 그 목록을 그대로 옮겨 적고 항목을 덧붙인다)
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
|
||||
+21
-2
@@ -3,7 +3,17 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-alpha.18
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
|
||||
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
|
||||
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
|
||||
# TRUSTED_PROXIES=*
|
||||
|
||||
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
@@ -34,7 +44,7 @@ DB_WRITE_PASSWORD=
|
||||
DB_PREFIX=g7_
|
||||
|
||||
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
|
||||
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
|
||||
# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다.
|
||||
DB_READ_HOST=127.0.0.1
|
||||
DB_READ_PORT=3306
|
||||
DB_READ_DATABASE=g7_testing
|
||||
@@ -77,6 +87,11 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
|
||||
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
|
||||
# AWS_URL=
|
||||
# AWS_ENDPOINT=
|
||||
# ATTACHMENT_DISK=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -92,3 +107,7 @@ INSTALLER_COMPLETED=true
|
||||
# Telescope Configuration
|
||||
TELESCOPE_ENABLED=false
|
||||
TELESCOPE_DB_CONNECTION=mysql
|
||||
|
||||
# .env 키 단위 우선(G7_ENV_PRIORITY)은 테스트 환경에서 활성화하지 않습니다.
|
||||
# 이 파일에 활성 라인을 두면 테스트가 개발 머신의 .env 명시 상태에 좌우되어
|
||||
# 재현 불가능해집니다. 잠금 축은 config 를 직접 주입해 검증합니다.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: 🐛 버그 제보
|
||||
about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다.
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 증상 요약
|
||||
|
||||
<!-- 어떤 문제가 발생했는지 한두 문장으로 적어 주세요. -->
|
||||
|
||||
## 재현 절차
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 기대한 동작
|
||||
|
||||
<!-- 원래 어떻게 동작해야 하는지 적어 주세요. -->
|
||||
|
||||
## 실제 동작
|
||||
|
||||
<!-- 실제로 어떻게 동작했는지 적어 주세요. 오류 메시지가 있다면 그대로 붙여 주세요. -->
|
||||
|
||||
## 환경 정보 (필수)
|
||||
|
||||
> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요.
|
||||
|
||||
- **코어 버전**: <!-- 예: 7.0.2 / config/app.php 의 APP_VERSION 또는 관리자 > 정보 화면 -->
|
||||
- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: <!-- 예: 7.0.0-beta.7 → 7.0.2 -->
|
||||
- **PHP 버전**: <!-- 예: 8.2.x / php -v -->
|
||||
- **웹서버·실행 방식**: <!-- 예: Apache + mod_php / Nginx + PHP-FPM / 공유호스팅 -->
|
||||
- **DB**: <!-- 예: MySQL 8.0 / MariaDB 10.x -->
|
||||
- **OS**: <!-- 예: Ubuntu 24.04 / Windows 11 -->
|
||||
|
||||
## 활성 확장 목록 (해당 시)
|
||||
|
||||
<!--
|
||||
확장(모듈/플러그인/템플릿) 관련 문제이거나 업그레이드 문제라면 아래 명령 결과를 붙여 주세요.
|
||||
php artisan module:list
|
||||
php artisan plugin:list
|
||||
php artisan template:list
|
||||
-->
|
||||
|
||||
```text
|
||||
(여기에 붙여넣기)
|
||||
```
|
||||
|
||||
## 커스텀 자산 여부 (해당 시)
|
||||
|
||||
> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요.
|
||||
|
||||
- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다.
|
||||
- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다.
|
||||
- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다.
|
||||
|
||||
<!-- 위 항목에 해당하면 어떤 경로에 무엇을 추가했는지 적어 주세요. -->
|
||||
|
||||
## 스크린샷·로그
|
||||
|
||||
<!--
|
||||
- 브라우저 콘솔 에러 (F12 > Console)
|
||||
- storage/logs/ 관련 로그
|
||||
- 화면 스크린샷
|
||||
-->
|
||||
|
||||
## 추가 정보
|
||||
|
||||
<!-- 원인 추정, 임시 우회 방법 등 참고될 내용이 있으면 적어 주세요. -->
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 질문·사용법 문의 (sir.kr Q&A)
|
||||
url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7
|
||||
about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요.
|
||||
- name: 보안 취약점 신고
|
||||
url: https://github.com/gnuboard/g7/security/advisories/new
|
||||
about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: ✨ 기능 제안
|
||||
about: 새로운 기능이나 개선 아이디어를 제안합니다.
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 해결하려는 문제
|
||||
|
||||
<!-- 어떤 불편이나 한계 때문에 이 기능이 필요한지 적어 주세요. -->
|
||||
|
||||
## 제안하는 기능
|
||||
|
||||
<!-- 원하는 동작을 구체적으로 적어 주세요. -->
|
||||
|
||||
## 적용 범위 (해당 시)
|
||||
|
||||
- [ ] 코어
|
||||
- [ ] 모듈 <!-- 어떤 모듈인지: -->
|
||||
- [ ] 플러그인 <!-- 어떤 플러그인인지: -->
|
||||
- [ ] 템플릿 <!-- 어떤 템플릿인지: -->
|
||||
|
||||
## 대안·참고 사항
|
||||
|
||||
<!-- 고려한 다른 방법, 타 CMS 사례, 참고 링크 등이 있으면 적어 주세요. -->
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: ⬆️ 업그레이드 문제
|
||||
about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다.
|
||||
title: "[Upgrade] "
|
||||
labels: "bug, upgrade"
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 업그레이드 경로 (필수)
|
||||
|
||||
- **업그레이드 전 버전**: <!-- 예: 7.0.0-beta.7 -->
|
||||
- **업그레이드 후(목표) 버전**: <!-- 예: 7.0.2 -->
|
||||
- **실행한 명령**: <!-- 예: php artisan core:update --source=/tmp/g7-7.0.2 -->
|
||||
- **실행 계정/권한**: <!-- 예: www-data / sudo(root) / 일반 사용자 -->
|
||||
|
||||
> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다.
|
||||
> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다.
|
||||
> 업그레이드 전 반드시 전체 백업을 권장합니다.
|
||||
|
||||
## 증상
|
||||
|
||||
<!-- 업데이트 중/후 어떤 문제가 발생했는지 적어 주세요. 오류 메시지는 그대로 붙여 주세요. -->
|
||||
|
||||
## 확장·커스텀 자산 상태 (필수)
|
||||
|
||||
- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. <!-- 경로: -->
|
||||
- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다.
|
||||
- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다.
|
||||
|
||||
<!-- 업데이트 전후 확장 목록을 붙여 주시면 도움이 됩니다:
|
||||
php artisan module:list / plugin:list / template:list
|
||||
-->
|
||||
|
||||
```text
|
||||
(확장 목록 붙여넣기)
|
||||
```
|
||||
|
||||
## 환경 정보
|
||||
|
||||
- **PHP 버전**:
|
||||
- **웹서버·실행 방식**: <!-- Apache+mod_php / Nginx+PHP-FPM / 공유호스팅 등 -->
|
||||
- **DB**:
|
||||
- **OS**:
|
||||
|
||||
## 로그·스크린샷
|
||||
|
||||
<!--
|
||||
- 콘솔 전체 출력 (업데이트 실행 로그)
|
||||
- storage/logs/ 의 관련 로그
|
||||
- 브라우저 콘솔 에러
|
||||
-->
|
||||
+48
@@ -48,6 +48,11 @@ node_modules/
|
||||
|
||||
# Settings JSON files (환경별 설정)
|
||||
/storage/app/settings/
|
||||
# 성능 계측 리포트 (g7:bench --report) — 측정값이 실행 머신 사양에 종속되므로 저장소에 축적하지 않는다
|
||||
/storage/app/benchmarks/
|
||||
|
||||
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
|
||||
/storage/app/ext-bundles/
|
||||
|
||||
# Bootstrap cache (auto-generated)
|
||||
/bootstrap/cache/*
|
||||
@@ -75,9 +80,26 @@ templates/_bundled/*/node_modules/
|
||||
!plugins/_pending/
|
||||
!templates/_pending/
|
||||
|
||||
# ===== 언어팩 시스템 =====
|
||||
|
||||
# 활성 언어팩 디렉토리 (설치된 복사본 — Git 제외)
|
||||
lang-packs/*/
|
||||
!lang-packs/_bundled/
|
||||
|
||||
# _pending 디렉토리 (외부 다운로드 임시 영역)
|
||||
lang-packs/_bundled/_pending/
|
||||
!lang-packs/_pending/
|
||||
|
||||
# DevTools debug dump
|
||||
storage/debug-dump/
|
||||
|
||||
# MaxMind GeoLite2 DB (재배포 금지 — 각 환경에서 직접 다운로드)
|
||||
storage/app/geoip/
|
||||
|
||||
# 인스톨러 런타임 산출물 — 각 환경에서 자동 생성/소비
|
||||
storage/installer-state.json
|
||||
storage/installer/
|
||||
|
||||
# 개발 전용 파일
|
||||
.api-test/
|
||||
.serena/
|
||||
@@ -87,6 +109,7 @@ boost.json
|
||||
cypress/
|
||||
cypress.config.js
|
||||
.env.testing
|
||||
.claude/settings.local.json
|
||||
|
||||
# ===== 보안: 민감 파일 방어 패턴 =====
|
||||
*.pem
|
||||
@@ -103,3 +126,28 @@ id_ed25519
|
||||
*.sql
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# NHN KCP 결제플러그인 PG 공개키는 예외 처리
|
||||
!plugins/_bundled/*/bin/pub.key
|
||||
|
||||
.claude/tmp/
|
||||
|
||||
# ===== Playwright E2E =====
|
||||
tests/Playwright/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
|
||||
.playwright-mcp/
|
||||
|
||||
# ===== 소스맵 =====
|
||||
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
|
||||
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
|
||||
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
|
||||
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
|
||||
*.map
|
||||
|
||||
# ===== 부트스트랩 리소스 정적 게시 (bake, #122) =====
|
||||
# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는
|
||||
# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다.
|
||||
# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
|
||||
/public/build/ext/
|
||||
|
||||
+1187
-3
File diff suppressed because it is too large
Load Diff
+477
-7
@@ -8,7 +8,7 @@
|
||||
|
||||
| 항목 | 요구사항 |
|
||||
|------|---------|
|
||||
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
|
||||
| **PHP** | 8.2 이상 (필수 확장 16개 포함 — 기능별 선택 확장은 별도) |
|
||||
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
|
||||
| **Composer** | 2.x |
|
||||
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
|
||||
@@ -47,6 +47,14 @@ git clone https://github.com/gnuboard/g7.git
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
**Apache + mod_fcgid 환경 추가 설정** (PHP 8.5 NTS Windows 등 mod_php 미제공 빌드):
|
||||
|
||||
`fcgid.conf` 에 다음 1줄을 추가 후 Apache 재시작. 미설정 시 mod_fcgid 의 default 64KB 출력 버퍼가 인스톨러 SSE 스트림과 폴링 응답을 스크립트 종료 시점까지 보관하여 설치 진행 상황이 화면에 실시간 반영되지 않는다.
|
||||
|
||||
```apache
|
||||
FcgidOutputBufferSize 0
|
||||
```
|
||||
|
||||
**Nginx 예시**:
|
||||
|
||||
```nginx
|
||||
@@ -69,6 +77,49 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
#### 정적 파일 최적화 블록을 함께 쓰는 경우
|
||||
|
||||
아래처럼 확장자로 캐시 규칙을 거는 블록(aaPanel · CyberPanel · Plesk 기본 템플릿에
|
||||
포함되어 있습니다)을 함께 사용한다면 주의가 필요합니다.
|
||||
|
||||
```nginx
|
||||
location ~* \.(js|css|json|png|jpg|svg|woff2?)$ {
|
||||
expires max;
|
||||
access_log off;
|
||||
}
|
||||
```
|
||||
|
||||
nginx 는 **정규식 location 을 프리픽스 location(`location /`)보다 먼저** 매칭합니다.
|
||||
G7 은 일부 동적 엔드포인트에 `.js` · `.css` · `.json` 확장자를 쓰므로, 위 블록 안에
|
||||
PHP 핸들러가 없으면 그 요청들이 `try_files ... /index.php` 폴백에 닿지 못하고
|
||||
nginx 가 직접 파일을 찾다가 404 를 반환합니다. 증상은 **모든 페이지가 백지**입니다.
|
||||
|
||||
해결 방법은 두 가지이며, 아무것도 하지 않아도 G7 이 스스로 복구합니다.
|
||||
|
||||
1. **그대로 두기** — 브라우저가 자산 로드 실패를 감지하면 확장자 없는 주소로 자동
|
||||
전환합니다. 설치 마법사도 설치 시점에 이를 감지해 설정에 반영합니다.
|
||||
설치 후 확정하려면 `php artisan g7:asset-url-mode extensionless` 를 실행하거나
|
||||
관리자 > 환경설정 > 일반에서 "자산 파일 주소 방식" 을 변경하세요.
|
||||
(검색엔진 봇은 JavaScript 를 실행하지 않으므로, SEO 를 쓴다면 이 확정을 권장합니다.)
|
||||
|
||||
2. **`/api/` 를 정규식 블록보다 우선시키기** — `^~` 는 정규식 location 보다 우선하므로
|
||||
아래 블록을 추가하면 `/api/` 요청이 항상 PHP 로 갑니다. 확장자 기반 캐시 최적화를
|
||||
정적 파일에만 그대로 유지할 수 있습니다.
|
||||
|
||||
```nginx
|
||||
location ^~ /api/ {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
```
|
||||
|
||||
서버가 동적 응답을 가로채는지는 아래 두 요청을 비교해 확인할 수 있습니다.
|
||||
첫 번째만 실패하면 가로채는 것입니다.
|
||||
|
||||
```bash
|
||||
curl -i https://example.com/api/system/asset-probe.js
|
||||
curl -i https://example.com/api/system/asset-probe
|
||||
```
|
||||
|
||||
### 3단계: 설치 마법사 실행
|
||||
|
||||
브라우저에서 접속합니다.
|
||||
@@ -97,12 +148,16 @@ git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
```
|
||||
|
||||
### 3단계: Composer 의존성 설치
|
||||
### 3단계: Composer 의존성 설치 (선택)
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
> 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||
>
|
||||
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||
|
||||
### 4단계: 환경 설정 파일 생성
|
||||
|
||||
```bash
|
||||
@@ -149,15 +204,17 @@ https://github.com/gnuboard/g7
|
||||
|
||||
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
|
||||
|
||||
### 4단계: Composer 의존성 설치
|
||||
### 4단계: Composer 의존성 설치 (선택)
|
||||
|
||||
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다.
|
||||
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||
|
||||
```bash
|
||||
cd g7-버전명
|
||||
composer install
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||
|
||||
### 5단계: 환경 설정 파일 생성
|
||||
|
||||
```bash
|
||||
@@ -184,6 +241,171 @@ php artisan serve
|
||||
|
||||
---
|
||||
|
||||
## 방법 4: 공유 호스팅
|
||||
|
||||
Composer 실행이 불가능한 공유 호스팅 환경에서의 설치 방법입니다. SSH + PHP CLI 사용이 가능한 요금제를 전제로 하며, [Vendor 번들 시스템](docs/extension/vendor-bundle.md)을 통해 Composer 없이 의존성을 배치합니다.
|
||||
|
||||
### Cafe24
|
||||
|
||||
#### 검증 환경
|
||||
|
||||
본 가이드는 Cafe24 [**뉴아우토반 호스팅 절약형**](https://hosting.cafe24.com/?controller=new_product_page&page=newautobahn) 요금제에서 검증되었습니다. 동일 계열(일반형/비즈니스형 등) 요금제는 더 넉넉한 리소스를 제공하므로 그대로 적용 가능합니다.
|
||||
|
||||
| 항목 | 절약형 사양 |
|
||||
|------|-------------|
|
||||
| 월 요금 | 500원 (1년 약정 시 450원) |
|
||||
| 웹 용량 | 700MB (FullSSD) |
|
||||
| 트래픽 | 1.6GB/일 |
|
||||
| DB | MariaDB 10.x (InnoDB), 서버 공간 내 무제한 |
|
||||
| PHP | 8.4 / 8.2 / 7.4 (Rocky OS 기준) |
|
||||
| 접속 | FTP / SFTP / SSH 지원 |
|
||||
|
||||
> 웹 용량 700MB는 G7 코어 + 기본 확장 설치에 충분하지만, 업로드 파일이 많아질 경우 **일반형(1.4GB) 이상**을 권장합니다.
|
||||
|
||||
#### 사전 준비
|
||||
|
||||
- SSH 접속이 허용된 Cafe24 호스팅 계정 (절약형 이상)
|
||||
- SFTP 클라이언트 (FileZilla, WinSCP, Cyberduck 등)
|
||||
- Cafe24 관리자 페이지에서 PHP 버전을 **8.2 또는 8.4**로 설정
|
||||
- Cafe24 관리자 페이지에서 생성한 MariaDB DB (utf8mb4)
|
||||
|
||||
#### 1단계: GitHub Release에서 배포 패키지 다운로드
|
||||
|
||||
[https://github.com/gnuboard/g7/releases](https://github.com/gnuboard/g7/releases) 접속하여 최신 릴리스의 **Assets** 섹션에서 배포 패키지를 다운로드합니다.
|
||||
|
||||
#### 2단계: SFTP로 ZIP 파일 업로드
|
||||
|
||||
다운로드한 ZIP 파일을 SFTP로 홈 디렉토리 바로 아래에 업로드합니다.
|
||||
|
||||
```text
|
||||
~/ ← Cafe24 계정의 홈 디렉토리
|
||||
├── www ← 기존 심볼릭 링크 또는 디렉토리 (4단계에서 갱신)
|
||||
└── g7-release.zip ← 업로드한 ZIP 파일
|
||||
```
|
||||
|
||||
#### 3단계: SSH 접속 후 압축 해제 및 심볼릭 링크 갱신
|
||||
|
||||
SSH 접속 후 홈 디렉토리에서 ZIP 압축 해제와 웹 루트 심볼릭 링크 갱신을 함께 진행합니다.
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
|
||||
# 업로드한 ZIP 압축 해제 (파일명은 실제 다운로드한 이름으로 교체)
|
||||
unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.11 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
```
|
||||
|
||||
압축 해제 후 디렉토리 구조는 다음과 같아야 합니다.
|
||||
|
||||
```text
|
||||
~/
|
||||
├── www ← 아래에서 갱신할 심볼릭 링크
|
||||
└── g7/
|
||||
├── app/
|
||||
├── bootstrap/
|
||||
├── public/
|
||||
├── storage/
|
||||
├── vendor-bundle.zip
|
||||
├── .env.example
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cafe24의 웹 루트는 홈 디렉토리 아래 `www`(심볼릭 링크 또는 디렉토리)이며, 이를 `g7/public/`으로 연결해야 합니다.
|
||||
|
||||
```bash
|
||||
# 기존 www 상태 확인
|
||||
ls -la www
|
||||
|
||||
# 기존 www 삭제 후 재생성
|
||||
rm www
|
||||
ln -s g7/public www
|
||||
|
||||
# 결과 확인 (아래와 같은 형태여야 합니다)
|
||||
ls -la www
|
||||
# lrwxrwxrwx 1 user user 9 ... www -> g7/public/
|
||||
```
|
||||
|
||||
기존 `www` 안에 운영 중인 사이트가 있다면, 먼저 SFTP로 백업 다운로드하거나 `mv www www.bak`으로 이름 변경한 뒤 새 심볼릭 링크를 생성하세요.
|
||||
|
||||
> 본 가이드의 모든 쉘 명령은 `cd ~` 또는 `cd ~/g7` 컨텍스트 진입 후 `./` 상대경로로 실행하므로, Cafe24 계정의 홈 디렉토리 절대경로를 몰라도 됩니다.
|
||||
|
||||
#### 4단계: 웹 인스톨러 실행
|
||||
|
||||
브라우저에서 도메인으로 접속합니다.
|
||||
|
||||
```text
|
||||
http://도메인/install
|
||||
```
|
||||
|
||||
설치 마법사의 각 단계를 진행합니다.
|
||||
|
||||
| Step | 작업 |
|
||||
|------|------|
|
||||
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
|
||||
| 1. 라이선스 | 동의 |
|
||||
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 + 권장 항목(OPcache · Composer 의존성 구성) 안내 |
|
||||
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
|
||||
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
|
||||
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
|
||||
|
||||
`.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
||||
|
||||
**Step 2 — Composer 의존성 구성 (선택 항목):**
|
||||
|
||||
이미 `vendor/` 가 준비되어 있으면 설치 마법사는 그것을 그대로 씁니다. 이때 그 `vendor/` 가 개발용 패키지까지 포함한 설치(`composer install`, 옵션 없음)인지 확인해 알려 줍니다.
|
||||
|
||||
- **운영용 구성 (개발용 패키지 없음)**: 권장 상태입니다. 조치가 필요 없습니다.
|
||||
- **개발용 패키지 N개 포함**: 설치는 그대로 진행됩니다. 운영에 사용할 서버라면 설치를 마친 뒤 프로젝트 루트에서 `composer install --no-dev --optimize-autoloader` 를 한 번 실행하세요. 그대로 두면 이후 코어 업데이트가 `vendor/` 를 교체할 때 이전 패키지 목록 캐시와 어긋나 업데이트가 중단될 수 있습니다.
|
||||
- **vendor 없음**: 설치 마법사가 운영용 구성으로 자동 설치하므로 조치가 필요 없습니다.
|
||||
|
||||
이 항목은 설치를 차단하지 않습니다.
|
||||
|
||||
**Step 3 — Vendor 설치 방식 선택:**
|
||||
|
||||
- **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다.
|
||||
- **번들 Vendor 사용**: `vendor-bundle.zip`을 명시적으로 선택합니다 (Cafe24 권장).
|
||||
|
||||
#### Cafe24 환경 체크리스트
|
||||
|
||||
설치 중 문제가 발생하면 아래 항목을 확인합니다.
|
||||
|
||||
- [ ] `php -v` 출력이 8.2 이상인가? (Cafe24 관리자에서 PHP 버전 변경 가능)
|
||||
- [ ] `php -m` 출력에 `pdo_mysql`, `mbstring`, `openssl`, `zip`이 포함되어 있는가?
|
||||
- [ ] `~/www`가 `g7/public/`를 올바르게 가리키는가? (`ls -la ~/www` 확인)
|
||||
- [ ] `vendor-bundle.zip`이 `~/g7/` 디렉토리에 존재하는가?
|
||||
- [ ] 웹 인스톨러 Step 3에서 "Vendor 설치 방식"을 **번들 Vendor 사용** 또는 **자동**으로 선택했는가?
|
||||
- [ ] DB 접속 정보(호스트/포트/계정)가 Cafe24 관리자 정보와 일치하는가?
|
||||
|
||||
---
|
||||
|
||||
## 설치 트러블슈팅
|
||||
|
||||
### 2단계에서 "Unexpected end of JSON input" 오류가 표시되는 경우
|
||||
|
||||
**증상**: 설치 마법사 2단계(설치 환경 확인)에서 요구사항 카드가 표시되지 않고
|
||||
`요구사항 검증 실패: ... Unexpected end of JSON input` 이 표시됩니다.
|
||||
서버 로그에는 아무 오류도 남지 않습니다.
|
||||
|
||||
**원인**: Windows 사용자 계정 이름에 한글(또는 서버가 쓰는 문자 인코딩 밖의 문자)이
|
||||
포함되어 있으면, 설치 마법사가 계정 이름을 조회하는 과정에서 응답을 만들지 못합니다.
|
||||
|
||||
**해결**:
|
||||
|
||||
- **7.0.10 이상**: 수정되었습니다. 별도 조치가 필요하지 않습니다.
|
||||
- **7.0.9 이하**: 계정 이름이 영문인 Windows 계정으로 웹 서버를 실행하거나,
|
||||
코어를 7.0.10 이상으로 올린 뒤 설치를 진행하세요.
|
||||
|
||||
설치 과정에서 문자 인코딩 관련 조치가 있었다면 `storage/logs/installation.log` 에
|
||||
`[env] whoami output was not UTF-8 — normalized` 형태로 기록됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 설치 후 확인
|
||||
|
||||
설치가 완료되면 아래 페이지에 접근할 수 있습니다.
|
||||
@@ -195,6 +417,180 @@ php artisan serve
|
||||
|
||||
> 사용자 페이지는 사용자 템플릿이 설치되어 있어야 접근할 수 있습니다. 인스톨러에서 사용자 템플릿을 함께 설치하거나, 관리자 페이지에서 템플릿을 먼저 설치해 주세요.
|
||||
|
||||
### 파일 권한 (설치 후 확인)
|
||||
|
||||
POSIX 권한 모델 (Linux/macOS/BSD) 환경에서 웹 서버 실행 계정이 쓸 수 있어야 하는 위치는 다음과 같습니다. Windows 환경에서는 해당하지 않습니다.
|
||||
|
||||
| 위치 | 필요한 이유 |
|
||||
|------|------------|
|
||||
| `storage/` · `bootstrap/cache` | 애플리케이션 동작에 항상 필요 (로그·캐시·세션·업로드·런타임 캐시) |
|
||||
| `modules/` · `plugins/` · `templates/` · `public/build` | 관리자 화면에서 확장(모듈/플러그인/템플릿)을 설치·업데이트·삭제할 때 필요 |
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data storage bootstrap/cache modules plugins templates public/build
|
||||
```
|
||||
|
||||
**`vendor/` 에는 쓰기 권한이 필요하지 않습니다.** 확장의 `vendor/` 는 설치·업데이트 시점에만 기록되며, 애플리케이션이 동작하면서 만드는 런타임 캐시는 모두 `storage/` 아래에 기록됩니다. 명령줄로 설치·업데이트를 수행한다면 그 계정만 쓸 수 있으면 됩니다.
|
||||
|
||||
전체 권한 모델(그룹 공유 방식 A / 소유자 통일 방식 B / ACL 방식 C 와 umask 운영)은 [docs/requirements.md](docs/requirements.md) "파일 권한 및 umask 운영 방식" 절을 참고하세요.
|
||||
|
||||
### 명령줄·cron 실행 계정
|
||||
|
||||
`php artisan` 명령과 cron 은 **웹 서버 실행 계정으로** 실행합니다 (예: `sudo -u www-data php artisan …`, 또는 `crontab -u www-data -e` 로 등록). 대부분의 명령이 캐시·병합 번들·임시 파일을 만드는데, root 나 다른 계정으로 실행하면 그 산출물이 그 계정 소유로 남아 이후 웹 요청이 같은 자리에 쓰려는 순간 `Permission denied` 로 500 을 낼 수 있습니다. 이 문제는 특정 명령의 결함이 아니라 캐시를 건드리는 모든 명령에 공통이므로, 계정을 맞추는 것이 유일한 해법입니다.
|
||||
|
||||
예외는 코어 업데이트(`php artisan core:update`)입니다. 이 명령은 `.env` 등 웹 계정이 쓸 수 없는 파일까지 고치므로 `sudo` 를 권장하며, 실행이 끝날 때 자기가 만든 파일의 소유권을 되돌리는 절차가 내장되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 업그레이드
|
||||
|
||||
이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
|
||||
|
||||
### 개요
|
||||
|
||||
그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
|
||||
|
||||
```bash
|
||||
sudo php artisan core:update
|
||||
```
|
||||
|
||||
이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
|
||||
|
||||
- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
|
||||
- 자동 백업 생성 (실패 시 자동 롤백)
|
||||
- 유지보수 모드 자동 진입
|
||||
- 데이터베이스 마이그레이션 실행
|
||||
- 업그레이드 스텝 실행 (버전별 데이터 보정)
|
||||
- 번들 확장 자동 재동기화
|
||||
- 캐시 정리 및 유지보수 모드 해제
|
||||
|
||||
### 업그레이드 사전 준비
|
||||
|
||||
업그레이드 전 다음을 확인하세요.
|
||||
|
||||
1. **데이터베이스 백업** (필수)
|
||||
|
||||
```bash
|
||||
mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
|
||||
|
||||
```bash
|
||||
tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
|
||||
```
|
||||
|
||||
3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
|
||||
|
||||
### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
|
||||
|
||||
운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
|
||||
|
||||
1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
|
||||
2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
|
||||
3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
|
||||
4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
|
||||
|
||||
> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
|
||||
|
||||
### 방법 2: CLI 자동 업그레이드 (표준 절차)
|
||||
|
||||
대다수 환경에서 권장되는 절차입니다.
|
||||
|
||||
```bash
|
||||
sudo php artisan core:update
|
||||
```
|
||||
|
||||
#### `sudo` 가 권장되는 이유
|
||||
|
||||
업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
|
||||
|
||||
- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
|
||||
- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
|
||||
- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
|
||||
- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
|
||||
|
||||
`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
|
||||
|
||||
#### `sudo` 없이 실행해도 되는 환경
|
||||
|
||||
- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
|
||||
- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
|
||||
|
||||
> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
|
||||
|
||||
### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
|
||||
|
||||
PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
|
||||
|
||||
```bash
|
||||
# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
|
||||
wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
|
||||
unzip <파일명>.zip -d /tmp/g7-new
|
||||
|
||||
# 2. 압축 해제된 경로를 지정하여 업데이트 실행
|
||||
sudo php artisan core:update --source=/tmp/g7-new
|
||||
```
|
||||
|
||||
`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
|
||||
|
||||
### 사용자군별 단계적 업그레이드
|
||||
|
||||
여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
|
||||
|
||||
| 현재 버전 | 권장 경로 |
|
||||
|----------|----------|
|
||||
| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
|
||||
| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
|
||||
| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
|
||||
| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
|
||||
|
||||
특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
|
||||
|
||||
> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
|
||||
|
||||
### 권한/캐시 트러블슈팅
|
||||
|
||||
업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
|
||||
|
||||
#### `.env` 읽기 실패 (`Permission denied`)
|
||||
|
||||
```bash
|
||||
ls -l .env
|
||||
# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
|
||||
```
|
||||
|
||||
대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
|
||||
|
||||
#### `bootstrap/cache` 쓰기 실패
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data bootstrap/cache storage
|
||||
```
|
||||
|
||||
소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
|
||||
|
||||
#### 업그레이드 도중 자동 롤백 후 부팅 실패
|
||||
|
||||
beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
|
||||
|
||||
```bash
|
||||
# 진단 모드 (실제 삭제 없음)
|
||||
sudo php artisan hotfix:rollback-stale-files
|
||||
|
||||
# 운영자 확인 후 실제 정리
|
||||
sudo php artisan hotfix:rollback-stale-files --prune
|
||||
```
|
||||
|
||||
### 업그레이드 후 확인
|
||||
|
||||
업그레이드가 끝나면 다음을 점검합니다.
|
||||
|
||||
1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
|
||||
2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
|
||||
3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
|
||||
4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 프로덕션 환경 추가 설정
|
||||
@@ -205,6 +601,78 @@ php artisan serve
|
||||
|
||||
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
|
||||
|
||||
`APP_URL` 은 명령줄 실행·큐 워커·메일 발송처럼 **요청이 없는 맥락**에서 절대 URL 을 만들 때의
|
||||
기준입니다. 웹 요청에서 만들어지는 절대 URL(화면 자산, 콜백 주소 등)은 `APP_URL` 이 아니라
|
||||
**요청 자체의 스킴과 호스트**를 따릅니다.
|
||||
|
||||
따라서 TLS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성(AWS ALB, CloudFront,
|
||||
Cloudflare, nginx 리버스 프록시, ngrok)에서는 `APP_URL` 만 `https://` 로 두어서는 부족합니다.
|
||||
사이트가 접속 주소와 방문자 IP 를 올바르게 인식하려면 `.env` 에 `TRUSTED_PROXIES` 를 함께
|
||||
지정해야 합니다.
|
||||
|
||||
```dotenv
|
||||
# 프록시 뒤에서 구동하는 경우에만 지정합니다 (미설정이 기본값).
|
||||
TRUSTED_PROXIES=*
|
||||
```
|
||||
|
||||
지정하지 않으면 화면이 표시되지 않거나(혼합 콘텐츠 차단), 결제 통보가 수신되지 않고, 모든
|
||||
방문자가 같은 IP 로 기록됩니다. 값 선택 기준과 도입 시 후속 조치는
|
||||
[docs/backend/reverse-proxy.md](docs/backend/reverse-proxy.md) 를 참고하세요.
|
||||
|
||||
### `.env` 를 운영 기준값으로 삼기 (선택)
|
||||
|
||||
기본 설계에서 **관리자 환경설정 화면이 운영 기준**입니다. 설치가 끝나면 메일·드라이버·디버그
|
||||
같은 항목은 화면에서 관리하며, 그 값이 `.env` 의 같은 항목보다 우선합니다. 설치 마법사가
|
||||
초기값을 저장하므로 **설치 직후부터** 그 우선순위가 적용됩니다.
|
||||
|
||||
컨테이너·구성 관리 도구·다중 서버처럼 `.env` 를 배포 기준값으로 관리하는 환경에서는 이
|
||||
우선순위를 뒤집을 수 있습니다. `.env` 에 다음 한 줄을 추가하세요.
|
||||
|
||||
```dotenv
|
||||
G7_ENV_PRIORITY=true
|
||||
```
|
||||
|
||||
켜면 **`.env` 에 값이 적혀 있는 항목만** 화면 저장값의 영향을 받지 않습니다. 그 항목들은
|
||||
관리자 화면에서 편집 불가로 표시되고, 화면에는 실제로 적용 중인 값이 나타납니다. 값이 비어
|
||||
있거나 줄이 없는 항목은 종전대로 화면에서 관리합니다.
|
||||
|
||||
주의할 점:
|
||||
|
||||
- `.env.example` 은 `APP_NAME`, `LOG_LEVEL`, `G7_UPDATE_GITHUB_URL` 등에 값을 채워 배포합니다.
|
||||
그 파일을 복사해 쓰고 있다면 스위치를 켜는 즉시 **그 항목들도 함께 잠깁니다.** 화면에서
|
||||
관리하고 싶은 항목은 값을 비우거나(`KEY=`) 줄을 지우세요.
|
||||
- `.env` 에 `BROADCAST_CONNECTION` 이나 `REVERB_*` 를 적으면 웹소켓 사용 여부까지 `.env` 가
|
||||
결정합니다. 관리자 화면의 웹소켓 토글은 그 설치에서 동작하지 않습니다.
|
||||
- `.env` 만 고친 경우에는 `php artisan config:cache` 를 다시 실행해야 반영됩니다. 관리자
|
||||
화면에서 저장하는 경우에는 자동으로 처리됩니다.
|
||||
- 비밀번호·시크릿·토큰 같은 값은 잠금 표시만 되고 화면에 나타나지 않습니다.
|
||||
|
||||
스위치를 넣지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
|
||||
|
||||
### `.env` 권한 강화 (선택)
|
||||
|
||||
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
|
||||
|
||||
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
|
||||
|
||||
- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
|
||||
|
||||
```bash
|
||||
sudo chown www-data:www-data .env
|
||||
sudo chmod 600 .env
|
||||
```
|
||||
|
||||
- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
|
||||
|
||||
```bash
|
||||
sudo chgrp www-data .env
|
||||
sudo chmod 640 .env
|
||||
```
|
||||
|
||||
웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
|
||||
|
||||
인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
|
||||
|
||||
### 데몬 프로세스
|
||||
|
||||
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
|
||||
@@ -219,7 +687,9 @@ php artisan serve
|
||||
cron에 아래 항목을 등록합니다.
|
||||
|
||||
```bash
|
||||
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
|
||||
* * * * * cd /path/to/g7 && sudo -u www-data php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
웹 서버 계정(`www-data` · `nginx` · `apache` 등)으로 실행해야 합니다 — root 의 crontab 에 그대로 등록하면 매분 캐시 파일이 root 소유로 만들어져 웹 요청이 실패할 수 있습니다. `crontab -u www-data -e` 로 그 계정의 crontab 에 등록하면 `sudo -u` 없이 같은 줄을 씁니다. 「명령줄·cron 실행 계정」 절을 참고하세요.
|
||||
|
||||
> 상세 내용은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
|
||||
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
<p align="center"><a href="README.md">English</a> | 한국어</p>
|
||||
|
||||
# 그누보드7
|
||||
|
||||
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 그누보드7 소개
|
||||
|
||||
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
|
||||
|
||||
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
|
||||
|
||||
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
|
||||
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
|
||||
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
|
||||
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
|
||||
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
|
||||
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
|
||||
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
|
||||
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
|
||||
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
|
||||
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
|
||||
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
|
||||
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
|
||||
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
|
||||
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
|
||||
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
|
||||
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
|
||||
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
|
||||
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
|
||||
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|------|------|
|
||||
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
|
||||
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
|
||||
| **인증** | Laravel Sanctum (Bearer 토큰) |
|
||||
| **테스트** | PHPUnit 11.x, Vitest |
|
||||
| **코드 품질** | Laravel Pint (PSR-12) |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
Gnuboard7
|
||||
├── Core (Laravel 12)
|
||||
│ ├── Controller → FormRequest → Service → Repository → Model
|
||||
│ ├── Hook System (Action / Filter)
|
||||
│ ├── Permission (Role → Permission → Scope)
|
||||
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
|
||||
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
|
||||
│ ├── Notification (Definition × Template × Recipients)
|
||||
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
|
||||
│
|
||||
├── Extensions
|
||||
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
|
||||
│ ├── Plugins — 결제, 인증, 마케팅 ...
|
||||
│ ├── Templates — 관리자 UI, 사용자 UI
|
||||
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
|
||||
│
|
||||
└── Template Engine
|
||||
├── JSON Layout → React Components
|
||||
└── Dynamic Rendering + Data Binding
|
||||
```
|
||||
|
||||
### 템플릿 엔진 동작 흐름
|
||||
|
||||
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
|
||||
|
||||
#### 제공 기능
|
||||
|
||||
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
|
||||
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
|
||||
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Backend ["🔧 Backend — Laravel"]
|
||||
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
|
||||
B --> |"상속 해석<br/>extends / partial"| B
|
||||
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
|
||||
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
|
||||
end
|
||||
|
||||
subgraph Frontend ["⚛️ Frontend — React"]
|
||||
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
|
||||
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
|
||||
F & G --> H["🎨 DynamicRenderer"]
|
||||
|
||||
H --> I{"❓ 조건 평가<br/>if 표현식"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
|
||||
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
|
||||
|
||||
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
|
||||
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React 렌더링"]
|
||||
end
|
||||
|
||||
subgraph Actions ["👆 사용자 인터랙션"]
|
||||
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — 페이지 이동"]
|
||||
Q --> S["📡 apiCall — API 호출"]
|
||||
Q --> T["🔄 setState — 상태 변경"]
|
||||
Q --> U["📋 openModal — 모달 열기"]
|
||||
S --> |"onSuccess · onError"| Q
|
||||
T --> |"상태 변경 → 리렌더링"| H
|
||||
end
|
||||
|
||||
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||
style Frontend fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#064e3b
|
||||
style Actions fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#831843
|
||||
|
||||
style A fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style B fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style M fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style C fill:#dc2626,stroke:#b91c1c,color:#fff
|
||||
style D fill:#059669,stroke:#047857,color:#fff
|
||||
|
||||
style E fill:#059669,stroke:#047857,color:#fff
|
||||
style F fill:#0891b2,stroke:#0e7490,color:#fff
|
||||
style G fill:#0891b2,stroke:#0e7490,color:#fff
|
||||
style H fill:#d97706,stroke:#b45309,color:#fff
|
||||
style I fill:#d97706,stroke:#b45309,color:#fff
|
||||
style J fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style K fill:#6b7280,stroke:#4b5563,color:#fff
|
||||
style L fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style N fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style O fill:#059669,stroke:#047857,color:#fff
|
||||
|
||||
style Q fill:#e11d48,stroke:#be123c,color:#fff
|
||||
style R fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style S fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style T fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style U fill:#be185d,stroke:#9d174d,color:#fff
|
||||
|
||||
linkStyle default stroke:#374151,stroke-width:2px
|
||||
```
|
||||
|
||||
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"data_sources": [
|
||||
{ "id": "products", "endpoint": "/api/products", "method": "GET" }
|
||||
],
|
||||
"layout": {
|
||||
"type": "basic", "name": "Div",
|
||||
"children": [
|
||||
{ "type": "basic", "name": "H1", "text": "$t:product_list" },
|
||||
{
|
||||
"type": "basic", "name": "Div",
|
||||
"iteration": { "source": "{{products?.data?.data}}", "item_var": "$item" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "{{$item.name}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic", "name": "Button", "text": "$t:add",
|
||||
"if": "{{products?.data?.abilities?.can_create}}",
|
||||
"actions": [{
|
||||
"event": "onClick",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/products/create" }
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
|
||||
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
|
||||
|
||||
### 핵심 시스템
|
||||
|
||||
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
|
||||
|
||||
#### 1. 확장 시스템 — 3원칙
|
||||
|
||||
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
|
||||
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
|
||||
|
||||
#### 2. 훅 시스템 (Action / Filter)
|
||||
|
||||
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
|
||||
|
||||
```php
|
||||
// Service 계층에서 훅 발행
|
||||
HookManager::doAction('core.user.after_create', $user, $data);
|
||||
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
|
||||
|
||||
// 모듈 Listener 가 훅 구독 (자동 발견)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
|
||||
|
||||
#### 3. 공통 캐시 시스템
|
||||
|
||||
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
|
||||
|
||||
| 드라이버 | 접두사 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
|
||||
|
||||
```php
|
||||
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
|
||||
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $repository,
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
|
||||
) {}
|
||||
```
|
||||
|
||||
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
|
||||
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
|
||||
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
|
||||
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
|
||||
|
||||
#### 4. 알림 시스템
|
||||
|
||||
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
|
||||
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
|
||||
│ variables=[...] │ │ subject, body, │ │ - role │
|
||||
│ │ │ click_url │ │ - specific_users │
|
||||
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
|
||||
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
|
||||
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
|
||||
|
||||
```php
|
||||
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
||||
|
||||
// ↓ NotificationHookListener → NotificationDispatcher:
|
||||
// 1. order.confirmed 정의 조회
|
||||
// 2. 활성 템플릿 순회 (mail/database)
|
||||
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
|
||||
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
|
||||
// 5. notification_logs 에 발송 이력 기록
|
||||
```
|
||||
|
||||
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
|
||||
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
|
||||
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
|
||||
|
||||
#### 5. 언어팩 시스템
|
||||
|
||||
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
|
||||
|
||||
| 영역 | 동작 |
|
||||
| --- | --- |
|
||||
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
|
||||
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
|
||||
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
|
||||
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
|
||||
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
|
||||
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
|
||||
|
||||
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
|
||||
|
||||
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
|
||||
|
||||
#### 6. 본인인증 (Identity Verification)
|
||||
|
||||
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
|
||||
|
||||
```text
|
||||
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Policy │ │ Purpose │ │ Provider │
|
||||
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
|
||||
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
|
||||
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
|
||||
│
|
||||
GenericNotification
|
||||
```
|
||||
|
||||
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
|
||||
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
|
||||
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
|
||||
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
|
||||
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
|
||||
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
|
||||
|
||||
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 시스템 요구사항
|
||||
|
||||
- PHP 8.2+ (필수 확장 16개 포함 — `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, `zip` 등. `gd`/`imagick`, `intl`, `redis`, `bcmath` 등은 해당 기능을 쓸 때만 필요하며 전체 목록은 [docs/requirements.md](docs/requirements.md) 참조)
|
||||
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
|
||||
- Composer 2.x
|
||||
- Node.js 20+ (프론트엔드 에셋을 직접 빌드할 때만 필요)
|
||||
- 웹 서버(Apache 또는 Nginx) — 문서 루트를 `public/` 으로 지정
|
||||
- Redis 6.0+ (선택 — 프로덕션의 캐시·큐에 권장)
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
# 1. 프로젝트 클론
|
||||
git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
|
||||
# 2. 환경 설정 파일 복사
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
||||
```
|
||||
|
||||
> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
|
||||
|
||||
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 기본 제공 확장
|
||||
|
||||
### 모듈
|
||||
|
||||
| 모듈 | 설명 |
|
||||
|------|------|
|
||||
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
|
||||
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
|
||||
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
|
||||
|
||||
### 플러그인
|
||||
|
||||
| 플러그인 | 설명 |
|
||||
|---------|------|
|
||||
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
|
||||
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
|
||||
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
|
||||
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
|
||||
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
|
||||
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
|
||||
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
|
||||
|
||||
### 템플릿
|
||||
|
||||
| 템플릿 | 설명 |
|
||||
|--------|------|
|
||||
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
|
||||
| **sirsoft-basic** | 사용자 기본 템플릿 |
|
||||
|
||||
### 번들 언어팩
|
||||
|
||||
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
|
||||
|
||||
| 식별자 | 설명 |
|
||||
| ------ | ---- |
|
||||
| **g7-core-ja** | 코어 일본어 |
|
||||
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
|
||||
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
|
||||
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
|
||||
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | 비즈뿌리오 메시지 발송 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
|
||||
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
|
||||
|
||||
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
|
||||
|
||||
### 학습용 샘플 확장
|
||||
|
||||
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
|
||||
|
||||
| 식별자 | 종류 | 설명 |
|
||||
| ------ | ---- | ---- |
|
||||
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
|
||||
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
|
||||
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
|
||||
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
|
||||
|
||||
---
|
||||
|
||||
## 비즈니스 모델
|
||||
|
||||
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
|
||||
|
||||
| 모델 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
|
||||
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
|
||||
|
||||
---
|
||||
|
||||
## 기존 그누보드 사용자
|
||||
|
||||
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 링크 |
|
||||
|------|------|
|
||||
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
|
||||
| 전체 문서 | [docs/README.md](docs/README.md) |
|
||||
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
|
||||
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
|
||||
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
|
||||
|
||||
---
|
||||
|
||||
## 기여하기
|
||||
|
||||
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
|
||||
|
||||
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- 코드 스타일: Laravel Pint (PSR-12)
|
||||
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
|
||||
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
|
||||
|
||||
---
|
||||
|
||||
## 만든 사람들
|
||||
|
||||
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
|
||||
|
||||
### Core Team
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>
|
||||
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
|
||||
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
|
||||
</p>
|
||||
|
||||
### 커뮤니티 기여자
|
||||
|
||||
제보와 건의로 그누보드7을 함께 만들어 주신 분들입니다. 아래 목록은 변경 기록(CHANGELOG)에 남은 제보자 표기에서 모았습니다.
|
||||
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
|
||||
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
<!-- community-contributors:end -->
|
||||
|
||||
코드 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
| 채널 | 링크 |
|
||||
|------|------|
|
||||
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
|
||||
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
|
||||
| 문의 | minsup@sir.kr |
|
||||
|
||||
---
|
||||
|
||||
## 변경 기록
|
||||
|
||||
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 보안 취약점
|
||||
|
||||
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
|
||||
|
||||
Copyright (c) 2026 SIRSOFT
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made by <a href="https://sir.kr">SIRSOFT</a>
|
||||
</p>
|
||||
@@ -1,74 +1,85 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
|
||||
</p>
|
||||
<p align="center">English | <a href="README.ko.md">한국어</a></p>
|
||||
|
||||
# Gnuboard7
|
||||
|
||||
**A modern, extensible CMS platform built with Laravel + React**
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
|
||||
<p align="center">
|
||||
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.1-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/status-Open%20Beta-orange" alt="Status"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://g7.gnuboard.com"><strong>Live Demo</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>Admin Demo</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>The demo UI language follows your browser (Korean/English); you can switch it in the UI.</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
|
||||
[About](#about-gnuboard7) · [Key Features](#key-features) · [Tech Stack](#tech-stack) · [Architecture](#architecture) · [Quick Start](#quick-start) · [Bundled Extensions](#bundled-extensions) · [Business Models](#business-models) · [Migrating from Gnuboard 5](#migrating-from-gnuboard-5) · [Documentation](#documentation) · [Contributing](#contributing) · [Team](#team) · [Community](#community) · [Changelog](#changelog) · [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 그누보드7 소개
|
||||
## About Gnuboard7
|
||||
|
||||
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
|
||||
**Gnuboard7** is a complete, ground-up redesign of Gnuboard — Korea's most widely used open-source CMS for 23 years — rebuilt on a modern stack.
|
||||
|
||||
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
|
||||
Everything from security to architecture was rewritten from scratch on Laravel and React.
|
||||
|
||||
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
|
||||
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
|
||||
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
|
||||
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
|
||||
- **JSON layout engine**: define React-based UI declaratively with JSON alone, no React knowledge required. Modules and plugins inject or extend UI dynamically through JSON without a frontend build. When you need something more advanced, you can develop and register custom React components
|
||||
- **One platform, many business models**: community, storefront, subscription, booking — extend it to fit your business
|
||||
- **Fine-grained access control**: Role + Permission + Scope, a three-tier model that keeps control as your service grows
|
||||
- **Global-ready**: native i18n support, locale-driven UI, multi-currency handling
|
||||
- **Extension system**: modules + plugins + templates, a three-layer structure that adds functionality without touching the core
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
## Key Features
|
||||
|
||||
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
|
||||
Everything a modern web platform needs, built in.
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
|
||||
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
|
||||
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
|
||||
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
|
||||
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection)를 구현합니다 |
|
||||
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
|
||||
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
|
||||
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
|
||||
| **캐싱** | 화면 구조와 API 응답, 권한 정보를 다층으로 캐싱합니다. 불필요한 재처리 없이 빠른 응답 속도를 유지합니다 |
|
||||
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
|
||||
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
|
||||
| Area | Description |
|
||||
|------|-------------|
|
||||
| **Modular architecture** | Modules + plugins + templates, a three-layer extension structure. Independent modules (boards, commerce, and more) can be developed without modifying the core. Hook-based injection preserves the clear layering of the Service-Repository pattern |
|
||||
| **Language pack system** | Install a new language from a ZIP file or GitHub URL without touching the core. Official bundled language packs (Japanese and others) are ready to use immediately, and labels an operator has edited are preserved per sub-key so a language pack never overwrites them. Packs apply independently to modules, plugins, and templates |
|
||||
| **Localization** | A consistent multilingual development experience from backend to frontend. Active language packs automatically enrich notification channel labels, provider/registry payloads, and settings catalogs (payment methods, currencies, shippable countries). Activity log and message surfaces are separated so modules and plugins describe their own domain labels in their own territory |
|
||||
| **Payment gateways** | A foundation for growing beyond a local business into global commerce. Payment integrations attach through the same extension-point pattern, and international gateways ship as separate plugins |
|
||||
| **Access control** | Control menus, features, and even data scope per role. Role + Permission + Scope three-tier access control provides flexible management that mirrors your organization |
|
||||
| **Identity verification (IDV)** | Every verification point — signup, password reset, sensitive operations — is managed centrally through declarative route/hook-level policies. The core ships a mail provider built in, and external providers (Korean identity-verification services such as KG Inicis and NHN KCP, as well as SMS, PortOne, and Stripe Identity-style services) attach through the same provider contract. When the server returns HTTP 428, the frontend interceptor opens a verification modal automatically and replays the original request on success |
|
||||
| **Security** | Automatic input validation and token-based authentication. Layered defense designed in from the start (CSRF/XSS/SQL injection), a real implementation of login throttling and account lockout (HTTP 423), and automatic blocking of installer endpoints once setup completes (HTTP 410) |
|
||||
| **Flexible screen composition** | Define a screen structure and see it applied immediately. Web-app-grade dynamic screens are achievable with JSON declarations alone, no frontend infrastructure required |
|
||||
| **Layout editor** | A WYSIWYG layout editor lets you place screen blocks directly and see the result right away |
|
||||
| **Proven foundation** | Built on Laravel + React — a stack adopted by companies worldwide, offering high extensibility and flexible UI implementation |
|
||||
| **Shared cache system** | `CacheInterface` plus three drivers (core/module/plugin) isolate key prefixes automatically (`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`). Tag-based automatic invalidation and central TTL management via `g7_core_settings('cache.*_ttl')` keep operations free of hardcoded values |
|
||||
| **Notification system** | A three-tier model — Definition × Template × Recipients — supports independent multi-channel delivery over mail, database, and real-time broadcast (Reverb). Targeting by author, role, specific users, or permission holders, plus hook-based dispatch, lets modules register their own notifications freely |
|
||||
| **SEO** | Powered by `jaybizzle/crawler-detect`, roughly 1,000 bot types (search engines, social unfurlers, AI search) are detected automatically: bots receive static HTML while regular users get the SPA. OG/Twitter card metadata, domain schemas declared by modules (Article/Product/Offer/AggregateRating), automatic and manual sitemap generation, and generator meta tags are all provided by the core |
|
||||
| **Activity log** | Administrator and user activity is recorded and searchable automatically. The Monolog-based structure is easy to extend, and action labels resolve from a module's or plugin's own translation files first, so each domain describes itself |
|
||||
| **Search** | Full-text search powered by Laravel Scout, covering key content such as products and posts |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
## Tech Stack
|
||||
|
||||
| 구분 | 기술 |
|
||||
|------|------|
|
||||
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
|
||||
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
|
||||
| **인증** | Laravel Sanctum (Bearer 토큰) |
|
||||
| **테스트** | PHPUnit 11.x, Vitest |
|
||||
| **코드 품질** | Laravel Pint (PSR-12) |
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
|
||||
| **Frontend** | React 19, Vite, Tailwind CSS 4 (dark mode supported) |
|
||||
| **Authentication** | Laravel Sanctum (Bearer tokens) |
|
||||
| **Testing** | PHPUnit 11.x, Vitest |
|
||||
| **Code quality** | Laravel Pint (PSR-12) |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gnuboard7
|
||||
@@ -76,66 +87,67 @@ Gnuboard7
|
||||
│ ├── Controller → FormRequest → Service → Repository → Model
|
||||
│ ├── Hook System (Action / Filter)
|
||||
│ ├── Permission (Role → Permission → Scope)
|
||||
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
|
||||
│ ├── Language Pack (virtual protected rows + ZIP/GitHub install + sub-key preservation)
|
||||
│ ├── Notification (Definition × Template × Recipients)
|
||||
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
|
||||
│
|
||||
├── Extensions
|
||||
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
|
||||
│ ├── Plugins — 결제, 인증, 마케팅 ...
|
||||
│ └── Templates — 관리자 UI, 사용자 UI
|
||||
│ ├── Modules — board, commerce, page ...
|
||||
│ ├── Plugins — payment, verification, marketing ...
|
||||
│ ├── Templates — admin UI, user UI
|
||||
│ └── LanguagePacks — official and third-party packs (Japanese and more)
|
||||
│
|
||||
└── Template Engine
|
||||
├── JSON Layout → React Components
|
||||
└── Dynamic Rendering + Data Binding
|
||||
```
|
||||
|
||||
### 템플릿 엔진 동작 흐름
|
||||
### How the template engine works
|
||||
|
||||
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
|
||||
In Gnuboard7 you **declare the UI structure in JSON**, and the engine interprets it and renders React components.
|
||||
|
||||
#### 현재 지원
|
||||
#### What it gives you
|
||||
|
||||
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
|
||||
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
|
||||
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
|
||||
#### 지원 예정
|
||||
|
||||
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용하여, **드래그 앤 드롭 방식의 비주얼 에디터**를 통해 비개발자도 화면을 직접 구성할 수 있도록 지원할 계획입니다
|
||||
- Build React-based UI from JSON declarations alone — screen development without React expertise
|
||||
- Modules and plugins inject or extend UI dynamically through JSON, with no frontend build
|
||||
- Develop and register custom React components when a screen needs something more advanced
|
||||
- Because the UI is defined as data (JSON) rather than code, a **WYSIWYG layout editor** lets non-developers place and edit screen blocks and see the result immediately
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Backend ["🔧 Backend — Laravel"]
|
||||
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
|
||||
B --> |"상속 해석<br/>extends / partial"| B
|
||||
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
|
||||
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
|
||||
A["📄 JSON layout file"] --> B["⚙️ LayoutService"]
|
||||
B --> |"inheritance<br/>extends / partial"| B
|
||||
M["📦 Module layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
|
||||
P["🔌 Plugin layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
|
||||
B --> C["🔒 Permission filtering<br/>drop components per user"]
|
||||
C --> D["📨 Merged JSON response<br/>cached · 1 hour TTL"]
|
||||
end
|
||||
|
||||
subgraph Frontend ["⚛️ Frontend — React"]
|
||||
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
|
||||
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
|
||||
D --> E["📥 LayoutLoader<br/>receive layout JSON"]
|
||||
E --> F["💾 State init<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 Data source loading<br/>parallel API calls"]
|
||||
F & G --> H["🎨 DynamicRenderer"]
|
||||
|
||||
H --> I{"❓ 조건 평가<br/>if 표현식"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
|
||||
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
|
||||
H --> I{"❓ Condition eval<br/>if expression"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React component"]
|
||||
I --> |"❌ false"| K["⏭️ Skip rendering"]
|
||||
|
||||
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
|
||||
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React 렌더링"]
|
||||
J --> L["🔗 Data binding<br/>expression → value"]
|
||||
L --> N["🖱️ Event binding<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React render"]
|
||||
end
|
||||
|
||||
subgraph Actions ["👆 사용자 인터랙션"]
|
||||
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — 페이지 이동"]
|
||||
Q --> S["📡 apiCall — API 호출"]
|
||||
Q --> T["🔄 setState — 상태 변경"]
|
||||
Q --> U["📋 openModal — 모달 열기"]
|
||||
subgraph Actions ["👆 User interaction"]
|
||||
O --> |"click · input"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — page transition"]
|
||||
Q --> S["📡 apiCall — API request"]
|
||||
Q --> T["🔄 setState — state change"]
|
||||
Q --> U["📋 openModal — open a modal"]
|
||||
S --> |"onSuccess · onError"| Q
|
||||
T --> |"상태 변경 → 리렌더링"| H
|
||||
T --> |"state change → re-render"| H
|
||||
end
|
||||
|
||||
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||
@@ -169,7 +181,7 @@ flowchart TB
|
||||
linkStyle default stroke:#374151,stroke-width:2px
|
||||
```
|
||||
|
||||
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
|
||||
**JSON layout example** — the JSON below renders as a real React UI:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -201,160 +213,374 @@ flowchart TB
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
|
||||
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
|
||||
Activating a module or plugin injects its UI and components automatically.
|
||||
Developers add or change UI with JSON alone — no separate frontend build — and UI elements are shown or hidden automatically according to permissions (abilities).
|
||||
|
||||
**확장 시스템 3원칙**
|
||||
### Core systems
|
||||
|
||||
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
|
||||
2. **동적 로딩** — composer.json 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
|
||||
Four systems work together to hold the platform up.
|
||||
|
||||
#### 1. Extension system — three principles
|
||||
|
||||
1. **Minimal core modification** — all business logic lives in modules and plugins
|
||||
2. **Dynamic loading** — discovered automatically by directory scan, with no `composer.json` hardcoding
|
||||
3. **Hook-based extension** — functionality is injected at the service layer through action and filter hooks
|
||||
|
||||
#### 2. Hook system (Action / Filter)
|
||||
|
||||
A lightweight hook system that operates separately from Laravel events. Actions handle side effects (logging, notifications); filters transform values (injecting defaults, extending permissions).
|
||||
|
||||
```php
|
||||
// Publish hooks from the service layer
|
||||
HookManager::doAction('core.user.after_create', $user, $data);
|
||||
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
|
||||
|
||||
// A module listener subscribes to the hook (auto-discovered)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Modules and plugins only need to place a class in their `Listeners/` directory and `HookListenerRegistrar` subscribes it automatically. Asynchronous execution through queue serialization is supported as well, and context such as `Auth::user()`, `request()->ip()`, and `App::getLocale()` is restored automatically inside the worker.
|
||||
|
||||
#### 3. Shared cache system
|
||||
|
||||
Built on `CacheInterface`, the **core, modules, and plugins** each manage their own cache without key collisions.
|
||||
|
||||
| Driver | Prefix | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | Core services (layouts, SEO, notifications, settings) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | Per-module isolated cache (board and product lists, cooldowns) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | Per-plugin isolated cache |
|
||||
|
||||
```php
|
||||
// Register a module service in the BaseModuleServiceProvider::$cacheServices array
|
||||
// and it is injected from the constructor type hint alone (same as the storage pattern)
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $repository,
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: prefix applied automatically
|
||||
) {}
|
||||
```
|
||||
|
||||
- **Central TTL management** — every cache TTL follows `g7_core_settings('cache.*_ttl')`. No hardcoding
|
||||
- **Automatic invalidation** — apply the `CacheInvalidatable` trait to a model and related caches are dropped by tag on `saved` / `deleted`
|
||||
- **Lifecycle integration** — when a module is deactivated or removed, `ModuleManager` flushes that module's isolated cache in bulk
|
||||
- **Frontend cache busting** — incrementing `ext.cache_version` propagates through the response `config.json` and invalidates browser caches via a `?v=` query parameter
|
||||
|
||||
#### 4. Notification system
|
||||
|
||||
Multi-channel notifications are managed through a three-tier model: **Definition × Template × Recipients**.
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
|
||||
│ tion ├──────┤ (independent per ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel) │ │ - related_user │
|
||||
│ variables=[...] │ │ channel=mail|db|... │ │ - role │
|
||||
│ │ │ subject, body, │ │ - specific_users │
|
||||
│ │ │ click_url │ │ │
|
||||
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- **Definition** — declares the notification type, supported channels, and variable metadata
|
||||
- **Template** — an independent subject, body, and click URL per channel (`mail` / `database` / `broadcast`). Administrators can customize them in multiple languages
|
||||
- **Recipients** — recipient rules declared as JSON per template. Because they are per-template, you can branch freely: "mail to the buyer, database notification to role holders"
|
||||
|
||||
```php
|
||||
// A module service only publishes a hook; the delivery pipeline runs automatically
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
||||
|
||||
// ↓ NotificationHookListener → NotificationDispatcher:
|
||||
// 1. Look up the order.confirmed definition
|
||||
// 2. Iterate active templates (mail/database)
|
||||
// 3. Resolve each template's recipients JSON → recipient collection
|
||||
// 4. Deliver per channel to each recipient (GenericNotification)
|
||||
// 5. Record the delivery in notification_logs
|
||||
```
|
||||
|
||||
- Three core notifications: `welcome`, `reset_password`, `password_changed`
|
||||
- Seven e-commerce module notifications: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- Real-time broadcasting runs on Laravel Reverb (WebSocket). Where Reverb is not configured, it skips gracefully without errors
|
||||
- A single `GenericNotification` class handles every notification — adding a new notification type requires no new notification class
|
||||
|
||||
#### 5. Language pack system
|
||||
|
||||
An operations tool for adding a new language without touching the core, with the same lifecycle as module, plugin, and template management (install → activate → update → remove, with automatic backup and rollback).
|
||||
|
||||
| Area | Behavior |
|
||||
| --- | --- |
|
||||
| Install paths | ZIP upload / GitHub URL / the `lang-packs/_bundled` bundled directory (synchronized in bulk on core updates) |
|
||||
| Scope | Applied separately to the core, modules, plugins, and templates — a module pack activates only while the matching core pack is active |
|
||||
| Preserving operator edits | Multilingual JSON columns record user overrides per sub-key (`name.ko` / `name.ja`), so editing one language's label preserves only that language while new languages sync automatically |
|
||||
| Activation effects | On activation or deactivation, the entity seeders of affected modules and plugins re-run, so menus, permissions, roles, manifests, and notification labels reach the database immediately |
|
||||
| Virtual protected rows | Korean and English are built into the core and bundled extensions and are always exposed as active and protected (editing and removal are blocked) |
|
||||
| Security | Installation is blocked if a pack contains executable PHP beyond language translations |
|
||||
|
||||
Sixteen official Japanese (ja) bundled packs — the core plus the main modules, plugins, and templates — are ready to use, and step 4 of the installer links language pack cards to your module, plugin, and template selection so they can be installed together.
|
||||
|
||||
> Details: [docs/extension/language-packs.md](docs/extension/language-packs.md) (Korean)
|
||||
|
||||
#### 6. Identity verification
|
||||
|
||||
Every verification point — signup, password reset, sensitive operations, the moment before payment — is managed centrally through declarative route/hook-level policies.
|
||||
|
||||
```text
|
||||
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Policy │ │ Purpose │ │ Provider │
|
||||
│ (enforcement point │ │ (verification goal, │ │ (mail, KCP, Inicis, │
|
||||
│ failure mode, │ ◀▶ │ allowed channels, │ ◀▶ │ SMS, external IDV) │
|
||||
│ step, conditions) │ │ source tracking) │ │ │
|
||||
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────▶ Message Template (policy × purpose) ◀──┘
|
||||
│
|
||||
GenericNotification
|
||||
```
|
||||
|
||||
- **Policy as the single source of truth** — toggling a policy takes effect immediately without editing route code. Every API route matches against the policy database automatically
|
||||
- **428 interceptor** — when the server returns HTTP 428, the frontend opens a verification modal automatically and replays the original request once verification succeeds
|
||||
- **Declarative registration** — modules and plugins declare `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()`, and those are registered automatically on activation or update while preserving operator edits
|
||||
- **Message templates** — define multilingual subjects and bodies per provider and per purpose/policy, falling back in the order policy → purpose → provider default
|
||||
- **External provider slots** — plugins can inject their own SDK UI for services such as KCP, PortOne, Toss verification, or Stripe Identity through the standard G7 extension-point pattern
|
||||
- **History management** — the admin screen offers tabs per verification method, unified search, multi-filters for status/purpose/channel/IP, and bulk destruction by retention period (180 days)
|
||||
|
||||
> Details: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md) (Korean)
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
## Quick Start
|
||||
|
||||
### 시스템 요구사항
|
||||
### System requirements
|
||||
|
||||
- PHP 8.2+ (필수 확장 30개 포함)
|
||||
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
|
||||
- Node.js 20+ (빌드 시에만 필요)
|
||||
- PHP 8.2+ with the required extensions (16 in total), including `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`. Additional extensions (`gd`/`imagick`, `intl`, `redis`, `bcmath`, and others) are optional and only needed for the features that use them — see [docs/requirements.md](docs/requirements.md)
|
||||
- MySQL 8.0+ or MariaDB 10.3+ (utf8mb4)
|
||||
- Composer 2.x
|
||||
- Node.js 20+ (only needed when building frontend assets)
|
||||
- A web server (Apache or Nginx) with the document root pointed at `public/`
|
||||
- Redis 6.0+ (optional — recommended for cache and queue in production)
|
||||
|
||||
### 설치
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# 1. 프로젝트 클론
|
||||
# 1. Clone the project
|
||||
git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
|
||||
# 2. 환경 설정 파일 복사
|
||||
# 2. (Optional) Install PHP dependencies — you can skip this step:
|
||||
# the setup wizard installs them automatically with the production
|
||||
# configuration when vendor/ is absent. Do not use a plain composer
|
||||
# install on a production site; it pulls in development packages.
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# 3. Copy the environment file
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
||||
# 4. Point your web server's document root at the public/ directory,
|
||||
# then open /install in a browser and follow the setup wizard
|
||||
```
|
||||
|
||||
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
||||
The setup wizard creates the application key, configures the database connection, runs the migrations, and lets you choose which modules, plugins, templates, and language packs to install. Once setup completes, the installer endpoints are blocked automatically (HTTP 410).
|
||||
|
||||
> Detailed installation guide (Korean): [INSTALL.md](INSTALL.md)
|
||||
|
||||
---
|
||||
|
||||
## 기본 제공 확장
|
||||
## Bundled Extensions
|
||||
|
||||
### 모듈
|
||||
### Modules
|
||||
|
||||
| 모듈 | 설명 |
|
||||
|------|------|
|
||||
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
|
||||
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
|
||||
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| **sirsoft-board** | Boards — multiple boards, comments, file attachments |
|
||||
| **sirsoft-ecommerce** | Storefront — products, orders, payments, shipping, coupons, product inquiries |
|
||||
| **sirsoft-page** | Pages — static content management |
|
||||
|
||||
### 플러그인
|
||||
### Plugins
|
||||
|
||||
| 플러그인 | 설명 |
|
||||
|---------|------|
|
||||
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
|
||||
| **sirsoft-verification** | 본인인증 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| **sirsoft-pay_kginicis** | KG Inicis payment integration (Korean payment gateway) |
|
||||
| **sirsoft-pay_nicepayments** | NICE Payments integration (Korean payment gateway, unified checkout) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP payment integration (Korean payment gateway, Standard Pay) |
|
||||
| **sirsoft-tosspayments** | Toss Payments integration (Korean payment gateway) |
|
||||
| **sirsoft-verification_kginicis** | KG Inicis identity verification (Korean identity verification) |
|
||||
| **sirsoft-verification_nhnkcp** | NHN KCP mobile identity verification (Korean identity verification) |
|
||||
| **sirsoft-daum_postcode** | Daum postcode lookup (Korean address search) |
|
||||
| **sirsoft-marketing** | Marketing tools |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 editor |
|
||||
| **sirsoft-gdpr** | Privacy and GDPR support |
|
||||
| **sirsoft-message_bizppurio** | Bizppurio messaging (SMS/LMS and KakaoTalk alimtalk delivery) |
|
||||
|
||||
### 템플릿
|
||||
### Templates
|
||||
|
||||
| 템플릿 | 설명 |
|
||||
|--------|------|
|
||||
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
|
||||
| **sirsoft-basic** | 사용자 기본 템플릿 |
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| **sirsoft-admin_basic** | Default admin template |
|
||||
| **sirsoft-basic** | Default user template |
|
||||
|
||||
### Bundled language packs
|
||||
|
||||
Official language packs you can install alongside the initial setup, so the core and the main modules, plugins, and templates share one consistent translation from the start.
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| **g7-core-ja** | Core, Japanese |
|
||||
| **g7-module-sirsoft-board-ja** | Board module, Japanese |
|
||||
| **g7-module-sirsoft-ecommerce-ja** | E-commerce module, Japanese |
|
||||
| **g7-module-sirsoft-page-ja** | Page module, Japanese |
|
||||
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor 5 plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | Daum postcode plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | Privacy/GDPR plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | Marketing plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | Bizppurio messaging plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG Inicis payment plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | NICE Payments plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP payment plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | Toss Payments plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG Inicis identity verification plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP mobile identity verification plugin, Japanese |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | Default admin template, Japanese |
|
||||
| **g7-template-sirsoft-basic-ja** | Default user template, Japanese |
|
||||
|
||||
> Korean and English are built into the core and bundled extensions and are always active without installation. Any other language can be added freely from a ZIP file or a GitHub URL.
|
||||
|
||||
### Sample extensions for learning
|
||||
|
||||
Minimal implementations for learning the extension system. They appear in the admin UI when the "include hidden" toggle is on, and are always visible from the CLI.
|
||||
|
||||
| Identifier | Type | Description |
|
||||
| ---------- | ---- | ----------- |
|
||||
| **gnuboard7-hello_module** | Module | Memo CRUD plus a hook publishing demo |
|
||||
| **gnuboard7-hello_plugin** | Plugin | Action/filter hook subscription demo |
|
||||
| **gnuboard7-hello_admin_template** | Admin template | A minimal set of basic components |
|
||||
| **gnuboard7-hello_user_template** | User template | Home page plus a memo list integration |
|
||||
|
||||
---
|
||||
|
||||
## 비즈니스 모델
|
||||
## Business Models
|
||||
|
||||
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
|
||||
One Gnuboard7 installation can run a range of businesses.
|
||||
|
||||
| 모델 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| **커뮤니티** | 게시판, 댓글, 회원 관리 | Beta |
|
||||
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | Beta |
|
||||
| Model | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| **Community** | Boards, comments, member management | Stable |
|
||||
| **Commerce** | Product registration, orders, payments, shipping management | Stable |
|
||||
|
||||
---
|
||||
|
||||
## 기존 그누보드 사용자
|
||||
## Migrating from Gnuboard 5
|
||||
|
||||
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
|
||||
A migration tool from Gnuboard 5 is planned.
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
## Documentation
|
||||
|
||||
| 문서 | 링크 |
|
||||
|------|------|
|
||||
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
|
||||
| 전체 문서 | [docs/README.md](docs/README.md) |
|
||||
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
|
||||
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API 레퍼런스 | 준비 중 |
|
||||
> Documentation is currently available in Korean only. English documentation is planned.
|
||||
|
||||
| Document | Link |
|
||||
|----------|------|
|
||||
| Installation guide | [INSTALL.md](INSTALL.md) |
|
||||
| Full documentation | [docs/README.md](docs/README.md) |
|
||||
| System requirements | [docs/requirements.md](docs/requirements.md) |
|
||||
| Backend development | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| Frontend development | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| Database | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| Extension system | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| Module development | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| Plugin development | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| Template development | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| Testing | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API reference | [docs/backend/api/README.md](docs/backend/api/README.md) |
|
||||
| API documentation policy | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
|
||||
|
||||
---
|
||||
|
||||
## 기여하기
|
||||
## Contributing
|
||||
|
||||
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
|
||||
Gnuboard7 is an open-source project, and contributions of every kind are welcome.
|
||||
|
||||
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- 코드 스타일: Laravel Pint (PSR-12)
|
||||
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
|
||||
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
|
||||
- Bug reports and feature proposals: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- Code style: Laravel Pint (PSR-12)
|
||||
- Testing: PHPUnit (backend) + Vitest (frontend)
|
||||
- AI collaboration: the repository ships a development rule specification for AI agents (AGENTS.md) along with MCP debugging tools, so AI tooling fits naturally into the workflow
|
||||
|
||||
---
|
||||
|
||||
## 만든 사람들
|
||||
## Team
|
||||
|
||||
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
|
||||
Developed by **[SIRSOFT](https://sir.kr)**.
|
||||
|
||||
### Core Team
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>
|
||||
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
|
||||
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
|
||||
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
|
||||
</p>
|
||||
|
||||
### Contributors
|
||||
### Community Contributors
|
||||
|
||||
커뮤니티 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
|
||||
Thanks to everyone who reported an issue or suggested a feature that shipped — the list below is compiled from the attributions in our changelogs.
|
||||
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
|
||||
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
<!-- community-contributors:end -->
|
||||
|
||||
The list of code contributors is available on [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors).
|
||||
|
||||
---
|
||||
|
||||
## 커뮤니티
|
||||
## Community
|
||||
|
||||
| 채널 | 링크 |
|
||||
|------|------|
|
||||
| Channel | Link |
|
||||
|---------|------|
|
||||
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
|
||||
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
|
||||
| 문의 | minsup@sir.kr |
|
||||
| SIR community (Korean) | [sir.kr](https://sir.kr) |
|
||||
| Contact | minsup@sir.kr |
|
||||
|
||||
---
|
||||
|
||||
## 변경 기록
|
||||
## Changelog
|
||||
|
||||
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
|
||||
For details on recent changes, see the [CHANGELOG](CHANGELOG.md) (Korean).
|
||||
|
||||
---
|
||||
|
||||
## 보안 취약점
|
||||
## Security Vulnerabilities
|
||||
|
||||
보안 취약점을 발견하셨다면 [GitHub Issues](https://github.com/gnuboard/g7/issues)에 보고해 주세요.
|
||||
If you discover a security vulnerability, please report it as a private post on the [SIR inquiry board](https://sir.kr/boards/co_qa) (Korean board), or email minsup@sir.kr.
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
## License
|
||||
|
||||
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
|
||||
Gnuboard7 is open-source software distributed under the [MIT License](LICENSE).
|
||||
|
||||
Copyright (c) 2026 SIRSOFT
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\ActivityLog\Traits;
|
||||
|
||||
use App\Enums\ActivityLogType;
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -39,17 +40,31 @@ trait ResolvesActivityLogType
|
||||
* 활동 로그를 기록합니다.
|
||||
*
|
||||
* context에 log_type이 명시되지 않으면 resolveLogType()으로 자동 결정합니다.
|
||||
* 호출 클래스 FQCN 으로부터 모듈/플러그인 origin 을 추론하여
|
||||
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
|
||||
* action 라벨 namespace fallback 용).
|
||||
*
|
||||
* @param string $action 액션명 (예: 'user.create')
|
||||
* @param array $context Monolog context 배열
|
||||
* @param string $action 액션명 (예: 'user.create')
|
||||
* @param array $context Monolog context 배열
|
||||
*/
|
||||
protected function logActivity(string $action, array $context): void
|
||||
{
|
||||
$context['log_type'] ??= $this->resolveLogType();
|
||||
|
||||
$origin = ExtensionManager::resolveExtensionByFqcn(static::class);
|
||||
if ($origin !== null) {
|
||||
$properties = $context['properties'] ?? [];
|
||||
$properties['extension_origin'] ??= $origin;
|
||||
$context['properties'] = $properties;
|
||||
}
|
||||
|
||||
// 활동 로그는 부수 기록이다 — 실패해도 그것을 유발한 요청(결제 콜백 등)까지
|
||||
// 죽여서는 안 된다. 채널 해석 실패는 Exception 이 아니라 Error 로 오므로
|
||||
// (\Log::channel() 이 null 을 반환하면 "->info() on null" = \Error)
|
||||
// Throwable 로 받아야 가드가 실제로 동작한다.
|
||||
try {
|
||||
Log::channel('activity')->info($action, $context);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to record activity log', [
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
/**
|
||||
* 배치 축 실행기 — 배치 커맨드 소요 시간 + 피크 메모리
|
||||
*
|
||||
* 배치는 한 번에 대량을 처리하므로 "느려짐"이 시간보다 메모리로 먼저 드러납니다(청크
|
||||
* 없이 전건 로딩 → OOM). 그래서 시간과 함께 피크 메모리를 함께 잽니다.
|
||||
*
|
||||
* 다른 축과 달리 트랜잭션으로 감싸지 않습니다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을
|
||||
* 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해집니다.
|
||||
* 대신 데이터를 변경하는 배치는 `--allow-write` 없이는 실행하지 않습니다.
|
||||
*/
|
||||
class BatchAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(private readonly QueryCollector $collector) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => null;
|
||||
|
||||
if ($profile->mutates() && ! $options->allowWrite) {
|
||||
return BenchmarkResult::skipped($profile, '데이터를 변경하는 배치입니다. --allow-write 를 붙여 실행하세요.');
|
||||
}
|
||||
|
||||
$command = (string) $profile->option('command');
|
||||
$arguments = (array) $profile->option('arguments', []);
|
||||
|
||||
if (! array_key_exists($command, Artisan::all())) {
|
||||
return BenchmarkResult::skipped($profile, "등록되지 않은 커맨드: {$command} (해당 확장이 설치되어 있는지 확인)");
|
||||
}
|
||||
|
||||
$notify("배치 실행: {$command}");
|
||||
|
||||
// 피크 메모리는 프로세스 단위 누적값이라 감소하지 않는다. 실행 전 값을 함께
|
||||
// 기록해야 "이 배치가 피크를 밀어올렸는지"를 판정할 수 있다.
|
||||
$peakBefore = memory_get_peak_usage(true);
|
||||
$usageBefore = memory_get_usage(true);
|
||||
$buffer = new BufferedOutput;
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
try {
|
||||
$collected = $this->collector->collect(
|
||||
static fn () => Artisan::call($command, $arguments, $buffer)
|
||||
);
|
||||
$exitCode = (int) $collected['value'];
|
||||
$queries = $collected['queries'];
|
||||
} catch (\Throwable $e) {
|
||||
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$elapsedMs = (microtime(true) - $start) * 1000;
|
||||
$peakAfter = memory_get_peak_usage(true);
|
||||
$usageAfter = memory_get_usage(true);
|
||||
$summary = $this->collector->summarize($queries);
|
||||
|
||||
$notes = [
|
||||
sprintf('실행 전 피크 %s → 실행 후 피크 %s', $this->formatBytes($peakBefore), $this->formatBytes($peakAfter)),
|
||||
];
|
||||
|
||||
if ($peakAfter <= $peakBefore) {
|
||||
$notes[] = '이 배치가 프로세스 피크를 밀어올리지 않았습니다 (실행 전 피크가 이미 더 높음).';
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$notes[] = "커맨드가 실패 종료했습니다 (exit={$exitCode}) — 시간·메모리는 실패 지점까지의 값입니다.";
|
||||
}
|
||||
|
||||
$output = trim($buffer->fetch());
|
||||
|
||||
if ($output !== '') {
|
||||
$notes[] = '커맨드 출력: '.$output;
|
||||
}
|
||||
|
||||
return new BenchmarkResult(
|
||||
profile: $profile,
|
||||
headers: ['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'],
|
||||
rows: [[
|
||||
$command,
|
||||
(string) $exitCode,
|
||||
number_format($elapsedMs, 1),
|
||||
$this->formatBytes($peakAfter),
|
||||
$this->formatBytes(max(0, $usageAfter - $usageBefore)),
|
||||
number_format($summary['count']),
|
||||
]],
|
||||
metrics: [
|
||||
'command' => $command,
|
||||
'arguments' => $arguments,
|
||||
'exit_code' => $exitCode,
|
||||
'elapsed_ms' => round($elapsedMs, 2),
|
||||
'peak_memory_before_bytes' => $peakBefore,
|
||||
'peak_memory_after_bytes' => $peakAfter,
|
||||
'memory_delta_bytes' => $usageAfter - $usageBefore,
|
||||
'query_count' => $summary['count'],
|
||||
'db_ms' => $summary['db_ms'],
|
||||
],
|
||||
notes: $notes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바이트를 사람이 읽는 단위로 바꿉니다.
|
||||
*
|
||||
* @param int $bytes 바이트
|
||||
* @return string 포맷된 문자열
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1024 ** 3) {
|
||||
return number_format($bytes / 1024 ** 3, 2).' GB';
|
||||
}
|
||||
|
||||
if ($bytes >= 1024 ** 2) {
|
||||
return number_format($bytes / 1024 ** 2, 1).' MB';
|
||||
}
|
||||
|
||||
return number_format($bytes / 1024, 1).' KB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\SyntheticSeeder;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 목록 조회 축 실행기 — 깊은 OFFSET 비용을 컬럼 폭 3축으로 계측
|
||||
*
|
||||
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"
|
||||
* 인지 확인하려면 같은 필터·정렬로 (1) 전체 컬럼 (2) 실제 목록 컬럼 (3) 키 컬럼만 조회한
|
||||
* 비용을 나란히 재야 합니다. 셋째 축이 지연 조인(`PaginatesWithDeferredJoin`)의 inner
|
||||
* 쿼리에 해당하므로, 이 세 값의 배수가 곧 지연 조인 적용의 기대 효과입니다.
|
||||
*/
|
||||
class ListAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
/**
|
||||
* 목록 1페이지 상한 (OFFSET 스캔 비용 대비 무시할 수준이라 고정)
|
||||
*/
|
||||
private const PAGE_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* 필터 선언에 쓸 수 있는 연산자 (닫힌 집합)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const FILTER_OPERATORS = ['=', '!=', '<>', '<', '<=', '>', '>=', 'like', 'in', 'not in'];
|
||||
|
||||
public function __construct(private readonly SyntheticSeeder $seeder) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::ListQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => null;
|
||||
$table = (string) $profile->option('table');
|
||||
|
||||
if (! Schema::hasTable($table)) {
|
||||
return BenchmarkResult::skipped($profile, "테이블이 없습니다: {$table} (해당 확장이 설치되어 있는지 확인)");
|
||||
}
|
||||
|
||||
if (($options->fresh || $options->seed > 0) && app()->environment('production')) {
|
||||
return BenchmarkResult::skipped($profile, '운영 환경에서는 시딩/비움을 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
$filterError = $this->validateFilters((array) $profile->option('filters', []));
|
||||
|
||||
if ($filterError !== null) {
|
||||
return BenchmarkResult::skipped($profile, $filterError);
|
||||
}
|
||||
|
||||
if ($options->fresh) {
|
||||
$this->seeder->truncate($table);
|
||||
$notify("비움: {$table}");
|
||||
}
|
||||
|
||||
$columns = $this->existingColumns($table, (array) $profile->option('columns', ['*']));
|
||||
$order = $this->existingOrder($table, (array) $profile->option('order', [['id', 'desc']]));
|
||||
|
||||
if ($options->seed > 0) {
|
||||
$notify("시딩: {$table} ".number_format($options->seed).' 건');
|
||||
$this->seeder->seed(
|
||||
$table,
|
||||
$options->seed,
|
||||
(array) $profile->option('seed_overrides', []),
|
||||
fn (int $inserted, int $total) => $notify(sprintf(' 시딩 %s / %s', number_format($inserted), number_format($total))),
|
||||
// 정렬 기준 컬럼은 nullable 이어도 채운다 — 비워두면 합성 행이 전부 동률이 되어
|
||||
// 계측이 정렬 인덱스가 아니라 tie-break 경로를 잰다.
|
||||
array_column($order, 0),
|
||||
);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$notes = [];
|
||||
|
||||
foreach ($options->offsets as $offset) {
|
||||
$allMs = $this->measure($profile, ['*'], $order, $offset, $options->runs);
|
||||
$listMs = $this->measure($profile, $columns, $order, $offset, $options->runs);
|
||||
$idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $options->runs);
|
||||
|
||||
$rows[] = [
|
||||
'offset' => $offset,
|
||||
'all_ms' => $allMs,
|
||||
'list_ms' => $listMs,
|
||||
'id_only_ms' => $idOnlyMs,
|
||||
'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null,
|
||||
];
|
||||
|
||||
if ($options->explain) {
|
||||
$notes[] = "EXPLAIN @ OFFSET {$offset} — 목록 컬럼";
|
||||
|
||||
foreach ($this->explain($profile, $columns, $order, $offset) as $line) {
|
||||
$notes[] = ' '.$line;
|
||||
}
|
||||
|
||||
// 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다
|
||||
$notes[] = "EXPLAIN @ OFFSET {$offset} — ID 만 (지연 조인 inner)";
|
||||
|
||||
foreach ($this->explain($profile, ['id'], $order, $offset) as $line) {
|
||||
$notes[] = ' '.$line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BenchmarkResult(
|
||||
profile: $profile,
|
||||
headers: ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'],
|
||||
rows: array_map(fn (array $row) => [
|
||||
number_format($row['offset']),
|
||||
number_format($row['all_ms'], 1),
|
||||
number_format($row['list_ms'], 1),
|
||||
number_format($row['id_only_ms'], 1),
|
||||
$row['ratio'] !== null ? $row['ratio'].'×' : '-',
|
||||
], $rows),
|
||||
metrics: [
|
||||
'table' => $table,
|
||||
'columns' => $columns,
|
||||
'runs' => $options->runs,
|
||||
'offsets' => $rows,
|
||||
],
|
||||
notes: $notes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스키마에 실제로 존재하는 컬럼만 남깁니다.
|
||||
*
|
||||
* `['*']` 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언입니다. 응답 계약상 넓은 컬럼을
|
||||
* 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, 이
|
||||
* 경우 계측의 비교 축은 select * vs select id 가 됩니다. 확장 버전에 따라 컬럼 구성이
|
||||
* 달라도 계측이 죽지 않도록 스키마에 없는 컬럼은 걸러냅니다.
|
||||
*
|
||||
* @param string $table 테이블명
|
||||
* @param array<int, string> $columns 후보 컬럼
|
||||
* @return array<int, string> 존재하는 컬럼 목록
|
||||
*/
|
||||
private function existingColumns(string $table, array $columns): array
|
||||
{
|
||||
if ($columns === ['*']) {
|
||||
return Schema::getColumnListing($table);
|
||||
}
|
||||
|
||||
$existing = array_values(array_filter(
|
||||
$columns,
|
||||
fn (mixed $column) => is_string($column) && Schema::hasColumn($table, $column)
|
||||
));
|
||||
|
||||
return $existing === [] ? ['id'] : $existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스키마에 존재하는 정렬 컬럼만 남깁니다.
|
||||
*
|
||||
* @param string $table 테이블명
|
||||
* @param array<int, array{0: string, 1: string}> $order 후보 정렬
|
||||
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
|
||||
*/
|
||||
private function existingOrder(string $table, array $order): array
|
||||
{
|
||||
$existing = array_values(array_filter(
|
||||
$order,
|
||||
fn (mixed $spec) => is_array($spec) && isset($spec[0]) && Schema::hasColumn($table, (string) $spec[0])
|
||||
));
|
||||
|
||||
return $existing === [] ? [['id', 'desc']] : $existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다.
|
||||
*
|
||||
* `DB::enableQueryLog()` 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잽니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 프로파일
|
||||
* @param array<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $order 정렬
|
||||
* @param int $offset OFFSET
|
||||
* @param int $runs 측정 횟수
|
||||
* @return float 중앙값 (밀리초)
|
||||
*/
|
||||
private function measure(BenchmarkProfile $profile, array $columns, array $order, int $offset, int $runs): float
|
||||
{
|
||||
// 첫 회는 캐시 워밍 성격이라 버린다
|
||||
$this->buildQuery($profile, $columns, $order, $offset)->get();
|
||||
|
||||
$samples = [];
|
||||
|
||||
for ($i = 0; $i < max(1, $runs); $i++) {
|
||||
$start = microtime(true);
|
||||
$this->buildQuery($profile, $columns, $order, $offset)->get();
|
||||
$samples[] = (microtime(true) - $start) * 1000;
|
||||
}
|
||||
|
||||
sort($samples);
|
||||
|
||||
return $samples[intdiv(count($samples), 2)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측 대상 쿼리를 조립합니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 프로파일
|
||||
* @param array<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $order 정렬
|
||||
* @param int $offset OFFSET
|
||||
* @return Builder 조립된 쿼리
|
||||
*/
|
||||
private function buildQuery(BenchmarkProfile $profile, array $columns, array $order, int $offset): Builder
|
||||
{
|
||||
$table = (string) $profile->option('table');
|
||||
$query = DB::table($table)->select($columns);
|
||||
|
||||
if ($profile->option('soft_delete', false) && Schema::hasColumn($table, 'deleted_at')) {
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $table, (array) $profile->option('filters', []));
|
||||
|
||||
foreach ($order as $spec) {
|
||||
$query->orderBy($spec[0], $spec[1] ?? 'asc');
|
||||
}
|
||||
|
||||
return $query->offset($offset)->limit(self::PAGE_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 필터를 계측 쿼리에 적용합니다.
|
||||
*
|
||||
* 값이 `[연산자, 값]` 형태면 그 연산자로, 그 밖에는 등가 비교로 해석합니다. 등가 비교만
|
||||
* 지원하면 화면이 실제로 거는 필터를 선언할 방법이 없는 목록이 생깁니다 — 주문 목록은
|
||||
* 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외하므로, 그것을 선언하지 못하면
|
||||
* 계측이 화면과 다른 인덱스를 타게 됩니다.
|
||||
*
|
||||
* 연산자는 닫힌 집합으로 해석합니다. 임의 문자열을 그대로 넘기면 선언 오타가 조용히
|
||||
* 통과하거나 의도치 않은 SQL 이 조립됩니다.
|
||||
*
|
||||
* @param Builder $query 계측 쿼리
|
||||
* @param string $table 대상 테이블
|
||||
* @param array<string, mixed> $filters 선언된 필터
|
||||
*/
|
||||
private function applyFilters(Builder $query, string $table, array $filters): void
|
||||
{
|
||||
foreach ($filters as $column => $declared) {
|
||||
if (! Schema::hasColumn($table, (string) $column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// [연산자, 값] 형태가 아니면 등가 비교 (배열 값을 IN 으로 오해석하지 않도록 형태로 판정)
|
||||
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
|
||||
$query->where($column, $declared);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$operator, $value] = $declared;
|
||||
|
||||
match (strtolower($operator)) {
|
||||
'=' => $query->where($column, '=', $value),
|
||||
'!=', '<>' => $query->where($column, '!=', $value),
|
||||
'<' => $query->where($column, '<', $value),
|
||||
'<=' => $query->where($column, '<=', $value),
|
||||
'>' => $query->where($column, '>', $value),
|
||||
'>=' => $query->where($column, '>=', $value),
|
||||
'like' => $query->where($column, 'like', $value),
|
||||
'in' => $query->whereIn($column, (array) $value),
|
||||
'not in' => $query->whereNotIn($column, (array) $value),
|
||||
// 도달 불가 — run() 이 실행 전에 연산자를 검증해 거부한다
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 필터의 연산자를 실행 전에 검증합니다.
|
||||
*
|
||||
* 알 수 없는 연산자를 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을
|
||||
* 재면서도 정상 측정으로 보고됩니다. 실행 전에 사유와 함께 거부합니다.
|
||||
*
|
||||
* @param array<string, mixed> $filters 선언된 필터
|
||||
* @return string|null 실패 사유 (문제 없으면 null)
|
||||
*/
|
||||
private function validateFilters(array $filters): ?string
|
||||
{
|
||||
foreach ($filters as $column => $declared) {
|
||||
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array(strtolower($declared[0]), self::FILTER_OPERATORS, true)) {
|
||||
return sprintf(
|
||||
'필터 %s 의 연산자를 알 수 없습니다: %s (허용: %s)',
|
||||
$column,
|
||||
$declared[0],
|
||||
implode(', ', self::FILTER_OPERATORS)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 계획을 수집합니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 프로파일
|
||||
* @param array<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $order 정렬
|
||||
* @param int $offset OFFSET
|
||||
* @return array<int, string> 실행 계획 요약 줄 목록
|
||||
*/
|
||||
private function explain(BenchmarkProfile $profile, array $columns, array $order, int $offset): array
|
||||
{
|
||||
$query = $this->buildQuery($profile, $columns, $order, $offset);
|
||||
|
||||
try {
|
||||
$plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings());
|
||||
} catch (\Throwable $e) {
|
||||
return ['실행 계획 수집 실패: '.$e->getMessage()];
|
||||
}
|
||||
|
||||
return array_map(function ($row) {
|
||||
$row = (array) $row;
|
||||
|
||||
return sprintf(
|
||||
'type=%s key=%s rows=%s filtered=%s extra=%s',
|
||||
$row['type'] ?? '-',
|
||||
$row['key'] ?? '-',
|
||||
$row['rows'] ?? '-',
|
||||
$row['filtered'] ?? '-',
|
||||
$row['Extra'] ?? '-'
|
||||
);
|
||||
}, $plan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\BenchmarkIdentity;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* 화면 응답 축 실행기 — 화면 1장의 응답 시간 + 실행 쿼리 건수 + N+1 후보
|
||||
*
|
||||
* 사용자가 체감하는 단위가 "화면 한 장"이므로 네 축 중 값어치가 가장 큽니다. 목록 SELECT
|
||||
* 는 빠른데 화면이 느린 경우(관계 지연 로딩으로 인한 N+1, 권한 조회 반복 등)를 잡는 것이
|
||||
* 이 축의 목적입니다.
|
||||
*
|
||||
* 실행 방식은 라우트를 **HTTP 커널로 내부 요청** 처리하는 것입니다. `Route::dispatch` 로
|
||||
* 라우트만 때리면 전역 미들웨어(인증·권한·로케일·타임존)를 건너뛰어 화면과 다른 것을 재게
|
||||
* 됩니다. 커널을 쓰면 실제 요청과 같은 경로를 지납니다.
|
||||
*
|
||||
* 계측 계정 생성과 요청 처리 전체를 롤백되는 트랜잭션으로 감싸므로, 계측이 데이터에 흔적을
|
||||
* 남기지 않습니다(`BenchmarkIdentity::withRolledBackTransaction`).
|
||||
*/
|
||||
class ScreenAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BenchmarkIdentity $identity,
|
||||
private readonly QueryCollector $collector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
if ($profile->mutates() && ! $options->allowWrite) {
|
||||
return BenchmarkResult::skipped($profile, '데이터를 변경하는 화면입니다. --allow-write 를 붙여 실행하세요.');
|
||||
}
|
||||
|
||||
[$uri, $uriError] = $this->resolveUri($profile);
|
||||
|
||||
if ($uriError !== null) {
|
||||
return BenchmarkResult::skipped($profile, $uriError);
|
||||
}
|
||||
|
||||
// `--as` 로 지정한 계정은 트랜잭션 진입 전에 확인한다 — 없는 계정으로 계측을 시작하면
|
||||
// 롤백 구간 안에서 실패해 사유만 흐려진다.
|
||||
if ($options->asUser !== null && $this->identity->findExistingUser($options->asUser) === null) {
|
||||
return BenchmarkResult::skipped($profile, "계측에 사용할 계정을 찾을 수 없습니다: {$options->asUser}");
|
||||
}
|
||||
|
||||
$method = strtoupper((string) $profile->option('method', 'GET'));
|
||||
$query = (array) $profile->option('query', []);
|
||||
$query['per_page'] ??= $options->perPage;
|
||||
|
||||
// 데이터를 변경하는 화면은 반복 측정 시 2회차부터 조건이 달라진다(중복 키, 재고 소진).
|
||||
// 롤백은 계측 종료 시 한 번이므로 회차 간에는 되돌려지지 않는다 → 1회만 잰다.
|
||||
$runs = $profile->mutates() ? 1 : max(1, $options->runs);
|
||||
$notes = [];
|
||||
|
||||
if ($profile->mutates()) {
|
||||
$notes[] = '데이터 변경 화면이라 1회만 측정했습니다 (회차 간 조건 변화 방지).';
|
||||
}
|
||||
|
||||
try {
|
||||
$measured = $this->identity->withRolledBackTransaction(
|
||||
fn () => $this->measure($profile, (string) $uri, $method, $query, $runs, $options, $onProgress)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$summary = $measured['summary'];
|
||||
|
||||
foreach ($summary['n_plus_one'] as $candidate) {
|
||||
$notes[] = sprintf('N+1 후보 %d회: %s', $candidate['count'], $candidate['sql']);
|
||||
}
|
||||
|
||||
if ($summary['n_plus_one'] === []) {
|
||||
$notes[] = 'N+1 후보 없음 (같은 SQL 5회 이상 반복 없음).';
|
||||
}
|
||||
|
||||
return new BenchmarkResult(
|
||||
profile: $profile,
|
||||
headers: ['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'],
|
||||
rows: [[
|
||||
$method.' '.$uri,
|
||||
(string) $measured['status'],
|
||||
number_format($measured['total_ms'], 1),
|
||||
number_format($summary['count']),
|
||||
number_format($summary['db_ms'], 1),
|
||||
]],
|
||||
metrics: [
|
||||
'uri' => $uri,
|
||||
'method' => $method,
|
||||
'query' => $query,
|
||||
'status' => $measured['status'],
|
||||
'runs' => $runs,
|
||||
'total_ms' => $measured['total_ms'],
|
||||
'query_count' => $summary['count'],
|
||||
'db_ms' => $summary['db_ms'],
|
||||
'n_plus_one' => $summary['n_plus_one'],
|
||||
'acting_user' => $measured['acting_user'],
|
||||
],
|
||||
notes: $notes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측 대상 URI 를 해석합니다.
|
||||
*
|
||||
* 라우트명 선언을 권장하는 이유는 두 가지입니다 — 프리픽스를 문자열로 조립하지 않아도
|
||||
* 되고, 라우트가 사라지면 계측이 조용히 404 를 재는 대신 사유를 남기고 건너뜁니다.
|
||||
*
|
||||
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
|
||||
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 프로파일
|
||||
* @return array{0: string|null, 1: string|null} [경로, 실패 사유]
|
||||
*/
|
||||
private function resolveUri(BenchmarkProfile $profile): array
|
||||
{
|
||||
$routeName = $profile->option('route');
|
||||
|
||||
if (is_string($routeName) && $routeName !== '') {
|
||||
if (Route::getRoutes()->getByName($routeName) === null) {
|
||||
return [null, "등록되지 않은 라우트명: {$routeName} (해당 확장이 설치되어 있는지 확인)"];
|
||||
}
|
||||
|
||||
return [route($routeName, (array) $profile->option('route_params', []), false), null];
|
||||
}
|
||||
|
||||
return ['/'.ltrim((string) $profile->option('uri'), '/'), null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 요청을 반복 실행해 응답 시간 중앙값과 쿼리 요약을 냅니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 프로파일
|
||||
* @param string $uri 경로
|
||||
* @param string $method HTTP 메서드
|
||||
* @param array<string, mixed> $query 쿼리스트링
|
||||
* @param int $runs 측정 횟수
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @return array{status: int, total_ms: float, summary: array<string, mixed>, acting_user: array<string, mixed>} 계측 결과
|
||||
*/
|
||||
private function measure(
|
||||
BenchmarkProfile $profile,
|
||||
string $uri,
|
||||
string $method,
|
||||
array $query,
|
||||
int $runs,
|
||||
BenchmarkRunOptions $options,
|
||||
?\Closure $onProgress,
|
||||
): array {
|
||||
$notify = $onProgress ?? static fn (string $message) => null;
|
||||
|
||||
$issued = $this->identity->issueToken(
|
||||
(array) $profile->option('permissions', []),
|
||||
$options->asUser !== null ? $this->identity->findExistingUser($options->asUser) : null
|
||||
);
|
||||
|
||||
$notify(sprintf(
|
||||
'인증: %s (%s)',
|
||||
$issued['user']->email,
|
||||
$issued['ephemeral'] ? '계측용 임시 계정 — 종료 시 롤백' : '지정 계정'
|
||||
));
|
||||
|
||||
$samples = [];
|
||||
$status = 0;
|
||||
$queries = [];
|
||||
|
||||
// 첫 회는 라우트 매칭·컨테이너 해석 워밍이라 버린다 (측정 회차와 별도로 1회 더 실행)
|
||||
for ($i = 0; $i <= $runs; $i++) {
|
||||
$collected = $this->collector->collect(
|
||||
fn () => $this->dispatch($uri, $method, $query, $issued['token'])
|
||||
);
|
||||
|
||||
/** @var array{status: int, ms: float} $outcome */
|
||||
$outcome = $collected['value'];
|
||||
$status = $outcome['status'];
|
||||
|
||||
if ($i === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$samples[] = $outcome['ms'];
|
||||
$queries = $collected['queries'];
|
||||
}
|
||||
|
||||
sort($samples);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'total_ms' => $samples[intdiv(count($samples), 2)],
|
||||
'summary' => $this->collector->summarize($queries),
|
||||
'acting_user' => [
|
||||
'id' => $issued['user']->id,
|
||||
'email' => $issued['user']->email,
|
||||
'ephemeral' => $issued['ephemeral'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 요청 1회를 처리하고 소요 시간을 잽니다.
|
||||
*
|
||||
* 커널이 컨테이너의 `request` 인스턴스와 인증 가드 상태를 갈아치우므로, 처리 후
|
||||
* 원상 복구합니다 — 복구하지 않으면 이어지는 회차/프로파일이 앞 요청의 인증 상태를
|
||||
* 물려받아 계측이 서로 오염됩니다.
|
||||
*
|
||||
* @param string $uri 경로
|
||||
* @param string $method HTTP 메서드
|
||||
* @param array<string, mixed> $query 쿼리스트링/바디
|
||||
* @param string $token Sanctum 토큰
|
||||
* @return array{status: int, ms: float} 상태 코드와 소요 시간
|
||||
*/
|
||||
private function dispatch(string $uri, string $method, array $query, string $token): array
|
||||
{
|
||||
$previousRequest = app()->bound('request') ? app('request') : null;
|
||||
|
||||
$request = Request::create($uri, $method, $query);
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
$request->headers->set('Authorization', 'Bearer '.$token);
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
try {
|
||||
$response = app(HttpKernel::class)->handle($request);
|
||||
$status = $response->getStatusCode();
|
||||
// 스트리밍 응답은 본문 생성까지가 사용자 체감 시간이라 여기서 소비한다
|
||||
$response->getContent();
|
||||
} finally {
|
||||
$ms = (microtime(true) - $start) * 1000;
|
||||
|
||||
Auth::forgetGuards();
|
||||
|
||||
if ($previousRequest !== null) {
|
||||
app()->instance('request', $previousRequest);
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => $status, 'ms' => $ms];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\BenchmarkIdentity;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 쓰기 축 실행기 — 저장 경로 1회 소요 시간
|
||||
*
|
||||
* 목록 조회와 달리 저장 경로는 커맨드가 스스로 조립할 수 없습니다(주문 생성 하나에 재고·
|
||||
* 쿠폰·마일리지·알림이 얽힘). 그래서 실행 자체는 소유 확장이 선언한 콜백에 맡기고, 이
|
||||
* 실행기는 시간·쿼리 건수 계측과 안전장치만 담당합니다.
|
||||
*
|
||||
* 콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용합니다 — 코어 선언은
|
||||
* `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없기
|
||||
* 때문입니다. 확장 선언도 같은 스키마를 쓰므로 형식을 통일합니다.
|
||||
*
|
||||
* 선언 필드는 세 개입니다 — `prepare`(계측 제외 선행 준비, 회차마다 실행), `callback`
|
||||
* (계측 대상, prepare 반환값을 인자로 받음), `cleanup`(트랜잭션이 되돌리지 못하는 잔여물 정리).
|
||||
*
|
||||
* 계측으로 생긴 행은 롤백되는 트랜잭션으로 되돌립니다. 트랜잭션이 되돌리지 못하는 것
|
||||
* (파일·캐시·외부 호출)은 프로파일이 선언한 `cleanup` 콜백이 정리합니다.
|
||||
*/
|
||||
class WriteAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BenchmarkIdentity $identity,
|
||||
private readonly QueryCollector $collector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Write;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => null;
|
||||
|
||||
if (! $options->allowWrite) {
|
||||
return BenchmarkResult::skipped($profile, '쓰기 축입니다. --allow-write 를 붙여 실행하세요.');
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
|
||||
foreach (['callback', 'prepare', 'cleanup'] as $field) {
|
||||
if ($field !== 'callback' && $profile->option($field) === null) {
|
||||
$resolved[$field] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$callable, $error] = $this->resolveCallable($profile->option($field), $profile, $field);
|
||||
|
||||
if ($error !== null) {
|
||||
return BenchmarkResult::skipped($profile, $error);
|
||||
}
|
||||
|
||||
$resolved[$field] = $callable;
|
||||
}
|
||||
|
||||
$callback = $resolved['callback'];
|
||||
$prepare = $resolved['prepare'];
|
||||
$cleanup = $resolved['cleanup'];
|
||||
|
||||
$runs = max(1, $options->runs);
|
||||
|
||||
try {
|
||||
$measured = $this->identity->withRolledBackTransaction(
|
||||
fn () => $this->measure($callback, $prepare, $runs, $onProgress)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
|
||||
} finally {
|
||||
if ($cleanup !== null) {
|
||||
// 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시)을 정리한다.
|
||||
// 정리 실패가 계측 결과를 삼키면 안 되므로 사유만 알린다.
|
||||
try {
|
||||
$cleanup();
|
||||
} catch (\Throwable $e) {
|
||||
$notify('cleanup 실패: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $this->collector->summarize($measured['queries']);
|
||||
|
||||
return new BenchmarkResult(
|
||||
profile: $profile,
|
||||
headers: ['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'],
|
||||
rows: [[
|
||||
number_format($measured['first_ms'], 1),
|
||||
number_format($measured['median_ms'], 1),
|
||||
(string) $runs,
|
||||
number_format($summary['count']),
|
||||
number_format($summary['db_ms'], 1),
|
||||
]],
|
||||
metrics: [
|
||||
'first_ms' => $measured['first_ms'],
|
||||
'median_ms' => $measured['median_ms'],
|
||||
'runs' => $runs,
|
||||
'samples_ms' => $measured['samples'],
|
||||
'query_count' => $summary['count'],
|
||||
'db_ms' => $summary['db_ms'],
|
||||
'n_plus_one' => $summary['n_plus_one'],
|
||||
],
|
||||
notes: array_merge(
|
||||
['계측으로 생긴 행은 롤백했습니다.'],
|
||||
array_map(
|
||||
fn (array $candidate) => sprintf('반복 쿼리 %d회: %s', $candidate['count'], $candidate['sql']),
|
||||
$summary['n_plus_one']
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 콜백을 호출 가능한 형태로 해석합니다.
|
||||
*
|
||||
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
|
||||
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
|
||||
*
|
||||
* @param mixed $declared 선언값
|
||||
* @param BenchmarkProfile $profile 프로파일 (오류 메시지용)
|
||||
* @param string $field 선언 필드명 (오류 메시지용)
|
||||
* @return array{0: callable|null, 1: string|null} [해석된 콜백, 실패 사유]
|
||||
*/
|
||||
private function resolveCallable(mixed $declared, BenchmarkProfile $profile, string $field): array
|
||||
{
|
||||
$where = $profile->qualifiedKey().' 의 '.$field;
|
||||
|
||||
if ($declared instanceof \Closure) {
|
||||
return [null, "{$where}: 클로저는 쓸 수 없습니다 (config:cache 불가). 'Fqcn' 또는 ['Fqcn', 'method'] 형식으로 선언하세요."];
|
||||
}
|
||||
|
||||
if (is_string($declared)) {
|
||||
if (! class_exists($declared)) {
|
||||
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$declared}"];
|
||||
}
|
||||
|
||||
$instance = app($declared);
|
||||
|
||||
if (! is_callable($instance)) {
|
||||
return [null, "{$where}: {$declared} 에 __invoke() 가 없습니다."];
|
||||
}
|
||||
|
||||
return [$instance, null];
|
||||
}
|
||||
|
||||
if (is_array($declared) && count($declared) === 2 && is_string($declared[0]) && is_string($declared[1])) {
|
||||
[$class, $method] = $declared;
|
||||
|
||||
if (! class_exists($class)) {
|
||||
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$class}"];
|
||||
}
|
||||
|
||||
$instance = app($class);
|
||||
|
||||
if (! method_exists($instance, $method)) {
|
||||
return [null, "{$where}: {$class}::{$method}() 가 없습니다."];
|
||||
}
|
||||
|
||||
return [[$instance, $method], null];
|
||||
}
|
||||
|
||||
return [null, "{$where}: 'Fqcn' 또는 ['Fqcn', 'method'] 형식이어야 합니다."];
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백을 반복 실행해 첫 회 시간과 중앙값을 냅니다.
|
||||
*
|
||||
* 첫 회를 버리지 않고 따로 보고하는 이유는, 저장 경로에서는 첫 회가 포함하는 비용
|
||||
* (클래스 로딩, 설정 해석, 관계 초기 조회)이 실사용에서도 발생하기 때문입니다.
|
||||
* 쿼리 요약은 마지막 회차 기준입니다 — 첫 회는 위 초기화 쿼리가 섞입니다.
|
||||
*
|
||||
* `prepare` 는 계측 구간 **밖**에서 회차마다 실행합니다. 저장 경로에는 선행 상태가
|
||||
* 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨),
|
||||
* 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하게 됩니다.
|
||||
*
|
||||
* @param callable $callback 저장 경로 콜백 (prepare 반환값을 인자로 받음)
|
||||
* @param callable|null $prepare 회차별 선행 준비 콜백 (계측 제외, 회차 번호를 인자로 받음)
|
||||
* @param int $runs 측정 횟수
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @return array{first_ms: float, median_ms: float, samples: array<int, float>, queries: array<int, array{sql: string, time: float}>} 계측 결과
|
||||
*/
|
||||
private function measure(callable $callback, ?callable $prepare, int $runs, ?\Closure $onProgress): array
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => null;
|
||||
$samples = [];
|
||||
$queries = [];
|
||||
|
||||
for ($i = 1; $i <= $runs; $i++) {
|
||||
$notify("쓰기 계측 {$i} / {$runs}");
|
||||
|
||||
$context = $prepare !== null ? $prepare($i) : null;
|
||||
|
||||
$start = microtime(true);
|
||||
$collected = $this->collector->collect(static function () use ($callback, $context) {
|
||||
$callback($context);
|
||||
});
|
||||
$samples[] = (microtime(true) - $start) * 1000;
|
||||
$queries = $collected['queries'];
|
||||
}
|
||||
|
||||
$sorted = $samples;
|
||||
sort($sorted);
|
||||
|
||||
return [
|
||||
'first_ms' => $samples[0],
|
||||
'median_ms' => $sorted[intdiv(count($sorted), 2)],
|
||||
'samples' => $samples,
|
||||
'queries' => $queries,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 화면 계측용 인증 주체 해석기
|
||||
*
|
||||
* 관리자 화면의 응답 시간은 로그인 상태에서만 잴 수 있습니다. 두 방식을 지원합니다.
|
||||
*
|
||||
* 기본 — 프로파일이 선언한 권한만 가진 임시 관리자를 즉석 생성해 Sanctum 토큰 발급
|
||||
* `--as` — 기존 계정을 지정 (그 계정의 실제 역할/권한으로 재측정)
|
||||
*
|
||||
* 두 경로 모두 계정/역할/토큰 생성이라는 **쓰기**를 동반하므로, 호출자가 열어둔 트랜잭션
|
||||
* 안에서 수행하고 계측 후 롤백해 흔적을 남기지 않습니다(`withRolledBackTransaction`).
|
||||
* 트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo`
|
||||
* 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 이 계정을 인증할 수
|
||||
* 있습니다. 운영 DB 에서도 잔여 계정이 생기지 않는 이유가 이 롤백입니다.
|
||||
*/
|
||||
class BenchmarkIdentity
|
||||
{
|
||||
/**
|
||||
* 롤백되는 트랜잭션 안에서 콜백을 실행합니다.
|
||||
*
|
||||
* 계측이 만든 계정·토큰과, 계측 대상이 만든 행까지 함께 되돌립니다. 쓰기 축의 결과를
|
||||
* 되돌리는 것도 같은 장치를 씁니다.
|
||||
*
|
||||
* @param \Closure $callback 트랜잭션 안에서 실행할 작업
|
||||
* @return mixed 콜백 반환값
|
||||
*/
|
||||
public function withRolledBackTransaction(\Closure $callback): mixed
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
// 계측 결과 산출 여부와 무관하게 되돌린다 — 예외로 빠져나가도 잔여 데이터 없음
|
||||
DB::rollBack();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측에 사용할 Sanctum 토큰을 발급합니다.
|
||||
*
|
||||
* 호출 전에 `--as` 계정 존재를 `findExistingUser()` 로 확인해야 합니다 — 계정 부재는
|
||||
* 계측 실패가 아니라 실행 전 판정 대상이라 이 메서드는 그 경우를 다루지 않습니다.
|
||||
*
|
||||
* @param array<int, string> $permissions 임시 계정에 부여할 권한 식별자 목록
|
||||
* @param User|null $actor `--as` 로 확인된 기존 계정 (null 이면 임시 계정 생성)
|
||||
* @return array{token: string, user: User, ephemeral: bool} 토큰과 인증 주체
|
||||
*/
|
||||
public function issueToken(array $permissions = [], ?User $actor = null): array
|
||||
{
|
||||
$user = $actor ?? $this->makeEphemeralAdmin($permissions);
|
||||
|
||||
return [
|
||||
'token' => $user->createToken('g7-bench-'.Str::random(8))->plainTextToken,
|
||||
'user' => $user,
|
||||
'ephemeral' => $actor === null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 기존 계정을 찾습니다.
|
||||
*
|
||||
* @param string $identifier 계정 ID 또는 이메일
|
||||
* @return User|null 찾은 계정 (없으면 null)
|
||||
*/
|
||||
public function findExistingUser(string $identifier): ?User
|
||||
{
|
||||
return ctype_digit($identifier)
|
||||
? User::find((int) $identifier)
|
||||
: User::where('email', $identifier)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 권한만 가진 임시 관리자를 생성합니다.
|
||||
*
|
||||
* 권한을 프로파일 선언에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야
|
||||
* 미들웨어 통과 여부까지 실제와 같아지기 때문입니다(전권 계정으로 재면 권한 검사
|
||||
* 비용과 분기가 달라집니다).
|
||||
*
|
||||
* @param array<int, string> $permissions 권한 식별자 목록
|
||||
* @return User 생성된 임시 관리자
|
||||
*/
|
||||
private function makeEphemeralAdmin(array $permissions): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$permissionIds = [];
|
||||
|
||||
foreach ($permissions as $identifier) {
|
||||
$identifier = (string) $identifier;
|
||||
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
|
||||
$permissionIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$benchRole = Role::create([
|
||||
'identifier' => 'g7_bench_'.Str::random(8),
|
||||
'name' => json_encode(['ko' => '성능 계측 전용', 'en' => 'Benchmark Only']),
|
||||
'description' => json_encode(['ko' => '성능 계측 임시 역할', 'en' => 'Temporary benchmark role']),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
|
||||
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if ($permissionIds !== []) {
|
||||
$benchRole->permissions()->sync($permissionIds);
|
||||
}
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
$user->roles()->attach($benchRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 프로파일 레지스트리 — 코어 config + 활성 확장 선언 수집
|
||||
*
|
||||
* 계측 대상은 "지금 이 설치본에 실제로 존재하는 것"이어야 하므로, 커맨드에 대상을
|
||||
* 하드코딩하지 않고 소유자가 선언한 것을 런타임에 모읍니다. 코어는
|
||||
* `config/benchmark.php`, 확장은 `getBenchmarkProfiles()` 오버라이드가 선언 지점입니다
|
||||
* (채널·알림 정의 등 기존 확장 선언 훅과 동일한 수집 모델).
|
||||
*
|
||||
* 확장 선언은 격리 호출합니다 — 확장 하나의 잘못된 선언이 전체 목록을 날리면 계측 자체를
|
||||
* 못 하게 되므로, 실패한 선언만 사유와 함께 `warnings()` 로 드러내고 나머지는 살립니다.
|
||||
* 조용히 버리지는 않습니다(버려진 선언이 곧 계측 사각이 됩니다).
|
||||
*/
|
||||
class BenchmarkProfileRegistry
|
||||
{
|
||||
/**
|
||||
* 수집된 프로파일 (정규화 키 → 프로파일)
|
||||
*
|
||||
* @var array<string, BenchmarkProfile>|null
|
||||
*/
|
||||
private ?array $profiles = null;
|
||||
|
||||
/**
|
||||
* 수집 중 발생한 선언 오류 메시지
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $warnings = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleManagerInterface $moduleManager,
|
||||
private readonly PluginManagerInterface $pluginManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 모든 프로파일을 정규화 키 기준으로 반환합니다.
|
||||
*
|
||||
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
if ($this->profiles !== null) {
|
||||
return $this->profiles;
|
||||
}
|
||||
|
||||
$profiles = [];
|
||||
|
||||
foreach ($this->collectCore() as $profile) {
|
||||
$profiles[$profile->qualifiedKey()] = $profile;
|
||||
}
|
||||
|
||||
foreach ($this->collectExtensions() as $profile) {
|
||||
$profiles[$profile->qualifiedKey()] = $profile;
|
||||
}
|
||||
|
||||
return $this->profiles = $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 축의 프로파일만 반환합니다.
|
||||
*
|
||||
* @param BenchmarkAxis $axis 대상 축
|
||||
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
|
||||
*/
|
||||
public function byAxis(BenchmarkAxis $axis): array
|
||||
{
|
||||
return array_filter($this->all(), fn (BenchmarkProfile $profile) => $profile->axis === $axis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 키로 프로파일을 해석합니다.
|
||||
*
|
||||
* 정규화 키(`sirsoft-ecommerce/orders`)를 우선 대조하고, 없으면 짧은 키로 찾습니다.
|
||||
* 짧은 키가 둘 이상의 확장에서 선언돼 모호하면 후보 목록을 사유로 돌려줍니다 —
|
||||
* 임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 됩니다.
|
||||
*
|
||||
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
|
||||
* @return array{0: BenchmarkProfile|null, 1: string|null} [찾은 프로파일, 실패 사유]
|
||||
*/
|
||||
public function resolve(string $key): array
|
||||
{
|
||||
$profiles = $this->all();
|
||||
|
||||
if (isset($profiles[$key])) {
|
||||
return [$profiles[$key], null];
|
||||
}
|
||||
|
||||
$matches = array_filter($profiles, fn (BenchmarkProfile $profile) => $profile->key === $key);
|
||||
|
||||
if ($matches === []) {
|
||||
return [null, "등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"];
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
$candidates = implode(', ', array_keys($matches));
|
||||
|
||||
return [null, "프로파일 키가 모호합니다: {$key} — 다음 중 하나로 지정하세요: {$candidates}"];
|
||||
}
|
||||
|
||||
return [reset($matches), null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 키로 프로파일을 찾습니다. (해석 실패 시 null)
|
||||
*
|
||||
* 사유가 필요하면 `resolve()` 를 씁니다.
|
||||
*
|
||||
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
|
||||
* @return BenchmarkProfile|null 찾은 프로파일
|
||||
*/
|
||||
public function find(string $key): ?BenchmarkProfile
|
||||
{
|
||||
return $this->resolve($key)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 중 무시된 선언의 사유 목록을 반환합니다.
|
||||
*
|
||||
* @return array<int, string> 경고 메시지
|
||||
*/
|
||||
public function warnings(): array
|
||||
{
|
||||
$this->all();
|
||||
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 프로파일을 수집합니다.
|
||||
*
|
||||
* @return array<int, BenchmarkProfile> 코어 프로파일 목록
|
||||
*/
|
||||
private function collectCore(): array
|
||||
{
|
||||
$declared = config('benchmark.profiles', []);
|
||||
|
||||
return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈/플러그인 프로파일을 수집합니다.
|
||||
*
|
||||
* @return array<int, BenchmarkProfile> 확장 프로파일 목록
|
||||
*/
|
||||
private function collectExtensions(): array
|
||||
{
|
||||
$profiles = [];
|
||||
|
||||
foreach ($this->moduleManager->getActiveModules() as $module) {
|
||||
$profiles = array_merge(
|
||||
$profiles,
|
||||
$this->normalize($this->extract($module), 'module', $module->getIdentifier())
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
|
||||
$profiles = array_merge(
|
||||
$profiles,
|
||||
$this->normalize($this->extract($plugin), 'plugin', $plugin->getIdentifier())
|
||||
);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장의 `getBenchmarkProfiles()` 선언을 격리 호출로 읽습니다.
|
||||
*
|
||||
* @param object $extension 확장 인스턴스
|
||||
* @return array<string, mixed> 선언 배열 (실패 시 빈 배열)
|
||||
*/
|
||||
private function extract(object $extension): array
|
||||
{
|
||||
if (! method_exists($extension, 'getBenchmarkProfiles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$declared = $extension->getBenchmarkProfiles();
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnings[] = sprintf(
|
||||
'%s: getBenchmarkProfiles() 호출 실패 — %s',
|
||||
method_exists($extension, 'getIdentifier') ? $extension->getIdentifier() : $extension::class,
|
||||
$e->getMessage()
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($declared) ? $declared : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언 배열을 검증해 프로파일 객체로 정규화합니다.
|
||||
*
|
||||
* @param array<string, mixed> $declared 선언 배열 (키 → 정의)
|
||||
* @param string $sourceKind 출처 종류 (core|module|plugin)
|
||||
* @param string $sourceIdentifier 출처 식별자
|
||||
* @return array<int, BenchmarkProfile> 정규화된 프로파일 목록
|
||||
*/
|
||||
private function normalize(array $declared, string $sourceKind, string $sourceIdentifier): array
|
||||
{
|
||||
$profiles = [];
|
||||
|
||||
foreach ($declared as $key => $definition) {
|
||||
if (! is_string($key) || $key === '') {
|
||||
$this->warnings[] = "{$sourceIdentifier}: 프로파일 키는 빈 문자열이 아닌 문자열이어야 합니다.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_array($definition)) {
|
||||
$this->warnings[] = "{$sourceIdentifier}/{$key}: 프로파일 정의는 배열이어야 합니다.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$axis = BenchmarkAxis::tryFrom((string) ($definition['type'] ?? ''));
|
||||
|
||||
if ($axis === null) {
|
||||
$this->warnings[] = sprintf(
|
||||
'%s/%s: 알 수 없는 type — %s (허용: %s)',
|
||||
$sourceIdentifier,
|
||||
$key,
|
||||
var_export($definition['type'] ?? null, true),
|
||||
implode('|', BenchmarkAxis::values())
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing = array_values(array_filter(
|
||||
$axis->requiredOptions(),
|
||||
fn (array $group) => array_filter(
|
||||
$group,
|
||||
fn (string $option) => isset($definition[$option])
|
||||
) === []
|
||||
));
|
||||
|
||||
if ($missing !== []) {
|
||||
$this->warnings[] = sprintf(
|
||||
'%s/%s: %s 축 필수 옵션 누락 — %s',
|
||||
$sourceIdentifier,
|
||||
$key,
|
||||
$axis->value,
|
||||
implode(', ', array_map(fn (array $group) => implode('|', $group), $missing))
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $definition['label'] ?? null;
|
||||
unset($definition['type'], $definition['label']);
|
||||
|
||||
$profiles[] = new BenchmarkProfile(
|
||||
key: $key,
|
||||
axis: $axis,
|
||||
sourceKind: $sourceKind,
|
||||
sourceIdentifier: $sourceIdentifier,
|
||||
options: $definition,
|
||||
label: is_string($label) ? $label : null,
|
||||
);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 계측 결과 출력기 — 표준출력 표 / JSON / 마크다운 리포트
|
||||
*
|
||||
* 축을 모르고도 출력할 수 있는 이유는 `BenchmarkResult` 가 표시용 표와 기계 판독용 수치를
|
||||
* 함께 담기 때문입니다. 축이 늘어도 이 클래스는 고치지 않습니다.
|
||||
*
|
||||
* 마크다운 리포트에 환경 정보를 반드시 함께 적습니다 — 계측값은 실행 머신·DB 버전·
|
||||
* OPcache 여부에 종속되므로, 환경이 빠진 수치는 다른 리포트와 비교할 수 없는 숫자입니다.
|
||||
* 문서에 옮길 수치는 이 경로로만 산출해 눈대중 기재를 막는 것이 `--report` 의 목적입니다.
|
||||
*/
|
||||
class BenchmarkReporter
|
||||
{
|
||||
/**
|
||||
* 실행 환경 정보를 수집합니다.
|
||||
*
|
||||
* @return array<string, string> 항목 → 값
|
||||
*/
|
||||
public function environment(): array
|
||||
{
|
||||
return [
|
||||
'APP_ENV' => (string) config('app.env'),
|
||||
'G7 버전' => (string) config('app.version'),
|
||||
'DB 연결' => (string) config('database.default'),
|
||||
'DB 스키마' => $this->databaseName(),
|
||||
'DB 버전' => $this->databaseVersion(),
|
||||
'PHP 버전' => PHP_VERSION,
|
||||
'OPcache' => function_exists('opcache_get_status') && @opcache_get_status(false) !== false ? 'on' : 'off',
|
||||
'memory_limit' => (string) ini_get('memory_limit'),
|
||||
'config:cache' => app()->configurationIsCached() ? 'on' : 'off',
|
||||
'실행 머신' => php_uname('s').' '.php_uname('r').' / '.php_uname('m'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 으로 직렬화합니다.
|
||||
*
|
||||
* @param array<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $warnings 프로파일 수집 경고
|
||||
* @return string JSON 문자열
|
||||
*/
|
||||
public function toJson(array $results, BenchmarkRunOptions $options, array $warnings = []): string
|
||||
{
|
||||
return (string) json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'environment' => $this->environment(),
|
||||
'options' => $options->toArray(),
|
||||
'warnings' => $warnings,
|
||||
'results' => array_map(fn (BenchmarkResult $result) => $result->toArray(), $results),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 리포트를 만듭니다.
|
||||
*
|
||||
* @param array<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $warnings 프로파일 수집 경고
|
||||
* @return string 마크다운 문서
|
||||
*/
|
||||
public function toMarkdown(array $results, BenchmarkRunOptions $options, array $warnings = []): string
|
||||
{
|
||||
$lines = [
|
||||
'# G7 성능 점검 리포트',
|
||||
'',
|
||||
'- 생성 시각: '.now()->toDateTimeString(),
|
||||
'',
|
||||
'## 실행 환경',
|
||||
'',
|
||||
'| 항목 | 값 |',
|
||||
'| ------ | ------ |',
|
||||
];
|
||||
|
||||
foreach ($this->environment() as $name => $value) {
|
||||
$lines[] = "| {$name} | {$value} |";
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '계측값은 위 환경에 종속됩니다. 다른 리포트와 비교할 때는 환경이 같은지 먼저 확인하세요.';
|
||||
$lines[] = '';
|
||||
$lines[] = '## 실행 조건';
|
||||
$lines[] = '';
|
||||
$lines[] = '| 항목 | 값 |';
|
||||
$lines[] = '| ------ | ------ |';
|
||||
|
||||
foreach ($options->toArray() as $name => $value) {
|
||||
$lines[] = sprintf('| %s | %s |', $name, $this->stringify($value));
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$lines[] = '';
|
||||
$lines[] = '## 무시된 프로파일 선언';
|
||||
$lines[] = '';
|
||||
|
||||
foreach ($warnings as $warning) {
|
||||
$lines[] = '- '.$warning;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (BenchmarkAxis::cases() as $axis) {
|
||||
$axisResults = array_values(array_filter(
|
||||
$results,
|
||||
fn (BenchmarkResult $result) => $result->profile->axis === $axis
|
||||
));
|
||||
|
||||
if ($axisResults === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf('## %s (%s)', $axis->label(), $axis->value);
|
||||
|
||||
foreach ($axisResults as $result) {
|
||||
$lines = array_merge($lines, $this->markdownSection($result));
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines)."\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 1건의 마크다운 절을 만듭니다.
|
||||
*
|
||||
* @param BenchmarkResult $result 계측 결과
|
||||
* @return array<int, string> 마크다운 줄 목록
|
||||
*/
|
||||
private function markdownSection(BenchmarkResult $result): array
|
||||
{
|
||||
$title = $result->profile->qualifiedKey();
|
||||
|
||||
if ($result->profile->label !== null) {
|
||||
$title .= ' — '.$result->profile->label;
|
||||
}
|
||||
|
||||
$lines = ['', '### '.$title, ''];
|
||||
|
||||
if ($result->skipped) {
|
||||
$lines[] = '측정하지 않음: '.$result->skipReason;
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$lines[] = '| '.implode(' | ', $result->headers).' |';
|
||||
$lines[] = '| '.implode(' | ', array_fill(0, count($result->headers), '------')).' |';
|
||||
|
||||
foreach ($result->rows as $row) {
|
||||
$lines[] = '| '.implode(' | ', $row).' |';
|
||||
}
|
||||
|
||||
if ($result->notes !== []) {
|
||||
$lines[] = '';
|
||||
|
||||
foreach ($result->notes as $note) {
|
||||
// 실행 계획/SQL 은 표 안에서 깨지므로 코드 스팬으로 감싼다
|
||||
$lines[] = '- '.(str_contains($note, '|') ? '`'.$note.'`' : $note);
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 옵션 값을 표에 넣을 문자열로 바꿉니다.
|
||||
*
|
||||
* @param mixed $value 옵션 값
|
||||
* @return string 표시 문자열
|
||||
*/
|
||||
private function stringify(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
is_bool($value) => $value ? 'true' : 'false',
|
||||
is_array($value) => implode(', ', array_map(fn (mixed $item) => (string) $item, $value)),
|
||||
$value === null => '-',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측이 사용한 DB 스키마명을 반환합니다.
|
||||
*
|
||||
* 읽기/쓰기 분리 설정에서는 스키마명이 `write`/`read` 하위에만 있어 최상위 `database`
|
||||
* 가 비어 있습니다. 리포트에 스키마가 비면 어느 DB 를 잰 것인지 알 수 없으므로
|
||||
* 세 위치를 순서대로 확인합니다.
|
||||
*
|
||||
* @return string 스키마명 (확인 불가 시 '-')
|
||||
*/
|
||||
private function databaseName(): string
|
||||
{
|
||||
$connection = (string) config('database.default');
|
||||
|
||||
foreach (['database', 'write.database', 'read.database'] as $key) {
|
||||
$name = config("database.connections.{$connection}.{$key}");
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 서버 버전을 조회합니다.
|
||||
*
|
||||
* @return string DB 버전 (조회 실패 시 '-')
|
||||
*/
|
||||
private function databaseVersion(): string
|
||||
{
|
||||
try {
|
||||
$row = DB::selectOne('select version() as version');
|
||||
|
||||
return (string) ($row->version ?? '-');
|
||||
} catch (\Throwable) {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Contracts;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 축 실행기 계약
|
||||
*
|
||||
* 축이 늘어날 때 커맨드를 고치지 않고 실행기를 추가하도록 분리합니다. 커맨드는
|
||||
* 프로파일의 `type` 으로 실행기를 고르는 일만 합니다.
|
||||
*/
|
||||
interface BenchmarkAxisRunner
|
||||
{
|
||||
/**
|
||||
* 이 실행기가 담당하는 축을 반환합니다.
|
||||
*
|
||||
* @return BenchmarkAxis 담당 축
|
||||
*/
|
||||
public function axis(): BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 프로파일 1건을 계측합니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 계측 대상
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param \Closure|null $onProgress 진행 상황 콜백 (string $message, 시딩 등 장시간 작업 알림용)
|
||||
* @return BenchmarkResult 계측 결과
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 프로파일 — 코어 config / 확장 선언에서 수집된 계측 대상 1건 (Value Object)
|
||||
*
|
||||
* 레지스트리가 선언 배열을 검증해 이 객체로 정규화하고, 축 실행기가 그대로 받아 씁니다.
|
||||
* 축별 옵션 스키마가 서로 다르므로 공통 필드(키·축·출처·라벨)만 프로퍼티로 승격하고
|
||||
* 축 고유 옵션은 `options` 에 남깁니다 — 축이 늘 때 VO 를 고치지 않아도 되게 합니다.
|
||||
*/
|
||||
final readonly class BenchmarkProfile
|
||||
{
|
||||
/**
|
||||
* @param string $key 선언된 프로파일 키 (확장 내부에서만 고유)
|
||||
* @param BenchmarkAxis $axis 계측 축
|
||||
* @param string $sourceKind 출처 종류 (core|module|plugin)
|
||||
* @param string $sourceIdentifier 출처 식별자 (코어는 'core')
|
||||
* @param array<string, mixed> $options 축 고유 옵션
|
||||
* @param string|null $label 표시용 설명
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public BenchmarkAxis $axis,
|
||||
public string $sourceKind,
|
||||
public string $sourceIdentifier,
|
||||
public array $options = [],
|
||||
public ?string $label = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 출처를 포함한 전역 고유 키를 반환합니다.
|
||||
*
|
||||
* 서로 다른 확장이 같은 키(`orders` 등)를 선언할 수 있으므로 충돌 시에는 이 키로
|
||||
* 지목합니다. 커맨드는 짧은 키가 유일할 때만 짧은 키를 허용합니다.
|
||||
*
|
||||
* @return string `{출처}/{키}` 형태의 정규화 키
|
||||
*/
|
||||
public function qualifiedKey(): string
|
||||
{
|
||||
return $this->sourceIdentifier.'/'.$this->key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 축 고유 옵션 값을 읽습니다.
|
||||
*
|
||||
* @param string $name 옵션 키
|
||||
* @param mixed $default 미선언 시 기본값
|
||||
* @return mixed 옵션 값
|
||||
*/
|
||||
public function option(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->options[$name] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 프로파일 실행이 데이터를 변경하는지 판정합니다.
|
||||
*
|
||||
* 축 기본값을 쓰되, 프로파일이 `mutating` 을 명시하면 그 선언을 따릅니다.
|
||||
* `screen` 축은 GET 이 아니면 변경으로 봅니다.
|
||||
*
|
||||
* @return bool 데이터 변경 여부
|
||||
*/
|
||||
public function mutates(): bool
|
||||
{
|
||||
$declared = $this->options['mutating'] ?? null;
|
||||
|
||||
if (is_bool($declared)) {
|
||||
return $declared;
|
||||
}
|
||||
|
||||
if ($this->axis === BenchmarkAxis::Screen) {
|
||||
return strtoupper((string) $this->option('method', 'GET')) !== 'GET';
|
||||
}
|
||||
|
||||
return $this->axis->mutatesByDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열로 직렬화합니다. (`--json` / 리포트 출력용)
|
||||
*
|
||||
* @return array<string, mixed> 직렬화 결과
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'key' => $this->key,
|
||||
'qualified_key' => $this->qualifiedKey(),
|
||||
'axis' => $this->axis->value,
|
||||
'source' => ['kind' => $this->sourceKind, 'identifier' => $this->sourceIdentifier],
|
||||
'label' => $this->label,
|
||||
'options' => $this->options,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
/**
|
||||
* 계측 결과 1건 — 축 실행기의 산출물 (Value Object)
|
||||
*
|
||||
* 출력기(`BenchmarkReporter`)가 축을 몰라도 표/JSON/마크다운을 만들 수 있도록,
|
||||
* 표시용 표(`headers`/`rows`)와 기계 판독용 수치(`metrics`)를 함께 담습니다.
|
||||
* 축이 늘어날 때 출력기를 고치지 않아도 되는 지점이 여기입니다.
|
||||
*/
|
||||
final readonly class BenchmarkResult
|
||||
{
|
||||
/**
|
||||
* @param BenchmarkProfile $profile 계측 대상 프로파일
|
||||
* @param array<int, string> $headers 표 헤더
|
||||
* @param array<int, array<int, string>> $rows 표 행 (표시용 문자열)
|
||||
* @param array<string, mixed> $metrics 기계 판독용 원시 수치
|
||||
* @param array<int, string> $notes 부가 설명 줄 (실행 계획, N+1 후보 등)
|
||||
* @param bool $skipped 실행하지 않았는지 여부
|
||||
* @param string|null $skipReason 실행하지 않은 사유
|
||||
*/
|
||||
public function __construct(
|
||||
public BenchmarkProfile $profile,
|
||||
public array $headers = [],
|
||||
public array $rows = [],
|
||||
public array $metrics = [],
|
||||
public array $notes = [],
|
||||
public bool $skipped = false,
|
||||
public ?string $skipReason = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 실행하지 않은 결과를 만듭니다.
|
||||
*
|
||||
* 실패/건너뜀을 결과 목록에서 빼면 리포트가 "전부 측정됨"으로 읽히므로, 사유를 담은
|
||||
* 결과로 남겨 표와 JSON 양쪽에 드러냅니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 대상 프로파일
|
||||
* @param string $reason 건너뛴 사유
|
||||
* @return self 건너뜀 결과
|
||||
*/
|
||||
public static function skipped(BenchmarkProfile $profile, string $reason): self
|
||||
{
|
||||
return new self(profile: $profile, skipped: true, skipReason: $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열로 직렬화합니다.
|
||||
*
|
||||
* @return array<string, mixed> 직렬화 결과
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'profile' => $this->profile->qualifiedKey(),
|
||||
'axis' => $this->profile->axis->value,
|
||||
'label' => $this->profile->label,
|
||||
'source' => ['kind' => $this->profile->sourceKind, 'identifier' => $this->profile->sourceIdentifier],
|
||||
'skipped' => $this->skipped,
|
||||
'skip_reason' => $this->skipReason,
|
||||
'headers' => $this->headers,
|
||||
'rows' => $this->rows,
|
||||
'metrics' => $this->metrics,
|
||||
'notes' => $this->notes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
/**
|
||||
* 계측 실행 옵션 — 커맨드 옵션을 축 실행기로 한 번 전달하는 값 묶음 (Value Object)
|
||||
*
|
||||
* 축마다 쓰는 옵션이 다르지만(offsets 는 list 축만, allowWrite 는 write/batch 축만)
|
||||
* 실행기 시그니처를 하나로 두기 위해 한 객체로 모읍니다.
|
||||
*/
|
||||
final readonly class BenchmarkRunOptions
|
||||
{
|
||||
/**
|
||||
* @param array<int, int> $offsets 측정할 OFFSET 목록 (list 축)
|
||||
* @param int $runs 측정 횟수 (첫 회는 버림)
|
||||
* @param int $seed 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)
|
||||
* @param bool $fresh 시딩 전 대상 테이블 비움 (list 축)
|
||||
* @param bool $explain 실행 계획 수집 (list 축)
|
||||
* @param bool $allowWrite 데이터 변경 축 실행 허용
|
||||
* @param string|null $asUser 계측에 사용할 기존 계정 (ID 또는 이메일, screen 축)
|
||||
* @param int $perPage 목록/화면 1페이지 건수
|
||||
*/
|
||||
public function __construct(
|
||||
public array $offsets = [0],
|
||||
public int $runs = 3,
|
||||
public int $seed = 0,
|
||||
public bool $fresh = false,
|
||||
public bool $explain = false,
|
||||
public bool $allowWrite = false,
|
||||
public ?string $asUser = null,
|
||||
public int $perPage = 20,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배열로 직렬화합니다. (리포트의 실행 조건 기재용)
|
||||
*
|
||||
* @return array<string, mixed> 직렬화 결과
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'offsets' => $this->offsets,
|
||||
'runs' => $this->runs,
|
||||
'seed' => $this->seed,
|
||||
'fresh' => $this->fresh,
|
||||
'explain' => $this->explain,
|
||||
'allow_write' => $this->allowWrite,
|
||||
'as_user' => $this->asUser,
|
||||
'per_page' => $this->perPage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 목록 프로파일 선언에서 필요한 색인을 도출하고 스키마와 대조합니다.
|
||||
*
|
||||
* 배경: 지연 조인은 inner 가 키 컬럼만 읽게 만들지만, 그 inner 가 **인덱스 순서 그대로**
|
||||
* 끝나야 깊은 OFFSET 이 싸진다. 정렬을 덮는 인덱스가 없으면 inner 도 filesort 로 전체를
|
||||
* 훑으므로 개선 폭이 사라진다. 그런데 어떤 색인이 필요한지는 지금까지 테이블마다 사람이
|
||||
* 손으로 설계했고, 맞는지 검사하는 장치가 없었다.
|
||||
*
|
||||
* 필요한 정보는 이미 계측 프로파일에 선언돼 있다 — `filters` / `order` / `soft_delete` 를
|
||||
* 이으면 그대로 색인 레시피가 된다:
|
||||
*
|
||||
* (등치 필터 컬럼들 → soft_delete 면 deleted_at → 정렬 컬럼들 → 기본키)
|
||||
*
|
||||
* 이 클래스는 그 도출을 한 곳에 두어, 새 목록이 프로파일을 선언하는 순간 필요한 색인이
|
||||
* 자동으로 드러나게 한다. 색인 설계를 사람의 기억이 아니라 이미 있는 선언에 붙이는 것이라
|
||||
* 호출자가 추가로 선언할 것은 없다.
|
||||
*
|
||||
* 등치가 아닌 필터(`in` / `not in` / 범위)는 선행 컬럼에 넣지 않는다 — 선행 컬럼이 등치로
|
||||
* 고정되지 않으면 뒤따르는 정렬 컬럼을 인덱스 순서로 쓸 수 없기 때문이다. 이 판정을 틀리면
|
||||
* "색인이 있는데도 filesort" 라는, 눈으로는 구분되지 않는 상태를 정상으로 보고하게 된다.
|
||||
*/
|
||||
class ListIndexAdvisor
|
||||
{
|
||||
/** 선행 컬럼으로 쓸 수 있는 연산자 (등치만) */
|
||||
private const EQUALITY_OPERATORS = ['='];
|
||||
|
||||
/**
|
||||
* 프로파일이 요구하는 색인 선행 컬럼 목록을 도출합니다.
|
||||
*
|
||||
* @param array<string, mixed> $options 목록 프로파일 옵션
|
||||
* @return array<int, string> 선행 컬럼 순서 (등치 → deleted_at → 정렬 → 기본키)
|
||||
*/
|
||||
public function requiredColumns(array $options, string $keyName = 'id'): array
|
||||
{
|
||||
$leading = [];
|
||||
|
||||
foreach (($options['filters'] ?? []) as $column => $declared) {
|
||||
if (is_array($declared) && count($declared) === 2 && is_string($declared[0])) {
|
||||
// [연산자, 값] 형태 — 등치만 선행 컬럼 자격이 있다
|
||||
if (in_array(strtolower($declared[0]), self::EQUALITY_OPERATORS, true)) {
|
||||
$leading[] = (string) $column;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 값만 준 형태는 등가 비교
|
||||
$leading[] = (string) $column;
|
||||
}
|
||||
|
||||
if ($options['soft_delete'] ?? false) {
|
||||
$leading[] = 'deleted_at';
|
||||
}
|
||||
|
||||
foreach (($options['order'] ?? []) as $spec) {
|
||||
$column = is_array($spec) ? ($spec[0] ?? null) : $spec;
|
||||
|
||||
if (is_string($column) && $column !== '') {
|
||||
$leading[] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬 끝에 기본키가 없으면 동률 구간에서 filesort 가 남는다
|
||||
if (end($leading) !== $keyName) {
|
||||
$leading[] = $keyName;
|
||||
}
|
||||
|
||||
return array_values(array_unique($leading));
|
||||
}
|
||||
|
||||
/**
|
||||
* 도출한 색인이 실제 스키마에 있는지 대조합니다.
|
||||
*
|
||||
* @param array<string, mixed> $options 목록 프로파일 옵션
|
||||
* @return array{
|
||||
* table: string|null,
|
||||
* required: array<int, string>,
|
||||
* satisfied_by: string|null,
|
||||
* partial_by: string|null,
|
||||
* status: string
|
||||
* } status: satisfied | tiebreak_missing | missing | table_absent | not_applicable
|
||||
*/
|
||||
public function inspect(array $options, string $keyName = 'id'): array
|
||||
{
|
||||
$table = $options['table'] ?? null;
|
||||
$required = $this->requiredColumns($options, $keyName);
|
||||
|
||||
$result = [
|
||||
'table' => $table,
|
||||
'required' => $required,
|
||||
'satisfied_by' => null,
|
||||
'partial_by' => null,
|
||||
'status' => 'not_applicable',
|
||||
];
|
||||
|
||||
if (! is_string($table) || $table === '') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable($table)) {
|
||||
$result['status'] = 'table_absent';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 기본키를 뺀 형태 — 색인은 있으나 tie-break 만 빠진 경우를 구분한다.
|
||||
// 이 둘은 처방이 다르다: 전자는 색인 신설, 후자는 기존 색인에 기본키를 덧붙이는 교체.
|
||||
$withoutKey = $required;
|
||||
if (end($withoutKey) === $keyName) {
|
||||
array_pop($withoutKey);
|
||||
}
|
||||
|
||||
foreach (Schema::getIndexes($table) as $index) {
|
||||
$columns = $index['columns'] ?? [];
|
||||
|
||||
if ($result['satisfied_by'] === null && $this->hasPrefix($columns, $required)) {
|
||||
$result['satisfied_by'] = $index['name'];
|
||||
}
|
||||
|
||||
if ($this->hasPrefix($columns, $withoutKey)) {
|
||||
// UNIQUE 색인은 값이 중복되지 않으므로 그 컬럼만으로 이미 전순서다.
|
||||
// 기본키를 덧붙일 필요가 없고, 붙여도 filesort 가 줄지 않는다.
|
||||
if ($result['satisfied_by'] === null && ($index['unique'] ?? false)) {
|
||||
$result['satisfied_by'] = $index['name'];
|
||||
}
|
||||
|
||||
if ($result['partial_by'] === null) {
|
||||
$result['partial_by'] = $index['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['status'] = match (true) {
|
||||
$result['satisfied_by'] !== null => 'satisfied',
|
||||
$result['partial_by'] !== null => 'tiebreak_missing',
|
||||
default => 'missing',
|
||||
};
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로파일에 선언된 면제 사유를 돌려줍니다.
|
||||
*
|
||||
* 행 수가 고정되거나 상한이 작아 색인이 불필요한 목록은 프로파일에 사유를 적어 면제한다.
|
||||
* 사유 없는 면제를 허용하지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데
|
||||
* 사유가 없으면 나중에 그 판단이 여전히 옳은지 확인할 방법이 없기 때문이다.
|
||||
*
|
||||
* @param array<string, mixed> $options 목록 프로파일 옵션
|
||||
* @return string|null 면제 사유 (없으면 null)
|
||||
*/
|
||||
public function exemptionReason(array $options): ?string
|
||||
{
|
||||
$reason = $options['index_exempt'] ?? null;
|
||||
|
||||
return is_string($reason) && trim($reason) !== '' ? trim($reason) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로파일 목록을 한 번에 점검합니다.
|
||||
*
|
||||
* @param array<string, BenchmarkProfile> $profiles 목록 축 프로파일
|
||||
* @return array<int, array<string, mixed>> 프로파일별 점검 결과
|
||||
*/
|
||||
public function inspectAll(array $profiles): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($profiles as $key => $profile) {
|
||||
$options = $profile->options ?? [];
|
||||
|
||||
$rows[] = array_merge(
|
||||
['profile' => is_string($key) ? $key : $profile->qualifiedKey()],
|
||||
$this->inspect($options),
|
||||
['exemption' => $this->exemptionReason($options)]
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 컬럼이 요구 목록을 선행 프리픽스로 포함하는지 판정합니다.
|
||||
*
|
||||
* @param array<int, string> $columns 인덱스 컬럼 순서
|
||||
* @param array<int, string> $required 요구 선행 컬럼
|
||||
*/
|
||||
private function hasPrefix(array $columns, array $required): bool
|
||||
{
|
||||
if ($required === [] || count($columns) < count($required)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_slice(array_values($columns), 0, count($required)) === array_values($required);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 실행 쿼리 수집기 — 화면/쓰기/배치 축의 쿼리 건수·시간·N+1 후보 산출
|
||||
*
|
||||
* 목록 SELECT 는 빨라도 화면이 느린 대표적 원인이 N+1 이므로, 응답 시간만 재고 끝내면
|
||||
* 원인을 못 찾습니다. 이 수집기는 계측 구간에서 실행된 쿼리를 모아 건수·DB 시간과, 같은
|
||||
* SQL 이 반복 실행된 그룹(N+1 후보)을 함께 돌려줍니다.
|
||||
*
|
||||
* `DB::listen` 은 한 번 등록하면 해제할 수 없으므로, 리스너는 인스턴스당 한 번만 등록하고
|
||||
* 수집 여부는 플래그로 켰다 끕니다 — 계측 구간 밖의 쿼리가 섞이지 않게 합니다.
|
||||
*/
|
||||
class QueryCollector
|
||||
{
|
||||
/**
|
||||
* 같은 SQL 이 이 횟수 이상 반복되면 N+1 후보로 본다
|
||||
*/
|
||||
private const N_PLUS_ONE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* 수집 구간 여부
|
||||
*/
|
||||
private bool $collecting = false;
|
||||
|
||||
/**
|
||||
* 수집된 쿼리 (sql, time)
|
||||
*
|
||||
* @var array<int, array{sql: string, time: float}>
|
||||
*/
|
||||
private array $queries = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
DB::listen(function (QueryExecuted $event) {
|
||||
if (! $this->collecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->queries[] = ['sql' => $event->sql, 'time' => (float) $event->time];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백 실행 구간의 쿼리를 수집합니다.
|
||||
*
|
||||
* @template TReturn
|
||||
*
|
||||
* @param \Closure(): TReturn $callback 계측 대상 작업
|
||||
* @return array{value: TReturn, queries: array<int, array{sql: string, time: float}>} 반환값과 수집 결과
|
||||
*/
|
||||
public function collect(\Closure $callback): array
|
||||
{
|
||||
$this->queries = [];
|
||||
$this->collecting = true;
|
||||
|
||||
try {
|
||||
$value = $callback();
|
||||
} finally {
|
||||
$this->collecting = false;
|
||||
}
|
||||
|
||||
return ['value' => $value, 'queries' => $this->queries];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집된 쿼리를 요약합니다.
|
||||
*
|
||||
* @param array<int, array{sql: string, time: float}> $queries 수집 결과
|
||||
* @return array{count: int, db_ms: float, n_plus_one: array<int, array{count: int, sql: string}>} 요약
|
||||
*/
|
||||
public function summarize(array $queries): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
$grouped[$query['sql']] = ($grouped[$query['sql']] ?? 0) + 1;
|
||||
}
|
||||
|
||||
arsort($grouped);
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ($grouped as $sql => $count) {
|
||||
if ($count < self::N_PLUS_ONE_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = ['count' => $count, 'sql' => $sql];
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => count($queries),
|
||||
'db_ms' => round(array_sum(array_column($queries, 'time')), 2),
|
||||
'n_plus_one' => $candidates,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 계측용 합성 행 시딩기
|
||||
*
|
||||
* 깊은 OFFSET 비용은 행 수와 행 폭이 있어야 재현되므로, 계측 전에 대상 테이블을 원하는
|
||||
* 규모까지 채웁니다. 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰
|
||||
* 채우므로 계측 대상이 늘 때마다 시더를 따라 만들지 않아도 됩니다.
|
||||
*
|
||||
* 합성 값은 실제 부모 행을 가리키지 않으므로 외래키 제약을 일시 해제합니다. 본 시딩이
|
||||
* 재현하려는 것은 대상 테이블의 OFFSET 스캔 비용(행 수·행 폭)이고 조인은 계측 대상이
|
||||
* 아니므로, 부모 무결성 없이 목적에 충분합니다 — 폐기 가능한 계측용 DB 에서만 쓰는 것을
|
||||
* 전제로 하며, 운영 환경 거부는 호출자(`ListAxisRunner`)가 담당합니다.
|
||||
*/
|
||||
class SyntheticSeeder
|
||||
{
|
||||
/**
|
||||
* 한 번에 삽입할 행 수
|
||||
*/
|
||||
private const CHUNK_SIZE = 500;
|
||||
|
||||
/**
|
||||
* 계측용 합성 행을 시딩합니다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @param int $count 시딩 건수
|
||||
* @param array<string, mixed> $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정)
|
||||
* @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total)
|
||||
* @param array<int, string> $requiredColumns nullable 이어도 반드시 채울 컬럼 (계측이 정렬 기준으로 삼는 컬럼)
|
||||
*/
|
||||
public function seed(string $table, int $count, array $overrides = [], ?\Closure $onProgress = null, array $requiredColumns = []): void
|
||||
{
|
||||
$fillable = $this->fillableColumns($table);
|
||||
$required = $this->requiredColumnDefinitions($table, $requiredColumns, $fillable);
|
||||
|
||||
$chunk = [];
|
||||
$inserted = 0;
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$chunk[] = $this->synthesizeRow($table, array_merge($fillable, $required), $overrides, $i);
|
||||
|
||||
if (count($chunk) >= self::CHUNK_SIZE) {
|
||||
$this->insert($table, $chunk);
|
||||
$inserted += count($chunk);
|
||||
$chunk = [];
|
||||
|
||||
if ($onProgress !== null) {
|
||||
$onProgress($inserted, $count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($chunk !== []) {
|
||||
$this->insert($table, $chunk);
|
||||
$inserted += count($chunk);
|
||||
|
||||
if ($onProgress !== null) {
|
||||
$onProgress($inserted, $count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블을 비웁니다.
|
||||
*
|
||||
* 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내립니다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
*/
|
||||
public function truncate(string $table): void
|
||||
{
|
||||
Schema::withoutForeignKeyConstraints(function () use ($table) {
|
||||
DB::table($table)->truncate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 값을 반드시 채워야 하는 컬럼 목록을 반환합니다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @return array<int, array<string, mixed>> Schema::getColumns() 항목 목록
|
||||
*/
|
||||
private function fillableColumns(string $table): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
Schema::getColumns($table),
|
||||
fn (array $column) => ! ($column['auto_increment'] ?? false)
|
||||
&& ! $column['nullable']
|
||||
&& ($column['default'] === null)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측이 정렬 기준으로 삼는 컬럼의 정의를 반환합니다.
|
||||
*
|
||||
* `fillableColumns()` 는 "NOT NULL + 기본값 없음" 만 남기므로, Laravel 기본 `timestamps()`
|
||||
* 처럼 nullable 로 선언된 `created_at` 계열은 여기서 빠진다. 그런데 목록 프로파일 대부분이
|
||||
* `created_at` 으로 정렬하므로, 그대로 두면 합성 행이 전부 NULL 동률이 되어 계측이
|
||||
* 정렬 인덱스가 아니라 tie-break 경로를 재게 된다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @param array<int, string> $names 반드시 채울 컬럼명 목록
|
||||
* @param array<int, array<string, mixed>> $already 이미 채우기로 한 컬럼 목록
|
||||
* @return array<int, array<string, mixed>> 추가로 채울 컬럼 정의 목록
|
||||
*/
|
||||
private function requiredColumnDefinitions(string $table, array $names, array $already): array
|
||||
{
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$covered = array_column($already, 'name');
|
||||
$columns = collect(Schema::getColumns($table))->keyBy('name');
|
||||
|
||||
$extra = [];
|
||||
|
||||
foreach (array_unique($names) as $name) {
|
||||
if (in_array($name, $covered, true) || ! $columns->has($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$column = $columns->get($name);
|
||||
|
||||
// auto increment 키는 DB 가 채운다
|
||||
if ($column['auto_increment'] ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extra[] = $column;
|
||||
}
|
||||
|
||||
return $extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* 합성 행 1건을 만듭니다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @param array<int, array<string, mixed>> $fillable 채워야 하는 컬럼 목록
|
||||
* @param array<string, mixed> $overrides 컬럼별 고정값
|
||||
* @param int $index 행 인덱스 (고유값 생성용)
|
||||
* @return array<string, mixed> 합성 행
|
||||
*/
|
||||
private function synthesizeRow(string $table, array $fillable, array $overrides, int $index): array
|
||||
{
|
||||
$row = [];
|
||||
|
||||
foreach ($fillable as $column) {
|
||||
$row[$column['name']] = array_key_exists($column['name'], $overrides)
|
||||
? $overrides[$column['name']]
|
||||
: $this->synthesizeValue($column, $index);
|
||||
}
|
||||
|
||||
// NULL 허용 컬럼이라 위 루프에서 빠졌더라도, 실제 조회 조건에 쓰이는 컬럼은
|
||||
// 채워야 계측이 화면과 같은 인덱스를 타게 된다.
|
||||
foreach ($overrides as $name => $value) {
|
||||
if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) {
|
||||
$row[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 합성 행을 삽입합니다. (외래키 제약 일시 해제)
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @param array<int, array<string, mixed>> $chunk 삽입할 행 묶음
|
||||
*/
|
||||
private function insert(string $table, array $chunk): void
|
||||
{
|
||||
Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) {
|
||||
DB::table($table)->insert($chunk);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 타입에 맞는 합성 값을 만듭니다.
|
||||
*
|
||||
* @param array<string, mixed> $column Schema::getColumns() 항목
|
||||
* @param int $index 행 인덱스 (고유값 생성용)
|
||||
* @return mixed 합성 값
|
||||
*/
|
||||
private function synthesizeValue(array $column, int $index): mixed
|
||||
{
|
||||
$type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar'));
|
||||
|
||||
return match (true) {
|
||||
str_contains($type, 'int') => $index,
|
||||
str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000,
|
||||
str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0,
|
||||
str_contains($type, 'json') => '{}',
|
||||
// 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다.
|
||||
// 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다.
|
||||
str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index),
|
||||
str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100),
|
||||
default => $this->synthesizeString($column, $index),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다.
|
||||
*
|
||||
* 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입,
|
||||
* 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다.
|
||||
*
|
||||
* @param array<string, mixed> $column Schema::getColumns() 항목
|
||||
* @param int $index 행 인덱스 (고유값 생성용)
|
||||
* @return string 합성 문자열
|
||||
*/
|
||||
private function synthesizeString(array $column, int $index): string
|
||||
{
|
||||
$length = 40;
|
||||
|
||||
// type 은 'varchar(10)' 형태로 선언 길이를 담고 있다
|
||||
if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) {
|
||||
$length = max(1, (int) $m[1]);
|
||||
}
|
||||
|
||||
// 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다
|
||||
if ($length <= 12) {
|
||||
return substr((string) $index, -$length);
|
||||
}
|
||||
|
||||
return substr("bench-{$index}-".Str::random(8), 0, $length);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,15 @@ class AsUnicodeJson implements CastsAttributes
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($value, true);
|
||||
// 이미 배열인 경우 (다시 read 시점에 배열이 들어오는 시나리오 방어)
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
// 스칼라/문자열로 디코드된 경우는 반환 타입(?array) 위반 → null 처리
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns\Seeder;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
|
||||
/**
|
||||
* TranslatableSeederInterface 구현체용 헬퍼 트레이트.
|
||||
*
|
||||
* 시더의 run() 안에서 $this->resolveTranslatedDefaults() 호출 시
|
||||
* 활성 언어팩의 ja/en 등 locale 키가 자동 머지된 entry 배열을 반환.
|
||||
*
|
||||
* @since 7.0.0-beta.5
|
||||
*/
|
||||
trait HasTranslatableSeeder
|
||||
{
|
||||
/**
|
||||
* 활성 언어팩의 다국어 데이터로 머지된 시드 entry 를 반환합니다.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function resolveTranslatedDefaults(): array
|
||||
{
|
||||
return HookManager::applyFilters(
|
||||
$this->resolveTranslationFilterName(),
|
||||
$this->getDefaults(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `seed.{ext}.{entity}.translations` 필터 키를 조립합니다.
|
||||
*/
|
||||
protected function resolveTranslationFilterName(): string
|
||||
{
|
||||
$extension = $this->getExtensionIdentifier();
|
||||
$entity = $this->getTranslatableEntity();
|
||||
|
||||
return $extension !== ''
|
||||
? "seed.{$extension}.{$entity}.translations"
|
||||
: "seed.{$entity}.translations";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\ApiDoc\ResourceFieldDescriber;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* 기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다.
|
||||
*
|
||||
* 파라미터 TODO 는 `api:docgen-backfill-params` 가 채우지만, 응답 필드 TODO 는 전용
|
||||
* 백필 커맨드가 없어 재생성(`api:docgen`)으로만 갱신되었습니다. 실측 서버 없이
|
||||
* 재생성하면 이미 실측된 응답 예시값이 퇴행하므로, 이 커맨드는 재생성 대신 응답 필드
|
||||
* 표의 `<!-- TODO: 설명 -->` 셀만 in-place 치환합니다.
|
||||
*
|
||||
* 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
|
||||
* 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
|
||||
*
|
||||
* 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
|
||||
* 응답 필드 표만 대상으로 하며, 파라미터 표(`<!-- TODO: 용도 -->`)는 건드리지 않습니다.
|
||||
*/
|
||||
class ApiDocBackfillFieldsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'api:docgen-backfill-fields
|
||||
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다';
|
||||
|
||||
/**
|
||||
* @var string 응답 필드 표를 식별하는 고유 헤더
|
||||
*/
|
||||
private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |';
|
||||
|
||||
/**
|
||||
* @var string 응답 필드 설명 TODO 마커
|
||||
*/
|
||||
private const TODO_MARKER = '<!-- TODO: 설명 -->';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$describer = new ResourceFieldDescriber;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$filled = 0;
|
||||
$skipped = 0;
|
||||
$filesChanged = 0;
|
||||
|
||||
foreach ($this->apiDocFiles() as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$result = $this->backfillFile($content, $describer, $filled, $skipped);
|
||||
|
||||
if ($result !== $content) {
|
||||
$filesChanged++;
|
||||
if (! $dryRun) {
|
||||
file_put_contents($file, $result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 API 문서 파일 경로를 수집합니다.
|
||||
*
|
||||
* @return array<int, string> 파일 경로 목록
|
||||
*/
|
||||
private function apiDocFiles(): array
|
||||
{
|
||||
$dirs = array_filter([
|
||||
base_path('docs/backend/api'),
|
||||
...glob(base_path('modules/_bundled/*/docs/api')),
|
||||
...glob(base_path('plugins/_bundled/*/docs/api')),
|
||||
], 'is_dir');
|
||||
|
||||
if ($dirs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
|
||||
$files[] = $f->getRealPath();
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 문서의 응답 필드 표 TODO 를 채웁니다.
|
||||
*
|
||||
* 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 설명 -->` 셀을
|
||||
* ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
|
||||
* 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다.
|
||||
*
|
||||
* @param string $content 문서 내용
|
||||
* @param ResourceFieldDescriber $describer 설명기
|
||||
* @param int $filled 채운 건수 (참조 누적)
|
||||
* @param int $skipped 유지 건수 (참조 누적)
|
||||
* @return string 치환된 문서 내용
|
||||
*/
|
||||
private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string
|
||||
{
|
||||
$lines = explode("\n", $content);
|
||||
$inFieldTable = false;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
// 응답 필드 표 헤더 진입 감지
|
||||
if (str_contains($line, self::FIELD_TABLE_HEADER)) {
|
||||
$inFieldTable = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 표 구분선(| --- | ...) 은 건너뜀
|
||||
if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
|
||||
if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) {
|
||||
$inFieldTable = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 응답 필드 행 파싱: | name | type | `sample` | <!-- TODO: 설명 --> |
|
||||
$cells = array_map('trim', explode('|', trim($line, '| ')));
|
||||
if (count($cells) < 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $type] = [$cells[0], $cells[1]];
|
||||
$desc = $describer->describe($name, $type);
|
||||
|
||||
if ($desc === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
|
||||
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
|
||||
$filled++;
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\ApiDoc\ParameterDescriber;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* 기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다.
|
||||
*
|
||||
* 실측 서버 없이 재생성하면 이미 실측된 응답표·사람 서술이 퇴행하므로, 이 커맨드는
|
||||
* 재생성 대신 파라미터 표의 `<!-- TODO: 용도 -->` 셀만 in-place 치환합니다.
|
||||
* 채움 규칙 SSoT 는 ApiDocScaffolder 와 동일한 ParameterDescriber 입니다 —
|
||||
* 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
|
||||
*
|
||||
* 도메인 특이 파라미터(ParameterDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
|
||||
*/
|
||||
class ApiDocBackfillParamsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'api:docgen-backfill-params
|
||||
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다';
|
||||
|
||||
/**
|
||||
* @var string 파라미터 표를 식별하는 고유 헤더
|
||||
*/
|
||||
private const PARAM_TABLE_HEADER = '| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |';
|
||||
|
||||
/**
|
||||
* @var string 파라미터 용도 TODO 마커
|
||||
*/
|
||||
private const TODO_MARKER = '<!-- TODO: 용도 -->';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$describer = new ParameterDescriber;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$filled = 0;
|
||||
$skipped = 0;
|
||||
$filesChanged = 0;
|
||||
|
||||
foreach ($this->apiDocFiles() as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$result = $this->backfillFile($content, $describer, $filled, $skipped);
|
||||
|
||||
if ($result !== $content) {
|
||||
$filesChanged++;
|
||||
if (! $dryRun) {
|
||||
file_put_contents($file, $result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}파라미터 용도 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 API 문서 파일 경로를 수집합니다.
|
||||
*
|
||||
* @return array<int, string> 파일 경로 목록
|
||||
*/
|
||||
private function apiDocFiles(): array
|
||||
{
|
||||
$dirs = array_filter([
|
||||
base_path('docs/backend/api'),
|
||||
...glob(base_path('modules/_bundled/*/docs/api')),
|
||||
...glob(base_path('plugins/_bundled/*/docs/api')),
|
||||
], 'is_dir');
|
||||
|
||||
if ($dirs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
|
||||
$files[] = $f->getRealPath();
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 문서의 파라미터 표 TODO 를 채웁니다.
|
||||
*
|
||||
* 파라미터 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 용도 -->` 셀을
|
||||
* ParameterDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
|
||||
*
|
||||
* @param string $content 문서 내용
|
||||
* @param ParameterDescriber $describer 설명기
|
||||
* @param int $filled 채운 건수 (참조 누적)
|
||||
* @param int $skipped 유지 건수 (참조 누적)
|
||||
* @return string 치환된 문서 내용
|
||||
*/
|
||||
private function backfillFile(string $content, ParameterDescriber $describer, int &$filled, int &$skipped): string
|
||||
{
|
||||
$lines = explode("\n", $content);
|
||||
$inParamTable = false;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
// 파라미터 표 헤더 진입 감지
|
||||
if (str_contains($line, self::PARAM_TABLE_HEADER)) {
|
||||
$inParamTable = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 표 구분선(| --- | ...) 은 건너뜀
|
||||
if ($inParamTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
|
||||
if ($inParamTable && ! str_starts_with(ltrim($line), '|')) {
|
||||
$inParamTable = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $inParamTable || ! str_contains($line, self::TODO_MARKER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파라미터 행 파싱: | name | location | type | 필수 | 허용값 | <!-- TODO: 용도 --> |
|
||||
$cells = array_map('trim', explode('|', trim($line, '| ')));
|
||||
if (count($cells) < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $location, $type] = [$cells[0], $cells[1], $cells[2]];
|
||||
$desc = $describer->describe($name, $location, $type);
|
||||
|
||||
if ($desc === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
|
||||
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
|
||||
$filled++;
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Services\SettingsService;
|
||||
use App\Support\AssetUrl;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 자산 URL 모드 조회/전환 Artisan 커맨드 (이슈 #486 §9)
|
||||
*
|
||||
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 동적 응답을 가로채는 서버에서는
|
||||
* **관리자 화면 자체가 뜨지 않는다**. "브라우저로 설정을 바꾸세요" 는 순환 참조이므로
|
||||
* CLI 탈출구가 반드시 필요하다.
|
||||
*
|
||||
* ```
|
||||
* php artisan g7:asset-url-mode # 현재 모드 + 진단 안내
|
||||
* php artisan g7:asset-url-mode extensionless # 전환
|
||||
* ```
|
||||
*/
|
||||
class AssetUrlModeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'g7:asset-url-mode
|
||||
{mode? : 전환할 모드 (extension | extensionless). 생략 시 현재 모드만 출력}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '자산 URL 모드를 조회하거나 전환합니다 (정적 최적화 서버 대응)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param SettingsService $settingsService 환경설정 서비스
|
||||
* @param SeoCacheManagerInterface $seoCacheManager SEO 캐시 매니저
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(SettingsService $settingsService, SeoCacheManagerInterface $seoCacheManager): int
|
||||
{
|
||||
$current = AssetUrl::mode();
|
||||
$requested = $this->argument('mode');
|
||||
|
||||
if ($requested === null) {
|
||||
$this->showStatus($current);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (! in_array($requested, [AssetUrl::MODE_EXTENSION, AssetUrl::MODE_EXTENSIONLESS], true)) {
|
||||
$this->error(__('asset_url_mode.invalid', ['mode' => $requested]));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($requested === $current) {
|
||||
$this->info(__('asset_url_mode.already', ['mode' => $current]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$saved = $settingsService->saveSettings([
|
||||
'_tab' => 'general',
|
||||
'general' => ['asset_url_mode' => $requested],
|
||||
]);
|
||||
|
||||
if (! $saved) {
|
||||
$this->error(__('asset_url_mode.save_failed'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
|
||||
// 모드를 바꾸면 그 URL 들이 전부 어긋나므로 함께 비운다 (계획서 §알려진 한계).
|
||||
$seoCacheManager->clearAll();
|
||||
|
||||
$this->info(__('asset_url_mode.switched', ['from' => $current, 'to' => $requested]));
|
||||
$this->line(__('asset_url_mode.seo_cache_cleared'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 모드와 진단 안내를 출력합니다.
|
||||
*
|
||||
* @param string $current 현재 모드
|
||||
*/
|
||||
private function showStatus(string $current): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->line(' '.__('asset_url_mode.current', ['mode' => $current]));
|
||||
$this->line('');
|
||||
|
||||
// audit:allow asset-url-builder-required reason: 두 모드의 URL 형태를 나란히
|
||||
// 보여주는 대조표라 빌더로 생성할 수 없다 (빌더는 현재 모드 하나만 만든다).
|
||||
$rows = $current === AssetUrl::MODE_EXTENSION
|
||||
? [
|
||||
['/api/templates/{id}/routes.json', '/api/templates/{id}/routes'],
|
||||
['/api/modules/bundle.js', '/api/modules/bundle/js'],
|
||||
['/api/templates/assets/{id}/js/a.js', '/api/templates/assets/{id}?file=js/a.js'],
|
||||
]
|
||||
: [
|
||||
['/api/templates/{id}/routes', '/api/templates/{id}/routes.json'],
|
||||
['/api/modules/bundle/js', '/api/modules/bundle.js'],
|
||||
['/api/templates/assets/{id}?file=js/a.js', '/api/templates/assets/{id}/js/a.js'],
|
||||
];
|
||||
|
||||
$this->table(
|
||||
[__('asset_url_mode.table.in_use'), __('asset_url_mode.table.alternative')],
|
||||
$rows,
|
||||
);
|
||||
|
||||
$this->line(' '.__('asset_url_mode.diagnose_title'));
|
||||
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe.js');
|
||||
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe');
|
||||
$this->line('');
|
||||
$this->line(' '.__('asset_url_mode.diagnose_hint'));
|
||||
$this->line('');
|
||||
$this->line(' '.__('asset_url_mode.switch_hint'));
|
||||
$this->line('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Benchmark\BenchmarkProfileRegistry;
|
||||
use App\Benchmark\BenchmarkReporter;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 성능 계측 커맨드 — 목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업 4축
|
||||
*
|
||||
* 계측 대상은 소유자가 선언합니다 — 코어는 `config/benchmark.php`, 확장은
|
||||
* `getBenchmarkProfiles()` 오버라이드입니다. 커맨드에 대상을 하드코딩하지 않는 이유는,
|
||||
* 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문입니다.
|
||||
*
|
||||
* 축별 실행은 `App\Benchmark\Axes\*` 실행기가 담당하고, 이 커맨드는 프로파일 선택·옵션
|
||||
* 해석·출력만 합니다.
|
||||
*
|
||||
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
|
||||
* 기다려 출력이 유실된다.
|
||||
*/
|
||||
class BenchCommand extends Command
|
||||
{
|
||||
protected $signature = 'g7:bench
|
||||
{--profile=* : 계측할 프로파일 키 (짧은 키 또는 출처/키 형태). 다중 지정 가능}
|
||||
{--axis= : 축 단위 실행 (list|screen|write|batch)}
|
||||
{--all : 등록된 모든 프로파일 실행}
|
||||
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분, list 축)}
|
||||
{--runs=3 : 측정 횟수 (첫 회는 버림)}
|
||||
{--per-page=20 : 목록/화면 1페이지 건수}
|
||||
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)}
|
||||
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
|
||||
{--explain : 각 OFFSET 의 실행 계획도 함께 수집 (list 축)}
|
||||
{--as= : 화면 계측에 사용할 기존 계정 (ID 또는 이메일). 미지정 시 계측용 임시 계정}
|
||||
{--allow-write : 데이터를 변경하는 축(write/batch/비-GET 화면) 실행 허용}
|
||||
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
|
||||
{--json : 기계 판독용 JSON 출력}
|
||||
{--report= : 마크다운 리포트 저장 경로 (값 없이 --report 만 주면 storage/app/benchmarks/ 아래 자동 생성)}
|
||||
{--list-profiles : 등록된 프로파일 목록 출력}';
|
||||
|
||||
protected $description = '목록 조회·화면 응답·쓰기·배치 4축 성능을 계측합니다 (대상은 코어 config + 확장 선언에서 수집)';
|
||||
|
||||
protected $aliases = ['g7:bench:pagination'];
|
||||
|
||||
/**
|
||||
* 리포트 기본 저장 디렉토리 (저장소 미추적 — 계측값은 실행 머신 사양에 종속되므로 축적하지 않음)
|
||||
*/
|
||||
private const REPORT_DIR = 'app/benchmarks';
|
||||
|
||||
/**
|
||||
* @param BenchmarkProfileRegistry $registry 프로파일 레지스트리
|
||||
* @param BenchmarkReporter $reporter 결과 출력기
|
||||
* @param iterable<BenchmarkAxisRunner> $runners 축 실행기 목록 (컨테이너 태그 주입)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BenchmarkProfileRegistry $registry,
|
||||
private readonly BenchmarkReporter $reporter,
|
||||
private readonly iterable $runners,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->applyDatabaseOverride();
|
||||
|
||||
if ($this->option('list-profiles')) {
|
||||
return $this->listProfiles();
|
||||
}
|
||||
|
||||
[$profiles, $selectionError] = $this->selectProfiles();
|
||||
|
||||
if ($selectionError !== null) {
|
||||
$this->error($selectionError);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($profiles === []) {
|
||||
$this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$options = $this->runOptions();
|
||||
|
||||
// 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다
|
||||
if (! $this->option('json')) {
|
||||
foreach ($this->reporter->environment() as $name => $value) {
|
||||
$this->line(sprintf(' %-14s %s', $name, $value));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->reportCollectionWarnings();
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$results[] = $this->runProfile($profile, $options);
|
||||
}
|
||||
|
||||
return $this->output($results, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측용 데이터베이스 지정을 반영합니다.
|
||||
*
|
||||
* 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다.
|
||||
* config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이
|
||||
* 유일한 수단입니다.
|
||||
*/
|
||||
private function applyDatabaseOverride(): void
|
||||
{
|
||||
$database = (string) $this->option('database');
|
||||
|
||||
if ($database === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$connection = config('database.default');
|
||||
|
||||
config([
|
||||
"database.connections.{$connection}.database" => $database,
|
||||
"database.connections.{$connection}.write.database" => $database,
|
||||
"database.connections.{$connection}.read.database" => $database,
|
||||
]);
|
||||
|
||||
DB::purge($connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 프로파일 목록을 출력합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function listProfiles(): int
|
||||
{
|
||||
$profiles = $this->registry->all();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)),
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['프로파일', '축', '출처', '설명'],
|
||||
array_map(fn (BenchmarkProfile $profile) => [
|
||||
$profile->qualifiedKey(),
|
||||
$profile->axis->value,
|
||||
$profile->sourceKind,
|
||||
$profile->label ?? '-',
|
||||
], array_values($profiles))
|
||||
);
|
||||
|
||||
$this->reportCollectionWarnings();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행할 프로파일을 고릅니다.
|
||||
*
|
||||
* @return array{0: array<int, BenchmarkProfile>, 1: string|null} [실행 대상, 실패 사유]
|
||||
*/
|
||||
private function selectProfiles(): array
|
||||
{
|
||||
$keys = array_filter((array) $this->option('profile'), 'strlen');
|
||||
|
||||
if ($keys !== []) {
|
||||
$selected = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
[$profile, $error] = $this->registry->resolve((string) $key);
|
||||
|
||||
if ($error !== null) {
|
||||
return [[], $error];
|
||||
}
|
||||
|
||||
$selected[] = $profile;
|
||||
}
|
||||
|
||||
return [$selected, null];
|
||||
}
|
||||
|
||||
$axisOption = (string) $this->option('axis');
|
||||
|
||||
if ($axisOption !== '') {
|
||||
$axis = BenchmarkAxis::tryFrom($axisOption);
|
||||
|
||||
if ($axis === null) {
|
||||
return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')'];
|
||||
}
|
||||
|
||||
return [array_values($this->registry->byAxis($axis)), null];
|
||||
}
|
||||
|
||||
return [$this->option('all') ? array_values($this->registry->all()) : [], null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드 옵션을 실행 옵션으로 해석합니다.
|
||||
*
|
||||
* @return BenchmarkRunOptions 실행 옵션
|
||||
*/
|
||||
private function runOptions(): BenchmarkRunOptions
|
||||
{
|
||||
$asUser = (string) $this->option('as');
|
||||
|
||||
return new BenchmarkRunOptions(
|
||||
offsets: $this->parseOffsets(),
|
||||
runs: max(1, (int) $this->option('runs')),
|
||||
seed: max(0, (int) $this->option('seed')),
|
||||
fresh: (bool) $this->option('fresh'),
|
||||
explain: (bool) $this->option('explain'),
|
||||
allowWrite: (bool) $this->option('allow-write'),
|
||||
asUser: $asUser === '' ? null : $asUser,
|
||||
perPage: max(1, (int) $this->option('per-page')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OFFSET 목록을 파싱합니다.
|
||||
*
|
||||
* @return array<int, int> 오름차순 정렬된 OFFSET 목록
|
||||
*/
|
||||
private function parseOffsets(): array
|
||||
{
|
||||
$offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen'));
|
||||
sort($offsets);
|
||||
|
||||
return $offsets === [] ? [0] : $offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로파일 1건을 계측합니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 대상 프로파일
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @return BenchmarkResult 계측 결과
|
||||
*/
|
||||
private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult
|
||||
{
|
||||
$runner = $this->runnerFor($profile->axis);
|
||||
|
||||
if ($runner === null) {
|
||||
return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}");
|
||||
}
|
||||
|
||||
if (! $this->option('json')) {
|
||||
$this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label()));
|
||||
}
|
||||
|
||||
$onProgress = $this->option('json')
|
||||
? null
|
||||
: fn (string $message) => $this->line(' '.$message);
|
||||
|
||||
return $runner->run($profile, $options, $onProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 축에 대응하는 실행기를 찾습니다.
|
||||
*
|
||||
* @param BenchmarkAxis $axis 대상 축
|
||||
* @return BenchmarkAxisRunner|null 실행기 (없으면 null)
|
||||
*/
|
||||
private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner
|
||||
{
|
||||
foreach ($this->runners as $runner) {
|
||||
if ($runner->axis() === $axis) {
|
||||
return $runner;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무시된 프로파일 선언을 알립니다.
|
||||
*
|
||||
* 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다.
|
||||
*/
|
||||
private function reportCollectionWarnings(): void
|
||||
{
|
||||
if ($this->option('json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->registry->warnings() as $warning) {
|
||||
$this->warn('무시된 프로파일 선언 — '.$warning);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계측 결과를 출력합니다.
|
||||
*
|
||||
* @param array<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function output(array $results, BenchmarkRunOptions $options): int
|
||||
{
|
||||
$warnings = $this->registry->warnings();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line($this->reporter->toJson($results, $options, $warnings));
|
||||
} else {
|
||||
foreach ($results as $result) {
|
||||
$this->newLine();
|
||||
$this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label()));
|
||||
|
||||
if ($result->skipped) {
|
||||
$this->warn(' 측정하지 않음: '.$result->skipReason);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->table($result->headers, $result->rows);
|
||||
|
||||
foreach ($result->notes as $note) {
|
||||
$this->line(' '.$note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->wantsReport()) {
|
||||
$path = $this->writeReport($results, $options, $warnings);
|
||||
$this->newLine();
|
||||
$this->info('리포트 저장: '.$path);
|
||||
}
|
||||
|
||||
// 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다
|
||||
$measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped);
|
||||
|
||||
return $measured === [] ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 출력이 요청되었는지 판정합니다.
|
||||
*
|
||||
* `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을
|
||||
* 구분할 수 없으므로 원시 입력을 확인합니다.
|
||||
*
|
||||
* @return bool 리포트 출력 여부
|
||||
*/
|
||||
private function wantsReport(): bool
|
||||
{
|
||||
return $this->input->hasParameterOption('--report')
|
||||
|| $this->input->hasParameterOption('--report=')
|
||||
|| (string) $this->option('report') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 리포트를 저장합니다.
|
||||
*
|
||||
* @param array<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $warnings 프로파일 수집 경고
|
||||
* @return string 저장된 절대 경로
|
||||
*/
|
||||
private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string
|
||||
{
|
||||
$given = (string) $this->option('report');
|
||||
|
||||
$path = $given !== ''
|
||||
? $given
|
||||
: storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md');
|
||||
|
||||
$directory = dirname($path);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings));
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -31,11 +31,13 @@ class BroadcastDashboardResources extends Command
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param DashboardService $dashboardService 대시보드 서비스
|
||||
* @return int
|
||||
*/
|
||||
public function handle(DashboardService $dashboardService): int
|
||||
{
|
||||
broadcast(new DashboardUpdated('resources', $dashboardService->getSystemResources()));
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.resources.updated', [
|
||||
'type' => 'resources',
|
||||
'data' => $dashboardService->getSystemResources(),
|
||||
]);
|
||||
|
||||
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 확장 프론트엔드 병합 번들 파일을 정리하는 커맨드
|
||||
*
|
||||
* 번들 캐시 파일(`storage/app/ext-bundles/{type}.{version}.{js|css}`)은 Laravel
|
||||
* 캐시 스토어 밖 파일시스템이라 version bump 나 cache:clear 가 구버전 파일을
|
||||
* 지우지 않는다(TTL/GC 없음). 이 커맨드가 현재 version 외의 구파일을 삭제한다.
|
||||
* (`CleanupLayoutPreviewsCommand` 파일 산출물 GC 패턴 미러)
|
||||
*/
|
||||
class CleanupExtensionBundlesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-bundles:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '오래된 확장 프론트엔드 병합 번들 파일(구 version)을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionBundleService $service 번들 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionBundleService $service): int
|
||||
{
|
||||
$currentVersion = $service->getCurrentVersion();
|
||||
$deleted = $service->cleanupStaleBundles($currentVersion);
|
||||
|
||||
$this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 부트스트랩 리소스 정적 게시 디렉토리를 정리하는 커맨드
|
||||
*
|
||||
* 게시 디렉토리(`public/build/ext/{version}/`)는 캐시 스토어 밖 파일시스템이라
|
||||
* version bump 가 구버전 디렉토리를 지우지 않는다. 게시 성공 직후 인라인 GC 가
|
||||
* 돌지만, 게시가 오래 없거나 실패한 환경의 잔존물을 이 커맨드가 회수한다.
|
||||
* (`CleanupExtensionBundlesCommand` 파일 산출물 GC 패턴 미러, #122)
|
||||
*/
|
||||
class CleanupExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '오래된 부트스트랩 리소스 정적 게시 디렉토리(구 version)를 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
$deleted = $service->cleanup();
|
||||
|
||||
$this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 빌드 산출물 정리 Trait
|
||||
*
|
||||
* 확장이 구동에 필요한 제3자 자산을 `dist/vendor/` 에 동봉하면서, vite 의
|
||||
* `emptyOutDir: true` 를 그대로 둘 수 없게 됐다 — 매 빌드마다 동봉 자산이 삭제된다.
|
||||
* 각 확장의 vite config 를 `emptyOutDir: false` 로 바꾸는 대신, **정리 책임을 빌드
|
||||
* 커맨드로 옮긴다.** 확장마다 정리 규칙을 복제하면 한 곳만 빠져도 그 확장에서만
|
||||
* 해시 청크가 누적되고, 그것은 배포본이 커진 뒤에야 드러난다.
|
||||
*
|
||||
* 정리 범위는 종전 `emptyOutDir: true` 와 같다 — `dist/` 전체를 비우되 **보존 대상만
|
||||
* 남긴다.** "빌드가 만드는 것만 골라 지운다" 로 좁히면 소스에서 사라진 파일의 산출물이
|
||||
* `dist/` 에 stale 로 남는다.
|
||||
*/
|
||||
trait PrunesBuildOutput
|
||||
{
|
||||
/**
|
||||
* 빌드 산출물 디렉토리에서 보존 대상을 제외한 전부를 삭제합니다.
|
||||
*
|
||||
* 감시(watch) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
|
||||
* 참조 중인 파일이 사라진다.
|
||||
*
|
||||
* **활성 디렉토리는 정리하지 않는다.** prune 은 빌드 *전에* 실행되므로, 웹이 서빙
|
||||
* 중인 `dist/` 를 비우면 prune~빌드 완료 구간 전체가 서빙 공백이 된다(빌드가 실패하면
|
||||
* 빈 채로 남는다). 확장 개발은 `_bundled` 에서 수행하고 활성 반영은 `{type}:update`
|
||||
* 가 담당하므로, 활성 경로 빌드는 예외적 경로다 — 그 경우 stale 산출물이 누적되는
|
||||
* 것을 감수하고 서빙 연속성을 택한다. 외부 확장처럼 `_bundled` 가 없어 활성 빌드가
|
||||
* 유일한 경로인 경우에도 사이트가 끊기지 않는다.
|
||||
*
|
||||
* @param string $buildPath 확장 루트 경로 (`dist/` 의 부모)
|
||||
* @param array<int, string> $preserve 삭제하지 않을 최상위 항목명
|
||||
* @return array<int, string> 삭제한 최상위 항목명 목록
|
||||
*/
|
||||
private function pruneBuildOutput(string $buildPath, array $preserve = ['vendor']): array
|
||||
{
|
||||
$distPath = rtrim($buildPath, '/\\').DIRECTORY_SEPARATOR.'dist';
|
||||
|
||||
if (! File::isDirectory($distPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $this->isBundledSourcePath($buildPath)) {
|
||||
$this->warn(
|
||||
' ⚠️ 활성 디렉토리 빌드 — 이전 산출물을 정리하지 않습니다 '
|
||||
.'(정리하면 빌드 완료까지 서빙이 끊깁니다). stale 산출물이 누적될 수 있으니 '
|
||||
.'개발은 _bundled 에서 하고 활성 반영은 update 커맨드로 하세요.'
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$removed = [];
|
||||
|
||||
foreach (new \FilesystemIterator($distPath, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
$name = $item->getBasename();
|
||||
|
||||
if (in_array($name, $preserve, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleted = $item->isDir()
|
||||
? File::deleteDirectory($item->getPathname())
|
||||
: File::delete($item->getPathname());
|
||||
|
||||
if ($deleted) {
|
||||
$removed[] = $name;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 삭제 실패는 빌드를 막을 이유가 못 된다 — vite 가 같은 경로를 덮어쓴다.
|
||||
// 다만 조용히 넘기면 stale 산출물이 남은 것을 알 수 없으므로 기록한다.
|
||||
Log::warning('빌드 산출물 정리 실패 (다음 빌드에서 재시도)', [
|
||||
'path' => $item->getPathname(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 경로가 소스 디렉토리(`_bundled` / `_pending`)인지 판정합니다.
|
||||
*
|
||||
* 이 둘은 웹이 서빙하지 않는 소스 보관소라 비워도 서빙 공백이 없다. 그 밖의 경로는
|
||||
* 활성 디렉토리(= 서빙 중)로 본다 — 판정을 뒤집어 두면(활성 목록을 열거하면) 새로운
|
||||
* 배치가 생길 때마다 조용히 활성 경로가 정리 대상이 된다.
|
||||
*
|
||||
* @param string $buildPath 확장 루트 경로
|
||||
* @return bool 소스 디렉토리 여부
|
||||
*/
|
||||
private function isBundledSourcePath(string $buildPath): bool
|
||||
{
|
||||
$normalized = str_replace('\\', '/', $buildPath);
|
||||
|
||||
return str_contains($normalized, '/_bundled/') || str_contains($normalized, '/_pending/');
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ class ConvertJsonUnicodeEscapes extends Command
|
||||
/**
|
||||
* 테이블 존재 여부를 확인합니다.
|
||||
*
|
||||
* @param string $table 테이블명 (프리픽스 포함)
|
||||
* @param string $table 테이블명 (프리픽스 포함)
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function tableExists(string $table): bool
|
||||
@@ -126,10 +126,10 @@ class ConvertJsonUnicodeEscapes extends Command
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼을 변환합니다.
|
||||
*
|
||||
* @param string $table 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @param bool $dryRun 드라이런 모드
|
||||
* @param string $table 테이블명
|
||||
* @param string $column 컬럼명
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @param bool $dryRun 드라이런 모드
|
||||
* @return array{int, int} [변환 건수, 스킵 건수]
|
||||
*/
|
||||
private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array
|
||||
@@ -152,11 +152,13 @@ class ConvertJsonUnicodeEscapes extends Command
|
||||
|
||||
$this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중...");
|
||||
|
||||
// chunkById(키셋 순회) 필수 — 콜백이 필터 조건 컬럼을 UTF-8 로 다시 써서 처리된
|
||||
// 행이 `LIKE '%\u%'` 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
|
||||
// 아직 변환되지 않은 행을 건너뛴다. (대상 테이블 8종 모두 `id` auto-increment PK)
|
||||
DB::table($table)
|
||||
->whereNotNull($column)
|
||||
->where($column, 'LIKE', '%\\\\u%')
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
|
||||
->chunkById($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
|
||||
foreach ($rows as $row) {
|
||||
$original = $row->{$column};
|
||||
$decoded = json_decode($original, true);
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class BuildCoreCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -82,26 +85,151 @@ class BuildCoreCommand extends Command
|
||||
*/
|
||||
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
|
||||
{
|
||||
$buildCommand = ['npm', 'run'];
|
||||
|
||||
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
|
||||
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
|
||||
if ($watchMode) {
|
||||
// build:core는 watch 모드가 없으므로 dev 사용
|
||||
$buildCommand[] = 'dev';
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
|
||||
$this->line(' Ctrl+C로 종료할 수 있습니다.');
|
||||
} else {
|
||||
$buildCommand[] = 'build:core';
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
|
||||
$buildEnv = $this->buildEnv($productionMode);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
}
|
||||
|
||||
return $result;
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
|
||||
return $editorResult;
|
||||
}
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
|
||||
return $devtoolsResult;
|
||||
}
|
||||
|
||||
// ── 4) 개발 대시보드 CSS (자체 제공 — 종전 Tailwind Play CDN 대체) ──
|
||||
$this->info('🔨 코어 빌드 시작 (개발 대시보드 CSS)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$dashboardResult = $this->runNpmCommand(['npm', 'run', 'build:core-devdashboard'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($dashboardResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 개발 대시보드 CSS 빌드 실패');
|
||||
|
||||
return $dashboardResult;
|
||||
}
|
||||
|
||||
$this->pruneStaleSourceMaps($productionMode);
|
||||
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로덕션 빌드 후 `public/build/core/` 에 남은 소스맵을 제거합니다.
|
||||
*
|
||||
* 프로덕션 빌드는 `G7_BUILD_SOURCEMAP=0` 으로 맵을 **만들지 않을 뿐**, 이전 개발 빌드가
|
||||
* 남긴 맵을 지우지는 않는다. 그 디렉토리는 웹루트라 남아 있는 맵은 웹서버가 그대로
|
||||
* 서빙하고, 맵에는 원본 코드 전문(`sourcesContent`)이 담긴다 — 확장자 화이트리스트가
|
||||
* 막아 주는 확장 에셋과 달리 이 경로는 정적 서빙이라 통과한다.
|
||||
*
|
||||
* 종전에는 루트 `npm run build` 의 `emptyOutDir` 이 디렉토리를 통째로 비우면서 이 맵들을
|
||||
* 함께 지웠다. 그 동작은 서빙 중인 코어 번들·게시본까지 지우는 결함이라 껐으므로(#122),
|
||||
* 소스맵 정리 책임을 빌드 커맨드가 명시적으로 넘겨받는다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
*/
|
||||
private function pruneStaleSourceMaps(bool $productionMode): void
|
||||
{
|
||||
if (! $productionMode) {
|
||||
// 로컬 빌드는 디버깅을 위해 맵을 의도적으로 생성한다 — 지우면 그 목적이 사라진다.
|
||||
return;
|
||||
}
|
||||
|
||||
$removed = [];
|
||||
|
||||
foreach (glob(public_path('build/core').DIRECTORY_SEPARATOR.'*.map') ?: [] as $map) {
|
||||
if (@unlink($map)) {
|
||||
$removed[] = basename($map);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->warn(' ⚠️ 소스맵 삭제 실패 (수동 제거 필요): '.$map);
|
||||
}
|
||||
|
||||
if ($removed !== []) {
|
||||
$this->line(' 🧹 잔존 소스맵 제거: '.implode(', ', $removed));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
||||
*
|
||||
* @param string $projectPath 프로젝트 경로
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runWatchBundles(string $projectPath): int
|
||||
{
|
||||
// 엔진 + 편집기 + DevTools + 개발 대시보드 CSS 를 각각 vite --watch 로 병렬 감시.
|
||||
// 1회 빌드가 굽는 산출물과 같은 집합이어야 한다 — 한쪽만 빠지면 감시 모드에서
|
||||
// 그 산출물만 조용히 stale 해진다.
|
||||
$bundles = [
|
||||
'engine' => ['npm', 'run', 'build:core-watch'],
|
||||
'editor' => ['npm', 'run', 'build:core-editor-watch'],
|
||||
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
|
||||
'dashboard' => ['npm', 'run', 'build:core-devdashboard-watch'],
|
||||
];
|
||||
|
||||
/** @var array<string, Process> $processes */
|
||||
$processes = [];
|
||||
|
||||
foreach ($bundles as $label => $command) {
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$command = array_merge(['cmd', '/c'], $command);
|
||||
}
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($projectPath);
|
||||
$process->setTimeout(null);
|
||||
$process->start(function ($type, $buffer) use ($label) {
|
||||
$this->output->write("[{$label}] ".$buffer);
|
||||
});
|
||||
|
||||
$processes[$label] = $process;
|
||||
}
|
||||
|
||||
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
|
||||
do {
|
||||
$anyRunning = false;
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$anyRunning = true;
|
||||
}
|
||||
}
|
||||
usleep(100000); // 100ms
|
||||
} while ($anyRunning);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,11 +253,19 @@ class BuildCoreCommand extends Command
|
||||
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$projectPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->pruneStaleSourceMaps($productionMode);
|
||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||
$this->showFullBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -150,22 +286,25 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$this->line(' 빌드 결과:');
|
||||
|
||||
// template-engine.min.js 확인
|
||||
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
|
||||
$engineFile = $corePath.'/template-engine.min.js';
|
||||
if (file_exists($engineFile)) {
|
||||
$fileSize = number_format(filesize($engineFile) / 1024, 2);
|
||||
$this->line(" - template-engine.min.js ({$fileSize} KB)");
|
||||
}
|
||||
|
||||
// lang 파일 확인
|
||||
$langPath = $corePath.'/lang';
|
||||
if (is_dir($langPath)) {
|
||||
$langFiles = glob($langPath.'/*.json');
|
||||
foreach ($langFiles as $langFile) {
|
||||
$fileName = 'lang/'.basename($langFile);
|
||||
$fileSize = number_format(filesize($langFile) / 1024, 2);
|
||||
$this->line(" - {$fileName} ({$fileSize} KB)");
|
||||
}
|
||||
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
|
||||
$editorFile = $corePath.'/layout-editor.min.js';
|
||||
if (file_exists($editorFile)) {
|
||||
$fileSize = number_format(filesize($editorFile) / 1024, 2);
|
||||
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
|
||||
}
|
||||
|
||||
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
|
||||
$devtoolsFile = $corePath.'/devtools.min.js';
|
||||
if (file_exists($devtoolsFile)) {
|
||||
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
|
||||
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,15 +374,30 @@ class BuildCoreCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -252,6 +406,11 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core\Concerns;
|
||||
|
||||
use App\Console\Commands\Core\ExecuteBundledUpdatesCommand;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Search\SearchIndexMaintenanceManager;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 코어 업데이트 완료 후 _bundled 번들에 신버전이 포함된
|
||||
* 모듈/플러그인/템플릿을 일괄 업데이트하는 대화형 프롬프트.
|
||||
*
|
||||
* CoreUpdateCommand 에서 사용되며, 다음 단계로 동작한다.
|
||||
* 1) 각 확장 매니저의 checkAllXxxForUpdates() 로 번들 업데이트 감지
|
||||
* 2) 감지 목록 표시 + 일괄 업데이트 진행 여부 확인
|
||||
* 3) 동의 시 전역 레이아웃 전략(overwrite|keep) 1회 질의
|
||||
* 4) 확장별 예외 변경 질의 (choice 로 다중 선택)
|
||||
* 5) 각 확장 매니저의 updateXxx() 호출
|
||||
* 6) 결과 요약 출력
|
||||
*
|
||||
* --force 옵션이 지정된 경우: 프롬프트 스킵 + 전역 전략 'overwrite' 로 즉시 실행.
|
||||
*
|
||||
* 본 트레이트를 사용하는 Command 는 HasUnifiedConfirm 트레이트도 함께 사용해야 한다
|
||||
* (yes/no 입력 정규화 및 재질문 루프 제공).
|
||||
*/
|
||||
trait BundledExtensionUpdatePrompt
|
||||
{
|
||||
/**
|
||||
* 번들 업데이트 목록 수집.
|
||||
*
|
||||
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
|
||||
*/
|
||||
protected function collectBundledUpdates(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
): array {
|
||||
// CoreUpdateService::collectBundledExtensionUpdates() 를 통해 _bundled manifest 버전을
|
||||
// DB 현재 버전과 직접 비교한다. Manager::checkXxxUpdate() 의 "GitHub 엄격 우선" 정책을
|
||||
// 우회하여 GitHub 미릴리스 상태에서도 _bundled 신버전을 정확히 감지.
|
||||
$extUpdates = app(CoreUpdateService::class)->collectBundledExtensionUpdates();
|
||||
|
||||
// 언어팩도 동일 패턴으로 _bundled vs DB 직접 비교 (LanguagePackService::collectBundledLangPackUpdates).
|
||||
$extUpdates['lang_packs'] = app(LanguagePackService::class)->collectBundledLangPackUpdates();
|
||||
|
||||
return $extUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 업데이트 UX 를 실행하고 결과를 반환한다.
|
||||
*
|
||||
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
|
||||
*/
|
||||
protected function runBundledExtensionUpdatePrompt(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
bool $force,
|
||||
): array {
|
||||
$updates = $this->collectBundledUpdates($moduleManager, $pluginManager, $templateManager);
|
||||
$updates['lang_packs'] = $updates['lang_packs'] ?? [];
|
||||
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']) + count($updates['lang_packs']);
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('활성 확장이 최신 번들과 일치합니다.');
|
||||
|
||||
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('번들에 새 버전이 포함된 확장이 감지되었습니다:');
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$this->line(" [모듈] {$m['identifier']} {$m['current_version']} → {$m['latest_version']}");
|
||||
}
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$this->line(" [플러그인] {$p['identifier']} {$p['current_version']} → {$p['latest_version']}");
|
||||
}
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$this->line(" [템플릿] {$t['identifier']} {$t['current_version']} → {$t['latest_version']}");
|
||||
}
|
||||
foreach ($updates['lang_packs'] as $lp) {
|
||||
$this->line(" [언어팩] {$lp['identifier']} {$lp['current_version']} → {$lp['latest_version']}");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $force && ! $this->unifiedConfirm('일괄 업데이트를 진행하시겠습니까?', true)) {
|
||||
$this->info('일괄 업데이트를 건너뜁니다.');
|
||||
|
||||
return ['success' => 0, 'failed' => 0, 'skipped' => $total, 'has_updates' => true];
|
||||
}
|
||||
|
||||
// 전역 레이아웃 전략
|
||||
$globalStrategy = 'overwrite';
|
||||
if (! $force) {
|
||||
$answer = $this->choice(
|
||||
'전역 레이아웃 전략을 선택하세요',
|
||||
['overwrite' => '모든 레이아웃을 번들로 덮어쓰기', 'keep' => '사용자가 수정한 레이아웃은 보존'],
|
||||
'overwrite',
|
||||
);
|
||||
$globalStrategy = $answer;
|
||||
}
|
||||
|
||||
// 확장별 전략 오버라이드
|
||||
$strategies = $this->collectPerExtensionStrategies($updates, $globalStrategy, $force);
|
||||
|
||||
// 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 운영 중인 사이트에서는
|
||||
// 기본값(아니오)을 유지하고 유지보수 시간에 별도로 수행하는 것이 안전하다.
|
||||
$rebuildSearchIndex = $this->askRebuildSearchIndex($force);
|
||||
|
||||
// 일괄 실행
|
||||
return $this->executeBulkUpdate(
|
||||
$moduleManager,
|
||||
$pluginManager,
|
||||
$templateManager,
|
||||
$updates,
|
||||
$strategies,
|
||||
$rebuildSearchIndex,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장별 전략 오버라이드 수집.
|
||||
*
|
||||
* @param array $updates collectBundledUpdates() 반환값
|
||||
* @return array<string, string> key: "{type}:{identifier}", value: strategy
|
||||
*/
|
||||
private function collectPerExtensionStrategies(array $updates, string $globalStrategy, bool $force): array
|
||||
{
|
||||
// lang_packs 는 layout 이 없어 overwrite/keep strategy 가 의미 없으므로 의도적으로 제외.
|
||||
// 매니페스트에는 strategy 없이 그대로 전달되어 ExecuteBundledUpdatesCommand 가 일괄 처리.
|
||||
$strategies = [];
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
foreach ($updates[$type] as $ext) {
|
||||
$strategies["{$type}:{$ext['identifier']}"] = $globalStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
if (! $this->unifiedConfirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
$choices = [];
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$choices[] = "[모듈] {$m['identifier']}";
|
||||
}
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$choices[] = "[플러그인] {$p['identifier']}";
|
||||
}
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$choices[] = "[템플릿] {$t['identifier']}";
|
||||
}
|
||||
|
||||
$overrideStrategy = $globalStrategy === 'overwrite' ? 'keep' : 'overwrite';
|
||||
|
||||
$this->info("예외 확장은 '{$overrideStrategy}' 전략이 적용됩니다.");
|
||||
$selected = $this->ask('예외 확장 선택 (쉼표로 구분, 빈 값이면 건너뛰기)', '');
|
||||
|
||||
if (trim((string) $selected) === '') {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
$lookup = [];
|
||||
$idx = 0;
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
foreach ($updates[$type] as $ext) {
|
||||
$lookup[$choices[$idx]] = "{$type}:{$ext['identifier']}";
|
||||
$idx++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_map('trim', explode(',', (string) $selected)) as $choice) {
|
||||
if (isset($lookup[$choice])) {
|
||||
$strategies[$lookup[$choice]] = $overrideStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 일괄 업데이트 실행.
|
||||
*
|
||||
* 구조 fix (beta.4 도입): 부모(`core:update`) 프로세스의 stale memory 가 신버전 sync
|
||||
* 메서드를 호출하지 못하던 결함의 영구 차단. 사용자 선택을 매니페스트로 직렬화한 후
|
||||
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 spawn 하여 실행한다 (자식은
|
||||
* 디스크의 fresh 코어 코드 로드).
|
||||
*
|
||||
* proc_open 미지원 / 실패 환경에서는 in-process fallback 으로 안전하게 전환 (기존 흐름).
|
||||
*
|
||||
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
|
||||
*/
|
||||
private function executeBulkUpdate(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
array $updates,
|
||||
array $strategies,
|
||||
bool $rebuildSearchIndex = false,
|
||||
): array {
|
||||
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
|
||||
// 운영자의 선택을 매니페스트에 실어 spawn 자식과 in-process fallback 이 같은 결정을 따르게 한다
|
||||
$manifest['rebuild_search_index'] = $rebuildSearchIndex;
|
||||
if ($this->bundledManifestIsEmpty($manifest)) {
|
||||
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('── 일괄 업데이트 실행 ──');
|
||||
|
||||
$spawnResult = $this->spawnBundledUpdates($manifest);
|
||||
if ($spawnResult !== null) {
|
||||
return [
|
||||
'success' => $spawnResult['success'],
|
||||
'failed' => $spawnResult['failed'],
|
||||
'skipped' => 0,
|
||||
'has_updates' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// proc_open 미지원 / spawn 실패 — in-process fallback
|
||||
$this->warn('별도 프로세스 spawn 실패 — in-process fallback 으로 전환합니다.');
|
||||
|
||||
return $this->executeBulkUpdateInProcess(
|
||||
$moduleManager,
|
||||
$pluginManager,
|
||||
$templateManager,
|
||||
$updates,
|
||||
$strategies,
|
||||
$rebuildSearchIndex,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 선택을 spawn 자식에 전달할 매니페스트 형식으로 직렬화.
|
||||
*
|
||||
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
|
||||
*/
|
||||
private function buildBundledUpdateManifest(array $updates, array $strategies): array
|
||||
{
|
||||
$manifest = [
|
||||
'modules' => [],
|
||||
'plugins' => [],
|
||||
'templates' => [],
|
||||
'lang_packs' => [],
|
||||
];
|
||||
|
||||
foreach ($updates['modules'] ?? [] as $m) {
|
||||
$id = $m['identifier'];
|
||||
$manifest['modules'][] = [
|
||||
'identifier' => $id,
|
||||
'strategy' => $strategies["modules:{$id}"] ?? 'overwrite',
|
||||
];
|
||||
}
|
||||
foreach ($updates['plugins'] ?? [] as $p) {
|
||||
$id = $p['identifier'];
|
||||
$manifest['plugins'][] = [
|
||||
'identifier' => $id,
|
||||
'strategy' => $strategies["plugins:{$id}"] ?? 'overwrite',
|
||||
];
|
||||
}
|
||||
foreach ($updates['templates'] ?? [] as $t) {
|
||||
$id = $t['identifier'];
|
||||
$manifest['templates'][] = [
|
||||
'identifier' => $id,
|
||||
'strategy' => $strategies["templates:{$id}"] ?? 'overwrite',
|
||||
];
|
||||
}
|
||||
foreach ($updates['lang_packs'] ?? [] as $lp) {
|
||||
$manifest['lang_packs'][] = ['identifier' => $lp['identifier']];
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
private function bundledManifestIsEmpty(array $manifest): bool
|
||||
{
|
||||
return empty($manifest['modules'])
|
||||
&& empty($manifest['plugins'])
|
||||
&& empty($manifest['templates'])
|
||||
&& empty($manifest['lang_packs']);
|
||||
}
|
||||
|
||||
/**
|
||||
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 실행.
|
||||
*
|
||||
* 자식 stdout 을 부모 콘솔로 실시간 전달 (단 `[BUNDLED-RESULT]` prefix 라인은
|
||||
* 결과 페이로드로 보관 후 부모 콘솔로 노출하지 않음). 종료 후 페이로드 파싱.
|
||||
*
|
||||
* @return array{success: int, failed: int}|null spawn 성공 시 결과, 실패/미지원 시 null
|
||||
*/
|
||||
private function spawnBundledUpdates(array $manifest): ?array
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$manifestPath = storage_path('app/core_pending'.DIRECTORY_SEPARATOR.'bundled-updates-manifest_'.uniqid().'.json');
|
||||
File::ensureDirectoryExists(dirname($manifestPath));
|
||||
File::put($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
try {
|
||||
$phpBinary = config('process.php_binary', PHP_BINARY);
|
||||
$artisan = base_path('artisan');
|
||||
|
||||
$command = [
|
||||
$phpBinary,
|
||||
$artisan,
|
||||
'core:execute-bundled-updates',
|
||||
'--manifest='.$manifestPath,
|
||||
];
|
||||
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
|
||||
$env = array_merge(getenv(), $_ENV);
|
||||
|
||||
// audit:allow spawn-after-vendor-swap-clears-package-manifest 이 spawn 은 vendor 를 바꾸지 않고,
|
||||
// 두 호출처가 모두 clearAllCaches() 뒤다 — CoreUpdateCommand Step 11(패키지 매니페스트 재생성)
|
||||
// 다음의 Step 12, ExecuteUpgradeStepsCommand 단독 실행의 캐시 정리 다음 단계. 따라서 자식이
|
||||
// 읽는 매니페스트는 이미 현재 vendor 기준으로 다시 만들어진 것이다.
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
|
||||
$resultPayload = null;
|
||||
$resultPrefix = ExecuteBundledUpdatesCommand::RESULT_PREFIX;
|
||||
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line === false) {
|
||||
continue;
|
||||
}
|
||||
$trimmed = rtrim($line);
|
||||
|
||||
if (str_starts_with($trimmed, $resultPrefix)) {
|
||||
$json = substr($trimmed, strlen($resultPrefix));
|
||||
$decoded = json_decode($json, true);
|
||||
if (is_array($decoded)) {
|
||||
$resultPayload = $decoded;
|
||||
}
|
||||
|
||||
continue; // 부모 콘솔에 노출 안 함
|
||||
}
|
||||
|
||||
$this->line($trimmed);
|
||||
}
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($process);
|
||||
|
||||
if ($resultPayload === null) {
|
||||
Log::warning('번들 spawn 자식이 결과 페이로드를 출력하지 않음 — 카운트 0 으로 처리');
|
||||
|
||||
return ['success' => 0, 'failed' => 0];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => (int) ($resultPayload['success'] ?? 0),
|
||||
'failed' => (int) ($resultPayload['failed'] ?? 0),
|
||||
];
|
||||
} finally {
|
||||
if (File::exists($manifestPath)) {
|
||||
File::delete($manifestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* in-process fallback — proc_open 미지원 환경 전용.
|
||||
*
|
||||
* 기존 (beta.3) 흐름의 직접 호출 패턴 보존. 단 부모 메모리의 stale 코드로 인해
|
||||
* sync 메서드 누락 결함이 재현될 수 있으므로 spawn 가능 환경에서는 사용되지 않는다.
|
||||
*
|
||||
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
|
||||
*/
|
||||
private function executeBulkUpdateInProcess(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
array $updates,
|
||||
array $strategies,
|
||||
bool $rebuildSearchIndex = false,
|
||||
): array {
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$id = $m['identifier'];
|
||||
$strategy = $strategies["modules:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [모듈] {$id} ({$strategy})");
|
||||
try {
|
||||
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$id = $p['identifier'];
|
||||
$strategy = $strategies["plugins:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [플러그인] {$id} ({$strategy})");
|
||||
try {
|
||||
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$id = $t['identifier'];
|
||||
$strategy = $strategies["templates:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [템플릿] {$id} ({$strategy})");
|
||||
try {
|
||||
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$langPackService = app(LanguagePackService::class);
|
||||
foreach ($updates['lang_packs'] ?? [] as $lp) {
|
||||
$id = $lp['identifier'];
|
||||
$this->line("→ [언어팩] {$id}");
|
||||
try {
|
||||
$pack = $langPackService->findByIdentifier($id);
|
||||
if (! $pack) {
|
||||
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
|
||||
}
|
||||
$langPackService->performUpdate($pack, true);
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
|
||||
|
||||
$this->runSearchIndexRebuildIfRequested($rebuildSearchIndex);
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'skipped' => 0,
|
||||
'has_updates' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 인덱스 재생성 여부를 운영자에게 확인합니다.
|
||||
*
|
||||
* 재생성은 인덱스 잠금 또는 전체 재색인을 유발하므로 기본값은 "아니오" 입니다.
|
||||
* `--force` (무인 실행) 시에도 묻지 않고 수행하지 않습니다 — 무인 실행이
|
||||
* 대용량 테이블을 잠그는 일이 없어야 합니다. 필요하면 `core:update
|
||||
* --rebuild-search-index` 로 명시하거나 이후 `search:index --repair` 를 실행합니다.
|
||||
*
|
||||
* @param bool $force 강제(무인) 실행 여부
|
||||
* @return bool 재생성 수행 여부
|
||||
*/
|
||||
private function askRebuildSearchIndex(bool $force): bool
|
||||
{
|
||||
// 커맨드가 옵션으로 이미 선택을 받았으면 그대로 따른다
|
||||
if ($this->hasOption('rebuild-search-index') && (bool) $this->option('rebuild-search-index')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$manager = app(SearchIndexMaintenanceManager::class);
|
||||
|
||||
// 점검을 제공하지 않는 엔진에서는 물을 것이 없다
|
||||
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line(' '.__('search.index.rebuild_cost_warning'));
|
||||
|
||||
return $this->unifiedConfirm(__('search.index.rebuild_after_bulk_confirm'), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청이 있었으면 검색 인덱스를 재생성합니다.
|
||||
*
|
||||
* @param bool $requested 재생성 요청 여부
|
||||
* @return void
|
||||
*/
|
||||
private function runSearchIndexRebuildIfRequested(bool $requested): void
|
||||
{
|
||||
if (! $requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('검색 인덱스: '.$report->summary());
|
||||
|
||||
foreach ($report->failed as $identifier => $message) {
|
||||
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 *:build 패턴과 일관된 코어 단독 vendor 번들 빌드 명령어.
|
||||
*/
|
||||
class CoreVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'core:vendor-bundle
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '코어 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class CoreVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'core:vendor-verify';
|
||||
|
||||
protected $description = '코어 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Search\SearchIndexMaintenanceManager;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 번들 확장(모듈/플러그인/템플릿/언어팩) 일괄 업데이트를 별도 PHP 프로세스에서 실행합니다.
|
||||
*
|
||||
* `core:update` 의 `BundledExtensionUpdatePrompt::executeBulkUpdate` 가 사용자 선택을
|
||||
* 매니페스트로 직렬화한 후 본 커맨드를 `proc_open` 으로 spawn 한다. 자식은 fresh PHP
|
||||
* 프로세스이므로 디스크에 막 적용된 신버전 코어 코드(특히 `ModuleManager` /
|
||||
* `PluginManager` / `TemplateManager` / `LanguagePackService`) 를 메모리에 로드한 상태
|
||||
* 에서 update 메서드를 호출한다.
|
||||
*
|
||||
* 부모 프로세스의 stale memory 가 신규 sync 메서드(`syncDeclarativeArtifacts` 등) 를
|
||||
* 호출하지 못하던 결함의 영구 차단 (beta.4 의 `Upgrade_7_0_0_beta_4` 사후 보정과는 별개의
|
||||
* 구조적 fix — 향후 모든 코어 업그레이드에서 동일 패턴 회귀를 차단).
|
||||
*
|
||||
* 매니페스트 형식:
|
||||
* ```json
|
||||
* {
|
||||
* "modules": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
||||
* "plugins": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
||||
* "templates": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
||||
* "lang_packs": [{"identifier": "..."}, ...]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 출력:
|
||||
* - stdout 에 진행 라인을 그대로 출력 (부모 콘솔이 forwarding)
|
||||
* - 종료 직전 `[BUNDLED-RESULT] {json}` 표식으로 최종 카운트 페이로드를 1회 출력 →
|
||||
* 부모가 stdout 파싱해 결과 복원.
|
||||
*/
|
||||
class ExecuteBundledUpdatesCommand extends Command
|
||||
{
|
||||
public const RESULT_PREFIX = '[BUNDLED-RESULT] ';
|
||||
|
||||
protected $signature = 'core:execute-bundled-updates
|
||||
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
|
||||
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
|
||||
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
|
||||
|
||||
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$manifestPath = (string) $this->option('manifest');
|
||||
if ($manifestPath === '' || ! File::isFile($manifestPath)) {
|
||||
$this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
|
||||
// ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
|
||||
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
|
||||
// output buffer 를 덮어쓰는 Laravel 동작 회피)
|
||||
try {
|
||||
app(ExtensionManager::class)->updateComposerAutoload();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$manifest = json_decode(File::get($manifestPath), true);
|
||||
if (! is_array($manifest)) {
|
||||
$this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
|
||||
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
|
||||
|
||||
$modules = $manifest['modules'] ?? [];
|
||||
$plugins = $manifest['plugins'] ?? [];
|
||||
$templates = $manifest['templates'] ?? [];
|
||||
$langPacks = $manifest['lang_packs'] ?? [];
|
||||
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$templateManager = app(TemplateManager::class);
|
||||
$langPackService = app(LanguagePackService::class);
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
$this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
|
||||
|
||||
foreach ($modules as $entry) {
|
||||
$id = (string) ($entry['identifier'] ?? '');
|
||||
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$this->line("→ [모듈] {$id} ({$strategy})");
|
||||
try {
|
||||
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($plugins as $entry) {
|
||||
$id = (string) ($entry['identifier'] ?? '');
|
||||
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$this->line("→ [플러그인] {$id} ({$strategy})");
|
||||
try {
|
||||
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($templates as $entry) {
|
||||
$id = (string) ($entry['identifier'] ?? '');
|
||||
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$this->line("→ [템플릿] {$id} ({$strategy})");
|
||||
try {
|
||||
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($langPacks as $entry) {
|
||||
$id = (string) ($entry['identifier'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$this->line("→ [언어팩] {$id}");
|
||||
try {
|
||||
$pack = $langPackService->findByIdentifier($id);
|
||||
if (! $pack) {
|
||||
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
|
||||
}
|
||||
$langPackService->performUpdate($pack, true);
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
|
||||
|
||||
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
|
||||
if ($rebuildSearchIndex) {
|
||||
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
|
||||
$this->newLine();
|
||||
$this->info('검색 인덱스: '.$report->summary());
|
||||
|
||||
foreach ($report->failed as $identifier => $message) {
|
||||
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
|
||||
}
|
||||
}
|
||||
|
||||
// 이전 버전 부모가 남긴 빈 격리 디렉토리(core_{ts}/extracted 껍데기) 청소 —
|
||||
// 부모의 정리 단계는 이 자식보다 먼저 끝나므로, 구버전 부모에서 올라오는
|
||||
// 업데이트도 이 자리에서 껍데기 없이 마무리된다. 실패해도 업데이트 결과와 무관.
|
||||
try {
|
||||
app(CoreUpdateService::class)->sweepEmptyStagingDirectories();
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
|
||||
$this->line(self::RESULT_PREFIX.json_encode([
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 코어 업그레이드 스텝을 별도 프로세스에서 실행합니다.
|
||||
*
|
||||
* CoreUpdateCommand 의 Step 10 에서 proc_open 으로 호출하여 최신 버전 클래스·
|
||||
* config 를 새 PHP 프로세스에서 로드하게 하는 진입점. 새 프로세스에서 실행되므로
|
||||
* upgrade step 이 신규 Service/Repository/Controller 등을 자유롭게 호출할 수 있다.
|
||||
*
|
||||
* 단, 이는 beta.3 이후 업그레이드 경로(경로 B)에만 적용된다. beta.1 → beta.2
|
||||
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
|
||||
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
|
||||
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
|
||||
*
|
||||
* 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
|
||||
* 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
|
||||
* 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
|
||||
* CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
|
||||
*
|
||||
* `--steps-only`: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 ·
|
||||
* 오토로드 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를
|
||||
* 생략한다. 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다. 보조 단계가
|
||||
* 이미 완료된 환경에서 무거운 권한 재귀 순회 등을 건너뛴다.
|
||||
*/
|
||||
class ExecuteUpgradeStepsCommand extends Command
|
||||
{
|
||||
use BundledExtensionUpdatePrompt;
|
||||
use ClearsTemplateCaches;
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
protected $signature = 'core:execute-upgrade-steps
|
||||
{--from= : 시작 버전}
|
||||
{--to= : 대상 버전}
|
||||
{--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
|
||||
{--steps-only : 업그레이드 스텝만 실행 — 권한 정상화·오토로드 재생성·마이그레이션·resync·버전 갱신·캐시 정리·번들 업데이트 등 모든 사전/사후 보조 단계 생략}
|
||||
{--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
|
||||
{--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
|
||||
{--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
|
||||
{--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
|
||||
{--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
|
||||
|
||||
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행하며, --steps-only 로 스텝만 실행할 수 있습니다.';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param CoreUpdateService $service 코어 업데이트 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(CoreUpdateService $service): int
|
||||
{
|
||||
$from = (string) $this->option('from');
|
||||
$to = (string) $this->option('to');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
// --steps-only: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 · 오토로드
|
||||
// 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를 생략한다.
|
||||
// 운영자가 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다.
|
||||
$stepsOnly = (bool) $this->option('steps-only');
|
||||
|
||||
if ($from === '' || $to === '') {
|
||||
$this->error('--from 과 --to 는 필수 옵션입니다.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록.
|
||||
// 부모 프로세스의 autoload-extensions.php 가 stale 한 경우 upgrade step 안에서
|
||||
// ModuleManager / PluginManager 호출 → declaration 메서드가 Models/Services 등
|
||||
// 다른 클래스 lazy load 시 "Class not found" 발생. 진입 직후 1회 호출로 모든 후속
|
||||
// upgrade step (현재 + 미래) 이 fresh autoload 환경에서 실행됨을 보장.
|
||||
//
|
||||
// 본 커맨드 자체가 spawn 자식 (proc_open 으로 fork 된 별개 PHP 프로세스) 의 진입점이라
|
||||
// 디스크의 fresh ExtensionManager(beta.X+1) 클래스를 메모리에 로드한 상태. 따라서
|
||||
// `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성
|
||||
// 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의
|
||||
// output buffer 를 덮어쓰는 Laravel 동작 회피)
|
||||
if (! $stepsOnly) {
|
||||
try {
|
||||
app(ExtensionManager::class)->updateComposerAutoload();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
|
||||
// fresh 디스크 config (`app.update.restore_ownership_group_writable`) 를 읽어 처리하므로
|
||||
// 미래 release 가 새 쓰기 권한 디렉토리를 도입할 때 본 호출은 자동으로 신규 항목을 처리한다.
|
||||
// upgrade step 에 mkdir/chown 코드를 매번 하드코딩할 필요 없음.
|
||||
//
|
||||
// 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는
|
||||
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
|
||||
if (! $stepsOnly) {
|
||||
try {
|
||||
// 구버전 부모(7.0.9 이하)는 spawn 전에 config 캐시를 비우지 않아 이 자식이 이전 버전
|
||||
// 캐시로 부팅할 수 있다. 그러면 `config()` 는 옛 목록이라 신버전이 추가한 디렉토리가
|
||||
// 빠진다 — 캐시 부팅이면 디스크 config/app.php 를 직접 읽는다 (7.0.9→7.0.10 실사례).
|
||||
// 판정은 캐시 파일의 실존으로 한다 — 부팅에 쓰였든 위 updateComposerAutoload() 가 방금
|
||||
// 재생성했든, 파일이 있으면 메모리 config 를 신뢰하지 않는다 (디스크 판독은 멱등).
|
||||
if (is_file($this->laravel->getCachedConfigPath())) {
|
||||
Log::channel('upgrade')->warning('[spawn] 이전 버전 config 캐시로 부팅됨 — update 목록은 디스크 config/app.php 에서 읽는다');
|
||||
$writablePaths = (array) $service->freshDiskUpdateConfig('restore_ownership_group_writable', []);
|
||||
} else {
|
||||
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
|
||||
}
|
||||
if (! empty($writablePaths)) {
|
||||
$service->ensureWritableDirectories(
|
||||
$writablePaths,
|
||||
function (string $level, string $msg): void {
|
||||
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
|
||||
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
|
||||
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
|
||||
Log::channel('upgrade')->$level('[spawn] '.$msg);
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 코어 업데이트 spawn 자식 감지.
|
||||
//
|
||||
// CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식에 `G7_UPDATE_IN_PROGRESS=1` env 를
|
||||
// 전달한다 (beta.3 부터 존재). 이 시그널은 자식이 부모의 `core:update` 컨텍스트에서
|
||||
// 실행 중임을 의미한다. 부모는 본인이 사전(Step 9 migration + resync) + 사후
|
||||
// (Step 11 version-env + cache-clear + bundled prompt) 단계를 모두 책임지므로 자식은
|
||||
// 5단계 모두 스킵해야 한다.
|
||||
//
|
||||
// 구버전 부모(예: beta.5)는 신버전 자식이 도입한 `--skip-*` 옵션을 모른다. 옵션 부재
|
||||
// 시 자식이 사후 단계를 실행하면 (특히 bundled prompt) non-interactive 환경에서
|
||||
// `Aborted.` exit=1 회귀 발생 (882deb9b0 사각지대). env 시그널이 옵션 부재를 보완.
|
||||
//
|
||||
// 단독 호출 (운영자 수동) 시엔 env 미설정 → 모든 단계 정상 진행.
|
||||
$isSpawnChild = getenv('G7_UPDATE_IN_PROGRESS') === '1';
|
||||
|
||||
// 사전 단계 (단독 실행 안전성 보장).
|
||||
//
|
||||
// CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
|
||||
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
|
||||
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
|
||||
// migration / permission / menu / seeder 누락 차단.
|
||||
if (! $this->option('skip-migrations') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('마이그레이션 실행');
|
||||
$service->runMigrations();
|
||||
} else {
|
||||
$this->info($stepsOnly ? '[steps-only] 마이그레이션 스킵' : '[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
|
||||
}
|
||||
|
||||
if (! $this->option('skip-resync') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
|
||||
$service->reloadCoreConfigAndResync();
|
||||
} else {
|
||||
$this->info($stepsOnly ? '[steps-only] resync 스킵' : '[spawn] resync 스킵 — 부모가 이미 실행');
|
||||
}
|
||||
|
||||
$stepsExecuted = 0;
|
||||
$stepsDiscovered = 0;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps(
|
||||
$from,
|
||||
$to,
|
||||
function (string $version) use (&$stepsExecuted): void {
|
||||
$this->info("upgrade step 실행: {$version}");
|
||||
$stepsExecuted++;
|
||||
},
|
||||
$force,
|
||||
function (int $discovered) use (&$stepsDiscovered): void {
|
||||
$stepsDiscovered = $discovered;
|
||||
},
|
||||
);
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
|
||||
// 부모 프로세스(spawnUpgradeStepsProcess)가 [HANDOFF] 라인을 stdout 에서
|
||||
// 읽어 페이로드를 복원하고 UpgradeHandoffException 재구성 후 상위로 던지도록,
|
||||
// JSON 페이로드를 표식과 함께 출력한다. 표식 문자열은 구분자 역할이므로 일반
|
||||
// step 출력과 충돌하지 않게 고정된 접두사를 사용한다.
|
||||
//
|
||||
// resumeCommand 는 step 작성자가 null 로 두는 것을 권장(CoreUpdateCommand 가
|
||||
// from/to 버전을 사용해 자동 생성). null 도 JSON 으로 그대로 전달.
|
||||
$payload = json_encode([
|
||||
'afterVersion' => $e->afterVersion,
|
||||
'reason' => $e->reason,
|
||||
'resumeCommand' => $e->resumeCommand,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->line('[HANDOFF] '.$payload);
|
||||
|
||||
Log::info('core:execute-upgrade-steps 핸드오프', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'after' => $e->afterVersion,
|
||||
'reason' => $e->reason,
|
||||
]);
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return UpgradeHandoffException::EXIT_CODE;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('core:execute-upgrade-steps 실패', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->error($e->getMessage());
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는
|
||||
// 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로
|
||||
// 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw,
|
||||
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
|
||||
$this->line('[STEPS_EXECUTED] '.json_encode([
|
||||
'count' => $stepsExecuted,
|
||||
'discovered' => $stepsDiscovered,
|
||||
'fromVersion' => $from,
|
||||
'toVersion' => $to,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 사후 단계 (단독 실행 안전성 보장).
|
||||
//
|
||||
// CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
|
||||
// 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
|
||||
// --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
|
||||
// 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
|
||||
// (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
|
||||
if (! $this->option('skip-version-env') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info(".env APP_VERSION={$to} 갱신");
|
||||
$service->updateVersionInEnv($to);
|
||||
} else {
|
||||
$this->info($stepsOnly ? '[steps-only] .env APP_VERSION 갱신 스킵' : '[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
|
||||
}
|
||||
|
||||
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('캐시 정리 (config/route/view/services/packages)');
|
||||
$service->clearAllCaches();
|
||||
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시 정리 직후 재시작 신호.
|
||||
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모가 보낸다.
|
||||
$service->signalQueueRestart();
|
||||
$this->info('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
|
||||
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
||||
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
||||
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
|
||||
ConfigCacheHelper::rebuild();
|
||||
// 라우트 캐시도 같은 이유로 되살린다 — 업그레이드 스텝이나 번들 확장 업데이트가
|
||||
// 라우트를 바꿨을 수 있고, 비운 채로 끝내면 이후 모든 요청이 라우트를 재등록한다.
|
||||
RouteCacheHelper::rebuild();
|
||||
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
|
||||
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
|
||||
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
|
||||
$this->incrementExtensionCacheVersion();
|
||||
} else {
|
||||
$this->info($stepsOnly ? '[steps-only] 캐시 정리 스킵' : '[spawn] 캐시 정리 스킵 — 부모가 처리');
|
||||
}
|
||||
|
||||
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
|
||||
$this->runBundledExtensionUpdatePrompt(
|
||||
app(ModuleManager::class),
|
||||
app(PluginManager::class),
|
||||
app(TemplateManager::class),
|
||||
$force,
|
||||
);
|
||||
} else {
|
||||
$this->info($stepsOnly ? '[steps-only] 번들 확장 일괄 업데이트 스킵' : '[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
|
||||
}
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$this->sweepEmptyStagingDirectories($service);
|
||||
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
|
||||
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
|
||||
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 버전 부모가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)를 청소합니다.
|
||||
*
|
||||
* 부모의 정리 단계는 소스 경로 안쪽만 지우던 결함(7.0.0~7.0.9)이 있어 업데이트마다
|
||||
* 껍데기가 남았고, sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없었다.
|
||||
* 부모는 구버전 클래스를 메모리에 들고 있어 고쳐도 다음 업데이트부터 효력이 있으므로,
|
||||
* 신버전 코드로 도는 이 자식이 치운다. 파일이 있는 디렉토리(부모가 쓰는 중인 격리
|
||||
* 디렉토리)는 술어상 건드리지 않는다. 실패는 업데이트 결과와 무관하므로 경고로 흡수한다.
|
||||
*
|
||||
* @param CoreUpdateService $service 코어 업데이트 서비스
|
||||
*/
|
||||
private function sweepEmptyStagingDirectories(CoreUpdateService $service): void
|
||||
{
|
||||
try {
|
||||
$swept = $service->sweepEmptyStagingDirectories();
|
||||
if ($swept > 0) {
|
||||
$this->info("[spawn] 빈 격리 디렉토리 청소: {$swept}개");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
|
||||
*
|
||||
* upgrade step 은 sudo 코어 업데이트에서 별도 PHP 프로세스로 spawn 되며(이 커맨드),
|
||||
* root 로 실행되어 `upgrade-YYYY-MM-DD.log`(Log::channel('upgrade')) 를 root 소유로
|
||||
* 만든다. 부모(CoreUpdateCommand) 프로세스는 업데이트 시작 시점의 *이전 버전* 클래스를
|
||||
* 메모리에 들고 있어, 부모 측 로그 정상화 로직이 추가돼 있어도 그 버전엔 없을 수 있다
|
||||
* (클래스 캐싱). 반면 본 spawn 자식은 항상 *신버전* 코드로 실행되므로, upgrade 로그를
|
||||
* 만든 주체인 자식이 자기 종료 직전에 직접 소유권을 정합하면 부모 버전과 무관하게 동작한다.
|
||||
*
|
||||
* 그 결과 이후 www-data(php-fpm) 의 module:update upgrade step 이 같은 날짜 upgrade
|
||||
* 로그에 append 할 수 있다. 본 메서드는 어떤 로그도 쓰지 않는다(쓰면 다시 root 가 됨).
|
||||
* sudo 아닌 환경에서는 silent no-op(멱등).
|
||||
*/
|
||||
private function restoreUpgradeLogOwnership(): void
|
||||
{
|
||||
$logsDir = storage_path('logs');
|
||||
if (! is_dir($logsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
|
||||
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
|
||||
// 두 패턴을 각각 순회한다.
|
||||
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
|
||||
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
|
||||
FilePermissionHelper::inheritOwnershipFromParent($logFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
|
||||
*
|
||||
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
|
||||
*/
|
||||
class ExpireIdentityChallengesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:expire-challenges';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$expired = $service->expirePastDue();
|
||||
|
||||
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use App\Support\ExtensionDoc\ExtensionDocContext;
|
||||
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
||||
use App\Support\ExtensionDoc\ExtensionInventory;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 확장 개발자 문서 생성 커맨드
|
||||
*
|
||||
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
|
||||
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
|
||||
*
|
||||
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
|
||||
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
|
||||
*/
|
||||
class ExtDocgenCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'ext:docgen
|
||||
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
|
||||
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
|
||||
{--check : 생성하지 않고 누락·드리프트만 리포트}
|
||||
{--json : 기계 판독 출력}
|
||||
{--dry-run : 대상과 실측 집계만 출력}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
|
||||
|
||||
/**
|
||||
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
|
||||
*/
|
||||
private array $malformed = [];
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param ExtensionInventory $inventory 번들 확장 인벤토리
|
||||
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
ExtensionInventory $inventory,
|
||||
ExtensionDocContext $context,
|
||||
ExtensionDocScaffolder $scaffolder
|
||||
): int {
|
||||
$scope = (string) $this->option('scope');
|
||||
|
||||
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
|
||||
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
|
||||
// 함께 쓸 수 없다고 명시적으로 거부한다.
|
||||
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
|
||||
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
|
||||
$conflict = match (true) {
|
||||
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
|
||||
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
|
||||
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($conflict !== null) {
|
||||
$message = $conflict;
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $message],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$records = $inventory->collect($scope);
|
||||
$this->malformed = $inventory->malformed();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->warn($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$ctx = $context->build($record);
|
||||
|
||||
$results[] = $this->processExtension($ctx, $scaffolder);
|
||||
}
|
||||
|
||||
return $this->report($results, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 확장을 처리합니다.
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return array<string, mixed> 처리 결과
|
||||
*/
|
||||
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
$stats = ExtensionDocScaffolder::statsOf($ctx);
|
||||
|
||||
$result = [
|
||||
'type' => $record['type'],
|
||||
'id' => $record['id'],
|
||||
'relPath' => $record['relPath'],
|
||||
'version' => $record['version'],
|
||||
'stats' => $stats,
|
||||
'surfaceAvailable' => $ctx['surface']['available'],
|
||||
'surfaceReason' => $ctx['surface']['reason'],
|
||||
'surfaceErrors' => $ctx['surface']['errors'],
|
||||
'documents' => [],
|
||||
'created' => [],
|
||||
'updated' => [],
|
||||
'missingDocuments' => [],
|
||||
'missingSections' => [],
|
||||
'missingBlocks' => [],
|
||||
'driftedBlocks' => [],
|
||||
'orphanBlocks' => [],
|
||||
'unfilled' => [],
|
||||
];
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
|
||||
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
|
||||
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
|
||||
if ($this->option('init') && ! $this->option('check')) {
|
||||
$this->initSkeletons($ctx, $scaffolder, $result);
|
||||
}
|
||||
|
||||
$bodies = $scaffolder->renderBlocks($ctx);
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
|
||||
|
||||
if (! is_file($abs)) {
|
||||
$result['missingDocuments'][] = $doc;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) File::get($abs);
|
||||
|
||||
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
|
||||
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
|
||||
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
|
||||
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
|
||||
$result['missingSections'][] = $doc.' → '.$section;
|
||||
}
|
||||
}
|
||||
|
||||
// 미채움 마커 잔량
|
||||
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
|
||||
$count = substr_count($content, $marker);
|
||||
if ($count > 0) {
|
||||
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
|
||||
}
|
||||
}
|
||||
|
||||
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
|
||||
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
|
||||
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
|
||||
|
||||
if ($emptyIntents > 0) {
|
||||
$result['unfilled'][] = [
|
||||
'doc' => $doc,
|
||||
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
|
||||
'count' => $emptyIntents,
|
||||
];
|
||||
}
|
||||
|
||||
$docBodies = [];
|
||||
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
|
||||
if (array_key_exists($key, $bodies)) {
|
||||
$docBodies[$key] = $bodies[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
|
||||
|
||||
foreach ($merged['missing'] as $key) {
|
||||
$result['missingBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
|
||||
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
|
||||
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
|
||||
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
|
||||
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
|
||||
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
|
||||
$result['orphanBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('check')) {
|
||||
if (! $merged['unchanged']) {
|
||||
foreach ($merged['replaced'] as $key) {
|
||||
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
|
||||
$result['driftedBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $merged['unchanged']) {
|
||||
File::put($abs, $merged['content']);
|
||||
|
||||
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
|
||||
if (! in_array($doc, $result['created'], true)) {
|
||||
$result['updated'][] = $doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @param array<string, mixed> $result 처리 결과 (created 누적)
|
||||
*/
|
||||
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
|
||||
if (is_file($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($abs));
|
||||
File::put($abs, $scaffolder->skeleton($doc, $ctx));
|
||||
$result['created'][] = $doc;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
|
||||
*
|
||||
* @param string $content 문서 내용
|
||||
* @param string $key 블록 키
|
||||
* @param string $body 새 본문
|
||||
* @return bool 다르면 true
|
||||
*/
|
||||
private function blockDiffers(string $content, string $key, string $body): bool
|
||||
{
|
||||
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
|
||||
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
|
||||
|
||||
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$from = (int) $sm[0][1] + strlen($sm[0][0]);
|
||||
|
||||
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
|
||||
|
||||
return $existing !== trim($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 결과를 출력하고 종료 코드를 결정합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 확장별 결과
|
||||
* @param string $scope 범위
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function report(array $results, string $scope): int
|
||||
{
|
||||
$issues = 0;
|
||||
foreach ($results as $result) {
|
||||
$issues += count($result['missingDocuments'])
|
||||
+ count($result['missingSections'])
|
||||
+ count($result['missingBlocks'])
|
||||
+ count($result['driftedBlocks'])
|
||||
+ count($result['orphanBlocks']);
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'scope' => $scope,
|
||||
'mode' => $this->mode(),
|
||||
'extensions' => $results,
|
||||
'malformed' => $this->malformed,
|
||||
'issues' => $issues,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
|
||||
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info(count($results).'개 확장 (scope='.$scope.')');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($results as $result) {
|
||||
$parts = [];
|
||||
foreach ($result['stats'] as $label => $value) {
|
||||
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
|
||||
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
|
||||
$parts[] = $value === null
|
||||
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
|
||||
: "{$label} {$value}";
|
||||
}
|
||||
|
||||
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
|
||||
$this->line(' '.implode(' · ', $parts));
|
||||
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
|
||||
|
||||
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
|
||||
$this->line(' 선언형 표면: '.$result['surfaceReason']);
|
||||
}
|
||||
if ($result['surfaceErrors'] !== []) {
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->line(" ⚠ {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($results as $result) {
|
||||
$label = sprintf('[%s] %s', $result['type'], $result['id']);
|
||||
|
||||
if ($result['created'] !== []) {
|
||||
$this->info("{$label} 생성: ".implode(', ', $result['created']));
|
||||
}
|
||||
if ($result['updated'] !== []) {
|
||||
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
|
||||
}
|
||||
if ($result['missingDocuments'] !== []) {
|
||||
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
|
||||
}
|
||||
if ($result['missingBlocks'] !== []) {
|
||||
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
|
||||
}
|
||||
if ($result['missingSections'] !== []) {
|
||||
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
|
||||
}
|
||||
if ($result['driftedBlocks'] !== []) {
|
||||
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
|
||||
}
|
||||
if ($result['orphanBlocks'] !== []) {
|
||||
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
|
||||
}
|
||||
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
|
||||
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
|
||||
if ($result['unfilled'] !== []) {
|
||||
$total = array_sum(array_column($result['unfilled'], 'count'));
|
||||
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
|
||||
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
|
||||
$result['unfilled'],
|
||||
)));
|
||||
}
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->malformed as $bad) {
|
||||
$this->warn(sprintf(
|
||||
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
|
||||
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
|
||||
|
||||
if ($this->option('check') && $issues > 0) {
|
||||
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 실행 모드를 반환합니다.
|
||||
*
|
||||
* @return string 모드 문자열
|
||||
*/
|
||||
private function mode(): string
|
||||
{
|
||||
if ($this->option('dry-run')) {
|
||||
return 'dry-run';
|
||||
}
|
||||
if ($this->option('check')) {
|
||||
return 'check';
|
||||
}
|
||||
if ($this->option('init')) {
|
||||
return 'init';
|
||||
}
|
||||
|
||||
return 'update';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UpdateAutoloadCommand extends Command
|
||||
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(ExtensionManager $extensionManager): int
|
||||
{
|
||||
public function handle(
|
||||
ExtensionManager $extensionManager,
|
||||
HookCacheManager $hookCacheManager,
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
): int {
|
||||
$this->info('확장 오토로드 파일을 생성합니다...');
|
||||
|
||||
try {
|
||||
@@ -34,6 +41,22 @@ class UpdateAutoloadCommand extends Command
|
||||
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
|
||||
$this->line(' → bootstrap/cache/autoload-extensions.php');
|
||||
|
||||
// 방금 생성한 PSR-4 매핑을 현재 프로세스의 ClassLoader 에 즉시 반영.
|
||||
// 이 커맨드는 매핑이 깨진 상태를 복구하는 경로이기도 한데, 재등록 없이 아래
|
||||
// loadModules/loadPlugins 를 호출하면 부팅 시점의 (깨진) 매핑으로 확장 클래스를
|
||||
// 찾지 못해 훅 캐시가 modules/plugins 0건으로 생성된다 — 모든 확장 훅이 조용히 소실.
|
||||
$extensionManager->reregisterRuntimeAutoload();
|
||||
|
||||
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
|
||||
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
|
||||
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager->loadPlugins();
|
||||
$hookCacheManager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
|
||||
$this->line(' → bootstrap/cache/hooks.php');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\GeoIp;
|
||||
|
||||
use App\Services\GeoIpDatabaseService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* MaxMind GeoLite2 DB 다운로드/갱신 커맨드.
|
||||
*
|
||||
* 모든 비즈니스 로직은 GeoIpDatabaseService에 위임합니다.
|
||||
* 이 커맨드는 CLI 인터페이스(옵션 파싱 + 결과 출력)만 담당합니다.
|
||||
*/
|
||||
class UpdateGeoLiteDatabaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'geoip:update
|
||||
{--force : 파일이 이미 존재해도 재다운로드}
|
||||
{--dry-run : 실제 다운로드 없이 키/URL만 검증}';
|
||||
|
||||
protected $description = 'MaxMind GeoLite2-City DB를 다운로드하여 storage/app/geoip/에 배치합니다.';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param GeoIpDatabaseService $service GeoIP DB 관리 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(GeoIpDatabaseService $service): int
|
||||
{
|
||||
if ($this->option('dry-run')) {
|
||||
return $this->handleDryRun($service);
|
||||
}
|
||||
|
||||
$result = $service->updateDatabase((bool) $this->option('force'));
|
||||
|
||||
return $this->reportResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run 모드: 라이선스 키 마스킹 후 URL만 출력합니다.
|
||||
*
|
||||
* @param GeoIpDatabaseService $service GeoIP DB 관리 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function handleDryRun(GeoIpDatabaseService $service): int
|
||||
{
|
||||
if (! $service->isLicenseKeyConfigured()) {
|
||||
$this->error('MaxMind 라이선스 키가 설정되지 않았습니다.');
|
||||
$this->line('관리자 > 환경설정 > 고급 탭에서 라이선스 키를 입력하세요.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$maskedUrl = $service->buildDownloadUrlForDisplay(true);
|
||||
$this->info('[dry-run] 다운로드 URL 검증 완료');
|
||||
$this->line(" URL: {$maskedUrl}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 결과를 CLI 출력으로 변환하고 exit code를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $result Service::updateDatabase() 반환값
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function reportResult(array $result): int
|
||||
{
|
||||
$status = (string) ($result['status'] ?? '');
|
||||
$message = (string) ($result['message'] ?? '');
|
||||
|
||||
if ($status === 'missing_license_key') {
|
||||
$this->error($message);
|
||||
$this->line('관리자 > 환경설정 > 고급 탭에서 라이선스 키를 입력하세요.');
|
||||
$this->line('무료 발급: https://www.maxmind.com/en/geolite2/signup');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($status === 'skipped') {
|
||||
$this->info($message);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status === 'updated') {
|
||||
$data = $result['data'] ?? [];
|
||||
$this->newLine();
|
||||
$this->info('✅ '.$message);
|
||||
$this->line(sprintf(' 파일: %s', $data['database_path'] ?? '-'));
|
||||
$this->line(sprintf(' 크기: %.2f MB', (int) ($data['file_size_bytes'] ?? 0) / 1048576));
|
||||
$this->line(sprintf(' 소요 시간: %s초', $data['elapsed_seconds'] ?? '-'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 그 외 모든 실패 상태
|
||||
$this->error($message !== '' ? $message : 'GeoIP DB 업데이트에 실패했습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
|
||||
*
|
||||
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
|
||||
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
|
||||
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
|
||||
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
|
||||
*/
|
||||
class HooksCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'hooks:cache';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
|
||||
* @param ModuleManager $moduleManager 모듈 매니저
|
||||
* @param PluginManager $pluginManager 플러그인 매니저
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
HookCacheManager $hookCacheManager,
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
): int {
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->info(__('hooks.cache_generated', [
|
||||
'core' => $counts['core'],
|
||||
'modules' => $counts['modules'],
|
||||
'plugins' => $counts['plugins'],
|
||||
'path' => $hookCacheManager->getCacheFilePath(),
|
||||
]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\HookCacheManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
|
||||
*
|
||||
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
|
||||
* 동작하므로 항상 안전하다 (route:clear 동형).
|
||||
*/
|
||||
class HooksClearCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'hooks:clear';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(HookCacheManager $hookCacheManager): int
|
||||
{
|
||||
if (! $hookCacheManager->isCached()) {
|
||||
$this->info(__('hooks.cache_absent'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$hookCacheManager->clear();
|
||||
$this->info(__('hooks.cache_cleared'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Hotfix;
|
||||
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리하는 단발성
|
||||
* 결함 보정 도구.
|
||||
*
|
||||
* 결함 컨텍스트: 7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식
|
||||
* 으로만 동작하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지
|
||||
* 않고 잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
|
||||
* 디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등 부팅
|
||||
* 부정합 발생.
|
||||
*
|
||||
* 도입 버전: 7.0.0-beta.6 신설.
|
||||
*
|
||||
* 동작:
|
||||
* - `--backup` 미지정 시 `storage/app/core_backups/` 의 가장 최근 백업 자동 선택
|
||||
* - 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 그 기반으로 잔존 파일 후보 식별 (정확)
|
||||
* - manifest 부재 시 보수적 진단 모드 (실제 정리는 운영자 수동)
|
||||
* - 기본은 진단 모드 (실제 삭제 없이 후보 목록만 출력)
|
||||
* - `--prune` 옵션 시 확인 프롬프트 후 정리. symlink/protected_paths 가드 적용
|
||||
* - 정리 결과는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 기록
|
||||
*
|
||||
* @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
|
||||
* 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
|
||||
*/
|
||||
class RollbackStaleFilesCommand extends Command
|
||||
{
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
protected $signature = 'hotfix:rollback-stale-files
|
||||
{--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
|
||||
{--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
|
||||
|
||||
protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$backupPath = $this->resolveBackupPath();
|
||||
if ($backupPath === null) {
|
||||
$this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("백업 디렉토리: {$backupPath}");
|
||||
|
||||
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
|
||||
$manifestAvailable = File::exists($manifestPath);
|
||||
|
||||
if (! $manifestAvailable) {
|
||||
$this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
|
||||
$this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
|
||||
$this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
|
||||
$this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
|
||||
$this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$candidates = $this->collectCandidates($backupPath, $manifestAvailable);
|
||||
|
||||
if ($candidates['files'] === [] && $candidates['dirs'] === []) {
|
||||
$this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'잔존 후보: 파일 %d개, 디렉토리 %d개',
|
||||
count($candidates['files']),
|
||||
count($candidates['dirs']),
|
||||
));
|
||||
foreach (array_slice($candidates['files'], 0, 20) as $f) {
|
||||
$this->line(" · 파일: {$f}");
|
||||
}
|
||||
if (count($candidates['files']) > 20) {
|
||||
$this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
|
||||
}
|
||||
foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
|
||||
$this->line(" · 디렉토리: {$d}");
|
||||
}
|
||||
if (count($candidates['dirs']) > 10) {
|
||||
$this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
|
||||
}
|
||||
|
||||
if (! $this->option('prune')) {
|
||||
$this->newLine();
|
||||
$this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $manifestAvailable) {
|
||||
$this->newLine();
|
||||
$this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
|
||||
$this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
|
||||
$this->info('취소되었습니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$protectedPaths = (array) config('app.update.protected_paths', []);
|
||||
$result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
|
||||
$result['removed_files'],
|
||||
$result['removed_dirs'],
|
||||
$result['protected_count'],
|
||||
$result['symlink_skipped'],
|
||||
$result['failed_count'],
|
||||
));
|
||||
|
||||
$logPath = storage_path(sprintf(
|
||||
'logs/hotfix_rollback_stale_files_%s.log',
|
||||
date('Ymd_His'),
|
||||
));
|
||||
@file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
|
||||
$this->line("로그: {$logPath}");
|
||||
|
||||
Log::info('hotfix:rollback-stale-files 정리 완료', [
|
||||
'backup_path' => $backupPath,
|
||||
'result' => $result,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
|
||||
*/
|
||||
private function resolveBackupPath(): ?string
|
||||
{
|
||||
$explicit = $this->option('backup');
|
||||
if ($explicit !== null && $explicit !== '') {
|
||||
$real = realpath($explicit);
|
||||
if ($real === false || ! is_dir($real)) {
|
||||
$this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $real;
|
||||
}
|
||||
|
||||
$backups = CoreBackupHelper::listBackups();
|
||||
if ($backups === []) {
|
||||
return null;
|
||||
}
|
||||
// listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
|
||||
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
|
||||
|
||||
return $backups[0]['path'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
|
||||
*
|
||||
* @return array{files:array<int,string>, dirs:array<int,string>}
|
||||
*/
|
||||
private function collectCandidates(string $backupPath, bool $manifestAvailable): array
|
||||
{
|
||||
if (! $manifestAvailable) {
|
||||
return ['files' => [], 'dirs' => []];
|
||||
}
|
||||
|
||||
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
|
||||
$raw = @file_get_contents($manifestPath);
|
||||
if ($raw === false) {
|
||||
return ['files' => [], 'dirs' => []];
|
||||
}
|
||||
|
||||
$manifest = json_decode($raw, true);
|
||||
if (! is_array($manifest)) {
|
||||
return ['files' => [], 'dirs' => []];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
|
||||
if (! is_string($rel) || $rel === '') {
|
||||
continue;
|
||||
}
|
||||
$absolute = base_path($rel);
|
||||
if (file_exists($absolute) || is_link($absolute)) {
|
||||
$files[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
$dirs = [];
|
||||
foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
|
||||
if (! is_string($rel) || $rel === '') {
|
||||
continue;
|
||||
}
|
||||
$absolute = base_path($rel);
|
||||
if (is_dir($absolute) && ! is_link($absolute)) {
|
||||
$dirs[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
return ['files' => $files, 'dirs' => $dirs];
|
||||
}
|
||||
|
||||
/**
|
||||
* 진단/정리 결과를 로그 텍스트로 빌드.
|
||||
*/
|
||||
private function buildLogEntry(string $backupPath, array $result, array $candidates): string
|
||||
{
|
||||
$lines = [
|
||||
'=== hotfix:rollback-stale-files 실행 로그 ===',
|
||||
'날짜: '.date('Y-m-d H:i:s'),
|
||||
"백업 디렉토리: {$backupPath}",
|
||||
'',
|
||||
'결과:',
|
||||
sprintf(' 파일 제거: %d', $result['removed_files']),
|
||||
sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
|
||||
sprintf(' 보호 경로 skip: %d', $result['protected_count']),
|
||||
sprintf(' symlink skip: %d', $result['symlink_skipped']),
|
||||
sprintf(' 실패: %d', $result['failed_count']),
|
||||
'',
|
||||
'후보 목록 (실행 직전 스냅샷):',
|
||||
];
|
||||
foreach ($candidates['files'] as $f) {
|
||||
$lines[] = " · 파일: {$f}";
|
||||
}
|
||||
foreach ($candidates['dirs'] as $d) {
|
||||
$lines[] = " · 디렉토리: {$d}";
|
||||
}
|
||||
|
||||
return implode("\n", $lines)."\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 언어팩을 활성화합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 activate 커맨드와 동일한 운영 도구.
|
||||
* 동일 슬롯의 다른 활성 팩은 자동으로 inactive 로 전환됩니다.
|
||||
*/
|
||||
class ActivateLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:activate
|
||||
{identifier : 언어팩 식별자}
|
||||
{--force : 의존성/검증 경고를 무시하고 강제 활성화}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩을 활성화합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = (string) $this->argument('identifier');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$pack = $this->service->findByIdentifier($identifier);
|
||||
if (! $pack) {
|
||||
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->activate($pack, $force);
|
||||
$this->info("언어팩 활성화 완료: {$identifier}");
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 활성화 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 언어팩 관련 캐시(레지스트리/Translator/템플릿/버전 스탬프)를 정리합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 cache-clear 커맨드와 동일한 운영 도구.
|
||||
* _bundled 의 다국어 파일을 손으로 수정한 뒤 즉시 반영하려는 운영 시나리오에 사용합니다.
|
||||
*/
|
||||
class CacheClearLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:cache-clear';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩 캐시(registry/translator/template/version)를 정리합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$result = $this->service->refreshCache();
|
||||
$this->info('언어팩 캐시 정리 완료:');
|
||||
foreach ($result as $key => $ok) {
|
||||
$this->line(' - '.$key.': '.($ok ? 'ok' : 'failed'));
|
||||
}
|
||||
|
||||
return collect($result)->every(fn ($v) => $v === true) ? self::SUCCESS : self::FAILURE;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 캐시 정리 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* GitHub 으로 설치된 언어팩의 최신 릴리스 버전을 확인하여 `latest_version` 컬럼을 갱신합니다.
|
||||
*
|
||||
* 관리 UI 에서 "업데이트 가능" 배지 노출에 사용되며, 스케줄러에서 주기적으로 실행됩니다.
|
||||
*/
|
||||
class CheckLanguagePackUpdatesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:check-updates {--identifier= : 특정 언어팩만 확인 (선택)}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '설치된 언어팩의 GitHub 업데이트를 확인합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service (실제 로직 SSoT)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* 실제 갱신 로직은 LanguagePackService::checkUpdates() 에 위임합니다.
|
||||
*
|
||||
* @return int 종료 코드 (0=성공, 1=실패)
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = $this->option('identifier');
|
||||
|
||||
try {
|
||||
$result = $this->service->checkUpdates($identifier);
|
||||
|
||||
if ($result['checked'] === 0) {
|
||||
$this->info('확인할 GitHub 기반 언어팩이 없습니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($result['details'] as $entry) {
|
||||
if ($entry['error']) {
|
||||
$this->warn(sprintf(' [%s] 조회 실패: %s', $entry['identifier'], $entry['error']));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
' [%s] %s → %s%s',
|
||||
$entry['identifier'],
|
||||
$entry['current'],
|
||||
$entry['latest'] ?? '?',
|
||||
$entry['has_update'] ? ' (업데이트 가능)' : '',
|
||||
);
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
$this->info(sprintf('확인 완료: %d 건 검사, %d 건 업데이트 가능.', $result['checked'], $result['updates']));
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('업데이트 확인 실패: '.$e->getMessage());
|
||||
Log::error('language-pack:check-updates 실패', [
|
||||
'identifier' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 언어팩을 비활성화합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 deactivate 커맨드와 동일한 운영 도구.
|
||||
* 보호된 팩(is_protected=true)은 거부되며, 호스트 cascade 컨텍스트에서만 우회됩니다.
|
||||
*/
|
||||
class DeactivateLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:deactivate
|
||||
{identifier : 언어팩 식별자}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩을 비활성화합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = (string) $this->argument('identifier');
|
||||
|
||||
$pack = $this->service->findByIdentifier($identifier);
|
||||
if (! $pack) {
|
||||
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->deactivate($pack);
|
||||
$this->info("언어팩 비활성화 완료: {$identifier}");
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 비활성화 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 번들/GitHub/URL 소스에서 언어팩을 설치합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 install 커맨드와 동일한 운영 도구.
|
||||
* `--source=bundled` 시 `lang-packs/_bundled/{identifier}` 디렉토리에서 설치하며,
|
||||
* 외부 다운로드 없이 코어 언어팩의 (재)설치/복구가 가능합니다.
|
||||
*/
|
||||
class InstallLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:install
|
||||
{identifier : 번들 식별자 또는 소스에 따른 식별자/URL}
|
||||
{--source=bundled : 설치 소스 (bundled|github|url)}
|
||||
{--no-activate : 설치 후 자동 활성화하지 않음}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '번들/GitHub/URL 소스에서 언어팩을 설치합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = (string) $this->argument('identifier');
|
||||
$source = (string) $this->option('source');
|
||||
$autoActivate = ! (bool) $this->option('no-activate');
|
||||
|
||||
try {
|
||||
$pack = match ($source) {
|
||||
'bundled' => $this->service->installFromBundled($identifier, $autoActivate),
|
||||
'github' => $this->service->installFromGithub($identifier, $autoActivate),
|
||||
'url' => $this->service->installFromUrl($identifier, null, $autoActivate),
|
||||
default => throw new \InvalidArgumentException(
|
||||
__('language_packs.errors.unsupported_source', ['source' => $source])
|
||||
),
|
||||
};
|
||||
|
||||
$this->info(sprintf(
|
||||
'언어팩 설치 완료: %s v%s (status=%s)',
|
||||
$pack->identifier,
|
||||
$pack->version,
|
||||
$pack->status,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 설치 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 언어팩 목록을 출력합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구. Service::list() 를 경유하므로
|
||||
* 설치된 DB 행뿐 아니라 "번들에 있으나 미설치"(uninstalled) 및 "active 로 기록됐으나
|
||||
* 설치본 파일 부재(드리프트)" 상태를 함께 표면화한다(이슈 #496).
|
||||
*/
|
||||
class ListLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:list {--scope= : 특정 스코프만 (core/module/plugin/template)}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩 목록을 출력합니다 (미설치 번들 및 드리프트 상태 포함).';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$filters = [];
|
||||
if ($scope = $this->option('scope')) {
|
||||
$filters['scope'] = $scope;
|
||||
}
|
||||
|
||||
$paginator = $this->service->list($filters, 100);
|
||||
$packs = $paginator->items();
|
||||
|
||||
if (empty($packs)) {
|
||||
$this->info('언어팩이 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($packs as $pack) {
|
||||
$rows[] = [
|
||||
$pack->identifier,
|
||||
$pack->scope,
|
||||
$pack->target_identifier ?? '-',
|
||||
$pack->locale,
|
||||
$pack->vendor,
|
||||
$pack->version,
|
||||
$this->formatStatus($pack),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Identifier', 'Scope', 'Target', 'Locale', 'Vendor', 'Version', 'Status'],
|
||||
$rows
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시용 상태 문자열을 만듭니다. 드리프트(active + 설치본 부재)는 명시적으로 표기합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 대상 팩
|
||||
* @return string 표시 상태
|
||||
*/
|
||||
private function formatStatus(LanguagePack $pack): string
|
||||
{
|
||||
if ((bool) $pack->getAttribute('files_missing')) {
|
||||
return $pack->status.' (파일 없음)';
|
||||
}
|
||||
|
||||
return (string) $pack->status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackBaseLocales;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 정책(supported_locales) 기준으로 미설치 번들 언어팩을 멱등 설치(프로비저닝)합니다.
|
||||
*
|
||||
* fresh install / 시더 / 수동 복구가 공유하는 단일 프로비저닝 SSoT 입니다.
|
||||
* base locale(ko/en)은 가상 보호 행으로 항상 서빙되므로 대상에서 제외되고,
|
||||
* 나머지 로케일(예: ja)은 `lang-packs/_bundled/` 에 소스가 있어도 설치본 디렉토리로
|
||||
* 복사·등록되어야 서빙되므로 이 커맨드가 그 프로비저닝을 담당합니다.
|
||||
*
|
||||
* 대상은 두 갈래입니다 — 미설치 번들 팩(신규 프로비저닝)과, 설치 행은 있으나 설치본 파일이
|
||||
* 사라진 드리프트 팩(수동 복구). 후자는 슬롯이 점유돼 있어 `getUninstalledBundledPacks()` 가
|
||||
* 잡지 못하므로 `getDriftedInstalledPacks()` 로 따로 모읍니다. 이 커맨드가 복구 경로를 겸하지 않으면
|
||||
* 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다.
|
||||
*
|
||||
* 멱등성: 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 재실행 시 신규 설치 0 건으로 수렴합니다.
|
||||
*/
|
||||
class ProvisionLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:provision
|
||||
{--locale=* : 대상 로케일(미지정 시 supported_locales 에서 base locale 제외)}
|
||||
{--scope=* : core/module/plugin/template 로 대상 스코프 한정}
|
||||
{--no-activate : 설치 후 자동 활성화하지 않음}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '정책(supported_locales) 기준 미설치 번들 언어팩을 멱등 설치합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$targetLocales = $this->resolveTargetLocales();
|
||||
if (empty($targetLocales)) {
|
||||
$this->info('프로비저닝 대상 로케일이 없습니다 (base locale 외 supported_locales 미선언).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$scopeFilter = array_values(array_filter(array_map('strval', (array) $this->option('scope'))));
|
||||
$autoActivate = ! (bool) $this->option('no-activate');
|
||||
|
||||
$inScope = fn (LanguagePack $pack) => in_array($pack->locale, $targetLocales, true)
|
||||
&& (empty($scopeFilter) || in_array($pack->scope, $scopeFilter, true));
|
||||
|
||||
// 미설치 번들(신규 프로비저닝) + 드리프트 설치 행(수동 복구) 이 모두 대상이다.
|
||||
// 드리프트 행은 슬롯이 점유돼 있어 getUninstalledBundledPacks() 가 잡지 못하므로 따로 합친다.
|
||||
$candidates = $this->service->getUninstalledBundledPacks()->filter($inScope)
|
||||
->concat($this->service->getDriftedInstalledPacks()->filter($inScope))
|
||||
->values();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info(sprintf(
|
||||
'설치하거나 복구할 번들 언어팩이 없습니다 (로케일: %s). 이미 프로비저닝된 상태입니다.',
|
||||
implode(', ', $targetLocales),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$installed = 0;
|
||||
$repaired = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $pack) {
|
||||
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
|
||||
$blockedReason = $pack->getAttribute('install_blocked_reason');
|
||||
|
||||
// 대상 확장 미설치/미활성 등으로 설치가 불가한 팩은 best-effort 로 건너뛴다.
|
||||
if ($blockedReason !== null && $blockedReason !== '') {
|
||||
$this->warn(sprintf('건너뜀 (설치 불가: %s): %s', $blockedReason, $identifier));
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$isRepair = (bool) $pack->getAttribute('files_missing');
|
||||
$result = $this->service->installFromBundled($identifier, $autoActivate);
|
||||
$this->info(sprintf(
|
||||
'%s: %s v%s (status=%s)',
|
||||
$isRepair ? '복구' : '설치',
|
||||
$result->identifier,
|
||||
$result->version,
|
||||
$result->status,
|
||||
));
|
||||
$isRepair ? $repaired++ : $installed++;
|
||||
} catch (Throwable $e) {
|
||||
$this->warn(sprintf('실패 (건너뜀): %s — %s', $identifier, $e->getMessage()));
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'프로비저닝 완료 — 설치 %d, 복구 %d, 건너뜀 %d, 실패 %d',
|
||||
$installed,
|
||||
$repaired,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
// 성과가 하나도 없고 실패만 있었다면 실패로 보고 (멱등 재실행/전량 스킵은 성공).
|
||||
return $installed === 0 && $repaired === 0 && $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로비저닝 대상 로케일을 해석합니다.
|
||||
*
|
||||
* `--locale` 지정 시 그 값, 미지정 시 `config('app.supported_locales')` 에서 base locale(ko/en)을 제외한 집합.
|
||||
*
|
||||
* @return array<int, string> 대상 로케일 목록 (base locale 제외)
|
||||
*/
|
||||
private function resolveTargetLocales(): array
|
||||
{
|
||||
$explicit = array_values(array_filter(array_map('strval', (array) $this->option('locale'))));
|
||||
$source = ! empty($explicit)
|
||||
? $explicit
|
||||
: array_map('strval', (array) config('app.supported_locales', []));
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
$source,
|
||||
fn (string $locale) => $locale !== '' && ! LanguagePackBaseLocales::isBaseLocale($locale),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 언어팩을 제거합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 uninstall 커맨드와 동일한 운영 도구.
|
||||
* 보호된 팩(`is_protected=true`)은 차단되며, `--force` 없이는 yes/no 확인을 요구합니다.
|
||||
*/
|
||||
class UninstallLanguagePackCommand extends Command
|
||||
{
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:uninstall
|
||||
{identifier : 언어팩 식별자}
|
||||
{--cascade : 코어 팩 제거 시 동일 locale 의 module/plugin/template 팩도 inactive 로 강등}
|
||||
{--force : 확인 없이 즉시 제거}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩을 제거합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = (string) $this->argument('identifier');
|
||||
$cascade = (bool) $this->option('cascade');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$pack = $this->service->findByIdentifier($identifier);
|
||||
if (! $pack) {
|
||||
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $force && ! $this->unifiedConfirm("언어팩 '{$identifier}' 을(를) 제거하시겠습니까?", false)) {
|
||||
$this->info('취소되었습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->uninstall($pack, $cascade);
|
||||
$this->info("언어팩 제거 완료: {$identifier}");
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 제거 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 언어팩을 업데이트합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 update 커맨드와 동일한 운영 도구.
|
||||
* 동시성 가드 + 백업 + 롤백은 Service 의 performUpdate 가 담당합니다.
|
||||
*
|
||||
* 옵션:
|
||||
* --force 버전이 동일해도 강제 재적용 (확장 update --force 와 동일 의미)
|
||||
* --source=auto|bundled|github 업데이트 소스 우선순위. auto(기본): force 시 bundled, 외 GitHub
|
||||
*/
|
||||
class UpdateLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:update
|
||||
{identifier : 언어팩 식별자}
|
||||
{--force : 버전 변경이 없어도 강제 재적용 (_bundled 우선)}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github)}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '언어팩을 GitHub 또는 _bundled 신버전으로 업데이트합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$identifier = (string) $this->argument('identifier');
|
||||
$force = (bool) $this->option('force');
|
||||
$source = (string) $this->option('source');
|
||||
|
||||
if (! in_array($source, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error("알 수 없는 --source 값: {$source} (auto|bundled|github 중 선택)");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$pack = $this->service->findByIdentifier($identifier);
|
||||
if (! $pack) {
|
||||
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$fromVersion = $pack->version;
|
||||
// source=bundled 는 force 와 동등 (bundled 1순위 강제). source=github 는 force 무시.
|
||||
$effectiveForce = $force || $source === 'bundled';
|
||||
$updated = $this->service->performUpdate($pack, $effectiveForce);
|
||||
|
||||
$this->info(sprintf(
|
||||
'언어팩 업데이트 완료: %s (%s → %s)',
|
||||
$updated->identifier,
|
||||
$fromVersion,
|
||||
$updated->version,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('언어팩 업데이트 실패: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Models\SystemConfig;
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
|
||||
*/
|
||||
class MigrateSettingsToJsonCommand extends Command
|
||||
{
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
/**
|
||||
* 커맨드 시그니처
|
||||
*
|
||||
@@ -93,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
'debug_mode' => 'debug',
|
||||
'sql_query_log' => 'debug',
|
||||
'log_level' => 'debug',
|
||||
'outbound_proxy' => 'debug',
|
||||
'outbound_proxy_bypass' => 'debug',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -122,8 +127,6 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* DI 컨테이너 바인딩 시점 문제로 직접 인스턴스화합니다.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -133,7 +136,7 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
if (! Schema::hasTable('system_configs')) {
|
||||
$this->warn('system_configs 테이블이 존재하지 않습니다. 기본값으로 초기화합니다.');
|
||||
|
||||
$configRepository = new JsonConfigRepository();
|
||||
$configRepository = new JsonConfigRepository;
|
||||
$configRepository->initialize();
|
||||
|
||||
$this->info('기본 설정 파일이 생성되었습니다.');
|
||||
@@ -142,11 +145,11 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
}
|
||||
|
||||
// JsonConfigRepository 직접 인스턴스화 (DI 컨테이너 바인딩 전 실행 가능)
|
||||
$configRepository = new JsonConfigRepository();
|
||||
$configRepository = new JsonConfigRepository;
|
||||
|
||||
// 기존 JSON 파일 존재 확인
|
||||
if (file_exists(storage_path('app/settings/general.json')) && ! $this->option('force')) {
|
||||
if (! $this->confirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?')) {
|
||||
if (! $this->unifiedConfirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?', false)) {
|
||||
$this->info('마이그레이션이 취소되었습니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -196,10 +199,6 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
|
||||
/**
|
||||
* 문자열 값을 원래 타입으로 파싱합니다.
|
||||
*
|
||||
* @param string $value
|
||||
* @param string $type
|
||||
* @return mixed
|
||||
*/
|
||||
private function parseValue(string $value, string $type): mixed
|
||||
{
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Concerns\PrunesBuildOutput;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\GeneratesComponentManifest;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -11,6 +13,8 @@ use Symfony\Component\Process\Process;
|
||||
class BuildModuleCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use GeneratesComponentManifest;
|
||||
use PrunesBuildOutput;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -187,8 +191,21 @@ class BuildModuleCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
|
||||
if (! $watchMode) {
|
||||
$removed = $this->pruneBuildOutput($buildPath);
|
||||
|
||||
if ($removed !== []) {
|
||||
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
|
||||
}
|
||||
}
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -196,6 +213,14 @@ class BuildModuleCommand extends Command
|
||||
// 빌드 결과 파일 확인
|
||||
$this->displayBuildResults($buildPath, $identifier);
|
||||
|
||||
// 편집기 컴포넌트 매니페스트(components.json) 생성
|
||||
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
|
||||
if ($manifestResult['written']) {
|
||||
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
|
||||
} else {
|
||||
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
|
||||
}
|
||||
|
||||
// 캐시 버전 증가 (브라우저 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
$this->line(' - 캐시 버전 갱신됨');
|
||||
@@ -328,15 +353,30 @@ class BuildModuleCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -345,6 +385,11 @@ class BuildModuleCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -116,7 +116,7 @@ class CheckModuleUpdatesCommand extends Command
|
||||
$updateCount = 0;
|
||||
|
||||
foreach ($result['details'] as $detail) {
|
||||
$isUpdate = $detail['update_available'] ?? false;
|
||||
$isUpdate = (bool) ($detail['update_available'] ?? true);
|
||||
if ($isUpdate) {
|
||||
$updateCount++;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class CheckModuleUpdatesCommand extends Command
|
||||
$this->table($headers, $tableData);
|
||||
$this->newLine();
|
||||
$this->info(__('modules.commands.check_updates.summary', [
|
||||
'total' => count($result['details']),
|
||||
'total' => $result['checked_count'] ?? count($result['details']),
|
||||
'updates' => $updateCount,
|
||||
]));
|
||||
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use App\Services\LayoutResolverService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClearModuleCacheCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use InvalidatesLayoutCache;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -26,13 +34,19 @@ class ClearModuleCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private ModuleManager $moduleManager,
|
||||
private ModuleRepositoryInterface $moduleRepository
|
||||
protected LayoutRepositoryInterface $layoutRepository,
|
||||
private ExtensionBundleService $bundleService,
|
||||
private LayoutResolverService $layoutResolver
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
|
||||
* + 버전 포함 키)를 무효화한다.
|
||||
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -58,6 +72,11 @@ class ClearModuleCacheCommand extends Command
|
||||
|
||||
$clearedCount = $this->clearModuleCache($identifier);
|
||||
|
||||
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
|
||||
ModuleManager::invalidateModuleStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
|
||||
'module' => $identifier,
|
||||
'count' => $clearedCount,
|
||||
@@ -66,25 +85,19 @@ class ClearModuleCacheCommand extends Command
|
||||
// 모든 모듈 캐시 삭제
|
||||
$this->info(__('modules.commands.cache_clear.clearing_all'));
|
||||
|
||||
// 전체 모듈 캐시 키 삭제
|
||||
$cacheKeys = [
|
||||
'modules.all',
|
||||
'modules.active',
|
||||
'modules.installed',
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 각 모듈별 캐시 삭제
|
||||
$allModules = $this->moduleManager->getAllModules();
|
||||
foreach ($allModules as $moduleName => $module) {
|
||||
foreach ($this->moduleManager->getAllModules() as $module) {
|
||||
$clearedCount += $this->clearModuleCache($module->getIdentifier());
|
||||
}
|
||||
|
||||
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
|
||||
ModuleManager::invalidateModuleStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
|
||||
$clearedCount += $this->bundleService->clearBundles('module');
|
||||
|
||||
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
|
||||
}
|
||||
|
||||
@@ -107,26 +120,23 @@ class ClearModuleCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* 특정 모듈의 캐시를 삭제합니다.
|
||||
*
|
||||
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
|
||||
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
|
||||
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
|
||||
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
|
||||
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
|
||||
*
|
||||
* @param string $identifier 모듈 식별자
|
||||
* @return int 캐시를 무효화한 레이아웃 수
|
||||
*/
|
||||
private function clearModuleCache(string $identifier): int
|
||||
{
|
||||
$clearedCount = 0;
|
||||
$this->invalidateExtensionLayoutCache($identifier, 'module');
|
||||
$this->layoutResolver->clearResolutionCacheByModule($identifier);
|
||||
|
||||
// 모듈별 캐시 키
|
||||
$cacheKeys = [
|
||||
"module.config.{$identifier}",
|
||||
"module.info.{$identifier}",
|
||||
"module.routes.{$identifier}",
|
||||
"module.permissions.{$identifier}",
|
||||
"module.menus.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $clearedCount;
|
||||
return $this->layoutRepository
|
||||
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\RebuildsSearchIndex;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Permission;
|
||||
use App\Rules\ValidExtensionIdentifier;
|
||||
@@ -16,11 +18,16 @@ use Illuminate\Support\Facades\Validator;
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use RebuildsSearchIndex;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'module:install {identifier : 설치할 모듈 식별자}';
|
||||
protected $signature = 'module:install
|
||||
{identifier : 설치할 모듈 식별자}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
|
||||
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -56,22 +63,29 @@ class InstallModuleCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
try {
|
||||
// 모듈 디렉토리 스캔 및 로드
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
// 이미 설치된 모듈인지 확인
|
||||
// 이미 설치된 모듈인지 확인 (force 시 스킵하여 재설치/복구 허용)
|
||||
$existingModule = $this->moduleRepository->findByIdentifier($identifier);
|
||||
if ($existingModule) {
|
||||
if ($existingModule && ! $force) {
|
||||
$this->warn('⚠️ '.__('modules.commands.install.already_installed', ['module' => $identifier]));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($existingModule && $force) {
|
||||
$this->warn('⚠️ '.__('modules.commands.install.force_reinstall', ['module' => $identifier]));
|
||||
}
|
||||
|
||||
// 모듈 설치
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(ModuleManager::INSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->moduleManager->installModule($identifier, $onProgress);
|
||||
$result = $this->moduleManager->installModule($identifier, $onProgress, $vendorMode, $force);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -103,6 +117,9 @@ class InstallModuleCommand extends Command
|
||||
|
||||
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
|
||||
|
||||
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
|
||||
$this->handleSearchIndexRebuild();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ class ListModuleCommand extends Command
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'module:list
|
||||
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
|
||||
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
|
||||
{--hidden : 숨김(hidden=true) 모듈도 함께 출력}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -39,6 +40,7 @@ class ListModuleCommand extends Command
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
$statusFilter = $this->option('status');
|
||||
$includeHidden = (bool) $this->option('hidden');
|
||||
|
||||
// 상태 필터 검증
|
||||
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
|
||||
@@ -58,6 +60,11 @@ class ListModuleCommand extends Command
|
||||
|
||||
// 설치된 모듈 추가
|
||||
foreach ($installedModules as $identifier => $module) {
|
||||
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
|
||||
if (! $includeHidden && ! empty($module['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if ($statusFilter) {
|
||||
if ($statusFilter === 'uninstalled') {
|
||||
@@ -86,6 +93,11 @@ class ListModuleCommand extends Command
|
||||
// 미설치 모듈 추가
|
||||
if (! $statusFilter || $statusFilter === 'uninstalled') {
|
||||
foreach ($uninstalledModules as $identifier => $module) {
|
||||
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
|
||||
if (! $includeHidden && ! empty($module['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 상태 필터 (uninstalled 또는 필터 없음)
|
||||
if ($statusFilter && $statusFilter !== 'uninstalled') {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 모듈 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 module:build 패턴과 동일하게 positional identifier + --all 옵션 지원.
|
||||
*/
|
||||
class ModuleVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'module:vendor-bundle
|
||||
{identifier? : 빌드할 모듈 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 모듈 빌드}
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '모듈 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
$this->line('사용법:');
|
||||
$this->line(' php artisan module:vendor-bundle sirsoft-ecommerce');
|
||||
$this->line(' php artisan module:vendor-bundle --all');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 모듈 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class ModuleVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'module:vendor-verify
|
||||
{identifier? : 검증할 모듈 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 모듈 검증}';
|
||||
|
||||
protected $description = '모듈 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\ModuleManager;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
|
||||
class UninstallModuleCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -82,7 +84,7 @@ class UninstallModuleCommand extends Command
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm(__('modules.commands.uninstall.confirm_question'), false)) {
|
||||
if (! $this->unifiedConfirm(__('modules.commands.uninstall.confirm_question'), false)) {
|
||||
$this->info(__('modules.commands.uninstall.aborted'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -93,7 +95,13 @@ class UninstallModuleCommand extends Command
|
||||
$deleteData = $this->option('delete-data');
|
||||
$onProgress = $this->createProgressCallback(ModuleManager::UNINSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->moduleManager->uninstallModule($identifier, $deleteData, $onProgress);
|
||||
$result = $this->moduleManager->uninstallModule(
|
||||
$identifier,
|
||||
$deleteData,
|
||||
$onProgress,
|
||||
$uninstallFailureReason,
|
||||
$preservedBackups
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -106,6 +114,16 @@ class UninstallModuleCommand extends Command
|
||||
$this->info(' - '.__('modules.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
|
||||
$this->info(' - '.__('modules.commands.uninstall.menus_deleted', ['count' => $menusCount]));
|
||||
$this->info(' - '.__('modules.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
|
||||
|
||||
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
|
||||
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
|
||||
foreach ($preservedBackups ?? [] as $backup) {
|
||||
$this->info(' - '.__('modules.commands.uninstall.custom_preserved', [
|
||||
'directory' => $backup['directory'],
|
||||
'archive' => $backup['archive'],
|
||||
]));
|
||||
}
|
||||
|
||||
Log::info(__('modules.commands.uninstall.success', ['module' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -3,21 +3,33 @@
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Console\Commands\Traits\RebuildsSearchIndex;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateModuleCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use HasUnifiedConfirm;
|
||||
use RebuildsSearchIndex;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'module:update
|
||||
{identifier : 업데이트할 모듈 식별자}
|
||||
{--force : 버전 비교 없이 강제 업데이트}';
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
|
||||
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}
|
||||
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -29,7 +41,8 @@ class UpdateModuleCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private ModuleManager $moduleManager,
|
||||
private ModuleRepositoryInterface $moduleRepository
|
||||
private ModuleRepositoryInterface $moduleRepository,
|
||||
private LayoutExtensionService $layoutExtensionService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -41,6 +54,40 @@ class UpdateModuleCommand extends Command
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$force = $this->option('force');
|
||||
$layoutStrategy = (string) $this->option('layout-strategy');
|
||||
$sourceOption = (string) $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
if (! in_array($layoutStrategy, ['overwrite', 'keep'], true)) {
|
||||
$this->error('❌ --layout-strategy 는 overwrite 또는 keep 이어야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($sourceOption, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error('❌ --source 는 auto, bundled, github 중 하나여야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// --zip 과 --source 동시 지정 금지 (zip 이 우선 대상이므로)
|
||||
if ($zipPath !== null && $sourceOption !== 'auto') {
|
||||
$this->error('❌ --zip 은 --source 와 동시에 지정할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('❌ 지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
$sourceOverride = $sourceOption === 'auto' ? null : $sourceOption;
|
||||
|
||||
try {
|
||||
$this->moduleManager->loadModules();
|
||||
@@ -54,38 +101,90 @@ class UpdateModuleCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 업데이트 확인
|
||||
$checkResult = $this->moduleManager->checkModuleUpdate($identifier);
|
||||
// 업데이트 확인 (--zip 모드는 GitHub/번들 비교를 우회하므로 스킵)
|
||||
if ($zipPath === null) {
|
||||
$checkResult = $this->moduleManager->checkModuleUpdate($identifier);
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('modules.commands.update.no_update', ['module' => $identifier]));
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('modules.commands.update.no_update', ['module' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('modules.commands.update.force_mode'));
|
||||
} else {
|
||||
$this->info(__('modules.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('modules.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
}
|
||||
} else {
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $module->version]));
|
||||
$this->info('업데이트 소스: ZIP ('.$zipPath.')');
|
||||
$this->info('업데이트 버전: (module.json 추출 후 판별)');
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
$this->info(__('modules.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('modules.commands.update.force_mode'));
|
||||
} else {
|
||||
$this->info(__('modules.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('modules.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
|
||||
if ($layoutStrategy === 'overwrite') {
|
||||
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
|
||||
LayoutSourceType::Module,
|
||||
$identifier
|
||||
);
|
||||
|
||||
if (! empty($modifiedExtensions)) {
|
||||
$this->newLine();
|
||||
$this->warn('⚠️ '.__('modules.commands.update.modified_extensions_warning', [
|
||||
'count' => count($modifiedExtensions),
|
||||
]));
|
||||
|
||||
foreach ($modifiedExtensions as $extension) {
|
||||
$this->warn(__('modules.commands.update.modified_extension_item', [
|
||||
'target' => $extension['target_name'],
|
||||
'source' => $extension['source_identifier'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 확인 프롬프트 (--force 시 건너뜀)
|
||||
if (! $force && ! $this->confirm(__('modules.commands.update.confirm_question'), false)) {
|
||||
if (! $force && ! $this->unifiedConfirm(__('modules.commands.update.confirm_question'), false)) {
|
||||
$this->info(__('modules.commands.update.aborted'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 실행
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(ModuleManager::UPDATE_STEPS);
|
||||
|
||||
// upgrade step 콘솔 출력 콜백: progress bar 를 잠시 지우고 별도 줄에 출력
|
||||
$onUpgradeStep = function (string $version) use ($identifier): void {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->clear();
|
||||
}
|
||||
$this->line(" • [{$identifier}] upgrade step 실행: {$version}");
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->display();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$updateResult = $this->moduleManager->updateModule($identifier, $force, $onProgress);
|
||||
$updateResult = $this->moduleManager->updateModule(
|
||||
$identifier,
|
||||
$force,
|
||||
$onProgress,
|
||||
$vendorMode,
|
||||
$layoutStrategy,
|
||||
$onUpgradeStep,
|
||||
$sourceOverride,
|
||||
$zipPath,
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -104,8 +203,12 @@ class UpdateModuleCommand extends Command
|
||||
'module' => $identifier,
|
||||
'from' => $updateResult['from_version'],
|
||||
'to' => $updateResult['to_version'],
|
||||
'layout_strategy' => $layoutStrategy,
|
||||
]);
|
||||
|
||||
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
|
||||
$this->handleSearchIndexRebuild();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 사이트내 알림을 정리하는 커맨드
|
||||
*/
|
||||
class NotificationCleanupCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'notification:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보관 기간이 지난 사이트내 알림을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param NotificationService $service 알림 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(NotificationService $service): int
|
||||
{
|
||||
$this->info('알림 정리 시작...');
|
||||
|
||||
$config = config('notification.database_channel', []);
|
||||
$this->info(" 읽음 보관: {$config['read_retention_days']}일, 미읽음 보관: {$config['unread_retention_days']}일");
|
||||
|
||||
$result = $service->cleanup();
|
||||
|
||||
$this->info(" 읽음 알림 삭제: {$result['deleted_read']}건");
|
||||
$this->info(" 미읽음 알림 삭제: {$result['deleted_unread']}건");
|
||||
$this->info('알림 정리 완료');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 Sanctum 토큰 발급 커맨드.
|
||||
*
|
||||
* 임의 권한 식별자(코어/모듈/플러그인) 를 받아 권한 보유 관리자 유저를 즉시 생성하고
|
||||
* Sanctum 개인 액세스 토큰을 발급한다. 발급된 토큰은 stdout 마지막 줄에 출력되어
|
||||
* Playwright fixture 가 stdin 캡처로 사용한다.
|
||||
*
|
||||
* 보안 가드 (3중):
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
|
||||
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
|
||||
* ③ APP_DEBUG 강제 — bypass flag 인지 후 `config('app.debug')` 를 true 로 inline override.
|
||||
* SettingsServiceProvider 의 testing/bypass 분기가 이미 settings JSON 덮어쓰기를 건너뛰므로
|
||||
* production + debug=false 환경에서도 토큰 발급이 가능하다
|
||||
*
|
||||
* 환경 매트릭스:
|
||||
* - local + bypass=1 : 로컬 개발자 PC 에서 직접 spec 작성/실행
|
||||
* - testing + bypass=1 : CI / PHPUnit 환경에서 .env.testing 로 동작하는 E2E 통합 (testing 환경은 이미 testing 가드로 통과)
|
||||
* - production + bypass=1 : production DB 가 활성 호스트를 가리키는 환경(예: g7.dev) 에서 E2E
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:issue-token --permissions=core.templates.layouts.edit
|
||||
*
|
||||
* 로직 출처: tests/Feature/Api/Admin/LayoutAccessCheckEndpointTest::makeAdminUser (44~87행)
|
||||
*/
|
||||
class PlaywrightIssueToken extends Command
|
||||
{
|
||||
protected $signature = 'playwright:issue-token
|
||||
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}
|
||||
{--no-admin-role : admin 역할을 부여하지 않고 지정한 권한만 가진 계정을 만든다 (권한 분기 검증용)}
|
||||
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}';
|
||||
|
||||
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
|
||||
|
||||
/** 테스트 전용 역할 식별자 접두사 */
|
||||
private const TEST_ROLE_PREFIX = 'playwright_test_';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
|
||||
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
|
||||
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
|
||||
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 sanctum 토큰 발급 + 디버그 정보 누락 방지.
|
||||
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
|
||||
Config::set('app.debug', true);
|
||||
|
||||
// 발급 전 오래된 테스트 아티팩트 정리 — 이 커맨드는 호출마다 유저 1명 + 역할 1개를
|
||||
// 새로 만들지만 회수 주체가 없어 그대로 누적된다. 정리하지 않으면 회원/역할 관리 화면이
|
||||
// 테스트 잔재로 뒤덮인다(실측: 발급 2974회 → 유저 2974명·역할 2974개 잔존).
|
||||
// 시간 임계값을 두어 동시 실행 중인 다른 워커의 계정은 건드리지 않는다.
|
||||
$gcHours = (int) $this->option('gc-hours');
|
||||
if ($gcHours > 0) {
|
||||
$this->pruneStaleTestArtifacts($gcHours);
|
||||
}
|
||||
|
||||
$permissions = $this->option('permissions') ?: [];
|
||||
|
||||
// --no-admin-role: 지정한 권한만 가진 계정을 만든다.
|
||||
// 기본값(플래그 없음)은 종전대로 admin 역할을 함께 부여한다 — 기존 spec 무영향.
|
||||
$user = $this->makeAdminUser($permissions, ! $this->option('no-admin-role'));
|
||||
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
|
||||
|
||||
$this->line($token);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임계 시간보다 오래된 playwright 테스트 유저/역할을 정리한다.
|
||||
*
|
||||
* 대상: `playwright_test_*` 역할과, 그 역할이 유일한 확장 역할인 유저(= 이 커맨드가
|
||||
* factory 로 만든 계정). 실제 회원과 구분되도록 반드시 역할 소유 관계로만 판별한다.
|
||||
*
|
||||
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반 chunk()/each()
|
||||
* 는 다음 페이지가 줄어든 결과 집합을 지나쳐 일부를 건너뛴다.
|
||||
*
|
||||
* @param int $hours 이 시간보다 오래된 아티팩트만 정리
|
||||
* @return void
|
||||
*/
|
||||
private function pruneStaleTestArtifacts(int $hours): void
|
||||
{
|
||||
$threshold = now()->subHours($hours);
|
||||
$roles = 0;
|
||||
$users = 0;
|
||||
|
||||
Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')
|
||||
->where('created_at', '<', $threshold)
|
||||
->chunkById(100, function ($chunk) use (&$roles, &$users) {
|
||||
foreach ($chunk as $role) {
|
||||
// 이 역할을 가진 유저 중, 다른 playwright 역할이 없는 계정만 제거 대상
|
||||
foreach ($role->users()->get() as $user) {
|
||||
$otherTestRoles = $user->roles()
|
||||
->where('roles.id', '!=', $role->id)
|
||||
->where('roles.identifier', 'like', self::TEST_ROLE_PREFIX.'%')
|
||||
->count();
|
||||
if ($otherTestRoles === 0) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->forceDelete();
|
||||
$users++;
|
||||
}
|
||||
}
|
||||
|
||||
$role->permissions()->detach();
|
||||
$role->users()->detach();
|
||||
$role->delete();
|
||||
$roles++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($roles > 0) {
|
||||
$this->info("[gc] playwright 테스트 잔재 정리: 역할 {$roles}건, 유저 {$users}건");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
|
||||
*
|
||||
* 절차:
|
||||
* 1. User factory 로 신규 유저 생성
|
||||
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
|
||||
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
|
||||
* 4. (withAdminRole 일 때만) admin role 보장 (firstOrCreate) + 유저-역할 부여
|
||||
*
|
||||
* `withAdminRole = false` 는 **권한 분기(읽기 전용 등) 검증 전용**이다. 기본값 true 는
|
||||
* admin 역할을 함께 붙이므로, 요청한 권한만 가진 세션을 만들 수 없다 —
|
||||
* admin 역할이 사이트의 전체 권한을 보유하기 때문에 `--permissions` 로 좁혀도
|
||||
* 화면은 항상 최대 권한으로 렌더된다(실측: admin 역할 권한 263건).
|
||||
*
|
||||
* @param array<int, string> $permissions 부여할 권한 식별자 목록
|
||||
* @param bool $withAdminRole admin 역할 동반 부여 여부
|
||||
* @return User 생성된 유저
|
||||
*/
|
||||
private function makeAdminUser(array $permissions, bool $withAdminRole = true): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$permissionIds = [];
|
||||
foreach ($permissions as $identifier) {
|
||||
// Role/Permission 의 name·description 은 모델에서 array 로 캐스팅된다.
|
||||
// 여기서 json_encode 한 문자열을 넣으면 Eloquent 가 한 번 더 인코딩해
|
||||
// 이중 인코딩된 값이 저장되고, 관리자 화면(역할 선택 등)에 JSON 원문이
|
||||
// `{"ko":"\uXXXX…"}` 형태로 그대로 노출된다. 배열을 그대로 넘긴다.
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'name' => ['ko' => $identifier, 'en' => $identifier],
|
||||
'description' => ['ko' => $identifier, 'en' => $identifier],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
$permissionIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$testRole = Role::create([
|
||||
'identifier' => 'playwright_test_'.uniqid(),
|
||||
'name' => ['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin'],
|
||||
'description' => ['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if (! empty($permissionIds)) {
|
||||
$testRole->permissions()->sync($permissionIds);
|
||||
}
|
||||
|
||||
if ($withAdminRole) {
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => ['ko' => '관리자', 'en' => 'Admin'],
|
||||
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
}
|
||||
|
||||
// test role 은 항상 부여한다 — GC(pruneStaleTestArtifacts)가 이 역할로 테스트 계정을
|
||||
// 식별하므로, 빠지면 --no-admin-role 로 만든 계정이 영구 잔존한다.
|
||||
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Extension\Traits\ComputesLayoutContentHash;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 시드 레이아웃 설치/제거 커맨드.
|
||||
*
|
||||
* 레이아웃 편집기 E2E 중 **저장(PUT)까지 수행하는** spec 은 편집 결과가 그대로 영속된다.
|
||||
* 이들이 제품 화면(`home` / `admin_dashboard` 등)을 대상으로 하면 실행할 때마다 빈 표·차트가
|
||||
* 누적돼 개발 사이트가 오염된다(실측: `home` 20,321 → 33,696 bytes, 빈 표 7개). spec 안에
|
||||
* 원복 장치를 넣어도 편집기가 캐시한 문서를 다시 밀어 넣어 오염 시점 크기로 되돌아왔다.
|
||||
*
|
||||
* 그래서 저장 spec 전용 시드 화면(`e2e_sandbox`)을 제품 레이아웃과 분리한다. 이 커맨드가
|
||||
* 그 화면을 **활성 템플릿 디렉토리에만** 설치하므로 `_bundled`(배포 원본)은 손대지 않고,
|
||||
* 활성 디렉토리는 Git 무시 대상이라 릴리스 산출물에도 포함되지 않는다.
|
||||
*
|
||||
* 설치 절차(템플릿별):
|
||||
* ① `tests/Playwright/fixtures/seed-layouts/{identifier}.e2e_sandbox.json` 을
|
||||
* `templates/{identifier}/layouts/e2e_sandbox.json` 으로 복사
|
||||
* ② 활성 `templates/{identifier}/routes.json` 에 라우트 1건 추가(멱등 — 마커로 식별).
|
||||
* 편집기 라우트 트리는 routes.json 에서 만들어지므로 이 항목이 없으면 `?route=` 로
|
||||
* 시드 화면을 열 수 없다
|
||||
* ③ 시드 레이아웃 DB 행만 upsert. 편집기의 조회/저장 대상은 DB 행이라 이 단계가 없으면 404
|
||||
*
|
||||
* ③ 에서 `template:refresh-layout`(전체 재동기화)을 쓰지 않는 이유: 그 경로는 파일에 없는
|
||||
* DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌린다. 편집기 UI 로 저장한 변경은
|
||||
* 파일이 아니라 DB 에만 있으므로, E2E 를 돌릴 때마다 사람이 편집기로 만든 결과가 사라진다.
|
||||
* 시드 설치는 시드 행 하나만 건드린다.
|
||||
*
|
||||
* `--remove` 는 ①②를 되돌리고 시드 DB 행만 삭제한다 (다른 레이아웃 무영향).
|
||||
*
|
||||
* 보안 가드 (2중) — `PlaywrightIssueToken` 과 동일 규약:
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
|
||||
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout --remove
|
||||
*/
|
||||
class PlaywrightSeedLayout extends Command
|
||||
{
|
||||
use ComputesLayoutContentHash, InvalidatesLayoutCache;
|
||||
|
||||
protected $signature = 'playwright:seed-layout
|
||||
{--template=* : 대상 템플릿 identifier (생략 시 기본 대상 전체)}
|
||||
{--remove : 시드 레이아웃/라우트를 제거한다}';
|
||||
|
||||
protected $description = 'Playwright E2E 전용 시드 레이아웃 설치/제거 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
|
||||
|
||||
/** 시드 레이아웃 이름 — 파일명·DB name·라우트 layout 이 모두 이 값을 공유한다. */
|
||||
private const LAYOUT_NAME = 'e2e_sandbox';
|
||||
|
||||
/**
|
||||
* routes.json 의 시드 라우트 식별 마커.
|
||||
*
|
||||
* 경로 문자열은 템플릿 타입마다 다르므로(사용자 템플릿 vs 관리자 템플릿), 제거 시에는
|
||||
* 경로가 아니라 이 마커로 대상을 찾는다.
|
||||
*/
|
||||
private const ROUTE_MARKER = 'playwright-seed-layout';
|
||||
|
||||
/** 원본 routes.json 보관 파일 접미사 — 제거 시 서식까지 바이트 동일 복원용 */
|
||||
private const ROUTES_BACKUP_SUFFIX = '.playwright-backup';
|
||||
|
||||
/**
|
||||
* 기본 대상 템플릿 → 라우트 경로.
|
||||
*
|
||||
* 관리자 템플릿 라우트는 관리자 URL 프리픽스 규약을 따른다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const DEFAULT_TARGETS = [
|
||||
'sirsoft-basic' => '/e2e-sandbox',
|
||||
'sirsoft-admin_basic' => '*/admin/e2e-sandbox',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private LayoutRepositoryInterface $layoutRepository,
|
||||
private TemplateRepositoryInterface $templateRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드 (0 성공)
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
|
||||
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
|
||||
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
|
||||
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// settings JSON 이 debug 를 덮어써도 시드 작업이 막히지 않도록 인라인 강제
|
||||
// (PlaywrightIssueToken 과 동일 근거 — bypass flag 인지 시 SettingsServiceProvider 는 이미 우회 상태).
|
||||
Config::set('app.debug', true);
|
||||
|
||||
$remove = (bool) $this->option('remove');
|
||||
$targets = $this->resolveTargets();
|
||||
|
||||
if ($targets === []) {
|
||||
$this->error('대상 템플릿이 없습니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($targets as $identifier => $routePath) {
|
||||
if (! $this->processTemplate($identifier, $routePath, $remove)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--template` 옵션을 기본 대상 목록과 대조해 처리 대상을 결정합니다.
|
||||
*
|
||||
* @return array<string, string> identifier => 라우트 경로
|
||||
*/
|
||||
private function resolveTargets(): array
|
||||
{
|
||||
$requested = $this->option('template') ?: [];
|
||||
|
||||
if ($requested === []) {
|
||||
return self::DEFAULT_TARGETS;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
foreach ($requested as $identifier) {
|
||||
if (! isset(self::DEFAULT_TARGETS[$identifier])) {
|
||||
$this->warn("시드 라우트 경로가 정의되지 않은 템플릿입니다 — 건너뜁니다: {$identifier}");
|
||||
|
||||
continue;
|
||||
}
|
||||
$targets[$identifier] = self::DEFAULT_TARGETS[$identifier];
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 1개에 대해 시드 레이아웃 설치 또는 제거를 수행합니다.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $routePath 시드 라우트 경로
|
||||
* @param bool $remove true 면 제거, false 면 설치
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function processTemplate(string $identifier, string $routePath, bool $remove): bool
|
||||
{
|
||||
$templateDir = base_path("templates/{$identifier}");
|
||||
|
||||
// 활성 디렉토리가 없으면 그 템플릿은 설치되지 않은 것 — 조용히 건너뛴다
|
||||
// (E2E 대상 템플릿이 환경마다 다를 수 있으므로 실패로 취급하지 않는다).
|
||||
if (! File::isDirectory($templateDir)) {
|
||||
$this->warn("활성 템플릿 디렉토리가 없어 건너뜁니다: {$identifier}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$template = $this->templateRepository->findByIdentifier($identifier);
|
||||
if (! $template) {
|
||||
$this->warn("템플릿이 DB 에 없어 건너뜁니다: {$identifier}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$layoutPath = "{$templateDir}/layouts/".self::LAYOUT_NAME.'.json';
|
||||
|
||||
if ($remove) {
|
||||
if (File::exists($layoutPath)) {
|
||||
File::delete($layoutPath);
|
||||
}
|
||||
$this->removeSeedRoute($identifier, $templateDir);
|
||||
$this->layoutRepository->deleteByName($template->id, self::LAYOUT_NAME);
|
||||
} else {
|
||||
$fixturePath = base_path(
|
||||
'tests/Playwright/fixtures/seed-layouts/'.$identifier.'.'.self::LAYOUT_NAME.'.json'
|
||||
);
|
||||
if (! File::exists($fixturePath)) {
|
||||
$this->error("시드 레이아웃 fixture 가 없습니다: {$fixturePath}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = json_decode((string) File::get($fixturePath), true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($content)) {
|
||||
$this->error("시드 레이아웃 fixture 파싱 실패: {$fixturePath}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
File::copy($fixturePath, $layoutPath);
|
||||
$this->addSeedRoute($identifier, $templateDir, $routePath);
|
||||
|
||||
$this->layoutRepository->updateOrCreate(
|
||||
['template_id' => $template->id, 'name' => self::LAYOUT_NAME],
|
||||
[
|
||||
'content' => $content,
|
||||
'source_type' => 'template',
|
||||
'original_content_hash' => $this->computeContentHash($content),
|
||||
'original_content_size' => $this->computeContentSize($content),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 편집기/공개 서빙 캐시에서 시드 레이아웃 키를 지운다. 삭제 경로에서도 캐시가 남으면
|
||||
// 편집기가 stale 문서를 계속 받는다.
|
||||
$this->forgetSeedLayoutCache($template->id, $identifier);
|
||||
|
||||
$this->info(($remove ? '제거' : '설치')." 완료: {$identifier} ({$routePath})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 routes.json 에 시드 라우트를 추가합니다.
|
||||
*
|
||||
* 재작성은 JSON 재직렬화라 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라진다. 그래서 첫
|
||||
* 설치 시 원본을 `.playwright-backup` 으로 보관하고, 제거 때 그 파일을 그대로 되돌린다
|
||||
* (바이트 동일 복원). 백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의
|
||||
* routes.json 을 "원본" 으로 굳히지 않기 위함.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
|
||||
* @param string $routePath 등록할 시드 라우트 경로
|
||||
*/
|
||||
private function addSeedRoute(string $identifier, string $templateDir, string $routePath): void
|
||||
{
|
||||
$path = "{$templateDir}/routes.json";
|
||||
$routes = $this->readRoutes($identifier, $path);
|
||||
if ($routes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
|
||||
if (! File::exists($backupPath)) {
|
||||
File::copy($path, $backupPath);
|
||||
}
|
||||
|
||||
// 마커 항목을 먼저 걷어내 반복 실행이 라우트를 중복 추가하지 않게 한다.
|
||||
$entries = $this->withoutSeedRoutes($routes);
|
||||
$entries[] = [
|
||||
'_marker' => self::ROUTE_MARKER,
|
||||
'_comment' => 'Playwright E2E 전용 시드 화면 — playwright:seed-layout 이 설치/제거한다. 제품 라우트가 아니다.',
|
||||
'path' => $routePath,
|
||||
'layout' => self::LAYOUT_NAME,
|
||||
'auth_required' => str_starts_with($routePath, '*/admin'),
|
||||
'meta' => [
|
||||
'title' => 'E2E Sandbox',
|
||||
],
|
||||
];
|
||||
|
||||
$routes['routes'] = $entries;
|
||||
|
||||
File::put(
|
||||
$path,
|
||||
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 routes.json 에서 시드 라우트를 제거합니다.
|
||||
*
|
||||
* 백업이 있으면 그것을 그대로 복원해 서식까지 원상 복구한다. 백업이 없으면(다른 경로로 시드가
|
||||
* 들어간 경우) 마커 항목만 걷어낸 재직렬화로 대체한다.
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier
|
||||
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
|
||||
*/
|
||||
private function removeSeedRoute(string $identifier, string $templateDir): void
|
||||
{
|
||||
$path = "{$templateDir}/routes.json";
|
||||
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
|
||||
|
||||
if (File::exists($backupPath)) {
|
||||
File::put($path, File::get($backupPath));
|
||||
File::delete($backupPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$routes = $this->readRoutes($identifier, $path);
|
||||
if ($routes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$routes['routes'] = $this->withoutSeedRoutes($routes);
|
||||
|
||||
File::put(
|
||||
$path,
|
||||
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* routes.json 을 디코드해 반환합니다 (부재/파싱 실패 시 경고 후 null).
|
||||
*
|
||||
* @param string $identifier 템플릿 identifier (경고 메시지용)
|
||||
* @param string $path routes.json 절대 경로
|
||||
* @return array<string, mixed>|null 디코드 결과
|
||||
*/
|
||||
private function readRoutes(string $identifier, string $path): ?array
|
||||
{
|
||||
if (! File::exists($path)) {
|
||||
$this->warn("routes.json 이 없어 라우트 처리를 건너뜁니다: {$identifier}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$routes = json_decode((string) File::get($path), true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($routes)) {
|
||||
$this->warn("routes.json 을 파싱할 수 없어 라우트 처리를 건너뜁니다: {$identifier}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 배열에서 시드 마커가 붙은 항목을 제외해 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $routes 디코드된 routes.json
|
||||
* @return array<int, mixed> 마커 항목이 제거된 라우트 배열
|
||||
*/
|
||||
private function withoutSeedRoutes(array $routes): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$routes['routes'] ?? [],
|
||||
fn ($route) => ! is_array($route) || ($route['_marker'] ?? null) !== self::ROUTE_MARKER
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시드 레이아웃의 캐시 키를 삭제합니다.
|
||||
*
|
||||
* `InvalidatesLayoutCache::forgetLayoutCacheKeys` 는 레이아웃 객체의
|
||||
* `template_id` / `name` / `source_type` / `source_identifier` 만 읽으므로,
|
||||
* 삭제 후(모델 부재) 에도 동일 형태의 경량 객체로 호출할 수 있다.
|
||||
*
|
||||
* @param int $templateId 템플릿 ID
|
||||
* @param string $identifier 템플릿 identifier (버전 포함 공개 서빙 키에 사용)
|
||||
*/
|
||||
private function forgetSeedLayoutCache(int $templateId, string $identifier): void
|
||||
{
|
||||
$this->forgetLayoutCacheKeys(
|
||||
(object) [
|
||||
'template_id' => $templateId,
|
||||
'name' => self::LAYOUT_NAME,
|
||||
'source_type' => null,
|
||||
'source_identifier' => null,
|
||||
],
|
||||
$identifier
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 2단계 인증 픽스처 커맨드.
|
||||
*
|
||||
* 두 가지 일을 한다.
|
||||
* - `--ensure-user` : 알려진 비밀번호를 가진 Active 사용자를 만들거나 갱신한다.
|
||||
* (`--admin` 이면 admin 역할을 함께 부여)
|
||||
* - `--plant` : 지정한 challenge 에 알려진 인증 코드의 해시를 심는다.
|
||||
*
|
||||
* 인증번호는 해시로만 저장되므로 브라우저 테스트가 되읽을 수 없다. 메일을 실제로 열어
|
||||
* 보는 대신 알려진 값을 심어 "코드가 맞을 때" 를 재현한다 — 검증 대상은 코드 생성이
|
||||
* 아니라 로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
|
||||
* 심는 방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 동일하다.
|
||||
*
|
||||
* 보안 가드 (3중, `playwright:issue-token` 과 동형):
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'`. production 웹 요청에서 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 필수
|
||||
* ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작이 가능하도록
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --ensure-user=user --password='Passw0rd!2fa'
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790
|
||||
*/
|
||||
class PlaywrightSeedTwoFactor extends Command
|
||||
{
|
||||
protected $signature = 'playwright:seed-two-factor
|
||||
{--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
|
||||
{--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
|
||||
{--admin : --ensure-user 계정에 admin 역할을 부여한다}
|
||||
{--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
|
||||
{--code=135790 : --plant 가 심을 인증 코드}
|
||||
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
|
||||
{--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
|
||||
|
||||
protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
|
||||
|
||||
/** 테스트 전용 계정 이메일 접두사 */
|
||||
private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
|
||||
// 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
|
||||
// 환경변수이므로 config:cache 의 영향을 받지 않는다.
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
|
||||
Config::set('app.debug', true);
|
||||
|
||||
$gcHours = (int) $this->option('gc-hours');
|
||||
if ($gcHours > 0) {
|
||||
$this->pruneStaleTestUsers($gcHours);
|
||||
}
|
||||
|
||||
$did = false;
|
||||
|
||||
// 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
|
||||
// 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
|
||||
if ($this->option('purge-users')) {
|
||||
$this->purgeTestUsers();
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($suffix = $this->option('ensure-user')) {
|
||||
$this->ensureUser((string) $suffix);
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($challengeId = $this->option('plant')) {
|
||||
if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if (! $did) {
|
||||
$this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
|
||||
*
|
||||
* @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
|
||||
*/
|
||||
private function ensureUser(string $suffix): void
|
||||
{
|
||||
$email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
|
||||
$password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if ($user === null) {
|
||||
$user = User::factory()->create([
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
]);
|
||||
} else {
|
||||
// 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
|
||||
// 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
|
||||
// 잠금 상태가 다음 실행으로 새지 않게 한다.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
'locked_until' => null,
|
||||
'failed_login_attempts' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($this->option('admin')) {
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => ['ko' => '관리자', 'en' => 'Admin'],
|
||||
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
}
|
||||
} else {
|
||||
// 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
|
||||
// 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
|
||||
$user->roles()->detach();
|
||||
}
|
||||
|
||||
$this->line($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 에 알려진 인증 코드의 해시를 심습니다.
|
||||
*
|
||||
* @param string $challengeId challenge UUID
|
||||
* @param string $code 심을 인증 코드
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function plantCode(string $challengeId, string $code): bool
|
||||
{
|
||||
$log = IdentityVerificationLog::find($challengeId);
|
||||
|
||||
if ($log === null) {
|
||||
$this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $log->metadata ?? [];
|
||||
$metadata['code_hash'] = Hash::make($code);
|
||||
|
||||
$log->metadata = $metadata;
|
||||
$log->save();
|
||||
|
||||
$this->line($code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
|
||||
*
|
||||
* 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
|
||||
* 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
|
||||
*/
|
||||
private function purgeTestUsers(): void
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
|
||||
}
|
||||
|
||||
/**
|
||||
* 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
|
||||
*
|
||||
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
|
||||
* chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
|
||||
*
|
||||
* @param int $hours 이 시간보다 오래된 계정만 정리
|
||||
*/
|
||||
private function pruneStaleTestUsers(int $hours): void
|
||||
{
|
||||
$threshold = now()->subHours($hours);
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->where('created_at', '<', $threshold)
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Concerns\PrunesBuildOutput;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\GeneratesComponentManifest;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -11,6 +13,8 @@ use Symfony\Component\Process\Process;
|
||||
class BuildPluginCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use GeneratesComponentManifest;
|
||||
use PrunesBuildOutput;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -187,8 +191,21 @@ class BuildPluginCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
|
||||
if (! $watchMode) {
|
||||
$removed = $this->pruneBuildOutput($buildPath);
|
||||
|
||||
if ($removed !== []) {
|
||||
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
|
||||
}
|
||||
}
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -196,6 +213,14 @@ class BuildPluginCommand extends Command
|
||||
// 빌드 결과 파일 확인
|
||||
$this->displayBuildResults($buildPath, $identifier);
|
||||
|
||||
// 편집기 컴포넌트 매니페스트(components.json) 생성
|
||||
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
|
||||
if ($manifestResult['written']) {
|
||||
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
|
||||
} else {
|
||||
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
|
||||
}
|
||||
|
||||
// 캐시 버전 증가 (브라우저 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
$this->line(' - 캐시 버전 갱신됨');
|
||||
@@ -328,15 +353,30 @@ class BuildPluginCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -345,6 +385,11 @@ class BuildPluginCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -116,7 +116,7 @@ class CheckPluginUpdatesCommand extends Command
|
||||
$updateCount = 0;
|
||||
|
||||
foreach ($result['details'] as $detail) {
|
||||
$isUpdate = $detail['update_available'] ?? false;
|
||||
$isUpdate = (bool) ($detail['update_available'] ?? true);
|
||||
if ($isUpdate) {
|
||||
$updateCount++;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class CheckPluginUpdatesCommand extends Command
|
||||
$this->table($headers, $tableData);
|
||||
$this->newLine();
|
||||
$this->info(__('plugins.commands.check_updates.summary', [
|
||||
'total' => count($result['details']),
|
||||
'total' => $result['checked_count'] ?? count($result['details']),
|
||||
'updates' => $updateCount,
|
||||
]));
|
||||
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClearPluginCacheCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use InvalidatesLayoutCache;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -26,13 +33,20 @@ class ClearPluginCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginManager $pluginManager,
|
||||
private PluginRepositoryInterface $pluginRepository
|
||||
protected LayoutRepositoryInterface $layoutRepository,
|
||||
private ExtensionBundleService $bundleService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
|
||||
* + 버전 포함 키)를 무효화한다.
|
||||
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
|
||||
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
|
||||
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -58,6 +72,11 @@ class ClearPluginCacheCommand extends Command
|
||||
|
||||
$clearedCount = $this->clearPluginCache($identifier);
|
||||
|
||||
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
|
||||
PluginManager::invalidatePluginStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
|
||||
'plugin' => $identifier,
|
||||
'count' => $clearedCount,
|
||||
@@ -66,25 +85,19 @@ class ClearPluginCacheCommand extends Command
|
||||
// 모든 플러그인 캐시 삭제
|
||||
$this->info(__('plugins.commands.cache_clear.clearing_all'));
|
||||
|
||||
// 전체 플러그인 캐시 키 삭제
|
||||
$cacheKeys = [
|
||||
'plugins.all',
|
||||
'plugins.active',
|
||||
'plugins.installed',
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 각 플러그인별 캐시 삭제
|
||||
$allPlugins = $this->pluginManager->getAllPlugins();
|
||||
foreach ($allPlugins as $pluginName => $plugin) {
|
||||
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
|
||||
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
|
||||
}
|
||||
|
||||
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
|
||||
PluginManager::invalidatePluginStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
|
||||
$clearedCount += $this->bundleService->clearBundles('plugin');
|
||||
|
||||
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
|
||||
}
|
||||
|
||||
@@ -107,25 +120,18 @@ class ClearPluginCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* 특정 플러그인의 캐시를 삭제합니다.
|
||||
*
|
||||
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
|
||||
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
|
||||
*
|
||||
* @return int 캐시를 무효화한 레이아웃 수
|
||||
*/
|
||||
private function clearPluginCache(string $identifier): int
|
||||
{
|
||||
$clearedCount = 0;
|
||||
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
|
||||
|
||||
// 플러그인별 캐시 키 (메뉴 캐시 없음)
|
||||
$cacheKeys = [
|
||||
"plugin.config.{$identifier}",
|
||||
"plugin.info.{$identifier}",
|
||||
"plugin.routes.{$identifier}",
|
||||
"plugin.permissions.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $clearedCount;
|
||||
return $this->layoutRepository
|
||||
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\RebuildsSearchIndex;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Models\Permission;
|
||||
use App\Rules\ValidExtensionIdentifier;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -15,11 +17,16 @@ use Illuminate\Support\Facades\Validator;
|
||||
class InstallPluginCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use RebuildsSearchIndex;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'plugin:install {identifier : 설치할 플러그인 식별자}';
|
||||
protected $signature = 'plugin:install
|
||||
{identifier : 설치할 플러그인 식별자}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
|
||||
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -55,22 +62,29 @@ class InstallPluginCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
try {
|
||||
// 플러그인 디렉토리 스캔 및 로드
|
||||
$this->pluginManager->loadPlugins();
|
||||
|
||||
// 이미 설치된 플러그인인지 확인
|
||||
// 이미 설치된 플러그인인지 확인 (force 시 스킵하여 재설치/복구 허용)
|
||||
$existingPlugin = $this->pluginRepository->findByIdentifier($identifier);
|
||||
if ($existingPlugin) {
|
||||
if ($existingPlugin && ! $force) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.install.already_installed', ['plugin' => $identifier]));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($existingPlugin && $force) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.install.force_reinstall', ['plugin' => $identifier]));
|
||||
}
|
||||
|
||||
// 플러그인 설치
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(PluginManager::INSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->pluginManager->installPlugin($identifier, $onProgress);
|
||||
$result = $this->pluginManager->installPlugin($identifier, $onProgress, $vendorMode, $force);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -100,6 +114,9 @@ class InstallPluginCommand extends Command
|
||||
|
||||
Log::info(__('plugins.commands.install.success', ['plugin' => $identifier]));
|
||||
|
||||
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
|
||||
$this->handleSearchIndexRebuild();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ class ListPluginCommand extends Command
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'plugin:list
|
||||
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
|
||||
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
|
||||
{--hidden : 숨김(hidden=true) 플러그인도 함께 출력}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -39,6 +40,7 @@ class ListPluginCommand extends Command
|
||||
$this->pluginManager->loadPlugins();
|
||||
|
||||
$statusFilter = $this->option('status');
|
||||
$includeHidden = (bool) $this->option('hidden');
|
||||
|
||||
// 상태 필터 검증
|
||||
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
|
||||
@@ -58,6 +60,11 @@ class ListPluginCommand extends Command
|
||||
|
||||
// 설치된 플러그인 추가
|
||||
foreach ($installedPlugins as $identifier => $plugin) {
|
||||
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
|
||||
if (! $includeHidden && ! empty($plugin['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if ($statusFilter) {
|
||||
if ($statusFilter === 'uninstalled') {
|
||||
@@ -86,6 +93,11 @@ class ListPluginCommand extends Command
|
||||
// 미설치 플러그인 추가
|
||||
if (! $statusFilter || $statusFilter === 'uninstalled') {
|
||||
foreach ($uninstalledPlugins as $identifier => $plugin) {
|
||||
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
|
||||
if (! $includeHidden && ! empty($plugin['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 상태 필터 (uninstalled 또는 필터 없음)
|
||||
if ($statusFilter && $statusFilter !== 'uninstalled') {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 플러그인 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 plugin:build 패턴과 동일하게 positional identifier + --all 옵션 지원.
|
||||
*/
|
||||
class PluginVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'plugin:vendor-bundle
|
||||
{identifier? : 빌드할 플러그인 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 플러그인 빌드}
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '플러그인 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
$this->line('사용법:');
|
||||
$this->line(' php artisan plugin:vendor-bundle sirsoft-payment');
|
||||
$this->line(' php artisan plugin:vendor-bundle --all');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 플러그인 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class PluginVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'plugin:vendor-verify
|
||||
{identifier? : 검증할 플러그인 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 플러그인 검증}';
|
||||
|
||||
protected $description = '플러그인 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\PluginManager;
|
||||
@@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Log;
|
||||
class UninstallPluginCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -79,7 +81,7 @@ class UninstallPluginCommand extends Command
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm(__('plugins.commands.uninstall.confirm_question'), false)) {
|
||||
if (! $this->unifiedConfirm(__('plugins.commands.uninstall.confirm_question'), false)) {
|
||||
$this->info(__('plugins.commands.uninstall.aborted'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -90,7 +92,13 @@ class UninstallPluginCommand extends Command
|
||||
$deleteData = $this->option('delete-data');
|
||||
$onProgress = $this->createProgressCallback(PluginManager::UNINSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->pluginManager->uninstallPlugin($identifier, $deleteData, $onProgress);
|
||||
$result = $this->pluginManager->uninstallPlugin(
|
||||
$identifier,
|
||||
$deleteData,
|
||||
$onProgress,
|
||||
$uninstallFailureReason,
|
||||
$preservedBackups
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -102,6 +110,16 @@ class UninstallPluginCommand extends Command
|
||||
$this->info(' - '.__('plugins.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
|
||||
$this->info(' - '.__('plugins.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
|
||||
$this->info(' - '.__('plugins.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
|
||||
|
||||
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
|
||||
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
|
||||
foreach ($preservedBackups ?? [] as $backup) {
|
||||
$this->info(' - '.__('plugins.commands.uninstall.custom_preserved', [
|
||||
'directory' => $backup['directory'],
|
||||
'archive' => $backup['archive'],
|
||||
]));
|
||||
}
|
||||
|
||||
Log::info(__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -3,21 +3,33 @@
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Console\Commands\Traits\RebuildsSearchIndex;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdatePluginCommand extends Command
|
||||
{
|
||||
use HasProgressBar;
|
||||
use HasUnifiedConfirm;
|
||||
use RebuildsSearchIndex;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'plugin:update
|
||||
{identifier : 업데이트할 플러그인 식별자}
|
||||
{--force : 버전 비교 없이 강제 업데이트}';
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
|
||||
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}
|
||||
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -29,7 +41,8 @@ class UpdatePluginCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginManager $pluginManager,
|
||||
private PluginRepositoryInterface $pluginRepository
|
||||
private PluginRepositoryInterface $pluginRepository,
|
||||
private LayoutExtensionService $layoutExtensionService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -41,6 +54,39 @@ class UpdatePluginCommand extends Command
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$force = $this->option('force');
|
||||
$layoutStrategy = (string) $this->option('layout-strategy');
|
||||
$sourceOption = (string) $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
if (! in_array($layoutStrategy, ['overwrite', 'keep'], true)) {
|
||||
$this->error('❌ --layout-strategy 는 overwrite 또는 keep 이어야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($sourceOption, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error('❌ --source 는 auto, bundled, github 중 하나여야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null && $sourceOption !== 'auto') {
|
||||
$this->error('❌ --zip 은 --source 와 동시에 지정할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('❌ 지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
$sourceOverride = $sourceOption === 'auto' ? null : $sourceOption;
|
||||
|
||||
try {
|
||||
$this->pluginManager->loadPlugins();
|
||||
@@ -54,38 +100,90 @@ class UpdatePluginCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 업데이트 확인
|
||||
$checkResult = $this->pluginManager->checkPluginUpdate($identifier);
|
||||
// 업데이트 확인 (--zip 모드는 GitHub/번들 비교를 우회하므로 스킵)
|
||||
if ($zipPath === null) {
|
||||
$checkResult = $this->pluginManager->checkPluginUpdate($identifier);
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('plugins.commands.update.no_update', ['plugin' => $identifier]));
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('plugins.commands.update.no_update', ['plugin' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.update.force_mode'));
|
||||
} else {
|
||||
$this->info(__('plugins.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('plugins.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
}
|
||||
} else {
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $plugin->version]));
|
||||
$this->info('업데이트 소스: ZIP ('.$zipPath.')');
|
||||
$this->info('업데이트 버전: (plugin.json 추출 후 판별)');
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
$this->info(__('plugins.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.update.force_mode'));
|
||||
} else {
|
||||
$this->info(__('plugins.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('plugins.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
|
||||
if ($layoutStrategy === 'overwrite') {
|
||||
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
|
||||
LayoutSourceType::Plugin,
|
||||
$identifier
|
||||
);
|
||||
|
||||
if (! empty($modifiedExtensions)) {
|
||||
$this->newLine();
|
||||
$this->warn('⚠️ '.__('plugins.commands.update.modified_extensions_warning', [
|
||||
'count' => count($modifiedExtensions),
|
||||
]));
|
||||
|
||||
foreach ($modifiedExtensions as $extension) {
|
||||
$this->warn(__('plugins.commands.update.modified_extension_item', [
|
||||
'target' => $extension['target_name'],
|
||||
'source' => $extension['source_identifier'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 확인 프롬프트 (--force 시 건너뜀)
|
||||
if (! $force && ! $this->confirm(__('plugins.commands.update.confirm_question'), false)) {
|
||||
if (! $force && ! $this->unifiedConfirm(__('plugins.commands.update.confirm_question'), false)) {
|
||||
$this->info(__('plugins.commands.update.aborted'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 실행
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(PluginManager::UPDATE_STEPS);
|
||||
|
||||
// upgrade step 콘솔 출력 콜백: progress bar 를 잠시 지우고 별도 줄에 출력
|
||||
$onUpgradeStep = function (string $version) use ($identifier): void {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->clear();
|
||||
}
|
||||
$this->line(" • [{$identifier}] upgrade step 실행: {$version}");
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->display();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$updateResult = $this->pluginManager->updatePlugin($identifier, $force, $onProgress);
|
||||
$updateResult = $this->pluginManager->updatePlugin(
|
||||
$identifier,
|
||||
$force,
|
||||
$onProgress,
|
||||
$vendorMode,
|
||||
$layoutStrategy,
|
||||
$onUpgradeStep,
|
||||
$sourceOverride,
|
||||
$zipPath,
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -104,8 +202,12 @@ class UpdatePluginCommand extends Command
|
||||
'plugin' => $identifier,
|
||||
'from' => $updateResult['from_version'],
|
||||
'to' => $updateResult['to_version'],
|
||||
'layout_strategy' => $layoutStrategy,
|
||||
]);
|
||||
|
||||
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
|
||||
$this->handleSearchIndexRebuild();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
|
||||
*/
|
||||
class PruneActivityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ActivityLogService $service 활동 로그 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ActivityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
|
||||
*
|
||||
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
|
||||
*/
|
||||
class PruneIdentityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$purged = $service->purge($days);
|
||||
|
||||
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NotificationLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
|
||||
*/
|
||||
class PruneNotificationLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param NotificationLogService $service 알림 발송 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(NotificationLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AttachmentService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 소유자 없는 고아 첨부파일 정리 커맨드
|
||||
*
|
||||
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
|
||||
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
|
||||
*
|
||||
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
|
||||
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
|
||||
*
|
||||
* @example php artisan attachments:prune-orphans --dry-run
|
||||
* @example php artisan attachments:prune-orphans --days=7 --limit=100
|
||||
*/
|
||||
class PruneOrphanAttachmentsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* 커맨드 이름 및 시그니처
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'attachments:prune-orphans
|
||||
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
|
||||
{--limit=500 : 한 번에 처리할 최대 건수}
|
||||
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
|
||||
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
|
||||
|
||||
/**
|
||||
* 커맨드 설명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
|
||||
|
||||
/**
|
||||
* @param AttachmentService $attachmentService 첨부파일 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드 실행
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
|
||||
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$days = $this->resolveRetentionDays();
|
||||
|
||||
if ($days < 1) {
|
||||
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
|
||||
$days,
|
||||
$result['scanned'],
|
||||
$result['deleted'],
|
||||
$result['failed'],
|
||||
));
|
||||
|
||||
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
|
||||
'days' => $days,
|
||||
'limit' => $limit,
|
||||
] + $result);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 정리 토글을 false 폴백으로 조회합니다.
|
||||
*
|
||||
* @return bool 자동 정리 활성 여부
|
||||
*/
|
||||
private function isCleanupEnabled(): bool
|
||||
{
|
||||
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
|
||||
*
|
||||
* @return int 보존기간(일)
|
||||
*/
|
||||
private function resolveRetentionDays(): int
|
||||
{
|
||||
$option = $this->option('days');
|
||||
|
||||
if ($option !== null && $option !== '') {
|
||||
return (int) $option;
|
||||
}
|
||||
|
||||
return (int) g7_core_settings('upload.orphan_retention_days', 30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ScheduleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
|
||||
*
|
||||
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneScheduleHistoryCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ScheduleService $service 스케줄 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ScheduleService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->pruneHistory($days);
|
||||
|
||||
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Seo\SeoCacheStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
|
||||
*
|
||||
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneSeoCacheStatsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(SeoCacheStatsService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->cleanup($days);
|
||||
|
||||
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\StorageLeftoverPruneService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 스토리지 잔존물 정리 커맨드
|
||||
*
|
||||
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
|
||||
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
|
||||
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
|
||||
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
|
||||
*/
|
||||
class PruneStorageLeftoversCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'storage:prune-leftovers
|
||||
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
|
||||
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
|
||||
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
|
||||
|
||||
/**
|
||||
* 대상군 키 → 출력 라벨.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const GROUP_LABELS = [
|
||||
'staging' => '업데이트 스테이징 잔존',
|
||||
'temp' => '코어 임시 산출물',
|
||||
'vendor_bundle_staging' => 'vendor 번들 스테이징',
|
||||
'extension_backups' => '확장 백업',
|
||||
'core_backups' => '코어 백업',
|
||||
'legacy_browser_log' => '레거시 브라우저 로그',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(StorageLeftoverPruneService $service): int
|
||||
{
|
||||
$days = max(0, (int) $this->option('days'));
|
||||
$backupDays = max(0, (int) $this->option('backup-days'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$result = $service->prune($days, $backupDays, $dryRun);
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach (self::GROUP_LABELS as $key => $label) {
|
||||
$paths = $result[$key] ?? [];
|
||||
$count = count($paths);
|
||||
$total += $count;
|
||||
|
||||
$this->line("{$label}: {$count}건");
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($paths as $path) {
|
||||
$this->line(" - {$path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
|
||||
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시(bake)를 수동 수행하는 커맨드
|
||||
*
|
||||
* 수명주기 이벤트(terminating 트리거)와 blade 자가 치유가 정상 경로지만,
|
||||
* 배포 직후 워밍이나 수동 복구가 필요할 때 이 커맨드로 즉시 게시한다.
|
||||
* 설치기 완료 단계에서도 호출된다 (#122).
|
||||
*/
|
||||
class PublishExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:publish {--force : 게시 완료 상태여도 강제 재게시}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '부트스트랩 리소스(다국어·컴포넌트·라우트·번들·템플릿 에셋)를 정적 파일로 게시합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
if (! $service->isEnabled()) {
|
||||
$this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// root 로 실행 중이면 경고만 하고 **계속 진행**한다 — 명시적 명령은 운영자 책임이고, 중단 가드는
|
||||
// 두지 않는다(#651 D3). 게시가 만드는 캐시 락 샤드·병합 번들이 root 소유로 남으면 이후 웹 요청의
|
||||
// 캐시 쓰기가 Permission denied 로 죽으므로(전면 500 실사례) 웹 계정 실행 형태를 함께 적는다.
|
||||
if (StatusExtensionStaticCacheCommand::isRootProcess()) {
|
||||
$this->warn('root 로 실행 중입니다 — 이 계정으로 게시하면 캐시 폴더·병합 번들이 root 소유가 되어 이후 웹 요청이 실패할 수 있습니다.');
|
||||
$this->warn('웹 계정으로 실행하세요: '.StatusExtensionStaticCacheCommand::publishCommandHint());
|
||||
}
|
||||
|
||||
$published = $service->publishCurrent(force: (bool) $this->option('force'));
|
||||
|
||||
if (! $published) {
|
||||
$this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 레이아웃 버전의 변경 요약(changes_summary)을 현재 알고리즘으로 재계산하는 커맨드
|
||||
*
|
||||
* 버전 변경량 측정이 키 경로 단위에서 라인 단위(버전 비교 diff 뷰와 동일 SSoT)로
|
||||
* 바뀌기 전에 저장된 버전들은 옛 기준의 changes_summary 를 담고 있어, 버전 목록의
|
||||
* 변경량이 상세 diff 와 불일치한다. 본 커맨드로 기존 버전을 일괄 재계산해 정합시킨다.
|
||||
*/
|
||||
class RecalculateLayoutVersionDiffsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'layout-versions:recalculate-diffs';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '레이아웃 버전의 변경 요약을 현재 라인 단위 알고리즘으로 재계산합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param LayoutVersionRepositoryInterface $repository 버전 저장소
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(LayoutVersionRepositoryInterface $repository): int
|
||||
{
|
||||
$this->info('레이아웃 버전 변경 요약 재계산을 시작합니다...');
|
||||
|
||||
$updated = $repository->recalculateAllChangeSummaries();
|
||||
|
||||
$this->info("버전 {$updated}건의 변경 요약을 재계산했습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Search;
|
||||
|
||||
use App\Console\Commands\Traits\HasUnifiedConfirm;
|
||||
use App\Enums\SearchIndexStatus;
|
||||
use App\Search\DTO\SearchIndexHealth;
|
||||
use App\Search\SearchIndexMaintenanceManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 검색 인덱스 점검·재생성 커맨드
|
||||
*
|
||||
* 인덱스는 있는데 내용이 색인되지 않아 검색이 조용히 0건을 돌려주는 상태를 검출하고,
|
||||
* 운영자가 선택하면 재생성합니다. 이 상태는 예외도 로그도 남기지 않으므로
|
||||
* "원래 검색이 안 되는 줄" 알고 지나가기 쉽습니다.
|
||||
*
|
||||
* 점검 방법은 **활성 Scout 엔진의 유지보수기**가 정합니다. 확장이 자체 검색 엔진을
|
||||
* 등록하면서 유지보수기를 함께 등록하면 이 커맨드가 그대로 그 엔진을 다룹니다
|
||||
* (`core.search.index_maintainers` 필터 훅).
|
||||
*
|
||||
* 사용 예시:
|
||||
* php artisan search:index # 활성 엔진의 인덱스 점검 (읽기 전용)
|
||||
* php artisan search:index --repair # 색인 누락 인덱스 재생성
|
||||
* php artisan search:index --filter=table=pages # 엔진별 필터 전달
|
||||
* php artisan search:index --json # 기계 판독용 출력
|
||||
*/
|
||||
class SearchIndexCommand extends Command
|
||||
{
|
||||
use HasUnifiedConfirm;
|
||||
|
||||
protected $signature = 'search:index
|
||||
{--repair : 색인이 누락된 인덱스를 재생성 (미지정 시 점검만)}
|
||||
{--filter=* : 엔진별 필터 (key=value 형태, 다중 지정 가능. FULLTEXT: table, index, samples)}
|
||||
{--json : 기계 판독용 JSON 출력}';
|
||||
|
||||
protected $description = '활성 검색 엔진의 인덱스가 실제로 내용을 색인하고 있는지 점검하고, 누락 시 재생성합니다';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param SearchIndexMaintenanceManager $manager 검색 인덱스 유지보수 진입점
|
||||
* @return int 종료 코드 (색인 누락 잔존 시 1)
|
||||
*/
|
||||
public function handle(SearchIndexMaintenanceManager $manager): int
|
||||
{
|
||||
$driver = $manager->driver();
|
||||
$filters = $this->parseFilters();
|
||||
|
||||
if (! $manager->hasMaintainer()) {
|
||||
$this->components->warn(__('search.index.no_maintainer', ['driver' => $driver]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (($reason = $manager->unavailableReason()) !== null) {
|
||||
$this->components->warn($reason);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$results = $manager->inspect($filters);
|
||||
|
||||
if ($results === []) {
|
||||
$this->components->warn(__('search.index.no_targets', ['driver' => $driver]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$report = null;
|
||||
|
||||
if ($this->option('repair')) {
|
||||
$targets = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
|
||||
|
||||
if ($targets !== [] && $this->confirmRebuild($targets)) {
|
||||
$report = $manager->repairStale($filters);
|
||||
$results = $manager->inspect($filters);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'driver' => $driver,
|
||||
'results' => array_map(fn (SearchIndexHealth $h) => $h->toArray(), $results),
|
||||
'repair' => $report?->toArray(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$this->render($driver, $results, $report?->summary());
|
||||
}
|
||||
|
||||
$remaining = array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild());
|
||||
|
||||
return ($remaining === [] && ($report === null || $report->failed === [])) ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--filter=key=value` 옵션을 연관 배열로 해석합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function parseFilters(): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
foreach ((array) $this->option('filter') as $raw) {
|
||||
if (! is_string($raw) || ! str_contains($raw, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = explode('=', $raw, 2);
|
||||
$key = trim($key);
|
||||
|
||||
if ($key !== '') {
|
||||
$filters[$key] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재생성 대상을 보여 주고 진행 여부를 확인합니다.
|
||||
*
|
||||
* @param array<int, SearchIndexHealth> $targets 재생성 대상
|
||||
* @return bool 진행 여부
|
||||
*/
|
||||
private function confirmRebuild(array $targets): bool
|
||||
{
|
||||
$this->newLine();
|
||||
$this->components->info(__('search.index.rebuild_targets', ['count' => count($targets)]));
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$this->line(' - '.$target->identifier.' ('.$target->measurement.')');
|
||||
}
|
||||
|
||||
$this->components->warn(__('search.index.rebuild_cost_warning'));
|
||||
|
||||
if ($this->unifiedConfirm(__('search.index.rebuild_confirm'), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->components->info(__('search.index.rebuild_skipped'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 판정 결과를 표로 출력합니다.
|
||||
*
|
||||
* @param string $driver 드라이버명
|
||||
* @param array<int, SearchIndexHealth> $results 판정 결과
|
||||
* @param string|null $repairSummary 재생성 요약 (수행했을 때만)
|
||||
* @return void
|
||||
*/
|
||||
private function render(string $driver, array $results, ?string $repairSummary): void
|
||||
{
|
||||
$rows = array_map(fn (SearchIndexHealth $health) => [
|
||||
$health->identifier,
|
||||
'<fg='.$health->status->consoleColor().'>'.$health->status->label().'</>',
|
||||
$health->measurement,
|
||||
], $results);
|
||||
|
||||
$this->newLine();
|
||||
$this->line(' '.__('search.index.driver_label', ['driver' => $driver]));
|
||||
$this->table([__('search.index.col.index'), __('search.index.col.status'), __('search.index.col.measurement')], $rows);
|
||||
|
||||
$counts = [];
|
||||
foreach (SearchIndexStatus::cases() as $case) {
|
||||
$counts[$case->value] = count(array_filter($results, fn (SearchIndexHealth $h) => $h->status === $case));
|
||||
}
|
||||
|
||||
$this->line(' '.__('search.index.counts', [
|
||||
'healthy' => $counts[SearchIndexStatus::Healthy->value],
|
||||
'degraded' => $counts[SearchIndexStatus::Degraded->value],
|
||||
'stale' => $counts[SearchIndexStatus::Stale->value],
|
||||
'skipped' => $counts[SearchIndexStatus::Skipped->value],
|
||||
'total' => count($results),
|
||||
]));
|
||||
|
||||
if ($repairSummary !== null) {
|
||||
$this->newLine();
|
||||
$this->components->info($repairSummary);
|
||||
}
|
||||
|
||||
if ($counts[SearchIndexStatus::Stale->value] > 0 && ! $this->option('repair')) {
|
||||
$this->newLine();
|
||||
$this->components->warn(__('search.index.stale_hint'));
|
||||
}
|
||||
|
||||
if ($counts[SearchIndexStatus::Degraded->value] > 0) {
|
||||
$this->line(' <comment>'.__('search.index.degraded_hint').'</comment>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* 의존성 취약점 전수 점검 커맨드 (#126)
|
||||
*
|
||||
* 이 저장소는 잠금파일이 60개 가까이 흩어져 있다 — 코어 하나와 확장 수십 개가 각자
|
||||
* `package-lock.json` / `composer.lock` 을 갖는다. 루트만 감사하면 확장 전부가 사각이
|
||||
* 되는데, 그 사각은 오류도 경고도 남기지 않는다: 취약한 라이브러리가 정상 동작하는 것이
|
||||
* 유일한 증상이다.
|
||||
*
|
||||
* 동봉(vendored) 자산은 어떤 잠금파일에도 없어 `npm audit` 이 **원리상** 볼 수 없다.
|
||||
* 그 축은 판정하지 않고 목록으로 노출한다 — 사람이 봐야 하는 대상이기 때문이다.
|
||||
*
|
||||
* 종료 코드: 취약점이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||
* **조치 대상 발견 신호**다.
|
||||
*/
|
||||
class SecurityAuditDependenciesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'security:audit-dependencies
|
||||
{--json : 결과를 JSON 으로 출력합니다}
|
||||
{--npm-only : npm 잠금파일만 점검합니다}
|
||||
{--composer-only : composer 잠금파일만 점검합니다}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '저장소의 모든 잠금파일(npm·composer) 운영 의존성 취약점을 점검합니다 (취약점 발견 시 비-0 종료)';
|
||||
|
||||
/** 잠금파일 탐색에서 제외할 경로 조각 */
|
||||
private const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', '_pending', 'storage'];
|
||||
|
||||
/** 개별 감사 프로세스 제한 시간(초) */
|
||||
private const PROCESS_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int 취약점이 없으면 0, 있으면 1
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$npmOnly = (bool) $this->option('npm-only');
|
||||
$composerOnly = (bool) $this->option('composer-only');
|
||||
|
||||
$results = [];
|
||||
|
||||
if (! $composerOnly) {
|
||||
foreach ($this->lockFiles('package-lock.json') as $lock) {
|
||||
$results[] = $this->auditNpm($lock);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $npmOnly) {
|
||||
foreach ($this->lockFiles('composer.lock') as $lock) {
|
||||
$results[] = $this->auditComposer($lock);
|
||||
}
|
||||
}
|
||||
|
||||
$vendored = $this->vendoredAssets();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'results' => $results,
|
||||
'vendored_assets' => $vendored,
|
||||
'totals' => $this->totals($results),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $this->totals($results)['vulnerable'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->renderTable($results);
|
||||
$this->renderVendored($vendored);
|
||||
|
||||
$totals = $this->totals($results);
|
||||
|
||||
$this->line('');
|
||||
|
||||
if ($totals['unmeasurable'] > 0) {
|
||||
$this->warn(sprintf(
|
||||
'점검 불가 %d건 — 감사 도구가 실행되지 않았습니다. "취약점 없음" 과 다릅니다.',
|
||||
$totals['unmeasurable']
|
||||
));
|
||||
}
|
||||
|
||||
if ($totals['vulnerable'] === 0) {
|
||||
$this->info(sprintf(
|
||||
'운영 의존성 취약점 0건 (감사 %d개 / 대상 없음 %d개 / 발견 %d개 잠금파일).',
|
||||
$totals['audited'],
|
||||
$totals['empty'],
|
||||
$totals['checked']
|
||||
));
|
||||
|
||||
return $totals['unmeasurable'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error(sprintf(
|
||||
'%d개 잠금파일에서 운영 의존성 취약점이 발견되었습니다 (총 %d건).',
|
||||
$totals['vulnerable'],
|
||||
$totals['advisories']
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소에서 잠금파일을 전수 탐색합니다.
|
||||
*
|
||||
* @param string $fileName 잠금파일 이름
|
||||
* @return array<int, string> 잠금파일 절대 경로 목록
|
||||
*/
|
||||
private function lockFiles(string $fileName): array
|
||||
{
|
||||
$root = base_path();
|
||||
$found = [];
|
||||
|
||||
// 제외 디렉토리는 **내려가기 전에** 잘라낸다. 파일 단계에서 거르면 `node_modules`
|
||||
// 수만 개를 전부 훑고 나서 버리게 되어 한 번 실행에 수 분이 걸린다.
|
||||
$directories = new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS);
|
||||
$pruned = new \RecursiveCallbackFilterIterator(
|
||||
$directories,
|
||||
function (\SplFileInfo $entry): bool {
|
||||
if (! $entry->isDir()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array($entry->getFilename(), self::EXCLUDED_SEGMENTS, true);
|
||||
}
|
||||
);
|
||||
|
||||
foreach (new \RecursiveIteratorIterator($pruned) as $entry) {
|
||||
if (! $entry->isFile() || $entry->getFilename() !== $fileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found[] = $entry->getPathname();
|
||||
}
|
||||
|
||||
sort($found);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 잠금파일 하나를 감사합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function auditNpm(string $lockPath): array
|
||||
{
|
||||
if ($this->isEmptyLock($lockPath, ['packages', 'dependencies'])) {
|
||||
return $this->emptyLock($lockPath, 'npm');
|
||||
}
|
||||
|
||||
$dir = dirname($lockPath);
|
||||
$process = $this->runProcess(['npm', 'audit', '--omit=dev', '--json'], $dir);
|
||||
$decoded = json_decode($process['output'], true);
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['metadata']['vulnerabilities'])) {
|
||||
return $this->unmeasurable($lockPath, 'npm', trim($process['error']) ?: 'npm audit 출력을 해석할 수 없습니다.');
|
||||
}
|
||||
|
||||
$severities = array_filter(
|
||||
array_map('intval', $decoded['metadata']['vulnerabilities']),
|
||||
static fn (int $count, string $key): bool => $count > 0 && $key !== 'total',
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
$total = (int) ($decoded['metadata']['vulnerabilities']['total'] ?? 0);
|
||||
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => 'npm',
|
||||
'status' => $total > 0 ? 'vulnerable' : 'clean',
|
||||
'advisories' => $total,
|
||||
'severities' => $severities,
|
||||
'detail' => $total > 0 ? implode(', ', array_keys($decoded['vulnerabilities'] ?? [])) : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 잠금파일 하나를 감사합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function auditComposer(string $lockPath): array
|
||||
{
|
||||
if ($this->isEmptyLock($lockPath, ['packages', 'packages-dev'])) {
|
||||
return $this->emptyLock($lockPath, 'composer');
|
||||
}
|
||||
|
||||
$dir = dirname($lockPath);
|
||||
$process = $this->runProcess(['composer', 'audit', '--locked', '--no-dev', '--format=json'], $dir);
|
||||
$decoded = json_decode($this->firstJsonObject($process['output']), true);
|
||||
|
||||
if (! is_array($decoded) || ! array_key_exists('advisories', $decoded)) {
|
||||
return $this->unmeasurable($lockPath, 'composer', trim($process['error']) ?: 'composer audit 출력을 해석할 수 없습니다.');
|
||||
}
|
||||
|
||||
$severities = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($decoded['advisories'] as $advisories) {
|
||||
foreach ((array) $advisories as $advisory) {
|
||||
$total++;
|
||||
$severity = (string) ($advisory['severity'] ?? 'unknown');
|
||||
$severities[$severity] = ($severities[$severity] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => 'composer',
|
||||
'status' => $total > 0 ? 'vulnerable' : 'clean',
|
||||
'advisories' => $total,
|
||||
'severities' => $severities,
|
||||
'detail' => $total > 0 ? implode(', ', array_keys($decoded['advisories'])) : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠금파일이 의존성을 하나도 담고 있지 않은지 판정합니다.
|
||||
*
|
||||
* 확장 대부분은 composer 의존성이 없어 빈 잠금을 갖는다. 그 상태에서 감사 도구는
|
||||
* "설치본이 없다" 는 오류를 내는데, 그것을 점검 불가로 세면 조치할 것이 없는 대상이
|
||||
* 24건씩 경고로 쌓여 진짜 점검 불가를 가린다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param array<int, string> $keys 의존성이 담기는 최상위 키
|
||||
* @return bool
|
||||
*/
|
||||
private function isEmptyLock(string $lockPath, array $keys): bool
|
||||
{
|
||||
$decoded = json_decode((string) @file_get_contents($lockPath), true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$entries = $decoded[$key] ?? [];
|
||||
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// npm 잠금의 `packages` 는 루트 자신을 빈 문자열 키로 항상 담는다 — 그것만 있으면
|
||||
// 빈 것이다. composer 잠금의 `packages` 는 리스트라 키가 정수이므로 건드리지 않는다.
|
||||
$meaningful = array_filter(
|
||||
array_keys($entries),
|
||||
static fn ($name): bool => $name !== ''
|
||||
);
|
||||
|
||||
if ($meaningful !== []) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 의존성이 없는 잠금파일을 기록합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param string $kind npm|composer
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function emptyLock(string $lockPath, string $kind): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => $kind,
|
||||
'status' => 'empty',
|
||||
'advisories' => 0,
|
||||
'severities' => [],
|
||||
'detail' => '의존성 없음',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 도구를 실행할 수 없었던 대상을 기록합니다.
|
||||
*
|
||||
* "점검 불가" 는 "취약점 없음" 과 다르다 — 뭉뚱그리면 운영자가 정상으로 읽는다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param string $kind npm|composer
|
||||
* @param string $reason 사유
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function unmeasurable(string $lockPath, string $kind, string $reason): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => $kind,
|
||||
'status' => 'unmeasurable',
|
||||
'advisories' => 0,
|
||||
'severities' => [],
|
||||
'detail' => mb_substr($reason, 0, 200),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 명령을 실행합니다 ( 과 충돌하지 않도록 이름을 분리).
|
||||
*
|
||||
* @param array<int, string> $command 명령과 인자
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @return array{output: string, error: string, exit: int}
|
||||
*/
|
||||
private function runProcess(array $command, string $cwd): array
|
||||
{
|
||||
$process = new Process($command, $cwd, null, null, self::PROCESS_TIMEOUT);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable $e) {
|
||||
return ['output' => '', 'error' => $e->getMessage(), 'exit' => -1];
|
||||
}
|
||||
|
||||
return [
|
||||
'output' => $process->getOutput(),
|
||||
'error' => $process->getErrorOutput(),
|
||||
'exit' => (int) $process->getExitCode(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 앞쪽 잡음을 걷어내고 첫 JSON 객체만 남깁니다.
|
||||
*
|
||||
* composer 는 오토로드 경고를 표준출력에 함께 흘릴 수 있다.
|
||||
*
|
||||
* @param string $output 명령 출력
|
||||
* @return string JSON 문자열 (없으면 빈 문자열)
|
||||
*/
|
||||
private function firstJsonObject(string $output): string
|
||||
{
|
||||
$start = strpos($output, '{');
|
||||
|
||||
return $start === false ? '' : substr($output, $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동봉 제3자 자산의 버전 디렉토리를 나열합니다.
|
||||
*
|
||||
* 감사 도구가 원리상 볼 수 없는 축이므로 판정하지 않고 노출만 한다.
|
||||
*
|
||||
* @return array<int, array{extension: string, library: string, versions: array<int, string>}>
|
||||
*/
|
||||
private function vendoredAssets(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (['templates', 'modules', 'plugins'] as $kind) {
|
||||
foreach ([$kind.'/*/dist/vendor', $kind.'/_bundled/*/dist/vendor'] as $pattern) {
|
||||
foreach ((array) glob(base_path($pattern), GLOB_ONLYDIR) as $vendorRoot) {
|
||||
foreach ((array) glob($vendorRoot.'/*', GLOB_ONLYDIR) as $libDir) {
|
||||
$versions = array_map(
|
||||
'basename',
|
||||
(array) glob($libDir.'/*', GLOB_ONLYDIR)
|
||||
);
|
||||
|
||||
$rows[] = [
|
||||
'extension' => $this->relative(dirname($vendorRoot, 2)),
|
||||
'library' => basename($libDir),
|
||||
'versions' => array_values($versions),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 집계를 계산합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 감사 결과
|
||||
* @return array{checked: int, vulnerable: int, unmeasurable: int, advisories: int}
|
||||
*/
|
||||
private function totals(array $results): array
|
||||
{
|
||||
return [
|
||||
'checked' => count($results),
|
||||
'audited' => count(array_filter($results, static fn (array $r): bool => in_array($r['status'], ['clean', 'vulnerable'], true))),
|
||||
'empty' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'empty')),
|
||||
'vulnerable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'vulnerable')),
|
||||
'unmeasurable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'unmeasurable')),
|
||||
'advisories' => array_sum(array_column($results, 'advisories')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 표를 출력합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 감사 결과
|
||||
* @return void
|
||||
*/
|
||||
private function renderTable(array $results): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->line('<options=bold>의존성 취약점 점검 (운영 의존성 기준)</>');
|
||||
$this->line('');
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$severities = [];
|
||||
|
||||
foreach ($result['severities'] as $severity => $count) {
|
||||
$severities[] = $severity.' '.$count;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$result['kind'],
|
||||
$result['target'],
|
||||
match ($result['status']) {
|
||||
'clean' => '이상 없음',
|
||||
'empty' => '대상 없음',
|
||||
'vulnerable' => '취약',
|
||||
default => '점검 불가',
|
||||
},
|
||||
$result['advisories'] > 0 ? (string) $result['advisories'] : '-',
|
||||
$severities === [] ? ($result['detail'] !== '' ? mb_substr($result['detail'], 0, 60) : '-') : implode(', ', $severities),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['종류', '대상', '상태', '건수', '내역'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동봉 자산 목록을 출력합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vendored 동봉 자산 목록
|
||||
* @return void
|
||||
*/
|
||||
private function renderVendored(array $vendored): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->line('<options=bold>동봉 제3자 자산 (감사 도구가 볼 수 없는 축 — 사람이 확인)</>');
|
||||
$this->line('');
|
||||
|
||||
if ($vendored === []) {
|
||||
$this->line(' (없음)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['확장', '라이브러리', '버전 디렉토리'],
|
||||
array_map(
|
||||
static fn (array $row): array => [
|
||||
$row['extension'],
|
||||
$row['library'],
|
||||
$row['versions'] === [] ? '(버전 디렉토리 없음)' : implode(', ', $row['versions']),
|
||||
],
|
||||
$vendored
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소 상대 경로로 바꿉니다.
|
||||
*
|
||||
* @param string $path 절대 경로
|
||||
* @return string
|
||||
*/
|
||||
private function relative(string $path): string
|
||||
{
|
||||
$base = base_path().DIRECTORY_SEPARATOR;
|
||||
|
||||
return str_replace('\\', '/', str_starts_with($path, $base) ? substr($path, strlen($base)) : $path);
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,26 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SitemapGenerationMode;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Sitemap XML 생성 Artisan 커맨드
|
||||
*
|
||||
* 큐를 통한 비동기 실행 또는 --sync 옵션으로 동기 실행을 지원합니다.
|
||||
* 큐 드라이버 설정에 따라 비동기/동기 실행을 자동 선택합니다.
|
||||
* --sync 옵션을 명시하면 큐 드라이버와 무관하게 동기 실행합니다.
|
||||
*/
|
||||
class SeoGenerateSitemapCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'seo:generate-sitemap {--sync : 동기 실행}';
|
||||
protected $signature = 'seo:generate-sitemap
|
||||
{--sync : 큐 드라이버를 무시하고 동기 실행}
|
||||
{--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)}
|
||||
{--mode=auto : 재생성 모드 (full|auto|incremental)}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
@@ -29,14 +35,44 @@ class SeoGenerateSitemapCommand extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('sync')) {
|
||||
GenerateSitemapJob::dispatchSync();
|
||||
$this->info('Sitemap이 생성되었습니다.');
|
||||
$mode = $this->resolveMode();
|
||||
if ($mode === null) {
|
||||
$allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases()));
|
||||
$this->error('잘못된 모드입니다. 허용: '.$allowed);
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$forceSync = (bool) $this->option('sync');
|
||||
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
|
||||
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
|
||||
$connection = (string) Config::get('queue.default', 'sync');
|
||||
$isSyncDriver = $connection === 'sync';
|
||||
|
||||
if ($forceSync || $isSyncDriver) {
|
||||
GenerateSitemapJob::dispatchSync($mode);
|
||||
$this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})");
|
||||
} else {
|
||||
GenerateSitemapJob::dispatch();
|
||||
$this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
|
||||
GenerateSitemapJob::dispatch($mode);
|
||||
$this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다.
|
||||
*
|
||||
* --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다.
|
||||
*
|
||||
* @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null)
|
||||
*/
|
||||
private function resolveMode(): ?SitemapGenerationMode
|
||||
{
|
||||
if ((bool) $this->option('rebuild')) {
|
||||
return SitemapGenerationMode::Full;
|
||||
}
|
||||
|
||||
return SitemapGenerationMode::tryFrom((string) $this->option('mode'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시 상태 점검 커맨드 (#122)
|
||||
*
|
||||
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이다. 그래서 정상
|
||||
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
|
||||
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
|
||||
*
|
||||
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
|
||||
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
|
||||
*
|
||||
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
|
||||
* 최근 실패 마커 / 잔존 버전 목록.
|
||||
*
|
||||
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||
* **이상 발견 신호**다 — 운영자가 조치할 대상이 있다는 뜻이다.
|
||||
*/
|
||||
class StatusExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '부트스트랩 리소스 정적 게시 상태를 점검합니다 (이상 발견 시 비-0 종료)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 이상 없으면 0, 이상 발견 시 1
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
$problems = [];
|
||||
|
||||
$report = $service->statusReport();
|
||||
$base = $service->baseDir();
|
||||
$publishHint = self::publishCommandHint();
|
||||
|
||||
$this->line('');
|
||||
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
|
||||
$this->line('');
|
||||
|
||||
$this->line(' 실행 계정 : '.$report['process_user']);
|
||||
|
||||
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
|
||||
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
|
||||
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
|
||||
if (self::isRootProcess()) {
|
||||
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
|
||||
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
|
||||
}
|
||||
|
||||
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
|
||||
$this->line(' 현재 캐시 버전 : '.$report['version']);
|
||||
$this->line(' 게시 루트 : '.$base);
|
||||
|
||||
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
|
||||
if (! $report['enabled']) {
|
||||
$this->line('');
|
||||
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
|
||||
if ($report['tree_writable']) {
|
||||
$this->line(' 트리 쓰기 : 가능');
|
||||
} else {
|
||||
$this->line(' 트리 쓰기 : <error>불가</error>');
|
||||
$problems[] = sprintf(
|
||||
'게시 트리에 쓸 수 없습니다 (%s, owner=%s, perms=%s). 웹 계정이 재게시할 수 없어 '
|
||||
.'모든 요청이 API 폴백으로 동작합니다.',
|
||||
$base,
|
||||
(string) (@fileowner($base) ?: 'unknown'),
|
||||
File::exists($base) ? substr(sprintf('%o', @fileperms($base)), -4) : 'absent'
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 현재 버전 게시 여부
|
||||
if ($report['published']) {
|
||||
$this->line(' 게시 상태 : 완료');
|
||||
$this->line(' manifest 파일 : '.$report['files'].'건');
|
||||
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
|
||||
|
||||
if ($report['files'] === 0) {
|
||||
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
|
||||
}
|
||||
} else {
|
||||
$this->line(' 게시 상태 : <comment>미게시</comment>');
|
||||
$problems[] = sprintf(
|
||||
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
|
||||
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
|
||||
.'「지금 다시 만들기」를 사용하세요.',
|
||||
$report['version'],
|
||||
$publishHint
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
|
||||
$marker = $report['failure'];
|
||||
|
||||
if ($marker !== null) {
|
||||
$this->line('');
|
||||
$this->line(' <error>최근 게시 실패</error>');
|
||||
$this->line(' 사유 : '.($marker['reason'] ?? '?').' — '.$this->reasonHint($marker['reason'] ?? ''));
|
||||
$this->line(' 버전 : '.($marker['version'] ?? '?'));
|
||||
$this->line(' 시각 : '.($marker['at'] ?? '?'));
|
||||
$this->line(' 연속 실패 : '.($marker['count'] ?? '?').'회');
|
||||
$this->line(' 상세 : '.($marker['message'] ?? ''));
|
||||
|
||||
$problems[] = '게시 실패 마커가 남아 있습니다 (사유: '.($marker['reason'] ?? '?').').';
|
||||
}
|
||||
|
||||
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
|
||||
$versions = $report['retained_versions'];
|
||||
|
||||
$this->line('');
|
||||
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
|
||||
|
||||
if (count($versions) > 3) {
|
||||
$problems[] = sprintf(
|
||||
'게시 버전이 %d개 누적됐습니다 (정상은 현재+직전 2개). 삭제가 실패하고 있을 수 '
|
||||
.'있습니다 — 소유권/권한을 확인하세요.',
|
||||
count($versions)
|
||||
);
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
if ($problems === []) {
|
||||
$this->info('이상 없음.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($problems as $problem) {
|
||||
$this->warn('• '.$problem);
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
|
||||
*
|
||||
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
|
||||
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
|
||||
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
|
||||
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
|
||||
*
|
||||
* @return string 안내 명령 문자열
|
||||
*/
|
||||
public static function publishCommandHint(): string
|
||||
{
|
||||
$command = 'php artisan ext-static:publish';
|
||||
$account = FilePermissionHelper::describeWebServerAccount();
|
||||
|
||||
return match ($account['mode']) {
|
||||
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
|
||||
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
|
||||
default => $command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
|
||||
*
|
||||
* @return bool root 실행 여부
|
||||
*/
|
||||
public static function isRootProcess(): bool
|
||||
{
|
||||
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 사유 코드에 대한 조치 힌트를 반환합니다.
|
||||
*
|
||||
* 사유별로 볼 곳이 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
|
||||
*
|
||||
* @param string $reason 사유 코드
|
||||
* @return string 조치 힌트
|
||||
*/
|
||||
private function reasonHint(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
|
||||
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
|
||||
.self::publishCommandHint(),
|
||||
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
|
||||
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
|
||||
default => '상세 메시지를 확인하세요.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands\Template;
|
||||
|
||||
use App\Console\Commands\Concerns\PrunesBuildOutput;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
|
||||
class BuildTemplateCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use PrunesBuildOutput;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -181,8 +183,21 @@ class BuildTemplateCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
|
||||
if (! $watchMode) {
|
||||
$removed = $this->pruneBuildOutput($buildPath);
|
||||
|
||||
if ($removed !== []) {
|
||||
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
|
||||
}
|
||||
}
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -285,15 +300,30 @@ class BuildTemplateCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -302,6 +332,11 @@ class BuildTemplateCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -116,7 +116,7 @@ class CheckTemplateUpdatesCommand extends Command
|
||||
$updateCount = 0;
|
||||
|
||||
foreach ($result['details'] as $detail) {
|
||||
$isUpdate = $detail['update_available'] ?? false;
|
||||
$isUpdate = (bool) ($detail['update_available'] ?? true);
|
||||
if ($isUpdate) {
|
||||
$updateCount++;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class CheckTemplateUpdatesCommand extends Command
|
||||
$this->table($headers, $tableData);
|
||||
$this->newLine();
|
||||
$this->info(__('templates.commands.check_updates.summary', [
|
||||
'total' => count($result['details']),
|
||||
'total' => $result['checked_count'] ?? count($result['details']),
|
||||
'updates' => $updateCount,
|
||||
]));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user