feat: 配置中心-版本历史(列表/两版对比/回滚/组级重置出厂)

Task 7d——Phase 3 配置中心四连收尾:

- API(api/config.ts):fetchConfigVersions / fetchConfigVersionDetail /
  rollbackConfig / resetConfigGroup 四函数补齐(骨架端点与类型已就位;
  响应形态对照 config_routes 实测——列表无 config 本体、详情整份+掩码、
  reset 响应与 PUT 分组保存同形)
- VersionsPane:版本 tab 实装(导航占位转正)——列表倒序(版本/时间/
  作者/备注,当前版标记)+ 详情抽屉整份 JSON;勾两版(限两个,第三个
  自动撤销最早勾选行)并排等宽 JSON 对比(spec §7.3 最低形态,范围
  下拉可聚焦单组——wall 20KB+ 场景);回滚确认框明示「将产生新版本,
  历史不变」→ 响应内新快照直接回写页头版本/快照/品牌表单,无需再 GET
- 组级重置出厂:六个已实装编辑卡(品牌/提示词/词表/邮件/推送/AI)头部
  危险色小按钮 → reset.ts 共用确认入口(明示组名与「恢复出厂档案值」)
  → POST config/{group}/reset → 表单/密钥态/版本刷新(RESET_GROUP_
  LABELS 九组镜像后端 CONFIG_GROUPS,含待实装的联系方式/引擎/杂项)
- 测试:vitest 120(+13——API 四函数调用形态 5 条 + model 辅助 8 条);
  后端不动 595;冒烟 jiancai:v1-v10 列表 → v1/v10 并排对比(push 组
  __SET__ 掩码可见)→ 回滚 v1 产 v11 且品牌回 v1 值 → 改占位存 v12 →
  重置品牌产 v13 表单回档案值 → 回滚 v10 复原现场(v14)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 21:39:16 +08:00
parent bf923d9cf7
commit 481243b958
10 changed files with 1166 additions and 16 deletions
+126 -1
View File
@@ -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' })
+86
View File
@@ -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<string, unknown>
}
/**
* 组级重置响应(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<ConfigVersionListResponse> {
return http.get<ConfigVersionListResponse>(
CONFIG_ENDPOINTS.versions(slug),
config
)
}
/**
* 单版本详情(GET .../config/versions/{n}):含整份快照(密钥字段为
* '__SET__' 掩码视图——历史行存原值但绝不回传)。版本不存在 → 404。
*/
export function fetchConfigVersionDetail(
slug: string,
version: number,
config?: RequestOptions
): Promise<ConfigVersionDetailResponse> {
return http.get<ConfigVersionDetailResponse>(
CONFIG_ENDPOINTS.versionDetail(slug, version),
config
)
}
/**
* 回滚(POST .../config/rollback):目标版本整份复制为新版本(历史不可变
* ——旧版本行永不改写),reload 热生效。响应含新版本号 + 新快照掩码视图
* (调用方据此回写当前配置态,无需再 GET config)。
*/
export function rollbackConfig(
slug: string,
body: ConfigRollbackRequest,
config?: RequestOptions
): Promise<ConfigRollbackResponse> {
return http.post<ConfigRollbackResponse>(
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<ResetConfigGroupResponse> {
return http.post<ResetConfigGroupResponse>(
CONFIG_ENDPOINTS.resetGroup(slug, group),
undefined,
config
)
}
/**
* 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 =
* 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动