@h-ai/cli 0.1.0-alpha.56 → 0.1.0-alpha.58

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 (208) hide show
  1. package/README.en.md +239 -0
  2. package/README.ja.md +239 -0
  3. package/README.md +51 -1
  4. package/dist/chunk-2H7UOFLK.js +0 -0
  5. package/dist/chunk-F4IBPVZ5.js +355 -0
  6. package/dist/chunk-F4IBPVZ5.js.map +1 -0
  7. package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
  8. package/dist/chunk-WUSFJFW5.js.map +1 -0
  9. package/dist/cli-config-templates-6XA7V6CC.js +5 -0
  10. package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
  11. package/dist/{cli-skill-templates-ZIWYMS4Y.js → cli-skill-templates-45CK4RQ2.js} +3 -3
  12. package/dist/{cli-skill-templates-ZIWYMS4Y.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
  13. package/dist/index.js +134 -125
  14. package/dist/index.js.map +1 -1
  15. package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
  16. package/dist/src-DUGLX4YB.js.map +1 -0
  17. package/package.json +17 -17
  18. package/templates/README.md +4 -4
  19. package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
  20. package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
  21. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
  22. package/templates/apps/api/.gitignore.hbs +13 -0
  23. package/templates/apps/api/.npmrc.hbs +1 -0
  24. package/templates/apps/api/Dockerfile.hbs +6 -5
  25. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
  26. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
  27. package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  28. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
  29. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
  30. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
  31. package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
  32. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
  33. package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
  34. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
  35. package/templates/apps/api/e2e/api-service.spec.ts.hbs +4 -2
  36. package/templates/apps/api/{eslint.config.js → eslint.config.js.hbs} +6 -8
  37. package/templates/apps/api/package.json.hbs +3 -3
  38. package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
  39. package/templates/apps/api/tsconfig.base.json +0 -1
  40. package/templates/apps/fullstack/.dockerignore.hbs +3 -0
  41. package/templates/apps/fullstack/.gitignore.hbs +16 -0
  42. package/templates/apps/fullstack/.npmrc.hbs +1 -0
  43. package/templates/apps/fullstack/Dockerfile.hbs +6 -5
  44. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
  45. package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
  46. package/templates/apps/fullstack/apps/{{projectName}}-app/eslint.config.js.hbs +5 -8
  47. package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
  48. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
  49. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
  50. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
  51. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
  52. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
  53. package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
  54. package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
  55. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
  56. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
  57. package/templates/apps/fullstack/apps/{{projectName}}-desktop/eslint.config.js.hbs +5 -8
  58. package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
  59. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
  60. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
  61. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
  62. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
  63. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
  64. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
  65. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
  66. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
  67. package/templates/apps/fullstack/apps/{{projectName}}-web/eslint.config.js.hbs +5 -8
  68. package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
  69. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
  70. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
  71. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
  72. package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
  73. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
  74. package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
  75. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
  76. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +207 -17
  77. package/templates/apps/fullstack/{eslint.config.js → eslint.config.js.hbs} +6 -8
  78. package/templates/apps/fullstack/package.json.hbs +3 -3
  79. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
  80. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
  81. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
  82. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
  83. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
  84. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
  85. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
  86. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
  87. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  88. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
  89. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
  90. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
  91. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
  92. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
  93. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
  94. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
  95. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
  96. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
  97. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
  98. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
  99. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
  100. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
  101. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
  102. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
  103. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
  104. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
  105. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
  106. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
  107. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
  108. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
  109. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
  110. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
  111. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
  112. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
  113. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
  114. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
  115. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
  116. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
  117. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
  118. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
  119. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
  120. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
  121. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
  122. package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
  123. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
  124. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
  125. package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
  126. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
  127. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
  128. package/templates/apps/fullstack/tsconfig.base.json +0 -1
  129. package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
  130. package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
  131. package/templates/apps/mobile-app/index.html.hbs +2 -2
  132. package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
  133. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
  134. package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
  135. package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
  136. package/templates/base/.npmrc.hbs +1 -0
  137. package/templates/base/Dockerfile.hbs +9 -8
  138. package/templates/base/e2e/app.spec.ts.hbs +1 -0
  139. package/templates/base/eslint-rules.js +45 -0
  140. package/templates/base/eslint.config.js.hbs +6 -8
  141. package/templates/base/package.json.hbs +39 -42
  142. package/templates/base/pnpm-workspace.yaml.hbs +26 -0
  143. package/templates/base/src/app.d.ts.hbs +4 -0
  144. package/templates/base/src/hooks.server.ts.hbs +0 -8
  145. package/templates/base/src/lib/server/init.ts.hbs +26 -5
  146. package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
  147. package/templates/base/tests/scaffold.test.ts.hbs +21 -1
  148. package/templates/base/vite.config.ts.hbs +21 -0
  149. package/templates/features/ai/routes/api/chat/+server.ts +6 -7
  150. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
  151. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
  152. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
  153. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
  154. package/templates/features/storage/routes/api/upload/+server.ts +10 -8
  155. package/templates/skills/AGENTS.md +9 -8
  156. package/templates/skills/CLAUDE.md +0 -10
  157. package/templates/skills/bridges/admin/AGENTS.md +8 -0
  158. package/templates/skills/bridges/admin/copilot-instructions.md +2 -0
  159. package/templates/skills/bridges/api/AGENTS.md +8 -1
  160. package/templates/skills/bridges/api/copilot-instructions.md +2 -0
  161. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +21 -4
  162. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +5 -0
  163. package/templates/skills/bridges/h5/AGENTS.md +8 -0
  164. package/templates/skills/bridges/h5/copilot-instructions.md +2 -0
  165. package/templates/skills/bridges/mobile-app/AGENTS.md +8 -0
  166. package/templates/skills/bridges/mobile-app/copilot-instructions.md +2 -0
  167. package/templates/skills/bridges/website/AGENTS.md +8 -0
  168. package/templates/skills/bridges/website/copilot-instructions.md +2 -0
  169. package/templates/skills/copilot-instructions.md +2 -0
  170. package/templates/skills/hai-api-client/SKILL.md +2 -0
  171. package/templates/skills/hai-app-create/SKILL.md +9 -7
  172. package/templates/skills/hai-app-create/development.md +72 -0
  173. package/templates/skills/hai-app-create/reference.md +44 -32
  174. package/templates/skills/hai-app-review/SKILL.md +50 -144
  175. package/templates/skills/hai-app-tests/SKILL.md +3 -1
  176. package/templates/skills/hai-app-tests/reference.md +47 -27
  177. package/templates/skills/hai-build/SKILL.md +5 -4
  178. package/templates/skills/hai-build/reference.md +56 -34
  179. package/templates/skills/hai-ci/SKILL.md +8 -15
  180. package/templates/skills/hai-core/SKILL.md +2 -0
  181. package/templates/skills/hai-core/reference.md +6 -4
  182. package/templates/skills/hai-framework-sync/SKILL.md +8 -2
  183. package/templates/skills/hai-pr-review/SKILL.md +5 -4
  184. package/templates/skills/hai-reldb/SKILL.md +1 -0
  185. package/templates/skills/hai-reldb/reference.md +3 -0
  186. package/templates/skills/hai-scheduler/SKILL.md +3 -0
  187. package/templates/skills/hai-serv/SKILL.md +2 -0
  188. package/templates/skills/hai-serv/reference.md +18 -2
  189. package/templates/skills/hai-storage/SKILL.md +3 -1
  190. package/templates/skills/hai-ui/SKILL.md +6 -0
  191. package/templates/skills/hai-ui/reference.md +7 -4
  192. package/templates/skills/hai-vecdb/reference.md +53 -49
  193. package/dist/chunk-O3ANVICQ.js.map +0 -1
  194. package/dist/chunk-WUYLYCW2.js +0 -237
  195. package/dist/chunk-WUYLYCW2.js.map +0 -1
  196. package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
  197. package/dist/src-TBIET7YU.js.map +0 -1
  198. package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
  199. package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
  200. package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
  201. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
  202. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
  203. package/templates/skills/bridges/admin/CLAUDE.md +0 -11
  204. package/templates/skills/bridges/api/CLAUDE.md +0 -11
  205. package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -5
  206. package/templates/skills/bridges/h5/CLAUDE.md +0 -11
  207. package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -11
  208. package/templates/skills/bridges/website/CLAUDE.md +0 -11
@@ -2,14 +2,24 @@
2
2
 
3
3
  后端 API Service,基于 `@h-ai/serv` 将共享 contract 装配为最小 HTTP App 抽象,不在应用代码中直接暴露 Hono。
4
4
 
5
- ## 骨架说明
5
+ ## 默认功能与配置
6
6
 
7
7
  - `config/_core.yml`:应用元信息(名称 / 版本 / 环境 / 语言),启动时由 `core.config` 加载。
8
8
  - `config/_serv.yml`:服务端运行时设置(CORS 来源等)。
9
- - `src/index.ts`:进程入口,先 `core.init({ configDir: './config' })` 加载配置再启动监听。
9
+ - `config/_db.yml` / `_cache.yml` / `_iam.yml` / `_storage.yml` / `_audit.yml`:mini 管理所需模块配置,初始化前使用模块公开 Schema 验证。
10
+ - `src/index.ts`:进程入口,先 `core.init({ configDir: './config' })`,再初始化 reldb → cache → crypto → storage → iam → audit;启动失败及退出反序释放。
10
11
  - `src/app-server.ts`:服务装配入口,挂载健康检查、OpenAPI、docs 与 procedures;应用名称 / 版本与 CORS 来源均取自上述配置。
11
12
  - `src/server/procedures/app-procedures.ts`:app 领域 procedures,对应 contract 中的 `app.info` / `app.echo` 路由。
12
- - `tests/app-server.test.ts`:通过 `ServerApp.request()` 验证健康检查与业务路由。
13
+ - `src/server/procedures/admin-procedures.ts`:认证、管理员用户/角色/审计管理和个人设置,使用 `.auth()` / `.role('admin')` 守卫。每个请求读取用户最新启用状态、角色和权限。
14
+ - 当前用户与刷新 procedures 直接选用 `@h-ai/serv/features/iam` 的默认实现;仅覆写登录、退出、修改密码以追加业务审计。未选的注册/OTP/通用管理接口不挂载,HTTP App 类型直接复用公开 `ServHttpApp`。
15
+ - `src/server/admin-service.ts`:角色分配、头像上传和审计编排,全部底层操作复用 hai 模块。固定 admin/user 两种角色;锁内重新鉴权,禁止管理员自删、自禁用、自降级,并补偿用户更新失败后的角色变更。
16
+ - `tests/app-server.test.ts`:真实加密 HTTP 测试,覆盖 Cookie/受信原生刷新、RBAC、旧会话权限、并发管理操作、补偿、头像安全、偏好、审计及会话失效。
17
+
18
+ CLI 已生成根目录 `.env`,`HAI_ADMIN_DEFAULT_PASSWORD` 为每个项目独立的随机强密码;`dev`/`start` 自动读取,首次登录使用用户名 `admin` 和该密码。仅在没有用户时创建管理员,不重设已有用户密码。密码不符合 `_iam.yml` 策略时,启动日志显示具体原因及错误码。`.env` 被 Git 和容器构建忽略,`.env.example` 不含真实密码;部署时设置目标环境自己的密码。所有业务 JSON 请求和响应强制加密;健康检查和随机路径的只读头像图片可直接访问。头像支持 PNG/JPEG/WebP,最大 512 KiB,校验内容特征和大小后由服务端生成 key,不能指定任意文件路径。
19
+
20
+ 默认 SQLite + 本地 storage + memory cache 适合单实例,管理写入仅支持单写实例:进程内保护无过期时间,共享 cache 锁定期续租,丢失锁或释放失败会记录错误。多实例读取可使用共享数据库、Redis cache 和 S3 storage,但管理写请求必须统一路由到同一写实例;仅更换 Redis 不提供任意多写副本的最后管理员原子保证。另需配置传输会话粘性路由:服务端私钥驻留单个进程,服务重启后客户端需重新协商。Web 使用 HttpOnly refresh Cookie + 内存 access token;仅同时满足客户端标识、body 通道头与受信原生 Origin 的 App/Desktop 请求返回 refresh token body。
21
+
22
+ 业务修改提交后的审计记录采用最大努力策略:写入失败输出包含操作及资源的结构化错误日志,业务仍返回原操作结果,避免将已经完成的变更误报为失败;应用启动时 audit 初始化失败则停止启动。
13
23
 
14
24
  ## 开发
15
25
 
@@ -0,0 +1,3 @@
1
+ userTable: hai_iam_users
2
+ userIdColumn: id
3
+ userNameColumn: username
@@ -0,0 +1,2 @@
1
+ # 多实例部署请使用共享 Redis,保障会话、管理员变更锁一致。
2
+ type: memory
@@ -6,9 +6,16 @@
6
6
  # 每个叶子项都可由约定式 HAI 环境变量覆盖,敏感值不要写入文件。
7
7
  # =============================================================================
8
8
 
9
- name: {{fullstack.servPackageName}}
9
+ {{yaml 'name' fullstack.appName}}
10
10
  version: 0.1.0
11
- env: ${HAI_ENV:development}
12
- debug: ${HAI_DEBUG:false}
11
+ env: development
12
+ debug: false
13
13
 
14
14
  defaultLocale: {{fullstack.i18n.baseLocale}}
15
+ logging:
16
+ level: info
17
+ format: json
18
+ redact:
19
+ - password
20
+ - token
21
+ - secret
@@ -0,0 +1,3 @@
1
+ # 默认单实例 SQLite;HAI_DB 可整体替换为 PostgreSQL/MySQL 配置。
2
+ type: sqlite
3
+ database: ./data/app.db
@@ -0,0 +1,21 @@
1
+ login:
2
+ password: true
3
+ otp: false
4
+ ldap: false
5
+ apikey: false
6
+ password:
7
+ minLength: 8
8
+ maxLength: 128
9
+ requireUppercase: false
10
+ requireLowercase: false
11
+ requireNumber: false
12
+ requireSpecialChar: false
13
+ session:
14
+ maxAge: 86400
15
+ sliding: true
16
+ singleDevice: false
17
+ refreshTokenMaxAge: 604800
18
+ rbac:
19
+ defaultRole: user
20
+ # 仅由应用创建 admin/user,避免生成无业务用途的默认角色与权限。
21
+ seedDefaultData: false
@@ -22,14 +22,21 @@ http:
22
22
 
23
23
  cors:
24
24
  origin: ${HAI_CORS_ORIGIN:*}
25
- nativeOrigins: ${HAI_CORS_NATIVEORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
26
- credentials: ${HAI_CORS_CREDENTIALS:false}
25
+ nativeOrigins: ${HAI_CORS_NATIVEORIGINS:http://localhost,https://localhost,http://tauri.localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
26
+ credentials: ${HAI_CORS_CREDENTIALS:true}
27
27
  allowedHeaders:
28
28
  - Authorization
29
29
  - Content-Type
30
30
  - X-Request-Id
31
31
  - X-Requested-With
32
+ - X-Request-Client
33
+ - X-HAI-Token-Transport
34
+ - X-Client-Id
35
+ - X-Encrypted
32
36
  exposedHeaders:
33
37
  - X-Request-Id
38
+ - X-Encrypted
34
39
 
35
- transport: false
40
+ transport:
41
+ keyExchangePath: /_hai/key-exchange
42
+ maxClients: 10000
@@ -0,0 +1,3 @@
1
+ # HAI_STORAGE 可整体替换为 S3/MinIO/RustFS 配置。
2
+ type: local
3
+ root: ./data/uploads
@@ -1,5 +1,5 @@
1
1
  {
2
- "engines": { "node": ">=22.12.0" },
2
+ "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
3
3
  "name": "{{fullstack.servPackageName}}",
4
4
  "type": "module",
5
5
  "version": "0.1.0",
@@ -25,12 +25,19 @@
25
25
  "test": "vitest run",
26
26
  "test:watch": "vitest",
27
27
  "clean": "rimraf dist .turbo",
28
- "dev": "pnpm build && node dist/index.js",
29
- "start": "node dist/index.js"
28
+ "dev": "pnpm build && node --env-file-if-exists=../../.env dist/index.js",
29
+ "start": "node --env-file-if-exists=../../.env dist/index.js"
30
30
  },
31
31
  "dependencies": {
32
+ "@h-ai/audit": "{{haiPackageSpecifier}}",
33
+ "@h-ai/cache": "{{haiPackageSpecifier}}",
32
34
  "@h-ai/core": "{{haiPackageSpecifier}}",
35
+ "@h-ai/crypto": "{{haiPackageSpecifier}}",
36
+ "@h-ai/iam": "{{haiPackageSpecifier}}",
37
+ "@h-ai/reldb": "{{haiPackageSpecifier}}",
33
38
  "@h-ai/serv": "{{haiPackageSpecifier}}",
39
+ "@h-ai/storage": "{{haiPackageSpecifier}}",
40
+ "zod": "{{fullstack.packageSpecifiers.zod}}",
34
41
  "{{fullstack.contractPackageName}}": "workspace:*"
35
42
  }
36
43
  }
@@ -1,48 +1,73 @@
1
- import type { ServConfigInput } from '@h-ai/serv'
2
- import { core } from '@h-ai/core'
1
+ import type { CoreConfig } from '@h-ai/core'
2
+ import type { ServHttpApp } from '@h-ai/serv'
3
+ import { core, err, HaiCommonError, ok } from '@h-ai/core'
4
+ import { createReldbTransportKeyStore, crypto } from '@h-ai/crypto'
5
+ import { iam } from '@h-ai/iam'
6
+ import { reldb } from '@h-ai/reldb'
3
7
  import { serv, ServConfigSchema } from '@h-ai/serv'
4
- import { ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
8
+ import { storage } from '@h-ai/storage'
9
+ import { ENABLED_FRONTENDS, {{fullstack.contractExportName}}, SUPPORTED_LOCALES } from '{{fullstack.contractPackageName}}'
5
10
  import { createServerProcedures } from './server/procedures/index.js'
6
11
 
7
12
  const STARTED_AT = new Date().toISOString()
8
13
 
9
- /** `_core.yml` 中的应用元信息(由 core.config 加载,未初始化时回退到默认值)。 */
10
- interface AppCoreConfig {
11
- name?: string
12
- version?: string
13
- env?: string
14
- }
15
-
16
- /** 应用侧最小 HTTP App 抽象;不把底层 Hono 类型暴露给项目代码。 */
17
- export interface ServerApp {
18
- readonly fetch: (request: Request) => Response | Promise<Response>
19
- readonly request: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>
20
- }
21
-
22
14
  /** 创建后端 HTTP App;与 @h-ai/serv 的 createApp 返回语义保持一致。 */
23
- export function createServerApp(): ServerApp {
24
- // 配置来自 config/_core.yml 与 config/_serv.yml。
25
- // 单元测试可不初始化 core,此时回退到内置默认值。
26
- const coreConfig = core.config.get<AppCoreConfig>('core')
27
- const servConfig = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
28
- const appName = coreConfig?.name ?? '{{fullstack.servPackageName}}'
29
- const appVersion = coreConfig?.version ?? '0.1.0'
15
+ export function createServerApp(): ServHttpApp {
16
+ const coreConfig = core.config.getOrThrow<CoreConfig>('core')
17
+ const servConfig = ServConfigSchema.parse(core.config.getOrThrow('serv'))
30
18
  const security = serv.createRuntimeSecurityPolicy({
31
- environment: coreConfig?.env,
19
+ environment: coreConfig.env,
32
20
  corsOrigin: servConfig.cors.origin,
33
21
  nativeOrigins: servConfig.cors.nativeOrigins,
34
22
  })
23
+ const publicPaths = [
24
+ ...(servConfig.http.health ? [servConfig.http.health.path, servConfig.http.health.readyPath].filter((path): path is `/${string}` => Boolean(path)) : []),
25
+ ...(servConfig.http.openapi && security.exposeApiDocs ? [servConfig.http.openapi.path] : []),
26
+ ...(servConfig.http.docs && security.exposeApiDocs ? [servConfig.http.docs.path] : []),
27
+ ]
35
28
 
36
29
  return serv.createApp({
37
30
  contract: {{fullstack.contractExportName}},
38
31
  procedures: createServerProcedures({
39
32
  app: {
40
- name: appName,
41
- version: appVersion,
33
+ name: coreConfig.name,
34
+ version: coreConfig.version,
35
+ env: coreConfig.env,
36
+ defaultLocale: coreConfig.defaultLocale,
37
+ supportedLocales: SUPPORTED_LOCALES,
42
38
  startedAt: STARTED_AT,
43
39
  frontends: [...ENABLED_FRONTENDS],
44
40
  },
45
41
  }),
42
+ iam,
43
+ verifyToken: async (token) => {
44
+ const session = await iam.session.verifyToken(token)
45
+ if (!session.success)
46
+ return session
47
+ const user = await iam.user.getUser(session.data.userId)
48
+ if (!user.success)
49
+ return user
50
+ if (!user.data?.enabled)
51
+ return err(HaiCommonError.UNAUTHORIZED, core.i18n.coreM('core_errorUnauthorized'))
52
+ // 会话中的授权同步属于最大努力,业务守卫始终读取最新持久化角色与权限。
53
+ const [roles, permissions] = await Promise.all([
54
+ iam.authz.getUserRoles(user.data.id),
55
+ iam.authz.getUserPermissions(user.data.id),
56
+ ])
57
+ if (!roles.success)
58
+ return roles
59
+ if (!permissions.success)
60
+ return permissions
61
+ return ok({ ...session.data, roles: roles.data.map(role => role.code), permissions: permissions.data.map(permission => permission.code) })
62
+ },
63
+ refreshCookie: {
64
+ secure: security.secureRefreshCookie,
65
+ nativeTokenTransport: { isRequest: request => request.headers.get('x-hai-token-transport') === 'body'
66
+ && ['{{projectName}}-app', '{{projectName}}-desktop'].includes(request.headers.get('x-request-client') ?? '')
67
+ && security.isNativeOrigin(request.headers.get('origin') ?? '') },
68
+ },
69
+ // 管理业务默认强制双向加密;客户端公钥复用 reldb 保存,服务端密钥仅驻留进程。
70
+ transport: { ...(servConfig.transport || {}), excludePaths: publicPaths, crypto, keyStore: createReldbTransportKeyStore({ reldb }) },
46
71
  http: {
47
72
  ...servConfig.http,
48
73
  openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
@@ -52,8 +77,18 @@ export function createServerApp(): ServerApp {
52
77
  middleware: serv.cors({
53
78
  origin: security.allowOrigin,
54
79
  credentials: servConfig.cors.credentials,
55
- allowedHeaders: [...servConfig.cors.allowedHeaders],
56
- exposedHeaders: [...servConfig.cors.exposedHeaders],
80
+ allowedHeaders: [...new Set([...servConfig.cors.allowedHeaders, 'X-Client-Id', 'X-Encrypted', 'X-Request-Client', 'X-HAI-Token-Transport'])],
81
+ exposedHeaders: [...new Set([...servConfig.cors.exposedHeaders, 'X-Encrypted'])],
82
+ }),
83
+ }, {
84
+ path: `${servConfig.http.apiPrefix}/assets/*`,
85
+ middleware: serv.storageAssets({
86
+ storage,
87
+ pathPrefix: `${servConfig.http.apiPrefix}/assets/`,
88
+ keyPattern: /^avatars\/[\w-]+\/[\w-]+\.(png|jpg|webp)$/,
89
+ allowedContentTypes: ['image/png', 'image/jpeg', 'image/webp'],
90
+ cacheControl: 'public, max-age=31536000, immutable',
91
+ crossOriginResourcePolicy: 'cross-origin',
57
92
  }),
58
93
  }],
59
94
  })
@@ -1,18 +1,33 @@
1
+ import type { CoreConfig } from '@h-ai/core'
1
2
  import type { ServConfigInput } from '@h-ai/serv'
3
+ import process from 'node:process'
2
4
  import { core } from '@h-ai/core'
3
5
  import { serv, ServConfigSchema } from '@h-ai/serv'
4
6
  import { createServerApp } from './app-server.js'
7
+ import { closeModules, initializeModules } from './server/runtime.js'
5
8
 
6
- // 加载 config/ 下的 _core.yml / _serv.yml 等配置文件(约定优于配置)
7
- core.init({ configDir: './config', logging: { level: 'info' } })
9
+ async function startServer(): Promise<void> {
10
+ // 加载配置后按依赖顺序初始化;日志设置沿用 _core.yml。
11
+ core.init({ configDir: './config' })
12
+ await initializeModules()
13
+ try {
14
+ // HOST/PORT 环境变量优先于 config/_serv.yml。
15
+ const { server } = ServConfigSchema.parse(core.config.getOrThrow<ServConfigInput>('serv'))
16
+ const app = createServerApp()
17
+ serv.listen(app, {
18
+ host: server.host,
19
+ port: server.port,
20
+ onListening: info => core.logger.info('Application listening', { name: core.config.getOrThrow<CoreConfig>('core').name, host: server.host, port: info.port }),
21
+ onClose: closeModules,
22
+ })
23
+ }
24
+ catch (error) {
25
+ await closeModules()
26
+ throw error
27
+ }
28
+ }
8
29
 
9
- // host/port 来自 config/_serv.yml 的 server(HOST/PORT 环境变量高于配置文件)。
10
- const { server } = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
11
- const app = createServerApp()
12
- const listenApp = app as Parameters<typeof serv.listen>[0]
13
-
14
- serv.listen(listenApp, {
15
- host: server.host,
16
- port: server.port,
17
- onListening: info => core.logger.info('{{fullstack.servPackageName}} listening', { host: server.host, port: info.port }),
30
+ void startServer().catch((error: unknown) => {
31
+ core.logger.fatal('Application startup failed', { error: error instanceof Error ? error.message : 'Unknown startup failure' })
32
+ process.exitCode = 1
18
33
  })
@@ -0,0 +1,276 @@
1
+ import type { HaiResult } from '@h-ai/core'
2
+ import type { Role, User } from '@h-ai/iam'
3
+ import type { ServConfig, ServContext } from '@h-ai/serv'
4
+ import type { AdminCreateUser, AdminRole, AdminRoleCode, AdminUpdateUser, AdminUser, AvatarUpload, ProfileUpdate } from '{{fullstack.contractPackageName}}'
5
+ import { Buffer } from 'node:buffer'
6
+ import { audit } from '@h-ai/audit'
7
+ import { cache } from '@h-ai/cache'
8
+ import { core, err, HaiCommonError, ok } from '@h-ai/core'
9
+ import { iam } from '@h-ai/iam'
10
+ import { storage } from '@h-ai/storage'
11
+ import { SUPPORTED_LOCALES } from '{{fullstack.contractPackageName}}'
12
+ import { serverM } from './messages.js'
13
+
14
+ const ROLE_CODES = new Set<AdminRoleCode>(['admin', 'user'])
15
+ const SUPPORTED_THEMES = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}] as readonly string[]
16
+ let adminMutationActive = false
17
+
18
+ /** 管理写操作串行化,锁内重新鉴权,防止两个管理员同时互相删除或降级。 */
19
+ export async function withAdminMutation<T>(context: ServContext, work: () => Promise<HaiResult<T>>): Promise<HaiResult<T>> {
20
+ // mini 的管理写入仅支持单写实例;进程内保护无租期,慢请求不会在 TTL 到期后被并发穿透。
21
+ if (adminMutationActive)
22
+ return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
23
+ adminMutationActive = true
24
+ const owner = core.id.uuid()
25
+ const key = 'hai:app:admin-mutation'
26
+ let acquired = false
27
+ let leaseLost = false
28
+ let renewalPending = false
29
+ let renewalTimer: ReturnType<typeof setInterval> | undefined
30
+ try {
31
+ const locked = await cache.lock.acquire(key, { owner, ttl: 300 })
32
+ if (!locked.success)
33
+ return locked
34
+ if (!locked.data)
35
+ return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
36
+ acquired = true
37
+ renewalTimer = setInterval(() => {
38
+ if (renewalPending || leaseLost)
39
+ return
40
+ renewalPending = true
41
+ void cache.lock.extend(key, 300, owner).then((renewed) => {
42
+ if (!renewed.success || !renewed.data) {
43
+ leaseLost = true
44
+ core.logger.error('Admin mutation lease renewal failed', { owner, error: renewed.success ? 'lease_not_owned' : renewed.error.code })
45
+ }
46
+ }).catch(() => {
47
+ leaseLost = true
48
+ core.logger.error('Admin mutation lease renewal failed', { owner, error: 'unexpected_renewal_failure' })
49
+ }).finally(() => {
50
+ renewalPending = false
51
+ })
52
+ }, 100_000)
53
+ renewalTimer.unref()
54
+ const actor = await getAdminUser(context.session?.userId ?? '')
55
+ if (!actor.success)
56
+ return actor
57
+ if (!actor.data.enabled || !actor.data.roles.includes('admin'))
58
+ return err(HaiCommonError.FORBIDDEN, serverM('selfProtection', { locale: context.locale }))
59
+ if (leaseLost)
60
+ return err(HaiCommonError.CONFLICT, serverM('busy', { locale: context.locale }))
61
+ return await work()
62
+ }
63
+ finally {
64
+ if (renewalTimer)
65
+ clearInterval(renewalTimer)
66
+ try {
67
+ if (acquired) {
68
+ const released = await cache.lock.release(key, owner)
69
+ if (!released.success || !released.data)
70
+ core.logger.error('Failed to release admin mutation lock', { owner, error: released.success ? 'lease_not_owned' : released.error.code })
71
+ }
72
+ }
73
+ finally {
74
+ adminMutationActive = false
75
+ }
76
+ }
77
+ }
78
+
79
+ /** 用户与角色读取失败直接透传,不能把角色查询失败当作普通用户。 */
80
+ export async function getAdminUser(id: string): Promise<HaiResult<AdminUser>> {
81
+ const user = await iam.user.getUser(id)
82
+ if (!user.success)
83
+ return user
84
+ if (!user.data)
85
+ return err(HaiCommonError.NOT_FOUND, serverM('notFound'))
86
+ const roles = await iam.authz.getUserRoles(id)
87
+ if (!roles.success)
88
+ return roles
89
+ return ok(toAdminUser(user.data, roles.data))
90
+ }
91
+
92
+ export function toAdminUser(user: User, roles: Role[]): AdminUser {
93
+ return {
94
+ id: user.id,
95
+ username: user.username,
96
+ email: user.email,
97
+ displayName: user.displayName,
98
+ avatarUrl: user.avatarUrl,
99
+ enabled: user.enabled,
100
+ roles: roles.map(role => role.code).filter(isRoleCode),
101
+ createdAt: user.createdAt.toISOString(),
102
+ updatedAt: user.updatedAt.toISOString(),
103
+ }
104
+ }
105
+
106
+ export function isRoleCode(code: string): code is AdminRoleCode {
107
+ return ROLE_CODES.has(code as AdminRoleCode)
108
+ }
109
+
110
+ export async function createAdminUser(input: AdminCreateUser, context: ServContext): Promise<HaiResult<AdminUser>> {
111
+ const role = await iam.authz.getRoleByCode(input.role)
112
+ if (!role.success)
113
+ return role
114
+ if (!role.data)
115
+ return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
116
+ const registered = await iam.user.register(input)
117
+ if (!registered.success)
118
+ return registered
119
+ const userId = registered.data.user.id
120
+ const assigned = await iam.authz.syncRoles(userId, [role.data.id])
121
+ if (!assigned.success) {
122
+ await rollbackCreatedUser(userId)
123
+ return assigned
124
+ }
125
+ if (!input.enabled) {
126
+ const disabled = await iam.user.updateUser(userId, { enabled: false })
127
+ if (!disabled.success) {
128
+ await rollbackCreatedUser(userId)
129
+ return disabled
130
+ }
131
+ }
132
+ await recordAudit(context, 'create', 'users', userId, { role: input.role, enabled: input.enabled })
133
+ return getAdminUser(userId)
134
+ }
135
+
136
+ async function rollbackCreatedUser(id: string): Promise<void> {
137
+ const removed = await iam.user.deleteUser(id)
138
+ if (!removed.success)
139
+ core.logger.error('Failed to roll back user creation', { userId: id, error: removed.error.code })
140
+ }
141
+
142
+ export async function updateAdminUser(input: AdminUpdateUser, context: ServContext): Promise<HaiResult<AdminUser>> {
143
+ if (input.id === context.session?.userId && (input.enabled === false || input.role === 'user'))
144
+ return err(HaiCommonError.FORBIDDEN, serverM('selfProtection', { locale: context.locale }))
145
+ const existing = await iam.user.getUser(input.id)
146
+ if (!existing.success)
147
+ return existing
148
+ if (!existing.data)
149
+ return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
150
+ const role = input.role ? await iam.authz.getRoleByCode(input.role) : undefined
151
+ if (role && !role.success)
152
+ return role
153
+ if (role?.success && !role.data)
154
+ return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
155
+ const { id, role: _role, ...profile } = input
156
+ const previousRoles = await iam.authz.getUserRoles(id)
157
+ if (!previousRoles.success)
158
+ return previousRoles
159
+ if (role?.success && role.data) {
160
+ const assigned = await iam.authz.syncRoles(id, [role.data.id])
161
+ if (!assigned.success)
162
+ return assigned
163
+ }
164
+ const updated = await iam.user.updateUser(id, profile)
165
+ if (!updated.success) {
166
+ // 角色 ID 可精确还原,避免 undefined 资料字段被 updateUser 忽略后无法补偿。
167
+ if (role?.success && role.data) {
168
+ const restored = await iam.authz.syncRoles(id, previousRoles.data.map(item => item.id))
169
+ if (!restored.success)
170
+ core.logger.error('Failed to restore roles after user update failure', { userId: id, error: restored.error.code })
171
+ }
172
+ return updated
173
+ }
174
+ await recordAudit(context, 'update', 'users', id, { fields: Object.keys(profile), role: input.role })
175
+ return getAdminUser(id)
176
+ }
177
+
178
+ export async function getAdminRoles(): Promise<HaiResult<{ items: AdminRole[] }>> {
179
+ const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 100 })
180
+ if (!roles.success)
181
+ return roles
182
+ const items = roles.data.items.filter(role => isRoleCode(role.code))
183
+ const counts = await iam.authz.getRoleUserCounts(items.map(role => role.id))
184
+ if (!counts.success)
185
+ return counts
186
+ return ok({ items: items.map(role => ({
187
+ id: role.id,
188
+ code: role.code as AdminRoleCode,
189
+ name: role.name,
190
+ description: role.description,
191
+ userCount: counts.data.get(role.id) ?? 0,
192
+ })) })
193
+ }
194
+
195
+ export async function updateProfile(input: ProfileUpdate, context: ServContext): Promise<HaiResult<AdminUser>> {
196
+ const token = context.accessToken ?? ''
197
+ const current = await iam.user.getCurrentUser(token)
198
+ if (!current.success)
199
+ return current
200
+ if ((input.locale && !(SUPPORTED_LOCALES as readonly string[]).includes(input.locale)) || (input.theme && !SUPPORTED_THEMES.includes(input.theme)))
201
+ return err(HaiCommonError.VALIDATION_ERROR, serverM('unsupportedPreference', { locale: context.locale }))
202
+ const metadata = { ...current.data.metadata }
203
+ if (input.locale)
204
+ metadata.locale = input.locale
205
+ if (input.theme)
206
+ metadata.theme = input.theme
207
+ const updated = await iam.user.updateCurrentUser(token, { displayName: input.displayName, email: input.email, metadata })
208
+ if (!updated.success)
209
+ return updated
210
+ await recordAudit(context, 'update', 'profile', current.data.id, { fields: Object.keys(input) })
211
+ return getAdminUser(current.data.id)
212
+ }
213
+
214
+ /** 上传路径由服务端生成,禁止客户端写入任意 storage key。 */
215
+ export async function uploadAvatar(userId: string, input: AvatarUpload, context: ServContext): Promise<HaiResult<{ avatarUrl: string }>> {
216
+ const current = await iam.user.getUser(userId)
217
+ if (!current.success)
218
+ return current
219
+ if (!current.data)
220
+ return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
221
+ const bytes = Buffer.from(input.base64, 'base64')
222
+ if (bytes.length === 0 || bytes.length > 512 * 1024 || bytes.toString('base64') !== input.base64 || !matchesImage(bytes, input.contentType))
223
+ return err(HaiCommonError.VALIDATION_ERROR, serverM('invalidAvatar', { locale: context.locale }))
224
+ const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }[input.contentType]
225
+ const key = `avatars/${userId}/${core.id.uuid()}.${extension}`
226
+ const stored = await storage.file.put(key, bytes, { contentType: input.contentType })
227
+ if (!stored.success)
228
+ return stored
229
+ const apiPrefix = core.config.getOrThrow<ServConfig>('serv').http.apiPrefix
230
+ // 只保存生成资源路径,公开 Origin 由客户端已配置的 API 地址决定,禁止信任 Host。
231
+ const avatarUrl = `${apiPrefix}/assets/${key}`
232
+ const updated = await iam.user.updateUser(userId, { avatarUrl })
233
+ if (!updated.success) {
234
+ const cleaned = await storage.file.delete(key)
235
+ if (!cleaned.success)
236
+ core.logger.warn('Failed to clean up rejected avatar', { error: cleaned.error.code })
237
+ return updated
238
+ }
239
+ // 更新成功后删除旧头像;只清理此用户且符合服务端生成规则的资源。
240
+ const oldKey = current.data.avatarUrl?.split('/assets/')[1]
241
+ if (oldKey && oldKey.startsWith(`avatars/${userId}/`) && /^avatars\/[\w-]+\/[\w-]+\.(?:png|jpg|webp)$/.test(oldKey)) {
242
+ const cleaned = await storage.file.delete(oldKey)
243
+ if (!cleaned.success)
244
+ core.logger.warn('Failed to clean up previous avatar', { error: cleaned.error.code })
245
+ }
246
+ await recordAudit(context, 'update', 'avatars', userId)
247
+ return ok({ avatarUrl })
248
+ }
249
+
250
+ function matchesImage(bytes: Buffer, contentType: AvatarUpload['contentType']): boolean {
251
+ if (contentType === 'image/png') {
252
+ if (bytes.length < 33 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || bytes.toString('ascii', 12, 16) !== 'IHDR')
253
+ return false
254
+ const width = bytes.readUInt32BE(16)
255
+ const height = bytes.readUInt32BE(20)
256
+ return width > 0 && height > 0 && width <= 4096 && height <= 4096
257
+ }
258
+ if (contentType === 'image/jpeg')
259
+ return bytes.length >= 4 && bytes.subarray(0, 3).toString('hex') === 'ffd8ff' && bytes.subarray(-2).toString('hex') === 'ffd9'
260
+ return bytes.length >= 20 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' && bytes.readUInt32LE(4) + 8 === bytes.length
261
+ }
262
+
263
+ /** 审计记录仅含操作字段与资源标识,不记录密码、token、头像内容或联系信息。 */
264
+ export async function recordAudit(context: ServContext, action: string, resource: string, resourceId?: string, details?: Record<string, unknown>): Promise<void> {
265
+ const result = await audit.log({
266
+ userId: context.session?.userId,
267
+ action,
268
+ resource,
269
+ resourceId,
270
+ details,
271
+ ipAddress: context.ip,
272
+ userAgent: context.userAgent,
273
+ })
274
+ if (!result.success)
275
+ core.logger.error('Failed to record audit event', { action, resource, error: result.error.code })
276
+ }
@@ -0,0 +1,29 @@
1
+ import { core } from '@h-ai/core'
2
+
3
+ /** 服务端业务提示与角色种子名称同步维护中英文。 */
4
+ export const serverM = core.i18n.createMessageGetter({
5
+ 'zh-CN': {
6
+ admin: '管理员',
7
+ user: '普通用户',
8
+ adminDescription: '管理用户、角色和审计日志',
9
+ userDescription: '仅可维护个人设置',
10
+ notFound: '用户或角色不存在',
11
+ selfProtection: '不能删除、禁用或降级当前管理员账号',
12
+ invalidAvatar: '请选择不超过 512 KB 的有效 PNG、JPEG 或 WebP 图片',
13
+ busy: '系统繁忙,请稍后重试',
14
+ unsupportedPreference: '不支持此语言或主题',
15
+ invalidDateRange: '结束时间不能早于开始时间',
16
+ },
17
+ 'en-US': {
18
+ admin: 'Administrator',
19
+ user: 'User',
20
+ adminDescription: 'Manage users, roles and audit logs',
21
+ userDescription: 'Manage personal settings only',
22
+ notFound: 'User or role does not exist',
23
+ selfProtection: 'You cannot delete, disable or demote your own administrator account',
24
+ invalidAvatar: 'Choose a valid PNG, JPEG or WebP image up to 512 KB',
25
+ busy: 'The system is busy. Please try again',
26
+ unsupportedPreference: 'This language or theme is not supported',
27
+ invalidDateRange: 'End date must not precede start date',
28
+ },
29
+ })