feat(admin): site-import API 与模型纯函数

Task 8(TDD):草稿包→表单/保存序列/warnings 分级纯函数 + POST/GET
API 薄封装。事实源 app/platform/routes/site_import_routes.py(GET 五键
无 failed_phase——retry 对 failed 一律展示后端 409 兜底)与 generator.py
build() 的 spec §4 草稿包形状。保存序列沉淀 spec §5 顺序语义(brand 的
GET 在头像上传之后)与 deflections_contact 池形态分派注记,Task 9 装配
消费。前端 vitest 188 → 201。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 21:56:05 +08:00
parent 17a5c9ec1c
commit 323588c54d
3 changed files with 525 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
/**
* site-import API(POST 启动 / GET 轮询)——Task 8;消费方
* site-import 视图(Task 9)。
*
* 端点事实源:app/platform/routes/site_import_routes.py
* (/api/platform/tenants/{slug}/site-import[/{task_id}],
* _tenant_guard 守租户——超管与租户管理员两角色可用)。
*
* 契约要点(Task 7 评审备忘):
* - POST 200(非 201)→ {task_id};body 单形态发送——新建 {url} /
* 复活 {retry: 'generate', task_id}(retry 形态后端忽略 url 字段);
* - 429(每租户 15 分钟 3 次频控)/ 409(并发上限·非 generate-failed
* 态复活)的 detail 已是白话文案——调用方原样展示,不二次翻译;
* - GET 五键 status/phase/pages/draft/error——**无 failed_phase**:前端
* retry 按钮对 failed 一律展示,能否复活由后端 409 兜底判定;
* - GET 404 = 任务不存在/已过期(终态语义——视图层 isTerminal404 消费)。
*
* 草稿包类型 SiteImportDraft 是 spec §4 的单一 TS 镜像,定义在视图
* model(type-only 反向引用,无运行时耦合——api 层不 import 视图代码)。
*/
import { http, type RequestOptions } from './client'
import type { SiteImportDraft } from '@/views/site-import/model'
/** 任务状态(app/platform/site_import/task.py TaskState 镜像) */
export type SiteImportStatus = 'pending' | 'crawling' | 'generating' | 'done' | 'failed'
/** GET 轮询响应五键(spec §4;后端恒返五键——非 done 时 draft 为 null) */
export interface SiteImportTaskResponse {
status: SiteImportStatus
/** 运行中阶段('crawling' | 'generating';非运行态 null) */
phase: string | null
/** 已抓页数(运行中递增;BFS 总数不可知——展示「第 N 页」不展示 M) */
pages: number
/** 草稿包(仅 status=done 非 null) */
draft: SiteImportDraft | null
/** 白话失败原因(仅 status=failed 非 null) */
error: string | null
}
/** POST body 单形态:新建 {url} / 复活 {retry, task_id}(后端对 retry
* 形态忽略 url——前端按用途二选一发送,不带冗余键) */
export type StartSiteImportBody = { url: string } | { retry: 'generate'; task_id: string }
/** POST 启动响应 */
export interface StartSiteImportResponse {
task_id: string
}
/** 启动/复活导入任务(POST /api/platform/tenants/{slug}/site-import)。
* 新建传 {url}(http(s) 公网地址——内网/非法地址 400);复活传
* {retry: 'generate', task_id}(同 task_id 只重跑生成步,复用已抓文本)。
* 429/409/404 经既有 ApiError 抛(detail 为白话文案)。 */
export function startSiteImport(
slug: string,
body: StartSiteImportBody,
config?: RequestOptions
): Promise<StartSiteImportResponse> {
return http.post<StartSiteImportResponse>(
`/api/platform/tenants/${slug}/site-import`,
body,
config
)
}
/** 轮询任务状态(GET /api/platform/tenants/{slug}/site-import/{task_id})。
* 3s 间隔;7.5 分钟窗口外自动停(> 服务端 7 分钟总超时)。404 = 过期
* 终态(isTerminal404)——跨租户持他人 task_id 同为 404。 */
export function getSiteImportTask(
slug: string,
taskId: string,
config?: RequestOptions
): Promise<SiteImportTaskResponse> {
return http.get<SiteImportTaskResponse>(
`/api/platform/tenants/${slug}/site-import/${taskId}`,
config
)
}
@@ -0,0 +1,200 @@
/**
* site-import 视图模型单元测试(Task 8)
*
* 钉住 src/views/site-import/model.ts 纯函数契约(事实源:
* app/platform/site_import/generator.py build() 的草稿包形状 spec §4
* + app/platform/routes/site_import_routes.py 的 GET 五键/POST 单形态):
* - draftToBrandForm:草稿 brand 八键 → 审核页品牌表单(含头像 dataURL
* 预览槽——avatar null 时表单 avatarDataUrl 为 null);
* - draftToPromptsTabs:四件套 → tabs 结构(key/label/text);
* - dataUrlToBlob:data:<mime>;base64 解码为 Blob(type/字节正确;非
* data URL / 缺 base64 标记 / 非法 base64 → null)——头像上传步的源;
* - buildSaveSequence:保存序列 头像→brand→prompts→wall→contact(头像
* 空则无头像步;brand 的 GET 语义在头像上传之后——spec §5 上传写新
* 版本含 avatar 文件名,读-改-写才不丢;wall 带池形态分派注记;每步
* 含 label 供进度展示);
* - warningsLevel:规格判定=info / 剔除·数字移除=warning / 其他=info;
* - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询)。
*/
import { describe, expect, it } from 'vitest'
import {
buildSaveSequence,
dataUrlToBlob,
draftToBrandForm,
draftToPromptsTabs,
isTerminal404,
warningsLevel,
type SiteImportDraft
} from '../model'
/** 假草稿包(spec §4 形状,数值为编造样本)——品牌八键/头像 data_url/
* 四件套/三语墙尾巴/contact 双开关/warnings 规格判定+剔除各一 */
const DRAFT: SiteImportDraft = {
brand: {
title: 'Televic Support',
color: '#1a73e8',
whatsapp: 'https://wa.me/34600000000',
email: 'info@televic.com',
welcome: 'Hi! What project are you working on?',
topics: ['Sic Bars', 'Graphite Components', 'Ceramic Tubes'],
placeholder: 'Ask about our products...',
privacy_url: ''
},
avatar: { data_url: 'data:image/png;base64,iVBORw0KGgo=', mime: 'image/png' },
prompts: {
system: 'You are the support assistant for Televic.',
company_facts: '- Founded in 1998\n- Based in Valencia',
extractor: 'Extract: name, whatsapp, email',
evaluator: 'Rate the intent level.'
},
wall_tails: {
en: 'Our manager will send you the full quotation on WhatsApp.',
zh: '客户经理会把完整报价发您——请留下您的联系方式。',
es: 'Nuestro gerente le enviará el presupuesto completo.'
},
contact: { cc_completion: true, email_light_ask: true },
warnings: [
'规格判定:公开(依据:产品页含 MPa/°C 参数表)',
'已剔除疑似模板内容(联系方式):555-0100'
]
}
/** 无头像形态(es 尾巴缺失降级一并覆盖——avatar null / es null 均合法) */
const DRAFT_NO_AVATAR: SiteImportDraft = {
...DRAFT,
avatar: null,
wall_tails: { en: DRAFT.wall_tails.en, zh: DRAFT.wall_tails.zh, es: null }
}
// ---- draftToBrandForm ----------------------------------------------------------------
describe('draftToBrandForm', () => {
it('草稿品牌八键映射表单模型', () => {
const form = draftToBrandForm(DRAFT)
expect(form.title).toBe('Televic Support')
expect(form.color).toBe('#1a73e8')
expect(form.whatsapp).toBe('https://wa.me/34600000000')
expect(form.email).toBe('info@televic.com')
expect(form.welcome).toBe('Hi! What project are you working on?')
expect(form.topics).toEqual(['Sic Bars', 'Graphite Components', 'Ceramic Tubes'])
expect(form.placeholder).toBe('Ask about our products...')
expect(form.privacy_url).toBe('')
// 头像预览槽来自草稿 avatar.data_url(非 brand 组——brand.avatar 是
// 上传后的文件名,由上传端点写版本,不在草稿里)
expect(form.avatarDataUrl).toBe('data:image/png;base64,iVBORw0KGgo=')
})
it('avatar 为 null 时表单 avatarDataUrl 为 null(其余品牌键不受影响)', () => {
const form = draftToBrandForm(DRAFT_NO_AVATAR)
expect(form.avatarDataUrl).toBeNull()
expect(form.title).toBe('Televic Support')
})
})
// ---- draftToPromptsTabs ----------------------------------------------------------------
describe('draftToPromptsTabs', () => {
it('四件套映射 tabs 结构(key/label/text)', () => {
const tabs = draftToPromptsTabs(DRAFT)
expect(tabs.map((t) => t.key)).toEqual(['system', 'company_facts', 'extractor', 'evaluator'])
for (const tab of tabs) {
expect(tab.label.trim()).not.toBe('')
expect(tab.text).toBe(DRAFT.prompts[tab.key])
}
})
})
// ---- dataUrlToBlob ----------------------------------------------------------------
describe('dataUrlToBlob', () => {
it('data:image/png;base64 解码为 Blob(type 正确/字节正确)', async () => {
const blob = dataUrlToBlob('data:image/png;base64,iVBORw0KGgo=')
expect(blob).not.toBeNull()
expect(blob!.type).toBe('image/png')
// atob('iVBORw0KGgo=') → PNG 魔数 89 50 4E 47 0D 0A 1A 0A
const bytes = new Uint8Array(await blob!.arrayBuffer())
expect([...bytes]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
})
it('非 data URL / 缺 base64 标记 / 非法 base64 → null', () => {
expect(dataUrlToBlob('https://example.com/logo.png')).toBeNull()
expect(dataUrlToBlob('data:image/png,rawbytes')).toBeNull()
expect(dataUrlToBlob('')).toBeNull()
expect(dataUrlToBlob('data:image/png;base64,!!!not-base64!!!')).toBeNull()
})
})
// ---- buildSaveSequence ----------------------------------------------------------------
describe('buildSaveSequence', () => {
it('保存序列:头像→brand→prompts→wall→contact 五步', () => {
const seq = buildSaveSequence(DRAFT)
expect(seq.map((s) => s.kind)).toEqual(['avatar', 'brand', 'prompts', 'wall', 'contact'])
})
it('avatar 为 null 时四步(无头像步)', () => {
const seq = buildSaveSequence(DRAFT_NO_AVATAR)
expect(seq.map((s) => s.kind)).toEqual(['brand', 'prompts', 'wall', 'contact'])
})
it('brand 步骤的 GET 在头像上传之后(顺序语义——序列注释里说明)', () => {
const seq = buildSaveSequence(DRAFT)
const avatarIdx = seq.findIndex((s) => s.kind === 'avatar')
const brandIdx = seq.findIndex((s) => s.kind === 'brand')
expect(avatarIdx).toBeLessThan(brandIdx)
// 顺序约束落在 brand 步注记里(Task 9 装配消费的执行语义)
expect(seq[brandIdx].note).toContain('头像')
})
it('wall 步骤带池形态分派信息', () => {
const wall = buildSaveSequence(DRAFT).find((s) => s.kind === 'wall')!
expect(wall.note).toContain('deflections_contact')
expect(wall.note).toContain('替换')
expect(wall.note).toContain('追加')
})
it('每步含 label 供进度展示', () => {
for (const step of buildSaveSequence(DRAFT)) {
expect(step.label.trim()).not.toBe('')
}
})
})
// ---- warningsLevel ----------------------------------------------------------------
describe('warningsLevel', () => {
it('规格判定=info / 剔除=warning / 数字移除=warning / 其他=info', () => {
expect(warningsLevel('规格判定:公开(依据:产品页含 MPa/°C 参数表)')).toBe('info')
expect(warningsLevel('规格判定:不公开(依据:产品页参数表判定)')).toBe('info')
expect(warningsLevel('已剔除疑似模板内容(联系方式):555-0100')).toBe('warning')
expect(warningsLevel('已剔除疑似模板内容(事实卡):+86 400-123-4567')).toBe('warning')
expect(warningsLevel('以下数字未在源站出现已移除:500、3000')).toBe('warning')
// 缺省兜底注记属「请人工确认」级——info
expect(warningsLevel('以下内容未生成,已用默认,请审核页确认:welcome、topics')).toBe('info')
})
})
// ---- isTerminal404 ----------------------------------------------------------------
/** 最小 ApiError 形状(client.ts 的 ApiError 契约:name + status)。本
* 测试不引真实 client——避免拉起 axios/element-plus/pinia 传递依赖。 */
function apiError(status: number): Error & { status: number } {
const err = new Error('任务不存在或已过期') as Error & { status: number }
err.name = 'ApiError'
err.status = status
return err
}
describe('isTerminal404', () => {
it('404 判终态(任务过期)', () => {
expect(isTerminal404(apiError(404))).toBe(true)
})
it('其他错误非终态', () => {
expect(isTerminal404(apiError(409))).toBe(false)
expect(isTerminal404(apiError(500))).toBe(false)
expect(isTerminal404(new Error('网络连接异常'))).toBe(false)
expect(isTerminal404('404')).toBe(false)
expect(isTerminal404(null)).toBe(false)
})
})
+248
View File
@@ -0,0 +1,248 @@
/**
* site-import 视图模型纯函数(Task 8)——草稿包→表单/保存序列/warnings 分级。
*
* 消费方:site-import/index.vue(Task 9 装配)。POST/GET 契约见
* app/platform/routes/site_import_routes.py 与 src/api/siteImport.ts
* (GET 五键形状/无 failed_phase——retry 对 failed 一律展示,后端 409
* 兜底判形态)。
*
* 草稿包形状事实源:app/platform/site_import/generator.py 的 build()
* (spec §4)——brand 八键 / avatar data_url / prompts 四件 / wall_tails
* 三语(es 可 None 合法降级)/ contact 双开关 / warnings 白话清单。
*
* 保存序列事实源:spec §5「零新写路径」——前端依次调既有端点:头像
* (有则 dataURL→Blob→POST brand/avatar)→ PUT config/brand(读-改-
* 写)→ PUT config/prompts → PUT config/wall(读-改-写,仅尾巴句)→
* PUT config/contact。本模块只构造步骤描述(kind/label/note),实际
* 请求编排与部分失败重试在 Task 9 的 index.vue。
*/
import type { ApiError } from '@/api/client'
// ---- 草稿包 TS 镜像(spec §4) -------------------------------------------------------
/** 草稿品牌八键(brand 组除 avatar 外的全部字符串键 + topics 列表;
* avatar 是上传后落库的文件名,不在草稿里——草稿侧头像在顶层 avatar 键) */
export interface SiteImportDraftBrand {
title: string
color: string
whatsapp: string | null
email: string | null
welcome: string
topics: string[]
placeholder: string
privacy_url: string
}
/** 草稿包(GET site-import 五键中的 draft,仅 status=done 非 null) */
export interface SiteImportDraft {
brand: SiteImportDraftBrand
/** 抓到的站点 logo(data: URL + mime);null = 无可上传头像 */
avatar: { data_url: string; mime: string | null } | null
prompts: {
system: string
company_facts: string
extractor: string
evaluator: string
}
/** 墙尾巴三语(deflections_contact 池的已留资态收尾句);es 可 null
* (合法降级——wall_schema 只必留 en/zh,保存时不触 es 池) */
wall_tails: { en: string; zh: string; es: string | null }
contact: { cc_completion: boolean; email_light_ask: boolean }
/** 白话注记清单(规格判定/剔除/移除/缺省兜底)——分级见 warningsLevel */
warnings: string[]
}
// ---- 品牌表单 ------------------------------------------------------------------------
/** 审核页品牌表单(草稿 brand 八键 + 头像 dataURL 预览槽;camelCase 无——
* 保持后端键名原样,与配置中心 BrandGroupValue 的键序对齐方便对照) */
export interface SiteImportBrandForm {
title: string
color: string
whatsapp: string
email: string
welcome: string
topics: string[]
placeholder: string
privacy_url: string
/** 草稿头像 data: URL(<img> 预览与上传源);null = 无头像步 */
avatarDataUrl: string | null
}
/** 防御读取:字符串(null → ''——PUT brand 的字符串键空串 = 未设置) */
function asStr(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 草稿 brand 八键 → 审核页品牌表单(头像预览槽取顶层 avatar.data_url) */
export function draftToBrandForm(draft: SiteImportDraft): SiteImportBrandForm {
const brand = draft.brand ?? ({} as SiteImportDraftBrand)
const topics = Array.isArray(brand.topics)
? brand.topics.filter((t): t is string => typeof t === 'string')
: []
return {
title: asStr(brand.title),
color: asStr(brand.color),
whatsapp: asStr(brand.whatsapp),
email: asStr(brand.email),
welcome: asStr(brand.welcome),
topics,
placeholder: asStr(brand.placeholder),
privacy_url: asStr(brand.privacy_url),
avatarDataUrl:
draft.avatar && typeof draft.avatar.data_url === 'string'
? draft.avatar.data_url
: null
}
}
// ---- 提示词四件套 tabs ----------------------------------------------------------------
/** 四件套键序(与配置中心 PROMPTS_KEYS / ai_service.PROMPTS_KEYS 同构) */
export const PROMPTS_TAB_KEYS = [
'system',
'company_facts',
'extractor',
'evaluator'
] as const
export type PromptsTabKey = (typeof PROMPTS_TAB_KEYS)[number]
/** tabs 展示标签(审核页右列 tab 标题) */
export const PROMPTS_TAB_LABELS: Record<PromptsTabKey, string> = {
system: '系统提示词',
company_facts: '公司事实卡',
extractor: '线索提取器',
evaluator: '意向评估器'
}
/** 提示词 tab 结构(key 定位 / label 展示 / text 可编辑文本) */
export interface PromptsTab {
key: PromptsTabKey
label: string
text: string
}
/** 草稿四件套 → tabs 结构(缺键防御补空串——编辑器空态可改可存) */
export function draftToPromptsTabs(draft: SiteImportDraft): PromptsTab[] {
const prompts = (draft.prompts ?? {}) as Record<string, unknown>
return PROMPTS_TAB_KEYS.map((key) => ({
key,
label: PROMPTS_TAB_LABELS[key],
text: asStr(prompts[key])
}))
}
// ---- dataURL → Blob -------------------------------------------------------------------
/** base64 → binary string(浏览器与 vitest happy-dom 均有全局 atob;
* 兜底 Buffer——两者输出一致:每字符一个字节) */
function decodeBase64(text: string): string {
if (typeof atob === 'function') return atob(text)
return Buffer.from(text, 'base64').toString('binary')
}
/**
* data:<mime>;base64,<payload> → Blob(头像上传步的 FormData 源)。
* 非 data URL / 缺 base64 标记 / 非法 base64 → null(调用方跳过头像步
* 并提示——草稿头像已过 crawler 魔数校验,null 属防御分支)。
*/
export function dataUrlToBlob(dataUrl: string): Blob | null {
const match = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl)
if (!match) return null
const [, mime, base64] = match
try {
const binary = decodeBase64(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new Blob([bytes], { type: mime })
} catch {
return null
}
}
// ---- 保存序列 ------------------------------------------------------------------------
export type SaveStepKind = 'avatar' | 'brand' | 'prompts' | 'wall' | 'contact'
/** 保存步骤描述(kind 驱动分派;label 供进度展示「第 N 步:<label>」;
* note 是 Task 9 装配消费的执行语义注记——顺序约束与分派规则沉淀在此,
* 索引页不另抄一份) */
export interface SaveStep {
kind: SaveStepKind
label: string
note?: string
}
/**
* 草稿 → 保存序列(spec §5 零新写路径,全部走既有端点):
* 头像(draft.avatar 非空才有)→ brand → prompts → wall → contact。
*
* 顺序硬约束:brand 步的 GET config/brand 快照必须在头像上传**之后**取
* ——上传头像会写新版本(brand.avatar 落文件名),之后再读-改-写才不把
* 文件名丢掉(spec §5)。wall 步的池形态分派:deflections_contact 出厂
* 单句 → 整句替换;已多句 → 追加(以保存时 GET 快照为准,es 尾巴 null
* 则不触 es 池)。
*/
export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
const steps: SaveStep[] = []
if (draft.avatar?.data_url) {
steps.push({
kind: 'avatar',
label: '上传头像',
note: 'dataURL→Blob(dataUrlToBlob)→ POST brand/avatar(multipart)'
})
}
steps.push(
{
kind: 'brand',
label: '品牌组',
note: 'GET config/brand 快照在本步取(须在头像上传之后——上传已写新版本含 avatar 文件名,读-改-写才不丢)→ 表单值整组 PUT'
},
{
kind: 'prompts',
label: '提示词四件套',
note: '四件即整组:GET config/prompts 读-改-写(未知键透传)→ PUT'
},
{
kind: 'wall',
label: '安全词表(墙尾巴)',
note: 'GET config/wall 读-改-写:deflections_contact 按池形态分派——单句→替换、多句→追加(es 尾巴 null 不触 es 池),其余键透传'
},
{
kind: 'contact',
label: '联系方式开关',
note: 'GET config/contact 读-改-写:cc_completion / email_light_ask 两开关'
}
)
return steps
}
// ---- warnings 分级 -------------------------------------------------------------------
/**
* 草稿 warning 白话 → 展示级(ElTag type):「规格判定:…」前缀 = info
* (行业分支依据,供审核参考);含「剔除」/「移除」= warning(内容已被
* 改动,需人工复核);其余(缺省兜底「已用默认请确认」等)= info。
*/
export function warningsLevel(w: string): 'info' | 'warning' {
if (w.startsWith('规格判定:')) return 'info'
if (w.includes('剔除') || w.includes('移除')) return 'warning'
return 'info'
}
// ---- 轮询终态判定 --------------------------------------------------------------------
/**
* GET 轮询错误是否终态:ApiError status 404 = 任务不存在/已过期(30
* 分钟保留期外),停止轮询并提示「任务已过期,请重新导入」。其余错误
* (429/409/500/网络错误)均可继续轮询或重试。ApiError 形状按 name +
* status 鸭子判定——本模块纯函数不引 client 运行时(type-only import)。
*/
export function isTerminal404(err: unknown): boolean {
return (
err instanceof Error &&
err.name === 'ApiError' &&
(err as ApiError).status === 404
)
}