feat: bootstrap commercial AI drama platform
This commit is contained in:
+595
@@ -0,0 +1,595 @@
|
||||
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("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_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_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()
|
||||
};
|
||||
Reference in New Issue
Block a user