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

2
.gitignore vendored
View File

@@ -11,6 +11,8 @@ test-results/
.cache/ .cache/
.eslintcache .eslintcache
*.tsbuildinfo *.tsbuildinfo
__pycache__/
*.py[cod]
# Environment files and local secrets # Environment files and local secrets
.env .env

View File

@@ -115,6 +115,7 @@ db.exec(`
); );
CREATE TABLE IF NOT EXISTS notes ( CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -196,6 +197,7 @@ function addColumn(table: string, definition: string) {
} }
addColumn('notes', "collection_id INTEGER"); addColumn('notes', "collection_id INTEGER");
addColumn('notes', 'external_id TEXT');
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'"); addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'"); addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1'); addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1');
@@ -210,6 +212,8 @@ addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1'); addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('users', 'last_login_at TEXT'); addColumn('users', 'last_login_at TEXT');
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''");
const seedGroup = db.prepare('SELECT id FROM operation_groups ORDER BY id LIMIT 1').get() as { id: number } | undefined; const seedGroup = db.prepare('SELECT id FROM operation_groups ORDER BY id LIMIT 1').get() as { id: number } | undefined;
let groupId = seedGroup?.id; let groupId = seedGroup?.id;
if (!groupId) { if (!groupId) {

View File

@@ -10,7 +10,7 @@ export const imagesRepository = {
WHERE note_id = ?${versionNumber ? ' AND version_number = ?' : ''} ORDER BY order_index ASC, id ASC`, versionNumber ? [noteId, versionNumber] : [noteId]); WHERE note_id = ?${versionNumber ? ' AND version_number = ?' : ''} ORDER BY order_index ASC, id ASC`, versionNumber ? [noteId, versionNumber] : [noteId]);
return rows.map(toImage); return rows.map(toImage);
}, },
async createMany(noteId: number, images: { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos'; storageKey: string }[], versionNumber = 1, existing?: QueryContext): Promise<number[]> { async createMany(noteId: number, images: { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string }[], versionNumber = 1, existing?: QueryContext): Promise<number[]> {
const insert = async (tx: QueryContext) => { const insert = async (tx: QueryContext) => {
const ids: number[] = []; const ids: number[] = [];
for (let index = 0; index < images.length; index += 1) { for (let index = 0; index < images.length; index += 1) {

View File

@@ -2,7 +2,7 @@ import { database } from '../database.js';
import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js'; import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
interface NoteRow { interface NoteRow {
id: number; collection_id: number; title: string; description: string; tags: string; id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: string;
review_status: ReviewStatus; version_number: number; created_at: string; review_status: ReviewStatus; version_number: number; created_at: string;
image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null; image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null;
} }
@@ -18,17 +18,22 @@ function toNote(row: NoteRow): Note {
} }
const select = ` const select = `
SELECT n.id, n.collection_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at, SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at,
(SELECT COUNT(*) FROM images i WHERE i.note_id = n.id AND i.version_number = n.version_number) AS image_count, COALESCE(ic.image_count, 0) AS image_count,
(SELECT COUNT(*) FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = n.id AND i.version_number = n.version_number) AS annotation_count, COALESCE(ac.annotation_count, 0) AS annotation_count,
(SELECT COUNT(*) FROM work_comments wc WHERE wc.note_id = n.id) AS comment_count, COALESCE(cc.comment_count, 0) AS comment_count,
(SELECT url FROM images WHERE note_id = n.id AND version_number = n.version_number ORDER BY order_index, id LIMIT 1) AS cover_url cover.url AS cover_url
FROM notes n`; FROM notes n
LEFT JOIN (SELECT note_id, version_number, COUNT(*) AS image_count FROM images GROUP BY note_id, version_number) ic ON ic.note_id = n.id AND ic.version_number = n.version_number
LEFT JOIN (SELECT i.note_id, i.version_number, COUNT(a.id) AS annotation_count FROM images i LEFT JOIN annotations a ON a.image_id = i.id GROUP BY i.note_id, i.version_number) ac ON ac.note_id = n.id AND ac.version_number = n.version_number
LEFT JOIN (SELECT note_id, COUNT(*) AS comment_count FROM work_comments GROUP BY note_id) cc ON cc.note_id = n.id
LEFT JOIN images cover ON cover.note_id = n.id AND cover.version_number = n.version_number AND cover.order_index = 0`;
export const notesRepository = { export const notesRepository = {
async list(query: NoteListQuery = {}): Promise<Note[]> { async list(query: NoteListQuery = {}): Promise<Note[]> {
const conditions: string[] = []; const params: unknown[] = []; const conditions: string[] = []; const params: unknown[] = [];
if (query.collectionId) { conditions.push('n.collection_id = ?'); params.push(query.collectionId); } if (query.collectionId) { conditions.push('n.collection_id = ?'); params.push(query.collectionId); }
if (query.externalId?.trim()) { conditions.push('n.external_id = ?'); params.push(query.externalId.trim()); }
if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); } if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); }
if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); } if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); }
if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); } if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); }
@@ -43,6 +48,10 @@ export const notesRepository = {
const row = await database.one<NoteRow>(`${select} WHERE n.id = ?`, [id]); const row = await database.one<NoteRow>(`${select} WHERE n.id = ?`, [id]);
return row ? toNote(row) : null; return row ? toNote(row) : null;
}, },
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
const row = await database.one<NoteRow>(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]);
return row ? toNote(row) : null;
},
async create(title: string, description: string, collectionId: number, tags: string[]): Promise<number> { async create(title: string, description: string, collectionId: number, tags: string[]): Promise<number> {
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']); return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
}, },

View File

@@ -11,19 +11,38 @@ import type { TextAnnotation } from '../../shared/types.js';
const router = Router(); 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 - 笔记列表 // GET /api/notes - 笔记列表
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => { 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; sort?: string;
order?: string; order?: string;
q?: string; q?: string;
collectionId?: string; collectionId?: string;
status?: 'draft' | 'pending' | 'changes_requested' | 'approved'; status?: 'draft' | 'pending' | 'changes_requested' | 'approved';
tag?: string; tag?: string;
externalId?: string;
}; };
const groupId = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform' ? undefined : req.authUser?.group_id ?? undefined; 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 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); res.json(list);
}); });
@@ -74,8 +93,17 @@ router.post(
const title = (req.body.title || '').toString().trim(); const title = (req.body.title || '').toString().trim();
const description = (req.body.description || '').toString().trim(); const description = (req.body.description || '').toString().trim();
const collectionId = Number(req.body.collectionId); const collectionId = Number(req.body.collectionId);
const tagsText = String(req.body.tags || ''); const tags = parseTags(req.body.tags);
const tags = tagsText ? [tagsText] : []; 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) { if (!title) {
res.status(400).json({ error: '标题不能为空' }); res.status(400).json({ error: '标题不能为空' });
return; return;
@@ -91,18 +119,32 @@ router.post(
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' }); res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return; 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: '请至少上传一张图片' }); res.status(400).json({ error: '请至少上传一张图片' });
return; return;
} }
const note = await notesService.create( let note;
title, try {
description, note = files.length
files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })), ? await notesService.create(
collectionId, title,
tags, description,
); files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })),
await audit(req, 'work.create', 'work', note.id, { collectionId, imageCount: files.length }); 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); res.status(201).json(note);
} catch (err) { } catch (err) {
next(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]); 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 (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).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 title = String(req.body?.title ?? context.title).trim();
const description = String(req.body?.description ?? context.description).trim(); const description = String(req.body?.description ?? context.description).trim();
const tagsText = String(req.body?.tags ?? ''); const tags = parseTags(req.body?.tags);
const tags = tagsText ? [tagsText] : [];
if (!title) { res.status(400).json({ error: '标题不能为空' }); return; } 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); const note = files.length
await audit(req, 'work.version_create', 'work', id, { versionNumber: note.version_number, imageCount: 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); res.status(201).json(note);
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); } } catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
}); });

View File

@@ -1,10 +1,10 @@
import { Router, type Response } from 'express'; import { Router, type Response } from 'express';
import { database } from '../database.js'; import { database } from '../database.js';
import { audit, canWriteProject, hashPassword, requireRole, requireWriter, type AuthRequest } from '../auth.js'; import { audit, canWriteProject, hashPassword, requireWriter, type AuthRequest } from '../auth.js';
import type { Project, WorkCollection } from '../../shared/types.js'; import type { Project, WorkCollection } from '../../shared/types.js';
const router = Router(); const router = Router();
const reader = requireRole('platform_admin', 'group_admin', 'operator'); const reader = requireWriter;
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string }; type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string }; type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
@@ -24,9 +24,18 @@ function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), gro
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; } function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
router.get('/', reader, async (req: AuthRequest, res: Response) => { router.get('/', reader, async (req: AuthRequest, res: Response) => {
const platform = req.authUser?.role === 'platform_admin'; const platform = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform';
const where = platform ? 'WHERE p.status != ?' : 'WHERE p.group_id = ? AND p.status != ?'; const projectKey = req.apiKey?.scope === 'project';
const params = platform ? ['archived'] : [req.authUser?.group_id, 'archived']; const where = platform
? 'WHERE p.status != ?'
: projectKey
? 'WHERE p.id = ? AND p.status != ?'
: 'WHERE p.group_id = ? AND p.status != ?';
const params = platform
? ['archived']
: projectKey
? [req.apiKey?.project_id, 'archived']
: [req.authUser?.group_id, 'archived'];
res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson)); res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson));
}); });
@@ -60,7 +69,7 @@ router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{ router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return} const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const rows=await database.all<CollectionRow>(`SELECT c.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson)); const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
}); });
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{ router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
@@ -79,7 +88,7 @@ router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Re
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{ router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return} const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})} try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
}); });
export default router; export default router;

