274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
|
|
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;
|