From 9048816abc161e15c7a80878d587242cdc17d104 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 15:50:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E7=A7=BB=E9=99=A4=20Excel=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=A2=84=E6=A3=80=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导 --- .../components/AiChat/AiMessageContent.tsx | 30 - .../components/AiChat/ImportPreflightCard.tsx | 377 --------- .../components/AiChat/api.integration.test.ts | 57 +- apps/admin/src/components/AiChat/api.ts | 89 -- .../AiChat/bubble.integration.test.tsx | 239 ------ .../message-mappers.integration.test.ts | 21 - .../AiChat/provider.integration.test.ts | 40 - .../admin/src/components/AiChat/sseReducer.ts | 4 - apps/admin/src/components/AiChat/types.ts | 63 -- .../src/components/AiChat/uiArtifacts.ts | 2 - .../AiChat/useAiChatMessageActions.tsx | 30 +- apps/server/src/ai-chat/ai-a2ui.artifact.ts | 2 - apps/server/src/ai-chat/ai-chat.constants.ts | 43 +- apps/server/src/ai-chat/ai-chat.controller.ts | 18 - apps/server/src/ai-chat/ai-chat.generation.ts | 3 +- .../src/ai-chat/ai-chat.service-base.ts | 35 +- .../src/ai-chat/ai-chat.service.spec.ts | 789 ------------------ .../server/src/ai-chat/ai-chat.submissions.ts | 155 ---- .../ai-chat/ai-chat.tool-actions.import.ts | 126 +-- .../src/ai-chat/ai-chat.tool-actions.ts | 1 - apps/server/src/ai-chat/ai-chat.tools.ts | 4 - apps/server/src/ai-chat/ai-chat.types.ts | 1 - apps/server/src/ai-chat/dto/ai-chat.dto.ts | 13 - .../src/imports/entities/import-run.entity.ts | 2 +- .../src/imports/imports.preflight.spec.ts | 232 ----- apps/server/src/imports/imports.preflight.ts | 425 ---------- .../src/imports/imports.service.spec.ts | 23 - apps/server/src/imports/imports.service.ts | 16 - apps/server/src/imports/imports.types.ts | 88 -- 29 files changed, 14 insertions(+), 2914 deletions(-) delete mode 100644 apps/admin/src/components/AiChat/ImportPreflightCard.tsx delete mode 100644 apps/server/src/imports/imports.preflight.spec.ts delete mode 100644 apps/server/src/imports/imports.preflight.ts diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index f9e10de..f176047 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -16,17 +16,14 @@ import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; -import { ImportPreflightCard } from './ImportPreflightCard'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; -import type { ResolveImportPreflightInput } from './api'; import type { AiAttachment, AiChatMessage, AiChatMessageStatus, AiChartSchema, AiFormSchema, - AiImportPreflight, AiImportWizard, AiReviewSection, AiReviewSchema, @@ -46,7 +43,6 @@ const toolLabels: Record = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', - preflight_import: '导入预检', start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', @@ -193,11 +189,6 @@ export interface AiMessageContentProps { type: AiReviewSectionType, ) => AiReviewSchema | Promise | void; onOpenImportWizard?: (runId: string) => void; - onResolveImportPreflight?: ( - messageId: number | undefined, - preflight: AiImportPreflight, - input: ResolveImportPreflightInput, - ) => Promise | void; } export const AiMessageContent: React.FC = ({ @@ -211,7 +202,6 @@ export const AiMessageContent: React.FC = ({ onConfirmReviewStep, onConfirmReviewGroup, onOpenImportWizard, - onResolveImportPreflight, }) => { const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; @@ -324,26 +314,6 @@ export const AiMessageContent: React.FC = ({ {attachmentCards} )} - {(() => { - const preflight = message.metadata?.a2uiImportPreflight; - if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null; - const typedPreflight = preflight as AiImportPreflight; - return ( - - onResolveImportPreflight( - typeof message.id === 'number' ? message.id : undefined, - typedPreflight, - input, - ) - : undefined - } - /> - ); - })()} {(() => { const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; if (!wizard || !onOpenImportWizard) return null; diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx deleted file mode 100644 index 42bfe23..0000000 --- a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { CheckOutlined, TableOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Flex, Input, Radio, Select, Space, Tag, Typography } from 'antd'; -import type { ResolveImportPreflightInput } from './api'; -import type { - AiImportPreflight, - AiImportPreflightQuestion, - AiImportPreflightVerdict, - AiReviewSectionType, -} from './types'; -import { STEP_FIELDS } from '../ImportWizard/types'; - -const VERDICT_META: Record< - AiImportPreflightVerdict, - { label: string; color: string } -> = { - ready: { label: '可导入', color: 'success' }, - needs_input: { label: '需要确认', color: 'warning' }, - blocked: { label: '暂无法导入', color: 'error' }, -}; - -interface SettingsDraft { - organization?: string | null; - updateExisting?: boolean; - duplicatePolicy?: 'error' | 'skip'; - skipUnmatched?: boolean; -} - -function defaultSettings(questions: AiImportPreflightQuestion[]): SettingsDraft { - const settings: SettingsDraft = {}; - for (const question of questions) { - if (question.type === 'update' && typeof question.default === 'boolean') { - settings.updateExisting = question.default; - } else if ( - question.type === 'duplicate' && - (question.default === 'error' || question.default === 'skip') - ) { - settings.duplicatePolicy = question.default; - } else if (question.type === 'reference' && typeof question.default === 'boolean') { - settings.skipUnmatched = question.default; - } else if (question.type === 'organization') { - settings.organization = typeof question.default === 'string' ? question.default : ''; - } - } - return settings; -} - -function missingFieldKeys( - stepKey: AiReviewSectionType, - missingLabels: string[], -): string[] { - const fields = STEP_FIELDS[stepKey] ?? []; - return missingLabels - .map((label) => fields.find((field) => field.label === label)?.key) - .filter((key): key is string => Boolean(key)); -} - -export interface ImportPreflightCardProps { - preflight: AiImportPreflight; - onResolve?: (input: ResolveImportPreflightInput) => Promise | void; -} - -/** - * 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因, - * 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务。 - * 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。 - */ -export const ImportPreflightCard: React.FC = ({ - preflight, - onResolve, -}) => { - const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input; - const resolved = preflight.resolved === true; - const permitted = new Set(preflight.permittedSteps ?? []); - const hasStages = preflight.stages.length > 0; - const showActions = !resolved && hasStages && Boolean(onResolve); - - const [mappingDraft, setMappingDraft] = useState>>( - () => - Object.fromEntries( - preflight.stages.map((stage) => [stage.stepKey, { ...stage.mapping }]), - ), - ); - const [settingsDraft, setSettingsDraft] = useState(() => - defaultSettings(preflight.questions), - ); - const [customOrganization, setCustomOrganization] = useState(false); - const [organizationInput, setOrganizationInput] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const organizationQuestion = preflight.questions.find( - (question) => question.type === 'organization', - ); - const organizationOptions = useMemo(() => { - const options = [...(organizationQuestion?.options ?? [])]; - if (!options.some((option) => option.value === '')) { - options.push({ label: '忽略校区', value: '' }); - } - return [ - ...options, - { label: '手动输入校区', value: '__custom__' }, - ]; - }, [organizationQuestion]); - - const setMapping = (stepKey: string, field: string, header?: string) => { - setMappingDraft((prev) => { - const current = { ...prev[stepKey] }; - if (header) current[field] = header; - else delete current[field]; - return { ...prev, [stepKey]: current }; - }); - }; - - const handleResolve = async () => { - if (!onResolve) return; - setSubmitting(true); - setError(null); - const settings: SettingsDraft = { ...settingsDraft }; - if (organizationQuestion) { - if (customOrganization) { - const value = organizationInput.trim(); - if (!value) { - setError('请先输入校区名称,或选择“忽略校区”'); - setSubmitting(false); - return; - } - settings.organization = value; - } else if (settings.organization === '__custom__') { - settings.organization = ''; - } - } - try { - await onResolve({ - mapping: mappingDraft, - settings: { - ...(settings.organization ? { organization: settings.organization } : {}), - ...(settings.updateExisting !== undefined - ? { updateExisting: settings.updateExisting } - : {}), - ...(settings.duplicatePolicy ? { duplicatePolicy: settings.duplicatePolicy } : {}), - ...(settings.skipUnmatched !== undefined - ? { skipUnmatched: settings.skipUnmatched } - : {}), - }, - }); - } catch (resolveError) { - setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败'); - } finally { - setSubmitting(false); - } - }; - - const nonMappingQuestions = preflight.questions.filter( - (question) => question.type !== 'mapping', - ); - - return ( - - - Excel 导入预检 - {verdict.label} - {resolved && ( - }> - 已生成向导 - - )} - - } - > - - {resolved && ( - - )} - {error && } - - {preflight.stages.length > 0 ? ( - - {preflight.stages.map((stage) => ( - - - {stage.label} - - {stage.sheetNames.join('、')} - - {stage.stepKey && !permitted.has(stage.stepKey) && ( - 无提交权限 - )} - - - 共 {stage.total} 行 - 新建 {stage.create} - 更新 {stage.update} - 错误 {stage.error} - 跳过 {stage.skip} - - - ))} - - ) : ( - 未识别到可导入的工作表 - )} - - {preflight.blocks.length > 0 && ( - - - 阻断原因 - - {preflight.blocks.map((block) => ( - - {block.label} - - {block.message}({block.count} 行) - - - ))} - - )} - - {showActions && ( - <> - {preflight.stages - .filter( - (stage) => - stage.missingRequired.length > 0 && - stage.stepKey && - permitted.has(stage.stepKey), - ) - .map((stage) => ( - - - 列映射 - - {stage.label} - - - 缺少:{stage.missingRequired.join('、')} - - - - {missingFieldKeys(stage.stepKey, stage.missingRequired).map((field) => { - const fieldMeta = STEP_FIELDS[stage.stepKey]?.find( - (item) => item.key === field, - ); - return ( - { - if (value === '__custom__') { - setCustomOrganization(true); - } else { - setCustomOrganization(false); - setSettingsDraft((prev) => ({ ...prev, organization: value })); - } - }} - /> - {customOrganization && ( - setOrganizationInput(event.target.value)} - /> - )} - - ) : ( - { - const value = event.target.value; - if (question.type === 'update') { - setSettingsDraft((prev) => ({ - ...prev, - updateExisting: value === 'true', - })); - } else if (question.type === 'duplicate') { - setSettingsDraft((prev) => ({ - ...prev, - duplicatePolicy: value as 'error' | 'skip', - })); - } else { - setSettingsDraft((prev) => ({ - ...prev, - skipUnmatched: value === 'true', - })); - } - }} - > - {(question.options ?? []).map((option) => ( - - {option.label} - - ))} - - )} - - ))} - - - - 未确认的列可在向导中继续调整 - - - - - )} - - {preflight.nextSteps.length > 0 && ( - - - 下一步建议 - - {preflight.nextSteps.map((step) => ( - - {step.label} - - {step.description} - - - ))} - - )} - - - ); -}; diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts index 5fdeb30..94d113e 100644 --- a/apps/admin/src/components/AiChat/api.integration.test.ts +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import api from '../../api'; -import { - aiChatApi, - resolveImportPreflight, - type ImportPreflightResolveUpdate, -} from './api'; +import { aiChatApi } from './api'; describe('AI chat API adapter', () => { afterEach(() => vi.restoreAllMocks()); @@ -75,55 +71,4 @@ describe('AI chat API adapter', () => { '/ai/chat/reviews/review-1/types/checkins/confirm', ); }); - - it('resolveImportPreflight 流式解析 SSE 事件并回调预检卡/向导更新', async () => { - const encoder = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'event: ui.import_preflight\ndata: {"preflight":{"verdict":"ready","resolved":true,"runId":"run-1"}}\n\n', - ), - ); - controller.enqueue( - encoder.encode( - 'event: ui.import_wizard\ndata: {"wizard":{"runId":"run-1","fileName":"students.xlsx","sheets":[],"steps":[]}}\n\n', - ), - ); - controller.enqueue(encoder.encode('event: done\ndata: {}\n\n')); - controller.close(); - }, - }); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body }); - vi.stubGlobal('fetch', fetchMock); - - const updates: ImportPreflightResolveUpdate[] = []; - await resolveImportPreflight( - 42, - { mapping: { students: { name: '姓名' } }, settings: { updateExisting: false } }, - (update) => updates.push(update), - ); - - expect(fetchMock).toHaveBeenCalledWith( - '/api/ai/chat/import/preflight/42/resolve/stream', - expect.objectContaining({ method: 'POST' }), - ); - expect(updates).toEqual([ - { preflight: { verdict: 'ready', resolved: true, runId: 'run-1' } }, - { wizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] } }, - ]); - }); - - it('resolveImportPreflight 非 2xx 时抛出服务端错误', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ message: '列映射「x」不在工作表表头中' }), - }); - vi.stubGlobal('fetch', fetchMock); - - await expect( - resolveImportPreflight(42, { mapping: {} }, () => undefined), - ).rejects.toThrow('列映射「x」不在工作表表头中'); - }); }); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index c1683b9..f336abc 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -1,11 +1,8 @@ import api from '../../api'; -import { useUserStore } from '../../store/user/userStore'; import type { AiApiResponse, AiAttachment, AiConversation, - AiImportPreflight, - AiImportWizard, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -86,89 +83,3 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } - -export interface ResolveImportPreflightInput { - mapping?: Record>; - settings?: { - organization?: string | null; - updateExisting?: boolean; - duplicatePolicy?: 'error' | 'skip'; - skipUnmatched?: boolean; - }; -} - -export interface ImportPreflightResolveUpdate { - preflight?: AiImportPreflight; - wizard?: AiImportWizard; -} - -/** - * 用户在预检卡内确认映射与策略后,调用服务端 resolve 流直接生成导入向导。 - * 与普通对话不同:不新增用户消息,只原位更新目标消息的预检卡/向导卡。 - */ -export async function resolveImportPreflight( - messageId: number, - input: ResolveImportPreflightInput, - onUpdate: (update: ImportPreflightResolveUpdate) => void, -): Promise { - const token = useUserStore.getState().token; - const response = await fetch(`/api/ai/chat/import/preflight/${messageId}/resolve/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - clientRequestId: crypto.randomUUID(), - mapping: input.mapping ?? {}, - settings: input.settings ?? {}, - }), - }); - if (response.status === 401) { - useUserStore.getState().logout(); - window.location.href = '/login'; - throw new Error('登录已失效'); - } - if (!response.ok || !response.body) { - let message = '导入向导生成失败'; - try { - const body = (await response.json()) as { message?: string; error?: string }; - message = body?.message ?? body?.error ?? message; - } catch { - // 保留默认错误信息 - } - throw new Error(message); - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split('\n\n'); - buffer = blocks.pop() ?? ''; - for (const block of blocks) { - const event = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim() ?? 'message'; - const dataLine = /^data:\s*(.+)$/m.exec(block)?.[1]?.trim(); - if (!dataLine || dataLine === '[DONE]') continue; - let data: Record; - try { - data = JSON.parse(dataLine) as Record; - } catch { - continue; - } - if (event === 'error') { - const message = - typeof data.message === 'string' ? data.message : '导入向导生成失败'; - throw new Error(message); - } - if (event === 'ui.import_preflight' && data.preflight) { - onUpdate({ preflight: data.preflight as AiImportPreflight }); - } else if (event === 'ui.import_wizard' && data.wizard) { - onUpdate({ wizard: data.wizard as AiImportWizard }); - } - } - } -} diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 46f1352..761bc73 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -7,7 +7,6 @@ import { AiMessageContent } from './AiMessageContent'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; -import type { ResolveImportPreflightInput } from './api'; import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types'; let container: HTMLDivElement | null = null; @@ -429,244 +428,6 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain('26暑期文化课宿舍.xlsx'); }); - it('renders an import preflight card from assistant message metadata', async () => { - const message: AiChatMessage = { - role: 'assistant', - content: '这是预检结果', - reasoningContent: '', - toolRuns: [], - attachments: [], - metadata: { - a2uiImportPreflight: { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2, - create: 1, - update: 1, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [ - { - key: 'students-next', - label: '分班 / 排课 / 入住', - description: '学生档案导入完成后可继续分班、排课或入住。', - after: ['students'], - }, - ], - }, - }, - }; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - - expect(container.textContent).toContain('Excel 导入预检'); - expect(container.textContent).toContain('需要确认'); - expect(container.textContent).toContain('学生档案'); - expect(container.textContent).toContain('新建 1'); - expect(container.textContent).toContain('更新 1'); - expect(container.textContent).toContain('未知校区'); - expect(container.textContent).toContain('分班 / 排课 / 入住'); - }); - - it('renders preflight card, form Q&A and opens the import wizard', async () => { - let openedRunId: string | null = null; - const message: AiChatMessage = { - role: 'assistant', - content: '请先确认导入策略,再打开向导。', - reasoningContent: '', - toolRuns: [], - attachments: [], - metadata: { - a2uiImportPreflight: { - verdict: 'ready', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 1, - create: 1, - update: 0, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [], - questions: [], - nextSteps: [], - }, - a2uiImportWizard: { - runId: 'run-1', - fileName: 'students.xlsx', - sheets: [{ name: '学生', headers: ['姓名'], rowCount: 1 }], - steps: [{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }], - }, - }, - forms: [ - { - id: 'form-1', - title: '确认导入策略', - submitLabel: '确认', - fields: [ - { - name: 'duplicatePolicy', - label: '重复行处理', - type: 'select', - options: [ - { label: '标记为错误', value: 'error' }, - { label: '跳过重复行', value: 'skip' }, - ], - }, - ], - }, - ], - }; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render( - { - openedRunId = runId; - }} - />, - ); - }); - - expect(container.textContent).toContain('Excel 导入预检'); - expect(container.textContent).toContain('确认导入策略'); - const wizardButton = Array.from(container.querySelectorAll('button')).find((item) => - item.textContent?.includes('打开导入向导'), - ) as HTMLButtonElement | undefined; - expect(wizardButton).toBeDefined(); - await act(async () => { - wizardButton?.click(); - }); - expect(openedRunId).toBe('run-1'); - }); - - it('preflight card resolves mapping and settings inline', async () => { - let resolvedMessageId: number | undefined; - let resolvedInput: ResolveImportPreflightInput | undefined; - const message: AiChatMessage = { - id: 42, - role: 'assistant', - content: '请在预检卡内确认列映射与策略。', - reasoningContent: '', - toolRuns: [], - attachments: [], - metadata: { - a2uiImportPreflight: { - verdict: 'blocked', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - headers: ['姓名', '学号', '手机号'], - total: 2, - create: 2, - update: 0, - error: 0, - skip: 0, - mapping: { name: '姓名', studentNo: '学号' }, - missingRequired: [], - }, - { - stepKey: 'rooms', - label: '宿舍档案', - sheetNames: ['宿舍'], - headers: ['宿舍号', '楼栋', '容量'], - total: 1, - create: 1, - update: 0, - error: 0, - skip: 0, - mapping: { roomNumber: '宿舍号' }, - missingRequired: ['容量'], - }, - ], - blocks: [ - { - code: 'missing_columns', - label: '缺少必填列', - stepKeys: ['rooms'], - message: '宿舍阶段缺少容量列', - count: 1, - }, - ], - questions: [ - { - key: 'update', - type: 'update', - label: '文件中有 1 行已匹配现有记录', - options: [ - { label: '更新已有记录', value: 'true' }, - { label: '跳过已有记录', value: 'false' }, - ], - default: true, - }, - ], - nextSteps: [], - permittedSteps: ['students', 'rooms'], - resolved: false, - runId: null, - }, - }, - }; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render( - { - resolvedMessageId = messageId; - resolvedInput = input; - }} - />, - ); - }); - - expect(container.textContent).toContain('容量'); - const resolveButton = Array.from(container.querySelectorAll('button')).find((item) => - item.textContent?.includes('生成导入向导'), - ) as HTMLButtonElement | undefined; - expect(resolveButton).toBeDefined(); - await act(async () => { - resolveButton?.click(); - }); - - expect(resolvedMessageId).toBe(42); - expect(resolvedInput?.mapping).toMatchObject({ - students: { name: '姓名', studentNo: '学号' }, - rooms: { roomNumber: '宿舍号' }, - }); - expect(resolvedInput?.settings).toMatchObject({ updateExisting: true }); - }); - it('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index baf9410..5363603 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -186,25 +186,4 @@ describe('AI chat history mapper', () => { expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' }); }); - it('keeps import preflight metadata on history messages', () => { - const preflight = { - verdict: 'needs_input', - stages: [], - blocks: [], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [], - }; - const mapped = mapHistoryMessage({ - id: 8, - role: 'assistant', - content: '预检完成', - reasoningContent: null, - status: 'completed', - errorCode: null, - createdAt: '2026-07-23T00:00:00.000Z', - metadata: { a2uiImportPreflight: preflight }, - }); - - expect(mapped.message.metadata?.a2uiImportPreflight).toEqual(preflight); - }); }); diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index c552dfe..a0761d0 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -189,46 +189,6 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); }); - it('stores ui.import_preflight in message metadata and restores it from completed message', () => { - const preflight = { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2, - create: 1, - update: 1, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.import_preflight', - data: JSON.stringify({ messageId: 8, preflight }), - }); - expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); - - message = reduceAiSseMessage(message, { - event: 'message.completed', - data: JSON.stringify({ - message: { - id: 8, - content: '预检完成', - status: 'completed', - metadata: { a2uiImportPreflight: preflight }, - }, - }), - }); - expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); - }); it('restores a persisted form from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index 6b1ac2e..e58a224 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -4,7 +4,6 @@ import type { AiChartSchema, AiChatMessage, AiFormSchema, - AiImportPreflight, AiModelRetryInfo, AiReviewSchema, AiSseChunk, @@ -30,7 +29,6 @@ export interface AiSsePayload { artifact?: AiArtifactSchema; review?: AiReviewSchema; chart?: AiChartSchema; - preflight?: AiImportPreflight; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -169,8 +167,6 @@ export function reduceAiSseMessage( message.charts = mergeById(message.charts, payload.chart); } else if (event === 'ui.artifact' && payload.artifact) { mergeArtifactIntoMessage(message, payload.artifact); - } else if (event === 'ui.import_preflight' && payload.preflight) { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; } else if (event === 'tool.started') { diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index 31bac56..aee0823 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -102,7 +102,6 @@ export type AiArtifactType = | 'form' | 'review' | 'chart' - | 'import_preflight' | 'import_wizard'; export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; @@ -136,68 +135,6 @@ export interface AiImportWizard { }>; } -export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked'; - -export interface AiImportPreflightStage { - stepKey: AiReviewSectionType; - label: string; - sheetNames: string[]; - /** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */ - headers?: string[]; - total: number; - create: number; - update: number; - error: number; - skip: number; - mapping: Record; - missingRequired: string[]; -} - -export interface AiImportPreflightBlock { - code: string; - label: string; - stepKeys: AiReviewSectionType[]; - message: string; - count: number; -} - -export interface AiImportPreflightQuestion { - key: string; - type: 'mapping' | 'organization' | 'update' | 'duplicate' | 'reference'; - label: string; - description?: string; - stepKey?: AiReviewSectionType; - options?: Array<{ label: string; value: string }>; - default?: string | boolean; -} - -export interface AiImportPreflightNextStep { - key: string; - label: string; - description: string; - after: AiReviewSectionType[]; -} - -export interface AiImportPreflight { - verdict: AiImportPreflightVerdict; - stages: AiImportPreflightStage[]; - blocks: AiImportPreflightBlock[]; - questions: AiImportPreflightQuestion[]; - nextSteps: AiImportPreflightNextStep[]; - attachmentId?: number; - headerRow?: number; - permittedSteps?: AiReviewSectionType[]; - resolved?: boolean; - runId?: string | null; - errorSamples?: Array<{ - code: string; - stepKey: AiReviewSectionType; - sheet: string; - rowNumber: number; - errors: string[]; - }>; -} - export type AiToolRunStatus = | 'running' | 'success' diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index abb20e2..32f1f59 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -60,8 +60,6 @@ export function mergeArtifactIntoMessage( message.reviews = mergeById(message.reviews, payload as AiReviewSchema); } else if (artifact.type === 'chart') { message.charts = mergeById(message.charts, payload as AiChartSchema); - } else if (artifact.type === 'import_preflight') { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; } else if (artifact.type === 'import_wizard') { message.metadata = { ...message.metadata, a2uiImportWizard: payload }; } diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index ad0b032..4d7bdea 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -6,7 +6,7 @@ import { App } from 'antd'; import type { UploadFile, UploadProps } from 'antd'; import { message } from '../../ui/app-message'; import { useSettingsStore } from '../../store/settings/settingsStore'; -import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; +import { aiChatApi } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; @@ -25,7 +25,6 @@ import type { AiChatMessage, AiChatMessageStatus, AiFormSchema, - AiImportPreflight, AiReviewSchema, AiReviewSection, AiReviewSectionType, @@ -416,31 +415,6 @@ export function useAiChatMessageActions({ [provider, setMessage], ); - const resolvePreflight = useCallback( - async ( - messageId: number | undefined, - _preflight: AiImportPreflight, - input: ResolveImportPreflightInput, - ): Promise => { - if (typeof messageId !== 'number') { - throw new Error('消息尚未完成生成,请稍后再试'); - } - await resolveImportPreflight(messageId, input, (update) => { - setMessage(messageId, (info) => ({ - message: { - ...info.message, - metadata: { - ...info.message.metadata, - ...(update.preflight ? { a2uiImportPreflight: update.preflight } : {}), - ...(update.wizard ? { a2uiImportWizard: update.wizard } : {}), - }, - }, - })); - }); - }, - [setMessage], - ); - const customUpload = useCallback>(async (options) => { const file = options.file as File; if (attachmentsRef.current.length >= 5) { @@ -556,7 +530,6 @@ export function useAiChatMessageActions({ onConfirmReviewStep={confirmReviewStep} onConfirmReviewGroup={confirmReviewGroup} onOpenImportWizard={setImportWizardRunId} - onResolveImportPreflight={resolvePreflight} /> ), })), @@ -570,7 +543,6 @@ export function useAiChatMessageActions({ isRequesting, messages, reloadMessage, - resolvePreflight, setImportWizardRunId, submitForm, submitReview, diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.ts index 11689fd..0600148 100644 --- a/apps/server/src/ai-chat/ai-a2ui.artifact.ts +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.ts @@ -2,7 +2,6 @@ export type A2uiArtifactType = | 'form' | 'review' | 'chart' - | 'import_preflight' | 'import_wizard'; export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; @@ -23,7 +22,6 @@ const A2UI_ARTIFACT_TYPES = new Set([ 'form', 'review', 'chart', - 'import_preflight', 'import_wizard', ]); diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 5afa881..5948a65 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -13,31 +13,6 @@ const CELL_VALUE_ANY_OF = [ ]; export const A2UI_TOOL_SCHEMAS = [ - { - type: 'function' as const, - function: { - name: 'preflight_import', - description: - '对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;预检结果会以可交互卡片展示列映射与策略确认,引导用户在卡内点击「生成导入向导」,无需在聊天里重复确认卡内已覆盖的问题。', - parameters: { - type: 'object', - properties: { - attachmentId: { - type: 'integer', - description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。', - }, - headerRow: { - type: 'integer', - description: '表头所在行(从 1 开始,默认 1)。预检时对整个文件使用该行作为表头。', - minimum: 1, - maximum: 1000, - }, - }, - required: ['attachmentId'], - additionalProperties: false, - }, - }, - }, { type: 'function' as const, function: { @@ -71,7 +46,7 @@ export const A2UI_TOOL_SCHEMAS = [ mapping: { type: 'object', description: - '列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。来自 preflight_import 报告的映射确认;未确认时省略,系统自动识别。', + '列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。用户确认后传入;未确认时省略,系统自动识别。', additionalProperties: { type: 'object', description: '字段名 -> 工作表表头', @@ -80,7 +55,7 @@ export const A2UI_TOOL_SCHEMAS = [ }, organization: { type: 'string', - description: '确认后的校区名称(预检报告出现未知校区时由用户确认)', + description: '确认后的校区名称(出现未知校区时由用户确认)', maxLength: 100, }, updateExisting: { @@ -201,20 +176,18 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。 修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。 当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: -1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。 -2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。 - 报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 -3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。 -4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 +1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。 +2. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 +3. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。 -每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。 +每个回答回合最多调用一次 start_import_wizard;导入完成后由你给出下一步建议,不要自动执行后续写操作。 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 -上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。 +上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入时直接调用 start_import_wizard,系统会从文件解析表头与行数据。 业务工作流引导(重要): - 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。 - 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。 - 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 - 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。 -- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。 +- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;确认后调用 start_import_wizard 生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts index e092adc..49339f9 100644 --- a/apps/server/src/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/ai-chat/ai-chat.controller.ts @@ -30,7 +30,6 @@ import { EditMessageDto, MessagePageQueryDto, RegenerateMessageDto, - ResolveImportPreflightDto, SendMessageDto, SubmitFormDto, SubmitReviewDto, @@ -238,23 +237,6 @@ export class AiChatController { ); } - @Post('import/preflight/:messageId/resolve/stream') - @Throttle({ default: { ttl: 60000, limit: 10 } }) - async resolveImportPreflight( - @Req() req: AuthenticatedRequest, - @Res() res: Response, - @Param('messageId', ParseIntPipe) messageId: number, - @Body() dto: ResolveImportPreflightDto, - ): Promise { - const conversationId = await this.service.resolvePreflightConversationId( - req.user.id, - messageId, - ); - return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) => - this.service.resolveImportPreflight(req.user, messageId, dto, signal, emit, onReady), - ); - } - @Post('reviews/:reviewId/steps/:sectionKey/confirm') async confirmReviewStep( @Req() req: AuthenticatedRequest, diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index 6a63bf6..1d86acd 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -73,8 +73,7 @@ export async function executeGeneration( tool.function.name !== 'create_student' && tool.function.name !== 'update_students' && tool.function.name !== 'render_form' && - tool.function.name !== 'start_import_wizard' && - tool.function.name !== 'preflight_import', + tool.function.name !== 'start_import_wizard', ); } tools.push(...A2UI_TOOL_SCHEMAS); diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts index 92e3c07..6daeb0f 100644 --- a/apps/server/src/ai-chat/ai-chat.service-base.ts +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -47,9 +47,7 @@ import { } from './ai-chat.streaming'; import { resolveFormConversationId, - resolvePreflightConversationId, resolveReviewConversationId, - resolveImportPreflight, submitForm, submitReview, confirmReviewStep, @@ -63,10 +61,7 @@ import { markReviewSubmittedOnMessage, } from './ai-chat.submissions'; import { denyWriteTool, executeTool } from './ai-chat.tools'; -import { - executePreflightImport, - executeStartImportWizard, -} from './ai-chat.tool-actions'; +import { executeStartImportWizard } from './ai-chat.tool-actions'; export abstract class AiChatServiceBase implements AiChatServiceContext { readonly activeConversations = new Set(); @@ -209,15 +204,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return executeStartImportWizard(this, messageId, call, context, emit); } - executePreflightImport( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executePreflightImport(this, messageId, call, context, emit); - } - listConversations(userId: number): Promise { return listConversations(this, userId); } @@ -318,10 +304,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return resolveReviewConversationId(this, userId, reviewId); } - resolvePreflightConversationId(userId: number, messageId: number): Promise { - return resolvePreflightConversationId(this, userId, messageId); - } - submitForm( user: AuthenticatedUser, formId: string, @@ -348,21 +330,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return submitReview(this, user, reviewId, dto, signal, emit, onReady); } - resolveImportPreflight( - user: AuthenticatedUser, - messageId: number, - dto: { - clientRequestId: string; - mapping?: Record; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); - } - confirmReviewStep( user: AuthenticatedUser, reviewId: string, diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 9b2fb64..50a5f94 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1503,259 +1503,6 @@ describe('AiChatService', () => { ); }); - it('preflight_import 生成预检报告并通过 ui.import_preflight 推送', async () => { - const { service } = createService(); - const toolRun = { id: 1, status: 'running' }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...toolRun, ...value })), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), - save: jest.fn(async (value) => value), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const report = { - verdict: 'ready', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2, - create: 2, - update: 0, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [], - questions: [], - nextSteps: [], - }; - const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; - (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - const emitted: Array<{ event: string; data: Record }> = []; - const result = await ( - service as unknown as { - executePreflightImport( - messageId: number, - call: { id: string; name: string; arguments: string }, - context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, - emit: (event: string, data?: unknown) => void, - ): Promise; - } - ).executePreflightImport( - 42, - { - id: 'call-1', - name: 'preflight_import', - arguments: JSON.stringify({ attachmentId: 9 }), - }, - { userId: 7, permissions: [], isSuperAdmin: false }, - (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), - ); - - const parsed = JSON.parse(result) as { status: string; report: unknown }; - expect(parsed.status).toBe('success'); - expect(parsed.report).toEqual(report); - expect(parsed).toMatchObject({ permittedSteps: [] }); - expect(importsService.preflightFile).toHaveBeenCalledWith( - expect.objectContaining({ originalName: 'students.xlsx' }), - 1, - ); - expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); - expect(messages.save).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - a2uiImportPreflight: { - ...report, - attachmentId: 9, - headerRow: 1, - permittedSteps: [], - resolved: false, - runId: null, - }, - }), - }), - ); - }); - - it('preflight_import 校验并透传 headerRow', async () => { - const { service } = createService(); - const toolRun = { id: 1, status: 'running' }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...toolRun, ...value })), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), - save: jest.fn(async (value) => value), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const report = { - verdict: 'ready', - stages: [], - blocks: [], - questions: [], - errorSamples: [], - nextSteps: [], - }; - const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; - (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - const result = await ( - service as unknown as { - executePreflightImport( - messageId: number, - call: { id: string; name: string; arguments: string }, - context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, - emit: (event: string, data?: unknown) => void, - ): Promise; - } - ).executePreflightImport( - 42, - { - id: 'call-1', - name: 'preflight_import', - arguments: JSON.stringify({ attachmentId: 9, headerRow: 5 }), - }, - { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, - jest.fn(), - ); - - const parsed = JSON.parse(result) as { status: string; permittedSteps: string[] }; - expect(parsed.status).toBe('success'); - expect(parsed.permittedSteps).toEqual(['students']); - expect(importsService.preflightFile).toHaveBeenCalledWith( - expect.objectContaining({ originalName: 'students.xlsx' }), - 5, - ); - }); - - it('preflight_import 结果超过 32KB 时返回精简版,SSE 仍推送完整报告', async () => { - const { service } = createService(); - const toolRun = { id: 1, status: 'running' }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...toolRun, ...value })), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }), - save: jest.fn(async (value) => value), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const report = { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2000, - create: 0, - update: 0, - error: 2000, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [], - questions: [{ key: 'mapping_students', type: 'mapping', label: '确认列映射' }], - errorSamples: Array.from({ length: 2000 }, (_, index) => ({ - code: 'format_error', - stepKey: 'students', - sheet: '学生', - rowNumber: index + 2, - errors: ['手机号格式不正确:'.repeat(20)], - })), - nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '下一步' }], - }; - const importsService = { preflightFile: jest.fn().mockResolvedValue(report) }; - (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - const emitted: Array<{ event: string; data: Record }> = []; - - const result = await ( - service as unknown as { - executePreflightImport( - messageId: number, - call: { id: string; name: string; arguments: string }, - context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, - emit: (event: string, data?: unknown) => void, - ): Promise; - } - ).executePreflightImport( - 42, - { - id: 'call-1', - name: 'preflight_import', - arguments: JSON.stringify({ attachmentId: 9 }), - }, - { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, - (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), - ); - - const parsed = JSON.parse(result) as { - status: string; - truncated: boolean; - report: { verdict: string; errorSamples: unknown[] }; - }; - expect(parsed.status).toBe('success'); - expect(parsed.truncated).toBe(true); - expect(parsed.report.verdict).toBe('needs_input'); - expect(parsed.report.errorSamples).toHaveLength(10); - const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); - expect(preflightEvent).toBeDefined(); - expect( - (preflightEvent?.data as { preflight?: { errorSamples?: unknown[] } }).preflight - ?.errorSamples, - ).toHaveLength(2000); - }); - - it('start_import_wizard 拒绝非法的确认参数', async () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; @@ -1813,542 +1560,6 @@ describe('AiChatService', () => { expect(importsService.createRun).not.toHaveBeenCalled(); }); - it('resolveImportPreflight 生成导入任务并原位更新预检卡', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const message = { - id: 42, - conversationId: 3, - role: 'assistant', - metadata: { - a2uiImportPreflight: { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - headers: ['姓名', '学号', '手机号'], - mapping: { name: '姓名' }, - missingRequired: [], - total: 1, - create: 1, - update: 0, - error: 0, - skip: 0, - }, - ], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: ['students'], - resolved: false, - runId: null, - }, - }, - }; - const messages = { - findOne: jest.fn().mockResolvedValue(message), - save: jest.fn(async (value) => value), - exists: jest.fn().mockResolvedValue(false), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const importsService = { - createRun: jest.fn().mockResolvedValue({ - id: 'run-9', - fileName: 'students.xlsx', - sheets: [], - steps: [ - { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, - ], - }), - }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - const emitted: Array<{ event: string; data: Record }> = []; - await service.resolveImportPreflight( - authenticatedUser, - 42, - { - clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', - mapping: { students: { name: '姓名', studentNo: '学号' } }, - settings: { updateExisting: false }, - }, - new AbortController().signal, - (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), - jest.fn(), - ); - expect(importsService.createRun).toHaveBeenCalledWith( - { id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false }, - 'ai', - expect.objectContaining({ originalName: 'students.xlsx' }), - 3, - [{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }], - { students: { name: '姓名', studentNo: '学号' } }, - { updateExisting: false }, - ); - expect(messages.save).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), - a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), - }), - }), - ); - expect(emitted.map(({ event }) => event)).toEqual( - expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), - ); - const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight'); - expect( - (preflightEvent?.data as { preflight?: { resolved?: boolean; runId?: string | null } }) - .preflight, - ).toMatchObject({ resolved: true, runId: 'run-9' }); - }); - - it('resolveImportPreflight 多工作表阶段携带全部 sheetNames 生成导入任务', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const message = { - id: 42, - conversationId: 3, - role: 'assistant', - metadata: { - a2uiImportPreflight: { - verdict: 'needs_input', - stages: [ - { - stepKey: 'checkins', - label: '入住管理', - sheetNames: ['四人间女', '四人间男'], - headers: ['姓名', '学号', '手机号', '宿舍号', '入住日期'], - mapping: { name: '姓名', roomNumber: '宿舍号' }, - missingRequired: [], - total: 2, - create: 2, - update: 0, - error: 0, - skip: 0, - }, - ], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: ['checkins'], - resolved: false, - runId: null, - }, - }, - }; - const messages = { - findOne: jest.fn().mockResolvedValue(message), - save: jest.fn(async (value) => value), - exists: jest.fn().mockResolvedValue(false), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'dorm.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const importsService = { - createRun: jest.fn().mockResolvedValue({ - id: 'run-9', - fileName: 'dorm.xlsx', - sheets: [], - steps: [ - { - stepKey: 'checkins', - label: '入住管理', - sheets: ['四人间女', '四人间男'], - status: 'pending', - }, - ], - }), - }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - await service.resolveImportPreflight( - authenticatedUser, - 42, - { clientRequestId: 'multi-sheet', mapping: {}, settings: {} }, - new AbortController().signal, - jest.fn(), - jest.fn(), - ); - - expect(importsService.createRun).toHaveBeenCalledWith( - { id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false }, - 'ai', - expect.objectContaining({ originalName: 'dorm.xlsx' }), - 3, - [{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }], - {}, - {}, - ); - }); - - it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const preflight = { - verdict: 'ready', - stages: [], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: [], - resolved: true, - runId: 'run-1', - }; - const messages = { - findOne: jest - .fn() - .mockResolvedValue({ - id: 42, - conversationId: 3, - role: 'assistant', - metadata: { - a2uiImportPreflight: preflight, - a2uiImportWizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] }, - }, - }), - exists: jest.fn().mockResolvedValue(false), - }; - const importsService = { createRun: jest.fn() }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { importsService: unknown }).importsService = importsService; - - const emitted: Array<{ event: string }> = []; - await service.resolveImportPreflight( - authenticatedUser, - 42, - { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, - new AbortController().signal, - (event) => emitted.push({ event }), - jest.fn(), - ); - - expect(importsService.createRun).not.toHaveBeenCalled(); - expect(emitted.map(({ event }) => event)).toEqual( - expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']), - ); - }); - - it('resolveImportPreflight 无预检 metadata 时拒绝', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ - id: 42, - conversationId: 3, - role: 'assistant', - metadata: null, - }), - exists: jest.fn().mockResolvedValue(false), - }; - const importsService = { createRun: jest.fn() }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { importsService: unknown }).importsService = importsService; - - await expect( - service.resolveImportPreflight( - authenticatedUser, - 42, - { clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' }, - new AbortController().signal, - jest.fn(), - jest.fn(), - ), - ).rejects.toBeInstanceOf(BadRequestException); - expect(importsService.createRun).not.toHaveBeenCalled(); - }); - - it('resolveImportPreflight 拒绝映射到表头之外的列', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ - id: 42, - conversationId: 3, - role: 'assistant', - metadata: { - a2uiImportPreflight: { - verdict: 'blocked', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - headers: ['姓名', '学号'], - mapping: { name: '姓名' }, - missingRequired: [], - total: 1, - create: 0, - update: 0, - error: 0, - skip: 0, - }, - ], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: ['students'], - resolved: false, - runId: null, - }, - }, - }), - exists: jest.fn().mockResolvedValue(false), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const importsService = { createRun: jest.fn() }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - await expect( - service.resolveImportPreflight( - authenticatedUser, - 42, - { - clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', - mapping: { students: { name: '不存在的列' } }, - }, - new AbortController().signal, - jest.fn(), - jest.fn(), - ), - ).rejects.toThrow('不在工作表表头中'); - expect(importsService.createRun).not.toHaveBeenCalled(); - }); - - it('resolveImportPreflight 拒绝非法的策略参数', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ - id: 42, - conversationId: 3, - role: 'assistant', - metadata: { - a2uiImportPreflight: { - verdict: 'ready', - stages: [], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: [], - resolved: false, - runId: null, - }, - }, - }), - exists: jest.fn().mockResolvedValue(false), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const importsService = { createRun: jest.fn() }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - await expect( - service.resolveImportPreflight( - authenticatedUser, - 42, - { - clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e', - settings: { duplicatePolicy: 'bogus' }, - }, - new AbortController().signal, - jest.fn(), - jest.fn(), - ), - ).rejects.toThrow('duplicatePolicy'); - expect(importsService.createRun).not.toHaveBeenCalled(); - }); - - it('resolvePreflightConversationId 返回消息所属会话', async () => { - const { service } = createService(); - const conversations = { - findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ - id: 42, - role: 'assistant', - conversation: { id: 3, userId: 7 }, - }), - }; - (service as unknown as { conversations: unknown }).conversations = conversations; - (service as unknown as { messages: unknown }).messages = messages; - - await expect(service.resolvePreflightConversationId(7, 42)).resolves.toBe(3); - expect(messages.findOne).toHaveBeenCalledWith({ - where: { id: 42 }, - relations: { conversation: true }, - }); - }); - - it('start_import_wizard 在已有预检卡时同步标记 resolved', async () => { - const { service } = createService(); - const toolRun = { id: 1, status: 'running' }; - const toolRuns = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...toolRun, ...value })), - }; - const messages = { - findOne: jest.fn().mockResolvedValue({ - id: 42, - conversationId: 3, - metadata: { - a2uiImportPreflight: { - verdict: 'ready', - stages: [], - blocks: [], - questions: [], - nextSteps: [], - errorSamples: [], - attachmentId: 9, - headerRow: 1, - permittedSteps: ['students'], - resolved: false, - runId: null, - }, - }, - }), - save: jest.fn(async (value) => value), - }; - const attachmentService = { - requireReadyOwned: jest.fn().mockResolvedValue([ - { - id: 9, - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - originalName: 'students.xlsx', - size: 10, - }, - ]), - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), - }; - const importsService = { - createRun: jest.fn().mockResolvedValue({ - id: 'run-9', - fileName: 'students.xlsx', - sheets: [], - steps: [ - { stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }, - ], - }), - }; - (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; - (service as unknown as { messages: unknown }).messages = messages; - (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; - (service as unknown as { importsService: unknown }).importsService = importsService; - - const emitted: Array<{ event: string; data: Record }> = []; - await ( - service as unknown as { - executeStartImportWizard( - messageId: number, - call: { id: string; name: string; arguments: string }, - context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, - emit: (event: string, data?: unknown) => void, - ): Promise; - } - ).executeStartImportWizard( - 42, - { - id: 'call-1', - name: 'start_import_wizard', - arguments: JSON.stringify({ - attachmentId: 9, - stages: [{ stepKey: 'students', sheet: '学生' }], - }), - }, - { userId: 7, permissions: ['student:import'], isSuperAdmin: false }, - (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), - ); - - expect(messages.save).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }), - a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }), - }), - }), - ); - expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); - }); }); diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 94dacc6..ea8a9ad 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,20 +1,8 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { AiChatServiceContext, AiSseEmitter, } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; -import type { - ImportStageRequest, - ImportStepKey, - PreflightReport, -} from '../imports/imports.types'; -import { - isExcelAttachment, - parseConfirmedMapping, - parseNestedSettings, -} from './ai-chat.import-confirm'; -import { compactImportWizard } from './ai-chat.tool-actions'; export async function resolveFormConversationId( context: AiChatServiceContext, @@ -34,149 +22,6 @@ export async function resolveReviewConversationId( return review.conversationId; } -export async function resolvePreflightConversationId( - context: AiChatServiceContext, - userId: number, - messageId: number, -): Promise { - const message = await context.messages.findOne({ - where: { id: messageId }, - relations: { conversation: true }, - }); - if (!message) throw new NotFoundException('消息不存在'); - if (message.role !== 'assistant') { - throw new BadRequestException('该消息不是助手消息,无法确认导入预检'); - } - const conversation = await context.requireOwnedConversation(userId, message.conversation.id); - return conversation.id; -} - -function readPreflightCard( - metadata: Record | null | undefined, -): PreflightReport { - const card = metadata?.a2uiImportPreflight; - if (!card || typeof card !== 'object' || Array.isArray(card)) { - throw new BadRequestException('预检报告不存在或已失效'); - } - if (!isPreflightReport(card)) { - throw new BadRequestException('预检报告格式异常'); - } - return card; -} - -function isPreflightReport(value: object): value is PreflightReport { - const record = value as Record; - return ( - typeof record.verdict === 'string' && - Array.isArray(record.stages) && - Array.isArray(record.blocks) && - Array.isArray(record.questions) && - Array.isArray(record.nextSteps) && - Array.isArray(record.errorSamples) - ); -} - -export async function resolveImportPreflight( - context: AiChatServiceContext, - user: AuthenticatedUser, - messageId: number, - dto: { - clientRequestId: string; - mapping?: Record; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - context.throwIfAborted(signal); - const message = await context.messages.findOne({ where: { id: messageId } }); - if (!message) throw new NotFoundException('消息不存在'); - if (message.role !== 'assistant') { - throw new BadRequestException('该消息不是助手消息,无法确认导入预检'); - } - const conversation = await context.requireOwnedConversation(user.id, message.conversationId); - const preflight = readPreflightCard(message.metadata); - - await context.acquireConversation(conversation.id); - try { - context.throwIfAborted(signal); - const existingWizard = message.metadata?.a2uiImportWizard; - if (existingWizard && typeof existingWizard === 'object' && !Array.isArray(existingWizard)) { - onReady(); - emit('ui.import_preflight', { - messageId, - preflight: { ...preflight, resolved: true }, - }); - emit('ui.import_wizard', { messageId, wizard: existingWizard }); - return; - } - - const attachmentId = preflight.attachmentId; - const headerRow = preflight.headerRow ?? 1; - if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { - throw new BadRequestException('预检报告缺少附件信息,请重新预检'); - } - const [attachment] = await context.attachmentService.requireReadyOwned(user.id, [ - attachmentId as number, - ]); - if (!isExcelAttachment(attachment)) { - throw new BadRequestException('附件不是 Excel 文件,无法生成导入向导'); - } - const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({ - stepKey: stage.stepKey, - sheets: stage.sheetNames, - headerRow, - })); - if (stages.some((stage) => !stage.sheets || stage.sheets.length === 0)) { - throw new BadRequestException('预检报告缺少工作表信息,请重新预检'); - } - const allowedHeadersByStep: Partial> = {}; - for (const stage of preflight.stages) { - allowedHeadersByStep[stage.stepKey] = stage.headers ?? []; - } - const mapping = parseConfirmedMapping(dto.mapping, { allowedHeadersByStep }); - const settings = parseNestedSettings(dto.settings); - if (!context.importsService) throw new BadRequestException('导入向导服务未配置'); - - const buffer = await context.attachmentService.readStoredBuffer(attachment); - const detail = await context.importsService.createRun( - { - id: user.id, - permissions: [...user.permissions], - isSuperAdmin: user.isSuperAdmin, - }, - 'ai', - { - originalName: attachment.originalName, - mimeType: attachment.mimeType, - size: attachment.size, - buffer, - }, - conversation.id, - stages, - mapping, - settings, - ); - const wizard = compactImportWizard(detail); - message.metadata = { - ...message.metadata, - a2uiImportPreflight: { ...preflight, resolved: true, runId: detail.id }, - a2uiImportWizard: wizard, - }; - await context.messages.save(message); - - onReady(); - emit('ui.import_preflight', { - messageId, - preflight: { ...preflight, resolved: true, runId: detail.id }, - }); - emit('ui.import_wizard', { messageId, wizard }); - } finally { - context.activeConversations.delete(conversation.id); - } -} - export { assertReviewImportPermissions, confirmReviewGroup, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index 25067d9..d5e07b1 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -2,7 +2,6 @@ import { IMPORT_STEP_KEYS, type ImportStageRequest, type ImportStepKey, - type PreflightReport, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; @@ -100,76 +99,13 @@ type ImportToolExecutor = ( ) => Promise; function makeImportToolExecutor( - toolName: 'preflight_import' | 'start_import_wizard', + toolName: 'start_import_wizard', handler: (tool: ImportToolContext) => Promise, ): ImportToolExecutor { return (context, messageId, call, agentContext, emit) => runImportTool(context, messageId, call, agentContext, emit, toolName, handler); } -export const executePreflightImport = makeImportToolExecutor( - 'preflight_import', - async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { - const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); - const headerRow = - parsedRecord.headerRow === undefined ? 1 : Number(parsedRecord.headerRow); - if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) { - throw new Error('headerRow 必须是 1-1000 之间的整数'); - } - const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ - attachmentId as number, - ]); - if (!isExcelAttachment(attachment)) { - throw new Error('附件不是 Excel 文件,无法预检导入'); - } - if (!context.importsService) throw new Error('导入预检服务未配置'); - const buffer = await context.attachmentService.readStoredBuffer(attachment); - const preflight: PreflightReport = await context.importsService.preflightFile({ - originalName: attachment.originalName, - mimeType: attachment.mimeType, - size: attachment.size, - buffer, - }, headerRow); - const permittedSteps = permittedStepKeys({ - id: ac.userId, - permissions: [...ac.permissions], - isSuperAdmin: ac.isSuperAdmin, - }); - const preflightCard: PreflightReport = { - ...preflight, - attachmentId: attachment.id, - headerRow, - permittedSteps, - resolved: false, - runId: null, - }; - assistant.metadata = { - ...assistant.metadata, - a2uiImportPreflight: preflightCard, - }; - await context.messages.save(assistant); - - await finishToolRun(context, run, call, startedAt, { - status: 'success', - summary: `已完成导入预检:${preflight.stages - .map((stage) => `${stage.label} ${stage.total} 行`) - .join('、') || '未识别到可导入阶段'}`, - }, emit); - emit('ui.import_preflight', { messageId, preflight: preflightCard }); - emit('ui.artifact', { - messageId, - artifact: buildA2uiArtifact({ - type: 'import_preflight', - id: `preflight-${attachment.id}`, - status: 'pending', - messageId, - conversationId: assistant.conversationId, - payload: preflightCard, - }), - }); - return preflightModelPayload(preflight, permittedSteps); - }); - export const executeStartImportWizard = makeImportToolExecutor( 'start_import_wizard', async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { @@ -219,18 +155,8 @@ export const executeStartImportWizard = makeImportToolExecutor( settings, ); const wizard = compactImportWizard(detail); - const preflightMeta = assistant.metadata?.a2uiImportPreflight; assistant.metadata = { ...assistant.metadata, - ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) - ? { - a2uiImportPreflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - } - : {}), a2uiImportWizard: wizard, }; await context.messages.save(assistant); @@ -242,16 +168,6 @@ export const executeStartImportWizard = makeImportToolExecutor( .map((step) => step.label) .join('、')}`, }, emit); - if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { - emit('ui.import_preflight', { - messageId, - preflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - }); - } emit('ui.import_wizard', { messageId, wizard }); emit('ui.artifact', { messageId, @@ -279,46 +195,6 @@ export const executeStartImportWizard = makeImportToolExecutor( }); }); -function preflightModelPayload( - report: PreflightReport, - permittedSteps: ImportStepKey[], -): string { - const guidance = - '预检报告已以卡片展示:请引导用户在卡内确认列映射与策略并点击「生成导入向导」;' + - '仅当用户在聊天文本中显式给出确认时才调用 start_import_wizard'; - const fullPayload = JSON.stringify({ - status: 'success', - report, - permittedSteps, - message: guidance, - }); - if (fullPayload.length <= 32 * 1024) return fullPayload; - return JSON.stringify({ - status: 'success', - truncated: true, - report: { - verdict: report.verdict, - stages: report.stages.map((stage) => ({ - stepKey: stage.stepKey, - label: stage.label, - sheetNames: stage.sheetNames, - total: stage.total, - create: stage.create, - update: stage.update, - error: stage.error, - skip: stage.skip, - mapping: stage.mapping, - missingRequired: stage.missingRequired, - })), - questions: report.questions, - errorSamples: report.errorSamples.slice(0, 10), - nextSteps: report.nextSteps, - }, - permittedSteps, - message: guidance, - }); -} - export function compactImportWizard(detail: any): { runId: string; fileName: string; diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 7b8fe3d..aea7f18 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -270,6 +270,5 @@ export async function executeRenderChart( export { compactImportWizard, - executePreflightImport, executeStartImportWizard, } from './ai-chat.tool-actions.import'; diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index 65376c0..3a0e6c1 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -2,7 +2,6 @@ import { AgentToolContextFactory } from '../agent-tools/agent-tool.types'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AiToolRun } from './entities'; import { - executePreflightImport, executeRenderChart, executeRenderForm, executeStartImportWizard, @@ -89,9 +88,6 @@ export async function executeTool( if (call.name === 'render_form') { return executeRenderForm(context, messageId, call, userId, emit); } - if (call.name === 'preflight_import') { - return executePreflightImport(context, messageId, call, agentContext, emit); - } if (call.name === 'start_import_wizard') { return executeStartImportWizard(context, messageId, call, agentContext, emit); } diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index e51cd7e..fff9ed6 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -185,7 +185,6 @@ export type AiSseEventName = | 'ui.review' | 'ui.chart' | 'ui.artifact' - | 'ui.import_preflight' | 'ui.import_wizard' | 'attachment.processed' | 'message.completed' diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts index e4a5340..6b16241 100644 --- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts @@ -110,19 +110,6 @@ export class SubmitReviewDto { reasoningEffort?: string | null; } -export class ResolveImportPreflightDto { - @IsUUID() - clientRequestId: string; - - @IsOptional() - @IsObject() - mapping?: Record; - - @IsOptional() - @IsObject() - settings?: Record; -} - export class MessagePageQueryDto { @IsOptional() @Type(() => Number) diff --git a/apps/server/src/imports/entities/import-run.entity.ts b/apps/server/src/imports/entities/import-run.entity.ts index 6654311..b0d2fb2 100644 --- a/apps/server/src/imports/entities/import-run.entity.ts +++ b/apps/server/src/imports/entities/import-run.entity.ts @@ -23,7 +23,7 @@ export class ImportRun { @Column({ name: 'sheets_json', type: 'mediumtext' }) sheetsJson: string; - /** Serialized ImportRunSettings — confirmed mapping/policies from AI preflight. */ + /** Serialized ImportRunSettings — mapping/policies confirmed by the user. */ @Column({ name: 'settings_json', type: 'text', nullable: true }) settingsJson: string | null; diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts deleted file mode 100644 index 01f10fc..0000000 --- a/apps/server/src/imports/imports.preflight.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { Organization } from '../entities/organization.entity'; -import { Room } from '../entities/room.entity'; -import { Student } from '../entities/student.entity'; -import { Occupancy } from '../entities/occupancy.entity'; -import { buildPreflightReport } from './imports.preflight'; -import type { ImportSheetData } from './imports.workbook'; - -function sheet(name: string, headers: string[], rows: unknown[][]): ImportSheetData { - return { name, headers, rows: rows as ImportSheetData['rows'] }; -} - -function dataSourceOf(options: { - students?: Student[]; - rooms?: Room[]; - organizations?: Organization[]; - occupancies?: Occupancy[]; -} = {}) { - return { - getRepository: jest.fn((entity: unknown) => { - if (entity === Student) return { find: jest.fn().mockResolvedValue(options.students ?? []) }; - if (entity === Room) return { find: jest.fn().mockResolvedValue(options.rooms ?? []) }; - if (entity === Organization) { - return { find: jest.fn().mockResolvedValue(options.organizations ?? []) }; - } - if (entity === Occupancy) { - return { find: jest.fn().mockResolvedValue(options.occupancies ?? []) }; - } - return { find: jest.fn().mockResolvedValue([]) }; - }), - }; -} - -describe('buildPreflightReport', () => { - it('全新学生表判定为 ready,给出分阶段统计与下一步建议', async () => { - const report = await buildPreflightReport( - dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never, - [ - sheet('学生', ['姓名', '学号', '手机号'], [ - ['张三', '2024001', '13800138000'], - ['李四', '2024002', '13900139000'], - ]), - ], - ); - - expect(report.verdict).toBe('ready'); - expect(report.questions).toEqual([]); - expect(report.stages).toHaveLength(1); - expect(report.stages[0]).toMatchObject({ - stepKey: 'students', - total: 2, - create: 2, - update: 0, - error: 0, - skip: 0, - headers: ['姓名', '学号', '手机号'], - mapping: { name: '姓名', studentNo: '学号', phone: '手机号' }, - }); - expect(report.blocks).toEqual([]); - expect(report.nextSteps.some((step) => step.key === 'students-next')).toBe(true); - }); - - it('已匹配记录时判定为 needs_input 并提出更新策略问题', async () => { - const existing = { - id: 88, - name: '张三', - studentNo: '2024001', - phone: '13800138000', - } as Student; - const report = await buildPreflightReport( - dataSourceOf({ students: [existing] }) as never, - [sheet('学生', ['姓名', '学号'], [['张三', '2024001']])], - ); - - expect(report.verdict).toBe('needs_input'); - expect(report.stages[0]).toMatchObject({ total: 1, create: 0, update: 1 }); - expect(report.questions.some((question) => question.type === 'update')).toBe(true); - }); - - it('缺少必填列时判定为 blocked 并归因 missing_columns', async () => { - const report = await buildPreflightReport( - dataSourceOf() as never, - [sheet('宿舍', ['宿舍号', '楼栋'], [['A101', '1号楼']])], - ); - - expect(report.verdict).toBe('blocked'); - expect(report.blocks).toContainEqual( - expect.objectContaining({ code: 'missing_columns', count: 1, stepKeys: ['rooms'] }), - ); - expect(report.stages[0].missingRequired).toContain('容量'); - expect(report.questions.some((question) => question.type === 'mapping')).toBe(true); - }); - - it('无法识别任何业务表时判定为 blocked', async () => { - const report = await buildPreflightReport( - dataSourceOf() as never, - [sheet('杂项', ['A', 'B'], [['x', 'y']])], - ); - - expect(report.verdict).toBe('blocked'); - expect(report.blocks).toContainEqual(expect.objectContaining({ code: 'no_stages' })); - expect(report.stages).toEqual([]); - }); - - it('文件内重复入住归因 duplicate_in_file 并提出重复策略问题', async () => { - const student = { - id: 88, - name: '张三', - studentNo: '2024001', - phone: '13800138000', - } as Student; - const room = { id: 5, roomNumber: 'A101' } as Room; - const report = await buildPreflightReport( - dataSourceOf({ students: [student], rooms: [room] }) as never, - [ - sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [ - ['张三', '2024001', 'A101', '2026-09-01'], - ['张三', '2024001', 'A101', '2026-09-02'], - ]), - ], - ); - - expect(report.verdict).toBe('needs_input'); - expect(report.stages[0]).toMatchObject({ stepKey: 'checkins', total: 2, create: 1, error: 1 }); - expect(report.blocks).toContainEqual( - expect.objectContaining({ code: 'duplicate_in_file', count: 1, stepKeys: ['checkins'] }), - ); - expect(report.questions.some((question) => question.type === 'duplicate')).toBe(true); - expect(report.errorSamples).toContainEqual( - expect.objectContaining({ - code: 'duplicate_in_file', - stepKey: 'checkins', - sheet: '入住', - rowNumber: 3, - errors: expect.arrayContaining([expect.stringContaining('请勿重复导入')]), - }), - ); - }); - - it('未知校区归因 unknown_organization 并提出校区归属问题', async () => { - const report = await buildPreflightReport( - dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never, - [sheet('学生', ['姓名', '学号', '校区'], [['张三', '2024001', '东校区']])], - ); - - expect(report.verdict).toBe('needs_input'); - expect(report.blocks).toContainEqual( - expect.objectContaining({ code: 'unknown_organization', count: 1 }), - ); - const orgQuestion = report.questions.find((question) => question.type === 'organization'); - expect(orgQuestion).toBeDefined(); - expect(orgQuestion?.options?.map((option) => option.value)).toContain('主校区'); - }); - - it('入住找不到学生/宿舍归因引用缺失并提出未匹配处理问题', async () => { - const student = { - id: 88, - name: '张三', - studentNo: '2024001', - phone: '13800138000', - } as Student; - const report = await buildPreflightReport( - dataSourceOf({ students: [student] }) as never, - [ - sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [ - ['张三', '2024001', 'A101', '2026-09-01'], - ]), - ], - ); - - expect(report.verdict).toBe('needs_input'); - expect(report.blocks).toContainEqual( - expect.objectContaining({ code: 'room_not_found', count: 1, stepKeys: ['checkins'] }), - ); - expect(report.questions.some((question) => question.type === 'reference')).toBe(true); - }); - - it('格式错误归因 format_error', async () => { - const report = await buildPreflightReport( - dataSourceOf() as never, - [sheet('学生', ['姓名', '手机号'], [['张三', '123']])], - ); - - expect(report.blocks).toContainEqual( - expect.objectContaining({ code: 'format_error', count: 1, stepKeys: ['students'] }), - ); - expect(report.verdict).toBe('blocked'); - expect(report.errorSamples).toContainEqual( - expect.objectContaining({ - code: 'format_error', - stepKey: 'students', - sheet: '学生', - rowNumber: 2, - }), - ); - }); - - it('同一阶段多张工作表且表头不一致时按表解析列映射', async () => { - const students = [ - { id: 88, name: '张三', studentNo: '2024001', phone: '13800138000' } as Student, - { id: 89, name: '李四', studentNo: '2024002', phone: '13900139000' } as Student, - ]; - const room = { id: 5, roomNumber: 'A101' } as Room; - const report = await buildPreflightReport( - dataSourceOf({ students, rooms: [room] }) as never, - [ - sheet('四人间女', ['姓名', '学号', '宿舍号', '入住日期'], [ - ['张三', '2024001', 'A101', '2026-09-01'], - ]), - sheet('四人间男', ['学生姓名', '学号', '房号', '日期'], [ - ['李四', '2024002', 'A101', '2026-09-02'], - ]), - ], - ); - - expect(report.verdict).toBe('ready'); - const stage = report.stages.find((item) => item.stepKey === 'checkins'); - expect(stage).toBeDefined(); - expect(stage).toMatchObject({ - sheetNames: ['四人间女', '四人间男'], - total: 2, - create: 2, - update: 0, - error: 0, - }); - expect(stage?.mapping).toEqual({ - name: expect.stringMatching(/^姓名|学生姓名$/), - studentNo: '学号', - roomNumber: expect.stringMatching(/^宿舍号|房号$/), - checkInDate: expect.stringMatching(/^入住日期|日期$/), - }); - }); -}); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts deleted file mode 100644 index dc0c8ff..0000000 --- a/apps/server/src/imports/imports.preflight.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { DataSource } from 'typeorm'; -import { Organization } from '../entities/organization.entity'; -import { buildLookups } from './imports.lookups'; -import { resolveSheetMapping, suggestMapping, suggestStep } from './imports.mapping'; -import { validateRow, type ImportBatchState } from './imports.rows'; -import { - IMPORT_STEP_IDENTITY_FIELDS, - IMPORT_STEP_LABELS, - IMPORT_STEP_ORDER, - IMPORT_STEP_REQUIRED_FIELDS, -} from './imports.types'; -import type { - CellValue, - ColumnMapping, - ImportStepKey, - PreflightBlock, - PreflightBlockCode, - PreflightErrorSample, - PreflightNextStep, - PreflightQuestion, - PreflightReport, - PreflightStageStat, -} from './imports.types'; -import type { ImportSheetData } from './imports.workbook'; - -const BLOCK_META: Record = { - no_stages: { - label: '未识别工作表', - message: '没有识别到可导入的学生、宿舍、入住或换宿工作表,请检查表头', - }, - missing_columns: { - label: '缺少必填列', - message: '阶段缺少必需列映射,无法自动导入', - }, - student_not_found: { - label: '未找到学生', - message: '部分行找不到匹配学生,需先完成学生档案或核对学号/手机号', - }, - room_not_found: { - label: '未找到宿舍', - message: '部分行找不到匹配宿舍,需先完成宿舍档案或核对宿舍号', - }, - duplicate_in_file: { - label: '文件内重复', - message: '同一文件内存在重复在住/换宿记录', - }, - already_checked_in: { - label: '已有在住', - message: '学生已有在住记录,重复入住会被拦截', - }, - format_error: { - label: '格式错误', - message: '部分行存在格式或取值错误(日期、手机号、容量等)', - }, - unknown_organization: { - label: '未知校区', - message: '部分行填写的校区不存在,需要确认归属', - }, -}; - -const REQUIRED_FIELD_LABELS: Record = { - name: '姓名', - roomNumber: '宿舍号', - capacity: '容量', - checkInDate: '入住日期', - oldRoom: '原宿舍', - newRoom: '新宿舍', - transferDate: '换宿日期', - identity: '学号或手机号', -}; - -const NEXT_STEP_DEFS: Array = [ - { - key: 'students-next', - label: '分班 / 排课 / 入住', - description: '学生档案导入完成后,可继续分班、排课或录入入住记录。', - after: ['students'], - }, - { - key: 'rooms-next', - label: '入住 / 费用', - description: '宿舍档案导入完成后,可录入入住记录并维护宿舍费用。', - after: ['rooms'], - }, - { - key: 'checkins-next', - label: '费用 / 账单', - description: '入住记录导入完成后,可录入公共费用并生成账单。', - after: ['checkins'], - }, - { - key: 'transfers-next', - label: '账单核对', - description: '换宿完成后建议核对在住记录与账单,避免计费偏差。', - after: ['transfers'], - }, -]; - -interface StageAnalysis extends PreflightStageStat { - rowErrorCodes: PreflightBlockCode[]; - unknownOrgs: string[]; - errorSamples: PreflightErrorSample[]; -} - -function classifyErrors(errors: string[]): PreflightBlockCode[] { - const codes = new Set(); - for (const error of errors) { - if ( - error.includes('未找到匹配学生') || - error.includes('缺少学生标识') || - error.includes('未找到该学生在原宿舍的在住记录') - ) { - codes.add('student_not_found'); - } else if ( - error.includes('未找到宿舍') || - error.includes('未找到原宿舍') || - error.includes('未找到新宿舍') - ) { - codes.add('room_not_found'); - } else if ( - error.includes('请勿重复导入') || - error.includes('请勿重复换宿') || - error.includes('本次文件中已有') - ) { - codes.add('duplicate_in_file'); - } else if (error.includes('已有在住记录')) { - codes.add('already_checked_in'); - } else if (error.includes('未找到校区')) { - codes.add('unknown_organization'); - } else { - codes.add('format_error'); - } - } - return [...codes]; -} - -async function analyzeStage( - dataSource: DataSource, - stepKey: ImportStepKey, - sheets: ImportSheetData[], -): Promise { - // 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。 - const mapping: ColumnMapping = {}; - for (const sheet of sheets) { - const suggested = suggestMapping(sheet.headers, stepKey); - for (const [field, header] of Object.entries(suggested)) { - if (!mapping[field]) mapping[field] = header; - } - } - const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey]; - const missingRequired = required - .filter((field) => !mapping[field]) - .map((field) => REQUIRED_FIELD_LABELS[field] ?? field); - const identityFields = IMPORT_STEP_IDENTITY_FIELDS[stepKey]; - const hasIdentity = identityFields.some((field) => mapping[field]); - - let total = 0; - let create = 0; - let update = 0; - let error = 0; - const rowErrorCodes: PreflightBlockCode[] = []; - const errorSamples: PreflightErrorSample[] = []; - const sampleCounts = new Map(); - const unknownOrgs = new Set(); - const batchState: ImportBatchState = { - checkinStudentIds: new Set(), - transferStudentIds: new Set(), - }; - - for (const sheet of sheets) { - const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey); - const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, sheetMapping); - for (let i = 0; i < sheet.rows.length; i += 1) { - const rawValues = sheet.rows[i]; - const fields: Record = {}; - for (const [field, header] of Object.entries(sheetMapping)) { - fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; - } - const result = validateRow(stepKey, fields, lookups, batchState); - total += 1; - if (result.errors.length > 0) { - error += 1; - const codes = classifyErrors(result.errors); - rowErrorCodes.push(...codes); - for (const code of codes) { - const count = sampleCounts.get(code) ?? 0; - if (count < 2) { - sampleCounts.set(code, count + 1); - errorSamples.push({ - code, - stepKey, - sheet: sheet.name, - rowNumber: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1, - errors: result.errors, - }); - } - } - if (stepKey === 'students' && result.errors.some((item) => item.includes('未找到校区'))) { - const org = String(fields.organization ?? ''); - if (org) unknownOrgs.add(org); - } - } else if (result.action === 'create') { - create += 1; - const studentId = result.resolvedIds._studentId; - if (studentId !== undefined) { - if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId); - if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId); - } - } else if (result.action === 'update') { - update += 1; - } - } - } - - return { - stepKey, - label: IMPORT_STEP_LABELS[stepKey], - sheetNames: sheets.map((sheet) => sheet.name), - headers: [...new Set(sheets.flatMap((sheet) => sheet.headers))], - total, - create, - update, - error, - skip: 0, - mapping, - missingRequired: hasIdentity - ? missingRequired - : [...new Set([...missingRequired, REQUIRED_FIELD_LABELS.identity])], - rowErrorCodes, - unknownOrgs: [...unknownOrgs], - errorSamples, - }; -} - -function aggregateBlocks(stages: StageAnalysis[]): PreflightBlock[] { - const counts = new Map(); - const stepKeys = new Map>(); - const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => { - counts.set(code, (counts.get(code) ?? 0) + count); - const keys = stepKeys.get(code) ?? new Set(); - keys.add(stepKey); - stepKeys.set(code, keys); - }; - for (const stage of stages) { - if (stage.missingRequired.length > 0) { - add('missing_columns', stage.stepKey, stage.total); - } - for (const code of stage.rowErrorCodes) { - add(code, stage.stepKey, 1); - } - } - return [...counts.entries()] - .map(([code, count]) => ({ - code, - label: BLOCK_META[code].label, - stepKeys: [...(stepKeys.get(code) ?? [])], - message: BLOCK_META[code].message, - count, - })) - .sort((a, b) => b.count - a.count); -} - -function buildQuestions( - stages: StageAnalysis[], - existingOrganizations: string[], -): PreflightQuestion[] { - const questions: PreflightQuestion[] = []; - for (const stage of stages) { - if (stage.missingRequired.length > 0) { - questions.push({ - key: `mapping_${stage.stepKey}`, - type: 'mapping', - label: `确认「${stage.label}」列映射`, - description: `缺少必需列映射:${stage.missingRequired.join('、')};请确认工作表中对应的列名`, - stepKey: stage.stepKey, - }); - } - } - const totalUpdates = stages.reduce((sum, stage) => sum + stage.update, 0); - if (totalUpdates > 0) { - questions.push({ - key: 'update', - type: 'update', - label: `文件中有 ${totalUpdates} 行已匹配现有记录`, - description: '选择更新已有记录,或跳过已匹配的行(仅新建)', - options: [ - { label: '更新已有记录', value: 'true' }, - { label: '跳过已有记录', value: 'false' }, - ], - default: true, - }); - } - const unknownOrgs = [...new Set(stages.flatMap((stage) => stage.unknownOrgs))]; - if (unknownOrgs.length > 0) { - const options = [ - ...existingOrganizations.slice(0, 19).map((name) => ({ label: name, value: name })), - { label: '忽略校区', value: '' }, - ]; - questions.push({ - key: 'organization', - type: 'organization', - label: '确认校区归属', - description: `文件中存在未匹配的校区:${unknownOrgs.join('、')},请选择实际归属校区`, - options, - }); - } - if (stages.some((stage) => stage.rowErrorCodes.includes('duplicate_in_file'))) { - questions.push({ - key: 'duplicate', - type: 'duplicate', - label: '文件内存在重复在住/换宿记录', - description: '选择将重复行标记为错误,或按策略跳过重复行', - options: [ - { label: '标记为错误', value: 'error' }, - { label: '跳过重复行', value: 'skip' }, - ], - default: 'error', - }); - } - if ( - stages.some((stage) => - stage.rowErrorCodes.some( - (code) => code === 'student_not_found' || code === 'room_not_found', - ), - ) - ) { - questions.push({ - key: 'reference', - type: 'reference', - label: '存在未匹配的学生或宿舍', - description: '选择保留错误提示,或跳过找不到学生/宿舍的行继续导入', - options: [ - { label: '保留错误提示', value: 'false' }, - { label: '跳过未匹配行', value: 'true' }, - ], - default: false, - }); - } - return questions; -} - -function decideVerdict( - stages: StageAnalysis[], - questions: PreflightQuestion[], - hasStages: boolean, -): PreflightReport['verdict'] { - if (!hasStages) return 'blocked'; - if (stages.some((stage) => stage.missingRequired.length > 0)) return 'blocked'; - if ( - stages.some( - (stage) => - stage.total > 0 && - stage.total === stage.error && - stage.rowErrorCodes.length > 0 && - stage.rowErrorCodes.every((code) => code === 'format_error'), - ) - ) { - return 'blocked'; - } - if (questions.length > 0) return 'needs_input'; - return 'ready'; -} - -/** - * 生成“可插入性预检报告”:按业务依赖分阶段统计,归类阻断原因, - * 给出需要用户确认的问题与导入后的下一步建议。纯读操作,不写库。 - */ -export async function buildPreflightReport( - dataSource: DataSource, - sheets: ImportSheetData[], -): Promise { - const grouped = new Map(); - for (const sheet of sheets) { - const suggestion = suggestStep(sheet.headers); - if (!suggestion) continue; - const list = grouped.get(suggestion.stepKey) ?? []; - list.push(sheet); - grouped.set(suggestion.stepKey, list); - } - const stageKeys = IMPORT_STEP_ORDER.filter((stepKey) => grouped.has(stepKey)); - const hasStages = stageKeys.length > 0; - - const stages: StageAnalysis[] = []; - const existingOrganizations = new Set(); - if (hasStages) { - for (const stepKey of stageKeys) { - const analysis = await analyzeStage(dataSource, stepKey, grouped.get(stepKey) ?? []); - stages.push(analysis); - } - const organizations = await dataSource - .getRepository(Organization) - .find({ select: { name: true } }); - for (const org of organizations) existingOrganizations.add(org.name); - } - - const blocks = aggregateBlocks(stages); - if (!hasStages) { - blocks.push({ - code: 'no_stages', - label: BLOCK_META.no_stages.label, - stepKeys: [], - message: BLOCK_META.no_stages.message, - count: sheets.length, - }); - } - const questions = buildQuestions(stages, [...existingOrganizations]); - const detectedKeys = new Set(stages.map((stage) => stage.stepKey)); - const nextSteps = NEXT_STEP_DEFS.filter((step) => step.after.some((key) => detectedKeys.has(key))); - - return { - verdict: decideVerdict(stages, questions, hasStages), - stages: stages.map( - ({ - rowErrorCodes: _rowErrorCodes, - unknownOrgs: _unknownOrgs, - errorSamples: _errorSamples, - ...stat - }) => stat, - ), - blocks, - questions, - nextSteps, - errorSamples: stages.flatMap((stage) => stage.errorSamples), - }; -} diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 1551747..7a618d4 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -867,29 +867,6 @@ describe('ImportsService', () => { expect(result.rows[0].errors.join(';')).toContain('按策略跳过'); }); - it('preflightFile 透传 headerRow 到解析层', async () => { - const parseSpy = jest - .spyOn(workbookModule, 'parseSheets') - .mockResolvedValue([]); - try { - const service = new ImportsService( - makeRunsRepo({} as ImportRun) as never, - makeStepsRepo({} as ImportStep) as never, - makeRowsRepo() as never, - {} as never, - ); - const report = await service.preflightFile(fileOf('students.xlsx', Buffer.from('x')), 3); - expect(parseSpy).toHaveBeenCalledWith( - expect.any(Buffer), - 'students.xlsx', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 3, - ); - expect(report.verdict).toBe('blocked'); - } finally { - parseSpy.mockRestore(); - } - }); it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => { const workbook = new ExcelJS.Workbook(); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index 1506d45..92e5136 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -7,16 +7,12 @@ import { ImportRow } from './entities/import-row.entity'; import { ImportRunService } from './imports.run.service'; import { ImportPreviewService } from './imports.preview.service'; import { ImportCommitService } from './imports.commit.service'; -import { buildPreflightReport } from './imports.preflight'; -import { parseSheets } from './imports.workbook'; -import type { ParsedImportFile } from './imports.types'; export type { ImportSheetMeta, ImportStepDetail, ImportRunDetail, ImportRunSettings, - PreflightReport, StepPreviewResult, } from './imports.types'; @@ -71,18 +67,6 @@ export class ImportsService { return this.runsSvc.createRun(...args); } - /** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */ - async preflightFile( - file: ParsedImportFile, - headerRow = 1, - ): Promise { - if (!file.buffer || file.buffer.length === 0) { - throw new BadRequestException('上传文件为空'); - } - const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow); - return buildPreflightReport(this.dataSource, sheets); - } - async getRun(...args: Parameters) { return this.runsSvc.getRun(...args); } diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 4b62040..21ec3d3 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -150,94 +150,6 @@ export interface ImportRunSettings { skipUnmatched?: boolean; } -export type PreflightVerdict = 'ready' | 'needs_input' | 'blocked'; - -export interface PreflightStageStat { - stepKey: ImportStepKey; - label: string; - sheetNames: string[]; - /** 该阶段所有工作表的表头并集,供前端预检卡渲染列映射选项。 */ - headers: string[]; - total: number; - create: number; - update: number; - error: number; - skip: number; - mapping: ColumnMapping; - missingRequired: string[]; -} - -export type PreflightBlockCode = - | 'no_stages' - | 'missing_columns' - | 'student_not_found' - | 'room_not_found' - | 'duplicate_in_file' - | 'already_checked_in' - | 'format_error' - | 'unknown_organization'; - -export interface PreflightBlock { - code: PreflightBlockCode; - label: string; - stepKeys: ImportStepKey[]; - message: string; - count: number; -} - -export type PreflightQuestionType = - | 'mapping' - | 'organization' - | 'update' - | 'duplicate' - | 'reference'; - -export interface PreflightQuestionOption { - label: string; - value: string; -} - -export interface PreflightQuestion { - key: string; - type: PreflightQuestionType; - label: string; - description?: string; - stepKey?: ImportStepKey; - options?: PreflightQuestionOption[]; - default?: string | boolean; -} - -export interface PreflightNextStep { - key: string; - label: string; - description: string; - after: ImportStepKey[]; -} - -/** 预检报告中的错误示例(仅工作表、行号与错误信息,不含原始行数据)。 */ -export interface PreflightErrorSample { - code: PreflightBlockCode; - stepKey: ImportStepKey; - sheet: string; - rowNumber: number; - errors: string[]; -} - -export interface PreflightReport { - verdict: PreflightVerdict; - stages: PreflightStageStat[]; - blocks: PreflightBlock[]; - questions: PreflightQuestion[]; - nextSteps: PreflightNextStep[]; - errorSamples: PreflightErrorSample[]; - /** 以下字段由 AI 预检卡使用,普通预检报告生成时不设置。 */ - attachmentId?: number; - headerRow?: number; - permittedSteps?: ImportStepKey[]; - resolved?: boolean; - runId?: string | null; -} - export interface StepPreviewResult { stepKey: ImportStepKey; sheetNames: string[];