create-entity-app-server 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (675) hide show
  1. package/.env.example +68 -0
  2. package/.gitignore +8 -0
  3. package/LICENSE +66 -0
  4. package/README.md +36 -0
  5. package/bin/create.js +222 -0
  6. package/configs/cache.json +7 -0
  7. package/configs/cors.json +24 -0
  8. package/configs/database.json +30 -0
  9. package/configs/security.json +45 -0
  10. package/configs/server.json +31 -0
  11. package/docs/README.md +274 -0
  12. package/docs/architecture.md +295 -0
  13. package/docs/cache.md +217 -0
  14. package/docs/configs.md +261 -0
  15. package/docs/database.md +505 -0
  16. package/docs/design/board-api-design.md +2342 -0
  17. package/docs/flows.md +581 -0
  18. package/docs/getting-started.md +83 -0
  19. package/docs/hooks.md +600 -0
  20. package/docs/internals.md +60 -0
  21. package/docs/plugins/2fa.md +121 -0
  22. package/docs/plugins/alimtalk.md +212 -0
  23. package/docs/plugins/friendtalk.md +158 -0
  24. package/docs/plugins/holidays.md +98 -0
  25. package/docs/plugins/how-to-create.md +148 -0
  26. package/docs/plugins/identity.md +223 -0
  27. package/docs/plugins/llm.md +567 -0
  28. package/docs/plugins/oauth.md +121 -0
  29. package/docs/plugins/ocr.md +168 -0
  30. package/docs/plugins/pg.md +226 -0
  31. package/docs/plugins/push.md +178 -0
  32. package/docs/plugins/sms.md +228 -0
  33. package/docs/plugins/taxinvoice.md +197 -0
  34. package/docs/routes/README.md +247 -0
  35. package/docs/routes/account-routes.md +262 -0
  36. package/docs/routes/alimtalk-routes.md +187 -0
  37. package/docs/routes/board-routes.md +492 -0
  38. package/docs/routes/email-verification.md +269 -0
  39. package/docs/routes/friendtalk-routes.md +45 -0
  40. package/docs/routes/holidays-routes.md +170 -0
  41. package/docs/routes/how-to-create.md +150 -0
  42. package/docs/routes/identity-routes.md +310 -0
  43. package/docs/routes/llm-routes.md +921 -0
  44. package/docs/routes/ocr-routes.md +133 -0
  45. package/docs/routes/password-reset.md +234 -0
  46. package/docs/routes/pg-routes.md +144 -0
  47. package/docs/routes/push-routes.md +205 -0
  48. package/docs/routes/sms-routes.md +243 -0
  49. package/docs/routes/smtp-routes.md +155 -0
  50. package/docs/routes/tax-invoice-routes.md +109 -0
  51. package/docs/schedules/dormancy-and-retention.md +160 -0
  52. package/docs/schedules/how-to-create.md +255 -0
  53. package/docs/scripts-guide.md +310 -0
  54. package/docs/security.md +221 -0
  55. package/docs/system.md +297 -0
  56. package/package.json +111 -0
  57. package/scripts/_gen-table-type.ts +605 -0
  58. package/scripts/build-minify-plugins.mjs +124 -0
  59. package/scripts/build-obfuscate-system.mjs +38 -0
  60. package/scripts/build.sh +140 -0
  61. package/scripts/dist-tsconfig.json +18 -0
  62. package/scripts/entity.sh +224 -0
  63. package/scripts/gen-table-type.sh +169 -0
  64. package/scripts/push.sh +102 -0
  65. package/scripts/release.sh +51 -0
  66. package/scripts/reset-all.sh +208 -0
  67. package/scripts/run.sh +202 -0
  68. package/src/app/hooks/README.md +148 -0
  69. package/src/app/hooks/account.ts +26 -0
  70. package/src/app/hooks/index.ts +19 -0
  71. package/src/app/hooks/order.ts +230 -0
  72. package/src/app/hooks/post.ts +162 -0
  73. package/src/app/plugins/2fa/config.example.json +15 -0
  74. package/src/app/plugins/2fa/config.json +17 -0
  75. package/src/app/plugins/2fa/config.ts +44 -0
  76. package/src/app/plugins/2fa/docs/README.md +139 -0
  77. package/src/app/plugins/2fa/entities/account.json +30 -0
  78. package/src/app/plugins/2fa/handlers/disable.ts +114 -0
  79. package/src/app/plugins/2fa/handlers/index.ts +11 -0
  80. package/src/app/plugins/2fa/handlers/recovery.ts +98 -0
  81. package/src/app/plugins/2fa/handlers/regenerate.ts +99 -0
  82. package/src/app/plugins/2fa/handlers/setup-verify.ts +121 -0
  83. package/src/app/plugins/2fa/handlers/setup.ts +92 -0
  84. package/src/app/plugins/2fa/handlers/status.ts +47 -0
  85. package/src/app/plugins/2fa/handlers/utils.ts +222 -0
  86. package/src/app/plugins/2fa/handlers/verify.ts +92 -0
  87. package/src/app/plugins/2fa/index.ts +50 -0
  88. package/src/app/plugins/2fa/routes.ts +49 -0
  89. package/src/app/plugins/2fa/templates/auth/2fa_disabled.html +23 -0
  90. package/src/app/plugins/2fa/templates/auth/2fa_recovery_regenerated.html +31 -0
  91. package/src/app/plugins/2fa/templates/auth/2fa_setup_complete.html +43 -0
  92. package/src/app/plugins/2fa/totp-utils.ts +189 -0
  93. package/src/app/plugins/2fa/types.ts +95 -0
  94. package/src/app/plugins/README.md +118 -0
  95. package/src/app/plugins/ais/config.json +7 -0
  96. package/src/app/plugins/ais/config.ts +32 -0
  97. package/src/app/plugins/ais/docs/README.md +142 -0
  98. package/src/app/plugins/ais/docs/api.md +138 -0
  99. package/src/app/plugins/ais/entities/ais_vessel.json +64 -0
  100. package/src/app/plugins/ais/handlers.ts +88 -0
  101. package/src/app/plugins/ais/index.ts +21 -0
  102. package/src/app/plugins/ais/routes.ts +13 -0
  103. package/src/app/plugins/ais/service.ts +242 -0
  104. package/src/app/plugins/ais/types/index.ts +78 -0
  105. package/src/app/plugins/alimtalk/config.example.json +52 -0
  106. package/src/app/plugins/alimtalk/config.json +26 -0
  107. package/src/app/plugins/alimtalk/config.ts +75 -0
  108. package/src/app/plugins/alimtalk/docs/README.md +140 -0
  109. package/src/app/plugins/alimtalk/entities/alimtalk_log.json +65 -0
  110. package/src/app/plugins/alimtalk/entities/alimtalk_msg.json +53 -0
  111. package/src/app/plugins/alimtalk/entity-adapter.ts +196 -0
  112. package/src/app/plugins/alimtalk/handlers.ts +84 -0
  113. package/src/app/plugins/alimtalk/index.ts +80 -0
  114. package/src/app/plugins/alimtalk/providers/aligo.ts +151 -0
  115. package/src/app/plugins/alimtalk/providers/index.ts +29 -0
  116. package/src/app/plugins/alimtalk/providers/nhn.ts +254 -0
  117. package/src/app/plugins/alimtalk/providers/ppurio.ts +145 -0
  118. package/src/app/plugins/alimtalk/providers/solapi.ts +145 -0
  119. package/src/app/plugins/alimtalk/routes.ts +15 -0
  120. package/src/app/plugins/alimtalk/service.ts +423 -0
  121. package/src/app/plugins/alimtalk/template-cache.ts +42 -0
  122. package/src/app/plugins/alimtalk/templates/alimtalk.json +27 -0
  123. package/src/app/plugins/alimtalk/types/client.ts +48 -0
  124. package/src/app/plugins/alimtalk/types/config.ts +53 -0
  125. package/src/app/plugins/alimtalk/types/friendtalk.ts +90 -0
  126. package/src/app/plugins/alimtalk/types/index.ts +4 -0
  127. package/src/app/plugins/alimtalk/types/job.ts +56 -0
  128. package/src/app/plugins/alimtalk/webhook.ts +211 -0
  129. package/src/app/plugins/distance-server/config.json +6 -0
  130. package/src/app/plugins/distance-server/config.ts +50 -0
  131. package/src/app/plugins/distance-server/docs/README.md +114 -0
  132. package/src/app/plugins/distance-server/handlers.ts +104 -0
  133. package/src/app/plugins/distance-server/index.ts +23 -0
  134. package/src/app/plugins/distance-server/routes.ts +36 -0
  135. package/src/app/plugins/distance-server/service.ts +187 -0
  136. package/src/app/plugins/distance-server/types/index.ts +8 -0
  137. package/src/app/plugins/example/config.json +6 -0
  138. package/src/app/plugins/example/config.ts +46 -0
  139. package/src/app/plugins/example/docs/README.md +64 -0
  140. package/src/app/plugins/example/entity-adapter.ts +96 -0
  141. package/src/app/plugins/example/handlers.ts +94 -0
  142. package/src/app/plugins/example/index.ts +63 -0
  143. package/src/app/plugins/example/routes.ts +30 -0
  144. package/src/app/plugins/example/service.ts +31 -0
  145. package/src/app/plugins/example/types/config.ts +11 -0
  146. package/src/app/plugins/example/types/index.ts +1 -0
  147. package/src/app/plugins/friendtalk/config.example.json +35 -0
  148. package/src/app/plugins/friendtalk/config.json +11 -0
  149. package/src/app/plugins/friendtalk/config.ts +70 -0
  150. package/src/app/plugins/friendtalk/docs/README.md +110 -0
  151. package/src/app/plugins/friendtalk/entities/friendtalk_log.json +89 -0
  152. package/src/app/plugins/friendtalk/entities/friendtalk_msg.json +91 -0
  153. package/src/app/plugins/friendtalk/entity-adapter.ts +150 -0
  154. package/src/app/plugins/friendtalk/handlers.ts +56 -0
  155. package/src/app/plugins/friendtalk/routes.ts +12 -0
  156. package/src/app/plugins/friendtalk/templates/friendtalk.json +16 -0
  157. package/src/app/plugins/holidays/config.example.json +6 -0
  158. package/src/app/plugins/holidays/config.json +10 -0
  159. package/src/app/plugins/holidays/config.ts +44 -0
  160. package/src/app/plugins/holidays/docs/README.md +122 -0
  161. package/src/app/plugins/holidays/entities/holiday.json +22 -0
  162. package/src/app/plugins/holidays/handlers.ts +135 -0
  163. package/src/app/plugins/holidays/index.ts +78 -0
  164. package/src/app/plugins/holidays/routes.ts +18 -0
  165. package/src/app/plugins/holidays/service.ts +241 -0
  166. package/src/app/plugins/holidays/types/api.ts +49 -0
  167. package/src/app/plugins/holidays/types/config.ts +8 -0
  168. package/src/app/plugins/holidays/types/index.ts +2 -0
  169. package/src/app/plugins/identity/config.example.json +43 -0
  170. package/src/app/plugins/identity/config.json +30 -0
  171. package/src/app/plugins/identity/config.ts +138 -0
  172. package/src/app/plugins/identity/crypto.ts +51 -0
  173. package/src/app/plugins/identity/docs/README.md +164 -0
  174. package/src/app/plugins/identity/entities/account.json +27 -0
  175. package/src/app/plugins/identity/entities/identity_verification.json +113 -0
  176. package/src/app/plugins/identity/entity-adapter.ts +242 -0
  177. package/src/app/plugins/identity/handlers.ts +239 -0
  178. package/src/app/plugins/identity/index.ts +80 -0
  179. package/src/app/plugins/identity/providers/danal.ts +150 -0
  180. package/src/app/plugins/identity/providers/index.ts +38 -0
  181. package/src/app/plugins/identity/providers/kmc.ts +140 -0
  182. package/src/app/plugins/identity/providers/nice.ts +304 -0
  183. package/src/app/plugins/identity/routes.ts +22 -0
  184. package/src/app/plugins/identity/service.ts +361 -0
  185. package/src/app/plugins/identity/types/config.ts +35 -0
  186. package/src/app/plugins/identity/types/index.ts +2 -0
  187. package/src/app/plugins/identity/types/verification.ts +105 -0
  188. package/src/app/plugins/kobc_freight/config.json +6 -0
  189. package/src/app/plugins/kobc_freight/config.ts +28 -0
  190. package/src/app/plugins/kobc_freight/docs/README.md +316 -0
  191. package/src/app/plugins/kobc_freight/entities/kobc_freight_entry.json +31 -0
  192. package/src/app/plugins/kobc_freight/entities/kobc_kcci_entry.json +67 -0
  193. package/src/app/plugins/kobc_freight/entities/kobc_kpli_entry.json +27 -0
  194. package/src/app/plugins/kobc_freight/entities/kobc_ncfi_entry.json +99 -0
  195. package/src/app/plugins/kobc_freight/handlers.ts +283 -0
  196. package/src/app/plugins/kobc_freight/index.ts +21 -0
  197. package/src/app/plugins/kobc_freight/routes.ts +39 -0
  198. package/src/app/plugins/kobc_freight/service.ts +604 -0
  199. package/src/app/plugins/kobc_freight/types/index.ts +99 -0
  200. package/src/app/plugins/llm/cache.ts +138 -0
  201. package/src/app/plugins/llm/chatbot-store.ts +270 -0
  202. package/src/app/plugins/llm/chunker.ts +96 -0
  203. package/src/app/plugins/llm/config.example.json +260 -0
  204. package/src/app/plugins/llm/config.json +71 -0
  205. package/src/app/plugins/llm/config.ts +99 -0
  206. package/src/app/plugins/llm/conversation-store.ts +140 -0
  207. package/src/app/plugins/llm/docs/README.md +120 -0
  208. package/src/app/plugins/llm/docs/api.md +250 -0
  209. package/src/app/plugins/llm/document-store.ts +318 -0
  210. package/src/app/plugins/llm/entities/llm_chatbot.json +66 -0
  211. package/src/app/plugins/llm/entities/llm_conversation.json +61 -0
  212. package/src/app/plugins/llm/entities/llm_document.json +67 -0
  213. package/src/app/plugins/llm/entities/llm_usage.json +51 -0
  214. package/src/app/plugins/llm/entities/llm_user_profile.json +45 -0
  215. package/src/app/plugins/llm/handlers.ts +1114 -0
  216. package/src/app/plugins/llm/index.ts +90 -0
  217. package/src/app/plugins/llm/profile-store.ts +125 -0
  218. package/src/app/plugins/llm/providers/anthropic.ts +233 -0
  219. package/src/app/plugins/llm/providers/azure.ts +267 -0
  220. package/src/app/plugins/llm/providers/gemini.ts +252 -0
  221. package/src/app/plugins/llm/providers/index.ts +86 -0
  222. package/src/app/plugins/llm/providers/ollama.ts +237 -0
  223. package/src/app/plugins/llm/providers/openai.ts +244 -0
  224. package/src/app/plugins/llm/routes.ts +73 -0
  225. package/src/app/plugins/llm/service.ts +965 -0
  226. package/src/app/plugins/llm/template-loader.ts +135 -0
  227. package/src/app/plugins/llm/templates/prompts/extract_json.json +8 -0
  228. package/src/app/plugins/llm/templates/prompts/summarize.json +10 -0
  229. package/src/app/plugins/llm/templates/prompts/translate.json +10 -0
  230. package/src/app/plugins/llm/types/chat.ts +96 -0
  231. package/src/app/plugins/llm/types/chatbot.ts +143 -0
  232. package/src/app/plugins/llm/types/config.ts +47 -0
  233. package/src/app/plugins/llm/types/conversation.ts +116 -0
  234. package/src/app/plugins/llm/types/index.ts +7 -0
  235. package/src/app/plugins/llm/types/profile.ts +48 -0
  236. package/src/app/plugins/llm/types/store.ts +50 -0
  237. package/src/app/plugins/llm/types/usage.ts +27 -0
  238. package/src/app/plugins/llm/usage-store.ts +64 -0
  239. package/src/app/plugins/oauth/account/handlers/index.ts +4 -0
  240. package/src/app/plugins/oauth/account/handlers/link.ts +165 -0
  241. package/src/app/plugins/oauth/account/handlers/providers-list.ts +49 -0
  242. package/src/app/plugins/oauth/account/handlers/refresh.ts +92 -0
  243. package/src/app/plugins/oauth/account/handlers/unlink.ts +105 -0
  244. package/src/app/plugins/oauth/config.example.json +65 -0
  245. package/src/app/plugins/oauth/config.json +72 -0
  246. package/src/app/plugins/oauth/config.ts +182 -0
  247. package/src/app/plugins/oauth/docs/README.md +160 -0
  248. package/src/app/plugins/oauth/entities/account_oauth.json +74 -0
  249. package/src/app/plugins/oauth/handlers/callback.ts +314 -0
  250. package/src/app/plugins/oauth/handlers/index.ts +2 -0
  251. package/src/app/plugins/oauth/handlers/redirect.ts +47 -0
  252. package/src/app/plugins/oauth/index.ts +74 -0
  253. package/src/app/plugins/oauth/providers/index.ts +530 -0
  254. package/src/app/plugins/oauth/routes.ts +49 -0
  255. package/src/app/plugins/oauth/service.ts +14 -0
  256. package/src/app/plugins/oauth/state.ts +105 -0
  257. package/src/app/plugins/oauth/types/index.ts +52 -0
  258. package/src/app/plugins/oauth/upsert.ts +162 -0
  259. package/src/app/plugins/ocr/cache.ts +50 -0
  260. package/src/app/plugins/ocr/config.example.json +103 -0
  261. package/src/app/plugins/ocr/config.json +110 -0
  262. package/src/app/plugins/ocr/config.ts +126 -0
  263. package/src/app/plugins/ocr/direction.ts +48 -0
  264. package/src/app/plugins/ocr/dispatch.ts +130 -0
  265. package/src/app/plugins/ocr/docs/README.md +125 -0
  266. package/src/app/plugins/ocr/docs/api.md +159 -0
  267. package/src/app/plugins/ocr/entities/ocr_result.json +98 -0
  268. package/src/app/plugins/ocr/entities/ocr_usage.json +57 -0
  269. package/src/app/plugins/ocr/entity-adapter.ts +198 -0
  270. package/src/app/plugins/ocr/errors.ts +42 -0
  271. package/src/app/plugins/ocr/handlers.ts +250 -0
  272. package/src/app/plugins/ocr/index.ts +68 -0
  273. package/src/app/plugins/ocr/llm-parser.ts +164 -0
  274. package/src/app/plugins/ocr/parsing-pipeline.ts +87 -0
  275. package/src/app/plugins/ocr/pdf-converter.ts +136 -0
  276. package/src/app/plugins/ocr/preprocessor.ts +313 -0
  277. package/src/app/plugins/ocr/providers/aws.ts +200 -0
  278. package/src/app/plugins/ocr/providers/azure.ts +183 -0
  279. package/src/app/plugins/ocr/providers/google.ts +155 -0
  280. package/src/app/plugins/ocr/providers/index.ts +80 -0
  281. package/src/app/plugins/ocr/providers/naver.ts +186 -0
  282. package/src/app/plugins/ocr/providers/tesseract.ts +198 -0
  283. package/src/app/plugins/ocr/providers/upstage.ts +156 -0
  284. package/src/app/plugins/ocr/quota.ts +108 -0
  285. package/src/app/plugins/ocr/refiner.ts +112 -0
  286. package/src/app/plugins/ocr/routes.ts +19 -0
  287. package/src/app/plugins/ocr/service.ts +333 -0
  288. package/src/app/plugins/ocr/template-loader.ts +72 -0
  289. package/src/app/plugins/ocr/template-matcher.ts +422 -0
  290. package/src/app/plugins/ocr/templates/business_reg.json +145 -0
  291. package/src/app/plugins/ocr/templates/career_cert.json +93 -0
  292. package/src/app/plugins/ocr/templates/driver_license.json +89 -0
  293. package/src/app/plugins/ocr/templates/facility_card.json +82 -0
  294. package/src/app/plugins/ocr/templates/id_card.json +55 -0
  295. package/src/app/plugins/ocr/templates/invoice.json +92 -0
  296. package/src/app/plugins/ocr/templates/namecard.json +116 -0
  297. package/src/app/plugins/ocr/templates/prompts/business_reg.json +14 -0
  298. package/src/app/plugins/ocr/templates/prompts/career_cert.json +16 -0
  299. package/src/app/plugins/ocr/templates/prompts/driver_license.json +14 -0
  300. package/src/app/plugins/ocr/templates/prompts/facility_card.json +15 -0
  301. package/src/app/plugins/ocr/templates/prompts/general.json +13 -0
  302. package/src/app/plugins/ocr/templates/prompts/id_card.json +11 -0
  303. package/src/app/plugins/ocr/templates/prompts/invoice.json +17 -0
  304. package/src/app/plugins/ocr/templates/prompts/namecard.json +15 -0
  305. package/src/app/plugins/ocr/templates/prompts/receipt.json +14 -0
  306. package/src/app/plugins/ocr/templates/receipt.json +79 -0
  307. package/src/app/plugins/ocr/types/config.ts +60 -0
  308. package/src/app/plugins/ocr/types/driver.ts +71 -0
  309. package/src/app/plugins/ocr/types/index.ts +5 -0
  310. package/src/app/plugins/ocr/types/parsed.ts +101 -0
  311. package/src/app/plugins/ocr/types/store.ts +70 -0
  312. package/src/app/plugins/ocr/types/template.ts +89 -0
  313. package/src/app/plugins/ocr/utils.ts +18 -0
  314. package/src/app/plugins/pg/config.example.json +79 -0
  315. package/src/app/plugins/pg/config.json +35 -0
  316. package/src/app/plugins/pg/config.ts +58 -0
  317. package/src/app/plugins/pg/docs/README.md +176 -0
  318. package/src/app/plugins/pg/entities/pg_cancel.json +60 -0
  319. package/src/app/plugins/pg/entities/pg_order.json +115 -0
  320. package/src/app/plugins/pg/entities/pg_webhook_log.json +52 -0
  321. package/src/app/plugins/pg/entity-adapter.ts +144 -0
  322. package/src/app/plugins/pg/handlers.ts +240 -0
  323. package/src/app/plugins/pg/index.ts +98 -0
  324. package/src/app/plugins/pg/providers/danal.ts +178 -0
  325. package/src/app/plugins/pg/providers/hecto.ts +340 -0
  326. package/src/app/plugins/pg/providers/index.ts +53 -0
  327. package/src/app/plugins/pg/providers/inicis.ts +151 -0
  328. package/src/app/plugins/pg/providers/kakaopay.ts +242 -0
  329. package/src/app/plugins/pg/providers/kcp.ts +147 -0
  330. package/src/app/plugins/pg/providers/naverpay.ts +299 -0
  331. package/src/app/plugins/pg/providers/payco.ts +290 -0
  332. package/src/app/plugins/pg/providers/payletter.ts +377 -0
  333. package/src/app/plugins/pg/providers/paypal.ts +423 -0
  334. package/src/app/plugins/pg/providers/toss.ts +157 -0
  335. package/src/app/plugins/pg/providers/wanna.ts +163 -0
  336. package/src/app/plugins/pg/routes.ts +31 -0
  337. package/src/app/plugins/pg/service.ts +531 -0
  338. package/src/app/plugins/pg/types/client.ts +52 -0
  339. package/src/app/plugins/pg/types/config.ts +42 -0
  340. package/src/app/plugins/pg/types/error.ts +25 -0
  341. package/src/app/plugins/pg/types/index.ts +4 -0
  342. package/src/app/plugins/pg/types/payment.ts +145 -0
  343. package/src/app/plugins/providers/docs/README.md +32 -0
  344. package/src/app/plugins/providers/solapi-auth.ts +27 -0
  345. package/src/app/plugins/push/config.example.json +26 -0
  346. package/src/app/plugins/push/config.json +18 -0
  347. package/src/app/plugins/push/config.ts +119 -0
  348. package/src/app/plugins/push/docs/README.md +147 -0
  349. package/src/app/plugins/push/entities/push_log.json +86 -0
  350. package/src/app/plugins/push/entities/push_msg.json +56 -0
  351. package/src/app/plugins/push/entity-adapter.ts +326 -0
  352. package/src/app/plugins/push/handlers.ts +193 -0
  353. package/src/app/plugins/push/index.ts +85 -0
  354. package/src/app/plugins/push/providers/apns.ts +152 -0
  355. package/src/app/plugins/push/providers/fcm.ts +181 -0
  356. package/src/app/plugins/push/providers/index.ts +42 -0
  357. package/src/app/plugins/push/providers/utils.ts +30 -0
  358. package/src/app/plugins/push/routes.ts +24 -0
  359. package/src/app/plugins/push/service.ts +297 -0
  360. package/src/app/plugins/push/types/config.ts +32 -0
  361. package/src/app/plugins/push/types/index.ts +14 -0
  362. package/src/app/plugins/push/types/job.ts +79 -0
  363. package/src/app/plugins/shared/docs/README.md +11 -0
  364. package/src/app/plugins/sms/config.example.json +30 -0
  365. package/src/app/plugins/sms/config.json +33 -0
  366. package/src/app/plugins/sms/config.ts +158 -0
  367. package/src/app/plugins/sms/docs/README.md +236 -0
  368. package/src/app/plugins/sms/entities/sms_log.json +65 -0
  369. package/src/app/plugins/sms/entities/sms_msg.json +82 -0
  370. package/src/app/plugins/sms/entities/sms_verification.json +50 -0
  371. package/src/app/plugins/sms/entity-adapter.ts +213 -0
  372. package/src/app/plugins/sms/handlers.ts +149 -0
  373. package/src/app/plugins/sms/index.ts +93 -0
  374. package/src/app/plugins/sms/providers/aligo.ts +73 -0
  375. package/src/app/plugins/sms/providers/aws-sns.ts +182 -0
  376. package/src/app/plugins/sms/providers/index.ts +47 -0
  377. package/src/app/plugins/sms/providers/nhn.ts +82 -0
  378. package/src/app/plugins/sms/providers/ppurio.ts +76 -0
  379. package/src/app/plugins/sms/providers/solapi.ts +83 -0
  380. package/src/app/plugins/sms/routes.ts +23 -0
  381. package/src/app/plugins/sms/service.ts +239 -0
  382. package/src/app/plugins/sms/types/client.ts +41 -0
  383. package/src/app/plugins/sms/types/config.ts +46 -0
  384. package/src/app/plugins/sms/types/index.ts +3 -0
  385. package/src/app/plugins/sms/types/job.ts +51 -0
  386. package/src/app/plugins/sms/verification.ts +162 -0
  387. package/src/app/plugins/smtp/config.json +5 -0
  388. package/src/app/plugins/smtp/config.ts +41 -0
  389. package/src/app/plugins/smtp/docs/README.md +165 -0
  390. package/src/app/plugins/smtp/handlers.ts +52 -0
  391. package/src/app/plugins/smtp/index.ts +33 -0
  392. package/src/app/plugins/smtp/routes.ts +19 -0
  393. package/src/app/plugins/smtp/templates/layout.html +50 -0
  394. package/src/app/plugins/smtp/types/config.ts +8 -0
  395. package/src/app/plugins/smtp/types/index.ts +1 -0
  396. package/src/app/plugins/taxinvoice/config.example.json +60 -0
  397. package/src/app/plugins/taxinvoice/config.json +35 -0
  398. package/src/app/plugins/taxinvoice/config.ts +117 -0
  399. package/src/app/plugins/taxinvoice/docs/README.md +322 -0
  400. package/src/app/plugins/taxinvoice/entities/tax_invoice.json +229 -0
  401. package/src/app/plugins/taxinvoice/entities/tax_invoice_item.json +56 -0
  402. package/src/app/plugins/taxinvoice/entities/tax_invoice_log.json +50 -0
  403. package/src/app/plugins/taxinvoice/entities/tax_invoice_party.json +61 -0
  404. package/src/app/plugins/taxinvoice/entity-adapter.ts +285 -0
  405. package/src/app/plugins/taxinvoice/handlers.ts +120 -0
  406. package/src/app/plugins/taxinvoice/index.ts +74 -0
  407. package/src/app/plugins/taxinvoice/providers/barobill.ts +273 -0
  408. package/src/app/plugins/taxinvoice/providers/bolta.ts +193 -0
  409. package/src/app/plugins/taxinvoice/providers/esero.ts +201 -0
  410. package/src/app/plugins/taxinvoice/providers/index.ts +41 -0
  411. package/src/app/plugins/taxinvoice/providers/popbill.ts +258 -0
  412. package/src/app/plugins/taxinvoice/providers/sendbill.ts +443 -0
  413. package/src/app/plugins/taxinvoice/providers/smartbill.ts +234 -0
  414. package/src/app/plugins/taxinvoice/routes.ts +17 -0
  415. package/src/app/plugins/taxinvoice/service.ts +439 -0
  416. package/src/app/plugins/taxinvoice/types/client.ts +57 -0
  417. package/src/app/plugins/taxinvoice/types/config.ts +42 -0
  418. package/src/app/plugins/taxinvoice/types/index.ts +4 -0
  419. package/src/app/plugins/taxinvoice/types/invoice.ts +128 -0
  420. package/src/app/plugins/taxinvoice/types/queue.ts +22 -0
  421. package/src/app/plugins/vessel_kr/config.json +9 -0
  422. package/src/app/plugins/vessel_kr/config.ts +32 -0
  423. package/src/app/plugins/vessel_kr/docs/README.md +167 -0
  424. package/src/app/plugins/vessel_kr/entities/vessel_kr_entry.json +136 -0
  425. package/src/app/plugins/vessel_kr/handlers.ts +102 -0
  426. package/src/app/plugins/vessel_kr/index.ts +21 -0
  427. package/src/app/plugins/vessel_kr/routes.ts +15 -0
  428. package/src/app/plugins/vessel_kr/service.ts +264 -0
  429. package/src/app/plugins/vessel_kr/types/index.ts +100 -0
  430. package/src/app/routes/README.md +71 -0
  431. package/src/app/routes/account/change-password/config.json +5 -0
  432. package/src/app/routes/account/change-password/entities/password_history.json +18 -0
  433. package/src/app/routes/account/change-password/handlers.ts +204 -0
  434. package/src/app/routes/account/change-password/routes.ts +28 -0
  435. package/src/app/routes/account/config.json +5 -0
  436. package/src/app/routes/account/reactivate/config.json +5 -0
  437. package/src/app/routes/account/reactivate/handlers.ts +249 -0
  438. package/src/app/routes/account/reactivate/routes.ts +14 -0
  439. package/src/app/routes/account/register/config-loader.ts +34 -0
  440. package/src/app/routes/account/register/config.json +8 -0
  441. package/src/app/routes/account/register/handlers.ts +207 -0
  442. package/src/app/routes/account/register/routes.ts +25 -0
  443. package/src/app/routes/account/register/types/index.ts +50 -0
  444. package/src/app/routes/account/routes.ts +31 -0
  445. package/src/app/routes/account/templates/force_reset.html +18 -0
  446. package/src/app/routes/account/templates/welcome.html +14 -0
  447. package/src/app/routes/account/withdraw/handlers.ts +111 -0
  448. package/src/app/routes/account/withdraw/routes.ts +18 -0
  449. package/src/app/routes/approval/config.json +5 -0
  450. package/src/app/routes/approval/entities/approval.json +99 -0
  451. package/src/app/routes/approval/entities/comments.json +17 -0
  452. package/src/app/routes/approval/entities/reference.json +16 -0
  453. package/src/app/routes/approval/routes.ts +30 -0
  454. package/src/app/routes/auth/config.json +5 -0
  455. package/src/app/routes/auth/handlers.ts +245 -0
  456. package/src/app/routes/auth/routes.ts +16 -0
  457. package/src/app/routes/board/config.json +5 -0
  458. package/src/app/routes/board/entities/board_category.json +90 -0
  459. package/src/app/routes/board/entities/board_comment.json +83 -0
  460. package/src/app/routes/board/entities/board_like.json +51 -0
  461. package/src/app/routes/board/entities/board_mention.json +50 -0
  462. package/src/app/routes/board/entities/board_post.json +148 -0
  463. package/src/app/routes/board/entities/board_post_tag.json +41 -0
  464. package/src/app/routes/board/entities/board_rating.json +127 -0
  465. package/src/app/routes/board/entities/board_read_log.json +29 -0
  466. package/src/app/routes/board/entities/board_report.json +53 -0
  467. package/src/app/routes/board/entities/board_tag.json +21 -0
  468. package/src/app/routes/board/handlers/categories.ts +134 -0
  469. package/src/app/routes/board/handlers/comments.ts +207 -0
  470. package/src/app/routes/board/handlers/files.ts +104 -0
  471. package/src/app/routes/board/handlers/likes.ts +31 -0
  472. package/src/app/routes/board/handlers/mentions.ts +54 -0
  473. package/src/app/routes/board/handlers/posts.ts +577 -0
  474. package/src/app/routes/board/handlers/ratings.ts +60 -0
  475. package/src/app/routes/board/handlers/reports.ts +131 -0
  476. package/src/app/routes/board/handlers/tags.ts +81 -0
  477. package/src/app/routes/board/routes.ts +137 -0
  478. package/src/app/routes/calendar/config.json +5 -0
  479. package/src/app/routes/calendar/entities/calendar_attendees.json +23 -0
  480. package/src/app/routes/calendar/entities/calendar_comments.json +17 -0
  481. package/src/app/routes/calendar/entities/calendar_events.json +48 -0
  482. package/src/app/routes/calendar/entities/calendar_kind.json +11 -0
  483. package/src/app/routes/calendar/entities/calendar_method.json +11 -0
  484. package/src/app/routes/calendar/routes.ts +32 -0
  485. package/src/app/routes/email-verify/config-loader.ts +47 -0
  486. package/src/app/routes/email-verify/config.example.json +13 -0
  487. package/src/app/routes/email-verify/config.json +16 -0
  488. package/src/app/routes/email-verify/entities/account.json +23 -0
  489. package/src/app/routes/email-verify/handlers/activate.ts +103 -0
  490. package/src/app/routes/email-verify/handlers/change.ts +106 -0
  491. package/src/app/routes/email-verify/handlers/confirm.ts +87 -0
  492. package/src/app/routes/email-verify/handlers/index.ts +20 -0
  493. package/src/app/routes/email-verify/handlers/send.ts +157 -0
  494. package/src/app/routes/email-verify/handlers/status.ts +53 -0
  495. package/src/app/routes/email-verify/handlers/utils.ts +85 -0
  496. package/src/app/routes/email-verify/routes.ts +54 -0
  497. package/src/app/routes/email-verify/templates/verification.html +15 -0
  498. package/src/app/routes/email-verify/templates/verification_link.html +19 -0
  499. package/src/app/routes/email-verify/types/index.ts +77 -0
  500. package/src/app/routes/email-verify/verification-utils.ts +57 -0
  501. package/src/app/routes/example-db/config.json +5 -0
  502. package/src/app/routes/example-db/handlers.ts +220 -0
  503. package/src/app/routes/example-db/models/account-ext.ts +33 -0
  504. package/src/app/routes/example-db/models/users.ts +30 -0
  505. package/src/app/routes/example-db/routes.ts +23 -0
  506. package/src/app/routes/example-db/types/defaults.ts +21 -0
  507. package/src/app/routes/example-db/types/index.ts +4 -0
  508. package/src/app/routes/example-db/types/params.ts +3 -0
  509. package/src/app/routes/example-db/types/query.ts +6 -0
  510. package/src/app/routes/example-db/types/user.ts +11 -0
  511. package/src/app/routes/example-es/config.json +5 -0
  512. package/src/app/routes/example-es/handlers.ts +216 -0
  513. package/src/app/routes/example-es/routes.ts +24 -0
  514. package/src/app/routes/example-es/types/defaults.ts +30 -0
  515. package/src/app/routes/example-es/types/index.ts +4 -0
  516. package/src/app/routes/example-es/types/params.ts +3 -0
  517. package/src/app/routes/example-es/types/post.ts +12 -0
  518. package/src/app/routes/example-es/types/query.ts +14 -0
  519. package/src/app/routes/funeral/config.json +5 -0
  520. package/src/app/routes/funeral/entities/funeral.json +77 -0
  521. package/src/app/routes/funeral/entities/funeral_docs.json +36 -0
  522. package/src/app/routes/funeral/entities/funeral_mourner.json +31 -0
  523. package/src/app/routes/funeral/entities/funeral_order.json +48 -0
  524. package/src/app/routes/funeral/entities/funeral_room.json +61 -0
  525. package/src/app/routes/funeral/entities/funeral_schedule.json +39 -0
  526. package/src/app/routes/funeral/routes.ts +32 -0
  527. package/src/app/routes/health/config.json +5 -0
  528. package/src/app/routes/health/handlers.ts +69 -0
  529. package/src/app/routes/health/routes.ts +14 -0
  530. package/src/app/routes/hr/career/config.json +5 -0
  531. package/src/app/routes/hr/career/entities/employee_career.json +15 -0
  532. package/src/app/routes/hr/career/routes.ts +25 -0
  533. package/src/app/routes/hr/config.json +5 -0
  534. package/src/app/routes/hr/education/config.json +5 -0
  535. package/src/app/routes/hr/education/entities/employee_education.json +29 -0
  536. package/src/app/routes/hr/education/entities/employee_education_mans.json +25 -0
  537. package/src/app/routes/hr/education/entities/employee_school.json +19 -0
  538. package/src/app/routes/hr/education/routes.ts +28 -0
  539. package/src/app/routes/hr/employee/config.json +5 -0
  540. package/src/app/routes/hr/employee/entities/employee.json +59 -0
  541. package/src/app/routes/hr/employee/entities/employee_cert.json +19 -0
  542. package/src/app/routes/hr/employee/entities/employee_reward.json +21 -0
  543. package/src/app/routes/hr/employee/routes.ts +27 -0
  544. package/src/app/routes/hr/entities/hr_group.json +47 -0
  545. package/src/app/routes/hr/entities/hr_group_pv.json +20 -0
  546. package/src/app/routes/hr/entities/hr_role.json +43 -0
  547. package/src/app/routes/hr/entities/hr_role_pv.json +20 -0
  548. package/src/app/routes/hr/routes.ts +29 -0
  549. package/src/app/routes/messages/chat/config.json +5 -0
  550. package/src/app/routes/messages/chat/entities/user_chat.json +47 -0
  551. package/src/app/routes/messages/chat/entities/user_chat_room.json +38 -0
  552. package/src/app/routes/messages/chat/entities/user_chat_room_member.json +49 -0
  553. package/src/app/routes/messages/chat/routes.ts +28 -0
  554. package/src/app/routes/messages/msgbox/config.json +5 -0
  555. package/src/app/routes/messages/msgbox/entities/user_msgbox.json +73 -0
  556. package/src/app/routes/messages/msgbox/routes.ts +28 -0
  557. package/src/app/routes/password-reset/config.example.json +13 -0
  558. package/src/app/routes/password-reset/config.json +15 -0
  559. package/src/app/routes/password-reset/entities/account.json +13 -0
  560. package/src/app/routes/password-reset/handlers.ts +335 -0
  561. package/src/app/routes/password-reset/password-utils.ts +96 -0
  562. package/src/app/routes/password-reset/routes.ts +84 -0
  563. package/src/app/routes/password-reset/templates/password_reset.html +21 -0
  564. package/src/app/routes/password-reset/templates/password_reset_link.html +19 -0
  565. package/src/app/routes/password-reset/types/index.ts +95 -0
  566. package/src/app/routes/privilege/config.json +5 -0
  567. package/src/app/routes/privilege/entities/pv_group.json +29 -0
  568. package/src/app/routes/privilege/entities/pv_group_item.json +31 -0
  569. package/src/app/routes/privilege/entities/pv_item.json +176 -0
  570. package/src/app/routes/privilege/entities/user_pv_group.json +20 -0
  571. package/src/app/routes/privilege/entities/user_pv_item.json +20 -0
  572. package/src/app/routes/privilege/routes.ts +33 -0
  573. package/src/app/routes/user/config.json +5 -0
  574. package/src/app/routes/user/entities/user.json +64 -0
  575. package/src/app/routes/user/entities/user_biometric.json +28 -0
  576. package/src/app/routes/user/routes.ts +27 -0
  577. package/src/app/routes/vessel-tracking/config.json +3 -0
  578. package/src/app/routes/vessel-tracking/entities/tracked_vessel.json +261 -0
  579. package/src/app/routes/vessel-tracking/handlers.ts +134 -0
  580. package/src/app/routes/vessel-tracking/routes.ts +25 -0
  581. package/src/app/routes/vessel-tracking/types/index.ts +5 -0
  582. package/src/app/routes/vessel-tracking/types/vessel.ts +59 -0
  583. package/src/app/schedules/README.md +105 -0
  584. package/src/app/schedules/ais_sync/config.json +4 -0
  585. package/src/app/schedules/ais_sync/index.ts +69 -0
  586. package/src/app/schedules/data-retention/config.json +9 -0
  587. package/src/app/schedules/data-retention/index.ts +238 -0
  588. package/src/app/schedules/dormancy/config.json +15 -0
  589. package/src/app/schedules/dormancy/entities/account.json +14 -0
  590. package/src/app/schedules/dormancy/entities/privacy_cron_lock.json +23 -0
  591. package/src/app/schedules/dormancy/index.ts +289 -0
  592. package/src/app/schedules/dormancy/templates/dormancy_completed.html +21 -0
  593. package/src/app/schedules/dormancy/templates/dormancy_warning.html +20 -0
  594. package/src/app/schedules/kobc_freight_sync/config.json +4 -0
  595. package/src/app/schedules/kobc_freight_sync/index.ts +94 -0
  596. package/src/app/schedules/vessel_kr_sync/config.json +4 -0
  597. package/src/app/schedules/vessel_kr_sync/index.ts +72 -0
  598. package/src/system/app.ts +129 -0
  599. package/src/system/cache/_store-ref.ts +15 -0
  600. package/src/system/cache/config.ts +61 -0
  601. package/src/system/cache/drivers/memcached.ts +135 -0
  602. package/src/system/cache/drivers/memory.ts +92 -0
  603. package/src/system/cache/drivers/redis.ts +109 -0
  604. package/src/system/cache/index.ts +43 -0
  605. package/src/system/cache/namespaced.ts +79 -0
  606. package/src/system/cache/plugin.ts +59 -0
  607. package/src/system/cache/types.ts +81 -0
  608. package/src/system/config/config-path.ts +20 -0
  609. package/src/system/config/cors.ts +49 -0
  610. package/src/system/config/database.ts +190 -0
  611. package/src/system/config/entity-server.ts +8 -0
  612. package/src/system/config/env-substitution.ts +4 -0
  613. package/src/system/config/env.ts +30 -0
  614. package/src/system/config/json-config.ts +13 -0
  615. package/src/system/config/module-path.ts +16 -0
  616. package/src/system/config/packet-encrypt.ts +80 -0
  617. package/src/system/config/rate-limit.ts +4 -0
  618. package/src/system/config/security-loader.ts +25 -0
  619. package/src/system/config/security.ts +16 -0
  620. package/src/system/config/server.ts +81 -0
  621. package/src/system/crypto/cipher.ts +117 -0
  622. package/src/system/crypto/data-encrypt.ts +174 -0
  623. package/src/system/crypto/hash.ts +24 -0
  624. package/src/system/crypto/packet.test.ts +23 -0
  625. package/src/system/crypto/packet.ts +97 -0
  626. package/src/system/crypto/random.ts +19 -0
  627. package/src/system/email/sender.ts +85 -0
  628. package/src/system/email/template-engine.ts +147 -0
  629. package/src/system/entity-server/bootstrap.ts +270 -0
  630. package/src/system/entity-server/client.ts +64 -0
  631. package/src/system/hooks/loader.ts +32 -0
  632. package/src/system/hooks/runner.ts +159 -0
  633. package/src/system/hooks/types.ts +75 -0
  634. package/src/system/hooks/withdraw-hooks.ts +42 -0
  635. package/src/system/http/cookie.ts +62 -0
  636. package/src/system/http/response.ts +16 -0
  637. package/src/system/index.ts +48 -0
  638. package/src/system/logging/log-format.ts +50 -0
  639. package/src/system/logging/logger.ts +104 -0
  640. package/src/system/middleware/_db-ref.ts +26 -0
  641. package/src/system/middleware/_push-ref.ts +28 -0
  642. package/src/system/middleware/access-log.ts +34 -0
  643. package/src/system/middleware/auth.ts +67 -0
  644. package/src/system/middleware/csrf.ts +172 -0
  645. package/src/system/middleware/database.ts +44 -0
  646. package/src/system/middleware/error-handler.ts +51 -0
  647. package/src/system/middleware/extension-loader.ts +111 -0
  648. package/src/system/middleware/packet-encrypt.ts +281 -0
  649. package/src/system/middleware/request-id.ts +18 -0
  650. package/src/system/plugins/access-log.ts +34 -0
  651. package/src/system/plugins/packet-encrypt.ts +281 -0
  652. package/src/system/proxy/register.ts +37 -0
  653. package/src/system/public-api.ts +140 -0
  654. package/src/system/push/sender.ts +131 -0
  655. package/src/system/routes/entity-interceptor.ts +327 -0
  656. package/src/system/routes/loader.ts +215 -0
  657. package/src/system/scheduler/cron-utils.ts +150 -0
  658. package/src/system/scheduler/distributed-lock.ts +141 -0
  659. package/src/system/scheduler/schedule-loader.ts +105 -0
  660. package/src/system/security/anonymous-device-id.ts +41 -0
  661. package/src/system/security/anonymous-device.ts +98 -0
  662. package/src/system/security/anonymous-packet-token.ts +23 -0
  663. package/src/system/security/packet-bootstrap.ts +16 -0
  664. package/src/system/security/password-policy.ts +191 -0
  665. package/src/system/startup-banner.ts +191 -0
  666. package/src/system/types/fastify.d.ts +53 -0
  667. package/src/system/utils/app-path.ts +31 -0
  668. package/src/system/utils/coerce.ts +28 -0
  669. package/src/system/utils/date-prefixed-log-stream.ts +176 -0
  670. package/src/system/utils/errors.ts +66 -0
  671. package/src/system/utils/format.ts +45 -0
  672. package/src/system/utils/http-client.ts +79 -0
  673. package/src/system/utils/user-agent.ts +82 -0
  674. package/tsconfig.app.json +17 -0
  675. package/tsconfig.json +39 -0
