feat(review): 重构项目级单方案验收协作
This commit is contained in:
182
api/routes/works.ts
Normal file
182
api/routes/works.ts
Normal 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: '每轮必须提供 1–30 张图片' }); 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: '批注内容须为 1–1000 个字符' }); 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: '反馈内容须为 1–2000 个字符' }); 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: '回复内容须为 1–1000 个字符' }); 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;
|
||||
Reference in New Issue
Block a user