View File

@@ -18,8 +18,8 @@ async function prepareFiles(files: UploadedFile[]) {
} }
export const notesService = { export const notesService = {
async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number }) { async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) {
return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId }); return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId });
}, },
async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> { async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
@@ -48,10 +48,10 @@ export const notesService = {
return result; return result;
}, },
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[]): Promise<Note> { async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const prepared = await prepareFiles(files); const prepared = await prepareFiles(files);
const noteId = await withTransaction(async (tx) => { const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']); const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
await imagesRepository.createMany(id, prepared, 1, tx); await imagesRepository.createMany(id, prepared, 1, tx);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]); await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
return id; return id;
@@ -59,6 +59,20 @@ export const notesService = {
return (await notesRepository.findById(noteId))!; return (await notesRepository.findById(noteId))!;
}, },
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
return id;
});
return (await notesRepository.findById(noteId))!;
},
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
return notesRepository.findByExternalId(collectionId, externalId);
},
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> { async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id); const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在'); if (!current) throw new Error('作品不存在');
@@ -73,6 +87,19 @@ export const notesService = {
return (await notesRepository.findById(id))!; return (await notesRepository.findById(id))!;
}, },
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const nextVersion = current.version_number + 1;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]);
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
});
return (await notesRepository.findById(id))!;
},
async remove(id: number) { return notesRepository.remove(id); }, async remove(id: number) { return notesRepository.remove(id); },
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); }, async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
}; };

