// aislop-ignore-file: duplicate-block -- 导入校验循环结构相似,逻辑已复用现有助手 import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { AiReview } from './entities/ai-review.entity'; import type { AiReviewSection, AiReviewSectionType, } from './entities/ai-review.entity'; import type { ExcelSheetRows } from './ai-excel-reader.service'; import { AiReviewStepSubmitResult, AiReviewSubmitResult, MAX_SECTIONS_JSON_BYTES, withInitialSectionState, } from './ai-review.shared'; import { buildSectionsFromWorkbookAsync, parseSections } from './ai-review.workbook'; import { validateSchema } from './ai-review.validation'; import { enrichWithIssues } from './ai-review.enrich'; import { submitAll, submitGroup, submitSection, } from './ai-review.submit'; import type { AiReviewSubmitContext } from './ai-review.submit'; /** * A2UI batch-import review lifecycle. * * The model parses an uploaded workbook (students / rooms / transfers), * calls `render_review`, and the user inspects per-table preview cards * before confirming. Confirmation runs each section in its own * transaction in dependency order: students → rooms → transfers → * checkins. Per-row problems are collected as issues and the row is * skipped instead of failing the whole import. */ @Injectable() export class AiReviewService { constructor( @InjectRepository(AiReview) private readonly reviews: Repository, private readonly dataSource: DataSource, ) {} private get submitContext(): AiReviewSubmitContext { return { reviews: this.reviews, dataSource: this.dataSource }; } /** * Validate `render_review` arguments and persist a pending review. * Throws BadRequestException when the schema is unsafe/invalid. */ async createReview( input: { userId: number; conversationId: number; assistantMessageId: number }, rawArgs: unknown, ): Promise { const schema = validateSchema(rawArgs); const sections = (await enrichWithIssues(this.dataSource, schema.sections)).map( withInitialSectionState, ); const sectionsJson = JSON.stringify(sections); if (Buffer.byteLength(sectionsJson, 'utf8') > MAX_SECTIONS_JSON_BYTES) { throw new BadRequestException('预览数据过大'); } return this.reviews.save( this.reviews.create({ id: uuidV7(), userId: input.userId, conversationId: input.conversationId, assistantMessageId: input.assistantMessageId, title: schema.title, summary: schema.summary, sectionsJson, status: 'pending', resultSummary: null, submittedAt: null, }), ); } async findOwnedPending(reviewId: string, userId: number): Promise { const review = await this.reviews.findOne({ where: { id: reviewId, userId, status: 'pending' }, }); if (!review) throw new NotFoundException('导入预览不存在、已确认或已失效'); return review; } /** * Mark every other pending review in the same conversation as expired. * Called after a new render_review is successfully created so older cards * are superseded instead of silently staying confirmable. */ async expirePreviousReviews( userId: number, conversationId: number, exceptReviewId: string, ): Promise { const pending = await this.reviews.find({ where: { userId, conversationId, status: 'pending' }, }); const expired = pending.filter((review) => review.id !== exceptReviewId); if (expired.length === 0) return []; const ids = expired.map((review) => review.id); await this.reviews.update({ id: In(ids) }, { status: 'expired' }); return expired.map((review) => ({ ...review, status: 'expired' as const })); } /** * Return a review owned by the user regardless of overall status. * Used by step confirmation so an already-completed card can respond * with a conflict instead of a plain not-found error. */ async findOwned(reviewId: string, userId: number): Promise { const review = await this.reviews.findOne({ where: { id: reviewId, userId }, }); if (!review) throw new NotFoundException('导入预览不存在'); return review; } /** * Return the newest pending review bound to an assistant message, if any. * Used to guarantee at most one batch-import preview card per message. */ async findPendingByAssistantMessage(assistantMessageId: number): Promise { return this.reviews.findOne({ where: { assistantMessageId, status: 'pending' }, order: { createdAt: 'DESC' }, }); } /** * 服务端直接解析上传的 Excel 生成审阅分表:行数据来自文件原文, * 不经过模型转抄,避免漏行/错值。表头按内置字典自动映射, * 模型可通过 sections[].columns[].sourceHeader 显式指定映射。 */ async buildSectionsFromWorkbook( sheets: ExcelSheetRows[], rawArgs: unknown, ): Promise { return await buildSectionsFromWorkbookAsync(sheets, rawArgs); } /** Public shape sent via `ui.review` SSE and mirrored into message metadata. */ serialize(review: AiReview): Record { return { id: review.id, title: review.title, summary: review.summary, sections: this.parseSections(review.sectionsJson), status: review.status, resultSummary: review.resultSummary, }; } parseSections(sectionsJson: string): AiReviewSection[] { return parseSections(sectionsJson); } async submitSection( reviewId: string, userId: number, sectionKey: string, ): Promise { return submitSection(this.submitContext, reviewId, userId, sectionKey); } async submitAll(reviewId: string, userId: number): Promise<{ review: AiReview; result: AiReviewSubmitResult; }> { return submitAll(this.submitContext, reviewId, userId); } async submitGroup( reviewId: string, userId: number, type: AiReviewSectionType, ): Promise<{ review: AiReview }> { return submitGroup(this.submitContext, reviewId, userId, type); } }