feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务

从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 →
体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。

F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配,
候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。
F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。

验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
This commit is contained in:
2026-09-30 09:02:54 +08:00
parent 7090ee7d65
commit 0d3aae75fa
10 changed files with 2879 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* 历史数据整理器(《03-接口设计》§13.2)。
*
* ## 这个功能的"卖点"就是报告里的一句话
*
* 契约里写明:**报告里最重要的一句话是 `autoHandled / needManual`** ——
* 「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
* 所以这一屏的核心不是"进度条",而是**那两个数字与重点清单**。
* 进度只是等待时的安抚,报告才是客户付钱的理由。
*
* ## 几个界面必须区分清楚的状态
*
* | status | 界面该显示什么 |
* |---|---|
* | `0` 排队 / `1` 解析中 / `2` 处理中 | 进度条 + 已处理的文件数;**不给"下载"按钮**(还没有产物) |
* | `3` 已完成 | 报告 + 下载结果包 + 导入为正式任务 |
* | `4` 失败 | **`errorMsg` 必须显示出来** |
*
* ⚠️ 失败时 `errorMsg` 是唯一能让用户知道"为什么"的东西。常见原因是
* "传了不是 zip 的文件"与"包里单个文件超过 50MB" —— 这两条都在服务端有明确文案,
* 界面要做的是**把它原样显示**,而不是笼统说"整理失败"。
*
* ## 为什么 `rosterTaskId` 这个参数要在界面上强调
*
* 契约 §13.2 的原话:**没有名册就只能做文件名规范化;有名单才能做证件号校验、
* 疑似串档检测** —— 而"疑似串档 1 例"正是报告里最有价值的一条。
* 也就是说:关联名册与不关联,"体检报告"是两份完全不同的东西。
* 界面必须把这件事说在**上传之前**,而不是等报告出来发现那两项全是 0。
*/
import { getToken } from '@/utils/auth'
import http from '@/utils/http'
/** 整理任务状态。 */
export type CleanupStatus = 0 | 1 | 2 | 3 | 4
export interface CleanupJobVO {
id: number
jobName: string
/** `1` 压缩包 / `2` 文件夹 / `3` 云盘链接(一期只支持 `1`)。 */
sourceType: number
/** 关联的已有名册任务。**有关联才有证件号校验与串档检测**。 */
rosterTaskId?: number | null
status: CleanupStatus
statusText: string
progress: number
totalFiles?: number | null
processedFiles?: number | null
parsedStudents?: number | null
parsedMaterials?: number | null
rawSizeBytes?: number | null
finalSizeBytes?: number | null
/** 形如 `91.5%`;没算出来时是 `—`(**不是 0%** —— 0% 看起来像"没压缩")。 */
compressRatio?: string
resultPath?: string | null
/** 导入成正式任务后的任务 id(有值就说明已导入,导入是幂等的)。 */
targetTaskId?: number | null
errorMsg?: string | null
createTime?: string
finishTime?: string | null
}
export interface CleanupJobPage {
list: CleanupJobVO[]
total: number
}
/** 报告汇总。 */
export interface ReportSummary {
totalFiles: number
nameInvalid: number
sizeExceeded: number
formatConverted: number
idcardMismatch: number
idcardAutoFixed: number
idcardNeedConfirm: number
/** 疑似串档:某人的**多项**材料证件号与名册均不同、且彼此一致 → 整个文件夹可能被换过。 */
crossFileSuspected: number
lowResolution: number
orphanImages: number
}
export interface Highlight {
type: string
/** `1` 提示 / `2` 警告 / `3` 必须人工。 */
level: number
rosterId?: number | null
realName?: string | null
title: string
materialCode?: string | null
detail?: string | null
suggestion?: string | null
}
export interface NameFixSample {
original: string
fixed: string
rule?: string
}
export interface IdcardConflict {
rosterId?: number | null
realName?: string | null
rosterValue?: string | null
fileNameValue?: string | null
verdict: string
verdictText: string
}
export interface CleanupReportVO {
jobId: number
summary: ReportSummary
/** 系统全自动处理了多少(**服务端算的**,不让前端去减)。 */
autoHandled: number
/** 必须人工判断的(串档 + 无法归位 + 待确认的证件号冲突)。 */
needManual: number
highlights: Highlight[]
nameFixedSamples: NameFixSample[]
idcardConflicts: IdcardConflict[]
}
export interface CleanupImportVO {
taskId: number
rosterCount: number
fileCount: number
}
export function getCleanupJobs(params: {
status?: number
keyword?: string
pageNum?: number
pageSize?: number
}) {
return http.get<CleanupJobPage>('/cleanup/jobs', params)
}
export function getCleanupJob(id: number) {
return http.get<CleanupJobVO>(`/cleanup/jobs/${id}`)
}
export function getCleanupReport(id: number) {
return http.get<CleanupReportVO>(`/cleanup/jobs/${id}/report`)
}
/**
* 创建整理任务(multipart)。
*
* ⚠️ 超时给到 600s:大包的解压 + 解析 + 重新打包都在服务端同步做,
* 而默认 30s 在几百 MB 的包上必然超时 —— 超时的表现是"点了没反应",
* 用户会重复点,于是同一个包被整理好几次。
*/
export function createCleanupJob(file: File, opts: {
jobName: string
rosterTaskId?: number
namingTemplate?: string
}) {
const fd = new FormData()
fd.append('file', file)
fd.append('jobName', opts.jobName)
fd.append('sourceType', '1')
if (opts.rosterTaskId !== undefined) {
fd.append('rosterTaskId', String(opts.rosterTaskId))
}
if (opts.namingTemplate) {
fd.append('namingTemplate', opts.namingTemplate)
}
// 不要手动设 Content-Type:axios 会带着 boundary 自己设
return http.request<{ jobId: number, status: number, estimatedSeconds: number }>({
method: 'post',
url: '/cleanup/jobs',
data: fd,
timeout: 600 * 1000
})
}
/**
* 导入为正式任务。
*
* ⚠️ 这条路**不能带 `importFiles`**:一期还做不到把整理结果里的文件自动挂到填报单,
* 而服务端会**如实拒绝**(`40044`)而不是安静忽略。界面因此不要给这个勾选项 ——
* 给了一个必然报错的选项,用户只会以为系统坏了。
*/
export function importCleanupJob(id: number, data: {
taskName: string
collectMode?: number
batchNo: string
importRoster?: boolean
majorName?: string
}) {
return http.post<CleanupImportVO>(`/cleanup/jobs/${id}/import`, {
collectMode: 1,
importRoster: true,
...data
})
}
/**
* 真正下载整理结果包。
*
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/cleanup/jobs/{id}/result` 是要登录的,
* 直接 `window.open` / `<a href>` 都不会带 `Authorization` 头,
* 用户会看到一个 401 页面(或"下载没反应")。
*
* 放在 api 层而不是页面里,与导出中心的 `downloadExportFile` 保持同一写法 ——
* 同一件事在一个项目里有两处实现,改的时候一定会漏一处。
*/
export async function downloadCleanupResult(id: number, jobName?: string): Promise<void> {
const token = getToken()
const base = import.meta.env.VITE_API_PREFIX || '/api/v1'
const res = await fetch(`${base}/cleanup/jobs/${id}/result`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
if (!res.ok) {
throw new Error(`下载失败(HTTP ${res.status})`)
}
const blob = await res.blob()
const href = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = href
a.download = `${jobName || '整理结果'}-${id}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(href)
}
/** 状态 → Tag 颜色(Arco 的 status 只认 normal/warning/success/danger)。 */
export function statusColor(status: CleanupStatus): 'normal' | 'warning' | 'success' | 'danger' {
switch (status) {
case 3:
return 'success'
case 4:
return 'danger'
case 1:
case 2:
return 'warning'
default:
return 'normal'
}
}
/** 重点条目的级别 → Tag 颜色。`3` 是"必须人工",用红色。 */
export function levelColor(level: number): 'normal' | 'warning' | 'danger' {
if (level >= 3) return 'danger'
if (level === 2) return 'warning'
return 'normal'
}
/**
* 报告里"最有说服力的一句话"。
*
* 契约的原话就是这个格式:**「112 个文件里 90 个系统全自动处理,
* 只有 1 个必须你亲自判断」**。所以这句话由前端按同一口径拼 ——
* 但它拼的是**服务端算好的两个数**,不是自己减出来的
* (前端减出来的数字早晚会与服务端分叉)。
*/
export function headlineOf(report: CleanupReportVO): string {
const total = report.summary.totalFiles
const need = report.needManual
return `${total} 个文件里,${report.autoHandled} 个系统全自动处理,`
// ⚠️ 不要在这里写 Markdown 的 `**粗体**`:它落在**文本节点**里,
// 页面上会原样显示两个星号(要粗体得让调用方用富文本渲染)
+ `${need > 0 ? `只有 ${need} 个必须你亲自判断` : '没有需要你判断的'}`
}