Files
ai-drama-platform/server/db.mjs
T

599 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync } from "node:crypto";
import { sampleProject } from "../src/data/sampleProject.js";
import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs";
const serverRoot = resolve(import.meta.dirname);
const projectRoot = resolve(serverRoot, "..");
const dataRoot = resolve(projectRoot, "data");
export const dbPath = resolve(process.env.AI_DRAMA_DB_PATH || dataRoot, process.env.AI_DRAMA_DB_PATH ? "" : "platform.sqlite");
await mkdir(process.env.AI_DRAMA_DB_PATH ? resolve(dbPath, "..") : dataRoot, { recursive: true });
const schema = await readFile(resolve(serverRoot, "schema.sql"), "utf8");
export const db = new DatabaseSync(dbPath);
db.exec("PRAGMA busy_timeout = 5000");
db.exec("PRAGMA journal_mode = WAL");
db.exec(schema);
function ensureColumn(table, column, definition) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
if (!columns.some((item) => item.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
// Keep the seeded local SQLite database compatible with new platform features.
ensureColumn("model_connectors", "last_probe_at", "TEXT");
ensureColumn("model_connectors", "latency_ms", "INTEGER");
ensureColumn("model_connectors", "error_message", "TEXT NOT NULL DEFAULT ''");
ensureColumn("model_connectors", "protocol_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("model_connectors", "auth_env", "TEXT NOT NULL DEFAULT ''");
ensureColumn("generation_jobs", "request_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("generation_jobs", "result_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("generation_jobs", "error_message", "TEXT NOT NULL DEFAULT ''");
ensureColumn("generation_jobs", "max_attempts", "INTEGER NOT NULL DEFAULT 3");
ensureColumn("generation_jobs", "next_run_at", "TEXT");
ensureColumn("generation_jobs", "leased_by", "TEXT");
ensureColumn("generation_jobs", "leased_at", "TEXT");
ensureColumn("generation_jobs", "started_at", "TEXT");
ensureColumn("generation_jobs", "finished_at", "TEXT");
ensureColumn("billing_accounts", "billing_cycle", "TEXT NOT NULL DEFAULT 'monthly'");
ensureColumn("billing_accounts", "currency", "TEXT NOT NULL DEFAULT 'CNY'");
ensureColumn("billing_accounts", "base_fee", "REAL NOT NULL DEFAULT 0");
ensureColumn("billing_accounts", "seat_unit_price", "REAL NOT NULL DEFAULT 0");
ensureColumn("billing_accounts", "storage_unit_price", "REAL NOT NULL DEFAULT 0");
ensureColumn("billing_accounts", "clip_unit_price", "REAL NOT NULL DEFAULT 0");
ensureColumn("billing_accounts", "quota_warning_percent", "INTEGER NOT NULL DEFAULT 80");
ensureColumn("billing_accounts", "current_period_start", "TEXT");
ensureColumn("billing_accounts", "current_period_end", "TEXT");
ensureColumn("billing_accounts", "next_invoice_at", "TEXT");
db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_dispatch ON generation_jobs(status, next_run_at, priority, created_at)");
ensureColumn("series", "show_engine", "TEXT NOT NULL DEFAULT ''");
ensureColumn("episodes", "target_duration_sec", "REAL NOT NULL DEFAULT 0");
ensureColumn("episodes", "hook", "TEXT NOT NULL DEFAULT ''");
ensureColumn("episodes", "cliffhanger", "TEXT NOT NULL DEFAULT ''");
ensureColumn("api_clients", "organization_id", "TEXT");
ensureColumn("api_clients", "workspace_id", "TEXT");
ensureColumn("api_clients", "client_key_hash", "TEXT");
ensureColumn("api_clients", "client_key_prefix", "TEXT NOT NULL DEFAULT ''");
ensureColumn("api_clients", "key_version", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("invitations", "token_hash", "TEXT");
ensureColumn("invitations", "token_hint", "TEXT");
ensureColumn("invitations", "accepted_user_id", "TEXT");
ensureColumn("invitations", "accepted_at", "TEXT");
ensureColumn("invitations", "revoked_at", "TEXT");
ensureColumn("identity_providers", "organization_id", "TEXT");
ensureColumn("identity_providers", "workspace_id", "TEXT");
ensureColumn("identity_providers", "jwks_url", "TEXT NOT NULL DEFAULT ''");
ensureColumn("identity_providers", "entry_point", "TEXT NOT NULL DEFAULT ''");
ensureColumn("identity_providers", "idp_cert_ref", "TEXT NOT NULL DEFAULT ''");
ensureColumn("identity_providers", "sp_issuer", "TEXT NOT NULL DEFAULT ''");
ensureColumn("identity_providers", "audience", "TEXT NOT NULL DEFAULT ''");
ensureColumn("identity_providers", "saml_name_id_format", "TEXT NOT NULL DEFAULT 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'");
ensureColumn("identity_providers", "want_assertions_signed", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("identity_providers", "want_authn_response_signed", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("identity_providers", "validate_in_response_to", "TEXT NOT NULL DEFAULT 'ifPresent'");
ensureColumn("identity_providers", "auto_provision", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("identity_providers", "default_role_key", "TEXT NOT NULL DEFAULT 'org_member'");
ensureColumn("identity_providers", "default_workspace_role_key", "TEXT NOT NULL DEFAULT 'writer'");
ensureColumn("asset_versions", "file_name", "TEXT NOT NULL DEFAULT ''");
ensureColumn("asset_versions", "mime_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'");
ensureColumn("asset_versions", "file_size", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("asset_versions", "content_sha256", "TEXT NOT NULL DEFAULT ''");
ensureColumn("shots", "current_version_id", "TEXT");
ensureColumn("deliveries", "active_batch_id", "TEXT");
ensureColumn("delivery_releases", "idempotency_key", "TEXT NOT NULL DEFAULT ''");
ensureColumn("delivery_releases", "preflight_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("delivery_releases", "result_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("projects", "archived_at", "TEXT");
ensureColumn("projects", "archived_by", "TEXT");
ensureColumn("projects", "archived_from_status", "TEXT");
ensureColumn("projects", "template_id", "TEXT NOT NULL DEFAULT 'ai-manhua-drama'");
ensureColumn("workspace_members", "access_mode", "TEXT NOT NULL DEFAULT 'all'");
ensureColumn("auth_sessions", "device_id", "TEXT");
ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'");
ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50");
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_lifecycle ON projects(workspace_id, status, updated_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_invites_token ON invitations(token_hash, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_identity_providers_org ON identity_providers(organization_id, enabled, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_oidc_login_states_hash ON oidc_login_states(state_hash, expires_at, consumed_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_relay ON saml_login_states(relay_state_hash, expires_at, consumed_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_request ON saml_login_states(request_id, expires_at, consumed_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_external_identities_user ON external_identities(user_id, provider_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sso_tickets_hash ON auth_sso_tickets(ticket_hash, expires_at, consumed_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_api_clients_key_hash ON api_clients(client_key_hash, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_user_created ON auth_security_events(user_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_type_created ON auth_security_events(event_type, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_user_last_seen ON auth_devices(user_id, last_seen_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_key_hash ON auth_devices(user_id, device_key_hash)");
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sessions_device ON auth_sessions(device_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC)");
function migrateApiClientSecrets() {
const rows = dbAll("SELECT id, client_key, client_key_hash, client_key_prefix, key_version FROM api_clients");
for (const row of rows) {
if (row.client_key_hash && row.client_key_prefix && Number(row.key_version || 0) >= 2) continue;
const storedValue = String(row.client_key || "");
const existingHash = String(row.client_key_hash || "");
const hash = /^[a-f0-9]{64}$/i.test(existingHash)
? existingHash.toLowerCase()
: storedValue.startsWith("hash:") && /^[a-f0-9]{64}$/i.test(storedValue.slice(5))
? storedValue.slice(5).toLowerCase()
: hashApiClientKey(storedValue);
const prefix = row.client_key_prefix || (storedValue.startsWith("hash:") ? "local-" : storedValue.slice(0, 12));
dbRun("UPDATE api_clients SET client_key = ?, client_key_hash = ?, client_key_prefix = ?, key_version = 2 WHERE id = ?", [apiClientStorageMarker(hash), hash, prefix, row.id]);
}
}
migrateApiClientSecrets();
const now = () => new Date().toISOString();
const isoDaysFromNow = (days) => new Date(Date.now() + days * 86400000).toISOString();
const monthStart = () => {
const date = new Date();
date.setUTCDate(1);
date.setUTCHours(0, 0, 0, 0);
return date.toISOString();
};
const monthEnd = () => {
const date = new Date();
date.setUTCMonth(date.getUTCMonth() + 1, 0);
date.setUTCHours(23, 59, 59, 999);
return date.toISOString();
};
export function dbGet(sql, params = []) {
return db.prepare(sql).get(...params) || null;
}
export function dbAll(sql, params = []) {
return db.prepare(sql).all(...params);
}
export function dbRun(sql, params = []) {
return db.prepare(sql).run(...params);
}
export function createPasswordRecord(password) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(String(password), salt, 64).toString("hex");
return { salt, hash };
}
export function passwordHash(password, salt) {
return scryptSync(String(password), salt, 64).toString("hex");
}
export function withTransaction(callback) {
db.exec("BEGIN IMMEDIATE");
try {
const result = callback();
db.exec("COMMIT");
return result;
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}
function insertIgnore(sql, params) {
dbRun(sql, params);
}
function seedRoles() {
const roles = [
["org_owner", "organization", "组织所有者", "组织全局管理、账单、成员和模型策略"],
["org_admin", "organization", "组织管理员", "组织成员、工作区、模型和审计管理"],
["org_member", "organization", "组织成员", "组织内的基础成员身份,具体能力由工作区/项目角色决定"],
["producer", "workspace", "制片", "项目、批次、队列、成本和交付管理"],
["writer", "workspace", "编剧", "剧本、分集、对白和镜头草稿"],
["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"],
["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"],
["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"],
["project_guest", "workspace", "项目受限成员", "只访问被授权项目,不继承工作区其他项目"],
["project_editor", "project", "项目编辑", "指定项目的内容编辑"],
["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"]
];
for (const role of roles) {
insertIgnore("INSERT OR IGNORE INTO roles(key, scope, name, description) VALUES (?, ?, ?, ?)", role);
}
const permissions = [
["organization:manage", "修改组织设置"],
["organization:members:invite", "邀请组织成员"],
["organization:roles:manage", "管理组织角色权限策略"],
["workspace:create", "创建工作区"],
["workspace:manage", "管理工作区设置"],
["workspace:members:manage", "管理工作区成员"],
["project:create", "创建项目"],
["project:manage", "管理项目设置"],
["project:members:manage", "管理项目成员"],
["task:view", "查看项目协作任务"],
["task:manage", "创建、分派和管理项目协作任务"],
["task:complete", "更新本人负责的协作任务状态"],
["script:edit", "编辑剧本和对白"],
["script:read", "查看剧本、分集和镜头"],
["asset:edit", "编辑角色、场景和道具锁"],
["prompt:edit", "编辑生成提示"],
["voice:edit", "编辑固定声线和字幕"],
["voice:approve", "审批角色参考音频和声音授权"],
["job:create", "创建生成任务"],
["job:prioritize", "调整任务优先级"],
["model:manage", "注册和管理模型连接器"],
["usage:view", "查看用量和成本"],
["billing:manage", "管理套餐和账单"],
["quota:manage", "管理组织席位与工作区配额"],
["qa:review", "执行审片和 QA"],
["delivery:approve", "批准交付版本"],
["delivery:view", "查看交付版本和导出物"],
["compliance:manage", "管理合规策略和证据"],
["audit:view", "查看审计日志"],
["system:settings:view", "查看系统配置"],
["system:settings:edit", "修改系统配置"],
["feature_flag:manage", "管理功能开关"],
["service:health:view", "查看服务健康状态"],
["api_client:manage", "管理 API 客户端"],
["notification:manage", "管理通知渠道"],
["queue:manage", "管理全局任务队列"],
["runner:manage", "管理本地 Runner"]
];
for (const permission of permissions) {
insertIgnore("INSERT OR IGNORE INTO permissions(key, description) VALUES (?, ?)", permission);
}
const rolePermissions = {
org_owner: permissions.map(([key]) => key),
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
"task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"],
project_guest: ["script:read", "task:view", "delivery:view"],
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"],
project_viewer: ["script:read", "task:view", "delivery:view"]
};
for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) {
for (const permissionKey of permissionKeys) {
insertIgnore("INSERT OR IGNORE INTO role_permissions(role_key, permission_key) VALUES (?, ?)", [roleKey, permissionKey]);
}
}
}
function seedUsers() {
const timestamp = now();
const users = [
["u-owner", "林制片", "producer@local.test", "#d97757", "active"],
["u-producer", "周制片", "producer2@local.test", "#3f7f87", "active"],
["u-writer", "白编剧", "writer@local.test", "#a77646", "active"],
["u-art", "沈美术", "art@local.test", "#8e6a9f", "active"],
["u-voice", "许配音", "voice@local.test", "#477d69", "active"],
["u-review", "顾审片", "review@local.test", "#9a6b51", "active"],
["u-local-worker", "本地 Worker", "local-worker@system.invalid", "#58656b", "suspended"]
];
for (const [id, displayName, email, color, status] of users) {
insertIgnore("INSERT OR IGNORE INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [id, displayName, email, color, status, timestamp, timestamp]);
}
}
function seedCredentials() {
const timestamp = now();
const demoPassword = "Demo@123456";
const users = dbAll("SELECT id FROM users WHERE status = 'active'");
for (const user of users) {
const existing = dbGet("SELECT user_id FROM user_credentials WHERE user_id = ?", [user.id]);
if (!existing) {
const record = createPasswordRecord(demoPassword);
insertIgnore("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [user.id, record.salt, record.hash, timestamp, timestamp]);
}
}
}
function seedOrganization({ id, name, slug, ownerUserId, description, workspaceId, workspaceName, workspaceSlug, projectId, projectName, projectType, readiness, risk }) {
const timestamp = now();
insertIgnore("INSERT OR IGNORE INTO organizations(id, name, slug, owner_user_id, deployment_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'private-local', 'active', ?, ?)", [id, name, slug, ownerUserId, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_owner', 'active', ?, ?, ?)", [`om-${id}`, id, ownerUserId, timestamp, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", [workspaceId, id, workspaceName, workspaceSlug, description, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'producer', 'active', ?, ?)", [`wm-${workspaceId}-${ownerUserId}`, workspaceId, ownerUserId, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, ?, 'production', ?, 'workspace', ?, ?, ?, ?)", [projectId, workspaceId, projectName, projectType, ownerUserId, readiness, risk, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", [`pm-${projectId}-${ownerUserId}`, projectId, ownerUserId, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO billing_accounts(id, organization_id, plan_name, billing_cycle, currency, seat_limit, storage_gb, monthly_clip_quota, quota_warning_percent, local_runner_only, cloud_connectors_require_approval, current_period_start, current_period_end, next_invoice_at, created_at, updated_at) VALUES (?, ?, 'Studio Local', 'monthly', 'CNY', 12, 1024, 2400, 80, 1, 1, ?, ?, ?, ?, ?)", [`bill-${id}`, id, monthStart(), monthEnd(), monthEnd(), timestamp, timestamp]);
dbRun("UPDATE billing_accounts SET current_period_start = COALESCE(current_period_start, ?), current_period_end = COALESCE(current_period_end, ?), next_invoice_at = COALESCE(next_invoice_at, ?) WHERE organization_id = ?", [monthStart(), monthEnd(), monthEnd(), id]);
const costCenters = [
[`cc-${id}-gpu`, "local-gpu", "本地 GPU / 电力", "本地推理与视频生成的运营成本", 500],
[`cc-${id}-storage`, "storage", "本地存储", "素材、缓存和交付归档存储", 200],
[`cc-${id}-ops`, "operations", "平台运营", "通知、审计和运维工作量", 300]
];
for (const [costCenterId, code, name, description, budget] of costCenters) {
insertIgnore("INSERT OR IGNORE INTO cost_centers(id, organization_id, code, name, description, monthly_budget, currency, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'CNY', 'active', ?, ?)", [costCenterId, id, code, name, description, budget, timestamp, timestamp]);
}
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 36, 'clips', ?, ?, ?, ?)", [`quota-${workspaceId}-clip`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
}
function seedMembersAndProjects() {
const timestamp = now();
const membershipRows = [
["om-studio-writer", "org-studio-lab", "u-writer", "org_admin"],
["om-studio-art", "org-studio-lab", "u-art", "org_admin"],
["om-studio-voice", "org-studio-lab", "u-voice", "org_admin"],
["om-studio-review", "org-studio-lab", "u-review", "org_admin"],
["om-northstar-producer", "org-northstar", "u-producer", "org_admin"]
];
for (const row of membershipRows) {
insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)", [...row, timestamp, timestamp, timestamp]);
}
dbRun("UPDATE organization_members SET role_key = 'org_member' WHERE organization_id = 'org-studio-lab' AND user_id IN ('u-writer', 'u-art', 'u-voice', 'u-review')");
const workspaceRows = [
["wm-local-writer", "ws-local-aidrama", "u-writer", "writer"],
["wm-local-art", "ws-local-aidrama", "u-art", "art_director"],
["wm-local-voice", "ws-local-aidrama", "u-voice", "voice_editor"],
["wm-local-review", "ws-local-aidrama", "u-review", "reviewer"],
["wm-pilot-owner", "ws-pilot", "u-owner", "producer"],
["wm-northstar-producer", "ws-northstar-main", "u-producer", "producer"]
];
for (const row of workspaceRows) {
insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [...row, timestamp, timestamp]);
}
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, '模板', 'template', ?, 'workspace', 48, 'low', ?, ?)", ["template-original-manhua", "ws-pilot", "原创国漫短剧模板", "u-owner", timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-template-owner", "template-original-manhua", "u-owner", timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, 'AI 漫剧', 'production', ?, 'workspace', 35, 'medium', ?, ?)", ["northstar-pilot", "ws-northstar-main", "山海志异·试播集", "u-producer", timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-northstar-producer", "northstar-pilot", "u-producer", timestamp, timestamp]);
}
function seedModels() {
const timestamp = now();
const models = [
["owned-i2v", "org-studio-lab", "ws-local-aidrama", "自有图生视频平台", "http-json", ["image-to-video", "first-last-frame", "vertical-video"], "http://127.0.0.1:7860/api/generate/i2v", "not-connected", "local", 0, "u-owner"],
["owned-image", "org-studio-lab", "ws-local-aidrama", "自有图片/改图平台", "http-json", ["text-to-image", "image-edit", "single-frame"], "http://127.0.0.1:7860/api/generate/image", "not-connected", "local", 0, "u-owner"],
["local-tts", "org-studio-lab", "ws-local-aidrama", "本地固定声线 TTS", "http-json", ["tts", "voice-lock", "subtitle-timing"], "http://127.0.0.1:7861/api/tts", "planned", "local", 0, "u-owner"],
["newapi-audio-production", "org-studio-lab", "ws-local-aidrama", "NewAPI 音频中转", "openai-compatible-audio", ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"], "https://newapi.ysblack.com/v1", "ready", "mixed", 1, "u-owner"],
["comfyui-optional", "org-studio-lab", "ws-local-aidrama", "ComfyUI 工作流桥接", "comfyui", ["workflow", "qwen-image", "qwen-edit"], "http://127.0.0.1:8188", "optional", "mixed", 1, "u-owner"],
["northstar-image", "org-northstar", "ws-northstar-main", "北辰本地图像 Runner", "http-json", ["text-to-image", "single-frame"], "http://127.0.0.1:7960/api/image", "ready", "local", 0, "u-producer"]
];
for (const [id, organizationId, workspaceId, label, kind, capabilities, endpoint, status, costMode, approvalRequired, createdBy] of models) {
insertIgnore("INSERT OR IGNORE INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, organizationId, workspaceId, label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired, createdBy, timestamp, timestamp]);
}
}
function seedSystemGovernance() {
const timestamp = now();
insertIgnore("INSERT OR IGNORE INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES ('u-owner', 'system_admin', 'active', ?, ?)", [timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO identity_policies(id, password_login_enabled, mfa_required_for_admins, mfa_required_for_all, sso_enabled, local_login_fallback, session_ttl_hours, max_sessions_per_user, updated_by, created_at, updated_at) VALUES ('default', 1, 0, 0, 0, 1, 12, 10, 'u-owner', ?, ?)", [timestamp, timestamp]);
const settings = [
["app.name", "general", "AI 短剧生产平台", "string", "平台显示名称", 0],
["app.locale", "general", "zh-CN", "string", "默认界面语言", 0],
["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0],
["deployment.mode", "deployment", "private-local", "string", "部署模式", 0],
["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0],
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0],
["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0],
["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0],
["generation.comfyui_optional", "generation", true, "boolean", "ComfyUI 仅作为可选适配器", 0],
["voice.fixed_voice_required", "voice", true, "boolean", "角色必须绑定固定声线", 0],
["qa.require_asr_alignment", "qa", true, "boolean", "配音任务必须通过 ASR/字幕对齐", 0],
["qa.require_continuity_lock", "qa", true, "boolean", "镜头生成必须引用连续性锁", 0],
["notifications.email_enabled", "notifications", false, "boolean", "启用邮件通知", 0],
["api.rate_limit_per_minute", "api", 120, "number", "API 每分钟请求上限", 0]
];
for (const [key, category, value, valueType, description, sensitive] of settings) {
insertIgnore("INSERT OR IGNORE INTO system_settings(key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)", [key, category, JSON.stringify(value), valueType, description, sensitive, timestamp, timestamp]);
}
const flags = [
["creator_portal", "用户创作空间", "项目、剧本、资产和交付工作台", 1],
["admin_console", "管理员后台", "组织、成员、模型、队列和计量治理", 1],
["system_settings", "系统配置中心", "部署、生成策略、存储和通知配置", 1],
["batch_generation", "批量生成", "允许制片人创建批量生成批次", 1],
["review_workflow", "审片审批流", "启用多人审片和交付审批", 1],
["comfyui_adapter", "ComfyUI 适配器", "可选的本地 ComfyUI 工作流桥接", 0],
["external_cloud_connectors", "外部云连接器", "允许接入付费或云端模型", 0]
];
for (const [key, label, description, enabled] of flags) {
insertIgnore("INSERT OR IGNORE INTO feature_flags(key, label, description, enabled, scope, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'system', 'u-owner', ?, ?)", [key, label, description, enabled, timestamp, timestamp]);
}
insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-local-log', '本地事件日志', 'local-log', '', 1, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.failed", "review.blocked", "quota.warning", "system.changed"]), timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-webhook-optional', '本地 Webhook(可选)', 'webhook', 'http://127.0.0.1:8790/hooks/ai-drama', 0, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.completed", "delivery.approved"]), timestamp, timestamp]);
const seedApiKeyHash = hashApiClientKey("local-runner-demo-key");
insertIgnore("INSERT OR IGNORE INTO api_clients(id, name, client_key, client_key_hash, client_key_prefix, key_version, status, scopes_json, created_by, created_at, updated_at) VALUES ('client-local-runner', '本地 Runner 客户端', ?, ?, 'local-runner-', 2, 'active', ?, 'u-owner', ?, ?)", [apiClientStorageMarker(seedApiKeyHash), seedApiKeyHash, JSON.stringify(["jobs:read", "jobs:write", "models:read"]), timestamp, timestamp]);
dbRun("UPDATE api_clients SET organization_id = COALESCE(organization_id, 'org-studio-lab'), workspace_id = COALESCE(workspace_id, 'ws-local-aidrama') WHERE id = 'client-local-runner'");
const services = [
["service-local-api", "local-api", "本地 API 服务", "local-service", "ready", "http://127.0.0.1:8787/api/health", 2, 0, "node-24"],
["service-sqlite", "sqlite", "SQLite 租户数据库", "database", "ready", dbPath, 1, 0, "node:sqlite"],
["service-script-runner", "script-runner", "剧本拆解 Runner", "runner", "ready", "local://script", 4, 0, "local"],
["service-image-runner", "image-runner", "单画面 Runner", "runner", "waiting-model", "local://image", null, 3, "local"],
["service-video-runner", "video-runner", "视频片段 Runner", "runner", "waiting-model", "local://video", null, 2, "local"],
["service-voice-runner", "voice-runner", "固定配音 Runner", "runner", "planned", "local://voice", null, 6, "local"],
["service-local-worker", "local-worker", "本地后台 Worker", "runner", "ready", "local://worker", 0, 0, "node-24"]
];
for (const [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version] of services) {
insertIgnore("INSERT OR IGNORE INTO service_health(id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?)", [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version, status === "ready" ? timestamp : null, timestamp]);
}
}
function seedUserNotifications() {
const timestamp = now();
insertIgnore(
"INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
"user-notification-seed-review",
"u-owner",
"org-studio-lab",
"ws-local-aidrama",
"thunder-mouth",
"review",
"review.changes_requested",
"warning",
"E01-S02 连续性检查需要确认",
"角色服装与上一段实际末帧的连续性证据已提交,请在审片中心确认后再进入下一轮生成。",
"qa",
"review-thunder-mouth-continuity",
JSON.stringify({ source: "seed", lane: "continuity-lock" }),
timestamp
]
);
insertIgnore(
"INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at, read_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
"user-notification-seed-policy",
"u-owner",
"org-studio-lab",
"ws-local-aidrama",
null,
"system",
"system.changed",
"info",
"本地生产策略已生效",
"平台当前保持 local-only:ComfyUI 仅作为可选适配器,外部云连接器默认关闭。",
"system-generation",
"generation.single_frame_only",
JSON.stringify({ source: "seed", policy: "local-only" }),
timestamp,
timestamp
]
);
}
function seedProductionGraph() {
const timestamp = now();
insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ["series-thunder-mouth", "thunder-mouth", sampleProject.series.title, sampleProject.series.logline, sampleProject.series.format, sampleProject.series.visualStyle, sampleProject.series.continuityRule, sampleProject.series.showEngine, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-thunder-mouth-1", "series-thunder-mouth", timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, ?, 'production', ?, ?, ?, ?, ?)", ["episode-thunder-mouth-01", "season-thunder-mouth-1", sampleProject.episode.title, sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, timestamp, timestamp]);
dbRun("UPDATE series SET show_engine = COALESCE(NULLIF(show_engine, ''), ?) WHERE id = ?", [sampleProject.series.showEngine, "series-thunder-mouth"]);
dbRun("UPDATE episodes SET target_duration_sec = CASE WHEN target_duration_sec = 0 THEN ? ELSE target_duration_sec END, hook = COALESCE(NULLIF(hook, ''), ?), cliffhanger = COALESCE(NULLIF(cliffhanger, ''), ?) WHERE id = ?", [sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, "episode-thunder-mouth-01"]);
for (const shot of sampleProject.shots) {
insertIgnore("INSERT OR IGNORE INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shot.id, "episode-thunder-mouth-01", Number(shot.id.split("-").pop()) || 1, shot.title, shot.firstFrame, shot.lastFrame, JSON.stringify({ transition: shot.transitionFromPrevious, camera: shot.camera }), timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'locked', 'u-owner', ?)", [`${shot.id}-v1`, shot.id, JSON.stringify(shot), timestamp]);
for (let index = 0; index < (shot.voiceLines || []).length; index += 1) {
const line = shot.voiceLines[index];
insertIgnore("INSERT OR IGNORE INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'u-owner', ?, ?)", [line.id, shot.id, index + 1, line.characterId, line.text, line.emotion || "", line.mouthPlan || "规避嘴形", line.targetDurationSec || 2, "", line.audioFile || "", timestamp, timestamp]);
}
}
insertIgnore("INSERT OR IGNORE INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', 1, ?, '原创短剧剧本', ?, 'analyzed', ?, 'u-owner', ?, ?)", [
"script-thunder-mouth-v1",
`第 1 集《${sampleProject.episode.title}》`,
`第 1 集《${sampleProject.episode.title}》\n\n${sampleProject.episode.hook}\n\n陈宇:等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。\n唐夏:那我现在该往哪走?\n陈宇:看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。\n唐夏:我给家里报个平安。`,
JSON.stringify({ chapters: sampleProject.scriptStudio.chapters, extracted: sampleProject.scriptStudio.extracted, parser: "local-rule-v1" }),
timestamp,
timestamp
]);
const assetSeeds = [
["asset-character-chen-yu", "character", "陈宇", "青年志愿者", "locked", "approved", "assets/thunder-mouth/characters/chen-yu/v1/portrait.png", { initial: "陈", tags: ["角色锁", "衣橱", "声线"], usage: "S01 全季", detail: sampleProject.characters[0].visualLock, lock: sampleProject.characters[0].costumeState }],
["asset-character-tang-xia", "character", "唐夏", "高中女生", "locked", "approved", "assets/thunder-mouth/characters/tang-xia/v1/portrait.png", { initial: "唐", tags: ["角色锁", "衣橱", "声线"], usage: "S01 前 6 集", detail: sampleProject.characters[1].visualLock, lock: sampleProject.characters[1].costumeState }],
["asset-location-metro-canopy-rain", "location", "地铁口玻璃连廊", "傍晚雷暴场景", "locked", "approved", "assets/thunder-mouth/locations/metro-canopy-rain/v1/location.png", { initial: "景", tags: ["场景锁", "天气", "机位"], usage: "E01 全部镜头", detail: sampleProject.locations[0].lock, lock: sampleProject.locations[0].cameraLock }],
["asset-prop-blue-umbrella", "prop", "折叠蓝伞", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/blue-umbrella/v1/prop.png", { initial: "伞", tags: ["道具锁", "陈宇"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[0].lock, lock: "右手低握,未打开" }],
["asset-prop-yellow-poncho", "prop", "黄色雨披", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/yellow-poncho/v1/prop.png", { initial: "披", tags: ["道具锁", "唐夏"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[1].lock, lock: "折在双臂上,不能突然穿上" }],
["asset-prop-warning-cones", "prop", "路锥和警戒线", "安全道具", "locked", "approved", "assets/thunder-mouth/props/warning-cones/v1/prop.png", { initial: "警", tags: ["道具锁", "安全"], usage: "shot-02", detail: sampleProject.props[2].lock, lock: "角色不跨越" }],
["asset-prop-phone", "prop", "手机", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/phone/v1/prop.png", { initial: "机", tags: ["道具锁", "shot-03"], usage: "shot-03", detail: sampleProject.props[3].lock, lock: "低位拿出,不遮挡脸" }],
["asset-voice-chen-yu", "voice", "陈宇固定参考音频", "自然男声 · 已授权", "locked", "approved", "assets/thunder-mouth/voices/chen-yu/v1/reference.wav", { initial: "声", tags: ["声线锁", "IndexTTS-2.5", "已授权"], usage: "S01 全季", detail: "自然、克制、年轻男声;只作为陈宇正式参考音频。", lock: "必须保留授权证据引用,不使用随机原生声线。", voiceId: "voice-chen-yu", ttsModel: "IndexTTS-2.5", consentRef: "local-consent/chen-yu-v1" }],
["asset-voice-tang-xia", "voice", "唐夏固定参考音频", "自然女声 · 待补证据", "review", "needs-evidence", "assets/thunder-mouth/voices/tang-xia/v1/reference.wav", { initial: "声", tags: ["声线锁", "待授权证据"], usage: "S01 前 6 集", detail: "自然、清晰、带轻微紧张感的女声;正式批量配音前必须补齐授权证据。", lock: "单句试听可以排队,未审批前禁止批量 TTS。", voiceId: "voice-tang-xia", ttsModel: "IndexTTS-2.5", consentRef: "" }],
["asset-style-cel-shading", "style", "国漫 2D 赛璐璐基线", "项目视觉基线", "locked", "approved", "assets/thunder-mouth/styles/cel-shading/v1/style.json", { initial: "风", tags: ["视觉锁", "9:16", "雨景"], usage: "全季复用", detail: sampleProject.series.visualStyle, lock: "禁止多格、拼图、多时间点" }],
["asset-lora-rain-local", "lora", "雷雨口 · 本地一致性适配", "自有模型平台资产", "draft", "needs-evidence", "assets/thunder-mouth/lora/rain-local/v1/model.safetensors", { initial: "L", tags: ["local-only", "角色", "场景"], usage: "shot-01 ~ shot-03", detail: "仅作为自有模型平台可选增强,不替代角色、场景、道具锁。", lock: "美术负责人确认后才能进入生产" }]
];
for (const [id, kind, name, subtitle, lockStatus, rightsStatus, storagePath, metadata] of assetSeeds) {
insertIgnore("INSERT OR IGNORE INTO assets(id, project_id, kind, name, lock_status, created_by, created_at, updated_at) VALUES (?, 'thunder-mouth', ?, ?, ?, 'u-owner', ?, ?)", [id, kind, name, lockStatus, timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO asset_versions(id, asset_id, version_number, storage_path, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, 'u-owner', ?)", [`${id}-v1`, id, storagePath, rightsStatus, JSON.stringify({ subtitle, ...metadata }), timestamp]);
dbRun("UPDATE assets SET current_version_id = COALESCE(current_version_id, ?), updated_at = ? WHERE id = ?", [`${id}-v1`, timestamp, id]);
}
const bindingSeeds = [
["asset-character-chen-yu", ["shot-01", "shot-02", "shot-03"], "character"],
["asset-character-tang-xia", ["shot-01", "shot-02", "shot-03"], "character"],
["asset-location-metro-canopy-rain", ["shot-01", "shot-02", "shot-03"], "location"],
["asset-prop-blue-umbrella", ["shot-01", "shot-02", "shot-03"], "prop"],
["asset-prop-yellow-poncho", ["shot-01", "shot-02", "shot-03"], "prop"],
["asset-prop-warning-cones", ["shot-02"], "prop"],
["asset-prop-phone", ["shot-03"], "prop"]
];
for (const [assetId, shotIds, usageRole] of bindingSeeds) {
for (const shotId of shotIds) insertIgnore("INSERT OR IGNORE INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, 'u-owner', ?)", [`binding-${assetId}-${shotId}`, assetId, shotId, usageRole, timestamp]);
}
for (const job of sampleProject.productionJobs) {
const shotId = sampleProject.shots.some((shot) => shot.id === job.shotId) ? job.shotId : null;
insertIgnore("INSERT OR IGNORE INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, priority, cost_policy, output_path, qa_status, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', ?, ?, ?, ?, 50, ?, ?, ?, 'u-owner', ?, ?)", [job.id, shotId, job.kind, job.adapter, job.status, job.costPolicy, job.output, job.qa, timestamp, timestamp]);
}
insertIgnore("INSERT OR IGNORE INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES ('usage-seed-001', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'u-owner', 'image-generation', 36, 'clips', 86, '{\"source\":\"seed\"}', ?)", [timestamp]);
const audits = [
["aud-001", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "project.created", "project", "thunder-mouth", "ok", "{}"],
["aud-002", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "policy.single-frame.checked", "shot", "shot-01", "pass", "{\"gate\":\"single_frame_only\"}"],
["aud-003", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "exports.write", "delivery", "project-template", "ok", "{}"],
["aud-004", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "cloud.connector.blocked", "model_connector", "paid-node", "requires-approval", "{\"policy\":\"local_runner_only\"}"]
];
for (const audit of audits) {
insertIgnore("INSERT OR IGNORE INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [...audit, timestamp]);
}
}
withTransaction(() => {
seedRoles();
seedUsers();
seedCredentials();
seedOrganization({
id: "org-studio-lab",
name: "星河短剧实验室",
slug: "xinghe-studio",
ownerUserId: "u-owner",
description: "本地 AI 漫剧与短剧主生产空间",
workspaceId: "ws-local-aidrama",
workspaceName: "短剧生产中心",
workspaceSlug: "drama-production",
projectId: "thunder-mouth",
projectName: "雷雨口",
projectType: "AI 漫剧",
readiness: 72,
risk: "medium"
});
seedOrganization({
id: "org-northstar",
name: "北辰内容厂牌",
slug: "northstar-content",
ownerUserId: "u-owner",
description: "第二个客户组织,用于验证跨组织隔离",
workspaceId: "ws-northstar-main",
workspaceName: "北辰试制部",
workspaceSlug: "pilot-room",
projectId: "northstar-pilot",
projectName: "山海志异·试播集",
projectType: "AI 漫剧",
readiness: 35,
risk: "medium"
});
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]);
seedMembersAndProjects();
seedModels();
seedSystemGovernance();
seedProductionGraph();
seedUserNotifications();
});
export function resetDatabaseForTests() {
db.exec("DELETE FROM audit_logs; DELETE FROM usage_events; DELETE FROM generation_jobs;");
}
export const databaseInfo = {
path: dbPath,
engine: "node:sqlite",
seededAt: now()
};