@@ -0,0 +1,2342 @@
1
+ # 범용 게시판 API 설계
2
+
3
+ > **작성일**: 2026-03-06
4
+ > **상태**: 🔍 검토 중
5
+ > **관련**: `gateway-server-architecture.md`, `plugin-models.md`
6
+
7
+ ---
8
+
9
+ ## 목차
10
+
11
+ 1. [개요](#1-개요)
12
+ 2. [용어 정의](#2-용어-정의)
13
+ 3. [아키텍처 흐름](#3-아키텍처-흐름)
14
+ 4. [엔티티 설계](#4-엔티티-설계)
15
+ 5. [앱서버 Routes 설계](#5-앱서버-routes-설계)
16
+ 6. [API 상세 스펙](#6-api-상세-스펙)
17
+ 7. [권한 및 보안](#7-권한-및-보안)
18
+ 8. [확장 고려사항](#8-확장-고려사항)
19
+
20
+ ---
21
+
22
+ ## 1. 개요
23
+
24
+ 여러 프로젝트(codeshop, elim_crm, elim_gas 등)에서 공통으로 사용할 수 있는 **범용 게시판 API**를 설계한다.
25
+ 각 프로젝트는 **entity-app-server**(Fastify/TypeScript)를 사용하며,
26
+ 게시판은 앱서버의 `routes/board/` 에 구현한다.
27
+
28
+ ### 핵심 요구사항
29
+
30
+ - 게시판 종류를 동적으로 생성/관리 (카테고리)
31
+ - 게시글 CRUD + 목록 조회 (페이징, 검색, 정렬)
32
+ - **답글**: 게시글에 대한 계층형 답변. `board_post` 엔티티에서 `parent_seq`로 자기참조 트리 구성 (depth 무제한)
33
+ - **댓글**: 게시글·답글 내부의 짧은 코멘트 (단일 depth)
34
+ - 파일 첨부
35
+ - 조회수 카운트
36
+ - 고정글(pin) 지원
37
+ - 권한 제어 (읽기/쓰기/관리)
38
+
39
+ ---
40
+
41
+ ## 2. 용어 정의
42
+
43
+ | 용어 | 엔티티 | 설명 |
44
+ | ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------- |
45
+ | **게시글** | `board_post` | 게시판의 원글. `parent_seq=null`, `depth=0`. |
46
+ | **답글** | `board_post` | 게시글에 대한 계층형 답변. **동일 엔티티** `board_post`에서 `parent_seq`로 트리를 형성한다. |
47
+ | **댓글** | `board_comment` | 게시글 또는 답글에 달리는 짧은 코멘트. 단일 depth. |
48
+ | **카테고리** | `board_category` | 게시판 종류 (공지사항, 자유게시판, Q&A 등) |
49
+ | **첨부파일** | `file_meta` (엔티티 서버 files API) | 게시글에 첨부된 파일. 엔티티 서버의 파일 스토리지를 사용한다. |
50
+
51
+ > **핵심**: 원글과 답글은 모두 `board_post` 엔티티 하나에 저장된다. 목록은 하나의 API로 원글·답글을 함께 조회하며,
52
+ > `parent_seq` / `depth` / `root_seq` 필드로 계층 구조를 표현한다.
53
+
54
+ ```
55
+ board_post (seq:42, parent_seq=null, depth=0) ← 원글
56
+ ├── board_post (seq:10, parent_seq=42, root_seq=42, depth=1) ← 답글
57
+ │ ├── board_post (seq:15, parent_seq=10, root_seq=42, depth=2) ← 대답변
58
+ │ │ └── 댓글 (board_comment, post_seq=15) ← 대답변 코멘트
59
+ │ └── 댓글 (board_comment, post_seq=10) ← 답글 코멘트
60
+ ├── board_post (seq:11, parent_seq=42, root_seq=42, depth=1) ← 답글 B
61
+ ├── 댓글 1 (board_comment, post_seq=42) ← 원글 코멘트
62
+ ├── 댓글 2 (board_comment, post_seq=42)
63
+ └── 첨부파일 (file_meta) ← /v1/files/board_post/upload?entity_seq=42
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 3. 아키텍처 흐름
69
+
70
+ ```
71
+ ┌──────────────┐ ┌───────────────────────┐ ┌──────────────────┐
72
+ │ Frontend │────▶│ App Server │────▶│ Entity Server │
73
+ │ (Vue/React) │◀────│ (Fastify/TypeScript) │◀────│ (Go/Fiber) │
74
+ └──────────────┘ └───────────────────────┘ └──────────────────┘
75
+ │ │
76
+ │ entity-app-server │ Entities:
77
+ │ routes/board/route.ts │ board_category
78
+ │ │ board_post
79
+ │ 역할: │ board_comment
80
+ │ - JWT 인증 (auth plugin) │ (parent_seq 자기참조)
81
+ │ - 입력 검증 │
82
+ │ - 권한 확인 │ Files API:
83
+ │ - 파일: files API 프록시 │ /v1/files/board_post/*
84
+ │ - entityServer.* SDK 호출 │ 역할:
85
+ │ - 응답 가공 │ - CRUD 처리
86
+ │ │ - 데이터 검증
87
+ │ @gateway/api 사용: │ - Hook 실행
88
+ │ ok(), fail(), entityServer │ - 이력 관리
89
+ └──────────────────────────────┘
90
+ ```
91
+
92
+ ### 요청 흐름 예시: 게시글 작성
93
+
94
+ ```
95
+ Frontend App Server (Fastify) Entity Server (Go)
96
+ │ │ │
97
+ │ POST /api/v1/board/{category}/submit │ │
98
+ │ { title, content, parent_seq, ... } │ │
99
+ │───────────────────────────▶│ │
100
+ │ │ 1. preHandler: authenticate │
101
+ │ │ 2. 입력 검증 │
102
+ │ │ 3. 카테고리 권한 확인 │
103
+ │ │ │
104
+ │ │ entityServer.submit("board_post",│
105
+ │ │ { category_seq, title, ... }) │
106
+ │ │──────────────────────────────────▶│
107
+ │ │ │ validate
108
+ │ │ │ insert
109
+ │ │ │ run hooks
110
+ │ │ { seq, ... } │
111
+ │ │◀──────────────────────────────────│
112
+ │ ok({ seq }) │ │
113
+ │◀───────────────────────────│ │
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 4. 엔티티 설계
119
+
120
+ ### 4.1 board_category (게시판 카테고리)
121
+
122
+ 게시판 종류를 정의한다. 공지사항, 자유게시판, Q&A 등.
123
+
124
+ ```json
125
+ {
126
+ "name": "board_category",
127
+ "description": "게시판 카테고리 (게시판 종류 정의)",
128
+ "fields": {
129
+ "name": {
130
+ "index": true,
131
+ "required": true,
132
+ "unique": true,
133
+ "comment": "게시판 이름 (예: notice, free, qna)"
134
+ },
135
+ "label": {
136
+ "required": true,
137
+ "comment": "표시 이름 (예: 공지사항, 자유게시판)"
138
+ },
139
+ "description": {
140
+ "comment": "게시판 설명"
141
+ },
142
+ "status": {
143
+ "index": true,
144
+ "type": ["active", "inactive"],
145
+ "default": "active",
146
+ "comment": "게시판 활성 상태"
147
+ },
148
+ "sort_order": {
149
+ "type": "uint",
150
+ "default": "0",
151
+ "comment": "정렬 순서"
152
+ },
153
+ "read_role": {
154
+ "type": ["all", "member", "admin"],
155
+ "default": "all",
156
+ "comment": "읽기 권한"
157
+ },
158
+ "write_role": {
159
+ "type": ["all", "member", "admin"],
160
+ "default": "member",
161
+ "comment": "쓰기 권한"
162
+ },
163
+ "reply_enabled": {
164
+ "type": ["Y", "N"],
165
+ "default": "Y",
166
+ "comment": "답글 허용 여부"
167
+ },
168
+ "comment_enabled": {
169
+ "type": ["Y", "N"],
170
+ "default": "Y",
171
+ "comment": "댓글 허용 여부"
172
+ },
173
+ "file_enabled": {
174
+ "type": ["Y", "N"],
175
+ "default": "Y",
176
+ "comment": "파일 첨부 허용 여부"
177
+ },
178
+ "guest_write_enabled": {
179
+ "type": ["Y", "N"],
180
+ "default": "N",
181
+ "comment": "비회원 글작성 허용 여부 (비밀번호 인증)"
182
+ },
183
+ "like_enabled": {
184
+ "type": ["Y", "N"],
185
+ "default": "N",
186
+ "comment": "좋아요/추천 허용 여부"
187
+ },
188
+ "rating_enabled": {
189
+ "type": ["Y", "N"],
190
+ "default": "N",
191
+ "comment": "별점 허용 여부"
192
+ },
193
+ "accept_enabled": {
194
+ "type": ["Y", "N"],
195
+ "default": "N",
196
+ "comment": "답변 채택 허용 여부 (Q&A 게시판용)"
197
+ },
198
+ "push_on_write": {
199
+ "type": ["Y", "N"],
200
+ "default": "N",
201
+ "comment": "글 등록 시 관리자 push 알림 여부 (문의게시판 등)"
202
+ },
203
+ "view_count_mode": {
204
+ "type": ["always", "session", "daily", "once"],
205
+ "default": "daily",
206
+ "comment": "조회수 카운트 방식. always=매 조회, session=세션당 1회, daily=1일 1인 1회(기본), once=1인 1회(영구)"
207
+ },
208
+ "anonymous_enabled": {
209
+ "type": ["Y", "N"],
210
+ "default": "N",
211
+ "comment": "익명 게시판 여부. Y이면 작성자 표시를 '익명'으로 강제, user_seq는 응답에서 숨김"
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ ### 4.2 board_post (게시글 · 답글)
218
+
219
+ 원글과 답글을 **동일 엔티티**에서 관리한다. `parent_seq=null`이면 원글(depth=0),
220
+ 값이 있으면 답글이다. 목록 API는 `category_seq` 기준으로 전체(원글+답글)를 한 번에 반환하며,
221
+ 프론트엔드에서 `parent_seq`와 `depth`를 이용해 트리 구조로 조립한다.
222
+
223
+ ```json
224
+ {
225
+ "name": "board_post",
226
+ "description": "게시판 원글 및 답글 (자기참조 트리)",
227
+ "fields": {
228
+ "category_seq": {
229
+ "index": true,
230
+ "required": true,
231
+ "type": "uint",
232
+ "comment": "게시판 카테고리 seq"
233
+ },
234
+ "user_seq": {
235
+ "index": true,
236
+ "type": "uint",
237
+ "comment": "작성자 user seq (비회원이면 null)"
238
+ },
239
+ "author_name": {
240
+ "required": true,
241
+ "comment": "작성자 표시 이름 (회원: 닉네임, 비회원: 입력값)"
242
+ },
243
+ "guest_password": {
244
+ "comment": "비회원 비밀번호 (bcrypt 해시, 비회원 글만 사용)"
245
+ },
246
+ "title": {
247
+ "index": true,
248
+ "required": true,
249
+ "comment": "제목"
250
+ },
251
+ "content": {
252
+ "required": true,
253
+ "comment": "본문 (HTML or Markdown)"
254
+ },
255
+ "status": {
256
+ "index": true,
257
+ "type": ["published", "draft", "deleted"],
258
+ "default": "published",
259
+ "comment": "글 상태"
260
+ },
261
+ "pinned": {
262
+ "index": true,
263
+ "type": ["Y", "N"],
264
+ "default": "N",
265
+ "comment": "상단 고정 여부"
266
+ },
267
+ "view_count": {
268
+ "type": "uint",
269
+ "default": "0",
270
+ "comment": "조회수"
271
+ },
272
+ "reply_count": {
273
+ "type": "uint",
274
+ "default": "0",
275
+ "comment": "답글 수 (캐시)"
276
+ },
277
+ "comment_count": {
278
+ "type": "uint",
279
+ "default": "0",
280
+ "comment": "댓글 수 (캐시)"
281
+ },
282
+ "file_count": {
283
+ "type": "uint",
284
+ "default": "0",
285
+ "comment": "첨부파일 수 (캐시, 앱서버에서 업로드/삭제 시 갱신)"
286
+ },
287
+ "parent_seq": {
288
+ "index": true,
289
+ "type": "uint",
290
+ "comment": "부모 글 seq (null=원글, 값 있으면 답글)"
291
+ },
292
+ "root_seq": {
293
+ "index": true,
294
+ "type": "uint",
295
+ "comment": "최상위 원글 seq (답글 조회 필터링용, 원글은 자기 자신 seq)"
296
+ },
297
+ "depth": {
298
+ "type": "uint",
299
+ "default": "0",
300
+ "comment": "깊이 (0=원글, 1=직접 답변, 2=대답변, ...)"
301
+ },
302
+ "like_count": {
303
+ "type": "uint",
304
+ "default": "0",
305
+ "comment": "좋아요 수 (캐시)"
306
+ },
307
+ "rating_sum": {
308
+ "type": "uint",
309
+ "default": "0",
310
+ "comment": "별점 합계 (캐시, rating_avg = rating_sum / rating_count)"
311
+ },
312
+ "rating_count": {
313
+ "type": "uint",
314
+ "default": "0",
315
+ "comment": "별점 참여 수 (캐시)"
316
+ },
317
+ "accepted": {
318
+ "index": true,
319
+ "type": ["Y", "N"],
320
+ "default": "N",
321
+ "comment": "채택된 답변 여부 (accept_enabled 게시판의 답글에만 사용)"
322
+ }
323
+ },
324
+ "fk": {
325
+ "category_seq": "board_category.seq",
326
+ "user_seq": "user.seq",
327
+ "parent_seq": "board_post.seq",
328
+ "root_seq": "board_post.seq"
329
+ },
330
+ "hooks": {
331
+ "after_insert": [
332
+ {
333
+ "description": "답글 작성 시 원글의 reply_count 증가 (원글 = root_seq 기준. depth에 관계없이 모든 하위 답글을 원글 1건으로 카운트)",
334
+ "type": "update",
335
+ "entity": "board_post",
336
+ "condition": "${new.parent_seq} != null",
337
+ "match": { "seq": "${new.root_seq}" },
338
+ "data": { "reply_count": "${target.reply_count + 1}" }
339
+ },
340
+ {
341
+ "description": "글 등록 시 카테고리 push_on_write=Y이면 관리자에게 push 알림",
342
+ "type": "push",
343
+ "condition": "${category.push_on_write} == 'Y'",
344
+ "target": "role:admin",
345
+ "title": "[${category.label}] 새 글이 등록되었습니다",
346
+ "body": "${new.author_name}: ${new.title}",
347
+ "data": {
348
+ "type": "board_post",
349
+ "seq": "${new.seq}",
350
+ "category": "${category.name}"
351
+ }
352
+ }
353
+ ],
354
+ "after_delete": [
355
+ {
356
+ "description": "답글 삭제 시 원글의 reply_count 감소 (root_seq 기준)",
357
+ "type": "update",
358
+ "entity": "board_post",
359
+ "condition": "${old.parent_seq} != null",
360
+ "match": { "seq": "${old.root_seq}" },
361
+ "data": { "reply_count": "${target.reply_count - 1}" }
362
+ }
363
+ ]
364
+ }
365
+ }
366
+ ```
367
+
368
+ ### 4.3 board_comment (댓글)
369
+
370
+ 게시글 또는 답글에 달리는 짧은 코멘트. 원글·답글 모두 `board_post` 엔티티이므로
371
+ `post_seq` 하나로 대상을 지정한다. `root_seq`는 원글의 전체 댓글을 한 번에 조회할 때 사용한다.
372
+
373
+ ```json
374
+ {
375
+ "name": "board_comment",
376
+ "description": "게시글/답글 댓글 (단일 depth)",
377
+ "fields": {
378
+ "post_seq": {
379
+ "index": true,
380
+ "required": true,
381
+ "type": "uint",
382
+ "comment": "댓글 대상 board_post seq (원글 또는 답글)"
383
+ },
384
+ "root_seq": {
385
+ "index": true,
386
+ "required": true,
387
+ "type": "uint",
388
+ "comment": "최상위 원글 seq (해당 원글의 전체 댓글 조회용)"
389
+ },
390
+ "user_seq": {
391
+ "index": true,
392
+ "required": true,
393
+ "type": "uint",
394
+ "comment": "작성자 user seq"
395
+ },
396
+ "author_name": {
397
+ "required": true,
398
+ "comment": "작성자 표시 이름"
399
+ },
400
+ "content": {
401
+ "required": true,
402
+ "comment": "댓글 내용"
403
+ },
404
+ "status": {
405
+ "index": true,
406
+ "type": ["active", "deleted"],
407
+ "default": "active",
408
+ "comment": "댓글 상태"
409
+ },
410
+ "rating_sum": {
411
+ "type": "uint",
412
+ "default": "0",
413
+ "comment": "별점 합계 (캐시, rating_avg = rating_sum / rating_count)"
414
+ },
415
+ "rating_count": {
416
+ "type": "uint",
417
+ "default": "0",
418
+ "comment": "별점 참여 수 (캐시)"
419
+ }
420
+ },
421
+ "fk": {
422
+ "post_seq": "board_post.seq",
423
+ "root_seq": "board_post.seq",
424
+ "user_seq": "user.seq"
425
+ },
426
+ "hooks": {
427
+ "after_insert": [
428
+ {
429
+ "description": "댓글 작성 시 대상 board_post.comment_count 증가",
430
+ "type": "update",
431
+ "entity": "board_post",
432
+ "match": { "seq": "${new.post_seq}" },
433
+ "data": { "comment_count": "${target.comment_count + 1}" }
434
+ }
435
+ ],
436
+ "after_delete": [
437
+ {
438
+ "description": "댓글 삭제 시 대상 board_post.comment_count 감소",
439
+ "type": "update",
440
+ "entity": "board_post",
441
+ "match": { "seq": "${old.post_seq}" },
442
+ "data": { "comment_count": "${target.comment_count - 1}" }
443
+ }
444
+ ]
445
+ }
446
+ }
447
+ ```
448
+
449
+ ### 4.4 첨부파일 — 엔티티 서버 Files API
450
+
451
+ `board_file` 별도 엔티티를 만들지 않는다. 엔티티 서버의 내장 파일 스토리지(`/v1/files/:entity/*`)를 사용하며,
452
+ `board_post` 엔티티를 스코프로 하여 `entity_seq`(게시글 seq)로 파일을 연결한다.
453
+
454
+ **엔티티 서버 Files API 라우트:**
455
+
456
+ | 동작 | 엔드포인트 | 설명 |
457
+ | -------- | ------------------------------------------- | ----------------------------------------------- |
458
+ | 업로드 | `POST /v1/files/board_post/upload` | `entity_seq={postSeq}`, `field_name=attachment` |
459
+ | 목록 | `POST /v1/files/board_post/list` | `ref_seq={postSeq}` |
460
+ | 다운로드 | `GET /v1/files/{uuid}` | 브라우저 인라인 뷰 (공개 파일) |
461
+ | 다운로드 | `POST /v1/files/board_post/download/{uuid}` | API 다운로드 (항상 attachment) |
462
+ | 삭제 | `POST /v1/files/board_post/delete/{uuid}` | |
463
+ | 메타 | `POST /v1/files/board_post/meta/{uuid}` | |
464
+
465
+ **앱서버 SDK 사용:**
466
+
467
+ ```typescript
468
+ // 업로드
469
+ const res = await entityServer.fileUpload("board_post", file, {
470
+ refSeq: postSeq,
471
+ isPublic: false,
472
+ });
473
+ // res.uuid → 파일 식별자
474
+ // res.data → FileMeta (uuid, original_name, mime_type, size, account_seq, created_time 등)
475
+
476
+ // 목록
477
+ const list = await entityServer.fileList("board_post", { refSeq: postSeq });
478
+
479
+ // 삭제
480
+ await entityServer.fileDelete("board_post", uuid);
481
+ ```
482
+
483
+ **file_count 갱신**: `board_post.file_count`는 별도 hook이 없으므로, 업로드·삭제 후 앱서버 핸들러에서
484
+ `entityServer.submit("board_post", { seq: postSeq, file_count: newCount })` 로 갱신한다.
485
+
486
+ ### 4.5 board_like (좋아요 · 추천)
487
+
488
+ 게시글 또는 답글에 대한 좋아요. `post_seq + user_seq` 유니크로 중복 방지.
489
+ 비회원은 좋아요 불가 (IP 기반 방식 필요 시 `guest_ip` 필드 추가 가능).
490
+
491
+ ```json
492
+ {
493
+ "name": "board_like",
494
+ "description": "게시글/답글 좋아요",
495
+ "fields": {
496
+ "post_seq": {
497
+ "index": true,
498
+ "required": true,
499
+ "type": "uint",
500
+ "comment": "대상 board_post seq"
501
+ },
502
+ "user_seq": {
503
+ "index": true,
504
+ "required": true,
505
+ "type": "uint",
506
+ "comment": "좋아요한 user seq"
507
+ }
508
+ },
509
+ "unique": ["post_seq", "user_seq"],
510
+ "fk": {
511
+ "post_seq": "board_post.seq",
512
+ "user_seq": "user.seq"
513
+ },
514
+ "hooks": {
515
+ "after_insert": [
516
+ {
517
+ "type": "update",
518
+ "entity": "board_post",
519
+ "match": { "seq": "${new.post_seq}" },
520
+ "data": { "like_count": "${target.like_count + 1}" }
521
+ }
522
+ ],
523
+ "after_delete": [
524
+ {
525
+ "type": "update",
526
+ "entity": "board_post",
527
+ "match": { "seq": "${old.post_seq}" },
528
+ "data": { "like_count": "${target.like_count - 1}" }
529
+ }
530
+ ]
531
+ }
532
+ }
533
+ ```
534
+
535
+ ### 4.6 board_rating (별점)
536
+
537
+ 게시글·답글·댓글 모두에 별점을 줄 수 있다. `target_type` + `target_seq` + `user_seq` 유니크로 중복 방지.
538
+ hook은 `target_type`에 따라 `board_post` 또는 `board_comment`의 캐시 필드를 갱신한다.
539
+ 평균은 `rating_sum / rating_count`로 계산한다.
540
+
541
+ ```json
542
+ {
543
+ "name": "board_rating",
544
+ "description": "게시글/답글/댓글 별점 (1~5)",
545
+ "fields": {
546
+ "target_type": {
547
+ "index": true,
548
+ "required": true,
549
+ "type": ["post", "comment"],
550
+ "comment": "별점 대상 유형 (post=게시글/답글, comment=댓글)"
551
+ },
552
+ "target_seq": {
553
+ "index": true,
554
+ "required": true,
555
+ "type": "uint",
556
+ "comment": "대상 엔티티 seq (board_post.seq 또는 board_comment.seq)"
557
+ },
558
+ "user_seq": {
559
+ "index": true,
560
+ "required": true,
561
+ "type": "uint",
562
+ "comment": "평가한 user seq"
563
+ },
564
+ "score": {
565
+ "required": true,
566
+ "type": ["1", "2", "3", "4", "5"],
567
+ "comment": "별점 (1~5)"
568
+ }
569
+ },
570
+ "unique": ["target_type", "target_seq", "user_seq"],
571
+ "fk": {
572
+ "user_seq": "user.seq"
573
+ },
574
+ "hooks": {
575
+ "after_insert": [
576
+ {
577
+ "description": "게시글/답글 별점 캐시 갱신",
578
+ "type": "update",
579
+ "entity": "board_post",
580
+ "condition": "${new.target_type} == 'post'",
581
+ "match": { "seq": "${new.target_seq}" },
582
+ "data": {
583
+ "rating_sum": "${target.rating_sum + new.score}",
584
+ "rating_count": "${target.rating_count + 1}"
585
+ }
586
+ },
587
+ {
588
+ "description": "댓글 별점 캐시 갱신",
589
+ "type": "update",
590
+ "entity": "board_comment",
591
+ "condition": "${new.target_type} == 'comment'",
592
+ "match": { "seq": "${new.target_seq}" },
593
+ "data": {
594
+ "rating_sum": "${target.rating_sum + new.score}",
595
+ "rating_count": "${target.rating_count + 1}"
596
+ }
597
+ }
598
+ ],
599
+ "after_update": [
600
+ {
601
+ "description": "게시글/답글 별점 수정 시 합계 보정",
602
+ "type": "update",
603
+ "entity": "board_post",
604
+ "condition": "${new.target_type} == 'post'",
605
+ "match": { "seq": "${new.target_seq}" },
606
+ "data": {
607
+ "rating_sum": "${target.rating_sum - old.score + new.score}"
608
+ }
609
+ },
610
+ {
611
+ "description": "댓글 별점 수정 시 합계 보정",
612
+ "type": "update",
613
+ "entity": "board_comment",
614
+ "condition": "${new.target_type} == 'comment'",
615
+ "match": { "seq": "${new.target_seq}" },
616
+ "data": {
617
+ "rating_sum": "${target.rating_sum - old.score + new.score}"
618
+ }
619
+ }
620
+ ],
621
+ "after_delete": [
622
+ {
623
+ "description": "게시글/답글 별점 취소 캐시 보정",
624
+ "type": "update",
625
+ "entity": "board_post",
626
+ "condition": "${old.target_type} == 'post'",
627
+ "match": { "seq": "${old.target_seq}" },
628
+ "data": {
629
+ "rating_sum": "${target.rating_sum - old.score}",
630
+ "rating_count": "${target.rating_count - 1}"
631
+ }
632
+ },
633
+ {
634
+ "description": "댓글 별점 취소 캐시 보정",
635
+ "type": "update",
636
+ "entity": "board_comment",
637
+ "condition": "${old.target_type} == 'comment'",
638
+ "match": { "seq": "${old.target_seq}" },
639
+ "data": {
640
+ "rating_sum": "${target.rating_sum - old.score}",
641
+ "rating_count": "${target.rating_count - 1}"
642
+ }
643
+ }
644
+ ]
645
+ }
646
+ }
647
+ ```
648
+
649
+ ### 4.7 board_tag (태그)
650
+
651
+ 게시글에 붙이는 태그. `name`은 소문자 슬러그(`javascript`, `vue-js`), `label`은 표시명.
652
+ `use_count`는 `board_post_tag` hook으로 자동 갱신된다.
653
+
654
+ ```json
655
+ {
656
+ "name": "board_tag",
657
+ "description": "게시글 태그",
658
+ "fields": {
659
+ "name": {
660
+ "index": true,
661
+ "required": true,
662
+ "unique": true,
663
+ "comment": "태그 슬러그 (예: javascript, vue-js) — lowercase, 하이픈 허용"
664
+ },
665
+ "label": {
666
+ "required": true,
667
+ "comment": "표시명 (예: JavaScript, Vue.js)"
668
+ },
669
+ "use_count": {
670
+ "type": "uint",
671
+ "default": "0",
672
+ "comment": "사용 횟수 캐시 (board_post_tag hook으로 갱신)"
673
+ }
674
+ }
675
+ }
676
+ ```
677
+
678
+ ### 4.8 board_post_tag (게시글-태그 연결)
679
+
680
+ `board_post`와 `board_tag`의 M:N 연결 테이블.
681
+ 삽입/삭제 시 hook으로 `board_tag.use_count`를 갱신한다.
682
+
683
+ ```json
684
+ {
685
+ "name": "board_post_tag",
686
+ "description": "게시글-태그 연결 (M:N)",
687
+ "fields": {
688
+ "post_seq": {
689
+ "index": true,
690
+ "required": true,
691
+ "type": "uint",
692
+ "comment": "board_post seq"
693
+ },
694
+ "tag_seq": {
695
+ "index": true,
696
+ "required": true,
697
+ "type": "uint",
698
+ "comment": "board_tag seq"
699
+ }
700
+ },
701
+ "unique": ["post_seq", "tag_seq"],
702
+ "fk": {
703
+ "post_seq": "board_post.seq",
704
+ "tag_seq": "board_tag.seq"
705
+ },
706
+ "hooks": {
707
+ "after_insert": [
708
+ {
709
+ "type": "update",
710
+ "entity": "board_tag",
711
+ "match": { "seq": "${new.tag_seq}" },
712
+ "data": { "use_count": "${target.use_count + 1}" }
713
+ }
714
+ ],
715
+ "after_delete": [
716
+ {
717
+ "type": "update",
718
+ "entity": "board_tag",
719
+ "match": { "seq": "${old.tag_seq}" },
720
+ "data": { "use_count": "${target.use_count - 1}" }
721
+ }
722
+ ]
723
+ }
724
+ }
725
+ ```
726
+
727
+ ### 4.9 board_report (신고)
728
+
729
+ 게시글·댓글에 대한 신고를 기록한다. `target_type + target_seq + user_seq` 복합 unique로 중복 신고를 방지한다.
730
+
731
+ ```json
732
+ {
733
+ "name": "board_report",
734
+ "description": "게시글·댓글 신고",
735
+ "fields": {
736
+ "target_type": {
737
+ "type": ["post", "comment"],
738
+ "required": true,
739
+ "comment": "신고 대상 유형"
740
+ },
741
+ "target_seq": {
742
+ "index": true,
743
+ "required": true,
744
+ "type": "uint",
745
+ "comment": "대상 seq (board_post 또는 board_comment)"
746
+ },
747
+ "user_seq": {
748
+ "index": true,
749
+ "required": true,
750
+ "type": "uint",
751
+ "comment": "신고한 회원 seq"
752
+ },
753
+ "reason": {
754
+ "type": ["spam", "abuse", "illegal", "other"],
755
+ "required": true,
756
+ "comment": "신고 사유"
757
+ },
758
+ "detail": {
759
+ "comment": "신고 상세 내용 (선택)"
760
+ },
761
+ "status": {
762
+ "type": ["pending", "resolved", "dismissed"],
763
+ "default": "pending",
764
+ "comment": "처리 상태. pending=미처리, resolved=처리완료, dismissed=기각"
765
+ }
766
+ },
767
+ "unique": ["target_type", "target_seq", "user_seq"]
768
+ }
769
+ ```
770
+
771
+ ### 4.10 board_read_log (읽음 표시)
772
+
773
+ 회원이 게시글을 읽은 기록을 저장한다. `post_seq + user_seq` 복합 unique로 중복 기록을 방지한다.
774
+ `read_time`을 업데이트 방식(upsert)으로 처리하여 목록 화면에서 새글 표시나 안 읽은 글 필터에 활용한다.
775
+
776
+ ```json
777
+ {
778
+ "name": "board_read_log",
779
+ "description": "게시글 읽음 기록",
780
+ "fields": {
781
+ "post_seq": {
782
+ "index": true,
783
+ "required": true,
784
+ "type": "uint",
785
+ "comment": "board_post seq"
786
+ },
787
+ "user_seq": {
788
+ "index": true,
789
+ "required": true,
790
+ "type": "uint",
791
+ "comment": "읽은 회원 seq"
792
+ },
793
+ "read_time": {
794
+ "type": "datetime",
795
+ "comment": "가장 최근에 읽은 시각 (upsert 시 갱신)"
796
+ }
797
+ },
798
+ "unique": ["post_seq", "user_seq"],
799
+ "fk": {
800
+ "post_seq": "board_post.seq"
801
+ }
802
+ }
803
+ ```
804
+
805
+ ### 4.11 board_mention (멘션)
806
+
807
+ 게시글·댓글 본문의 `@username` 언급을 파싱하여 저장한다.
808
+ 삽입 hook으로 해당 회원에게 Push 알림을 발송한다.
809
+
810
+ ```json
811
+ {
812
+ "name": "board_mention",
813
+ "description": "게시글·댓글 멘션 기록",
814
+ "fields": {
815
+ "source_type": {
816
+ "type": ["post", "comment"],
817
+ "required": true,
818
+ "comment": "멘션이 포함된 소스 유형"
819
+ },
820
+ "source_seq": {
821
+ "index": true,
822
+ "required": true,
823
+ "type": "uint",
824
+ "comment": "소스 seq (board_post 또는 board_comment)"
825
+ },
826
+ "mentioned_user_seq": {
827
+ "index": true,
828
+ "required": true,
829
+ "type": "uint",
830
+ "comment": "멘션된 회원 seq"
831
+ },
832
+ "is_read": {
833
+ "type": ["Y", "N"],
834
+ "default": "N",
835
+ "comment": "알림 읽음 여부"
836
+ }
837
+ },
838
+ "unique": ["source_type", "source_seq", "mentioned_user_seq"],
839
+ "hooks": {
840
+ "after_insert": [
841
+ {
842
+ "type": "push",
843
+ "target": "user:${new.mentioned_user_seq}",
844
+ "title": "회원이 나를 멘션했습니다",
845
+ "body": "${new.source_type} #${new.source_seq}",
846
+ "data": {
847
+ "type": "mention",
848
+ "source_type": "${new.source_type}",
849
+ "source_seq": "${new.source_seq}"
850
+ }
851
+ }
852
+ ]
853
+ }
854
+ }
855
+ ```
856
+
857
+ ---
858
+
859
+ ### 엔티티 관계도
860
+
861
+ ```
862
+ board_category
863
+
864
+ │ 1:N
865
+
866
+ board_post ◀──────────────────── board_post ─── user
867
+ │ parent_seq (자기참조) (답글, depth≥1) │
868
+ │ root_seq (원글 직추적) │ (user_seq FK)
869
+ │ │
870
+ │ 1:N (post_seq FK) │
871
+ ├──▶ board_comment ────────────────────────────────┘
872
+ │ (post_seq → 대상 글, root_seq → 원글)
873
+
874
+ │ 1:N (post_seq FK)
875
+ ├──▶ board_like (post_seq + user_seq unique)
876
+
877
+
878
+ │ 1:N (target_type='comment', target_seq=comment.seq)
879
+ ├──▶ board_rating (target_type='comment', target_seq=comment.seq)
880
+
881
+ │ 1:N (post_seq FK)
882
+ ├──▶ board_rating (target_type='post', target_seq=post.seq, target_type+target_seq+user_seq unique, score 1~5)
883
+
884
+ │ 1:N (post_seq FK)
885
+ ├──▶ board_post_tag ────▶ board_tag (name unique, use_count 캐시)
886
+
887
+ │ 1:N (target_type='post', target_seq=post.seq)
888
+ ├──▶ board_report (target_type+target_seq+user_seq unique, status: pending/resolved/dismissed)
889
+
890
+ │ 1:N (target_type='comment', target_seq=comment.seq)
891
+ ├──▶ board_report (댓글 신고)
892
+
893
+ │ 1:N (post_seq FK)
894
+ ├──▶ board_read_log (post_seq + user_seq unique, read_time 갱신)
895
+
896
+ │ 1:N (source_type='post', source_seq=post.seq)
897
+ ├──▶ board_mention (source_type+source_seq+mentioned_user_seq unique, hook → push)
898
+
899
+ │ 1:N (entity_seq FK, 엔티티 서버 files API)
900
+ └──▶ file_meta ← /v1/files/board_post/upload?entity_seq={seq}
901
+ ```
902
+
903
+ ---
904
+
905
+ ## 5. 앱서버 Routes 설계
906
+
907
+ ### 5.1 라우트 구조
908
+
909
+ `src/app/routes/board/` 디렉토리에 Fastify 라우트로 구현한다.
910
+
911
+ ```typescript
912
+ // src/app/routes/board/route.ts
913
+ import type { FastifyInstance } from "fastify";
914
+ import * as posts from "./handlers/posts.ts";
915
+ import * as comments from "./handlers/comments.ts";
916
+ import * as files from "./handlers/files.ts";
917
+ import * as categories from "./handlers/categories.ts";
918
+ import * as likes from "./handlers/likes.ts";
919
+ import * as ratings from "./handlers/ratings.ts";
920
+ import * as tags from "./handlers/tags.ts";
921
+ import * as reports from "./handlers/reports.ts";
922
+ import * as mentions from "./handlers/mentions.ts";
923
+
924
+ export default async function boardRoutes(app: FastifyInstance) {
925
+ // ── 카테고리 ──
926
+ app.get("/categories", categories.list);
927
+ app.get("/categories/:seq", categories.detail);
928
+ app.post(
929
+ "/categories",
930
+ { preHandler: app.authenticate },
931
+ categories.create,
932
+ );
933
+ app.put(
934
+ "/categories/:seq",
935
+ { preHandler: app.authenticate },
936
+ categories.update,
937
+ );
938
+ app.delete(
939
+ "/categories/:seq",
940
+ { preHandler: app.authenticate },
941
+ categories.remove,
942
+ );
943
+
944
+ // ── 게시글 · 답글 (board_post 통합) ──
945
+ app.get("/:category/list", posts.list);
946
+ app.get("/posts/:seq", posts.detail);
947
+ app.post(
948
+ "/:category/submit",
949
+ { preHandler: app.authenticate },
950
+ posts.create,
951
+ );
952
+ app.put("/posts/:seq", { preHandler: app.authenticate }, posts.update);
953
+ app.delete("/posts/:seq", { preHandler: app.authenticate }, posts.remove);
954
+
955
+ // ── 댓글 (post_seq = 원글 또는 답글 board_post seq) ──
956
+ app.get("/posts/:postSeq/comments", comments.list);
957
+ app.post(
958
+ "/posts/:postSeq/comments/submit",
959
+ { preHandler: app.authenticate },
960
+ comments.create,
961
+ );
962
+ app.put(
963
+ "/comments/:seq",
964
+ { preHandler: app.authenticate },
965
+ comments.update,
966
+ );
967
+ app.delete(
968
+ "/comments/:seq",
969
+ { preHandler: app.authenticate },
970
+ comments.remove,
971
+ );
972
+
973
+ // ── 파일 (엔티티 서버 files API 프록시) ──
974
+ app.get("/posts/:postSeq/files", files.list);
975
+ app.post(
976
+ "/posts/:postSeq/files",
977
+ { preHandler: app.authenticate },
978
+ files.upload,
979
+ );
980
+ app.delete("/files/:uuid", { preHandler: app.authenticate }, files.remove);
981
+ // 다운로드는 엔티티 서버 /v1/files/{uuid} 로 직접 리다이렉트 또는 프록시
982
+ app.get("/files/:uuid", files.view);
983
+
984
+ // ── 비회원 글작성 ──
985
+ app.post("/:category/guest-submit", posts.guestCreate);
986
+ app.post("/posts/:seq/guest-auth", posts.guestAuth); // 비밀번호 확인 → 임시 토큰 반환
987
+
988
+ // ── 좋아요 토글 (이미 좋아요 → 취소, 없으면 추가) ──
989
+ app.post(
990
+ "/posts/:seq/like",
991
+ { preHandler: app.authenticate },
992
+ likes.toggle,
993
+ );
994
+
995
+ // ── 답변 채택 (원글 작성자 또는 관리자만) ──
996
+ app.post(
997
+ "/posts/:seq/accept",
998
+ { preHandler: app.authenticate },
999
+ posts.accept,
1000
+ );
1001
+
1002
+ // ── 별점 등록 / 수정 (게시글·답글 또는 댓글) ──
1003
+ app.post(
1004
+ "/posts/:seq/rating",
1005
+ { preHandler: app.authenticate },
1006
+ ratings.upsert,
1007
+ );
1008
+ app.post(
1009
+ "/comments/:seq/rating",
1010
+ { preHandler: app.authenticate },
1011
+ ratings.upsert,
1012
+ );
1013
+
1014
+ // ── 태그 ──
1015
+ app.get("/tags", tags.list);
1016
+ app.put(
1017
+ "/posts/:seq/tags",
1018
+ { preHandler: app.authenticate },
1019
+ tags.setPostTags,
1020
+ );
1021
+
1022
+ // ── 신고 ──
1023
+ app.post(
1024
+ "/posts/:seq/report",
1025
+ { preHandler: app.authenticate },
1026
+ reports.submit,
1027
+ );
1028
+ app.post(
1029
+ "/comments/:seq/report",
1030
+ { preHandler: app.authenticate },
1031
+ reports.submit,
1032
+ );
1033
+
1034
+ // ── 읽음 표시 ──
1035
+ app.post(
1036
+ "/posts/:seq/read",
1037
+ { preHandler: app.authenticate },
1038
+ posts.markRead,
1039
+ );
1040
+
1041
+ // ── 멘션 ──
1042
+ app.get("/mentions", { preHandler: app.authenticate }, mentions.list);
1043
+ app.patch(
1044
+ "/mentions/:seq/read",
1045
+ { preHandler: app.authenticate },
1046
+ mentions.markRead,
1047
+ );
1048
+ }
1049
+ ```
1050
+
1051
+ > 모든 엔드포인트는 `/api/v1/board/*` 으로 매핑된다 (폴더명 기반 자동 prefix).
1052
+
1053
+ ### 5.2 핸들러 디렉토리 구조
1054
+
1055
+ ```
1056
+ src/app/routes/board/
1057
+ ├── route.ts # 라우트 정의
1058
+ └── handlers/
1059
+ ├── posts.ts # 게시글 CRUD + 비회원 작성/인증 + 답변 채택 + 읽음 표시
1060
+ ├── comments.ts # 댓글 CRUD
1061
+ ├── files.ts # 파일 — entityServer.fileUpload/fileList/fileDelete 사용
1062
+ ├── likes.ts # 좋아요 토글 (board_like)
1063
+ ├── ratings.ts # 별점 등록/수정 (board_rating)
1064
+ ├── tags.ts # 태그 목록 조회 / 게시글 태그 일괄 설정 (board_tag, board_post_tag)
1065
+ ├── reports.ts # 신고 제출 (board_report) — 게시글·댓글 공용
1066
+ ├── mentions.ts # 멘션 목록/읽음 처리 (board_mention)
1067
+ └── categories.ts # 카테고리 관리
1068
+ ```
1069
+
1070
+ ### 5.3 핸들러 구현 예시
1071
+
1072
+ ```typescript
1073
+ // src/app/routes/board/handlers/posts.ts
1074
+ import { FastifyRequest, FastifyReply } from "fastify";
1075
+ import { ok, fail, entityServer } from "@gateway/api";
1076
+
1077
+ export async function list(
1078
+ req: FastifyRequest<{
1079
+ Params: { category: string };
1080
+ Querystring: {
1081
+ page?: number;
1082
+ per_page?: number;
1083
+ search?: string;
1084
+ root_seq?: number; // root_seq 필터: 특정 원글의 답글만 조회
1085
+ depth?: number; // depth 필터: 0=원글만, 생략=전체
1086
+ };
1087
+ }>,
1088
+ reply: FastifyReply,
1089
+ ) {
1090
+ const { category } = req.params;
1091
+ const { page = 1, per_page = 20, search, root_seq, depth } = req.query;
1092
+
1093
+ const cat = await entityServer.find("board_category", {
1094
+ name: category,
1095
+ status: "active",
1096
+ });
1097
+ if (cat.count === 0)
1098
+ return reply.send(fail("카테고리를 찾을 수 없습니다."));
1099
+
1100
+ const filter: Record<string, unknown> = {
1101
+ category_seq: cat.items[0].seq,
1102
+ status: "published",
1103
+ };
1104
+ if (root_seq !== undefined) filter.root_seq = root_seq;
1105
+ if (depth !== undefined) filter.depth = depth;
1106
+
1107
+ const resp = await entityServer.list("board_post", {
1108
+ filter,
1109
+ search,
1110
+ page,
1111
+ limit: per_page,
1112
+ sort: "created_time",
1113
+ order: "desc",
1114
+ });
1115
+
1116
+ return reply.send(ok(resp));
1117
+ }
1118
+
1119
+ export async function create(
1120
+ req: FastifyRequest<{
1121
+ Params: { category: string };
1122
+ Body: {
1123
+ title: string;
1124
+ content: string;
1125
+ parent_seq?: number; // 답글일 때 설정
1126
+ pinned?: string;
1127
+ status?: string; // "published" | "draft"
1128
+ tags?: string[]; // 태그 name 배열
1129
+ };
1130
+ }>,
1131
+ reply: FastifyReply,
1132
+ ) {
1133
+ const { category } = req.params;
1134
+ const { title, content, parent_seq, pinned, status, tags } = req.body;
1135
+ const userSeq = req.user.account_seq;
1136
+
1137
+ const cat = await entityServer.find("board_category", {
1138
+ name: category,
1139
+ status: "active",
1140
+ });
1141
+ if (cat.count === 0)
1142
+ return reply.send(fail("카테고리를 찾을 수 없습니다."));
1143
+
1144
+ const catItem = cat.items[0];
1145
+
1146
+ // pinned는 관리자만 설정 가능
1147
+ if (pinned === "Y" && !req.user.is_admin)
1148
+ return reply.send(fail("글 고정은 관리자만 가능합니다."));
1149
+
1150
+ // 답글일 때 root_seq, depth 자동 가산
1151
+ let root_seq: number | undefined;
1152
+ let depth = 0;
1153
+ if (parent_seq) {
1154
+ const parent = await entityServer.find("board_post", {
1155
+ seq: parent_seq,
1156
+ });
1157
+ if (parent.count === 0)
1158
+ return reply.send(fail("부모 글을 찾을 수 없습니다."));
1159
+ const p = parent.items[0];
1160
+ root_seq = p.root_seq ?? p.seq;
1161
+ depth = (p.depth ?? 0) + 1;
1162
+ }
1163
+
1164
+ // 익명 게시판이면 author_name 강제 치환
1165
+ const isAnonymous = catItem.anonymous_enabled === "Y";
1166
+ const authorName = isAnonymous ? "익명" : req.user.name;
1167
+
1168
+ const resp = await entityServer.submit("board_post", {
1169
+ category_seq: catItem.seq,
1170
+ user_seq: userSeq,
1171
+ author_name: authorName,
1172
+ title,
1173
+ content,
1174
+ status: status || "published",
1175
+ parent_seq: parent_seq ?? null,
1176
+ root_seq: root_seq ?? null, // 원글은 insert 후 seq로 갱신 필요
1177
+ depth,
1178
+ pinned: parent_seq ? "N" : pinned || "N",
1179
+ });
1180
+
1181
+ // 원글이면 root_seq = 자기 seq
1182
+ if (!parent_seq) {
1183
+ await entityServer.submit("board_post", {
1184
+ seq: resp.seq,
1185
+ root_seq: resp.seq,
1186
+ });
1187
+ }
1188
+
1189
+ // 태그 연결
1190
+ if (tags && tags.length > 0) {
1191
+ await setPostTags(resp.seq, tags);
1192
+ }
1193
+
1194
+ // @username 멘션 파싱 및 board_mention 삽입
1195
+ const mentionPattern = /@([a-zA-Z0-9_]+)/g;
1196
+ let match: RegExpExecArray | null;
1197
+ while ((match = mentionPattern.exec(content)) !== null) {
1198
+ const username = match[1];
1199
+ const user = await entityServer.find("user", { username });
1200
+ if (user.count > 0) {
1201
+ await entityServer.submit("board_mention", {
1202
+ source_type: "post",
1203
+ source_seq: resp.seq,
1204
+ mentioned_user_seq: user.items[0].seq,
1205
+ });
1206
+ }
1207
+ }
1208
+
1209
+ return reply.code(201).send(ok({ seq: resp.seq }));
1210
+ }
1211
+ ```
1212
+
1213
+ ### 5.4 비회원 글작성 핸들러 예시
1214
+
1215
+ ```typescript
1216
+ // src/app/routes/board/handlers/posts.ts (추가 함수)
1217
+ import bcrypt from "bcrypt";
1218
+
1219
+ export async function guestCreate(
1220
+ req: FastifyRequest<{
1221
+ Params: { category: string };
1222
+ Body: {
1223
+ title: string;
1224
+ content: string;
1225
+ guest_name: string;
1226
+ guest_password: string; // 평문 — 핸들러에서 bcrypt 해시 처리
1227
+ };
1228
+ }>,
1229
+ reply: FastifyReply,
1230
+ ) {
1231
+ const { category } = req.params;
1232
+ const { title, content, guest_name, guest_password } = req.body;
1233
+
1234
+ const cat = await entityServer.find("board_category", {
1235
+ name: category,
1236
+ status: "active",
1237
+ });
1238
+ if (cat.count === 0)
1239
+ return reply.send(fail("카테고리를 찾을 수 없습니다."));
1240
+
1241
+ const catItem = cat.items[0];
1242
+ if (catItem.guest_write_enabled !== "Y")
1243
+ return reply.send(
1244
+ fail("이 게시판은 비회원 글작성이 허용되지 않습니다."),
1245
+ );
1246
+
1247
+ const hashed = await bcrypt.hash(guest_password, 10);
1248
+
1249
+ const resp = await entityServer.submit("board_post", {
1250
+ category_seq: catItem.seq,
1251
+ user_seq: null, // 비회원
1252
+ author_name: guest_name,
1253
+ guest_password: hashed,
1254
+ title,
1255
+ content,
1256
+ depth: 0,
1257
+ });
1258
+
1259
+ // root_seq = 자기 seq
1260
+ await entityServer.submit("board_post", {
1261
+ seq: resp.seq,
1262
+ root_seq: resp.seq,
1263
+ });
1264
+
1265
+ return reply.code(201).send(ok({ seq: resp.seq }));
1266
+ }
1267
+
1268
+ export async function guestAuth(
1269
+ req: FastifyRequest<{
1270
+ Params: { seq: string };
1271
+ Body: { guest_password: string };
1272
+ }>,
1273
+ reply: FastifyReply,
1274
+ ) {
1275
+ const seq = Number(req.params.seq);
1276
+ const { guest_password } = req.body;
1277
+
1278
+ const post = await entityServer.find("board_post", { seq });
1279
+ if (post.count === 0) return reply.send(fail("게시글을 찾을 수 없습니다."));
1280
+
1281
+ const item = post.items[0];
1282
+ if (!item.guest_password)
1283
+ return reply.send(fail("비회원 게시글이 아닙니다."));
1284
+
1285
+ const ok_ = await bcrypt.compare(guest_password, item.guest_password);
1286
+ if (!ok_) return reply.send(fail("비밀번호가 일치하지 않습니다."));
1287
+
1288
+ // 임시 허가 토큰 (서명된 JWT, 만료 30분)
1289
+ const token = await reply.jwtSign(
1290
+ { guest_post_seq: seq },
1291
+ { expiresIn: "30m" },
1292
+ );
1293
+ return reply.send(ok({ token }));
1294
+ }
1295
+
1296
+ export async function accept(
1297
+ req: FastifyRequest<{ Params: { seq: string } }>,
1298
+ reply: FastifyReply,
1299
+ ) {
1300
+ const seq = Number(req.params.seq);
1301
+ const userSeq = req.user.account_seq;
1302
+
1303
+ const post = await entityServer.find("board_post", { seq });
1304
+ if (post.count === 0) return reply.send(fail("게시글을 찾을 수 없습니다."));
1305
+ const item = post.items[0];
1306
+
1307
+ // 원글은 채택할 수 없다 (답글만 채택 가능)
1308
+ if (item.parent_seq === null)
1309
+ return reply.send(
1310
+ fail("원글은 채택할 수 없습니다. 답글만 채택 가능합니다."),
1311
+ );
1312
+
1313
+ // 원글 작성자 또는 관리자만 채택 가능
1314
+ // 원글(root)의 작성자를 확인해야 하므로 root_seq로 원글 조회
1315
+ const rootPost = await entityServer.find("board_post", {
1316
+ seq: item.root_seq,
1317
+ });
1318
+ if (rootPost.count === 0)
1319
+ return reply.send(fail("원글을 찾을 수 없습니다."));
1320
+ if (rootPost.items[0].user_seq !== userSeq && !req.user.is_admin)
1321
+ return reply.send(fail("채택 권한이 없습니다."));
1322
+
1323
+ // 같은 root_seq 내 기존 채택 해제
1324
+ if (item.root_seq) {
1325
+ const prevAccepted = await entityServer.find("board_post", {
1326
+ root_seq: item.root_seq,
1327
+ accepted: "Y",
1328
+ });
1329
+ for (const p of prevAccepted.items) {
1330
+ await entityServer.submit("board_post", {
1331
+ seq: p.seq,
1332
+ accepted: "N",
1333
+ });
1334
+ }
1335
+ }
1336
+
1337
+ await entityServer.submit("board_post", { seq, accepted: "Y" });
1338
+ return reply.send(ok(null));
1339
+ }
1340
+ ```
1341
+
1342
+ ```typescript
1343
+ // src/app/routes/board/handlers/likes.ts
1344
+ import type { FastifyRequest, FastifyReply } from "fastify";
1345
+ import { ok, fail, entityServer } from "@gateway/api";
1346
+
1347
+ export async function toggle(
1348
+ req: FastifyRequest<{ Params: { seq: string } }>,
1349
+ reply: FastifyReply,
1350
+ ) {
1351
+ const postSeq = Number(req.params.seq);
1352
+ const userSeq = req.user.account_seq;
1353
+
1354
+ const existing = await entityServer.find("board_like", {
1355
+ post_seq: postSeq,
1356
+ user_seq: userSeq,
1357
+ });
1358
+
1359
+ if (existing.count > 0) {
1360
+ // 이미 좋아요 → 취소
1361
+ await entityServer.remove("board_like", { seq: existing.items[0].seq });
1362
+ return reply.send(ok({ liked: false }));
1363
+ } else {
1364
+ // 좋아요 추가
1365
+ await entityServer.submit("board_like", {
1366
+ post_seq: postSeq,
1367
+ user_seq: userSeq,
1368
+ });
1369
+ return reply.send(ok({ liked: true }));
1370
+ }
1371
+ }
1372
+ ```
1373
+
1374
+ ```typescript
1375
+ // src/app/routes/board/handlers/tags.ts
1376
+ import type { FastifyRequest, FastifyReply } from "fastify";
1377
+ import { ok, fail, entityServer } from "@gateway/api";
1378
+
1379
+ export async function list(
1380
+ req: FastifyRequest<{
1381
+ Querystring: { search?: string; limit?: number };
1382
+ }>,
1383
+ reply: FastifyReply,
1384
+ ) {
1385
+ const { search, limit = 20 } = req.query;
1386
+ const resp = await entityServer.list("board_tag", {
1387
+ search,
1388
+ limit,
1389
+ sort: "use_count",
1390
+ order: "desc",
1391
+ });
1392
+ return reply.send(ok(resp));
1393
+ }
1394
+
1395
+ export async function setPostTags(
1396
+ req: FastifyRequest<{
1397
+ Params: { seq: string };
1398
+ Body: { tags: string[] }; // tag name(슬러그) 배열
1399
+ }>,
1400
+ reply: FastifyReply,
1401
+ ) {
1402
+ const postSeq = Number(req.params.seq);
1403
+ const { tags: tagNames } = req.body;
1404
+
1405
+ // 기존 board_post_tag 전체 삭제 (replace 방식)
1406
+ const existing = await entityServer.list("board_post_tag", {
1407
+ filter: { post_seq: postSeq },
1408
+ limit: 100,
1409
+ });
1410
+ for (const pt of existing.items) {
1411
+ await entityServer.remove("board_post_tag", { seq: pt.seq });
1412
+ }
1413
+
1414
+ const result: string[] = [];
1415
+ for (const name of tagNames) {
1416
+ const slug = name.toLowerCase().replace(/\s+/g, "-");
1417
+ // 태그 조회 또는 자동 생성
1418
+ let tagRes = await entityServer.find("board_tag", { name: slug });
1419
+ if (tagRes.count === 0) {
1420
+ const created = await entityServer.submit("board_tag", {
1421
+ name: slug,
1422
+ label: name,
1423
+ });
1424
+ tagRes = await entityServer.find("board_tag", { seq: created.seq });
1425
+ }
1426
+ await entityServer.submit("board_post_tag", {
1427
+ post_seq: postSeq,
1428
+ tag_seq: tagRes.items[0].seq,
1429
+ });
1430
+ result.push(slug);
1431
+ }
1432
+
1433
+ return reply.send(ok({ tags: result }));
1434
+ }
1435
+ ```
1436
+
1437
+ ```typescript
1438
+ // src/app/routes/board/handlers/ratings.ts
1439
+ import type { FastifyRequest, FastifyReply } from "fastify";
1440
+ import { ok, fail, entityServer } from "@gateway/api";
1441
+
1442
+ /**
1443
+ * /posts/:seq/rating → target_type: "post"
1444
+ * /comments/:seq/rating → target_type: "comment"
1445
+ * routeConfig.url 에서 target_type을 판별한다.
1446
+ */
1447
+ export async function upsert(
1448
+ req: FastifyRequest<{
1449
+ Params: { seq: string };
1450
+ Body: { score: number }; // 1~5
1451
+ }>,
1452
+ reply: FastifyReply,
1453
+ ) {
1454
+ const targetSeq = Number(req.params.seq);
1455
+ const userSeq = req.user.account_seq;
1456
+ const { score } = req.body;
1457
+
1458
+ const targetType = req.routeOptions.url.includes("/comments/")
1459
+ ? "comment"
1460
+ : "post";
1461
+
1462
+ if (score < 1 || score > 5)
1463
+ return reply.send(fail("별점은 1~5 사이여야 합니다."));
1464
+
1465
+ const existing = await entityServer.find("board_rating", {
1466
+ target_type: targetType,
1467
+ target_seq: targetSeq,
1468
+ user_seq: userSeq,
1469
+ });
1470
+
1471
+ if (existing.count > 0) {
1472
+ // 기존 별점 수정 → after_update hook이 rating_sum 보정
1473
+ await entityServer.submit("board_rating", {
1474
+ seq: existing.items[0].seq,
1475
+ score: String(score),
1476
+ });
1477
+ return reply.send(ok({ updated: true }));
1478
+ } else {
1479
+ await entityServer.submit("board_rating", {
1480
+ target_type: targetType,
1481
+ target_seq: targetSeq,
1482
+ user_seq: userSeq,
1483
+ score: String(score),
1484
+ });
1485
+ return reply.send(ok({ created: true }));
1486
+ }
1487
+ }
1488
+ ```
1489
+
1490
+ ```typescript
1491
+ // src/app/routes/board/handlers/reports.ts
1492
+ import type { FastifyRequest, FastifyReply } from "fastify";
1493
+ import { ok, fail, entityServer } from "@gateway/api";
1494
+
1495
+ /**
1496
+ * POST /posts/:seq/report → target_type: "post"
1497
+ * POST /comments/:seq/report → target_type: "comment"
1498
+ * routeOptions.url 에서 target_type을 판별한다.
1499
+ */
1500
+ export async function submit(
1501
+ req: FastifyRequest<{
1502
+ Params: { seq: string };
1503
+ Body: {
1504
+ reason: "spam" | "abuse" | "illegal" | "other";
1505
+ detail?: string;
1506
+ };
1507
+ }>,
1508
+ reply: FastifyReply,
1509
+ ) {
1510
+ const targetSeq = Number(req.params.seq);
1511
+ const userSeq = req.user.account_seq;
1512
+ const { reason, detail } = req.body;
1513
+
1514
+ const targetType = req.routeOptions.url.includes("/comments/")
1515
+ ? "comment"
1516
+ : "post";
1517
+
1518
+ // 대상 존재 여부 확인 (없으면 404)
1519
+ const targetEntity =
1520
+ targetType === "comment" ? "board_comment" : "board_post";
1521
+ const target = await entityServer.find(targetEntity, { seq: targetSeq });
1522
+ if (target.count === 0)
1523
+ return reply.code(404).send(fail("신고 대상을 찾을 수 없습니다."));
1524
+
1525
+ // 동일 대상에 대한 중복 신고 방지
1526
+ const existing = await entityServer.find("board_report", {
1527
+ target_type: targetType,
1528
+ target_seq: targetSeq,
1529
+ user_seq: userSeq,
1530
+ });
1531
+ if (existing.count > 0) return reply.send(fail("이미 신고한 대상입니다."));
1532
+
1533
+ await entityServer.submit("board_report", {
1534
+ target_type: targetType,
1535
+ target_seq: targetSeq,
1536
+ user_seq: userSeq,
1537
+ reason,
1538
+ detail: detail ?? null,
1539
+ });
1540
+
1541
+ return reply.code(201).send(ok({ reported: true }));
1542
+ }
1543
+ ```
1544
+
1545
+ ```typescript
1546
+ // src/app/routes/board/handlers/mentions.ts
1547
+ import type { FastifyRequest, FastifyReply } from "fastify";
1548
+ import { ok, fail, entityServer } from "@gateway/api";
1549
+
1550
+ export async function list(
1551
+ req: FastifyRequest<{ Querystring: { is_read?: "Y" | "N" } }>,
1552
+ reply: FastifyReply,
1553
+ ) {
1554
+ const userSeq = req.user.account_seq;
1555
+ const { is_read } = req.query;
1556
+
1557
+ const filter: Record<string, unknown> = { mentioned_user_seq: userSeq };
1558
+ if (is_read) filter.is_read = is_read;
1559
+
1560
+ const resp = await entityServer.list("board_mention", {
1561
+ filter,
1562
+ sort: "created_time",
1563
+ order: "desc",
1564
+ });
1565
+ return reply.send(ok(resp));
1566
+ }
1567
+
1568
+ export async function markRead(
1569
+ req: FastifyRequest<{ Params: { seq: string } }>,
1570
+ reply: FastifyReply,
1571
+ ) {
1572
+ const seq = Number(req.params.seq);
1573
+
1574
+ const mention = await entityServer.find("board_mention", { seq });
1575
+ if (mention.count === 0)
1576
+ return reply.code(404).send(fail("멘션을 찾을 수 없습니다."));
1577
+ if (mention.items[0].mentioned_user_seq !== req.user.account_seq)
1578
+ return reply.code(403).send(fail("본인 멘션만 처리할 수 있습니다."));
1579
+
1580
+ await entityServer.submit("board_mention", { seq, is_read: "Y" });
1581
+ return reply.send(ok({ read: true }));
1582
+ }
1583
+ ```
1584
+
1585
+ > **익명 처리**: `posts.ts` create/guestCreate 핸들러에서 카테고리의 `anonymous_enabled = "Y"` 여부를 확인하여
1586
+ > `author_name`을 `"익명"`으로 강제 저장하고, 응답 시 `user_seq`를 `null`로 마스킹한다.
1587
+ > 댓글도 동일하게 익명 게시판이면 `author_name = "익명"`, 응답 시 `user_seq = null` 마스킹.
1588
+ > `user_seq`는 DB에는 실제 값으로 저장되며, 관리자의 신고 처리와 본인 확인에만 내부적으로 사용된다.
1589
+
1590
+ > **읽음 표시**: `posts.ts markRead` 핸들러에서 `board_read_log`를 upsert(`post_seq + user_seq` unique)하여
1591
+ > `read_time`을 현재 시각으로 갱신한다. 상세 조회(GET /posts/:seq) 시 자동으로 호출할 수도 있고,
1592
+ > 클라이언트에서 명시적으로 `POST /posts/:seq/read`를 호출하는 방식 중 카테고리 설정으로 선택한다.
1593
+
1594
+ > **멘션 처리**: `comments.ts create` 및 `posts.ts create` 핸들러에서 `content` 내 `@username` 패턴을
1595
+ > 정규식으로 파싱하고, 해당 username을 가진 회원을 조회한 후 `board_mention`을 삽입한다.
1596
+ > 삽입 hook이 자동으로 Push 알림을 발송하므로 핸들러에서 별도 처리는 필요 없다.
1597
+ >
1598
+ > **멘션 수정 시 정리 규칙**: 글/댓글 수정 시 멘션을 재파싱하며, 기존 멘션 중 수정 후 본문에 없는 것은 삭제,
1599
+ > 새로 추가된 멘션만 `board_mention`에 삽입한다 (diff 방식). 이미 읽은 멘션의 삭제는 문제없다.
1600
+
1601
+ ### 5.5 앱서버 핸들러 역할
1602
+
1603
+ | 역할 | 설명 |
1604
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
1605
+ | **인증** | `preHandler: app.authenticate` — JWT 검증 |
1606
+ | **입력 검증** | title 길이, content HTML sanitize 등 |
1607
+ | **권한 확인** | 카테고리별 read_role/write_role 대조, 본인 확인 |
1608
+ | **Entity Server 호출** | `entityServer.*` SDK로 CRUD 위임 |
1609
+ | **응답 가공** | `ok()`, `fail()` 래퍼로 응답 형식 통일 |
1610
+ | **조회수 처리** | `view_count_mode`에 따라 카운트 증가. `daily`(기본): Redis TTL 86400s 키로 중복 방지. 회원은 `user_seq`, 비회원은 IP 기반 |
1611
+
1612
+ ---
1613
+
1614
+ ## 6. API 상세 스펙
1615
+
1616
+ 모든 엔드포인트의 base path: `/api/v1/board`
1617
+
1618
+ ### 6.1 게시글 목록 조회
1619
+
1620
+ ```
1621
+ GET /api/v1/board/{category}/list?page=1&per_page=20&search={keyword}&sort=created_time&order=desc&root_seq={seq}&depth={n}
1622
+ ```
1623
+
1624
+ **Query Parameters:**
1625
+
1626
+ | 파라미터 | 타입 | 기본값 | 설명 |
1627
+ | -------- | ------ | ------------ | --------------------------------------------- |
1628
+ | category | string | (path) | 카테고리 name (URL path) |
1629
+ | page | int | 1 | 페이지 번호 |
1630
+ | per_page | int | 20 | 페이지당 건수 |
1631
+ | search | string | - | 제목+내용 통합 검색 |
1632
+ | sort | string | created_time | 정렬 기준 |
1633
+ | order | string | desc | asc / desc |
1634
+ | pinned | string | - | Y: 고정글만 |
1635
+ | root_seq | int | - | 특정 원글의 답글만 조회 (트리 펼치기) |
1636
+ | depth | int | - | 0: 원글만, 생략: 전체 (원글+답글 flat 목록) |
1637
+ | status | string | published | `draft`: 내 임시저장 목록 (인증 필요, 본인만) |
1638
+
1639
+ > **주의**: 기본 목록(`depth` 생략)은 원글과 답글을 **모두 flat하게** 반환한다.
1640
+ > 프론트엔드에서 `parent_seq`와 `depth`를 이용해 트리 구조로 조립한다.
1641
+ > `status=draft` 조회 시 앱서버 핸들러에서 `user_seq: req.user.account_seq` 필터를 강제하여 본인 임시저장만 반환한다.
1642
+
1643
+ **Response:**
1644
+
1645
+ ```json
1646
+ {
1647
+ "success": true,
1648
+ "data": {
1649
+ "items": [
1650
+ {
1651
+ "seq": 42,
1652
+ "category_seq": 1,
1653
+ "user_seq": 10,
1654
+ "author_name": "홍길동",
1655
+ "title": "공지사항입니다",
1656
+ "status": "published",
1657
+ "pinned": "Y",
1658
+ "parent_seq": null,
1659
+ "root_seq": 42,
1660
+ "depth": 0,
1661
+ "view_count": 123,
1662
+ "reply_count": 3,
1663
+ "comment_count": 5,
1664
+ "like_count": 12,
1665
+ "rating_sum": 18,
1666
+ "rating_count": 5,
1667
+ "accepted": "N",
1668
+ "file_count": 2,
1669
+ "tags": ["javascript", "vue"],
1670
+ "created_time": "2026-03-06T09:00:00Z",
1671
+ "updated_time": "2026-03-06T09:00:00Z"
1672
+ },
1673
+ {
1674
+ "seq": 10,
1675
+ "category_seq": 1,
1676
+ "user_seq": 11,
1677
+ "author_name": "김철수",
1678
+ "title": "답변드립니다",
1679
+ "status": "published",
1680
+ "pinned": "N",
1681
+ "parent_seq": 42,
1682
+ "root_seq": 42,
1683
+ "depth": 1,
1684
+ "view_count": 20,
1685
+ "reply_count": 0,
1686
+ "comment_count": 2,
1687
+ "like_count": 3,
1688
+ "rating_sum": 4,
1689
+ "rating_count": 1,
1690
+ "accepted": "Y",
1691
+ "file_count": 0,
1692
+ "tags": [],
1693
+ "created_time": "2026-03-06T10:00:00Z",
1694
+ "updated_time": "2026-03-06T10:00:00Z"
1695
+ }
1696
+ ],
1697
+ "pagination": {
1698
+ "page": 1,
1699
+ "per_page": 20,
1700
+ "total": 156,
1701
+ "total_pages": 8
1702
+ }
1703
+ }
1704
+ }
1705
+ ```
1706
+
1707
+ > **참고**: 목록 조회 시 `content`는 제외하여 전송량을 줄인다.
1708
+ > 원글(`parent_seq=null`)과 답글이 flat하게 섞여 있으므로, 프론트엔드에서 `parent_seq`로 트리 조립.
1709
+
1710
+ ### 6.2 게시글 상세 조회
1711
+
1712
+ ```
1713
+ GET /api/v1/board/posts/{seq}
1714
+ ```
1715
+
1716
+ **Response:**
1717
+
1718
+ ```json
1719
+ {
1720
+ "success": true,
1721
+ "data": {
1722
+ "seq": 42,
1723
+ "category_seq": 1,
1724
+ "user_seq": 10,
1725
+ "author_name": "홍길동",
1726
+ "title": "공지사항입니다",
1727
+ "content": "<p>본문 내용...</p>",
1728
+ "status": "published",
1729
+ "pinned": "Y",
1730
+ "parent_seq": null,
1731
+ "root_seq": 42,
1732
+ "depth": 0,
1733
+ "view_count": 124,
1734
+ "reply_count": 3,
1735
+ "comment_count": 5,
1736
+ "like_count": 12,
1737
+ "rating_sum": 18,
1738
+ "rating_count": 5,
1739
+ "accepted": "N",
1740
+ "file_count": 2,
1741
+ "created_time": "2026-03-06T09:00:00Z",
1742
+ "updated_time": "2026-03-06T09:00:00Z",
1743
+ "files": [
1744
+ {
1745
+ "uuid": "550e8400-e29b-41d4-a716-446655440000",
1746
+ "original_name": "첨부자료.pdf",
1747
+ "mime_type": "application/pdf",
1748
+ "size": 204800,
1749
+ "account_seq": 10,
1750
+ "created_time": "2026-03-06T09:00:00Z"
1751
+ }
1752
+ ],
1753
+ "tags": ["javascript", "vue"],
1754
+ "is_mine": true
1755
+ }
1756
+ }
1757
+ ```
1758
+
1759
+ > 상세 조회 시 앱서버에서 `view_count_mode`에 따라 조건부로 `view_count`를 +1 갱신 후 응답한다.
1760
+ >
1761
+ > | mode | 동작 |
1762
+ > | --------- | ---------------------------------------------------------------------- |
1763
+ > | `always` | 매 요청마다 증가 |
1764
+ > | `session` | 요청 헤더의 세션/쿠키 기준, 없으면 설정 후 1회 증가 |
1765
+ > | `daily` | Redis `view:{postSeq}:{userSeq\|ip}` 키 TTL 86400s — 키 없을 때만 증가 |
1766
+ > | `once` | Redis `view:{postSeq}:{userSeq\|ip}` 키 영구 저장 — 키 없을 때만 증가 |
1767
+
1768
+ ### 6.3 게시글 작성 (원글 · 답글 공통)
1769
+
1770
+ ```
1771
+ POST /api/v1/board/{category}/submit
1772
+ Content-Type: application/json
1773
+
1774
+ {
1775
+ "title": "공지사항입니다",
1776
+ "content": "<p>본문 내용</p>",
1777
+ "parent_seq": null,
1778
+ "pinned": "N",
1779
+ "status": "published",
1780
+ "tags": ["javascript", "vue"]
1781
+ }
1782
+ ```
1783
+
1784
+ > `parent_seq`가 null이면 원글, 값이 있으면 답글로 처리된다.
1785
+ > 앱서버에서 부모 글의 `root_seq`, `depth`를 조회하여 자동 계산한다.
1786
+ > `status: "draft"` 로 전송하면 임시저장되며, 목록에 노출되지 않는다.
1787
+ > `tags` 배열이 있으면 `setPostTags`를 내부 호출하여 태그를 연결한다 (없으면 태그 변경 없음).
1788
+
1789
+ ### 6.4 게시글 수정
1790
+
1791
+ ```
1792
+ PUT /api/v1/board/posts/{seq}
1793
+ Content-Type: application/json
1794
+
1795
+ {
1796
+ "title": "수정된 제목",
1797
+ "content": "<p>수정된 본문</p>",
1798
+ "status": "published",
1799
+ "tags": ["javascript"]
1800
+ }
1801
+ ```
1802
+
1803
+ > 임시저장 글을 발행할 때는 `status: "published"` 를 함께 전송한다.
1804
+ > `tags` 배열을 포함하면 태그를 일괄 교체한다 (생략 시 기존 태그 유지).
1805
+
1806
+ ### 6.5 게시글 삭제
1807
+
1808
+ ```
1809
+ DELETE /api/v1/board/posts/{seq}
1810
+ ```
1811
+
1812
+ > soft delete — `status: "deleted"` 처리.
1813
+
1814
+ ### 6.6 댓글 목록
1815
+
1816
+ ```
1817
+ GET /api/v1/board/posts/{postSeq}/comments?page=1&per_page=50
1818
+ ```
1819
+
1820
+ > `postSeq`는 원글 또는 답글의 board_post seq 모두 가능.
1821
+
1822
+ **Response:**
1823
+
1824
+ ```json
1825
+ {
1826
+ "success": true,
1827
+ "data": {
1828
+ "items": [
1829
+ {
1830
+ "seq": 100,
1831
+ "post_seq": 42,
1832
+ "user_seq": 15,
1833
+ "author_name": "박영희",
1834
+ "content": "좋은 글이네요!",
1835
+ "rating_sum": 8,
1836
+ "rating_count": 2,
1837
+ "created_time": "2026-03-06T11:00:00Z",
1838
+ "updated_time": "2026-03-06T11:00:00Z"
1839
+ }
1840
+ ],
1841
+ "pagination": {
1842
+ "page": 1,
1843
+ "per_page": 50,
1844
+ "total": 5,
1845
+ "total_pages": 1
1846
+ }
1847
+ }
1848
+ }
1849
+ ```
1850
+
1851
+ > 익명 게시판(`anonymous_enabled = "Y"`) 댓글의 경우:
1852
+ >
1853
+ > - `author_name`은 `"익명"`으로 강제 대체되어 저장된다.
1854
+ > - `user_seq`는 내부적으로 DB에 저장되지만, **응답 시 `null`로 마스킹**하여 반환한다 (본인 식별/신고 처리에만 내부 사용).
1855
+
1856
+ ### 6.7 댓글 작성
1857
+
1858
+ ```
1859
+ POST /api/v1/board/posts/{postSeq}/comments/submit
1860
+ Content-Type: application/json
1861
+
1862
+ {
1863
+ "content": "댓글 내용입니다."
1864
+ }
1865
+ ```
1866
+
1867
+ > `postSeq`가 원글이면 원글에, 답글이면 해당 답글에 달린다.
1868
+ > 앱서버에서 `post_seq = postSeq`, `root_seq = root_seq_of_post` 자동 설정.
1869
+
1870
+ ### 6.8 파일 목록
1871
+
1872
+ ```
1873
+ GET /api/v1/board/posts/{postSeq}/files
1874
+ ```
1875
+
1876
+ **Response:**
1877
+
1878
+ ```json
1879
+ {
1880
+ "success": true,
1881
+ "data": {
1882
+ "items": [
1883
+ {
1884
+ "uuid": "550e8400-e29b-41d4-a716-446655440000",
1885
+ "original_name": "첨부자료.pdf",
1886
+ "mime_type": "application/pdf",
1887
+ "size": 204800,
1888
+ "account_seq": 10,
1889
+ "is_public": false,
1890
+ "created_time": "2026-03-06T09:00:00Z"
1891
+ }
1892
+ ],
1893
+ "total": 2
1894
+ }
1895
+ }
1896
+ ```
1897
+
1898
+ > 앱서버 핸들러에서 `entityServer.fileList("board_post", { refSeq: postSeq })` 호출.
1899
+
1900
+ ### 6.9 파일 업로드
1901
+
1902
+ ```
1903
+ POST /api/v1/board/posts/{postSeq}/files
1904
+ Content-Type: multipart/form-data
1905
+
1906
+ file: (binary)
1907
+ ```
1908
+
1909
+ **Response:**
1910
+
1911
+ ```json
1912
+ {
1913
+ "success": true,
1914
+ "data": {
1915
+ "uuid": "550e8400-e29b-41d4-a716-446655440000",
1916
+ "original_name": "첨부자료.pdf",
1917
+ "mime_type": "application/pdf",
1918
+ "size": 204800,
1919
+ "account_seq": 10,
1920
+ "created_time": "2026-03-06T09:00:00Z"
1921
+ }
1922
+ }
1923
+ ```
1924
+
1925
+ > 앱서버 핸들러 처리:
1926
+ >
1927
+ > 1. `entityServer.fileUpload("board_post", file, { refSeq: postSeq })` 호출
1928
+ > 2. 업로드 성공 후 `board_post.file_count` +1 갱신
1929
+
1930
+ ### 6.10 파일 뷰 / 다운로드
1931
+
1932
+ ```
1933
+ # 브라우저 인라인 뷰 (이미지, PDF 등 바로보기)
1934
+ GET /api/v1/board/files/{uuid}
1935
+
1936
+ # 강제 다운로드
1937
+ GET /api/v1/board/files/{uuid}?download
1938
+ ```
1939
+
1940
+ > 앱서버 핸들러에서 엔티티 서버 `GET /v1/files/{uuid}` 로 리다이렉트 또는 프록시.
1941
+
1942
+ ### 6.11 파일 삭제
1943
+
1944
+ ```
1945
+ DELETE /api/v1/board/files/{uuid}
1946
+ ```
1947
+
1948
+ > 앱서버 핸들러 처리:
1949
+ >
1950
+ > 1. `entityServer.fileDelete("board_post", uuid)` 호출
1951
+ > 2. 삭제 성공 후 `board_post.file_count` -1 갱신
1952
+
1953
+ ### 6.12 비회원 글 작성
1954
+
1955
+ ```
1956
+ POST /api/v1/board/{category}/guest-submit
1957
+ Content-Type: application/json
1958
+
1959
+ {
1960
+ "title": "문의드립니다",
1961
+ "content": "<p>본문 내용</p>",
1962
+ "guest_name": "홍길동",
1963
+ "guest_password": "1234"
1964
+ }
1965
+ ```
1966
+
1967
+ > 카테고리의 `guest_write_enabled = "Y"` 일 때만 허용.
1968
+ > `guest_password`는 핸들러에서 bcrypt 해시 후 저장. 응답에는 비밀번호 미포함.
1969
+
1970
+ **Response:**
1971
+
1972
+ ```json
1973
+ { "success": true, "data": { "seq": 55 } }
1974
+ ```
1975
+
1976
+ ### 6.13 비회원 본인 확인 (비밀번호 → 임시 토큰)
1977
+
1978
+ ```
1979
+ POST /api/v1/board/posts/{seq}/guest-auth
1980
+ Content-Type: application/json
1981
+
1982
+ { "guest_password": "1234" }
1983
+ ```
1984
+
1985
+ **Response:**
1986
+
1987
+ ```json
1988
+ {
1989
+ "success": true,
1990
+ "data": {
1991
+ "token": "<jwt — expires 30m>",
1992
+ "note": "이 토큰을 Authorization: Bearer 헤더에 담아 수정/삭제 요청"
1993
+ }
1994
+ }
1995
+ ```
1996
+
1997
+ > 수정·삭제 시 이 토큰을 Authorization 헤더에 담아 `posts.update` / `posts.remove` 에서 `req.user.guest_post_seq` 확인.
1998
+
1999
+ ### 6.14 좋아요 토글
2000
+
2001
+ ```
2002
+ POST /api/v1/board/posts/{seq}/like
2003
+ Authorization: Bearer <token>
2004
+ ```
2005
+
2006
+ **Response:**
2007
+
2008
+ ```json
2009
+ { "success": true, "data": { "liked": true } }
2010
+ ```
2011
+
2012
+ > 이미 좋아요된 상태면 취소(`liked: false`), 없으면 추가(`liked: true`).
2013
+ > hook에 의해 `board_post.like_count` 자동 증감.
2014
+
2015
+ ### 6.15 답변 채택
2016
+
2017
+ ```
2018
+ POST /api/v1/board/posts/{seq}/accept
2019
+ Authorization: Bearer <token>
2020
+ ```
2021
+
2022
+ **Response:**
2023
+
2024
+ ```json
2025
+ { "success": true, "data": null }
2026
+ ```
2027
+
2028
+ > 원글 작성자 또는 관리자만 가능. 같은 `root_seq` 내 기존 채택글은 자동 해제.
2029
+ > `board_post.accepted = "Y"` 로 설정.
2030
+ > **원글(`parent_seq = null`)은 채택할 수 없다 — 답글(`depth ≥ 1`)만 채택 대상이다.**
2031
+
2032
+ ### 6.16 별점 등록 / 수정
2033
+
2034
+ 게시글·답글과 댓글 모두 별점을 줄 수 있다.
2035
+
2036
+ **게시글·답글:**
2037
+
2038
+ ```
2039
+ POST /api/v1/board/posts/{seq}/rating
2040
+ Authorization: Bearer <token>
2041
+ Content-Type: application/json
2042
+
2043
+ { "score": 4 }
2044
+ ```
2045
+
2046
+ **댓글:**
2047
+
2048
+ ```
2049
+ POST /api/v1/board/comments/{seq}/rating
2050
+ Authorization: Bearer <token>
2051
+ Content-Type: application/json
2052
+
2053
+ { "score": 4 }
2054
+ ```
2055
+
2056
+ **Response (신규):**
2057
+
2058
+ ```json
2059
+ { "success": true, "data": { "created": true } }
2060
+ ```
2061
+
2062
+ **Response (수정):**
2063
+
2064
+ ```json
2065
+ { "success": true, "data": { "updated": true } }
2066
+ ```
2067
+
2068
+ > `score`는 1~5 정수. 동일 `target_type + target_seq + user_seq`로 재요청 시 수정 처리.
2069
+ > hook에 의해 대상(`board_post` 또는 `board_comment`)의 `rating_sum`, `rating_count` 자동 갱신.
2070
+ > 평균 별점: `rating_sum / rating_count` (프론트엔드에서 계산).
2071
+
2072
+ ### 6.17 태그 목록 조회
2073
+
2074
+ ```
2075
+ GET /api/v1/board/tags?search={keyword}&limit=20
2076
+ ```
2077
+
2078
+ **Response:**
2079
+
2080
+ ```json
2081
+ {
2082
+ "success": true,
2083
+ "data": {
2084
+ "items": [
2085
+ {
2086
+ "seq": 1,
2087
+ "name": "javascript",
2088
+ "label": "JavaScript",
2089
+ "use_count": 42
2090
+ },
2091
+ { "seq": 2, "name": "vue-js", "label": "Vue.js", "use_count": 18 }
2092
+ ]
2093
+ }
2094
+ }
2095
+ ```
2096
+
2097
+ > `use_count` 내림차순 정렬. `search`로 태그 이름/라벨 검색 가능. 입력 자동완성에 활용.
2098
+
2099
+ ### 6.18 게시글 태그 일괄 설정
2100
+
2101
+ ```
2102
+ PUT /api/v1/board/posts/{seq}/tags
2103
+ Authorization: Bearer <token>
2104
+ Content-Type: application/json
2105
+
2106
+ { "tags": ["javascript", "vue-js"] }
2107
+ ```
2108
+
2109
+ **Response:**
2110
+
2111
+ ```json
2112
+ { "success": true, "data": { "tags": ["javascript", "vue-js"] } }
2113
+ ```
2114
+
2115
+ > `tags`는 tag `name`(슬러그) 배열. 존재하지 않는 태그는 자동 생성 (label = name).
2116
+ > 기존 `board_post_tag`를 **전체 삭제 후 재연결** (replace 방식).
2117
+ > 빈 배열 `[]` 전송 시 태그 전체 해제.
2118
+ > 게시글 작성(6.3)/수정(6.4) 시 `tags` 필드를 함께 보내면 이 로직을 내부 호출한다.
2119
+
2120
+ ### 6.19 신고
2121
+
2122
+ 게시글 또는 댓글을 신고한다. 한 회원이 동일 대상에 중복 신고할 수 없다.
2123
+
2124
+ ```
2125
+ POST /api/v1/board/posts/{seq}/report
2126
+ POST /api/v1/board/comments/{seq}/report
2127
+ Authorization: Bearer <token>
2128
+ Content-Type: application/json
2129
+
2130
+ {
2131
+ "reason": "spam", // "spam" | "abuse" | "illegal" | "other"
2132
+ "detail": "..." // 선택. reason이 "other"일 때 상세 내용
2133
+ }
2134
+ ```
2135
+
2136
+ **Response:**
2137
+
2138
+ ```json
2139
+ { "success": true, "data": { "reported": true } }
2140
+ ```
2141
+
2142
+ **Error cases:**
2143
+
2144
+ - `400` — 이미 신고한 대상 (`"이미 신고한 대상입니다."`)
2145
+ - `404` — 존재하지 않는 게시글/댓글
2146
+
2147
+ ### 6.19.1 신고 관리 (관리자)
2148
+
2149
+ 신고 목록 조회 및 상태 변경. 관리자 전용.
2150
+
2151
+ ```
2152
+ GET /api/v1/board/admin/reports?status=pending&page=1&per_page=20
2153
+ Authorization: Bearer <admin-token>
2154
+ ```
2155
+
2156
+ **Response:**
2157
+
2158
+ ```json
2159
+ {
2160
+ "success": true,
2161
+ "data": {
2162
+ "items": [
2163
+ {
2164
+ "seq": 1,
2165
+ "target_type": "post",
2166
+ "target_seq": 42,
2167
+ "user_seq": 15,
2168
+ "reason": "spam",
2169
+ "detail": null,
2170
+ "status": "pending",
2171
+ "created_time": "2026-03-06T12:00:00Z"
2172
+ }
2173
+ ],
2174
+ "pagination": {
2175
+ "page": 1,
2176
+ "per_page": 20,
2177
+ "total": 3,
2178
+ "total_pages": 1
2179
+ }
2180
+ }
2181
+ }
2182
+ ```
2183
+
2184
+ 신고 상태 변경:
2185
+
2186
+ ```
2187
+ PATCH /api/v1/board/admin/reports/{seq}
2188
+ Authorization: Bearer <admin-token>
2189
+ Content-Type: application/json
2190
+
2191
+ { "status": "resolved" } // "resolved" | "dismissed"
2192
+ ```
2193
+
2194
+ ```json
2195
+ { "success": true, "data": null }
2196
+ ```
2197
+
2198
+ > `status`를 `resolved`로 변경하면 해당 게시글/댓글을 숨김 처리(`status: "hidden"`) 할 수 있고,
2199
+ > `dismissed`로 변경하면 신고를 기각한다.
2200
+
2201
+ ### 6.20 읽음 표시
2202
+
2203
+ 회원이 게시글을 읽음 처리한다. `board_read_log`에 upsert 방식으로 기록한다.
2204
+ 상세 조회(6.2) 시 앱서버에서 자동 호출할 수 있고, 클라이언트가 명시적으로 호출할 수도 있다.
2205
+
2206
+ ```
2207
+ POST /api/v1/board/posts/{seq}/read
2208
+ Authorization: Bearer <token>
2209
+ ```
2210
+
2211
+ **Response:**
2212
+
2213
+ ```json
2214
+ { "success": true, "data": { "read": true } }
2215
+ ```
2216
+
2217
+ > 비회원은 읽음 표시를 하지 않는다 (인증 필수).
2218
+ > 목록 조회 시 `read` 여부는 `board_read_log` 조인으로 `is_read: true/false` 응답에 포함할 수 있다.
2219
+ > **upsert 동시성 처리**: unique(`post_seq + user_seq`) 제약에 의해 `INSERT ON DUPLICATE KEY UPDATE read_time = NOW()` 또는
2220
+ > Entity Server의 `submit`이 unique 충돌 시 자동 update하는 방식으로 race condition을 방지한다.
2221
+
2222
+ ### 6.21 멘션 알림 목록
2223
+
2224
+ 로그인한 회원의 읽지 않은 멘션 목록을 조회한다.
2225
+
2226
+ ```
2227
+ GET /api/v1/board/mentions?is_read=N
2228
+ Authorization: Bearer <token>
2229
+ ```
2230
+
2231
+ **Response:**
2232
+
2233
+ ```json
2234
+ {
2235
+ "success": true,
2236
+ "data": {
2237
+ "items": [
2238
+ {
2239
+ "seq": 1,
2240
+ "source_type": "comment",
2241
+ "source_seq": 42,
2242
+ "source_title": "공지사항입니다",
2243
+ "source_author_name": "홍길동",
2244
+ "source_content_preview": "@당신의아이디 확인 부탁드립니다...",
2245
+ "is_read": "N",
2246
+ "created_time": "2025-01-01T00:00:00Z"
2247
+ }
2248
+ ],
2249
+ "total": 1
2250
+ }
2251
+ }
2252
+ ```
2253
+
2254
+ > `source_title`은 멘션 소스가 속한 게시글 제목, `source_author_name`은 멘션을 작성한 사람,
2255
+ > `source_content_preview`는 본문 앞부분 50자 미리보기. 프론트엔드 알림 UI 구성에 활용.
2256
+
2257
+ 멘션 확인(읽음 처리):
2258
+
2259
+ ```
2260
+ PATCH /api/v1/board/mentions/{seq}/read
2261
+ Authorization: Bearer <token>
2262
+ ```
2263
+
2264
+ ```json
2265
+ { "success": true, "data": { "read": true } }
2266
+ ```
2267
+
2268
+ > 멘션 목록 조회/읽음 처리 모두 인증이 필요하며, 본인 멘션만 조회/처리 가능해야 한다.
2269
+
2270
+ ## 7. 권한 및 보안
2271
+
2272
+ ### 7.1 권한 매트릭스
2273
+
2274
+ | 동작 | 비회원 | 일반 회원 | 관리자 |
2275
+ | ---------------------------------------------- | ------------------------- | -------------------------------------- | ------ |
2276
+ | 목록 조회 | read_role 에 따름 | ✅ | ✅ |
2277
+ | 상세 조회 | read_role 에 따름 | ✅ | ✅ |
2278
+ | 글 작성 (원글·답글, 회원) | ❌ | write_role 에 따름 | ✅ |
2279
+ | 글 작성 (비회원 — `guest_write_enabled = "Y"`) | ✅ (비밀번호 필수) | — | ✅ |
2280
+ | 글 수정 | ✅ (guest-auth 토큰 필요) | 본인만 | ✅ |
2281
+ | 글 삭제 | ✅ (guest-auth 토큰 필요) | 본인만 | ✅ |
2282
+ | 댓글 작성 | ❌ | ✅ | ✅ |
2283
+ | 댓글 삭제 | ❌ | 본인만 | ✅ |
2284
+ | 파일 업로드 | ❌ | write_role 에 따름 | ✅ |
2285
+ | 파일 삭제 | ❌ | 본인(글 작성자)만 | ✅ |
2286
+ | 좋아요 | ❌ | ✅ (`like_enabled = "Y"`) | ✅ |
2287
+ | 별점 (게시글·답글) | ❌ | ✅ (`rating_enabled = "Y"`) | ✅ |
2288
+ | 별점 (댓글) | ❌ | ✅ (`rating_enabled = "Y"`) | ✅ |
2289
+ | 답변 채택 | ❌ | 원글 작성자만 (`accept_enabled = "Y"`) | ✅ |
2290
+ | 글 고정(pin) | ❌ | ❌ | ✅ |
2291
+ | 신고 | ❌ | ✅ (중복 신고 불가) | ✅ |
2292
+ | 신고 관리 (목록·상태변경) | ❌ | ❌ | ✅ |
2293
+ | 읽음 표시 | ❌ | ✅ (자동 또는 명시적) | ✅ |
2294
+ | 카테고리 관리 | ❌ | ❌ | ✅ |
2295
+
2296
+ ### 7.2 보안 고려사항
2297
+
2298
+ - **XSS 방지**: content 필드는 앱서버 핸들러에서 HTML sanitize 처리
2299
+ - **비회원 비밀번호**: `guest_password`는 bcrypt(cost=10) 해시 후 저장. 조회/수정/삭제 시 `bcrypt.compare` 검증. 평문을 응답·로그에 절대 포함하지 않는다.
2300
+ - **비회원 임시 토큰**: `guest-auth` 응답 JWT는 `guest_post_seq` claim만 포함, 만료 30분. 해당 글 수정/삭제에만 사용 가능.
2301
+ - **파일 업로드**: 엔티티 서버에서 MIME 타입 검증, SHA-256 중복 감지 처리. 앱서버에서 파일 크기 사전 제한
2302
+ - **SQL Injection**: Entity Server의 parameterized query로 방어
2303
+ - **Rate Limiting**: 앱서버 rate-limit 설정 (글 작성/댓글 작성/좋아요)
2304
+ - **CSRF**: 앱서버 CSRF 플러그인 (`X-CSRF-Token` 헤더)
2305
+ - **패킷 암호화**: 앱서버 packet-encrypt 플러그인 (선택)
2306
+
2307
+ ---
2308
+
2309
+ ## 8. 확장 고려사항
2310
+
2311
+ 현재 구현된 확장 기능 (이미 설계에 포함):
2312
+
2313
+ | 기능 | 위치 |
2314
+ | ------------- | ----------------------------------------------------------------------------------------------------------- |
2315
+ | 비회원 글작성 | 섹션 4.1 `guest_write_enabled`, 섹션 5.4 `guestCreate` 핸들러 |
2316
+ | 좋아요/추천 | 섹션 4.5 `board_like`, 섹션 5.4 `likes.ts` 핸들러 |
2317
+ | Push 알림 | 섹션 4.2 `board_post.after_insert` hook (`push_on_write = "Y"`) |
2318
+ | 답변 채택 | 섹션 4.2 `board_post.accepted`, 섹션 5.4 `posts.accept` 핸들러 |
2319
+ | 별점 | 섹션 4.6 `board_rating`, 섹션 5.4 `ratings.ts` 핸들러 |
2320
+ | 임시저장 | `board_post.status = "draft"`, 6.1 `status=draft` 쿼리 파라미터, 6.3/6.4 `status` 필드 |
2321
+ | 태그 | 섹션 4.7 `board_tag`, 4.8 `board_post_tag`, 6.17 목록, 6.18 일괄 설정, `tags.ts` 핸들러 |
2322
+ | 신고 | 섹션 4.9 `board_report`, 6.19 신고 API, 6.19.1 관리자 신고 관리 API, `reports.ts` 핸들러 (게시글·댓글 공용) |
2323
+ | 읽음 표시 | 섹션 4.10 `board_read_log`, 6.20 읽음 표시 API, `posts.markRead` 핸들러 |
2324
+ | 멘션 | 섹션 4.11 `board_mention`, 6.21 멘션 목록 API, 댓글/글 작성 시 `@` 파싱 후 자동 삽입 |
2325
+ | 익명 | 섹션 4.1 `anonymous_enabled`, 핸들러에서 `author_name` 강제·`user_seq` 마스킹 |
2326
+
2327
+ 향후 필요 시 추가 가능한 기능들:
2328
+
2329
+ | 기능 | 구현 방안 |
2330
+ | ------------- | -------------------------------------------------------- |
2331
+ | 에디터 이미지 | 앱서버에 이미지 업로드 라우트 추가 → content 내 URL 삽입 |
2332
+
2333
+ ---
2334
+
2335
+ ## 참고
2336
+
2337
+ - Entity Server CRUD SDK: `entityServer.list()`, `entityServer.submit()` 등 (`@gateway/api`)
2338
+ - 앱서버 라우트 패턴: `src/app/routes/board/route.ts` (Fastify auto-prefix `/api/v1/board/`)
2339
+ - 기존 패턴 참고: `routes/account/`, `plugins/smtp/` 등의 핸들러 구조
2340
+ - 엔티티 정의: `src/app/routes/entities/*.json` 에 배치 (board_category, board_post, board_comment, board_like, board_rating, board_tag, board_post_tag, board_report, board_read_log, board_mention)
2341
+ - 파일 스토리지: 엔티티 서버 내장 files API (`/v1/files/board_post/*`), 별도 엔티티 불필요
2342
+ - 파일 SDK: `entityServer.fileUpload()`, `entityServer.fileList()`, `entityServer.fileDelete()` (`entity-server-client`)