feat(auth): 添加认证模块和图片批注功能(项目初始化)

- 实现用户登录、登出、密码修改等认证功能
- 添加会话管理和权限控制中间件
- 创建图片批注组件和相关API路由
- 实现批注的增删改查功能
- 添加Docker和Git忽略配置文件
- 创建系统架构文档和开发约定说明
- 集成认证模块到前端应用路由中
This commit is contained in:
yuzhe
2026-07-21 15:28:55 +08:00
commit b0c498fbb6
81 changed files with 10826 additions and 0 deletions

102
api/app.ts Normal file
View File

@@ -0,0 +1,102 @@
import 'express-async-errors';
/**
* Express 应用主入口
*/
import express, {
type Request,
type Response,
} from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import dotenv from 'dotenv';
import notesRoutes from './routes/notes.js';
import imagesRoutes from './routes/images.js';
import annotationsRoutes from './routes/annotations.js';
import projectsRoutes from './routes/projects.js';
import commentsRoutes from './routes/comments.js';
import authRoutes from './routes/auth.js';
import { optionalAuth } from './auth.js';
import groupsRoutes from './routes/groups.js';
import managementRoutes from './routes/management.js';
import storageRoutes from './routes/storage.js';
import reviewRoutes from './routes/review.js';
import { UPLOADS_DIR } from './upload.js';
import { database, databaseDialect } from './database.js';
dotenv.config();
const app: express.Application = express();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.set('trust proxy', 1);
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((item) => item.trim()).filter(Boolean);
app.use(cors({ origin: allowedOrigins?.length ? allowedOrigins : true, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.use(express.urlencoded({ extended: true, limit: '20mb' }));
app.use(optionalAuth);
// 静态图片资源
app.use(
'/uploads',
express.static(UPLOADS_DIR, {
maxAge: '7d',
immutable: true,
}),
);
/**
* API 路由
*/
app.use('/api/notes', notesRoutes);
app.use('/api/images', imagesRoutes);
app.use('/api/annotations', annotationsRoutes);
app.use('/api/projects', projectsRoutes);
app.use('/api', commentsRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/groups', groupsRoutes);
app.use('/api/management', managementRoutes);
app.use('/api/management/storage-configs', storageRoutes);
app.use('/api/review', reviewRoutes);
/**
* health
*/
app.use('/api/health', async (_req: Request, res: Response) => {
await database.one('SELECT 1 AS ready');
res.status(200).json({ success: true, message: 'ok', database: databaseDialect });
});
const distDir = path.resolve(__dirname, '..', 'dist');
if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) {
app.use(express.static(distDir, { maxAge: '1h' }));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) { next(); return; }
res.sendFile(path.join(distDir, 'index.html'));
});
}
/**
* 错误处理
*/
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
void _next;
console.error('[API Error]', err);
// multer 文件类型/大小错误
const message = err.message || '服务器内部错误';
if (err.message?.includes('不支持的文件类型')) {
res.status(400).json({ success: false, error: message });
return;
}
res.status(500).json({ success: false, error: message });
});
/**
* 404
*/
app.use((_req: Request, res: Response) => {
res.status(404).json({ success: false, error: 'API not found' });
});
export default app;

