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:
@@ -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' })
|
||||
|
||||
@@ -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 版本)——保存词表前的自动
|
||||
|
||||
Reference in New Issue
Block a user