feat(review): 重构项目级单方案验收协作
This commit is contained in:
72
api/routes/projectWorks.ts
Normal file
72
api/routes/projectWorks.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import { Router, type NextFunction, type Response } from 'express';
|
||||
import { canWriteProject, requireWriter, audit, type AuthRequest } from '../auth.js';
|
||||
import { database } from '../database.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { upload } from '../upload.js';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const projectId = Number(req.params.projectId);
|
||||
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该项目' }); return; }
|
||||
const { sort, order, q, status, tag, externalId } = req.query as Record<string, string | undefined>;
|
||||
res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters<typeof notesService.list>[0]['status'], tag, externalId }));
|
||||
});
|
||||
|
||||
router.post('/', 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 projectId = Number(req.params.projectId);
|
||||
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; }
|
||||
const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]);
|
||||
if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; }
|
||||
if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const title = String(req.body?.title ?? '').trim();
|
||||
const description = String(req.body?.description ?? '').trim();
|
||||
const tags = parseTags(req.body?.tags);
|
||||
const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null;
|
||||
const imageUrls = parseImageUrls(req.body?.images);
|
||||
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
|
||||
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; }
|
||||
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 1–30 张上传图片或公开图片 URL' }); return; }
|
||||
if (externalId) {
|
||||
const existing = await notesService.findByProjectExternalId(projectId, externalId);
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
}
|
||||
let work;
|
||||
try {
|
||||
work = files.length
|
||||
? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId)
|
||||
: await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId);
|
||||
} catch (error) {
|
||||
const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null;
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
throw error;
|
||||
}
|
||||
await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||||
res.status(201).json(work);
|
||||
} catch (error) { cleanup(); next(error); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user