feat(api): 支持外部客户端创建作品和更新版本
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<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 ids: number[] = [];
|
||||
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';
|
||||
|
||||
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<Note[]> {
|
||||
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<NoteRow>(`${select} WHERE n.id = ?`, [id]);
|
||||
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> {
|
||||
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();
|
||||
|
||||
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); }
|
||||
});
|
||||
|
||||
@@ -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<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)=>{
|
||||
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)=>{
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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<NoteDetail | null> {
|
||||
@@ -48,10 +48,10 @@ export const notesService = {
|
||||
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 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<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> {
|
||||
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<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 setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user