feat(review): 重构项目级单方案验收协作

This commit is contained in:
yuzhe
2026-07-22 16:41:03 +08:00
parent 6091d61612
commit e7e268d4eb
45 changed files with 1830 additions and 812 deletions

182
api/routes/works.ts Normal file
View File

@@ -0,0 +1,182 @@
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;