Files
delivery-desk/api/services/notesService.ts

194 lines
13 KiB
TypeScript
Raw Normal View History

import sharp from 'sharp';
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus } 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 { storeUploadedFile } from '../storage.js';
import { recalculateCollectionStatus } from './collectionsService.js';
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
export interface UploadCandidate { candidate_name: string; title: string; description: string; tags: string[]; files: UploadedFile[] }
export interface UrlCandidate { candidate_name: string; title: string; description: string; tags: string[]; images: string[] }
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
type PreparedCandidate = { candidate_name: string; 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<StoredImage[]> {
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
}
function externalImages(images: string[]): StoredImage[] {
return images.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external', storageKey: '' }));
}
async function createRoundInTransaction(
tx: QueryContext,
noteId: number,
collectionId: number,
candidates: PreparedCandidate[],
createdBy: number | undefined,
fromStatus: ReviewStatus,
): Promise<{ roundId: number; roundNumber: number; firstVersion: 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 candidate_status = 'pending'", [note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status = '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 firstVersion = 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],
);
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const versionNumber = firstVersion + index;
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, candidate.title, candidate.description, JSON.stringify(candidate.tags), roundId, candidate.candidate_name, createdBy ?? null],
);
await imagesRepository.createMany(noteId, candidate.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, '工作台'],
);
}
const first = candidates[0];
await tx.execute(
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?",
[first.title, first.description, JSON.stringify(first.tags), firstVersion, roundId, noteId],
);
await recalculateCollectionStatus(collectionId, tx);
return { roundId, roundNumber, firstVersion };
}
async function prepareUploadCandidates(candidates: UploadCandidate[]): Promise<PreparedCandidate[]> {
return Promise.all(candidates.map(async (candidate) => ({
candidate_name: candidate.candidate_name,
title: candidate.title,
description: candidate.description,
tags: candidate.tags,
images: await prepareFiles(candidate.files),
})));
}
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): Promise<NoteDetail | null> {
const current = await notesRepository.findById(id);
if (!current) return null;
const selectedVersion = requestedVersion && requestedVersion !== 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, requestedVersion])
: undefined;
if (requestedVersion && requestedVersion !== 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 workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string; collection_status: NoteDetail['collection']['status'] }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name, c.status AS collection_status FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
if (!workContext) return null;
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v.candidate_name, v.candidate_status, v.created_at, r.round_number, r.status AS round_status, r.selected_version_number
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, v.version_number ASC`, [id]);
const result: NoteDetail = {
...note,
images: [] as ImageWithAnnotations[],
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), review_round_id: Number(item.review_round_id), round_number: Number(item.round_number), selected_version_number: item.selected_version_number == null ? null : Number(item.selected_version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status },
};
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
return result;
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const prepared = await prepareFiles(files);
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: prepared }], undefined, 'draft');
return id;
});
return (await notesRepository.findById(noteId))!;
},
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: externalImages(imageUrls) }], undefined, 'draft');
return id;
});
return (await notesRepository.findById(noteId))!;
},
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
return notesRepository.findByExternalId(collectionId, externalId);
},
async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = await prepareUploadCandidates(candidates);
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) }));
await withTransaction((tx) => createRoundInTransaction(tx, 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<Note> {
return this.createReviewRound(id, [{ candidate_name: '方案 A', title, description, tags, files }], createdBy);
},
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
return this.createReviewRoundFromUrls(id, [{ candidate_name: '方案 A', title, description, tags, images: imageUrls }], createdBy);
},
async remove(id: number) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number }>('SELECT collection_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);
return removed;
});
},
async setStatus(id: number, status: ReviewStatus) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_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) {
const candidateStatus = status === 'draft' ? 'draft' : 'pending';
await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ? AND candidate_status NOT IN ('selected', 'not_selected')", [status, candidateStatus, note.active_round_id]);
await tx.execute('UPDATE review_rounds SET status = ?, selected_version_number = NULL, completed_at = NULL WHERE id = ?', [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
}
await recalculateCollectionStatus(Number(note.collection_id), tx);
return true;
});
},
};