Files
delivery-desk/api/routes/works.ts

183 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import { Router, type NextFunction, type Response } from 'express';
import { audit, canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
import { notesService } from '../services/notesService.js';
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
import { upload } from '../upload.js';
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
const router = Router();
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
const raw = String(value ?? '').trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
catch { /* comma-separated form input */ }
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
}
function parseImageUrls(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
const urls = value.map((item) => String(item).trim());
return urls.every((url) => { try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } }) ? urls : null;
}
async function workProjectId(workId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT project_id FROM notes WHERE id = ?', [workId]))?.project_id;
}
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
router.get('/:workId', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品' }); return; }
const round = req.query.round ? Number(req.query.round) : undefined;
const detail = await notesService.getDetail(workId, undefined, round);
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
res.json(detail);
});
router.get('/:workId/annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
res.json(await notesService.getFeedback(workId));
});
router.get('/:workId/optimization-context', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.query.round);
const includeHistory = req.query.include_history === 'true';
if (!Number.isInteger(roundNumber) || roundNumber < 1) { res.status(400).json({ error: '请指定有效的验收轮次' }); return; }
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
const [detail, feedback] = await Promise.all([
notesService.getDetail(workId, undefined, roundNumber),
notesService.getFeedback(workId),
]);
const round = feedback?.rounds.find((item) => item.round_number === roundNumber);
if (!detail || !round) { res.status(404).json({ error: '验收轮次不存在' }); return; }
const visible = (item: { status: string; withdrawn_at: string | null }) => includeHistory || (item.status === 'open' && !item.withdrawn_at);
const repliesFor = (type: FeedbackType, id: number) => round.feedback_replies.filter((reply) => reply.feedback_type === type && reply.feedback_id === id && (includeHistory || !reply.withdrawn_at));
const withReplies = <T extends { id: number }>(type: FeedbackType, item: T) => ({ ...item, replies: repliesFor(type, item.id) });
res.json({
project: detail.project,
work_id: workId,
work_label: `Work ${String(workId).padStart(3, '0')}`,
round_number: roundNumber,
version_number: round.version_number,
content: {
title: detail.title,
description: detail.description,
tags: detail.tags,
images: detail.images.map(({ id, url, width, height, order_index }) => ({ image_id: id, url, width, height, order_index })),
},
feedback: {
image_annotations: round.image_annotations.filter(visible).map((item) => withReplies('image_annotation', item)),
text_annotations: round.text_annotations.filter(visible).map((item) => withReplies('text_annotation', item)),
general_comments: round.comments.filter(visible).map((item) => withReplies('comment', item)),
},
});
});
router.post('/:workId/rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权操作该作品' }); return; }
const projectState = await database.one<{ status: string }>('SELECT status FROM projects WHERE id=?', [projectId]);
if (!projectState || projectState.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const current = await notesService.getDetail(workId);
if (!current) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
const title = String(req.body?.title ?? current.title).trim();
const description = String(req.body?.description ?? current.description).trim();
const tags = parseTags(req.body?.tags ?? current.tags);
const imageUrls = parseImageUrls(req.body?.images);
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '每轮必须提供 130 张图片' }); return; }
const work = files.length
? await notesService.createRound(workId, { title, description, tags, files: files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) }, req.authUser?.id)
: await notesService.createRoundFromUrls(workId, { title, description, tags, images: imageUrls }, req.authUser?.id);
await audit(req, 'work.round_create', 'work', workId, { roundNumber: work.version_number, imageCount: files.length || imageUrls.length });
res.status(201).json(work);
} catch (error) { cleanup(); next(error); }
});
router.post('/:workId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const target = req.body?.target as 'title' | 'description' | 'tags';
const startOffset = Number(req.body?.start_offset);
const endOffset = Number(req.body?.end_offset);
const selectedText = String(req.body?.selected_text ?? '');
const content = String(req.body?.content ?? '').trim();
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string; project_id: number; project_review_status: string; project_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,n.project_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id JOIN projects p ON p.id=n.project_id
WHERE v.note_id=? AND r.round_number=?`, [workId, roundNumber]);
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
if (!await canWriteProject(req, Number(version.project_id))) { res.status(403).json({ error: '无权批注该作品' }); return; }
if (version.project_status !== 'active' || version.project_review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? parseTags(version.tags).join(' ') : '';
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const prefix = source.slice(Math.max(0, startOffset - 24), startOffset);
const suffix = source.slice(endOffset, endOffset + 24);
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?,?,?,?, 'operator')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, prefix, suffix, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { workId, roundNumber, target, startOffset, endOffset });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id = ?', [id]));
});
router.post('/:workId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number; version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.project_id,n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, Number(context.project_id))) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active' || context.review_status === 'completed' || context.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?, 'operator')", [workId, context.version_number, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.post('/:workId/feedback/:type/:feedbackId/replies', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
const content = String(req.body?.content ?? '').trim(); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 11000 个字符' }); return; }
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.authUser?.display_name || 'API', 'operator'));
});
router.post('/:workId/feedback/:type/:feedbackId/withdraw', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (target.withdrawn_at) { res.json({ success: true }); return; }
if (target.author_role !== 'operator' || target.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
await withdrawFeedback(type, feedbackId); res.json({ success: true });
});
export default router;