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

73 lines
4.5 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 { 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: '请提供 130 张上传图片或公开图片 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;