From e4d1d3bcea50ce2b4f640ce72815e5984b22b810 Mon Sep 17 00:00:00 2001 From: yuzhe Date: Tue, 21 Jul 2026 18:28:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=94=AF=E6=8C=81=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=88=9B=E5=BB=BA=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=92=8C=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + api/db.ts | 4 + api/repositories/imagesRepository.ts | 2 +- api/repositories/notesRepository.ts | 23 ++-- api/routes/notes.ts | 81 ++++++++++--- api/routes/projects.ts | 23 ++-- api/services/notesService.ts | 35 +++++- db/postgres/schema.sql | 6 +- docs/handoff.md | 3 +- docs/integration-guide.md | 76 +++++++++--- scripts/test-postgres-runtime.ts | 41 ++++++- shared/types.ts | 4 +- tests/api_create_work.py | 174 +++++++++++++++++++++++++++ 13 files changed, 418 insertions(+), 56 deletions(-) create mode 100644 tests/api_create_work.py diff --git a/.gitignore b/.gitignore index 9d7d087..2c385b0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ test-results/ .cache/ .eslintcache *.tsbuildinfo +__pycache__/ +*.py[cod] # Environment files and local secrets .env diff --git a/api/db.ts b/api/db.ts index ed33ce1..2fe3bb0 100644 --- a/api/db.ts +++ b/api/db.ts @@ -115,6 +115,7 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', 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', 'external_id TEXT'); addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'"); addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'"); 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('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; let groupId = seedGroup?.id; if (!groupId) { diff --git a/api/repositories/imagesRepository.ts b/api/repositories/imagesRepository.ts index 9b9b3bd..a862b34 100644 --- a/api/repositories/imagesRepository.ts +++ b/api/repositories/imagesRepository.ts @@ -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]); 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 { + async createMany(noteId: number, images: { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string }[], versionNumber = 1, existing?: QueryContext): Promise { const insert = async (tx: QueryContext) => { const ids: number[] = []; for (let index = 0; index < images.length; index += 1) { diff --git a/api/repositories/notesRepository.ts b/api/repositories/notesRepository.ts index d92d784..9d285c2 100644 --- a/api/repositories/notesRepository.ts +++ b/api/repositories/notesRepository.ts @@ -2,7 +2,7 @@ import { database } from '../database.js'; import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js'; 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; 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 = ` - SELECT n.id, n.collection_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, - (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, - (SELECT COUNT(*) FROM work_comments wc WHERE wc.note_id = n.id) 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 - FROM notes n`; + SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at, + COALESCE(ic.image_count, 0) AS image_count, + COALESCE(ac.annotation_count, 0) AS annotation_count, + COALESCE(cc.comment_count, 0) AS comment_count, + cover.url AS cover_url + 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 = { async list(query: NoteListQuery = {}): Promise { const conditions: string[] = []; const params: unknown[] = []; 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.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()}"%`); } @@ -43,6 +48,10 @@ export const notesRepository = { const row = await database.one(`${select} WHERE n.id = ?`, [id]); return row ? toNote(row) : null; }, + async findByExternalId(collectionId: number, externalId: string): Promise { + const row = await database.one(`${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 { return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']); }, diff --git a/api/routes/notes.ts b/api/routes/notes.ts index 5963734..8058d9f 100644 --- a/api/routes/notes.ts +++ b/api/routes/notes.ts @@ -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 需要包含 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; @@ -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 需要包含 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 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); } }); diff --git a/api/routes/projects.ts b/api/routes/projects.ts index 97c0df4..7416bc2 100644 --- a/api/routes/projects.ts +++ b/api/routes/projects.ts @@ -1,10 +1,10 @@ import { Router, type Response } from 'express'; 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'; 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 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) }; } router.get('/', reader, async (req: AuthRequest, res: Response) => { - const platform = req.authUser?.role === 'platform_admin'; - const where = platform ? 'WHERE p.status != ?' : 'WHERE p.group_id = ? AND p.status != ?'; - const params = platform ? ['archived'] : [req.authUser?.group_id, 'archived']; + const platform = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform'; + const projectKey = req.apiKey?.scope === 'project'; + 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(`${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)=>{ const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return} - const rows=await database.all(`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(`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)=>{ @@ -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)=>{ 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(`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(`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; diff --git a/api/services/notesService.ts b/api/services/notesService.ts index 0b99087..c5556ee 100644 --- a/api/services/notesService.ts +++ b/api/services/notesService.ts @@ -18,8 +18,8 @@ async function prepareFiles(files: UploadedFile[]) { } export const notesService = { - async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number }) { - 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 }); + 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, externalId: query.externalId }); }, async getDetail(id: number, requestedVersion?: number): Promise { @@ -48,10 +48,10 @@ export const notesService = { return result; }, - async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[]): Promise { + async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise { const prepared = await prepareFiles(files); 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 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; @@ -59,6 +59,20 @@ export const notesService = { return (await notesRepository.findById(noteId))!; }, + async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise { + 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 { + return notesRepository.findByExternalId(collectionId, externalId); + }, + async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); @@ -73,6 +87,19 @@ export const notesService = { return (await notesRepository.findById(id))!; }, + async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise { + 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 setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); }, }; diff --git a/db/postgres/schema.sql b/db/postgres/schema.sql index 6acf04b..f79c9df 100644 --- a/db/postgres/schema.sql +++ b/db/postgres/schema.sql @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS collections ( CREATE TABLE IF NOT EXISTS notes ( id BIGSERIAL PRIMARY KEY, collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + external_id TEXT, title TEXT NOT NULL, description 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() ); +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 ( id BIGSERIAL PRIMARY KEY, 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, height 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 '', version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0) ); diff --git a/docs/handoff.md b/docs/handoff.md index 7675981..e01c416 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -16,7 +16,7 @@ 以下需求尚未在代码中完整落地,不应在交付时宣称可用: - ZIP + CSV 批量导入和最多 100 个作品的异步批量 API -- `externalId` 幂等创建项目、作品交付集和作品 +- `externalId` 幂等创建作品(项目和作品交付集暂未支持) - webhook 与站内未读通知 - PDF 验收报告和最终原图 ZIP 导出 - 批注/回复的参考图片附件 @@ -34,4 +34,3 @@ 2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。 3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。 4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。 - diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 8e447c7..2d090bf 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -22,14 +22,34 @@ API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。 | `/api/management/storage-configs` | COS 配置、连接测试和启用 | | `/api/projects` | 项目创建、查询和编辑 | | `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 | -| `/api/notes` | 作品查询与 multipart 上传 | -| `/api/notes/:noteId/versions` | 上传作品新版本 | +| `/api/notes` | 作品查询与创建 | +| `/api/notes/:noteId/versions` | 创建作品新版本 | | `/api/notes/:noteId/status` | 草稿与待验收状态切换 | | `/api/notes/:noteId/text-annotations` | 标题/正文批注 | | `/api/images/:imageId/annotations` | 图片坐标批注 | | `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 | | `/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 不能创建项目。 @@ -54,34 +74,62 @@ curl -X POST http://localhost:3010/api/projects/1/collections \ ## 上传作品 -作品上传使用 `multipart/form-data`,至少一张、最多 30 张图片,单图最大 20 MB。图片数组顺序就是初始展示顺序,第一张为封面。 +外部客户端使用 JSON 创建作品,`images` 直接传入 1–30 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL,不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。 ```bash curl -X POST http://localhost:3010/api/notes \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ - -F "collectionId=1" \ - -F "title=作品标题" \ - -F "description=正文内容" \ - -F "tags=用户填写的标签原文" \ - -F "images=@./01.jpg" \ - -F "images=@./02.jpg" + -H "Content-Type: application/json" \ + -d '{ + "collectionId": 1, + "externalId": "client-work-20260721-001", + "title": "作品标题", + "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 curl -X POST http://localhost:3010/api/notes/12/versions \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ - -F "title=修改后的标题" \ - -F "description=修改后的正文" \ - -F "tags=修改后的标签原文" \ - -F "images=@./v2-01.jpg" + -H "Content-Type: application/json" \ + -d '{ + "title": "修改后的标题", + "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 返回: diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index 57ab1ac..6010320 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -64,6 +64,8 @@ try { 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!'})}); 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); 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); @@ -74,12 +76,49 @@ try { expectStatus(project.response.status, 201, '创建项目', project.body); if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称'); 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); 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); 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!' }) }); expectStatus(reviewLogin.response.status, 200, '客户登录'); @@ -93,7 +132,7 @@ try { const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie); 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 { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await closeDatabase(); diff --git a/shared/types.ts b/shared/types.ts index f0d4e82..3d922e7 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -125,6 +125,7 @@ export interface WorkCollection { export interface Note { id: number; collection_id: number; + external_id: string | null; title: string; description: string; tags: string[]; @@ -144,7 +145,7 @@ export interface NoteImage { width: number; height: number; order_index: number; - storage_provider: 'local' | 'tencent_cos'; + storage_provider: 'local' | 'tencent_cos' | 'external'; storage_key: string; } @@ -231,4 +232,5 @@ export interface NoteListQuery { tag?: string; projectId?: number; groupId?: number; + externalId?: string; } diff --git a/tests/api_create_work.py b/tests/api_create_work.py new file mode 100644 index 0000000..8aa706a --- /dev/null +++ b/tests/api_create_work.py @@ -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)