feat(api): 支持外部客户端创建作品和更新版本

This commit is contained in:
yuzhe
2026-07-21 18:28:21 +08:00
parent b42f46c182
commit e4d1d3bcea
13 changed files with 418 additions and 56 deletions

View File

@@ -11,19 +11,38 @@ 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 } = req.query as {
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 });
const list = await notesService.list({ sort, order, q, collectionId: collectionId ? Number(collectionId) : undefined, status, tag, groupId, projectId, externalId });
res.json(list);
});
@@ -74,8 +93,17 @@ router.post(
const title = (req.body.title || '').toString().trim();
const description = (req.body.description || '').toString().trim();
const collectionId = Number(req.body.collectionId);
const tagsText = String(req.body.tags || '');
const tags = tagsText ? [tagsText] : [];
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 需要包含 130 个有效的 HTTP/HTTPS 图片 URL' });
return;
}
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) {
res.status(400).json({ error: 'externalId 仅支持 1128 位字母、数字、点、下划线、冒号和横线' });
return;
}
if (!title) {
res.status(400).json({ error: '标题不能为空' });
return;
@@ -91,18 +119,32 @@ router.post(
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return;
}
if (files.length === 0) {
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;
}
const note = await notesService.create(
title,
description,
files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })),
collectionId,
tags,
);
await audit(req, 'work.create', 'work', note.id, { collectionId, imageCount: files.length });
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);
@@ -117,14 +159,17 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
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; }
if (!files.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 130 个有效的 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 tagsText = String(req.body?.tags ?? '');
const tags = tagsText ? [tagsText] : [];
const tags = parseTags(req.body?.tags);
if (!title) { res.status(400).json({ error: '标题不能为空' }); return; }
const note = 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 audit(req, 'work.version_create', 'work', id, { versionNumber: note.version_number, imageCount: files.length });
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); }
});