232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
/**
|
||
* 笔记路由
|
||
*/
|
||
import { Router, type Response, type NextFunction } from 'express';
|
||
import { upload } from '../upload.js';
|
||
import { notesService } from '../services/notesService.js';
|
||
import { recalculateCollectionStatus } from '../services/collectionsService.js';
|
||
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
|
||
import { database, withTransaction } from '../database.js';
|
||
import fs from 'fs';
|
||
import type { TextAnnotation } from '../../shared/types.js';
|
||
|
||
const router = Router();
|
||
|
||
function parseTags(value: unknown): string[] {
|
||
return Array.isArray(value)
|
||
? value.map((tag) => String(tag).trim()).filter(Boolean)
|
||
: String(value || '').trim() ? [String(value).trim()] : [];
|
||
}
|
||
|
||
function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
|
||
if (value === undefined) return { valid: true, urls: [] };
|
||
if (!Array.isArray(value)) return { valid: false, urls: [] };
|
||
const urls = value.map((item) => String(item).trim());
|
||
const valid = urls.length <= 30 && urls.every((url) => {
|
||
if (!url || url.length > 2048) return false;
|
||
try { return ['http:', 'https:'].includes(new URL(url).protocol); }
|
||
catch { return false; }
|
||
});
|
||
return { valid, urls };
|
||
}
|
||
|
||
// GET /api/notes - 笔记列表
|
||
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
const { sort, order, q, collectionId, status, tag, externalId } = req.query as {
|
||
sort?: string;
|
||
order?: string;
|
||
q?: string;
|
||
collectionId?: string;
|
||
status?: 'draft' | 'pending' | 'changes_requested' | 'approved';
|
||
tag?: string;
|
||
externalId?: string;
|
||
};
|
||
const groupId = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform' ? undefined : req.authUser?.group_id ?? undefined;
|
||
const projectId = req.apiKey?.scope === 'project' ? req.apiKey.project_id ?? undefined : undefined;
|
||
const list = await notesService.list({ sort, order, q, collectionId: collectionId ? Number(collectionId) : undefined, status, tag, groupId, projectId, externalId });
|
||
res.json(list);
|
||
});
|
||
|
||
// GET /api/notes/:noteId - 笔记详情
|
||
router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const id = Number(req.params.noteId);
|
||
if (!Number.isFinite(id)) {
|
||
res.status(400).json({ error: '无效的笔记 ID' });
|
||
return;
|
||
}
|
||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权查看该作品' }); return; }
|
||
const version = req.query.version ? Number(req.query.version) : undefined;
|
||
const detail = await notesService.getDetail(id, version);
|
||
if (!detail) {
|
||
res.status(404).json({ error: '笔记不存在' });
|
||
return;
|
||
}
|
||
res.json(detail);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
const noteId = Number(req.params.noteId);
|
||
const versionNumber = Number(req.body?.version_number);
|
||
const target = req.body?.target;
|
||
const content = String(req.body?.content ?? '').trim();
|
||
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
|
||
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; }
|
||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
|
||
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
|
||
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
|
||
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
|
||
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
|
||
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
|
||
});
|
||
|
||
// POST /api/notes - 上传新笔记 (multipart/form-data)
|
||
router.post(
|
||
'/',
|
||
requireWriter,
|
||
upload.array('images', 30),
|
||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const title = (req.body.title || '').toString().trim();
|
||
const description = (req.body.description || '').toString().trim();
|
||
const collectionId = Number(req.body.collectionId);
|
||
const tags = parseTags(req.body.tags);
|
||
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
|
||
const externalId = String(req.body.externalId ?? req.body.external_id ?? '').trim() || null;
|
||
if (!validImageUrls) {
|
||
res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' });
|
||
return;
|
||
}
|
||
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) {
|
||
res.status(400).json({ error: 'externalId 仅支持 1–128 位字母、数字、点、下划线、冒号和横线' });
|
||
return;
|
||
}
|
||
if (!title) {
|
||
res.status(400).json({ error: '标题不能为空' });
|
||
return;
|
||
}
|
||
if (!Number.isFinite(collectionId)) {
|
||
res.status(400).json({ error: '请选择作品交付集' });
|
||
return;
|
||
}
|
||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
|
||
if (!collection || !await canWriteProject(req, collection.project_id)) {
|
||
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
|
||
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
|
||
return;
|
||
}
|
||
if (externalId) {
|
||
const existing = await notesService.findByExternalId(collectionId, externalId);
|
||
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
|
||
}
|
||
if (files.length === 0 && imageUrls.length === 0) {
|
||
res.status(400).json({ error: '请至少上传一张图片' });
|
||
return;
|
||
}
|
||
let note;
|
||
try {
|
||
note = files.length
|
||
? await notesService.create(
|
||
title,
|
||
description,
|
||
files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })),
|
||
collectionId,
|
||
tags,
|
||
externalId,
|
||
)
|
||
: await notesService.createFromUrls(title, description, imageUrls, collectionId, tags, externalId);
|
||
} catch (error) {
|
||
const existing = externalId ? await notesService.findByExternalId(collectionId, externalId) : null;
|
||
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
|
||
throw error;
|
||
}
|
||
await audit(req, 'work.create', 'work', note.id, { collectionId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||
res.status(201).json(note);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
},
|
||
);
|
||
|
||
router.post('/:noteId/versions', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||
try {
|
||
const id = Number(req.params.noteId);
|
||
const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
|
||
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
|
||
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' }); return; }
|
||
if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
|
||
const title = String(req.body?.title ?? context.title).trim();
|
||
const description = String(req.body?.description ?? context.description).trim();
|
||
const tags = parseTags(req.body?.tags);
|
||
if (!title) { res.status(400).json({ error: '标题不能为空' }); return; }
|
||
const note = files.length
|
||
? await notesService.createVersion(id, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, req.authUser?.id)
|
||
: await notesService.createVersionFromUrls(id, title, description, imageUrls, tags, req.authUser?.id);
|
||
await audit(req, 'work.version_create', 'work', id, { versionNumber: note.version_number, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||
res.status(201).json(note);
|
||
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
|
||
});
|
||
|
||
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
const id = Number(req.params.noteId);
|
||
const status = req.body?.status;
|
||
if (!['draft', 'pending'].includes(status)) {
|
||
res.status(400).json({ error: '无效的验收状态' });
|
||
return;
|
||
}
|
||
const context = await database.one<{ project_id: number; review_status: string }>('SELECT c.project_id, n.review_status FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||
if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; }
|
||
if (!await notesService.setStatus(id, status)) {
|
||
res.status(404).json({ error: '作品不存在' });
|
||
return;
|
||
}
|
||
res.json({ success: true, status });
|
||
});
|
||
|
||
router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
|
||
const id = Number(req.params.noteId);
|
||
const reason = String(req.body?.reason ?? '').trim();
|
||
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
|
||
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number }>('SELECT n.review_status, n.version_number, n.collection_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
|
||
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
||
const actor = req.authUser!;
|
||
await withTransaction(async (tx) => {
|
||
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
||
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
|
||
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
|
||
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||
});
|
||
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
|
||
res.json({ success: true, status: 'pending' });
|
||
});
|
||
|
||
// DELETE /api/notes/:noteId - 删除笔记
|
||
router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
const id = Number(req.params.noteId);
|
||
if (!Number.isFinite(id)) {
|
||
res.status(400).json({ error: '无效的笔记 ID' });
|
||
return;
|
||
}
|
||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||
const ok = await notesService.remove(id);
|
||
if (!ok) {
|
||
res.status(404).json({ error: '笔记不存在' });
|
||
return;
|
||
}
|
||
res.status(204).end();
|
||
});
|
||
|
||
export default router;
|