From 65aad53e12e006f6bf7974c63d57bd9f17365177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 23:29:31 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20Phase=203=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B9=B3=E5=8F=B0=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?+=E5=85=A8=E9=A1=B5=E5=86=92=E7=83=9F+=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9(Phase 3 最后任务): - 平台设置页实装(settings/platform):推荐键表单(ai.* 三键 / smtp.* 四键,键名为前端约定——PlatformSetting 是自由 KV 表)+ 存储总览表格;保存 = 逐键 PUT 只提交脏键(与 config 组整组替换 不同);密钥键哨兵交互与凭据页同款(已设置占位 / 清除 / 恢复 保持 / 端口 1-65535 校验);页头注记 resolver 回退链未接线、 现阶段仅存储。 - api/settings.ts:fetchPlatformSettings / putPlatformSetting 实装 (GET items 列表 / PUT 单键 upsert 形态钉在 docstring)。 - vitest +14(model 10 + api 4):掩码判定后端镜像、归一/脏检测/ 提交值/校验/展示全覆盖;前端基线 139 → 153。 - 全页面浏览器冒烟通过(登录→强制改密→仪表盘双视角→租户管理→ 会话与线索→配置中心全组→统计分析→平台设置→退出→租户管理员 视角菜单收敛)。 - CLAUDE.md:新增 Phase 3 完成状态段(基线 595/153、本地起法、 admin 能力一览、前端约定);Phase 3 清理单结项 7/11/12(掩码 哨兵 / 站点权限 / push enabled 显式化);Phase 4 清理单增补四项 (components.dts 行尾漂移 / 推送探测 env 回退真址 / 生产反代 /api/public / VITE_TENANT_PREVIEW_HOST 单域名)。README 现状 刷新至 Phase 3。 Co-Authored-By: Claude Code --- CLAUDE.md | 46 ++- README.md | 9 +- admin/src/api/__tests__/settings.test.ts | 78 ++++ admin/src/api/settings.ts | 44 ++- .../settings/platform/__tests__/model.test.ts | 149 +++++++ admin/src/views/settings/platform/index.vue | 366 +++++++++++++++++- admin/src/views/settings/platform/model.ts | 265 +++++++++++++ 7 files changed, 933 insertions(+), 24 deletions(-) create mode 100644 admin/src/api/__tests__/settings.test.ts create mode 100644 admin/src/views/settings/platform/__tests__/model.test.ts create mode 100644 admin/src/views/settings/platform/model.ts diff --git a/CLAUDE.md b/CLAUDE.md index b7dc329..539205c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,10 +86,10 @@ 8. **400(预检撞车)vs 409(竞态 IntegrityError)并存**——API 冻结前统一。 9. **limiter 全量清扫**(token/登录限流的内存键永不回访,长跑进程缓慢累积;收口 = 定期清扫)。 10. **lead_push_service 的 settings re-export 锚**(app.engine.config 的 settings 在 lead_push_service 顶部 re-export,22 处测试 monkeypatch 依赖此锚——改动前先迁测试)。 -11. **密钥掩码未实现**——GET config/settings 返回明文 api_key/smtp 密码;Phase 3 前端对接前必须定掩码哨兵方案(GET 吐 `__SET__` + PUT 收哨兵保留旧值)(出处:终审审查 I-2)。 -12. **站点管理权限现为仅超管**(spec §7.3 写租户管理员同左)——Phase 3 对接时决策放开或改 spec(出处:终审审查 M-2)。 +11. ~~密钥掩码未实现~~(Phase 3 收口,commit 0c25287:GET config/settings 四处响应密钥值掩码为哨兵 `__SET__`,PUT 收哨兵保留库值;前端 `admin/src/api/mask.ts` secretToSubmit/secretPlaceholder 同款契约)。 +12. ~~站点管理权限现为仅超管~~(Phase 3 收口,commit 0c25287:放开租户管理员同权,与 spec §7.3 原文一致)。 -**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已在 Phase 2 完成): +**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已结,结项时点见各条注记;未结项滚入 Phase 4 清理单增补): 1. ~~FakeAI 三份拷贝收敛进 conftest~~(Task 10 完成:conftest.py 单一定义,tests/ 零拷贝)。 2. ~~rate_limit 与 ws_conn_limit 的 0 语义统一 + 配置类型校验~~(Task 10 完成)。 @@ -97,19 +97,41 @@ 4. ~~avatar 从 seed_profiles 迁 uploads 卷~~(Task 7 完成:上传 + 三级解析链)。 5. **AIService.from_runtime 类方法**——组装正典化(出处:引擎改造审查)。 6. **boundary_filter 视图常量退役**——admin 重写后旧测试迁移时(出处:引擎改造审查)。 -7. **push enabled 布尔空槽语义**——admin 配置显式化时解决(出处:push 审查)。 +7. ~~push enabled 布尔空槽语义~~(Phase 3 收口:凭据页显式布尔开关 ElSwitch,normalize `enabled === true` 空槽归 false,提交永为显式布尔)。 8. **contact.template_languages 运行期接线**——后台批次(出处:配置 schema 审查)。 9. **话术含引号/换行的转义**——编辑器批次(`_format_deflection_pool` docstring 有注)。 10. **产品决策项**:国名纠正 e164 变化时是否补发更正邮件(上游行为更优);REST/WS 服务端 IP 口径是否统一(出处:上游吸收审查)。 11. **/health 分级健康**——Phase 4 部署批次(进程存活之外按依赖分级上报;出处:终审审查 M-3)。 12. **PromptProvider.from_dir 的 FileNotFoundError 带路径上下文**——提示词目录缺失时异常信息含实际路径(出处:终审审查 M-4)。 -## Phase 3 计划编写注意 +## Phase 3 完成状态(2026-10-13 收尾核对) -- conftest 三坑纪律延续:`async_db` 夹具用 NullPool;TestClient 必须走 portal 桥接事件循环;禁止 `asyncio.run` 直开租户 session_factory(三个坑都在 tests/conftest.py 有既成解法,照抄勿发明)。 -- `build_app` / `app_client` / `TenantTestClient` 夹具已就绪,新测试优先复用而非自建 app 实例;**平台层测试用 `db_source_app` / `super_client` 夹具**(conftest.py:DB 源装配 + 超管登录态客户端,Phase 2 全部平台路由测试的既成姿势)。 -- 引擎侧读配置一律经 **config_views 单入口**(app/platform/config_views.py:resolve_email/push/ai_settings、resolve_contact_flags、context_seed_map、name_to_region_extras)——新消费点不再自写 resolver。 -- 统计前端照 **stats.py docstring 的指标口径表**展示(会话数不要求有消息、线索=三选一非空、推送成功率分母口径、北京日界趋势等,歧义处已定死)。 +**测试基线:后端 pytest 595 passed / 前端 vitest 153 passed**(Phase 3 起步 139,Task 9 平台设置页 +14;两端数字均已实跑核对,后端唯一 warning 仍为 starlette TestClient 弃用提示,无害)。`pnpm build`(vue-tsc --noEmit && vite build)零错误。后续回归以此为准。 + +**交付形态**:`admin/` 子目录的 Vue3 + TS + Vite + Element Plus SPA(art-design-pro 模板,pnpm 管理),与后端同仓。本地起法: + +- 后端:`PYTHONUTF8=1 .venv/Scripts/python -m uvicorn app.main:app --port 8018 --workers 1`(不变)。 +- 前端:`cd admin && pnpm install && pnpm dev`(默认 3006,被占自动顺延);Vite 代理 `/api` → 8018,`/api/public` 与 `/static` 走 Host 改写代理(`VITE_TENANT_PREVIEW_HOST`,见 admin/.env.development 注释)。`pnpm test` 跑 vitest;`pnpm build` 出 dist。 +- 登录:超管 / 租户管理员同一登录页(滑块验证为纯前端交互校验);must_change_password 由后端 409 门强制,前端跳 `#/auth/force-change-password`。 + +**admin 能力一览**(Phase 2 平台 API 的全量对接;菜单按角色过滤,后端 require_super / _tenant_guard 兜底): + +- 认证流:登录 / 强制改密 / 退出;pinia + 内存 token,401 统一跳登录,改密 409 契约锁定。 +- 仪表盘:平台总览 + 租户卡片墙(超管)/ 我的指标(租户管理员),时间窗切换。 +- 租户管理(仅超管):列表 / 创建向导(消费出厂档案)/ 详情编辑 / 站点与域名子资源 / 停用复启。 +- 会话与线索:列表(筛选 / 分页 / 租户切换)/ 详情消息流 / 推送日志 / 删除。 +- 配置中心:品牌(表单 / 本地简化预览 / 嵌入代码 / 头像上传)、提示词四件套与结构化词表、试金石面板、凭据(邮件 / 推送 / AI 表单 + SMTP 测试 + 推送探测)、版本历史(两版对比 / 回滚 / 组级重置出厂)。 +- 统计分析:卡片 / 转化漏斗 / 按日趋势 / 意向·来源·站点·国家分布(ECharts)/ 推送健康表;时间与租户筛选(口径同 stats.py docstring 口径表)。 +- 平台设置(仅超管):自由 KV 表的推荐键表单(ai.* / smtp.*,键名是前端约定)+ 存储总览表格;保存 = 逐键 PUT(只提交脏键)。**当前仅存储与展示——resolver 的平台默认回退链未接线(页头注记),引擎不读取这些值**;密钥键掩码哨兵交互与凭据页同款(清除 / 恢复保持)。 + +**前端实现约定**(后续维护须知): + +- 视图逻辑沉淀在各 `views/**/model.ts` 纯函数(掩码判定 / 归一 / 脏检测 / 提交值构造 / 校验),vitest 直接覆盖;.vue 只做装配。auto-import 生效(ref/reactive/ElMessage 等无需手写 import)。 +- 密钥键判定前端镜像后端 `_SETTING_SECRET_MARKERS` 子串匹配('monkey' 含 'key' 也命中——与后端一致是契约,不"修正")。 +- PUT config 整组替换语义:前端封装读-改-写(GET 全组 → 改 → PUT);平台设置不同,为逐键 upsert。 +- `components.d.ts` 构建漂移:`pnpm build`/dev 会重写入库的 dts(仅行尾差异)——`git diff --ignore-cr-at-eol` 为空即 `git checkout --` 还原入库版(超集原则,勿提交行尾噪音)。 + +**滚动纪律**(自 Phase 3 计划注意延续):conftest 三坑(NullPool / portal 桥接 / 禁 asyncio.run)、平台层测试用 `db_source_app` / `super_client` 夹具、引擎读配置走 config_views 单入口、统计展示照 stats.py 口径表——均照抄勿发明。 ## Phase 4 部署记档(提前锚定,部署批次展开成 docs/deploy-production.md) @@ -118,10 +140,16 @@ - **TENANT_SOURCE=db 默认**(生产装配形态);`file` 为冷备/本地调试回退。 - **admin 域名反代区分**:`/api/platform/*` 前缀豁免租户 Host 解析——平台后台 API 可与租户聊天域名同源反代(按路径分流)或独立域名,两形态皆可;租户聊天面(`/`、`/static`、WS、`/api/public`、`/api/chat*`)仍严格按 Host 解析租户,未知 Host 404。 - **ADMIN_PASSWORD 默认值警示**(终审 I-4):lifespan 启动检测到 ADMIN_PASSWORD 仍是出厂默认(admin123)时打 WARNING 日志——生产部署必须设 ADMIN_PASSWORD 环境变量换掉,且超管首登立即改密(must_change_password 门强制)。 +- **Phase 4 清理单增补**(Phase 3 冒烟沉淀;Phase 3 清理单未结项 5/6/8/9/10 一并滚入): + 1. `components.d.ts` 构建行尾漂移(长期收口 = 生成物出库或 EOL 规范钩子,现阶段靠手工还原)。 + 2. 推送探测 env 回退真址:租户 push URL 空槽时 `resolve_push_settings` 回退 env `PUSH_API_URL`(默认即真 UniService 生产址)——后台点「探测」会对真实服务发空批量 POST(`{"messages": []}`,按契约文档应为无操作,但属真实出站)。收口 = 部署设 stub 域或探测端点拒绝 env 回退 URL。 + 3. 生产反代需规划 admin 域的头像/品牌公开端点:`/api/public/*` 按租户 Host 解析,admin 域直挂必 404——开发期靠 Vite Host 改写代理,生产要么同源反代改 Host(单租户形态)要么前端改用租户域名绝对 URL。 + 4. `VITE_TENANT_PREVIEW_HOST` 单一预览域名:开发代理只指向一个租户 Host,切换租户时头像等公开资源预览不准(.env.development 已注记)。 ## 文档索引 - 设计文档(spec):`docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md` - Phase 1 实施计划:`docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md` - Phase 2 实施计划:`docs/superpowers/plans/2026-10-13-phase2-platform-layer.md` +- Phase 3 实施计划:`docs/superpowers/plans/2026-10-13-phase3-admin-spa.md` - 部署坑手册:Phase 4 补 `docs/deploy-production.md`(尚未存在;Phase 4 部署记档段已锚定要点) diff --git a/README.md b/README.md index 15183dd..ba4b714 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # all-kefu -统一多租户 AI 客服平台:一套引擎 + 租户配置包,消除此前"每客户复制一套代码"的分叉模式。项目由 ai-kefu / ai-kefu-jc 两个复制分叉项目合并而来(jc 为主线),Phase 1 引擎合并与租户化改造、Phase 2 平台层(platform.db + admin API)已完成。 +统一多租户 AI 客服平台:一套引擎 + 租户配置包,消除此前"每客户复制一套代码"的分叉模式。项目由 ai-kefu / ai-kefu-jc 两个复制分叉项目合并而来(jc 为主线),Phase 1 引擎合并与租户化改造、Phase 2 平台层(platform.db + admin API)、Phase 3 管理后台 SPA 均已完成。 -**现状能力**(Phase 2 收尾时点): +**现状能力**(Phase 3 收尾时点): - 多租户引擎:TenantRuntime 隔离边界,engine 包不感知平台层,可整体拎走独立部署。 - 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化;配置存 platform.db 版本快照(分组校验 / 回滚 / 热加载),seed_profiles 为出厂档案与测试形态。 - Host 路由:按域名解析租户,未知 Host 404;WS 通道租户化客户端 key 防串库,另有限流与每 IP 连接上限。 - 引擎行为:cc 区号补全判定树、多语言墙(中/西/葡借道/俄/阿)、dual-contact 分步双要、CC 强制墙、lead 邮件推送。 -- 平台层(Phase 2):scrypt 认证 + 内存 token + 登录限流;租户管理(创建向导 / 站点 / 域名 / 停用);配置版本管线(整组替换 / 回滚 / 重置出厂 / 试金石);头像上传(魔数校验 + uuid 重命名);数据视图(会话 / 推送日志 / 删除,租户 scope 强制);统计(卡片 / 漏斗 / 趋势 / 分布 / 推送健康,逐库聚合禁 ATTACH);DB 源生产装配(TENANT_SOURCE=db 默认 + bootstrap_platform.py 引导)。admin API 全在 `/api/platform` 前缀(Host 豁免、路由依赖守护),端点清单见 [CLAUDE.md](CLAUDE.md)。 +- 平台层:scrypt 认证 + 内存 token + 登录限流;租户管理(创建向导 / 站点 / 域名 / 停用);配置版本管线(整组替换 / 回滚 / 重置出厂 / 试金石);头像上传(魔数校验 + uuid 重命名);数据视图(会话 / 推送日志 / 删除,租户 scope 强制);统计(卡片 / 漏斗 / 趋势 / 分布 / 推送健康,逐库聚合禁 ATTACH);密钥掩码哨兵(`__SET__` 往返);DB 源生产装配(TENANT_SOURCE=db 默认 + bootstrap_platform.py 引导)。admin API 全在 `/api/platform` 前缀(Host 豁免、路由依赖守护),端点清单见 [CLAUDE.md](CLAUDE.md)。 +- 管理后台 SPA(Phase 3,`admin/` 目录,Vue3 + Element Plus + pnpm):登录 / 强制改密 / 角色化菜单(超管 vs 租户管理员);仪表盘(平台总览 + 租户卡片墙 / 我的指标);租户管理与站点域名;会话与线索(筛选 / 详情消息流 / 推送日志 / 删除);配置中心(品牌 + 本地预览 + 嵌入代码 + 头像上传 / 提示词词表 / 试金石 / 凭据掩码编辑 + SMTP 测试 + 推送探测 / 版本对比回滚重置);统计分析(ECharts 漏斗 / 趋势 / 分布 / 推送健康);平台设置(ai.* / smtp.* 推荐键 KV 表单 + 存储总览,现阶段仅存储不接引擎回退链)。 -**本地跑法与工作约定**:见 [CLAUDE.md](CLAUDE.md)(含 uvicorn 启动命令、Host 访问方式、Windows PYTHONUTF8 要求、全量回归基线 566)。 +**本地跑法**:后端 `PYTHONUTF8=1 .venv/Scripts/python -m uvicorn app.main:app --port 8018 --workers 1`;管理后台 `cd admin && pnpm install && pnpm dev`(Vite 代理 /api → 8018)。工作约定与测试基线(后端 pytest 595 / 前端 vitest 153)见 [CLAUDE.md](CLAUDE.md)。 设计文档(spec):[docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md](docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md) diff --git a/admin/src/api/__tests__/settings.test.ts b/admin/src/api/__tests__/settings.test.ts new file mode 100644 index 0000000..c322fac --- /dev/null +++ b/admin/src/api/__tests__/settings.test.ts @@ -0,0 +1,78 @@ +/** + * 平台设置 API 单元测试(Phase 3 Task 9) + * + * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 的 + * settings_router):GET 列表 {items:[{key,value}]} / PUT 单键 upsert + * {key,value}——与 config 组的"整组替换"不同,KV 表逐键写。 + * HTTP 层行为已在 client.test.ts 覆盖,此处 mock http 只断言调用形态。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), put: vi.fn() } +})) + +import { http } from '@/api/client' +import { + SETTINGS_ENDPOINTS, + fetchPlatformSettings, + putPlatformSetting +} from '@/api/settings' + +describe('平台设置 API(src/api/settings.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchPlatformSettings:GET /api/platform/settings(超管端点)', async () => { + const body = { + items: [ + { key: 'ai.api_key', value: '__SET__' }, + { key: 'ai.model', value: 'glm-4-flash' } + ] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchPlatformSettings() + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, undefined) + }) + + it('fetchPlatformSettings:透传 RequestOptions(内联错误展示)', async () => { + vi.mocked(http.get).mockResolvedValue({ items: [] }) + await fetchPlatformSettings({ showErrorMessage: false }) + expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, { + showErrorMessage: false + }) + }) + + it('putPlatformSetting:PUT 单键 upsert,请求体 {key, value}', async () => { + const response = { key: 'smtp.host', value: 'smtp.exmail.qq.com' } + vi.mocked(http.put).mockResolvedValue(response) + + const res = await putPlatformSetting( + { key: 'smtp.host', value: 'smtp.exmail.qq.com' }, + { showErrorMessage: false } + ) + + expect(res).toEqual(response) + expect(http.put).toHaveBeenCalledWith( + SETTINGS_ENDPOINTS.put, + { key: 'smtp.host', value: 'smtp.exmail.qq.com' }, + { showErrorMessage: false } + ) + }) + + it('putPlatformSetting:密钥哨兵原样提交(后端还原库值)', async () => { + vi.mocked(http.put).mockResolvedValue({ key: 'ai.api_key', value: '__SET__' }) + + await putPlatformSetting({ key: 'ai.api_key', value: '__SET__' }) + + expect(http.put).toHaveBeenCalledWith( + SETTINGS_ENDPOINTS.put, + { key: 'ai.api_key', value: '__SET__' }, + undefined + ) + }) +}) diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts index b6f76dd..625705b 100644 --- a/admin/src/api/settings.ts +++ b/admin/src/api/settings.ts @@ -1,15 +1,26 @@ /** - * 平台设置 API 骨架(Phase 3 Task 3) + * 平台设置 API(Phase 3 Task 3 骨架;Task 9 实装调用函数) * * 端点事实源:app/platform/routes/config_routes.py 的 settings_router * (/api/platform/settings,仅超管:集团 AI 凭据 / 默认 SMTP 等)。 - * 本文件只建类型与端点常量——调用函数在 Task 9(平台设置)实装。 * - * 密钥掩码:key 名含 key/password/secret 的非空字符串值在列表响应中 - * 掩码为 '__SET__'(嵌套 dict 递归两层);PUT 提交 '__SET__' = 保持库值。 + * 形态(GET 列表 / PUT 单键 upsert——与 config 组的"整组替换"不同, + * 平台设置是 KV 表,逐键写): + * - GET → {items: [{key, value}]}(按 key 升序;value 任意 JSON—— + * 后端 Text 列 json.dumps 直存,读回还原类型) + * - PUT → body {key, value}:同 key 再写 = 整值替换;响应 {key, value} + * (掩码视图) + * + * 密钥掩码:key 名含 key/password/secret 的非空字符串值在响应中 + * 掩码为 '__SET__'(嵌套 dict 递归);PUT 提交 '__SET__' = 保持库值。 * 表单语义转换见 src/api/mask.ts。 + * + * 注意(CLAUDE.md Phase 3 取舍):resolver 的平台默认回退链**尚未**接入 + * platform_settings——本端点现阶段只做存储与展示,引擎不读取这些值。 */ +import { http, type RequestOptions } from './client' + /** 平台设置项(列表响应行;value 可能是掩码哨兵或任意 JSON) */ export interface PlatformSettingItem { key: string @@ -32,3 +43,28 @@ export const SETTINGS_ENDPOINTS = { list: '/api/platform/settings', put: '/api/platform/settings' } as const + +/** + * 平台设置列表(GET /api/platform/settings,仅超管):按 key 升序的 + * 全量 KV。value 为掩码视图——密钥键(key 名含 key/password/secret) + * 的非空字符串值已是 '__SET__' 哨兵,调用方经 model 层的 normalize + * 转表单态、经 formatSettingValue 做展示,绝不久存原文。 + */ +export function fetchPlatformSettings( + config?: RequestOptions +): Promise { + return http.get(SETTINGS_ENDPOINTS.list, config) +} + +/** + * upsert 单个设置项(PUT /api/platform/settings):同 key 再写 = 整值 + * 替换(无部分合并)。密钥键提交 '__SET__' 哨兵 = 保持库值(后端还原, + * 哨兵字面量绝不落库)。响应 {key, value} 为掩码视图。 + * 本页按"仅提交脏键"调用——未改动 的键不发请求。 + */ +export function putPlatformSetting( + body: PutPlatformSettingRequest, + config?: RequestOptions +): Promise { + return http.put(SETTINGS_ENDPOINTS.put, body, config) +} diff --git a/admin/src/views/settings/platform/__tests__/model.test.ts b/admin/src/views/settings/platform/__tests__/model.test.ts new file mode 100644 index 0000000..b1b14a1 --- /dev/null +++ b/admin/src/views/settings/platform/__tests__/model.test.ts @@ -0,0 +1,149 @@ +/** + * 平台设置页模型测试(Phase 3 Task 9) + * + * 钉住 model.ts 与后端 config_routes settings_router 的契约: + * - 密钥判定镜像 _SETTING_SECRET_MARKERS(子串匹配 + 大小写不敏感—— + * 'monkey' 命中 'key' 是与后端一致的行为,不"修正"); + * - normalize:GET items → 表单(密钥输入恒空 + 掩码态单列;数值 → + * 字符串;缺键补空); + * - 脏检测:密钥键输入新值 / 显式清除才脏(不动不发请求);普通键 + * trim 后比对; + * - 提交值:密钥走 secretToSubmit(新值 / 空串清除 / 哨兵保持);int + * 键空串 → null、合法整数 → 数值; + * - 展示:哨兵与密钥键非空串一律脱敏文案(后端掩码失效也不回显原文)。 + */ +import { describe, expect, it } from 'vitest' + +import { MASKED_SENTINEL } from '@/api/mask' +import { + AI_SETTING_FIELDS, + SMTP_SETTING_FIELDS, + ALL_SETTING_FIELDS, + buildSettingValue, + formatSettingValue, + isFieldDirty, + isRecommendedKey, + isSecretSettingKey, + normalizeSettingsItems, + validateSettingsForm +} from '../model' + +const AI_KEY = AI_SETTING_FIELDS.find((f) => f.key === 'ai.api_key')! +const SMTP_PASSWORD = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.password')! +const SMTP_PORT = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.port')! +const BASE_URL = AI_SETTING_FIELDS.find((f) => f.key === 'ai.base_url')! + +describe('平台设置模型(settings/platform/model.ts)', () => { + // ---- 密钥判定(后端镜像) ---------------------------------------------------------- + + it('isSecretSettingKey:key/password/secret 片段命中(子串 + 大小写不敏感)', () => { + expect(isSecretSettingKey('ai.api_key')).toBe(true) + expect(isSecretSettingKey('smtp.password')).toBe(true) + expect(isSecretSettingKey('client_secret')).toBe(true) + expect(isSecretSettingKey('API-KEY')).toBe(true) + // 子串匹配的后端同款行为:monkey 含 key 也命中(契约一致,不修正) + expect(isSecretSettingKey('monkey')).toBe(true) + expect(isSecretSettingKey('smtp.host')).toBe(false) + expect(isSecretSettingKey('ai.model')).toBe(false) + }) + + // ---- normalize --------------------------------------------------------------------- + + it('normalizeSettingsItems:密钥键输入恒空 + 掩码态单列;数值转字符串;缺键补空', () => { + const state = normalizeSettingsItems([ + { key: 'ai.api_key', value: MASKED_SENTINEL }, + { key: 'ai.base_url', value: 'https://open.bigmodel.cn/api/paas/v4' }, + { key: 'smtp.port', value: 465 }, + { key: 'smtp.password', value: '' }, + { key: 'other.custom', value: { nested: true } } + ]) + expect(state.form['ai.api_key']).toBe('') + expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL) + expect(state.form['ai.base_url']).toBe('https://open.bigmodel.cn/api/paas/v4') + expect(state.form['smtp.port']).toBe('465') + // 空串密码 = 未设置(后端哨兵只掩非空) + expect(state.masked['smtp.password']).toBe('') + expect(state.form['ai.model']).toBe('') + expect(state.form['smtp.host']).toBe('') + expect(state.items).toHaveLength(5) + }) + + it('normalizeSettingsItems:后端掩码失效漏出原文 → 仍按哨兵处理(保守策略)', () => { + const state = normalizeSettingsItems([ + { key: 'ai.api_key', value: 'sk-leaked-plaintext' } + ]) + expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL) + expect(state.form['ai.api_key']).toBe('') + }) + + // ---- 脏检测 ------------------------------------------------------------------------- + + it('isFieldDirty:密钥键——输入新值 / 显式清除才脏,不动不提交', () => { + const maskedSet = MASKED_SENTINEL + // 未动:输入空 + 有效掩码 = 原掩码 → 不脏 + expect(isFieldDirty(AI_KEY, '', maskedSet, maskedSet, '')).toBe(false) + // 输入新值 → 脏 + expect(isFieldDirty(AI_KEY, 'sk-new', maskedSet, maskedSet, '')).toBe(true) + // 显式清除(有效掩码被置 '',原已设置)→ 脏(提交空串) + expect(isFieldDirty(AI_KEY, '', '', maskedSet, '')).toBe(true) + // 库中未设置 + 输入空 → 不脏(不发无意义的空 PUT) + expect(isFieldDirty(AI_KEY, '', '', '', '')).toBe(false) + }) + + it('isFieldDirty:普通键 trim 后比对(两端空白视为未改)', () => { + expect(isFieldDirty(BASE_URL, 'https://x.cn', '', '', 'https://x.cn')).toBe(false) + expect(isFieldDirty(BASE_URL, ' https://x.cn ', '', '', 'https://x.cn')).toBe(false) + expect(isFieldDirty(BASE_URL, 'https://y.cn', '', '', 'https://x.cn')).toBe(true) + expect(isFieldDirty(BASE_URL, '', '', '', 'https://x.cn')).toBe(true) + }) + + // ---- 提交值 ------------------------------------------------------------------------- + + it('buildSettingValue:密钥键走 secretToSubmit(新值 / 清除空串 / 哨兵保持)', () => { + expect(buildSettingValue(AI_KEY, 'sk-new', MASKED_SENTINEL)).toBe('sk-new') + expect(buildSettingValue(AI_KEY, '', '')).toBe('') + // 未动但被强制提交 → 哨兵(后端还原库值) + expect(buildSettingValue(AI_KEY, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL) + expect(buildSettingValue(SMTP_PASSWORD, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL) + }) + + it('buildSettingValue:int 键空串 → null、合法整数 → 数值、其余 trim 字符串', () => { + expect(buildSettingValue(SMTP_PORT, '', '')).toBeNull() + expect(buildSettingValue(SMTP_PORT, ' 465 ', '')).toBe(465) + // 非整数被 validate 前置拦截;此处字符串透传兜底(后端 value 任意 JSON) + expect(buildSettingValue(SMTP_PORT, 'abc', '')).toBe('abc') + expect(buildSettingValue(BASE_URL, ' https://x.cn ', '')).toBe('https://x.cn') + }) + + // ---- 校验 --------------------------------------------------------------------------- + + it('validateSettingsForm:端口 1-65535 整数(空槽合法)', () => { + const form = { 'smtp.port': '465' } + expect(validateSettingsForm(SMTP_SETTING_FIELDS, form)).toEqual([]) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '' })).toEqual([]) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '0' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '70000' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': 'abc' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '4.5' })).toHaveLength(1) + }) + + // ---- 展示 --------------------------------------------------------------------------- + + it('formatSettingValue:哨兵 / 密钥键非空串 → 脱敏文案;其余原样或 JSON', () => { + expect(formatSettingValue('ai.api_key', MASKED_SENTINEL)).toBe('已设置(值已脱敏)') + expect(formatSettingValue('smtp.password', 'raw-secret')).toBe('已设置(值已脱敏)') + expect(formatSettingValue('smtp.host', 'smtp.qq.com')).toBe('smtp.qq.com') + expect(formatSettingValue('smtp.host', '')).toBe('""') + expect(formatSettingValue('x.flag', null)).toBe('null') + expect(formatSettingValue('x.flag', undefined)).toBe('—') + expect(formatSettingValue('x.flag', { a: 1 })).toBe('{"a":1}') + expect(formatSettingValue('x.list', [1, 2])).toBe('[1,2]') + }) + + it('推荐键集合:ai.* 三键 + smtp.* 四键', () => { + expect(ALL_SETTING_FIELDS).toHaveLength(7) + expect(isRecommendedKey('ai.api_key')).toBe(true) + expect(isRecommendedKey('smtp.port')).toBe(true) + expect(isRecommendedKey('other.custom')).toBe(false) + }) +}) diff --git a/admin/src/views/settings/platform/index.vue b/admin/src/views/settings/platform/index.vue index 6268b9c..73be125 100644 --- a/admin/src/views/settings/platform/index.vue +++ b/admin/src/views/settings/platform/index.vue @@ -1,25 +1,377 @@ - + diff --git a/admin/src/views/settings/platform/model.ts b/admin/src/views/settings/platform/model.ts new file mode 100644 index 0000000..15afc41 --- /dev/null +++ b/admin/src/views/settings/platform/model.ts @@ -0,0 +1,265 @@ +/** + * 平台设置页模型(Phase 3 Task 9) + * + * 消费方:settings/platform/index.vue。纯逻辑无 DOM 依赖,vitest 直接 + * 覆盖(__tests__/model.test.ts)。 + * + * 事实源对齐: + * - 后端 app/platform/routes/config_routes.py 的 settings_router: + * PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON), + * 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*) + * 是前端约定,不是后端 schema; + * - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含 + * key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中, + * 与后端行为一致是契约,不"修正"); + * - GET 响应的密钥值已掩码为 '__SET__'(哨兵);PUT 提交哨兵 = 保持 + * 库值(secretToSubmit,见 src/api/mask.ts)。 + * + * 保存语义(与 config 组的"整组替换"不同):逐键 upsert——只 PUT 脏键 + * (改了值的普通键 / 输入了新值的密钥键 / 显式清除的密钥键),未改动 + * 的键不发请求。 + */ + +import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask' +import type { PlatformSettingItem } from '@/api/settings' + +// ---- 密钥判定(后端 _is_secret_setting_key 镜像) ---------------------------------- + +const SECRET_MARKERS = ['key', 'password', 'secret'] as const + +/** key 名是否命中密钥片段(大小写不敏感子串匹配,与后端同款) */ +export function isSecretSettingKey(key: string): boolean { + const lowered = key.toLowerCase() + return SECRET_MARKERS.some((marker) => lowered.includes(marker)) +} + +/** + * 任意非空字符串 → 掩码哨兵。比 === MASKED_SENTINEL 更保守:即使后端 + * 掩码失效漏出原始密钥(理论不可达),也按「已设置」处理——展示层绝不 + * 回显原文,提交层靠"不动不提交"天然不回传。与 config/center/model.ts + * 的 asSecretMasked 同策略(跨视图不互相引,双胞胎注记)。 + */ +export function asSecretMasked(value: unknown): string { + return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : '' +} + +// ---- 推荐键元数据 ------------------------------------------------------------------ + +/** 推荐键字段元数据(secret = 密钥哨兵交互;int = 整数规范化提交) */ +export interface SettingFieldMeta { + key: string + label: string + placeholder: string + secret?: boolean + kind?: 'int' +} + +/** 集团 AI 凭据(租户 ai 组空槽的回退源——回退链接线归后续版本) */ +export const AI_SETTING_FIELDS: SettingFieldMeta[] = [ + { + key: 'ai.api_key', + label: 'API Key', + placeholder: '集团统一密钥(如智谱 sk-…)', + secret: true + }, + { + key: 'ai.base_url', + label: 'Base URL', + placeholder: 'https://open.bigmodel.cn/api/paas/v4' + }, + { + key: 'ai.model', + label: '模型', + placeholder: '如 glm-4-flash' + } +] + +/** 默认 SMTP(租户 email 组空槽的回退源——回退链接线归后续版本) */ +export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [ + { + key: 'smtp.host', + label: 'SMTP 服务器', + placeholder: '如 smtp.exmail.qq.com' + }, + { + key: 'smtp.port', + label: 'SMTP 端口', + placeholder: '如 465(1-65535 整数)', + kind: 'int' + }, + { + key: 'smtp.user', + label: 'SMTP 账号', + placeholder: '发件邮箱账号' + }, + { + key: 'smtp.password', + label: 'SMTP 密码', + placeholder: '密码或授权码', + secret: true + } +] + +/** 表单分组(页卡顺序) */ +export const SETTING_FIELD_GROUPS: Array<{ + id: 'ai' | 'smtp' + title: string + description: string + fields: SettingFieldMeta[] +}> = [ + { + id: 'ai', + title: '集团 AI 凭据', + description: '租户 ai 组空槽的回退源(推荐键名 ai.*——键名自由,本页约定)', + fields: AI_SETTING_FIELDS + }, + { + id: 'smtp', + title: '默认 SMTP', + description: '租户 email 组空槽的回退源(推荐键名 smtp.*——键名自由,本页约定)', + fields: SMTP_SETTING_FIELDS + } +] + +/** 全部推荐键(顺序展开) */ +export const ALL_SETTING_FIELDS: SettingFieldMeta[] = + SETTING_FIELD_GROUPS.flatMap((group) => group.fields) + +// ---- 归一 / 脏检测 / 提交值 --------------------------------------------------------- + +/** 表单态:推荐键 → 字符串输入(密钥输入恒空——「已设置」由占位符表达) */ +export type PlatformSettingsForm = Record + +/** GET items → 表单 + 密钥掩码态 + 原始 items(表格展示) */ +export interface PlatformSettingsState { + form: PlatformSettingsForm + /** 密钥键 → GET 掩码态(哨兵 = 库中已设置;'' = 未设置) */ + masked: Record + items: PlatformSettingItem[] +} + +/** 存储值 → 表单串(字符串原样 / 有限数值 String / 缺省空 / 其余 JSON 展示) */ +function valueToForm(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (value === null || value === undefined) return '' + try { + return JSON.stringify(value) ?? '' + } catch { + return '' + } +} + +/** GET items → 表单态(缺键补空;密钥键输入恒空 + 掩码态单列) */ +export function normalizeSettingsItems( + items: PlatformSettingItem[] +): PlatformSettingsState { + const byKey = new Map(items.map((item) => [item.key, item.value])) + const form: PlatformSettingsForm = {} + const masked: Record = {} + for (const field of ALL_SETTING_FIELDS) { + const value = byKey.get(field.key) + if (field.secret) { + form[field.key] = '' + masked[field.key] = asSecretMasked(value) + } else { + form[field.key] = valueToForm(value) + } + } + return { form, masked, items } +} + +/** + * 字段是否需要提交(脏检测): + * - 密钥键:输入了新值 → 脏;库中已设置(原掩码态哨兵)且显式清除 + * (有效掩码态被置 '')→ 脏(提交空串 = 清空);其余不脏不发请求; + * - 普通键:trim 后与归一表单值不同 → 脏(trim 归一:' 465 ' → '465')。 + * + * @param field 字段元数据 + * @param input 当前输入 + * @param effectiveMasked 有效掩码态(清除按钮按下时为 '',否则原掩码态) + * @param originalMasked GET 时的原掩码态(密钥键脏检测用) + * @param originalForm GET 归一出的表单值(普通键脏检测用) + */ +export function isFieldDirty( + field: SettingFieldMeta, + input: string, + effectiveMasked: string, + originalMasked: string, + originalForm: string +): boolean { + if (field.secret) { + if (input.trim() !== '') return true + return originalMasked === MASKED_SENTINEL && effectiveMasked === '' + } + return input.trim() !== originalForm.trim() +} + +/** + * 脏字段的提交值: + * - 密钥键走 secretToSubmit(新值 → 新值;显式清除 → 空串;哨兵 → 保持 + * 库值——"仅提交脏键"下单键 PUT 通常用不到哨兵分支,保留以对齐凭据 + * 页往返契约); + * - int 键:空串 → null(未设置);合法整数 → 数值(后端 json.dumps 还原 + * 类型);非法输入被 validateSettingsForm 前置拦截,此处字符串透传兜底; + * - 其余:trim 后字符串。 + */ +export function buildSettingValue( + field: SettingFieldMeta, + input: string, + effectiveMasked: string +): unknown { + if (field.secret) { + return secretToSubmit(input.trim(), effectiveMasked) + } + const text = input.trim() + if (field.kind === 'int') { + if (text === '') return null + const n = Number(text) + return Number.isInteger(n) ? n : text + } + return text +} + +/** 表单预检(只查非空输入的格式;空槽 = 未设置合法) */ +export function validateSettingsForm( + fields: SettingFieldMeta[], + form: PlatformSettingsForm +): string[] { + const errors: string[] = [] + for (const field of fields) { + if (field.kind !== 'int') continue + const text = (form[field.key] ?? '').trim() + if (text === '') continue + const n = Number(text) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + errors.push(`${field.label}(${field.key}):须为 1-65535 的整数(留空 = 未设置)`) + } + } + return errors +} + +// ---- 存储总览表格展示 --------------------------------------------------------------- + +/** 表格展示值:哨兵 / 密钥键非空串 → 脱敏文案;其余标量与 JSON 原样 */ +export function formatSettingValue(key: string, value: unknown): string { + if ( + value === MASKED_SENTINEL || + (isSecretSettingKey(key) && typeof value === 'string' && value !== '') + ) { + return '已设置(值已脱敏)' + } + if (value === null) return 'null' + if (value === undefined) return '—' + if (typeof value === 'string') return value === '' ? '""' : value + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +/** 键是否属于本页推荐键集合(表格区分"可编辑键 / 其他已存键") */ +export function isRecommendedKey(key: string): boolean { + return ALL_SETTING_FIELDS.some((field) => field.key === key) +}