114
api/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import type { CurrentUser, UserRole } from '../shared/types.js';
import { database } from './database.js';
export interface ApiKeyPrincipal {
id: number;
group_id: number | null;
project_id: number | null;
scope: 'platform' | 'project';
}
export type AuthRequest = Request & { authUser?: CurrentUser; apiKey?: ApiKeyPrincipal };
export function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, expected] = stored.split(':');
if (!salt || !expected) return false;
const actual = scryptSync(password, salt, 64);
const expectedBuffer = Buffer.from(expected, 'hex');
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`;
}
export async function createSession(userId: number): Promise<string> {
const token = randomBytes(32).toString('base64url');
await database.execute('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)', [userId, sha256(token), new Date(Date.now() + 7 * 86400000).toISOString()]);
return token;
}
function readCookie(req: Request, name: string): string | undefined {
return req.headers.cookie?.split(';').map((item) => item.trim()).find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1);
}
export async function optionalAuth(req: AuthRequest, _res: Response, next: NextFunction) {
const bearer = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined;
if (bearer?.startsWith('dd_')) {
const key = await database.one<ApiKeyPrincipal>(`
SELECT k.id, k.group_id, k.project_id, k.scope
FROM api_keys k
LEFT JOIN operation_groups g ON g.id = k.group_id
LEFT JOIN projects p ON p.id = k.project_id
WHERE k.key_hash = ? AND k.status = 'active'
AND (k.scope = 'platform' OR (g.status = 'active' AND p.status = 'active'))
`, [sha256(bearer)]);
if (key) {
req.apiKey = key;
await database.execute('UPDATE api_keys SET last_used_at = ? WHERE id = ?', [new Date().toISOString(), key.id]);
}
next();
return;
}
const token = bearer || readCookie(req, 'proofing_session');
if (token) {
const row = await database.one<Omit<CurrentUser, 'must_change_password'> & { must_change_password: boolean | number }>(`
SELECT u.id, u.group_id, g.name AS group_name, u.username, u.display_name, u.role, u.must_change_password
FROM sessions s JOIN users u ON u.id = s.user_id
LEFT JOIN operation_groups g ON g.id = u.group_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.status = 'active'
`, [sha256(token), new Date().toISOString()]);
if (row) req.authUser = { ...row, must_change_password: Boolean(row.must_change_password) };
}
next();
}
export function requireRole(...roles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.authUser) { res.status(401).json({ error: '请先登录运营账号' }); return; }
if (!roles.includes(req.authUser.role)) { res.status(403).json({ error: '当前账号没有执行此操作的权限' }); return; }
next();
};
}
export function requireWriter(req: AuthRequest, res: Response, next: NextFunction) {
if (req.authUser && ['platform_admin', 'group_admin', 'operator'].includes(req.authUser.role)) {
next();
return;
}
if (req.apiKey) {
next();
return;
}
res.status(401).json({ error: '请登录工作台账号或提供有效的 API Key' });
}
export async function canWriteProject(req: AuthRequest, projectId: number): Promise<boolean> {
if (req.authUser?.role === 'platform_admin') return true;
if (req.authUser) {
const project = await database.one<{ group_id: number | null }>('SELECT group_id FROM projects WHERE id = ?', [projectId]);
return Boolean(project && project.group_id === req.authUser.group_id);
}
if (req.apiKey?.scope === 'platform') return true;
return req.apiKey?.scope === 'project' && req.apiKey.project_id === projectId;
}
export async function audit(req: AuthRequest, action: string, entityType: string, entityId?: number, detail: Record<string, unknown> = {}) {
await database.execute('INSERT INTO audit_logs (group_id, user_id, action, entity_type, entity_id, detail) VALUES (?, ?, ?, ?, ?, ?)', [req.authUser?.group_id ?? req.apiKey?.group_id ?? null, req.authUser?.id ?? null, action, entityType, entityId ?? null, JSON.stringify(req.apiKey ? { ...detail, apiKeyId: req.apiKey.id } : detail)]);
}
export async function clearSession(token: string | undefined) {
if (token) await database.execute('DELETE FROM sessions WHERE token_hash = ?', [sha256(token)]);
}
export function sessionCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `proofing_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800${secure}`;
}

38
api/configCrypto.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
const dataDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'data');
const localKeyPath = path.join(dataDir, '.config-encryption-key');
function loadKey(): Buffer {
const configured = process.env.COS_CONFIG_ENCRYPTION_KEY?.trim();
if (configured) return createHash('sha256').update(configured, 'utf8').digest();
fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(localKeyPath)) {
fs.writeFileSync(localKeyPath, randomBytes(32).toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
}
return Buffer.from(fs.readFileSync(localKeyPath, 'utf8').trim(), 'base64');
}
const key = loadKey();
export function encryptSecret(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
}
export function decryptSecret(value: string): string {
const [version, iv, tag, encrypted] = value.split(':');
if (version !== 'v1' || !iv || !tag || !encrypted) throw new Error('无法读取已保存的存储凭证');
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]).toString('utf8');
}

48
api/customerAuth.ts Normal file
View File

@@ -0,0 +1,48 @@
import { randomBytes } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { database } from './database.js';
import { sha256 } from './auth.js';
export type CustomerRequest = Request & {
customer?: { project_id: number; reviewer_name: string };
};
function readCookie(req: Request, name: string): string | undefined {
return req.headers.cookie?.split(';').map((item) => item.trim()).find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1);
}
export async function createCustomerSession(projectId: number, reviewerName: string): Promise<string> {
const token = randomBytes(32).toString('base64url');
await database.execute('INSERT INTO customer_sessions (project_id, reviewer_name, token_hash, expires_at) VALUES (?, ?, ?, ?)', [projectId, reviewerName, sha256(token), new Date(Date.now() + 7 * 86400000).toISOString()]);
return token;
}
export function customerSessionCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `review_session=${token}; HttpOnly; SameSite=Lax; Path=/api/review; Max-Age=604800${secure}`;
}
export async function optionalCustomer(req: CustomerRequest, _res: Response, next: NextFunction) {
const token = readCookie(req, 'review_session');
if (token) {
const row = await database.one<NonNullable<CustomerRequest['customer']>>(`
SELECT s.project_id, s.reviewer_name
FROM customer_sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.token_hash = ? AND s.expires_at > ?
AND p.customer_access_enabled = TRUE AND p.status != 'archived'
AND (p.access_expires_at IS NULL OR p.access_expires_at > ?)
`, [sha256(token), new Date().toISOString(), new Date().toISOString()]);
if (row) req.customer = row;
}
next();
}
export async function requireCustomerProject(req: CustomerRequest, res: Response, next: NextFunction) {
const project = await database.one<{ id: number }>('SELECT id FROM projects WHERE slug = ?', [req.params.slug]);
if (!project || !req.customer || req.customer.project_id !== project.id) {
res.status(401).json({ error: '请先输入项目访问密码' });
return;
}
next();
}

116
api/database.ts Normal file
View File

@@ -0,0 +1,116 @@
import fs from 'node:fs';
import path from 'node:path';
import pg, { type PoolClient } from 'pg';
import type Database from 'better-sqlite3';
import dotenv from 'dotenv';
import { randomBytes, scryptSync } from 'node:crypto';
dotenv.config();
type SqliteDatabase = Database.Database;
type Connection = PoolClient | SqliteDatabase;
const databaseUrl = process.env.DATABASE_URL?.trim();
export const databaseDialect = databaseUrl ? 'postgres' : 'sqlite';
let sqlite: SqliteDatabase | undefined;
let pool: pg.Pool | undefined;
if (databaseUrl) {
if (databaseUrl === 'pg-mem://' && process.env.NODE_ENV === 'test') {
const { newDb } = await import('pg-mem');
const adapter = newDb({ autoCreateForeignKeyIndices: true }).adapters.createPg();
pool = new adapter.Pool() as unknown as pg.Pool;
} else {
pool = new pg.Pool({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined, max: Number(process.env.PG_POOL_MAX || 10) });
}
let schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
if (databaseUrl === 'pg-mem://') schema = schema.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
await pool.query(schema);
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
if (userCount === 0) {
const initialPassword = process.env.INITIAL_ADMIN_PASSWORD;
if (!initialPassword) throw new Error('空 PostgreSQL 数据库需要配置 INITIAL_ADMIN_PASSWORD');
const salt = randomBytes(16).toString('hex');
const passwordHash = `${salt}:${scryptSync(initialPassword, salt, 64).toString('hex')}`;
await pool.query('INSERT INTO users (group_id, username, display_name, password_hash, role, must_change_password) VALUES (NULL, $1, $2, $3, $4, TRUE)', [process.env.INITIAL_ADMIN_USERNAME || 'admin', process.env.INITIAL_ADMIN_DISPLAY_NAME || '平台管理员', passwordHash, 'platform_admin']);
}
} else {
sqlite = (await import('./db.js')).db;
}
function postgresSql(sql: string): string {
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
async function connectionQuery<T>(connection: Connection, sql: string, params: unknown[]): Promise<{ rows: T[]; rowCount: number }> {
if (databaseDialect === 'postgres') {
const result = await (connection as PoolClient).query(postgresSql(sql), params);
return { rows: result.rows as T[], rowCount: result.rowCount ?? 0 };
}
const statement = (connection as SqliteDatabase).prepare(sql);
const sqliteParams = params.map((value) => typeof value === 'boolean' ? Number(value) : value);
if (/^\s*(SELECT|WITH|PRAGMA)/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
return { rows: statement.all(...sqliteParams) as T[], rowCount: 0 };
}
const result = statement.run(...sqliteParams);
return { rows: [], rowCount: result.changes };
}
export interface QueryContext {
all<T>(sql: string, params?: unknown[]): Promise<T[]>;
one<T>(sql: string, params?: unknown[]): Promise<T | undefined>;
execute(sql: string, params?: unknown[]): Promise<{ changes: number }>;
insertId(sql: string, params?: unknown[]): Promise<number>;
}
function context(connection: Connection): QueryContext {
return {
async all<T>(sql, params = []) { return (await connectionQuery<T>(connection, sql, params)).rows; },
async one<T>(sql, params = []) { return (await connectionQuery<T>(connection, sql, params)).rows[0]; },
async execute(sql, params = []) { return { changes: (await connectionQuery(connection, sql, params)).rowCount }; },
async insertId(sql, params = []) {
if (databaseDialect === 'postgres') {
const result = await connectionQuery<{ id: number }>(connection, `${sql.replace(/;\s*$/, '')} RETURNING id`, params);
return Number(result.rows[0]?.id);
}
const sqliteParams = params.map((value) => typeof value === 'boolean' ? Number(value) : value);
const result = (connection as SqliteDatabase).prepare(sql).run(...sqliteParams);
return Number(result.lastInsertRowid);
},
};
}
export const database: QueryContext = context((pool ?? sqlite) as Connection);
export async function withTransaction<T>(work: (tx: QueryContext) => Promise<T>): Promise<T> {
if (databaseDialect === 'postgres') {
const client = await pool!.connect();
try {
await client.query('BEGIN');
const result = await work(context(client));
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
sqlite!.exec('BEGIN IMMEDIATE');
try {
const result = await work(context(sqlite!));
sqlite!.exec('COMMIT');
return result;
} catch (error) {
sqlite!.exec('ROLLBACK');
throw error;
}
}
export async function closeDatabase(): Promise<void> {
if (pool) await pool.end();
if (sqlite) sqlite.close();
}

273
api/db.ts Normal file
View File

@@ -0,0 +1,273 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { randomBytes, scryptSync } from 'crypto';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.resolve(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
export const db = new Database(path.join(dataDir, 'app.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS operation_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
must_change_password INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users(group_id) WHERE role = 'group_admin';
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS customer_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
reviewer_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
detail TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
project_id INTEGER,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_by INTEGER NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS storage_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'tencent_cos',
region TEXT NOT NULL,
bucket TEXT NOT NULL,
public_base_url TEXT NOT NULL DEFAULT '',
cdn_domain TEXT NOT NULL DEFAULT '',
path_prefix TEXT NOT NULL DEFAULT 'delivery-desk',
secret_id_encrypted TEXT NOT NULL,
secret_key_encrypted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
test_status TEXT NOT NULL DEFAULT 'untested',
test_message TEXT NOT NULL DEFAULT '',
last_tested_at TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
activated_at TEXT,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reviewing',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
url TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
order_index INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS text_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
target TEXT NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending',
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(note_id, version_number),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
actor_name TEXT NOT NULL,
actor_role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
`);
function addColumn(table: string, definition: string) {
const name = definition.split(/\s+/)[0];
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!columns.some((column) => column.name === name)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
}
}
addColumn('notes', "collection_id INTEGER");
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');
addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'");
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
addColumn('projects', 'group_id INTEGER');
addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
addColumn('projects', 'access_expires_at TEXT');
addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'");
addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('users', 'last_login_at TEXT');
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) {
groupId = Number(db.prepare('INSERT INTO operation_groups (name) VALUES (?)').run('默认运营组').lastInsertRowid);
}
function passwordHash(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`;
}
const userCount = (db.prepare('SELECT COUNT(*) AS count FROM users').get() as { count: number }).count;
if (userCount === 0) {
const insert = db.prepare('INSERT INTO users (group_id, username, display_name, password_hash, role) VALUES (?, ?, ?, ?, ?)');
insert.run(null, 'admin', '平台管理员', passwordHash(process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe123!'), 'platform_admin');
insert.run(groupId, 'manager', '组管理员', passwordHash(process.env.INITIAL_MANAGER_PASSWORD || 'Manager123!'), 'group_admin');
insert.run(groupId, 'operator', '光影叙事', passwordHash(process.env.INITIAL_OPERATOR_PASSWORD || 'Operator123!'), 'operator');
}
db.prepare("UPDATE operation_groups SET name = '默认运营组' WHERE name IN ('默认内容团队', '默认创作空间')").run();
db.prepare("UPDATE users SET display_name = '组管理员' WHERE username = 'manager' AND display_name IN ('团队管理员', '空间管理员')").run();
db.prepare("UPDATE users SET display_name = '光影叙事' WHERE username = 'operator' AND display_name IN ('内容运营', '内容专员', '内容编辑')").run();
const seedProject = db.prepare('SELECT id FROM projects ORDER BY id LIMIT 1').get() as { id: number } | undefined;
let projectId = seedProject?.id;
if (!projectId) {
projectId = Number(db.prepare(`INSERT INTO projects (name, slug, client_description) VALUES (?, ?, ?)`)
.run('光影内容计划', 'light-notes', '阶段作品、反馈与验收记录').lastInsertRowid);
}
const seedCollection = db.prepare('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1').get(projectId) as { id: number } | undefined;
let collectionId = seedCollection?.id;
if (!collectionId) {
collectionId = Number(db.prepare(`INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)`)
.run(projectId, '2026 年 7 月任务', '本月内容作品交付集').lastInsertRowid);
}
db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId);
db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId);
db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集'
WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run();
db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
db.exec(`
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id);
CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id);
CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id);
CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id);
CREATE INDEX IF NOT EXISTS idx_audit_group_id ON audit_logs(group_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_group_id ON api_keys(group_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_project_id ON api_keys(project_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status);
CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);
export default db;

9
api/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
*/
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';
export default function handler(req: VercelRequest, res: VercelResponse) {
return app(req, res);
}

View File

@@ -0,0 +1,16 @@
import { database } from '../database.js';
import type { Annotation, CreateAnnotationRequest } from '../../shared/types.js';
type AnnotationRow = Annotation & { id: number | string; image_id: number | string };
function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Number(row.id), image_id: Number(row.image_id) }; }
export const annotationsRepository = {
async listByImage(imageId: number): Promise<Annotation[]> {
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
},
async create(imageId: number, data: CreateAnnotationRequest): Promise<Annotation> {
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name) VALUES (?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE id = ?', [id]))!);
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
};

View File

@@ -0,0 +1,28 @@
import { database, withTransaction, type QueryContext } from '../database.js';
import type { NoteImage } from '../../shared/types.js';
interface ImageRow extends Omit<NoteImage, 'id' | 'note_id' | 'order_index'> { id: number | string; note_id: number | string; order_index: number | string }
function toImage(row: ImageRow): NoteImage { return { ...row, id: Number(row.id), note_id: Number(row.note_id), order_index: Number(row.order_index), url: /^https?:\/\//.test(row.url) || row.url.startsWith('/') ? row.url : `/uploads/${row.url}` }; }
export const imagesRepository = {
async listByNote(noteId: number, versionNumber?: number): Promise<NoteImage[]> {
const rows = await database.all<ImageRow>(`SELECT id, note_id, url, width, height, order_index, storage_provider, storage_key FROM images
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[]> {
const insert = async (tx: QueryContext) => {
const ids: number[] = [];
for (let index = 0; index < images.length; index += 1) {
const image = images[index];
ids.push(await tx.insertId('INSERT INTO images (note_id, url, width, height, order_index, storage_provider, storage_key, version_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [noteId, image.url, image.width, image.height, index, image.storageProvider, image.storageKey, versionNumber]));
}
return ids;
};
return existing ? insert(existing) : withTransaction(insert);
},
async findById(id: number): Promise<NoteImage | null> {
const row = await database.one<ImageRow>('SELECT id, note_id, url, width, height, order_index, storage_provider, storage_key FROM images WHERE id = ?', [id]);
return row ? toImage(row) : null;
},
};

View File

@@ -0,0 +1,53 @@
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;
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;
}
function toNote(row: NoteRow): Note {
return {
...row,
id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
image_count: Number(row.image_count), annotation_count: Number(row.annotation_count), comment_count: Number(row.comment_count),
tags: JSON.parse(row.tags || '[]') as string[],
cover_image: row.cover_url ? (/^https?:\/\//.test(row.cover_url) || row.cover_url.startsWith('/') ? row.cover_url : `/uploads/${row.cover_url}`) : '',
};
}
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`;
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.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()}"%`); }
if (query.projectId) { conditions.push('n.collection_id IN (SELECT id FROM collections WHERE project_id = ?)'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.collection_id IN (SELECT c.id FROM collections c JOIN projects p ON p.id = c.project_id WHERE p.group_id = ?)'); params.push(query.groupId); }
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
const order = query.order === 'asc' ? 'ASC' : 'DESC';
const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at';
return (await database.all<NoteRow>(`${select}${where} ORDER BY ${sort} ${order}, n.id DESC`, params)).map(toNote);
},
async findById(id: number): Promise<Note | null> {
const row = await database.one<NoteRow>(`${select} WHERE n.id = ?`, [id]);
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']);
},
async setStatus(id: number, status: ReviewStatus): Promise<boolean> {
return (await database.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id])).changes > 0;
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0; },
};

18
api/routes/annotations.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Router, type Response } from 'express';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router = Router();
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.annotationId);
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]);
if (!context) { res.status(404).json({ error: '批注不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; }
await annotationsRepository.remove(id);
res.status(204).end();
});
export default router;

43
api/routes/auth.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Router, type Request, type Response } from 'express';
import { database } from '../database.js';
import { clearSession, createSession, hashPassword, sessionCookie, type AuthRequest, verifyPassword } from '../auth.js';
const router = Router();
router.post('/login', async (req: Request, res: Response) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
const row = await database.one<{ id: number; password_hash: string }>('SELECT id, password_hash FROM users WHERE username = ? AND status = ?', [username, 'active']);
if (!row || !verifyPassword(password, row.password_hash)) { res.status(401).json({ error: '账号或密码错误' }); return; }
await database.execute('UPDATE users SET last_login_at = ? WHERE id = ?', [new Date().toISOString(), row.id]);
const token = await createSession(row.id);
res.setHeader('Set-Cookie', sessionCookie(token));
res.json({ success: true });
});
router.post('/logout', async (req: Request, res: Response) => {
const token = req.headers.cookie?.match(/(?:^|; )proofing_session=([^;]+)/)?.[1];
await clearSession(token);
res.setHeader('Set-Cookie', 'proofing_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0');
res.status(204).end();
});
router.get('/me', (req: AuthRequest, res: Response) => {
if (!req.authUser) { res.status(401).json({ error: '未登录' }); return; }
res.json(req.authUser);
});
router.post('/change-password', async (req: AuthRequest, res: Response) => {
if (!req.authUser) { res.status(401).json({ error: '未登录' }); return; }
const currentPassword = String(req.body?.currentPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (newPassword.length < 8 || !/[A-Za-z]/.test(newPassword) || !/\d/.test(newPassword)) {
res.status(400).json({ error: '新密码至少 8 位,并同时包含字母和数字' }); return;
}
const row = await database.one<{ password_hash: string }>('SELECT password_hash FROM users WHERE id = ?', [req.authUser.id]);
if (!row || !verifyPassword(currentPassword, row.password_hash)) { res.status(400).json({ error: '当前密码错误' }); return; }
await database.execute('UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?', [hashPassword(newPassword), false, req.authUser.id]);
res.json({ success: true });
});
export default router;

28
api/routes/comments.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router, type Response } from 'express';
import { database } from '../database.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import type { WorkComment } from '../../shared/types.js';
const router = Router();
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [noteId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '回复内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id, content, author_name, author_role) VALUES (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.commentId); const status = req.body?.status;
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]);
if (!context) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该反馈' }); return; }
await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]);
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
export default router;

