feat(api): 支持外部客户端创建作品和更新版本
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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']);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 需要包含 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) {
|
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 需要包含 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 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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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); },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。
|
||||||
|
|
||||||
|
|||||||
@@ -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` 直接传入 1–30 个公开可读的 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 返回:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
174
tests/api_create_work.py
Normal 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)
|
||||||
Reference in New Issue
Block a user