View File

@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collections (
CREATE TABLE IF NOT EXISTS notes ( CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
external_id TEXT,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
@@ -56,6 +57,9 @@ CREATE TABLE IF NOT EXISTS notes (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE TABLE IF NOT EXISTS images ( CREATE TABLE IF NOT EXISTS images (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -63,7 +67,7 @@ CREATE TABLE IF NOT EXISTS images (
width INTEGER NOT NULL DEFAULT 0, width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0, height INTEGER NOT NULL DEFAULT 0,
order_index INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0,
storage_provider TEXT NOT NULL DEFAULT 'local' CHECK (storage_provider IN ('local', 'tencent_cos')), storage_provider TEXT NOT NULL DEFAULT 'local' CHECK (storage_provider IN ('local', 'tencent_cos', 'external')),
storage_key TEXT NOT NULL DEFAULT '', storage_key TEXT NOT NULL DEFAULT '',
version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0) version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0)
); );

View File

@@ -16,7 +16,7 @@
以下需求尚未在代码中完整落地,不应在交付时宣称可用: 以下需求尚未在代码中完整落地,不应在交付时宣称可用:
- ZIP + CSV 批量导入和最多 100 个作品的异步批量 API - ZIP + CSV 批量导入和最多 100 个作品的异步批量 API
- `externalId` 幂等创建项目作品交付集和作品 - `externalId` 幂等创建作品(项目作品交付集暂未支持)
- webhook 与站内未读通知 - webhook 与站内未读通知
- PDF 验收报告和最终原图 ZIP 导出 - PDF 验收报告和最终原图 ZIP 导出
- 批注/回复的参考图片附件 - 批注/回复的参考图片附件
@@ -34,4 +34,3 @@
2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。 2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。
3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。 3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。
4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。 4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。

View File

@@ -22,14 +22,34 @@ API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。
| `/api/management/storage-configs` | COS 配置、连接测试和启用 | | `/api/management/storage-configs` | COS 配置、连接测试和启用 |
| `/api/projects` | 项目创建、查询和编辑 | | `/api/projects` | 项目创建、查询和编辑 |
| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 | | `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 |
| `/api/notes` | 作品查询与 multipart 上传 | | `/api/notes` | 作品查询与创建 |
| `/api/notes/:noteId/versions` | 上传作品新版本 | | `/api/notes/:noteId/versions` | 创建作品新版本 |
| `/api/notes/:noteId/status` | 草稿与待验收状态切换 | | `/api/notes/:noteId/status` | 草稿与待验收状态切换 |
| `/api/notes/:noteId/text-annotations` | 标题/正文批注 | | `/api/notes/:noteId/text-annotations` | 标题/正文批注 |
| `/api/images/:imageId/annotations` | 图片坐标批注 | | `/api/images/:imageId/annotations` | 图片坐标批注 |
| `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 | | `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 |
| `/api/health` | 数据库就绪检查 | | `/api/health` | 数据库就绪检查 |
## 查询运营组、项目、作品交付集和作品
调用方不需要预先知道数据库 ID。使用 API Key 按顺序查询:
```bash
# 返回 Key 有权访问的项目,响应包含 group_id、group_name 和项目 id
curl http://localhost:3010/api/projects \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询项目中的作品交付集
curl http://localhost:3010/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询交付集中的作品,响应包含作品 id、external_id 和 version_number
curl "http://localhost:3010/api/notes?collectionId=1" \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
项目级 Key 的项目列表只会返回绑定项目;平台级 Key 可以查询全部运营组的项目。创建作品时只传 `collectionId`,服务会据此确定项目和运营组并校验权限,不需要重复传递 `projectId``groupId`
## 创建项目 ## 创建项目
平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。 平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。
@@ -54,34 +74,62 @@ curl -X POST http://localhost:3010/api/projects/1/collections \
## 上传作品 ## 上传作品
作品上传使用 `multipart/form-data`,至少一张、最多 30 张图片,单图最大 20 MB。图片数组顺序就是初始展示顺序,第一张为封面。 外部客户端使用 JSON 创建作品,`images` 直接传入 130 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。
```bash ```bash
curl -X POST http://localhost:3010/api/notes \ curl -X POST http://localhost:3010/api/notes \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-F "collectionId=1" \ -H "Content-Type: application/json" \
-F "title=作品标题" \ -d '{
-F "description=正文内容" \ "collectionId": 1,
-F "tags=用户填写的标签原文" \ "externalId": "client-work-20260721-001",
-F "images=@./01.jpg" \ "title": "作品标题",
-F "images=@./02.jpg" "description": "正文内容",
"tags": ["用户填写的标签原文"],
"images": [
"https://cdn.example.com/works/01.jpg",
"https://cdn.example.com/works/02.jpg"
]
}'
``` ```
当前接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按一段原文保存和展示不会自动添加 `#` 或拆分为标签库 `externalId` 是调用方在当前作品交付集内的作品唯一标识,支持字母、数字、点、下划线、冒号和横线,最长 128 位。相同 `collectionId + externalId` 的重复请求不会重复创建作品,而会以 `200` 返回原作品并包含 `"idempotent": true`。创建成功响应中的 `id` 是后续上传版本所需的 `workId`;如果调用方丢失了该 ID可以通过 `GET /api/notes?collectionId=1&externalId=client-work-20260721-001` 找回
URL 图片不会进入当前配置的 COS也不会由服务检查其内容或长期可用性因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示不会自动添加 `#` 或拆分为标签库。
## 创建新版本 ## 创建新版本
```bash ```bash
curl -X POST http://localhost:3010/api/notes/12/versions \ curl -X POST http://localhost:3010/api/notes/12/versions \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-F "title=修改后的标题" \ -H "Content-Type: application/json" \
-F "description=修改后的正文" \ -d '{
-F "tags=修改后的标签原文" \ "title": "修改后的标题",
-F "images=@./v2-01.jpg" "description": "修改后的正文",
"tags": ["修改后的标签原文"],
"images": [
"https://cdn.example.com/works/v2-01.jpg",
"https://cdn.example.com/works/v2-02.jpg"
]
}'
``` ```
批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据。 批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据。
## Python 冒烟脚本
项目自带 `tests/api_create_work.py`,只使用 Python 标准库。推荐通过环境变量提供项目级 API Key
```powershell
$env:DELIVERY_DESK_API_KEY = 'dd_live_xxx'
python tests/api_create_work.py --project-id 1 --collection-id 1
# 为已有作品创建新版本
python tests/api_create_work.py --project-id 1 --collection-id 1 --work-id 12
```
如果项目级 Key 只能访问一个项目,并且项目下只有一个作品交付集,可以省略两个 ID。脚本也支持不传 Key、改用 `--username` 后交互输入密码。
## 错误响应 ## 错误响应
错误统一以 JSON 返回: 错误统一以 JSON 返回:

View File

@@ -64,6 +64,8 @@ try {
if((renameGroup.body as {name:string}).name!=='已更名运营组')throw new Error('运营组名称未正确更新'); if((renameGroup.body as {name:string}).name!=='已更名运营组')throw new Error('运营组名称未正确更新');
const newAdminLogin=await request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'first_operator',password:'Operator123!'})}); const newAdminLogin=await request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'first_operator',password:'Operator123!'})});
expectStatus(newAdminLogin.response.status,200,'新组管理员登录',newAdminLogin.body); expectStatus(newAdminLogin.response.status,200,'新组管理员登录',newAdminLogin.body);
const newAdminCookie=newAdminLogin.response.headers.get('set-cookie')?.split(';')[0];
if(!newAdminCookie)throw new Error('新组管理员登录未返回会话 Cookie');
const accountsAfterLogin=await request(`/api/management/users?groupId=${groupId}`,{},adminCookie); const accountsAfterLogin=await request(`/api/management/users?groupId=${groupId}`,{},adminCookie);
if(!(accountsAfterLogin.body as Array<{username:string;last_login_at:string|null}>).find((item)=>item.username==='first_operator')?.last_login_at)throw new Error('最近登录时间未记录'); if(!(accountsAfterLogin.body as Array<{username:string;last_login_at:string|null}>).find((item)=>item.username==='first_operator')?.last_login_at)throw new Error('最近登录时间未记录');
const adminLogs=await request('/api/management/audit-logs?userId=1',{},adminCookie); const adminLogs=await request('/api/management/audit-logs?userId=1',{},adminCookie);
@@ -74,12 +76,49 @@ try {
expectStatus(project.response.status, 201, '创建项目', project.body); expectStatus(project.response.status, 201, '创建项目', project.body);
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称'); if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
const projectId = Number((project.body as { id: number }).id); const projectId = Number((project.body as { id: number }).id);
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
const otherProjectId=Number((otherProject.body as {id:number}).id);
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie); const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
expectStatus(access.response.status, 200, '配置客户访问'); expectStatus(access.response.status, 200, '配置客户访问');
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie); const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
expectStatus(collection.response.status, 201, '创建作品交付集'); expectStatus(collection.response.status, 201, '创建作品交付集');
const collectionId=Number((collection.body as {id:number}).id);
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
const projectKeyId=Number((projectKey.body as {item:{id:number}}).item.id);
const projectToken=(projectKey.body as {token:string}).token;
const bearerHeaders={Authorization:`Bearer ${projectToken}`};
const visibleProjects=await request('/api/projects',{headers:bearerHeaders});
expectStatus(visibleProjects.response.status,200,'项目级 Key 查询项目',visibleProjects.body);
if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目');
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集');
const createWorkBody={collectionId,externalId:'runtime-client-work-001',title:'接口作品 V1',description:'公开 URL 图片',tags:['API 测试'],images:['https://cdn.example.com/runtime-v1.jpg']};
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
const workId=Number((createdWork.body as {id:number}).id);
const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body);
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
const foundWork=await request(`/api/notes?collectionId=${collectionId}&externalId=runtime-client-work-001`,{headers:bearerHeaders});
expectStatus(foundWork.response.status,200,'externalId 查询作品',foundWork.body);
if((foundWork.body as Array<{id:number}>).length!==1||Number((foundWork.body as Array<{id:number}>)[0].id)!==workId)throw new Error('未能通过 externalId 找回作品');
const newVersion=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V2',description:'第二版',tags:['API 测试'],images:['https://cdn.example.com/runtime-v2.jpg']})});
expectStatus(newVersion.response.status,201,'JSON URL 创建新版本',newVersion.body);
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) }); const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
expectStatus(reviewLogin.response.status, 200, '客户登录'); expectStatus(reviewLogin.response.status, 200, '客户登录');
@@ -93,7 +132,7 @@ try {
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie); const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
expectStatus(revoke.response.status, 204, '吊销平台 API Key'); expectStatus(revoke.response.status, 204, '吊销平台 API Key');
process.stdout.write('PostgreSQL 运行时验证通过:单组管理员、多光影叙事、多平台管理员、管理员更换、活动时间、审计筛选、项目、客户门禁、作品交付集、API Key\n'); process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n');
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase(); await closeDatabase();

