@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.
- package/README.en.md +239 -0
- package/README.ja.md +239 -0
- package/README.md +51 -1
- package/dist/chunk-2H7UOFLK.js +0 -0
- package/dist/chunk-F4IBPVZ5.js +355 -0
- package/dist/chunk-F4IBPVZ5.js.map +1 -0
- package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
- package/dist/chunk-WUSFJFW5.js.map +1 -0
- package/dist/cli-config-templates-6XA7V6CC.js +5 -0
- package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
- package/dist/{cli-skill-templates-ZIWYMS4Y.js → cli-skill-templates-45CK4RQ2.js} +3 -3
- package/dist/{cli-skill-templates-ZIWYMS4Y.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
- package/dist/index.js +134 -125
- package/dist/index.js.map +1 -1
- package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
- package/dist/src-DUGLX4YB.js.map +1 -0
- package/package.json +17 -17
- package/templates/README.md +4 -4
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
- package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
- package/templates/apps/api/.gitignore.hbs +13 -0
- package/templates/apps/api/.npmrc.hbs +1 -0
- package/templates/apps/api/Dockerfile.hbs +6 -5
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +4 -2
- package/templates/apps/api/{eslint.config.js → eslint.config.js.hbs} +6 -8
- package/templates/apps/api/package.json.hbs +3 -3
- package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
- package/templates/apps/api/tsconfig.base.json +0 -1
- package/templates/apps/fullstack/.dockerignore.hbs +3 -0
- package/templates/apps/fullstack/.gitignore.hbs +16 -0
- package/templates/apps/fullstack/.npmrc.hbs +1 -0
- package/templates/apps/fullstack/Dockerfile.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/eslint.config.js.hbs +5 -8
- package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/eslint.config.js.hbs +5 -8
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/eslint.config.js.hbs +5 -8
- package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +207 -17
- package/templates/apps/fullstack/{eslint.config.js → eslint.config.js.hbs} +6 -8
- package/templates/apps/fullstack/package.json.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
- package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
- package/templates/apps/fullstack/tsconfig.base.json +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
- package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
- package/templates/apps/mobile-app/index.html.hbs +2 -2
- package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
- package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
- package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
- package/templates/base/.npmrc.hbs +1 -0
- package/templates/base/Dockerfile.hbs +9 -8
- package/templates/base/e2e/app.spec.ts.hbs +1 -0
- package/templates/base/eslint-rules.js +45 -0
- package/templates/base/eslint.config.js.hbs +6 -8
- package/templates/base/package.json.hbs +39 -42
- package/templates/base/pnpm-workspace.yaml.hbs +26 -0
- package/templates/base/src/app.d.ts.hbs +4 -0
- package/templates/base/src/hooks.server.ts.hbs +0 -8
- package/templates/base/src/lib/server/init.ts.hbs +26 -5
- package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
- package/templates/base/tests/scaffold.test.ts.hbs +21 -1
- package/templates/base/vite.config.ts.hbs +21 -0
- package/templates/features/ai/routes/api/chat/+server.ts +6 -7
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
- package/templates/features/storage/routes/api/upload/+server.ts +10 -8
- package/templates/skills/AGENTS.md +9 -8
- package/templates/skills/CLAUDE.md +0 -10
- package/templates/skills/bridges/admin/AGENTS.md +8 -0
- package/templates/skills/bridges/admin/copilot-instructions.md +2 -0
- package/templates/skills/bridges/api/AGENTS.md +8 -1
- package/templates/skills/bridges/api/copilot-instructions.md +2 -0
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +21 -4
- package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +5 -0
- package/templates/skills/bridges/h5/AGENTS.md +8 -0
- package/templates/skills/bridges/h5/copilot-instructions.md +2 -0
- package/templates/skills/bridges/mobile-app/AGENTS.md +8 -0
- package/templates/skills/bridges/mobile-app/copilot-instructions.md +2 -0
- package/templates/skills/bridges/website/AGENTS.md +8 -0
- package/templates/skills/bridges/website/copilot-instructions.md +2 -0
- package/templates/skills/copilot-instructions.md +2 -0
- package/templates/skills/hai-api-client/SKILL.md +2 -0
- package/templates/skills/hai-app-create/SKILL.md +9 -7
- package/templates/skills/hai-app-create/development.md +72 -0
- package/templates/skills/hai-app-create/reference.md +44 -32
- package/templates/skills/hai-app-review/SKILL.md +50 -144
- package/templates/skills/hai-app-tests/SKILL.md +3 -1
- package/templates/skills/hai-app-tests/reference.md +47 -27
- package/templates/skills/hai-build/SKILL.md +5 -4
- package/templates/skills/hai-build/reference.md +56 -34
- package/templates/skills/hai-ci/SKILL.md +8 -15
- package/templates/skills/hai-core/SKILL.md +2 -0
- package/templates/skills/hai-core/reference.md +6 -4
- package/templates/skills/hai-framework-sync/SKILL.md +8 -2
- package/templates/skills/hai-pr-review/SKILL.md +5 -4
- package/templates/skills/hai-reldb/SKILL.md +1 -0
- package/templates/skills/hai-reldb/reference.md +3 -0
- package/templates/skills/hai-scheduler/SKILL.md +3 -0
- package/templates/skills/hai-serv/SKILL.md +2 -0
- package/templates/skills/hai-serv/reference.md +18 -2
- package/templates/skills/hai-storage/SKILL.md +3 -1
- package/templates/skills/hai-ui/SKILL.md +6 -0
- package/templates/skills/hai-ui/reference.md +7 -4
- package/templates/skills/hai-vecdb/reference.md +53 -49
- package/dist/chunk-O3ANVICQ.js.map +0 -1
- package/dist/chunk-WUYLYCW2.js +0 -237
- package/dist/chunk-WUYLYCW2.js.map +0 -1
- package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
- package/dist/src-TBIET7YU.js.map +0 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
- package/templates/skills/bridges/admin/CLAUDE.md +0 -11
- package/templates/skills/bridges/api/CLAUDE.md +0 -11
- package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -5
- package/templates/skills/bridges/h5/CLAUDE.md +0 -11
- package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -11
- 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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
# 每个叶子项都可由约定式 HAI 环境变量覆盖,敏感值不要写入文件。
|
|
7
7
|
# =============================================================================
|
|
8
8
|
|
|
9
|
-
name
|
|
9
|
+
{{yaml 'name' fullstack.appName}}
|
|
10
10
|
version: 0.1.0
|
|
11
|
-
env:
|
|
12
|
-
debug:
|
|
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,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:
|
|
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:
|
|
40
|
+
transport:
|
|
41
|
+
keyExchangePath: /_hai/key-exchange
|
|
42
|
+
maxClients: 10000
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"engines": { "node": "
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
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():
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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:
|
|
41
|
-
version:
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
})
|
package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs
ADDED
|
@@ -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
|
+
})
|