7
api/routes/groups.ts Normal file
View File

@@ -0,0 +1,7 @@
import { Router, type Response } from 'express';
import { audit, requireRole, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router=Router();
router.patch('/current',requireRole('group_admin'),async(req:AuthRequest,res:Response)=>{const groupId=req.authUser?.group_id;const name=String(req.body?.name??'').trim();if(!groupId){res.status(400).json({error:'当前账号未归属运营组'});return}if(name.length<2||name.length>40){res.status(400).json({error:'组名长度需要在 240 个字符之间'});return}try{await database.execute('UPDATE operation_groups SET name=? WHERE id=?',[name,groupId]);await audit(req,'group.rename','operation_group',groupId,{name});res.json({id:groupId,name})}catch{res.status(409).json({error:'该运营组名称已存在'})}});
export default router;

39
api/routes/images.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Router, type Response, type NextFunction } from 'express';
import { imagesRepository } from '../repositories/imagesRepository.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router = Router();
async function imageProjectId(imageId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT c.project_id FROM images i JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE i.id = ?', [imageId]))?.project_id;
}
router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId) { res.status(404).json({ error: '图片不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该图片' }); return; }
res.json(await annotationsRepository.listByImage(imageId));
} catch (error) { next(error); }
});
router.post('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权操作该图片' }); return; }
const { x, y } = req.body ?? {};
const content = String(req.body?.content ?? '').trim();
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; }
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' }));
} catch (error) { next(error); }
});
export default router;

