feat: 配置中心-品牌(表单/本地预览/嵌入代码/头像上传)+ 站点覆盖层编辑入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 19:27:08 +08:00
parent 6f22e51908
commit cac91e9e3e
7 changed files with 1660 additions and 127 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关)
*
* 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与
* upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http
* 只断言调用形态:
* - GET config:快照 + version(原样组——缺键防御读取靠 normalizeBrandGroup)
* - PUT config/brand:请求体是**组值本身**(整组替换语义,非 {note,value}
* 包装),备注走 comment 查询参数,无备注时不带 params
* - 头像上传:multipart file 字段(FormData)
* - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/client', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() }
}))
import { http } from '@/api/client'
import {
CONFIG_ENDPOINTS,
fetchTenantConfig,
normalizeBrandGroup,
putConfigGroup,
uploadBrandAvatar
} from '@/api/config'
describe('配置中心 API(src/api/config.ts)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchTenantConfig:GET /api/platform/tenants/{slug}/config', async () => {
const body = {
slug: 'jiancai',
version: 3,
config: {
brand: { title: 'HESUPIPE Support', color: '#066fc0', topics: ['Get Quotation'] }
}
}
vi.mocked(http.get).mockResolvedValue(body)
const res = await fetchTenantConfig('jiancai')
expect(res).toEqual(body)
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/config', undefined)
})
it('putConfigGroup:PUT 请求体为组值本身 + comment 查询参数(整组替换契约)', async () => {
const brand = {
title: 'HESUPIPE Support',
color: '#ff6600',
avatar: 'abc123.png',
welcome: 'Hello',
placeholder: 'Ask me',
whatsapp: '+8615937108317',
email: 'hesu@hesuu.com',
privacy_url: 'https://hesupipe.com/privacy-policy',
topics: ['Get Quotation', 'Delivery & Shipping']
}
const response = { slug: 'jiancai', group: 'brand', version: 4, config: brand }
vi.mocked(http.put).mockResolvedValue(response)
const res = await putConfigGroup('jiancai', 'brand', { ...brand }, '改主题色')
expect(res).toEqual(response)
expect(http.put).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/config/brand',
brand, // 组值直传——绝不能包成 { note, value }(后端 _json_body 整组校验)
{ params: { comment: '改主题色' } }
)
})
it('putConfigGroup:无备注时不带 params(comment 是可选项)', async () => {
vi.mocked(http.put).mockResolvedValue({
slug: 'jiancai',
group: 'brand',
version: 5,
config: {}
})
await putConfigGroup('jiancai', 'brand', { title: 'T' })
expect(http.put).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/config/brand',
{ title: 'T' },
{ params: undefined }
)
})
it('uploadBrandAvatar:POST multipart(FormData file 字段)', async () => {
const response = { slug: 'jiancai', avatar: '7f3c9d.png', version: 6 }
vi.mocked(http.post).mockResolvedValue(response)
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'logo.png', {
type: 'image/png'
})
const res = await uploadBrandAvatar('jiancai', file, { showErrorMessage: false })
expect(res).toEqual(response)
expect(http.post).toHaveBeenCalledTimes(1)
const [url, body, config] = vi.mocked(http.post).mock.calls[0]
expect(url).toBe('/api/platform/tenants/jiancai/brand/avatar')
expect(body).toBeInstanceOf(FormData)
expect((body as FormData).get('file')).toBe(file)
expect(config).toEqual({ showErrorMessage: false })
})
it('CONFIG_ENDPOINTS.avatarUpload:端点路径与后端 upload_routes 一致', () => {
expect(CONFIG_ENDPOINTS.avatarUpload('jiancai')).toBe(
'/api/platform/tenants/jiancai/brand/avatar'
)
expect(CONFIG_ENDPOINTS.putGroup('jiancai', 'brand')).toBe(
'/api/platform/tenants/jiancai/config/brand'
)
})
})
describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => {
it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => {
const out = normalizeBrandGroup({ title: 'HESUPIPE Support' })
expect(out).toEqual({
title: 'HESUPIPE Support',
color: '',
avatar: '',
welcome: '',
placeholder: '',
whatsapp: '',
email: '',
privacy_url: '',
topics: []
})
})
it('类型漂移防御:非字符串字段读作空串、topics 只留字符串项', () => {
const out = normalizeBrandGroup({
title: 123,
color: null,
topics: ['Get Quotation', 42, null, 'Delivery'],
welcome: true
})
expect(out.title).toBe('')
expect(out.color).toBe('')
expect(out.welcome).toBe('')
expect(out.topics).toEqual(['Get Quotation', 'Delivery'])
})
it('完整组原样保留(正常路径)', () => {
const full = {
title: 'HESUPIPE Support',
color: '#066fc0',
avatar: '/static/kefu-small.png',
welcome: 'What kind of piping project?',
placeholder: 'Ask about pipes...',
whatsapp: '+8615937108317',
email: 'hesu@hesuu.com',
privacy_url: 'https://hesupipe.com/privacy-policy',
topics: ['Get Quotation']
}
expect(normalizeBrandGroup(full)).toEqual(full)
})
it('null / undefined 组值 → 全空表单值', () => {
expect(normalizeBrandGroup(null).title).toBe('')
expect(normalizeBrandGroup(undefined).topics).toEqual([])
})
})
+157 -18
View File
@@ -1,16 +1,49 @@
/**
* 租户配置中心 API 骨架(Phase 3 Task 3)
* 租户配置中心 API(Phase 3 Task 3 骨架;Task 7a 实装品牌相关调用函数)
*
* 端点事实源:app/platform/routes/config_routes.py
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)。
* 本文件只建类型与端点常量——调用函数在 Task 7(配置中心四件)实装。
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)
* 与 app/platform/routes/upload_routes.py(头像上传)。
*
* 密钥掩码哨兵:GET 的密钥字段非空时为 '__SET__'(见 src/api/mask.ts
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。品牌组无密钥字段,
* 无哨兵处理需求。
*
* ## Task 7a 契约修正(对齐后端事实源)
*
* - PUT /config/{group} 的请求体是**组值本身**(整组替换语义),保存备注
* 走查询参数 comment(config_routes.put_config_group 的 comment 形参);
* - 版本行字段是 updated_by / comment(非骨架期误写的 created_by / note);
* - 回滚请求体只认 version(note 不被后端读取);
* - 试金石响应逐句为 {sentence, action, reason, replacement}。
*/
/** 配置组名(快照顶层键) */
export type ConfigGroupKey = 'brand' | 'prompts' | 'wall' | 'push' | 'email' | 'ai'
import { http, type RequestOptions } from './client'
/** 配置组名(快照顶层键;与 config_validation.CONFIG_GROUPS 对齐) */
export type ConfigGroupKey =
| 'brand'
| 'prompts'
| 'wall'
| 'contact'
| 'email'
| 'push'
| 'ai'
| 'engine'
| 'misc'
/** 品牌组值(整组替换语义:字段清单固定,topics 为字符串数组) */
export interface BrandGroupValue {
title: string
color: string
avatar: string
welcome: string
topics: string[]
placeholder: string
whatsapp: string
email: string
privacy_url: string
}
/** 当前生效配置响应(GET /api/platform/tenants/{slug}/config) */
export interface TenantConfigResponse {
@@ -20,12 +53,20 @@ export interface TenantConfigResponse {
config: Record<string, Record<string, unknown>>
}
/** 配置版本行(GET .../config/versions) */
/** 分组保存响应(PUT .../config/{group};config 为该组掩码视图) */
export interface PutConfigGroupResponse {
slug: string
group: string
version: number
config: Record<string, unknown>
}
/** 配置版本行(GET .../config/versions;字段名与后端一致) */
export interface ConfigVersionItem {
version: number
created_at: string
created_by: string
note: string
updated_by: string
comment: string
}
/** 版本列表响应 */
@@ -40,27 +81,43 @@ export interface ConfigVersionDetailResponse extends ConfigVersionItem {
config: Record<string, Record<string, unknown>>
}
/** 回滚请求(POST .../config/rollback,产生新版本) */
/** 回滚请求(POST .../config/rollback,产生新版本;后端只读 version) */
export interface ConfigRollbackRequest {
version: number
note: string
}
/** 分组保存请求(PUT .../config/{group}) */
export interface PutConfigGroupRequest {
note: string
value: Record<string, unknown>
/** 回滚响应 */
export interface ConfigRollbackResponse {
slug: string
rollback_to: number
version: number
config: Record<string, Record<string, unknown>>
}
/** 词表试金石请求(POST .../wall/test) */
export interface WallTestRequest {
sentences: string[]
has_contact?: boolean
source?: 'web' | 'app'
}
/** 词表试金石响应(逐句判定) */
/** 词表试金石响应(逐句判定;action: blocked/replaced/pass) */
export interface WallTestResponse {
results: Array<{ sentence: string; blocked: boolean; rule?: string }>
slug: string
wall_source: 'current' | 'override'
results: Array<{
sentence: string
action: 'blocked' | 'replaced' | 'pass'
reason: 'boundary' | 'no_contact' | 'reask' | null
replacement: string | null
}>
}
/** 头像上传响应(POST .../brand/avatar;avatar 为落盘文件名) */
export interface AvatarUploadResponse {
slug: string
avatar: string
version: number
}
/** 配置中心端点 */
@@ -74,5 +131,87 @@ export const CONFIG_ENDPOINTS = {
versionDetail: (slug: string, version: number) =>
`/api/platform/tenants/${slug}/config/versions/${version}`,
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`,
avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`
} as const
/**
* 当前生效配置(MAX 版本快照 + version)。
* 注意快照是"原样组"——组内缺省键由消费侧深合并出厂默认,
* 品牌表单消费 config.brand 时用 normalizeBrandGroup 防御读取。
*/
export function fetchTenantConfig(
slug: string,
config?: RequestOptions
): Promise<TenantConfigResponse> {
return http.get<TenantConfigResponse>(CONFIG_ENDPOINTS.current(slug), config)
}
/**
* 分组保存(整组替换语义):请求体 = 组值本身,备注走 comment 查询参数。
* 调用方必须先 GET 全组、改字段后整组提交——部分提交会丢掉未携带的键
* (后端 deep merge 出厂默认兜底,但租户既有定制会被覆盖丢失)。
*/
export function putConfigGroup(
slug: string,
group: ConfigGroupKey | string,
value: Record<string, unknown>,
comment?: string,
config?: RequestOptions
): Promise<PutConfigGroupResponse> {
return http.put<PutConfigGroupResponse>(
CONFIG_ENDPOINTS.putGroup(slug, group),
value,
{
...config,
params: comment ? { comment } : undefined
}
)
}
/**
* 头像上传(multipart file 字段):魔数校验 / 2MB 上限在后端
* (upload_routes.py),前端做扩展名与大小预检提前拦截。
* 成功后 brand.avatar 更新为落盘文件名,版本 +1 并热生效。
*/
export function uploadBrandAvatar(
slug: string,
file: File,
config?: RequestOptions
): Promise<AvatarUploadResponse> {
const form = new FormData()
form.append('file', file)
return http.post<AvatarUploadResponse>(
CONFIG_ENDPOINTS.avatarUpload(slug),
form,
config
)
}
/**
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
* 缺键补空串(与 DEFAULT_CONFIG.brand 的空串出厂一致);topics 只留字符串项。
*/
export function normalizeBrandGroup(
value: Record<string, unknown> | null | undefined
): BrandGroupValue {
const source = value ?? {}
const str = (key: string): string => {
const v = source[key]
return typeof v === 'string' ? v : ''
}
const topics = source.topics
return {
title: str('title'),
color: str('color'),
avatar: str('avatar'),
welcome: str('welcome'),
placeholder: str('placeholder'),
whatsapp: str('whatsapp'),
email: str('email'),
privacy_url: str('privacy_url'),
topics: Array.isArray(topics)
? topics.filter((t): t is string => typeof t === 'string')
: []
}
}