Files
ai-drama-platform/server/db.mjs
T
2026-09-01 13:22:04 +08:00

1295 lines
91 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, workflowTemplates } 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", "model_route_approval_id", "TEXT");
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("asset_versions", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("asset_versions", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("asset_versions", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
ensureColumn("asset_versions", "license_scope", "TEXT NOT NULL DEFAULT ''");
ensureColumn("asset_versions", "expires_at", "TEXT");
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("script_documents", "metadata_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");
ensureColumn("knowledge_documents", "rights_status", "TEXT NOT NULL DEFAULT 'needs-evidence'");
ensureColumn("knowledge_documents", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("knowledge_documents", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
ensureColumn("knowledge_documents", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("knowledge_documents", "current_version_number", "INTEGER NOT NULL DEFAULT 1");
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_clearance_reports_scope ON delivery_clearance_reports(organization_id, workspace_id, project_id, delivery_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_release ON delivery_clearance_reports(release_id, created_at DESC)");
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)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_scope ON model_route_approval_requests(organization_id, workspace_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_requester ON model_route_approval_requests(requester_user_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_job ON model_route_approval_requests(job_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_generation_jobs_model_approval ON generation_jobs(model_route_approval_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, 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", "管理组织角色权限策略"],
["policy:read", "查看组织生产、安全和合规策略"],
["policy:manage", "管理组织生产、安全和合规策略"],
["workspace:create", "创建工作区"],
["workspace:manage", "管理工作区设置"],
["workspace:members:manage", "管理工作区成员"],
["project:create", "创建项目"],
["project:manage", "管理项目设置"],
["project:members:manage", "管理项目成员"],
["workflow:manage", "管理生产流程模板和版本"],
["task:view", "查看项目协作任务"],
["task:manage", "创建、分派和管理项目协作任务"],
["task:complete", "更新本人负责的协作任务状态"],
["style:read", "查看组织、工作区和项目风格生产标准"],
["style:manage", "管理风格标准、品牌规范和项目绑定"],
["script:edit", "编辑剧本和对白"],
["script:read", "查看剧本、分集和镜头"],
["asset:edit", "编辑角色、场景和道具锁"],
["prompt:edit", "编辑生成提示"],
["voice:edit", "编辑固定声线和字幕"],
["voice:approve", "审批角色参考音频和声音授权"],
["job:create", "创建生成任务"],
["job:prioritize", "调整任务优先级"],
["model:manage", "注册和管理模型连接器"],
["model:approve", "审批模型路由、外部连接器和一次性生成调用"],
["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",
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "model:manage", "model:approve", "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", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
reviewer: ["script:read", "task:view", "task:complete", "policy:read", "style:read", "qa:review", "voice:approve", "delivery:view"],
project_guest: ["script:read", "task:view", "style:read", "delivery:view"],
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "style:read", "job:create", "delivery:view"],
project_viewer: ["script:read", "task:view", "style:read", "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 rollQuotaPeriods() {
const start = monthStart();
const end = monthEnd();
const timestamp = now();
const rows = dbAll("SELECT * FROM quota_allocations");
for (const row of rows) {
const current = dbGet(
"SELECT julianday(?) <= julianday('now') AND julianday(?) >= julianday('now') AS active",
[row.period_start, row.period_end]
);
if (current?.active) continue;
const usedValue = row.metric === "clip"
? Number(dbGet(
`SELECT COALESCE(SUM(units), 0) AS units
FROM usage_events
WHERE organization_id = ?
AND (workspace_id = ? OR (? IS NULL AND workspace_id IS NULL))
AND unit_name IN ('job', 'clip', 'clips')
AND created_at >= datetime('now', 'start of month')`,
[row.organization_id, row.workspace_id, row.workspace_id]
)?.units || 0)
: Number(row.used_value || 0);
dbRun("UPDATE quota_allocations SET used_value = ?, period_start = ?, period_end = ?, updated_at = ? WHERE id = ?", [usedValue, start, end, timestamp, row.id]);
}
}
function commercialEntitlementDefaults(billing = {}) {
const clipLimit = Number(billing.monthly_clip_quota || 2400);
const storageGb = Number(billing.storage_gb || 1024);
const seatLimit = Number(billing.seat_limit || 12);
return [
["limit.seats", "组织席位", "tenant", seatLimit, "人", 1, "block", { commercialGate: "member-invite-and-sso" }],
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
["limit.knowledge_documents", "知识库素材", "knowledge", 300, "篇", 1, "block", { commercialGate: "knowledge_document:ingest" }],
["limit.knowledge_context_packs", "知识上下文包", "knowledge", 180, "包", 1, "block", { commercialGate: "knowledge_context_pack:create" }],
["limit.delivery_channels", "交付渠道", "delivery", 12, "个", 1, "block", { commercialGate: "delivery_channel:create" }],
["feature.batch_generation", "批量生产", "feature", 1, "开关", 1, "block", { description: "允许创建批量生成与流水线任务" }],
["feature.private_delivery_portal", "客户交付门户", "feature", 1, "开关", 1, "block", { description: "允许创建带令牌的客户预览/下载门户" }],
["feature.comfyui_adapter", "ComfyUI 可选桥接", "feature", 1, "开关", 0, "block", { description: "默认关闭,明确启用后才可作为适配器" }],
["feature.external_cloud_connectors", "外部云连接器", "feature", 1, "开关", 0, "block", { description: "默认关闭,付费/公网模型必须显式审批" }]
];
}
function seedCommercialPlans() {
const timestamp = now();
const plans = [
["plan-starter-local", "starter-local", "Starter Local", "单工作室本地试制版,适合一条短剧流水线验证。", "monthly", "CNY", 0, 5, 256, 500, { workspaces: 2, projects: 8, modelConnectors: 6, apiClients: 3, knowledgeDocuments: 80, knowledgeContextPacks: 40, deliveryChannels: 3 }, { batchGeneration: false, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local"], externalApprovalRequired: true }, "社区支持"],
["plan-studio-local", "studio-local", "Studio Local", "商业工作室私有生产版,覆盖剧本、资产、生成、审片、交付和账单。", "monthly", "CNY", 0, 12, 1024, 2400, { workspaces: 8, projects: 36, modelConnectors: 16, apiClients: 8, knowledgeDocuments: 300, knowledgeContextPacks: 180, deliveryChannels: 12 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "工作日响应"],
["plan-enterprise-private", "enterprise-private", "Enterprise Private", "多组织私有化商业版,适合平台代理、内容厂牌和多项目制片团队。", "annual", "CNY", 0, 50, 8192, 20000, { workspaces: 50, projects: 300, modelConnectors: 80, apiClients: 50, knowledgeDocuments: 5000, knowledgeContextPacks: 2000, deliveryChannels: 60 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: true, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "专属运维窗口"]
];
for (const [id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, limits, features, connectorPolicy, supportSla] of plans) {
insertIgnore(
"INSERT OR IGNORE INTO subscription_plan_templates(id, tier_key, name, description, billing_cycle, currency, base_fee, seat_limit, storage_gb, monthly_clip_quota, limits_json, features_json, connector_policy_json, support_sla, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
[id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, JSON.stringify(limits), JSON.stringify(features), JSON.stringify(connectorPolicy), supportSla, timestamp, timestamp]
);
}
}
function seedOrganizationEntitlements() {
const timestamp = now();
const organizations = dbAll(
`SELECT o.id AS organization_id,
b.seat_limit, b.storage_gb, b.monthly_clip_quota
FROM organizations o
LEFT JOIN billing_accounts b ON b.organization_id = o.id
WHERE o.status = 'active'`
);
for (const organization of organizations) {
for (const [key, label, category, limitValue, unit, enabled, enforcement, metadata] of commercialEntitlementDefaults(organization)) {
insertIgnore(
"INSERT OR IGNORE INTO organization_entitlements(id, organization_id, entitlement_key, label, category, limit_value, unit, enabled, enforcement, source, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'plan', ?, ?, ?)",
[`ent-${organization.organization_id}-${key.replace(/[^a-z0-9]+/gi, "-")}`, organization.organization_id, key, label, category, limitValue, unit, enabled, enforcement, JSON.stringify(metadata), timestamp, timestamp]
);
}
}
}
function seedCommercialApprovals() {
const timestamp = now();
const requests = [
{
id: "commercial-approval-seed-entitlement",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "entitlement_overage",
title: "《雨夜来电》本月追加 500 个生成任务",
status: "submitted",
priority: "high",
targetKey: "limit.generation_jobs_monthly",
currentValue: 2400,
requestedValue: 2900,
unit: "job",
businessReason: "连续三集试制需要补拍转场镜头和反应镜头,仍限定为本地 Runner 执行。",
risk: { localOnly: true, paidCloud: false, singleFrameGate: true, continuityLedger: true },
evidence: { project: "thunder-mouth", source: "seed://commercial/entitlement-overage" },
requester: "u-writer",
reviewer: null,
reviewedAt: null,
decisionNote: "",
effect: {}
},
{
id: "commercial-approval-seed-connector",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "external_connector",
title: "评估 NewAPI 中转的外部官方模型通道",
status: "submitted",
priority: "urgent",
targetKey: "feature.external_cloud_connectors",
currentValue: 0,
requestedValue: 1,
unit: "开关",
businessReason: "仅申请连接能力评估,不自动调用付费云端节点;需要管理员确认成本和合规边界。",
risk: { localOnly: false, paidCloud: true, requiresExplicitApproval: true, secretStored: false },
evidence: { connectorPolicy: "env-secret-only", source: "seed://commercial/external-connector" },
requester: "u-owner",
reviewer: null,
reviewedAt: null,
decisionNote: "",
effect: {}
},
{
id: "commercial-approval-seed-compliance",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "compliance_review",
title: "原创小说素材版权与分块入库复核",
status: "approved",
priority: "medium",
targetKey: "commercial-rights-review",
currentValue: 0,
requestedValue: 1,
unit: "次",
businessReason: "确认《雨夜来电》素材按原创来源入库,允许用于剧本拆解和知识上下文包。",
risk: { sourceType: "original", knowledgeChunking: true, derivativeUse: "internal-production" },
evidence: { knowledgeDocumentId: "knowledge-rain-night", source: "seed://commercial/compliance-review" },
requester: "u-owner",
reviewer: "u-owner",
reviewedAt: timestamp,
decisionNote: "示例放行:原创素材,后续交付仍需逐素材证据。",
effect: { complianceRecordId: "commercial-compliance-seed-rain-night" }
}
];
for (const request of requests) {
insertIgnore(
`INSERT OR IGNORE INTO commercial_approval_requests(
id, organization_id, workspace_id, project_id, request_type, title, status, priority, target_key,
current_value, requested_value, unit, business_reason, risk_assessment_json, evidence_json,
decision_note, effect_json, requester_user_id, reviewer_user_id, reviewed_at, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
[
request.id,
request.organizationId,
request.workspaceId,
request.projectId,
request.requestType,
request.title,
request.status,
request.priority,
request.targetKey,
request.currentValue,
request.requestedValue,
request.unit,
request.businessReason,
JSON.stringify(request.risk),
JSON.stringify(request.evidence),
request.decisionNote,
JSON.stringify(request.effect),
request.requester,
request.reviewer,
request.reviewedAt,
timestamp,
timestamp
]
);
}
insertIgnore(
"INSERT OR IGNORE INTO compliance_records(id, organization_id, workspace_id, project_id, subject_type, subject_id, policy_key, status, evidence_json, reviewed_by, reviewed_at, created_at, updated_at) VALUES ('commercial-compliance-seed-rain-night', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'knowledge_document', 'knowledge-rain-night', 'commercial-rights-review', 'approved', ?, 'u-owner', ?, ?, ?)",
[JSON.stringify({ source: "seed://commercial/compliance-review", knowledgeDocumentId: "knowledge-rain-night" }), timestamp, 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"],
["owned-story-parser", "org-studio-lab", "ws-local-aidrama", "自有文本解析网关", "openai-compatible", ["chat", "script-split", "knowledge-extract"], "http://127.0.0.1:7862/v1", "ready", "local", 0, "u-owner"],
["owned-embedding", "org-studio-lab", "ws-local-aidrama", "自有向量检索网关", "openai-compatible", ["embedding", "rerank", "knowledge-retrieval"], "http://127.0.0.1:7863/v1", "planned", "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 seedModelCatalog() {
const timestamp = now();
const entries = [
["catalog-qwen-image-2d", "org-studio-lab", "ws-local-aidrama", "owned-image", "qwen-image-2d-lock", "Qwen Image 国漫单画面", "qwen-image", ["text-to-image", "single-frame", "continuity-lock"], 32768, 4096, { mode: "per-image", estimatedCny: 0.18, locality: "local" }, "active", "approved", { aspect: "9:16", guardrails: ["single-frame-only", "no-collage"] }],
["catalog-wan-i2v", "org-studio-lab", "ws-local-aidrama", "owned-i2v", "wan-i2v-lastframe", "Wan 图生视频连续版", "wan-video", ["image-to-video", "first-last-frame", "clip-bridge"], 65536, 4096, { mode: "per-clip", estimatedCny: 0.42, locality: "local" }, "active", "approved", { durationSec: [5, 10], notes: "优先使用上一段实际末帧" }],
["catalog-qwen-parser", "org-studio-lab", "ws-local-aidrama", "owned-story-parser", "qwen-script-parser", "Qwen 剧本/小说解析", "qwen-text", ["chat", "script-split", "knowledge-extract", "scene-parse"], 131072, 8192, { mode: "per-1k-tokens", estimatedCny: 0.03, locality: "local" }, "active", "approved", { languages: ["zh-CN"], preferredFor: ["script-import", "knowledge-ingest"] }],
["catalog-bge-m3", "org-studio-lab", "ws-local-aidrama", "owned-embedding", "bge-m3-local", "BGE-M3 向量检索", "embedding", ["embedding", "retrieval", "knowledge-search"], 8192, 0, { mode: "per-1k-tokens", estimatedCny: 0.01, locality: "local" }, "planned", "approved", { dimensions: 1024, preferredFor: ["knowledge-search"] }],
["catalog-indextts", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "IndexTTS-2.5", "IndexTTS-2.5 固定声线", "tts", ["tts", "voice-lock", "emotion-control"], 16384, 2048, { mode: "per-10s-audio", estimatedCny: 0.12, locality: "mixed" }, "active", "review", { referenceRequired: true, approvalGate: "voice-rights" }],
["catalog-paraformer", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "paraformer-zh-long", "Paraformer 长音频 ASR", "asr", ["asr", "subtitle-timing", "alignment"], 16384, 2048, { mode: "per-minute-audio", estimatedCny: 0.05, locality: "mixed" }, "active", "approved", { output: "verbose_json" }],
["catalog-northstar-image", "org-northstar", "ws-northstar-main", "northstar-image", "northstar-sd-xl", "北辰 SDXL 单画面", "sdxl", ["text-to-image", "single-frame"], 32768, 4096, { mode: "per-image", estimatedCny: 0.16, locality: "local" }, "active", "approved", { aspect: "9:16" }]
];
for (const [id, organizationId, workspaceId, connectorId, modelKey, displayName, family, capabilities, contextWindow, maxOutputTokens, cost, status, approvalStatus, metadata] of entries) {
insertIgnore(
"INSERT OR IGNORE INTO model_catalog_entries(id, organization_id, workspace_id, connector_id, model_key, display_name, family, capabilities_json, context_window, max_output_tokens, cost_json, status, approval_status, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
[id, organizationId, workspaceId, connectorId, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), timestamp, timestamp]
);
}
}
function seedModelRoutes() {
const timestamp = now();
const routes = [
["route-script-ingest", "org-studio-lab", "ws-local-aidrama", "小说/剧本导入解析", "knowledge-ingest", "chunk-and-extract", "catalog-qwen-parser", "catalog-bge-m3", "prefer-local", "follow-model", 15, "active", { chunkStrategy: "chapter-scene-dialogue", maxChunkChars: 520 }],
["route-script-materialize", "org-studio-lab", "ws-local-aidrama", "剧本拆解与场景草稿", "script-pipeline", "scene-draft", "catalog-qwen-parser", null, "prefer-local", "follow-model", 10, "active", { target: "script_documents" }],
["route-image-keyframe", "org-studio-lab", "ws-local-aidrama", "单画面关键帧", "ai-manhua-drama", "image-keyframe", "catalog-qwen-image-2d", null, "local-only", "follow-model", 60, "active", { qaGate: "single-frame" }],
["route-video-clip", "org-studio-lab", "ws-local-aidrama", "图生视频片段", "ai-manhua-drama", "video-clip", "catalog-wan-i2v", null, "local-only", "follow-model", 120, "active", { requireActualLastFrame: true }],
["route-voice-tts", "org-studio-lab", "ws-local-aidrama", "角色固定配音", "voice-pipeline", "tts", "catalog-indextts", null, "prefer-approved", "explicit-review", 40, "active", { referenceAssetKind: "voice", forbidRandomNativeVoice: true }],
["route-voice-asr", "org-studio-lab", "ws-local-aidrama", "ASR 对齐校验", "voice-pipeline", "asr", "catalog-paraformer", null, "prefer-approved", "follow-model", 20, "active", { output: "verbose_json" }]
];
for (const [id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, policy] of routes) {
insertIgnore(
"INSERT OR IGNORE INTO model_routing_policies(id, organization_id, workspace_id, name, workflow_key, operation_key, primary_model_id, fallback_model_id, policy_mode, approval_mode, budget_limit_cny, status, policy_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
[id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), timestamp, timestamp]
);
}
}
function seedStyleKits() {
const timestamp = now();
const kits = [
{
id: "stylekit-studio-guoman-vertical",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: null,
scopeMode: "workspace",
name: "星河国漫竖屏生产标准",
description: "工作区默认商用风格标准:原创国漫 2D、9:16、固定字幕与声线策略,所有关键帧强制单画面。",
status: "active",
visualProfile: {
styleFamily: "国产漫画 / 国漫 2D 动画",
linework: "干净线稿、清晰轮廓、轻赛璐璐阴影",
colorScript: "雷雨青绿、暖肤色、低饱和环境光",
cameraLanguage: "稳定中景、反应镜头、环境插入镜头优先",
continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
},
subtitlePreset: {
language: "zh-CN",
fontFamily: "PingFang SC / Noto Sans SC",
position: "bottom-safe-area",
maxCharsPerLine: 18,
style: "白字深色描边,避免遮挡人物脸部和关键道具"
},
voicePolicy: {
finalVoiceMode: "fixed-reference-required",
forbidRandomNativeVoice: true,
ttsPreference: "local-or-owned-first",
fallbackMouthPlan: ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"]
},
deliverySpec: {
aspectRatio: "9:16",
resolution: "1080x1920",
fps: 24,
clipDurationSec: [5, 10],
exportFormat: "mp4",
projectFolder: "exports/{projectId}"
},
promptRules: {
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, one continuous scene, one complete image.",
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"],
renderNotes: ["角色服装、道具、天气和机位必须继承 continuity ledger", "每次图片生成 batch size 固定为 1"]
},
singleFramePolicy: {
enabled: true,
imageOutputCount: 1,
batchSize: 1,
forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "多时间点"],
requiredPhrase: "one single complete frame"
},
namingRules: {
shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}",
subtitlePattern: "{episodeId}-{voiceLineId}.srt",
deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}"
},
qaPolicy: {
gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"],
minimumReviewScore: 90,
requireLockedAssets: true
},
metadata: {
source: "seed://style-kits/studio-guoman-vertical",
commercialReference: "brand-kit-style-guide-production-standard"
},
bindProjectId: "thunder-mouth",
bindingNotes: "《雷雨口》商用 MVP 默认继承工作区国漫竖屏生产标准。"
},
{
id: "stylekit-northstar-fog-pilot",
organizationId: "org-northstar",
workspaceId: "ws-northstar-main",
projectId: "northstar-pilot",
scopeMode: "project",
name: "北辰雾境试播标准",
description: "北辰内容厂牌专用试播标准,用于验证跨组织隔离和项目级风格绑定。",
status: "active",
visualProfile: {
styleFamily: "原创国漫 2D 奇幻",
linework: "偏硬朗线条、雾中高反差边缘光",
colorScript: "冷雾灰、青铜色、少年角色暖色点光",
cameraLanguage: "稳定中景与环境压迫感,禁止多格构图",
continuityLocks: ["character", "location", "prop", "weather", "camera"]
},
subtitlePreset: {
language: "zh-CN",
fontFamily: "PingFang SC",
position: "bottom-safe-area",
maxCharsPerLine: 16,
style: "白字深描边"
},
voicePolicy: {
finalVoiceMode: "fixed-reference-required",
forbidRandomNativeVoice: true,
ttsPreference: "local-runner-only",
fallbackMouthPlan: ["远景", "侧脸", "反应镜头"]
},
deliverySpec: {
aspectRatio: "9:16",
resolution: "1080x1920",
fps: 24,
clipDurationSec: [5, 8],
exportFormat: "mp4",
projectFolder: "exports/{projectId}"
},
promptRules: {
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, fantasy fog gate, one scene only.",
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "分屏"],
renderNotes: ["不得串用星河实验室角色或道具", "首尾帧必须来自当前项目"]
},
singleFramePolicy: {
enabled: true,
imageOutputCount: 1,
batchSize: 1,
forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏"],
requiredPhrase: "one single complete frame"
},
namingRules: {
shotPattern: "{projectId}/pilot/shot-{shotNumber}-{version}",
subtitlePattern: "{projectId}-{voiceLineId}.srt",
deliveryPattern: "{projectName}-pilot-{releaseVersion}"
},
qaPolicy: {
gates: ["single-frame", "cross-org-isolation", "continuity", "clip-transition"],
minimumReviewScore: 92,
requireLockedAssets: true
},
metadata: {
source: "seed://style-kits/northstar-fog-pilot",
commercialReference: "tenant-isolated-production-standard"
},
bindProjectId: "northstar-pilot",
bindingNotes: "北辰试播项目强制使用项目级雾境风格标准。"
}
];
for (const kit of kits) {
insertIgnore(
`INSERT OR IGNORE INTO style_kits(
id, organization_id, workspace_id, project_id, scope_mode, name, description, status,
visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json,
prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json,
metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)`,
[
kit.id,
kit.organizationId,
kit.workspaceId,
kit.projectId,
kit.scopeMode,
kit.name,
kit.description,
kit.status,
JSON.stringify(kit.visualProfile),
JSON.stringify(kit.subtitlePreset),
JSON.stringify(kit.voicePolicy),
JSON.stringify(kit.deliverySpec),
JSON.stringify(kit.promptRules),
JSON.stringify(kit.singleFramePolicy),
JSON.stringify(kit.namingRules),
JSON.stringify(kit.qaPolicy),
JSON.stringify(kit.metadata),
timestamp,
timestamp
]
);
insertIgnore(
"INSERT OR IGNORE INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'strict', ?, 'u-owner', ?, ?)",
[`binding-${kit.bindProjectId}-style-kit`, kit.organizationId, kit.workspaceId, kit.bindProjectId, kit.id, kit.bindingNotes, timestamp, timestamp]
);
}
}
function seedKnowledgeLibrary() {
const timestamp = now();
const content = [
"第一章 雨夜来电",
"暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。",
"",
"唐夏:我到地铁口了,可这里的警戒线被人挪开了。",
"陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。",
"",
"第二章 失踪的雨披",
"两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。"
].join("\n");
const analysis = {
parser: "local-rule-v2",
chapterCount: 2,
chunkCount: 4,
summary: "暴雨夜的地铁口事故线索,包含角色对话、场景限制、关键道具和连续性信息。",
entities: {
characters: ["陈宇", "唐夏"],
locations: ["旧城区玻璃连廊", "地铁口"],
props: ["蓝伞", "黄色雨披", "警戒线", "警戒锥"]
}
};
const provenance = {
sourceLabel: "平台原创示例小说",
author: "AI Drama Platform Demo",
rightsOwner: "本地示例组织",
evidenceRef: "seed://original/rain-night",
licenseNote: "原创演示素材,仅用于本地样例和 smoke 测试",
sourceUrl: "",
importedFrom: "seed"
};
const tags = ["原创", "雨夜", "连续性样例"];
const risk = {
scanner: "local-governance-v1",
status: "pass",
score: 0,
rightsStatus: "approved",
sourceType: "novel",
tags,
issues: [],
checks: {
provenanceEvidence: true,
commercialRightsApproved: true,
knownIpReferences: 0,
personaLikenessRisks: 0,
singleFramePolicyRisks: 0,
safetySensitiveMatches: 0
},
scannedAt: timestamp
};
insertIgnore(
"INSERT OR IGNORE INTO knowledge_documents(id, organization_id, workspace_id, project_id, scope_mode, title, source_type, language, content, status, summary, analysis_json, metadata_json, created_by, created_at, updated_at) VALUES ('knowledge-rain-night', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, 'indexed', ?, ?, ?, 'u-owner', ?, ?)",
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama" }), timestamp, timestamp]
);
dbRun(
`UPDATE knowledge_documents
SET rights_status = 'approved',
provenance_json = ?,
tags_json = ?,
risk_json = ?,
current_version_number = CASE WHEN current_version_number < 1 THEN 1 ELSE current_version_number END
WHERE id = 'knowledge-rain-night'
AND (rights_status = 'needs-evidence' OR provenance_json = '{}' OR risk_json = '{}')`,
[JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk)]
);
insertIgnore(
`INSERT OR IGNORE INTO knowledge_document_versions(
id, document_id, version_number, title, source_type, language, content, summary,
analysis_json, provenance_json, tags_json, risk_json, metadata_json, created_by, created_at
) VALUES ('knowledge-rain-night-v1', 'knowledge-rain-night', 1, '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?)`,
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama", seed: true }), timestamp]
);
const chunks = [
["knowledge-rain-night-chunk-1", 1, "chapter", "第一章 雨夜来电", "暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。", 116, ["暴雨", "玻璃连廊", "蓝伞"], { characters: ["陈宇", "唐夏"], locations: ["旧城区玻璃连廊"], props: ["蓝伞"] }, { chapter: 1, sceneHint: "开场建立场景与冲突" }],
["knowledge-rain-night-chunk-2", 2, "dialogue", "地铁口对话", "唐夏:我到地铁口了,可这里的警戒线被人挪开了。\n陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。", 124, ["地铁口", "警戒线", "落地电线"], { characters: ["陈宇", "唐夏"], locations: ["地铁口"], props: ["蓝伞", "警戒线"] }, { chapter: 1, mouthAvoidance: true, sceneHint: "适合侧脸/反应镜头对白" }],
["knowledge-rain-night-chunk-3", 3, "chapter", "第二章 失踪的雨披", "两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。", 58, ["黄色雨披", "警戒锥"], { characters: ["陈宇", "唐夏"], props: ["黄色雨披", "警戒锥"] }, { chapter: 2, sceneHint: "道具连续性检查点" }],
["knowledge-rain-night-chunk-4", 4, "lore", "现场异常", "唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。", 42, ["异常布置", "悬念"], { characters: ["唐夏"] }, { chapter: 2, sceneHint: "结尾悬念与下一集钩子" }]
];
for (const [id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities, metadata] of chunks) {
insertIgnore(
"INSERT OR IGNORE INTO knowledge_chunks(id, document_id, chunk_index, chunk_type, heading, content, token_estimate, keywords_json, entities_json, metadata_json, created_at) VALUES (?, 'knowledge-rain-night', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[id, chunkIndex, chunkType, heading, body, tokenEstimate, JSON.stringify(keywords), JSON.stringify(entities), JSON.stringify(metadata), timestamp]
);
}
const openingChunks = chunks.slice(0, 2);
const citations = openingChunks.map(([id, chunkIndex, chunkType, heading], index) => ({
key: `K${index + 1}`,
documentId: "knowledge-rain-night",
documentTitle: "《雨夜来电》原始小说素材",
chunkId: id,
chunkIndex,
heading,
sourceType: "novel",
scopeMode: "workspace",
rightsStatus: "approved",
riskStatus: "pass",
projectId: null,
chunkType
}));
const packChunks = openingChunks.map(([id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities], index) => ({
citationKey: citations[index].key,
id,
documentId: "knowledge-rain-night",
documentTitle: "《雨夜来电》原始小说素材",
chunkIndex,
chunkType,
heading,
content: body,
tokenEstimate,
keywords,
entities,
rightsStatus: "approved",
riskStatus: "pass"
}));
const packGovernance = {
status: "pass",
rights: { approved: packChunks.length },
risk: { pass: packChunks.length },
blockingCount: 0,
reviewCount: 0
};
const promptContext = [
"# 知识库上下文包:雨夜开场冲突",
"使用要求:基于引用素材做原创改编;保留人物、道具、地点和时间线连续性;生成画面仍必须是一张完整单画面,不得输出多格、拼图或分屏。",
"治理摘要:pass;未批准/需复核片段 0;阻断片段 0。",
...packChunks.map((chunk) => [
`## [${chunk.citationKey}] ${chunk.heading}`,
`来源:《${chunk.documentTitle}》 / novel / chunk ${chunk.chunkIndex} / rights=approved / risk=pass`,
`内容:${chunk.content}`
].join("\n"))
].join("\n\n");
insertIgnore(
`INSERT OR IGNORE INTO knowledge_context_packs(
id, organization_id, workspace_id, project_id, scope_mode, name, query, source_type, max_tokens,
token_estimate, chunk_ids_json, citations_json, chunks_json, prompt_context, status,
metadata_json, created_by, created_at, updated_at
) VALUES ('knowledge-pack-rain-night-opening', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '雨夜开场冲突', '蓝伞 地铁口', 'novel', 800, ?, ?, ?, ?, ?, 'active', ?, 'u-owner', ?, ?)`,
[
packChunks.reduce((sum, chunk) => sum + Number(chunk.tokenEstimate || 0), 0),
JSON.stringify(packChunks.map((chunk) => chunk.id)),
JSON.stringify(citations),
JSON.stringify(packChunks),
promptContext,
JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance }),
timestamp,
timestamp
]
);
dbRun(
`UPDATE knowledge_context_packs
SET citations_json = ?, chunks_json = ?, prompt_context = ?, metadata_json = ?
WHERE id = 'knowledge-pack-rain-night-opening'`,
[JSON.stringify(citations), JSON.stringify(packChunks), promptContext, JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance })]
);
}
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.provider", "storage", "local-filesystem", "string", "媒体文件存储提供方;生产可切换为 S3-compatible 适配层", 0],
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
["storage.retention_days", "storage", 30, "number", "无引用临时媒体的最短保留天数", 0],
["storage.cleanup_unreferenced_only", "storage", true, "boolean", "清理时只允许处理未被生产证据引用的临时文件", 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 workflowStepDefinition(label, index) {
const normalized = String(label || "");
let jobKind = "";
let requiresShot = false;
if (/静态图|分镜图|关键帧|插画/.test(normalized)) { jobKind = "单画面关键帧"; requiresShot = true; }
else if (/图生视频|视频生成|成片/.test(normalized)) { jobKind = "首尾帧图生视频"; requiresShot = true; }
else if (/旁白|配音|TTS/.test(normalized)) { jobKind = "固定 TTS 配音"; requiresShot = true; }
else if (/ASR|字幕|对齐/.test(normalized)) { jobKind = "ASR 台词校验"; requiresShot = true; }
else if (/合成/.test(normalized)) jobKind = "剪辑合成清单";
return { key: `step-${index + 1}`, label: normalized, jobKind, requiresShot, dependsOnPrevious: index > 0 };
}
function seedWorkflowTemplates() {
const timestamp = now();
for (const template of workflowTemplates) {
const status = template.status === "主流程" || template.status === "可用" ? "active" : "draft";
const steps = template.steps.map((label, index) => workflowStepDefinition(label, index));
const gates = [
{ key: "single-frame", label: "一图一画面", blocking: true },
{ key: "continuity-lock", label: "角色 / 场景 / 道具连续性", blocking: true },
{ key: "voice-subtitle-asr", label: "声音 / 字幕 / ASR 对齐", blocking: true },
{ key: "clip-bridge", label: "片段衔接 / 实际末帧", blocking: true }
];
insertIgnore(
"INSERT OR IGNORE INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at) VALUES (?, NULL, NULL, ?, 1, ?, ?, ?, ?, ?, ?, 'owned-model-platform', 'u-owner', ?, ?)",
[`workflow-global-${template.id}-v1`, template.id, template.name, template.id, status, template.warning || "", JSON.stringify(steps), JSON.stringify(gates), timestamp, 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]);
}
insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [
"series-northstar-pilot",
"northstar-pilot",
"山海志异·试播集",
"北辰试制部用于验证多组织隔离的原创国漫试播项目。",
"原创国漫 2D 动画风格,竖屏 9:16,单一完整画面,干净线稿与克制动效。",
"角色、场景、道具、天气、镜头和声线全部随项目隔离,禁止串用其他组织素材。",
"以单镜头冲突推进试播片段,所有生成先经过本地 Runner 与审片门。",
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-northstar-pilot-1", "series-northstar-pilot", 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', 45, '雾气里出现不该存在的石门。', '门后传来第二个自己的声音。', ?, ?)", ["episode-northstar-pilot-01", "season-northstar-pilot-1", timestamp, timestamp]);
const northstarShot = {
id: "shot-northstar-pilot-001",
title: "雾中石门出现",
durationSec: 6,
characterIds: ["northstar-yun"],
locationId: "northstar-fog-gate",
propIds: ["northstar-bronze-bell"],
camera: "稳定中景,角色在画面右侧停步,石门在远处雾中显现,单一连续画面。",
action: "少年抬手按住铜铃,雾气向石门方向收束,镜头不切分。",
firstFrame: "episode-start",
lastFrame: "pending-actual-last-frame",
transitionFromPrevious: "episode-start",
prompt: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, no panels, one scene only.",
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard",
seed: 260831
};
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 (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [
northstarShot.id,
"episode-northstar-pilot-01",
northstarShot.title,
northstarShot.firstFrame,
northstarShot.lastFrame,
JSON.stringify({ camera: northstarShot.camera, transition: northstarShot.transitionFromPrevious }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', 'u-producer', ?)", [`${northstarShot.id}-v1`, northstarShot.id, JSON.stringify(northstarShot), timestamp]);
dbRun("UPDATE shots SET current_version_id = COALESCE(current_version_id, ?) WHERE id = ?", [`${northstarShot.id}-v1`, northstarShot.id]);
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, request_json, result_json, error_message, created_by, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'episode-northstar-pilot-01', ?, '单画面关键帧', 'northstar-image', 'queued', 45, 'local', 'storage/jobs/northstar-pilot/shot-001/keyframe.png', 'wait', ?, '{}', '', 'u-producer', ?, ?)", [
"job-northstar-pilot-keyframe-001",
northstarShot.id,
JSON.stringify({ schema: "ai-drama.job.v1", seeded: true, job: { adapterId: "northstar-image" }, constraints: { singleFrameOnly: true, imageOutputCount: 1 } }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, score, evidence_json, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', ?, 'single-frame', 'pending', NULL, ?, ?, ?)", [
"review-northstar-pilot-single-frame",
northstarShot.id,
JSON.stringify({ blockers: ["等待北辰审片员确认一图一画面证据。"], source: "seed" }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'v0.1-internal', 'storage/deliveries/northstar-pilot/v0.1/delivery-manifest.json', 'internal', 'review', ?, ?)", [
"delivery-northstar-pilot-v01",
timestamp,
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()]);
seedCommercialPlans();
seedOrganizationEntitlements();
rollQuotaPeriods();
seedCommercialApprovals();
seedMembersAndProjects();
seedModels();
seedModelCatalog();
seedModelRoutes();
seedStyleKits();
seedSystemGovernance();
seedWorkflowTemplates();
seedProductionGraph();
seedKnowledgeLibrary();
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()
};