57
api/routes/management.ts Normal file
View File

@@ -0,0 +1,57 @@
import { randomBytes } from 'crypto';
import { Router, type Response } from 'express';
import { audit, hashPassword, requireRole, sha256, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
const router=Router();const passwordValid=(password:string)=>password.length>=8&&/[A-Za-z]/.test(password)&&/\d/.test(password);
router.get('/groups', requireRole('platform_admin'), async (_req, res) => {
const rows = await database.all<Record<string, unknown>>(`SELECT g.*,COALESCE(us.user_count,0) AS user_count,COALESCE(us.active_user_count,0) AS active_user_count,COALESCE(us.operator_count,0) AS operator_count,COALESCE(us.disabled_user_count,0) AS disabled_user_count,us.group_admin_name,us.group_admin_status,COALESCE(ps.project_count,0) AS project_count,COALESCE(ps.customer_link_count,0) AS customer_link_count FROM operation_groups g LEFT JOIN (SELECT group_id,COUNT(*) AS user_count,COUNT(CASE WHEN status='active' THEN 1 END) AS active_user_count,COUNT(CASE WHEN role='operator' THEN 1 END) AS operator_count,COUNT(CASE WHEN status='disabled' THEN 1 END) AS disabled_user_count,MAX(CASE WHEN role='group_admin' THEN display_name END) AS group_admin_name,MAX(CASE WHEN role='group_admin' THEN status END) AS group_admin_status FROM users WHERE group_id IS NOT NULL GROUP BY group_id) us ON us.group_id=g.id LEFT JOIN (SELECT group_id,COUNT(*) AS project_count,COUNT(CASE WHEN customer_access_enabled=TRUE THEN 1 END) AS customer_link_count FROM projects GROUP BY group_id) ps ON ps.group_id=g.id ORDER BY g.id DESC`);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), user_count: Number(row.user_count), active_user_count:Number(row.active_user_count),operator_count:Number(row.operator_count),disabled_user_count:Number(row.disabled_user_count),project_count: Number(row.project_count),customer_link_count:Number(row.customer_link_count) })));
});
router.post('/groups',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const name=String(req.body?.name??'').trim();const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 240 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 332 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}try{const created=await withTransaction(async(tx)=>{const groupId=await tx.insertId('INSERT INTO operation_groups (name) VALUES (?)',[name]);const userId=await tx.insertId("INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,'group_admin')",[groupId,username,displayName,hashPassword(password)]);return{groupId,userId}});await audit(req,'group.create','group',created.groupId,{name,firstAdminId:created.userId});res.status(201).json({id:created.groupId,name,status:'active',user_count:1,project_count:0})}catch{res.status(409).json({error:'运营组名称或账号已经存在'})}});
router.patch('/groups/:groupId/status',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const status=req.body?.status;if(!Number.isFinite(groupId)||!['active','disabled'].includes(status)){res.status(400).json({error:'无效的运营组或状态'});return}const changed=await withTransaction(async(tx)=>{const result=await tx.execute('UPDATE operation_groups SET status=? WHERE id=?',[status,groupId]);if(status==='disabled'&&result.changes){await tx.execute("UPDATE users SET status='disabled' WHERE group_id=?",[groupId]);await tx.execute('DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE group_id=?)',[groupId])}return result.changes});if(!changed){res.status(404).json({error:'运营组不存在'});return}await audit(req,`group.${status}`,'group',groupId);res.json({success:true,status})});
router.patch('/groups/:groupId',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const name=String(req.body?.name??'').trim();if(!Number.isFinite(groupId)){res.status(400).json({error:'无效的运营组'});return}if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 240 个字符'});return}try{const result=await database.execute('UPDATE operation_groups SET name=? WHERE id=?',[name,groupId]);if(!result.changes){res.status(404).json({error:'运营组不存在'});return}await audit(req,'group.rename','group',groupId,{name});res.json({id:groupId,name})}catch{res.status(409).json({error:'该运营组名称已经存在'})}});
router.get('/users', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const isPlatform = req.authUser?.role === 'platform_admin';
const requestedGroup = Number(req.query.groupId);
const where = isPlatform && !Number.isFinite(requestedGroup) ? '' : 'WHERE u.group_id=?';
const params = where ? [isPlatform ? requestedGroup : req.authUser?.group_id] : [];
const rows = await database.all<Record<string, unknown>>(`SELECT u.id,u.group_id,g.name AS group_name,u.username,u.display_name,u.role,u.status,u.must_change_password,u.last_login_at,u.created_at,a.last_operation_at FROM users u LEFT JOIN operation_groups g ON g.id=u.group_id LEFT JOIN (SELECT user_id,MAX(created_at) AS last_operation_at FROM audit_logs GROUP BY user_id) a ON a.user_id=u.id ${where} ORDER BY CASE u.role WHEN 'platform_admin' THEN 0 WHEN 'group_admin' THEN 1 ELSE 2 END,g.name,u.display_name`, params);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), must_change_password: Boolean(row.must_change_password) })));
});
router.post('/users',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{
const isPlatform=req.authUser?.role==='platform_admin';
const requestedRole=String(req.body?.role??'operator');
const role=isPlatform&&['platform_admin','group_admin','operator'].includes(requestedRole)?requestedRole:'operator';
const groupId=role==='platform_admin'?null:(isPlatform?Number(req.body?.group_id):req.authUser?.group_id);
const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');
if(role!=='platform_admin'&&!Number.isFinite(groupId)){res.status(400).json({error:'请选择运营组'});return}
if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 332 位字母、数字、点、横线或下划线'});return}
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}
if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}
if(role!=='platform_admin'&&!await database.one('SELECT id FROM operation_groups WHERE id=? AND status=?',[groupId,'active'])){res.status(400).json({error:'运营组不存在或已停用'});return}
if(role==='group_admin'&&await database.one('SELECT id FROM users WHERE group_id=? AND role=?',[groupId,'group_admin'])){res.status(409).json({error:'每个运营组只能有一位组管理员'});return}
try{const id=await database.insertId('INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,?)',[groupId,username,displayName,hashPassword(password),role]);await audit(req,'user.create','user',id,{groupId,username,role});res.status(201).json({id,group_id:groupId,username,display_name:displayName,role,status:'active',must_change_password:true})}catch{res.status(409).json({error:role==='group_admin'?'每个运营组只能有一位组管理员':'账号已经存在'})}
});
async function manageableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row||Number(row.id)===req.authUser?.id)return undefined;if(req.authUser?.role==='platform_admin')return row;return Number(row.group_id)===req.authUser?.group_id&&row.role==='operator'?row:undefined}
async function renameableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row)return undefined;if(req.authUser?.role==='platform_admin')return row;if(req.authUser?.role==='group_admin'&&Number(row.group_id)===req.authUser.group_id&&(Number(row.id)===req.authUser.id||row.role==='operator'))return row;return undefined}
router.patch('/users/:userId/name',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const displayName=String(req.body?.display_name??'').trim();const target=await renameableUser(req,userId);if(!target){res.status(403).json({error:'不能修改该账号姓名'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}await database.execute('UPDATE users SET display_name=? WHERE id=?',[displayName,userId]);await audit(req,'user.name_update','user',userId,{displayName});res.json({success:true,display_name:displayName})});
router.patch('/users/:userId/status',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const status=req.body?.status;if(!['active','disabled'].includes(status)||!await manageableUser(req,userId)){res.status(403).json({error:'不能修改该账号'});return}await database.execute('UPDATE users SET status=? WHERE id=?',[status,userId]);if(status==='disabled')await database.execute('DELETE FROM sessions WHERE user_id=?',[userId]);await audit(req,`user.${status}`,'user',userId);res.json({success:true,status})});
router.post('/users/:userId/reset-password',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const password=String(req.body?.password??'');if(!await manageableUser(req,userId)){res.status(403).json({error:'不能重置该账号密码'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET password_hash=?,must_change_password=? WHERE id=?',[hashPassword(password),true,userId]);await tx.execute('DELETE FROM sessions WHERE user_id=?',[userId])});await audit(req,'user.password_reset','user',userId);res.json({success:true})});
router.post('/groups/:groupId/replace-admin',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const userId=Number(req.body?.user_id);const previousAction=req.body?.previous_action==='disable'?'disable':'demote';const next=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE id=? AND group_id=? AND role=? AND status=?',[userId,groupId,'operator','active']);const current=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE group_id=? AND role=?',[groupId,'group_admin']);if(!next||!current){res.status(400).json({error:'请选择本组一位已启用的光影叙事'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET role=?,status=? WHERE id=?',['operator',previousAction==='disable'?'disabled':'active',current.id]);await tx.execute('UPDATE users SET role=? WHERE id=?',['group_admin',next.id])});await audit(req,'group.admin_replace','group',groupId,{previousAdminId:Number(current.id),nextAdminId:Number(next.id),previousAction});res.json({success:true,previous_admin:current.display_name,next_admin:next.display_name})});
router.get('/api-keys', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const platform = req.authUser?.role === 'platform_admin';
const where = platform ? "k.scope='platform'" : "k.scope='project' AND k.group_id=?";
const params = platform ? [] : [req.authUser?.group_id];
const rows = await database.all<Record<string, unknown>>(`SELECT k.id,k.group_id,k.project_id,p.name AS project_name,k.name,k.key_prefix,k.scope,k.status,u.display_name AS created_by_name,k.last_used_at,k.created_at FROM api_keys k LEFT JOIN projects p ON p.id=k.project_id JOIN users u ON u.id=k.created_by WHERE ${where} ORDER BY k.id DESC`, params);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), project_id: row.project_id == null ? null : Number(row.project_id) })));
});
router.post('/api-keys',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const platform=req.authUser?.role==='platform_admin';const scope=platform?'platform':'project';const name=String(req.body?.name??'').trim();const projectId=scope==='project'?Number(req.body?.project_id):null;if(name.length<2||name.length>50){res.status(400).json({error:'Key 名称需为 250 个字符'});return}if(scope==='project'&&!await database.one('SELECT id FROM projects WHERE id=? AND group_id=?',[projectId,req.authUser?.group_id])){res.status(400).json({error:'请选择本组项目'});return}const token=`dd_live_${randomBytes(32).toString('base64url')}`;const prefix=`${token.slice(0,16)}`;const id=await database.insertId('INSERT INTO api_keys (group_id,project_id,name,key_prefix,key_hash,scope,created_by) VALUES (?,?,?,?,?,?,?)',[scope==='project'?req.authUser?.group_id:null,projectId,name,prefix,sha256(token),scope,req.authUser?.id]);await audit(req,'api_key.create','api_key',id,{name,scope,projectId});res.status(201).json({token,item:{id,group_id:scope==='project'?req.authUser?.group_id:null,project_id:projectId,name,key_prefix:prefix,scope,status:'active',created_by_name:req.authUser?.display_name,last_used_at:null,created_at:new Date().toISOString()}})});
router.delete('/api-keys/:keyId',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const keyId=Number(req.params.keyId);const platform=req.authUser?.role==='platform_admin';const key=await database.one<{id:number;group_id:number|null;scope:string}>('SELECT id,group_id,scope FROM api_keys WHERE id=?',[keyId]);const allowed=key&&(platform?key.scope==='platform':key.scope==='project'&&Number(key.group_id)===req.authUser?.group_id);if(!allowed){res.status(404).json({error:'API Key 不存在'});return}await database.execute("UPDATE api_keys SET status='revoked',revoked_at=? WHERE id=?",[new Date().toISOString(),keyId]);await audit(req,'api_key.revoke','api_key',keyId);res.status(204).end()});
router.get('/audit-logs',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const conditions:string[]=[];const params:unknown[]=[];if(req.authUser?.role!=='platform_admin'){conditions.push('l.group_id=?');params.push(req.authUser?.group_id)}const userId=Number(req.query.userId);if(Number.isFinite(userId)){conditions.push('l.user_id=?');params.push(userId)}const where=conditions.length?`WHERE ${conditions.join(' AND ')}`:'';const rows=await database.all<Record<string,unknown>&{detail:string}>(`SELECT l.*,g.name AS group_name,u.display_name AS user_name FROM audit_logs l LEFT JOIN operation_groups g ON g.id=l.group_id LEFT JOIN users u ON u.id=l.user_id ${where} ORDER BY l.id DESC LIMIT 200`,params);res.json(rows.map((row)=>{try{return{...row,id:Number(row.id),group_id:row.group_id==null?null:Number(row.group_id),user_id:row.user_id==null?null:Number(row.user_id),detail:typeof row.detail==='string'?JSON.parse(row.detail):row.detail}}catch{return{...row,id:Number(row.id),detail:{}}}}))});
export default router;

