import sharp from 'sharp'; import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus, WorkFeedbackBundle, WorkRound } from '../../shared/types.js'; import { notesRepository } from '../repositories/notesRepository.js'; import { imagesRepository } from '../repositories/imagesRepository.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js'; import { storeExternalImageUrl, storeUploadedFile } from '../storage.js'; import { recalculateCollectionStatus } from './collectionsService.js'; import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.js'; export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string } export interface UploadRound { title: string; description: string; tags: string[]; files: UploadedFile[] } export interface UrlRound { title: string; description: string; tags: string[]; images: string[] } type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string }; type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] }; async function readImageSize(filePath: string): Promise<{ width: number; height: number }> { try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; } catch { return { width: 0, height: 0 }; } } async function prepareFiles(files: UploadedFile[]): Promise { return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) }))); } async function prepareExternalImages(images: string[]): Promise { const prepared: StoredImage[] = []; for (const image of images) prepared.push(await storeExternalImageUrl(image)); return prepared; } async function createRoundInTransaction( tx: QueryContext, noteId: number, projectId: number, collectionId: number, round: PreparedRound, createdBy: number | undefined, fromStatus: ReviewStatus, ): Promise<{ roundId: number; roundNumber: number; versionNumber: number }> { const note = await tx.one<{ active_round_id: number | null }>( `SELECT active_round_id FROM notes WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`, [noteId], ); if (note?.active_round_id) { await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'", [note.active_round_id]); await tx.execute("UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', 'reviewing')", [new Date().toISOString(), note.active_round_id]); } const maxima = await tx.one<{ max_version: number | string | null; max_round: number | string | null }>( `SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version, (SELECT MAX(round_number) FROM review_rounds WHERE note_id = ?) AS max_round`, [noteId, noteId], ); const versionNumber = Number(maxima?.max_version ?? 0) + 1; const roundNumber = Number(maxima?.max_round ?? 0) + 1; const roundId = await tx.insertId( "INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)", [noteId, roundNumber, createdBy ?? null], ); await tx.execute( "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, '', 'pending', ?)", [noteId, versionNumber, round.title, round.description, JSON.stringify(round.tags), roundId, createdBy ?? null], ); await imagesRepository.createMany(noteId, round.images, versionNumber, tx); await tx.execute( "INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [noteId, versionNumber, fromStatus, '工作台'], ); await tx.execute( "UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?", [round.title, round.description, JSON.stringify(round.tags), versionNumber, roundId, noteId], ); await recalculateCollectionStatus(collectionId, tx); await recalculateProjectReviewStatus(projectId, tx); return { roundId, roundNumber, versionNumber }; } async function prepareUploadRound(round: UploadRound): Promise { return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) }; } function mapRound(row: Omit & { tags: string }): WorkRound { return { ...row, version_number: Number(row.version_number), review_round_id: Number(row.review_round_id), round_number: Number(row.round_number), tags: JSON.parse(row.tags || '[]') as string[], }; } export const notesService = { async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) { return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId }); }, async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise { const current = await notesRepository.findById(id); if (!current) return null; const requested = requestedRound ? await database.one<{ version_number: number }>('SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?', [id, requestedRound]) : undefined; const targetVersion = requested ? Number(requested.version_number) : requestedVersion; const selectedVersion = targetVersion && targetVersion !== current.version_number ? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, targetVersion]) : undefined; if (targetVersion && targetVersion !== current.version_number && !selectedVersion) return null; const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current; const images = await imagesRepository.listByNote(id, note.version_number); const project = await database.one<{ id: number; name: string; slug: string; status: NoteDetail['project']['status']; review_status: NoteDetail['project']['review_status'] }>('SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?', [id]); if (!project) return null; const roundRows = await database.all & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id, v.created_at, r.round_number, r.status AS round_status, r.completion_reason FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? ORDER BY r.round_number DESC`, [id]); const result: NoteDetail = { ...note, images: [] as ImageWithAnnotations[], text_annotations: await database.all('SELECT * FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), comments: await database.all('SELECT * FROM work_comments WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), feedback_replies: await database.all('SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), rounds: roundRows.map(mapRound), review_events: await database.all('SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC', [id, note.version_number]), project: { id: Number(project.id), name: project.name, slug: project.slug, status: project.status, review_status: project.review_status }, }; for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) }); return result; }, async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], tags: string[], externalId: string | null = null): Promise { const prepared = await prepareFiles(files); return withTransaction(async (tx) => { const collectionId = await ensureProjectCompatibilityCollection(projectId, tx); const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise { const prepared = await prepareExternalImages(imageUrls); return withTransaction(async (tx) => { const collectionId = await ensureProjectCompatibilityCollection(projectId, tx); const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise { const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]); if (!collection) throw new Error('作品交付集不存在'); const projectId = Number(collection.project_id); const prepared = await prepareFiles(files); return withTransaction(async (tx) => { const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise { const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]); if (!collection) throw new Error('作品交付集不存在'); const projectId = Number(collection.project_id); const prepared = await prepareExternalImages(imageUrls); return withTransaction(async (tx) => { const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, async findByExternalId(collectionId: number, externalId: string): Promise { return notesRepository.findByExternalId(collectionId, externalId); }, async findByProjectExternalId(projectId: number, externalId: string): Promise { return notesRepository.findByProjectExternalId(projectId, externalId); }, async createRound(id: number, round: UploadRound, createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); const prepared = await prepareUploadRound(round); await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); const prepared = { ...round, images: await prepareExternalImages(round.images) }; await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise { return this.createRound(id, { title, description, tags, files }, createdBy); }, async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise { return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy); }, async getFeedback(id: number): Promise { const rounds = await database.all<{ round_number: number; version_number: number }>('SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC', [id]); if (!rounds.length && !await notesRepository.findById(id)) return null; const imageAnnotations = await database.all[number]>(`SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id`, [id]); const textAnnotations = await database.all[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]); const comments = await database.all('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]); const replies = await database.all('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]); const events = await database.all('SELECT * FROM review_events WHERE note_id = ? ORDER BY id', [id]); return { work_id: id, rounds: rounds.map((round) => ({ round_number: Number(round.round_number), version_number: Number(round.version_number), image_annotations: imageAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)), text_annotations: textAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)), comments: comments.filter((item) => Number(item.version_number) === Number(round.version_number)), feedback_replies: replies.filter((item) => Number(item.version_number) === Number(round.version_number)), review_events: events.filter((item) => Number(item.version_number) === Number(round.version_number)), })), }; }, async remove(id: number) { return withTransaction(async (tx) => { const note = await tx.one<{ collection_id: number; project_id: number }>('SELECT collection_id, project_id FROM notes WHERE id = ?', [id]); if (!note) return false; const removed = (await tx.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0; if (removed) { await recalculateCollectionStatus(Number(note.collection_id), tx); await recalculateProjectReviewStatus(Number(note.project_id), tx); } return removed; }); }, async setStatus(id: number, status: ReviewStatus) { return withTransaction(async (tx) => { const note = await tx.one<{ collection_id: number; project_id: number; active_round_id: number | null }>('SELECT collection_id, project_id, active_round_id FROM notes WHERE id = ?', [id]); if (!note) return false; await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]); if (note.active_round_id) { await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?", [status, status === 'draft' ? 'draft' : 'pending', note.active_round_id]); await tx.execute("UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]); } await recalculateCollectionStatus(Number(note.collection_id), tx); await recalculateProjectReviewStatus(Number(note.project_id), tx); return true; }); }, };