diff --git a/admin/src/api/__tests__/config.test.ts b/admin/src/api/__tests__/config.test.ts index f267988..50f05a4 100644 --- a/admin/src/api/__tests__/config.test.ts +++ b/admin/src/api/__tests__/config.test.ts @@ -1,6 +1,6 @@ /** * 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石; - * Task 7c——凭据测试端点) + * Task 7c——凭据测试端点;Task 7d——版本历史/回滚/组级重置) * * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与 * upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http @@ -13,6 +13,12 @@ * - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已 * 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索 * 数据)——两者 showErrorMessage: false 内联展示 + * - GET versions / versions/{n}(Task 7d):列表不含 config 本体;详情含 + * 整份快照(密钥 '__SET__' 掩码视图) + * - POST rollback(Task 7d):body 只带 {version}——目标版本复制为新版本 + * (历史不可变),响应含新版本号 + 新快照 + * - POST config/{group}/reset(Task 7d):无请求体(后端 reset 不读 + * body),响应与 PUT 分组保存同形 * - 头像上传:multipart file 字段(FormData) * - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项 */ @@ -25,9 +31,13 @@ vi.mock('@/api/client', () => ({ import { http } from '@/api/client' import { CONFIG_ENDPOINTS, + fetchConfigVersionDetail, + fetchConfigVersions, fetchTenantConfig, normalizeBrandGroup, putConfigGroup, + resetConfigGroup, + rollbackConfig, testEmail, testPush, testWall, @@ -263,6 +273,121 @@ describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => }) }) +describe('版本历史 / 回滚 / 重置 API(Task 7d · 事实源 config_routes)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchConfigVersions:GET versions 列表(升序元数据,不含 config 本体)', async () => { + const response = { + slug: 'jiancai', + items: [ + { + version: 1, + created_at: '2026-10-12T09:00:00', + updated_by: 'super#1', + comment: '创建向导初始版本' + }, + { + version: 2, + created_at: '2026-10-13T10:30:00', + updated_by: 'tenant#3', + comment: '改主题色' + } + ] + } + vi.mocked(http.get).mockResolvedValue(response) + + const res = await fetchConfigVersions('jiancai', { showErrorMessage: false }) + + expect(res).toEqual(response) + expect(res.items[0]).not.toHaveProperty('config') // 列表行绝无 config 本体 + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/versions', + { showErrorMessage: false } + ) + }) + + it('fetchConfigVersionDetail:GET versions/{n} 详情(整份快照 + 掩码)', async () => { + const response = { + slug: 'jiancai', + version: 2, + created_at: '2026-10-13T10:30:00', + updated_by: 'tenant#3', + comment: '改主题色', + config: { + brand: { title: 'HESUPIPE Support' }, + push: { api_key: '__SET__' } + } + } + vi.mocked(http.get).mockResolvedValue(response) + + const res = await fetchConfigVersionDetail('jiancai', 2) + + expect(res).toEqual(response) + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/versions/2', + undefined + ) + }) + + it('rollbackConfig:POST rollback,body 只带 {version}(note 不被后端读取)', async () => { + const response = { + slug: 'jiancai', + rollback_to: 2, + version: 11, + config: { brand: { title: 'HESUPIPE Support' } } + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await rollbackConfig('jiancai', { version: 2 }, { + showErrorMessage: false + }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/rollback', + { version: 2 }, // 回滚请求体只认 version——多余字段是死代码 + { showErrorMessage: false } + ) + }) + + it('resetConfigGroup:POST config/{group}/reset,无请求体(后端不读 body)', async () => { + const response = { + slug: 'jiancai', + group: 'brand', + version: 12, + config: { title: 'AI Support', color: '#066fc0' } + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await resetConfigGroup('jiancai', 'brand', { showErrorMessage: false }) + + expect(res).toEqual(response) // 响应与 PUT 分组保存同形(新版本 + 组掩码视图) + expect(http.post).toHaveBeenCalledTimes(1) + const [url, body, config] = vi.mocked(http.post).mock.calls[0] + expect(url).toBe('/api/platform/tenants/jiancai/config/brand/reset') + expect(body).toBeUndefined() + expect(config).toEqual({ showErrorMessage: false }) + }) + + it('CONFIG_ENDPOINTS.versions / versionDetail / rollback / resetGroup:路径与后端一致', () => { + expect(CONFIG_ENDPOINTS.versions('jiancai')).toBe( + '/api/platform/tenants/jiancai/config/versions' + ) + expect(CONFIG_ENDPOINTS.versionDetail('jiancai', 7)).toBe( + '/api/platform/tenants/jiancai/config/versions/7' + ) + expect(CONFIG_ENDPOINTS.rollback('jiancai')).toBe( + '/api/platform/tenants/jiancai/config/rollback' + ) + expect(CONFIG_ENDPOINTS.resetGroup('jiancai', 'push')).toBe( + '/api/platform/tenants/jiancai/config/push/reset' + ) + }) +}) + describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => { it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => { const out = normalizeBrandGroup({ title: 'HESUPIPE Support' }) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index 19920bf..d11dd52 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -33,6 +33,20 @@ * - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索 * 数据**(后端发空 messages 批量);不可达时 reason 分类 * dns/timeout/connect,auth_ok 三态(true/false/null=不定)。 + * + * ## Task 7d 版本历史 / 回滚 / 重置契约(config_routes) + * + * - GET versions:升序 items(version/created_at/updated_by/comment), + * **不含 config 本体**(列表几十 KB × N 版本会白白撑爆响应);展示侧 + * 自行倒序(最新在前); + * - GET versions/{n}:含整份快照(掩码视图——密钥字段 '__SET__',历史行 + * 本身永远存原值);created_at 是 naive isoformat(无时区后缀); + * - POST rollback {version}:目标版本整份复制为新版本(历史不可变), + * 响应 {rollback_to, version(新), config(新快照掩码视图)}; + * - POST config/{group}/reset:该组恢复出厂档案值(代码库当前模板,非 + * 创建时快照),响应形态与 PUT 相同({group, version, config}); + * 合法组 = config_validation.CONFIG_GROUPS(RESET_GROUP_LABELS 镜像); + * push 组重置可能因出厂 prefix 被其他租户占用而 400(跨租户查重)。 */ import { http, type RequestOptions } from './client' @@ -78,6 +92,12 @@ export interface PutConfigGroupResponse { config: Record } +/** + * 组级重置响应(POST .../config/{group}/reset):与 PUT 分组保存同形 + * (新版本号 + 该组出厂值的掩码视图——密钥字段 '__SET__')。 + */ +export type ResetConfigGroupResponse = PutConfigGroupResponse + /** 配置版本行(GET .../config/versions;字段名与后端一致) */ export interface ConfigVersionItem { version: number @@ -217,6 +237,72 @@ export function putConfigGroup( ) } +/** + * 版本列表(GET .../config/versions):升序 items(版本/时间/作者/备注), + * 不含 config 本体。展示侧自行倒序(最新在前)——后端列表只回元数据, + * 详情走 fetchConfigVersionDetail 按需取整份。 + */ +export function fetchConfigVersions( + slug: string, + config?: RequestOptions +): Promise { + return http.get( + CONFIG_ENDPOINTS.versions(slug), + config + ) +} + +/** + * 单版本详情(GET .../config/versions/{n}):含整份快照(密钥字段为 + * '__SET__' 掩码视图——历史行存原值但绝不回传)。版本不存在 → 404。 + */ +export function fetchConfigVersionDetail( + slug: string, + version: number, + config?: RequestOptions +): Promise { + return http.get( + CONFIG_ENDPOINTS.versionDetail(slug, version), + config + ) +} + +/** + * 回滚(POST .../config/rollback):目标版本整份复制为新版本(历史不可变 + * ——旧版本行永不改写),reload 热生效。响应含新版本号 + 新快照掩码视图 + * (调用方据此回写当前配置态,无需再 GET config)。 + */ +export function rollbackConfig( + slug: string, + body: ConfigRollbackRequest, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.rollback(slug), + body, + config + ) +} + +/** + * 组级重置出厂(POST .../config/{group}/reset,无请求体):该组恢复出厂 + * 档案值(代码库当前模板,随平台升级演进——非创建时快照),产新版本 + + * reload 热生效。合法组见 model.ts 的 RESET_GROUP_LABELS(后端 + * CONFIG_GROUPS 镜像,未知组 400);push 组重置可能因出厂 prefix 被其他 + * 租户占用而 400(跨租户查重)——调用方 showErrorMessage: false 内联展示。 + */ +export function resetConfigGroup( + slug: string, + group: ConfigGroupKey | string, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.resetGroup(slug, group), + undefined, + config + ) +} + /** * 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 = * 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动 diff --git a/admin/src/views/config/center/CredsPane.vue b/admin/src/views/config/center/CredsPane.vue index 5271650..cdb6704 100644 --- a/admin/src/views/config/center/CredsPane.vue +++ b/admin/src/views/config/center/CredsPane.vue @@ -13,6 +13,9 @@ - 推送连通性探测:POST push/test(后端发空 messages 批量,零线索数据) ——三态展示:不可达(dns/timeout/connect 原因分类)/ 可达但鉴权失败 (401·403)/ 正常(2xx);其余状态码 = 可达但鉴权不定 + - 重置出厂(Task 7d):三卡头部危险色小按钮 → 确认框 → POST + config/{group}/reset → 表单/密钥态回档案值 + emit saved;push 出厂 + 前缀被其他租户占用的 400 detail 内联展示 -->