183
api/routes/notes.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* 笔记路由
*/
import { Router, type Response, type NextFunction } from 'express';
import { upload } from '../upload.js';
import { notesService } from '../services/notesService.js';
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
import fs from 'fs';
import type { TextAnnotation } from '../../shared/types.js';
const router = Router();
// GET /api/notes - 笔记列表
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const { sort, order, q, collectionId, status, tag } = req.query as {
sort?: string;
order?: string;
q?: string;
collectionId?: string;
status?: 'draft' | 'pending' | 'changes_requested' | 'approved';
tag?: 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 });
res.json(list);
});
// GET /api/notes/:noteId - 笔记详情
router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const id = Number(req.params.noteId);
if (!Number.isFinite(id)) {
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权查看该作品' }); return; }
const version = req.query.version ? Number(req.query.version) : undefined;
const detail = await notesService.getDetail(id, version);
if (!detail) {
res.status(404).json({ error: '笔记不存在' });
return;
}
res.json(detail);
} catch (err) {
next(err);
}
});
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId);
const versionNumber = Number(req.body?.version_number);
const target = req.body?.target;
const content = String(req.body?.content ?? '').trim();
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
});
// POST /api/notes - 上传新笔记 (multipart/form-data)
router.post(
'/',
requireWriter,
upload.array('images', 30),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
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] : [];
if (!title) {
res.status(400).json({ error: '标题不能为空' });
return;
}
if (!Number.isFinite(collectionId)) {
res.status(400).json({ error: '请选择作品交付集' });
return;
}
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
if (!collection || !await canWriteProject(req, collection.project_id)) {
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return;
}
if (files.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 });
res.status(201).json(note);
} catch (err) {
next(err);
}
},
);
router.post('/:noteId/versions', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
try {
const id = Number(req.params.noteId);
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 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] : [];
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 });
res.status(201).json(note);
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
});
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const status = req.body?.status;
if (!['draft', 'pending'].includes(status)) {
res.status(400).json({ error: '无效的验收状态' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (!await notesService.setStatus(id, status)) {
res.status(404).json({ error: '作品不存在' });
return;
}
res.json({ success: true, status });
});
router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const reason = String(req.body?.reason ?? '').trim();
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
const note = await database.one<{ review_status: string; version_number: number; project_id: number }>('SELECT n.review_status, n.version_number, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
const actor = req.authUser!;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
});
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
res.json({ success: true, status: 'pending' });
});
// DELETE /api/notes/:noteId - 删除笔记
router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
if (!Number.isFinite(id)) {
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
const ok = await notesService.remove(id);
if (!ok) {
res.status(404).json({ error: '笔记不存在' });
return;
}
res.status(204).end();
});
export default router;