View File

@@ -125,6 +125,7 @@ export interface WorkCollection {
export interface Note { export interface Note {
id: number; id: number;
collection_id: number; collection_id: number;
external_id: string | null;
title: string; title: string;
description: string; description: string;
tags: string[]; tags: string[];
@@ -144,7 +145,7 @@ export interface NoteImage {
width: number; width: number;
height: number; height: number;
order_index: number; order_index: number;
storage_provider: 'local' | 'tencent_cos'; storage_provider: 'local' | 'tencent_cos' | 'external';
storage_key: string; storage_key: string;
} }
@@ -231,4 +232,5 @@ export interface NoteListQuery {
tag?: string; tag?: string;
projectId?: number; projectId?: number;
groupId?: number; groupId?: number;
externalId?: string;
} }

174
tests/api_create_work.py Normal file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""通过 Delivery Desk API 创建作品或上传作品新版本。"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
from datetime import datetime
from http.cookies import SimpleCookie
from http.cookiejar import CookieJar
from urllib.error import HTTPError, URLError
from urllib.request import HTTPCookieProcessor, Request, build_opener
class ApiError(RuntimeError):
pass
def request_json(opener, url: str, *, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None):
request = Request(url, data=data, method=method, headers=headers or {})
try:
with opener.open(request, timeout=15) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else None, response.headers
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
detail = json.loads(body).get("error", body)
except json.JSONDecodeError:
detail = body
raise ApiError(f"{method} {url} 返回 {error.code}: {detail}") from error
except URLError as error:
raise ApiError(f"无法连接 {url}: {error.reason}") from error
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或上传新版本")
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
parser.add_argument("--project-id", type=int, default=None, help="不传时自动选择唯一可访问的项目")
parser.add_argument("--collection-id", type=int, default=None, help="不传时自动选择项目下唯一的作品交付集")
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品创建新版本")
parser.add_argument("--external-id", default=None, help="调用方作品唯一标识,用于幂等创建和找回作品")
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
parser.add_argument("--password", default=os.getenv("DELIVERY_DESK_PASSWORD"))
parser.add_argument("--title", default=None)
parser.add_argument("--description", default="由 tests/api_create_work.py 通过 API 创建。")
parser.add_argument("--tags", default="API 测试")
parser.add_argument(
"--image-url",
action="append",
dest="image_urls",
default=None,
help="公开可读的图片 URL可重复传入默认使用一张公共占位图",
)
return parser.parse_args()
def choose_item(items: list[dict], requested_id: int | None, label: str) -> dict:
if requested_id is not None:
item = next((candidate for candidate in items if int(candidate["id"]) == requested_id), None)
if item is None:
raise ApiError(f"无权访问或不存在的{label} ID: {requested_id}")
return item
if len(items) == 1:
return items[0]
choices = ", ".join(f'{item["id"]}:{item["name"]}' for item in items) or ""
raise ApiError(f"可访问的{label}不是唯一项,请显式传入对应 ID。当前可选{choices}")
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
opener = build_opener(HTTPCookieProcessor(CookieJar()))
auth_headers: dict[str, str] = {}
if args.api_key:
auth_headers["Authorization"] = f"Bearer {args.api_key}"
else:
password = args.password or getpass.getpass(f"请输入账号 {args.username} 的密码: ")
login_data = json.dumps({"username": args.username, "password": password}).encode("utf-8")
_, _, login_headers = request_json(
opener,
f"{base_url}/api/auth/login",
method="POST",
data=login_data,
headers={"Content-Type": "application/json"},
)
cookies = SimpleCookie()
cookies.load(login_headers.get("Set-Cookie", ""))
session = cookies.get("proofing_session")
if session is None:
raise ApiError("登录成功,但接口没有返回会话 Cookie")
auth_headers["Cookie"] = f"proofing_session={session.value}"
_, projects, _ = request_json(opener, f"{base_url}/api/projects", headers=auth_headers)
project = choose_item(projects, args.project_id, "项目")
project_id = int(project["id"])
_, collections, _ = request_json(
opener,
f"{base_url}/api/projects/{project_id}/collections",
headers=auth_headers,
)
collection = choose_item(collections, args.collection_id, "作品交付集")
collection_id = int(collection["id"])
if int(collection["project_id"]) != project_id:
raise ApiError(f"作品交付集 {collection_id} 不属于项目 {project_id}")
title = args.title or f"API 测试作品 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
image_urls = args.image_urls or ["https://placehold.co/1200x800/png?text=Delivery+Desk+API+Test"]
upload_headers = {**auth_headers, "Content-Type": "application/json"}
payload = {
"title": title,
"description": args.description,
"tags": [args.tags] if args.tags else [],
"images": image_urls,
}
if args.work_id is not None:
_, current, _ = request_json(opener, f"{base_url}/api/notes/{args.work_id}", headers=auth_headers)
if int(current["project"]["id"]) != project_id or int(current["collection"]["id"]) != collection_id:
raise ApiError(f"作品 {args.work_id} 不属于选定的项目和作品交付集")
status, work, _ = request_json(
opener,
f"{base_url}/api/notes/{args.work_id}/versions",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers=upload_headers,
)
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
raise ApiError("新版本接口没有返回目标作品")
action = "version_created"
external_id = work.get("external_id")
else:
external_id = args.external_id or f"api-smoke-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
create_payload = {**payload, "collectionId": collection_id, "externalId": external_id}
body = json.dumps(create_payload).encode("utf-8")
status, work, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
if status not in (200, 201) or not work or int(work.get("collection_id", 0)) != collection_id:
raise ApiError("接口未返回属于目标作品交付集的作品")
_, repeated, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
if int(repeated.get("id", 0)) != int(work["id"]) or not repeated.get("idempotent"):
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
action = "work_created" if status == 201 else "existing_work_returned"
print(
json.dumps(
{
"success": True,
"action": action,
"group": project.get("group_name"),
"project": project.get("name"),
"collection": collection.get("name"),
"work_id": work.get("id"),
"external_id": external_id,
"version_number": work.get("version_number"),
"title": work.get("title"),
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ApiError, ValueError) as error:
print(f"测试失败: {error}", file=sys.stderr)
raise SystemExit(1)