feat(auth): 添加认证模块和图片批注功能(项目初始化)
- 实现用户登录、登出、密码修改等认证功能 - 添加会话管理和权限控制中间件 - 创建图片批注组件和相关API路由 - 实现批注的增删改查功能 - 添加Docker和Git忽略配置文件 - 创建系统架构文档和开发约定说明 - 集成认证模块到前端应用路由中
This commit is contained in:
102
api/app.ts
Normal file
102
api/app.ts
Normal 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
114
api/auth.ts
Normal 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
38
api/configCrypto.ts
Normal 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
48
api/customerAuth.ts
Normal 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
116
api/database.ts
Normal 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
273
api/db.ts
Normal 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
9
api/index.ts
Normal 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);
|
||||
}
|
||||
16
api/repositories/annotationsRepository.ts
Normal file
16
api/repositories/annotationsRepository.ts
Normal 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; },
|
||||
};
|
||||
28
api/repositories/imagesRepository.ts
Normal file
28
api/repositories/imagesRepository.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
53
api/repositories/notesRepository.ts
Normal file
53
api/repositories/notesRepository.ts
Normal 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
18
api/routes/annotations.ts
Normal 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
43
api/routes/auth.ts
Normal 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
28
api/routes/comments.ts
Normal 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: '回复内容须为 1–2000 个字符' }); 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
7
api/routes/groups.ts
Normal 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:'组名长度需要在 2–40 个字符之间'});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
39
api/routes/images.ts
Normal 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
57
api/routes/management.ts
Normal 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:'运营组名称需为 2–40 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});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:'运营组名称需为 2–40 个字符'});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:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}
|
||||
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});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:'姓名需为 2–40 个字符'});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 名称需为 2–50 个字符'});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
183
api/routes/notes.ts
Normal 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: '批注内容须为 1–1000 个字符' }); 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
85
api/routes/projects.ts
Normal 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
32
api/routes/review.ts
Normal 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:'请填写 2–30 个字符的姓名'});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:'反馈内容须为 1–2000 个字符'});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:'批注内容须为 1–1000 个字符'});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:'批注内容须为 1–1000 个字符'});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
16
api/routes/storage.ts
Normal 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
37
api/server.ts
Normal 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;
|
||||
78
api/services/notesService.ts
Normal file
78
api/services/notesService.ts
Normal 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
90
api/storage.ts
Normal 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
51
api/upload.ts
Normal 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;
|
||||
Reference in New Issue
Block a user