85
api/routes/projects.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Router, type Response } from 'express';
import { database } from '../database.js';
import { audit, canWriteProject, hashPassword, requireRole, 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');
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 };
function projectSelect(where: string) {
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
p.customer_access_enabled, p.access_expires_at,
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
COALESCE(cc.collection_count, 0) AS collection_count,
COALESCE(wc.work_count, 0) AS work_count
FROM projects p
JOIN operation_groups g ON g.id = p.group_id
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
${where}`;
}
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_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) => {
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'];
res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson));
});
router.post('/', requireWriter, async (req: AuthRequest, res: Response) => {
const name=String(req.body?.name??'').trim(); const slug=String(req.body?.slug??'').trim().toLowerCase(); const description=String(req.body?.client_description??'').trim();
if(!name||!/^[a-z0-9-]+$/.test(slug)){res.status(400).json({error:'请填写项目名称,项目标识仅支持小写字母、数字和连字符'});return}
if(req.apiKey&&req.apiKey.scope!=='platform'){res.status(403).json({error:'项目级 API Key 不能创建项目'});return}
const groupId=req.authUser?.group_id??Number(req.body?.groupId??req.body?.group_id);
if(!await database.one('SELECT id FROM operation_groups WHERE id = ? AND status = ?', [groupId,'active'])){res.status(400).json({error:'请选择有效的运营组'});return}
let id: number;
try {
id=await database.insertId('INSERT INTO projects (name, slug, client_description, group_id) VALUES (?, ?, ?, ?)',[name,slug,description,groupId]);
} catch {
res.status(409).json({error:'项目标识已存在'});return;
}
await audit(req,'project.create','project',id,{name,slug});
res.status(201).json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.get('/:projectId', reader, async (req: AuthRequest,res:Response)=>{
const id=Number(req.params.projectId); if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const row=await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]); if(!row){res.status(404).json({error:'项目不存在'});return} res.json(projectJson(row));
});
router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'项目名称不能为空'});return}
if(!(await database.execute('UPDATE projects SET name = ?, client_description = ? WHERE id = ?',[name,description,id])).changes){res.status(404).json({error:'项目不存在'});return}
await audit(req,'project.update','project',id,{name});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
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));
});
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
const enabled=req.body?.enabled===true;const password=String(req.body?.password??'');const expiresAt=String(req.body?.expires_at??'').trim()||null;
const current=await database.one<{access_password_hash:string}>('SELECT access_password_hash FROM projects WHERE id = ?',[id]);if(!current){res.status(404).json({error:'项目不存在'});return}
if(password&&password.length<6){res.status(400).json({error:'客户访问密码至少 6 位'});return}if(enabled&&!password&&!current.access_password_hash){res.status(400).json({error:'启用客户访问前请设置访问密码'});return}if(expiresAt&&!Number.isFinite(new Date(expiresAt).getTime())){res.status(400).json({error:'到期时间格式无效'});return}
await database.execute('UPDATE projects SET customer_access_enabled = ?, access_password_hash = ?, access_expires_at = ? WHERE id = ?',[enabled,password?hashPassword(password):current.access_password_hash,expiresAt,id]);if(!enabled)await database.execute('DELETE FROM customer_sessions WHERE project_id = ?',[id]);
await audit(req,'project.customer_access_update','project',id,{enabled,passwordReset:Boolean(password),expiresAt});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
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:'同一项目内作品交付集名称不能重复'})}
});
export default router;

32
api/routes/review.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Router, type Response } from 'express';
import { database, withTransaction } from '../database.js';
import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js';
import { verifyPassword } from '../auth.js';
import { notesService } from '../services/notesService.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
const router=Router();router.use(optionalCustomer);
type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null};
const projectBySlug=(slug:string)=>database.one<ProjectAccess>(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]);
const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now());
router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})});
router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 230 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one<Record<string,unknown>>("SELECT * FROM collections WHERE id=? AND project_id=? AND status IN ('reviewing','completed')",[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection,works})});
router.get('/:slug/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one('SELECT n.id FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 12000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}const belongs=await database.one('SELECT v.id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one(`SELECT i.id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string}>(`SELECT n.version_number,n.review_status FROM notes n WHERE n.id=? AND n.review_status!='draft' AND n.collection_id IN (SELECT id FROM collections WHERE project_id=?)`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name])});res.json({success:true,status:decision})});
export default router;

16
api/routes/storage.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Router, type Response } from 'express';
import { audit, requireRole, type AuthRequest } from '../auth.js';
import { encryptSecret } from '../configCrypto.js';
import { database, withTransaction } from '../database.js';
import { testStorageConfig, type StorageConfigRecord } from '../storage.js';
const router=Router();
type PublicRow=Record<string,unknown>&{id:number|string;has_credentials:boolean|number};
async function publicRows(){return(await database.all<PublicRow>(`SELECT s.id,s.provider,s.region,s.bucket,s.public_base_url,s.cdn_domain,s.path_prefix,s.status,s.test_status,s.test_message,s.last_tested_at,u.display_name AS created_by_name,s.created_at,s.activated_at,1 AS has_credentials FROM storage_configs s JOIN users u ON u.id=s.created_by ORDER BY CASE s.status WHEN 'active' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END,s.id DESC`)).map((item)=>({...item,id:Number(item.id),has_credentials:Boolean(item.has_credentials)}))}
function validUrl(value:string){if(!value)return true;try{return['http:','https:'].includes(new URL(value).protocol)}catch{return false}}
router.get('/',requireRole('platform_admin'),async(_req,res)=>res.json(await publicRows()));
router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))});
router.post('/:configId/test',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<StorageConfigRecord>('SELECT id,region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted FROM storage_configs WHERE id=? AND status!=?',[id,'archived']);if(!config){res.status(404).json({error:'存储配置不存在'});return}try{const message=await testStorageConfig(config);await database.execute("UPDATE storage_configs SET test_status='passed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_passed','storage_config',id,{bucket:config.bucket});res.json({success:true,test_status:'passed',test_message:message})}catch(error){const message=error instanceof Error?error.message:'COS 连接测试失败';await database.execute("UPDATE storage_configs SET test_status='failed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_failed','storage_config',id,{bucket:config.bucket,message});res.status(400).json({error:message,test_status:'failed'})}});
router.post('/:configId/activate',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<{id:number;bucket:string;test_status:string}>('SELECT id,bucket,test_status FROM storage_configs WHERE id=? AND status=?',[id,'draft']);if(!config){res.status(404).json({error:'待启用的存储配置不存在'});return}if(config.test_status!=='passed'){res.status(409).json({error:'连接测试通过后才能启用该配置'});return}await withTransaction(async(tx)=>{await tx.execute("UPDATE storage_configs SET status='archived' WHERE status='active'");await tx.execute("UPDATE storage_configs SET status='active',activated_at=? WHERE id=?",[new Date().toISOString(),id])});await audit(req,'storage_config.activate','storage_config',id,{bucket:config.bucket});res.json({success:true})});
export default router;

37
api/server.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* local server entry file, for local development
*/
import app from './app.js';
import { closeDatabase } from './database.js';
/**
* start server with port
*/
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`Server ready on port ${PORT}`);
});
/**
* close server
*/
process.on('SIGTERM', () => {
console.log('SIGTERM signal received');
server.close(async () => {
await closeDatabase();
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('SIGINT signal received');
server.close(async () => {
await closeDatabase();
console.log('Server closed');
process.exit(0);
});
});
export default app;

View File

@@ -0,0 +1,78 @@
import sharp from 'sharp';
import type { Note, NoteDetail, ImageWithAnnotations, ReviewStatus } from '../../shared/types.js';
import { notesRepository } from '../repositories/notesRepository.js';
import { imagesRepository } from '../repositories/imagesRepository.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { database, withTransaction } from '../database.js';
import { storeUploadedFile } from '../storage.js';
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
catch { return { width: 0, height: 0 }; }
}
async function prepareFiles(files: UploadedFile[]) {
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
}
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 getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
const current = await notesRepository.findById(id);
if (!current) return null;
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion])
: undefined;
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
const images = await imagesRepository.listByNote(id, note.version_number);
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
if (!workContext) return null;
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
const result: NoteDetail = {
...note,
images: [] as ImageWithAnnotations[],
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
collection: { id: Number(workContext.collection_id), name: workContext.collection_name },
};
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
return result;
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[]): 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']);
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;
});
return (await notesRepository.findById(noteId))!;
},
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('作品不存在');
const nextVersion = current.version_number + 1;
const prepared = await prepareFiles(files);
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, prepared, 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); },
};

90
api/storage.ts Normal file
View File

@@ -0,0 +1,90 @@
import COS from 'cos-nodejs-sdk-v5';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { database } from './database.js';
import { decryptSecret } from './configCrypto.js';
export interface StorageConfigRecord {
id: number;
region: string;
bucket: string;
public_base_url: string;
cdn_domain: string;
path_prefix: string;
secret_id_encrypted: string;
secret_key_encrypted: string;
}
export interface StoredUpload {
url: string;
storageProvider: 'local' | 'tencent_cos';
storageKey: string;
}
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
secret_id_encrypted, secret_key_encrypted
FROM storage_configs WHERE status = 'active' ORDER BY id DESC LIMIT 1
`);
}
function createClient(config: StorageConfigRecord) {
return new COS({
SecretId: decryptSecret(config.secret_id_encrypted),
SecretKey: decryptSecret(config.secret_key_encrypted),
});
}
function cleanPrefix(value: string): string {
return value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
}
function objectUrl(config: StorageConfigRecord, key: string): string {
const base = (config.cdn_domain || config.public_base_url || `https://${config.bucket}.cos.${config.region}.myqcloud.com`).replace(/\/+$/, '');
return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
}
function safeError(error: unknown): string {
if (!error || typeof error !== 'object') return 'COS 连接失败';
const item = error as { code?: string; statusCode?: number; message?: string };
return [item.code, item.statusCode, item.message].filter(Boolean).join(' · ').slice(0, 300) || 'COS 连接失败';
}
export async function testStorageConfig(config: StorageConfigRecord): Promise<string> {
const cos = createClient(config);
const prefix = cleanPrefix(config.path_prefix);
const key = `${prefix ? `${prefix}/` : ''}.delivery-desk-check/${randomUUID()}.txt`;
try {
await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: Buffer.from('delivery-desk storage check'), ContentType: 'text/plain' });
await cos.getObject({ Bucket: config.bucket, Region: config.region, Key: key });
await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key });
return '上传、读取和删除测试均已通过';
} catch (error) {
try { await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); } catch { /* best-effort cleanup */ }
throw new Error(safeError(error));
}
}
export async function storeUploadedFile(file: { filename: string; originalname?: string; mimetype?: string; path: string }): Promise<StoredUpload> {
const config = await getActiveStorageConfig();
if (!config) return { url: file.filename, storageProvider: 'local', storageKey: file.filename };
const prefix = cleanPrefix(config.path_prefix);
const now = new Date();
const extension = path.extname(file.originalname || file.filename).toLowerCase() || '.jpg';
const month = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
const key = `${prefix ? `${prefix}/` : ''}originals/${month}/${randomUUID()}${extension}`;
const cos = createClient(config);
await cos.putObject({
Bucket: config.bucket,
Region: config.region,
Key: key,
Body: fs.createReadStream(file.path),
ContentLength: fs.statSync(file.path).size,
ContentType: file.mimetype || 'application/octet-stream',
});
fs.unlinkSync(file.path);
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
}

51
api/upload.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* multer 文件上传配置
*/
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.resolve(__dirname, '..', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadsDir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
const unique = `${Date.now()}_${Math.round(Math.random() * 1e6)}${ext}`;
cb(null, unique);
},
});
const allowedMime = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
]);
export const upload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024, // 20MB / 单图
files: 30, // 最多 30 张
},
fileFilter: (_req, file, cb) => {
if (allowedMime.has(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`不支持的文件类型: ${file.mimetype}`));
}
},
});
export const UPLOADS_DIR = uploadsDir;