const bcrypt = require("bcryptjs"); const crypto = require("node:crypto"); const { pool, withTransaction } = require("./db"); const { DEFAULT_MODEL_PRICES } = require("./pricing"); const { getDefaultAdminPassword } = require("./securityConfig"); const { ENTERPRISE_BETA_ACCOUNTS, ENTERPRISE_BETA_INITIAL_BALANCE_CENTS, createEnterpriseBetaPasswordMap, normalizeEnterpriseInviteCode, } = require("./enterpriseBetaAccounts"); async function ensureMigrationTable() { await pool.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); } async function hasMigration(id) { const { rows } = await pool.query("SELECT 1 FROM schema_migrations WHERE id = $1", [id]); return rows.length > 0; } async function recordMigration(client, id) { await client.query("INSERT INTO schema_migrations (id) VALUES ($1)", [id]); } async function getColumnNames(tableName) { const { rows } = await pool.query( "SELECT column_name FROM information_schema.columns WHERE table_name = $1", [tableName], ); return rows.map((r) => r.column_name); } async function hasColumn(tableName, columnName) { const columns = await getColumnNames(tableName); return columns.includes(columnName); } async function addColumnIfMissing(tableName, columnDefinition) { const [columnName] = columnDefinition.trim().split(/\s+/); if (!(await hasColumn(tableName, columnName))) { await pool.query(`ALTER TABLE ${tableName} ADD COLUMN ${columnDefinition}`); } } async function runMigration(id, migrate) { if (await hasMigration(id)) return; await withTransaction(async (client) => { await migrate(client); await recordMigration(client, id); }); } async function createBaseSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, avatar_url TEXT, bio TEXT, profile_background_url TEXT, email TEXT, email_verified INTEGER NOT NULL DEFAULT 0, phone TEXT, wechat_openid TEXT, wechat_unionid TEXT, auth_provider TEXT NOT NULL DEFAULT 'password', current_session_id TEXT, current_session_started_at TIMESTAMPTZ, role TEXT NOT NULL DEFAULT 'user', max_concurrency INTEGER NOT NULL DEFAULT 30, enabled INTEGER NOT NULL DEFAULT 1, enterprise_id INTEGER, is_enterprise_admin INTEGER NOT NULL DEFAULT 0, balance_cents INTEGER NOT NULL DEFAULT 0, billing_mode TEXT NOT NULL DEFAULT 'credits', beta_expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS api_keys ( id SERIAL PRIMARY KEY, provider TEXT NOT NULL, api_key TEXT NOT NULL, label TEXT DEFAULT '', max_concurrency INTEGER NOT NULL DEFAULT 10, active_count INTEGER NOT NULL DEFAULT 0, total_used INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS key_leases ( id SERIAL PRIMARY KEY, key_id INTEGER NOT NULL REFERENCES api_keys(id), user_id INTEGER NOT NULL REFERENCES users(id), leased_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), released_at TIMESTAMPTZ, lease_token TEXT UNIQUE NOT NULL, estimated_cost_cents INTEGER, enterprise_id INTEGER, settled INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS usage_logs ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, provider TEXT NOT NULL, key_id INTEGER, action TEXT NOT NULL, model TEXT, duration_ms INTEGER, status TEXT, enterprise_id INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS config_profiles ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL DEFAULT 'default', config_json TEXT NOT NULL DEFAULT '{}', description TEXT DEFAULT '', updated_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS sms_verification_codes ( id SERIAL PRIMARY KEY, phone TEXT NOT NULL, purpose TEXT NOT NULL, code_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS wechat_login_sessions ( state TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', user_id INTEGER REFERENCES users(id), error TEXT, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS api_call_logs ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, provider TEXT NOT NULL, model TEXT, display_model TEXT, prompt_tokens INTEGER, completion_tokens INTEGER, duration_ms INTEGER, status TEXT NOT NULL DEFAULT 'success', cost_estimate REAL, api_client TEXT, enterprise_id INTEGER, enterprise_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider, enabled); CREATE INDEX IF NOT EXISTS idx_key_leases_active ON key_leases(key_id, released_at); CREATE INDEX IF NOT EXISTS idx_usage_logs_user ON usage_logs(user_id, created_at); CREATE INDEX IF NOT EXISTS idx_api_call_logs_user ON api_call_logs(user_id, created_at); CREATE INDEX IF NOT EXISTS idx_api_call_logs_model ON api_call_logs(model, created_at); CREATE INDEX IF NOT EXISTS idx_api_call_logs_created ON api_call_logs(created_at); CREATE INDEX IF NOT EXISTS idx_users_enterprise ON users(enterprise_id, enabled); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(LOWER(email)) WHERE email IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique ON users(phone) WHERE phone IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wechat_openid_unique ON users(wechat_openid) WHERE wechat_openid IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_sms_verification_codes_lookup ON sms_verification_codes(phone, purpose, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wechat_login_sessions_status ON wechat_login_sessions(status, expires_at); CREATE INDEX IF NOT EXISTS idx_usage_logs_enterprise ON usage_logs(enterprise_id, created_at); CREATE INDEX IF NOT EXISTS idx_api_call_logs_enterprise ON api_call_logs(enterprise_id, created_at); CREATE INDEX IF NOT EXISTS idx_key_leases_settled ON key_leases(settled, released_at); `); } async function migrateEnterpriseAndPricingSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS enterprises ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, contact_name TEXT, contact_phone TEXT, balance_cents INTEGER NOT NULL DEFAULT 0, tax_id TEXT, legal_person_name TEXT, legal_person_phone TEXT, enterprise_code TEXT UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS model_prices ( id SERIAL PRIMARY KEY, model_key TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'text', pricing_type TEXT NOT NULL DEFAULT 'token', input_price_mills INTEGER, output_price_mills INTEGER, flat_price_mills INTEGER, currency TEXT NOT NULL DEFAULT 'CNY', enabled INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_enterprises_code ON enterprises(enterprise_code); CREATE INDEX IF NOT EXISTS idx_model_prices_enabled ON model_prices(enabled, category); `); } async function migrateBillingSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS transactions ( id SERIAL PRIMARY KEY, enterprise_id INTEGER REFERENCES enterprises(id), enterprise_name TEXT, user_id INTEGER, target_user_id INTEGER, type TEXT NOT NULL, amount_cents INTEGER NOT NULL, balance_after_cents INTEGER NOT NULL, description TEXT, payment_order_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS packages ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', price_cents INTEGER NOT NULL, credits_cents INTEGER NOT NULL DEFAULT 0, image_quota INTEGER NOT NULL DEFAULT 0, video_quota INTEGER NOT NULL DEFAULT 0, text_quota INTEGER NOT NULL DEFAULT 0, duration_days INTEGER NOT NULL DEFAULT 365, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS enterprise_packages ( id SERIAL PRIMARY KEY, enterprise_id INTEGER NOT NULL REFERENCES enterprises(id), package_id INTEGER NOT NULL REFERENCES packages(id), remaining_image INTEGER NOT NULL DEFAULT 0, remaining_video INTEGER NOT NULL DEFAULT 0, remaining_text INTEGER NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ NOT NULL, activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS payment_orders ( id SERIAL PRIMARY KEY, order_no TEXT UNIQUE NOT NULL, enterprise_id INTEGER REFERENCES enterprises(id), enterprise_name TEXT, user_id INTEGER, type TEXT NOT NULL, amount_cents INTEGER NOT NULL, package_id INTEGER, status TEXT NOT NULL DEFAULT 'pending', payment_method TEXT, payment_trade_no TEXT, paid_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS invoices ( id SERIAL PRIMARY KEY, enterprise_id INTEGER NOT NULL REFERENCES enterprises(id), enterprise_name TEXT, payment_order_id INTEGER REFERENCES payment_orders(id), type TEXT NOT NULL DEFAULT 'general', title TEXT NOT NULL, tax_no TEXT, amount_cents INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', invoice_no TEXT, invoice_url TEXT, issued_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_enterprises_enabled ON enterprises(enabled); CREATE INDEX IF NOT EXISTS idx_transactions_enterprise ON transactions(enterprise_id, created_at); CREATE INDEX IF NOT EXISTS idx_enterprise_packages_enterprise ON enterprise_packages(enterprise_id, expires_at); CREATE INDEX IF NOT EXISTS idx_payment_orders_enterprise ON payment_orders(enterprise_id, created_at); CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status, created_at); CREATE INDEX IF NOT EXISTS idx_invoices_enterprise ON invoices(enterprise_id, created_at); CREATE INDEX IF NOT EXISTS idx_users_balance ON users(balance_cents); `); } async function migrateTransactionsNullableEnterpriseId(client) { await client.query("ALTER TABLE transactions ALTER COLUMN enterprise_id DROP NOT NULL"); } async function migrateMultiDeviceSessionsSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query( "CREATE INDEX IF NOT EXISTS idx_user_sessions_user_created ON user_sessions(user_id, created_at DESC)", ); } async function migrateTaskPollHeartbeat(client) { await addColumnIfMissing("generation_tasks", "last_poll_at TIMESTAMPTZ"); await client.query( "UPDATE generation_tasks SET last_poll_at = updated_at WHERE last_poll_at IS NULL", ); await client.query( "CREATE INDEX IF NOT EXISTS idx_generation_tasks_poll_heartbeat ON generation_tasks(status, last_poll_at) WHERE status IN ('pending', 'running')", ); } async function migrateGenerationTasksUserStatusIndex(client) { await client.query( "CREATE INDEX IF NOT EXISTS idx_generation_tasks_user_status_updated ON generation_tasks(user_id, status, updated_at DESC)", ); } async function migrateGenerationTasksBillingColumns(client) { await addColumnIfMissing("generation_tasks", "cost_cents INTEGER NOT NULL DEFAULT 0"); await addColumnIfMissing("generation_tasks", "billing_target TEXT"); await addColumnIfMissing("generation_tasks", "billing_refunded INTEGER NOT NULL DEFAULT 0"); await client.query( "CREATE INDEX IF NOT EXISTS idx_generation_tasks_billing_refund ON generation_tasks(status, billing_refunded) WHERE billing_refunded = 0 AND cost_cents > 0", ); } async function ensureModelPriceSeed() { const columns = await getColumnNames("model_prices"); const useMills = columns.includes("input_price_mills"); if (!useMills) return; for (const row of DEFAULT_MODEL_PRICES) { await pool.query( ` INSERT INTO model_prices ( model_key, display_name, category, pricing_type, input_price_mills, output_price_mills, flat_price_mills, currency, enabled ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (model_key) DO NOTHING `, [ row.modelKey, row.displayName, row.category, row.pricingType, row.inputPriceMills, row.outputPriceMills, row.flatPriceMills, row.currency, row.enabled ? 1 : 0, ], ); } } async function migrateEnterpriseFields(client) { await addColumnIfMissing("enterprises", "tax_id TEXT"); await addColumnIfMissing("enterprises", "legal_person_name TEXT"); await addColumnIfMissing("enterprises", "legal_person_phone TEXT"); await addColumnIfMissing("enterprises", "enterprise_code TEXT UNIQUE"); const { rows } = await client.query( "SELECT 1 FROM pg_indexes WHERE indexname = 'idx_enterprises_code'", ); if (rows.length === 0) { await client.query( "CREATE INDEX IF NOT EXISTS idx_enterprises_code ON enterprises(enterprise_code)", ); } } async function migrateUserBalance(client) { await addColumnIfMissing("users", "balance_cents INTEGER NOT NULL DEFAULT 0"); await addColumnIfMissing("payment_orders", "user_id INTEGER"); await addColumnIfMissing("transactions", "target_user_id INTEGER"); const { rows } = await client.query( "SELECT 1 FROM pg_indexes WHERE indexname = 'idx_users_balance'", ); if (rows.length === 0) { await client.query("CREATE INDEX IF NOT EXISTS idx_users_balance ON users(balance_cents)"); } } async function migrateProjectsTable(client) { await client.query(` CREATE TABLE IF NOT EXISTS projects ( id VARCHAR(64) PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(200) NOT NULL, description TEXT, oss_key VARCHAR(500) NOT NULL, thumbnail_url VARCHAR(1000), storyboard_count INTEGER NOT NULL DEFAULT 0, image_count INTEGER NOT NULL DEFAULT 0, video_count INTEGER NOT NULL DEFAULT 0, file_size BIGINT NOT NULL DEFAULT 0, current_revision INTEGER NOT NULL DEFAULT 1, current_fingerprint VARCHAR(128), updated_by_device_id VARCHAR(128), source_case_id INTEGER, origin_type VARCHAR(32) NOT NULL DEFAULT 'manual', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS project_revisions ( project_id VARCHAR(64) NOT NULL REFERENCES projects(id) ON DELETE CASCADE, revision_number INTEGER NOT NULL, oss_key VARCHAR(500) NOT NULL, content_fingerprint VARCHAR(128), source_device_id VARCHAR(128), save_reason VARCHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, revision_number) ); CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id); CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_project_revisions_project_created_at ON project_revisions(project_id, created_at DESC); `); } async function migrateProjectRevisionSchema(client) { await client.query(` ALTER TABLE projects ADD COLUMN IF NOT EXISTS current_revision INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS current_fingerprint VARCHAR(128), ADD COLUMN IF NOT EXISTS updated_by_device_id VARCHAR(128) `); await client.query(` CREATE TABLE IF NOT EXISTS project_revisions ( project_id VARCHAR(64) NOT NULL REFERENCES projects(id) ON DELETE CASCADE, revision_number INTEGER NOT NULL, oss_key VARCHAR(500) NOT NULL, content_fingerprint VARCHAR(128), source_device_id VARCHAR(128), save_reason VARCHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, revision_number) ) `); await client.query(` CREATE INDEX IF NOT EXISTS idx_project_revisions_project_created_at ON project_revisions(project_id, created_at DESC) `); await client.query(` INSERT INTO project_revisions ( project_id, revision_number, oss_key, content_fingerprint, source_device_id, save_reason, created_at ) SELECT p.id, COALESCE(NULLIF(p.current_revision, 0), 1), p.oss_key, p.current_fingerprint, p.updated_by_device_id, 'migration', COALESCE(p.updated_at, NOW()) FROM projects p WHERE NOT EXISTS ( SELECT 1 FROM project_revisions pr WHERE pr.project_id = p.id AND pr.revision_number = COALESCE(NULLIF(p.current_revision, 0), 1) ) `); } async function migrateGenerationTasksSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS generation_tasks ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id VARCHAR(64) NOT NULL REFERENCES projects(id) ON DELETE CASCADE, client_queue_id VARCHAR(128) NOT NULL, type VARCHAR(16) NOT NULL, status VARCHAR(24) NOT NULL, provider_task_id VARCHAR(256), params_json TEXT NOT NULL DEFAULT '{}', result_url VARCHAR(2000), progress INTEGER NOT NULL DEFAULT 0, error TEXT, dedupe_key VARCHAR(256), source_device_id VARCHAR(128), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, UNIQUE (project_id, client_queue_id) ); CREATE INDEX IF NOT EXISTS idx_generation_tasks_user_project_updated ON generation_tasks(user_id, project_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_generation_tasks_provider_task ON generation_tasks(provider_task_id); CREATE INDEX IF NOT EXISTS idx_generation_tasks_status ON generation_tasks(status, updated_at DESC); `); } async function migrateWorkbenchTaskSchema(client) { await addColumnIfMissing("generation_tasks", "conversation_id INTEGER REFERENCES conversations(id) ON DELETE SET NULL"); const { rows: notNullRows } = await client.query(` SELECT 1 FROM information_schema.columns WHERE table_name = 'generation_tasks' AND column_name = 'project_id' AND is_nullable = 'NO' `); if (notNullRows.length > 0) { await client.query("ALTER TABLE generation_tasks ALTER COLUMN project_id DROP NOT NULL"); } await client.query(` ALTER TABLE generation_tasks DROP CONSTRAINT IF EXISTS generation_tasks_project_id_client_queue_id_key; DROP INDEX IF EXISTS idx_generation_tasks_user_project_updated; CREATE INDEX IF NOT EXISTS idx_generation_tasks_user_conversation_updated ON generation_tasks(user_id, conversation_id, updated_at DESC); CREATE UNIQUE INDEX IF NOT EXISTS idx_generation_tasks_project_queue_unique ON generation_tasks(project_id, client_queue_id) WHERE project_id IS NOT NULL; `); } async function migrateApiCallDisplayModel(_client) { await addColumnIfMissing("api_call_logs", "display_model TEXT"); } async function migrateExternalAuthSchema(client) { await client.query(` ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT, ADD COLUMN IF NOT EXISTS phone TEXT, ADD COLUMN IF NOT EXISTS wechat_openid TEXT, ADD COLUMN IF NOT EXISTS wechat_unionid TEXT, ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'password' `); await client.query(` CREATE TABLE IF NOT EXISTS sms_verification_codes ( id SERIAL PRIMARY KEY, phone TEXT NOT NULL, purpose TEXT NOT NULL, code_hash TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique ON users(phone) WHERE phone IS NOT NULL", ); await client.query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wechat_openid_unique ON users(wechat_openid) WHERE wechat_openid IS NOT NULL", ); await client.query( "CREATE INDEX IF NOT EXISTS idx_sms_verification_codes_lookup ON sms_verification_codes(phone, purpose, created_at DESC)", ); } async function migrateUserAvatarSchema(_client) { await addColumnIfMissing("users", "avatar_url TEXT"); } async function migrateUserProfileAndEmailSchema(client) { await client.query(` ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT, ADD COLUMN IF NOT EXISTS profile_background_url TEXT, ADD COLUMN IF NOT EXISTS email TEXT, ADD COLUMN IF NOT EXISTS email_verified INTEGER NOT NULL DEFAULT 0 `); await client.query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(LOWER(email)) WHERE email IS NOT NULL", ); } async function migrateSingleDeviceSessionSchema(client) { await client.query(` ALTER TABLE users ADD COLUMN IF NOT EXISTS current_session_id TEXT, ADD COLUMN IF NOT EXISTS current_session_started_at TIMESTAMPTZ `); } async function migrateCommunitySchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS community_cases ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id VARCHAR(64) REFERENCES projects(id) ON DELETE SET NULL, title VARCHAR(200) NOT NULL, description TEXT, cover_url VARCHAR(1000), tags_json TEXT NOT NULL DEFAULT '[]', metadata_json TEXT NOT NULL DEFAULT '{}', status VARCHAR(24) NOT NULL DEFAULT 'pending', review_note TEXT, reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL, reviewed_at TIMESTAMPTZ, published_at TIMESTAMPTZ, copy_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS community_case_assets ( id SERIAL PRIMARY KEY, case_id INTEGER NOT NULL REFERENCES community_cases(id) ON DELETE CASCADE, asset_type VARCHAR(32) NOT NULL, title VARCHAR(200), url VARCHAR(1000), oss_key VARCHAR(500), metadata_json TEXT NOT NULL DEFAULT '{}', sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS community_case_copies ( id SERIAL PRIMARY KEY, case_id INTEGER NOT NULL REFERENCES community_cases(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id VARCHAR(64) REFERENCES projects(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_community_cases_status_updated ON community_cases(status, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_community_cases_user_updated ON community_cases(user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_community_case_assets_case_order ON community_case_assets(case_id, sort_order, id); CREATE INDEX IF NOT EXISTS idx_community_case_copies_user_created ON community_case_copies(user_id, created_at DESC); `); } async function migrateCommunityReviewColumns(_client) { await addColumnIfMissing("community_cases", "review_note TEXT"); await addColumnIfMissing("community_cases", "reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); await addColumnIfMissing("community_cases", "reviewed_at TIMESTAMPTZ"); await addColumnIfMissing("community_cases", "published_at TIMESTAMPTZ"); await addColumnIfMissing("community_cases", "copy_count INTEGER NOT NULL DEFAULT 0"); await pool.query(` CREATE TABLE IF NOT EXISTS community_case_reactions ( id SERIAL PRIMARY KEY, case_id INTEGER NOT NULL REFERENCES community_cases(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, reaction_type VARCHAR(24) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(case_id, user_id, reaction_type) ); CREATE INDEX IF NOT EXISTS idx_community_case_reactions_case_type ON community_case_reactions(case_id, reaction_type); CREATE INDEX IF NOT EXISTS idx_community_case_reactions_user ON community_case_reactions(user_id, created_at DESC); `); } async function migrateGenerationTasksProjectQueueIndex(client) { await client.query(` DELETE FROM generation_tasks older USING generation_tasks newer WHERE older.project_id IS NOT NULL AND older.project_id = newer.project_id AND older.client_queue_id = newer.client_queue_id AND older.id < newer.id; CREATE UNIQUE INDEX IF NOT EXISTS idx_generation_tasks_project_queue_unique ON generation_tasks(project_id, client_queue_id) WHERE project_id IS NOT NULL; `); } async function migrateUserBillingMode(_client) { await addColumnIfMissing("users", "billing_mode TEXT NOT NULL DEFAULT 'credits'"); await addColumnIfMissing("users", "beta_expires_at TIMESTAMPTZ"); } async function migrateProjectOriginSchema(_client) { await addColumnIfMissing("projects", "source_case_id INTEGER"); await addColumnIfMissing("projects", "origin_type VARCHAR(32) NOT NULL DEFAULT 'manual'"); } async function migrateWechatLoginSessions(client) { await client.query(` CREATE TABLE IF NOT EXISTS wechat_login_sessions ( state TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', user_id INTEGER REFERENCES users(id), error TEXT, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query( "CREATE INDEX IF NOT EXISTS idx_wechat_login_sessions_status ON wechat_login_sessions(status, expires_at)", ); } async function migrateConversationsSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS conversations ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(200) NOT NULL DEFAULT '新对话', mode VARCHAR(20) NOT NULL DEFAULT 'chat', messages_json TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query("CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON conversations(user_id)"); await client.query("CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC)"); } async function migrateReportsSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS user_reports ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, report_type VARCHAR(64) NOT NULL DEFAULT 'other', target_type VARCHAR(64), target_id VARCHAR(128), contact_name VARCHAR(120), contact_email VARCHAR(200), contact_phone VARCHAR(60), title VARCHAR(200) NOT NULL, description TEXT NOT NULL, page_url TEXT, status VARCHAR(32) NOT NULL DEFAULT 'pending', ip_address VARCHAR(64), user_agent TEXT, handled_by INTEGER REFERENCES users(id) ON DELETE SET NULL, handled_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await client.query("CREATE INDEX IF NOT EXISTS idx_user_reports_status_created ON user_reports(status, created_at DESC)"); await client.query("CREATE INDEX IF NOT EXISTS idx_user_reports_user_created ON user_reports(user_id, created_at DESC)"); } async function migrateWebPdrSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS web_assets ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(32) NOT NULL DEFAULT 'asset', name VARCHAR(200) NOT NULL, description TEXT, url VARCHAR(1000), oss_key VARCHAR(500), tags_json TEXT NOT NULL DEFAULT '[]', status VARCHAR(32) NOT NULL DEFAULT 'ready', source_task_id INTEGER REFERENCES generation_tasks(id) ON DELETE SET NULL, source_project_id VARCHAR(64) REFERENCES projects(id) ON DELETE SET NULL, metadata_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS web_notifications ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(64) NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, target_type VARCHAR(64), target_id VARCHAR(128), metadata_json TEXT NOT NULL DEFAULT '{}', read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS web_drafts ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scope VARCHAR(64) NOT NULL, target_id VARCHAR(128) NOT NULL DEFAULT 'default', payload_json TEXT NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(user_id, scope, target_id) ); CREATE TABLE IF NOT EXISTS community_case_reactions ( id SERIAL PRIMARY KEY, case_id INTEGER NOT NULL REFERENCES community_cases(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, reaction_type VARCHAR(24) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(case_id, user_id, reaction_type) ); CREATE INDEX IF NOT EXISTS idx_web_assets_user_updated ON web_assets(user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_web_assets_user_type ON web_assets(user_id, type, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_web_assets_source_project ON web_assets(source_project_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_web_notifications_user_created ON web_notifications(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_web_notifications_user_unread ON web_notifications(user_id, created_at DESC) WHERE read_at IS NULL; CREATE INDEX IF NOT EXISTS idx_web_drafts_user_scope ON web_drafts(user_id, scope, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_community_case_reactions_case_type ON community_case_reactions(case_id, reaction_type); CREATE INDEX IF NOT EXISTS idx_community_case_reactions_user ON community_case_reactions(user_id, created_at DESC); `); } async function migrateBetaInviteCodeUsageSchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS beta_invite_code_uses ( code TEXT PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_beta_invite_code_uses_user ON beta_invite_code_uses(user_id, used_at DESC); `); } async function migrateEnterpriseBetaSupportSchema(client) { await client.query(` ALTER TABLE enterprises ADD COLUMN IF NOT EXISTS admin_user_id INTEGER; CREATE TABLE IF NOT EXISTS enterprise_members ( id SERIAL PRIMARY KEY, enterprise_id INTEGER NOT NULL REFERENCES enterprises(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(enterprise_id, user_id) ); CREATE TABLE IF NOT EXISTS enterprise_invites ( id SERIAL PRIMARY KEY, enterprise_id INTEGER NOT NULL REFERENCES enterprises(id) ON DELETE CASCADE, code_hash TEXT NOT NULL UNIQUE, code_label TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), used_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS credit_ledger ( id SERIAL PRIMARY KEY, enterprise_id INTEGER REFERENCES enterprises(id) ON DELETE SET NULL, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, task_id INTEGER, model TEXT, task_type TEXT NOT NULL, resolution TEXT, duration_seconds INTEGER, rate_cents_per_second INTEGER, amount_cents INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'reserved', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_enterprise_members_enterprise ON enterprise_members(enterprise_id, role); CREATE INDEX IF NOT EXISTS idx_enterprise_members_user ON enterprise_members(user_id); CREATE INDEX IF NOT EXISTS idx_enterprise_invites_enterprise ON enterprise_invites(enterprise_id, status); CREATE INDEX IF NOT EXISTS idx_credit_ledger_enterprise ON credit_ledger(enterprise_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_credit_ledger_user ON credit_ledger(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_credit_ledger_task ON credit_ledger(task_id); `); } async function ensureSchema() { await ensureMigrationTable(); await runMigration("001_base_schema", createBaseSchema); await runMigration("002_enterprise_and_pricing_schema", migrateEnterpriseAndPricingSchema); await runMigration("003_billing_schema", migrateBillingSchema); await runMigration("005_enterprise_fields", migrateEnterpriseFields); await runMigration("006_user_balance", migrateUserBalance); await runMigration("007_projects_table", migrateProjectsTable); await runMigration("008_project_revision_schema", migrateProjectRevisionSchema); await runMigration("009_api_call_display_model", migrateApiCallDisplayModel); await runMigration("010_external_auth_schema", migrateExternalAuthSchema); await runMigration("011_wechat_login_sessions", migrateWechatLoginSessions); await runMigration("012_generation_tasks_schema", migrateGenerationTasksSchema); await runMigration("013_user_avatar_schema", migrateUserAvatarSchema); await runMigration("014_community_schema", migrateCommunitySchema); await runMigration("015_user_billing_mode", migrateUserBillingMode); await runMigration("016_project_origin_schema", migrateProjectOriginSchema); await runMigration("017_conversations_schema", migrateConversationsSchema); await runMigration("018_user_reports_schema", migrateReportsSchema); await runMigration("019_workbench_task_schema", migrateWorkbenchTaskSchema); await runMigration("020_user_profile_and_email_schema", migrateUserProfileAndEmailSchema); await runMigration("021_single_device_session_schema", migrateSingleDeviceSessionSchema); await runMigration("022_web_pdr_schema", migrateWebPdrSchema); await runMigration("023_community_review_columns", migrateCommunityReviewColumns); await runMigration("024_generation_tasks_project_queue_index", migrateGenerationTasksProjectQueueIndex); await runMigration("025_beta_invite_code_usage", migrateBetaInviteCodeUsageSchema); await runMigration("026_enterprise_beta_support", migrateEnterpriseBetaSupportSchema); await runMigration("027_transactions_nullable_enterprise_id", migrateTransactionsNullableEnterpriseId); await runMigration("028_multi_device_sessions", migrateMultiDeviceSessionsSchema); await runMigration("029_task_poll_heartbeat", migrateTaskPollHeartbeat); await runMigration("030_generation_tasks_user_status_index", migrateGenerationTasksUserStatusIndex); await runMigration("031_generation_tasks_billing_columns", migrateGenerationTasksBillingColumns); await runMigration("032_ecommerce_video_history", migrateEcommerceVideoHistorySchema); await ensureModelPriceSeed(); } async function ensureDefaultAdmin(password) { const { rows } = await pool.query("SELECT id FROM users WHERE username = $1", ["admin"]); if (rows.length > 0) return false; const resolvedPassword = getDefaultAdminPassword(password); const hash = bcrypt.hashSync(resolvedPassword, 10); await pool.query( ` INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, $3, $4, $5, $6, $7) `, ["admin", hash, "admin", 100, null, 0, 0], ); return true; } function hashEnterpriseInviteCode(code) { return crypto.createHash("sha256").update(normalizeEnterpriseInviteCode(code)).digest("hex"); } async function ensureEnterpriseBetaAccounts(env = process.env) { const passwordMap = createEnterpriseBetaPasswordMap(env); const result = { createdAdmins: [], updatedAdmins: [], skippedAdmins: [], enterprises: [], }; await withTransaction(async (client) => { for (const account of ENTERPRISE_BETA_ACCOUNTS) { const password = passwordMap.get(account.adminUsername); if (!password) { result.skippedAdmins.push({ username: account.adminUsername, reason: "missing_password", }); continue; } if (password.length < 6) { throw new Error(`${account.adminUsername} password must be at least 6 characters`); } const hash = bcrypt.hashSync(password, 10); const enterpriseRows = await client.query( ` INSERT INTO enterprises (name, enterprise_code, balance_cents, enabled) VALUES ($1, $2, $3, 1) ON CONFLICT (enterprise_code) DO UPDATE SET name = EXCLUDED.name, balance_cents = CASE WHEN enterprises.balance_cents <= 0 THEN EXCLUDED.balance_cents ELSE enterprises.balance_cents END, enabled = 1, updated_at = NOW() RETURNING id, balance_cents `, [account.enterpriseName, account.enterpriseId, ENTERPRISE_BETA_INITIAL_BALANCE_CENTS], ); const enterprise = enterpriseRows.rows[0]; const existingAdmin = await client.query("SELECT id FROM users WHERE username = $1", [ account.adminUsername, ]); const adminRows = await client.query( existingAdmin.rows.length > 0 ? ` UPDATE users SET password_hash = $2, role = 'user', max_concurrency = 30, enterprise_id = $3, is_enterprise_admin = 1, balance_cents = 0, enabled = 1, updated_at = NOW() WHERE username = $1 RETURNING id ` : ` INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, 'user', 30, $3, 1, 0) RETURNING id `, [account.adminUsername, hash, enterprise.id], ); const adminUserId = adminRows.rows[0].id; await client.query("UPDATE enterprises SET admin_user_id = $1, updated_at = NOW() WHERE id = $2", [ adminUserId, enterprise.id, ]); await client.query( ` INSERT INTO enterprise_members (enterprise_id, user_id, role) VALUES ($1, $2, 'admin') ON CONFLICT (enterprise_id, user_id) DO UPDATE SET role = 'admin' `, [enterprise.id, adminUserId], ); await client.query( ` INSERT INTO enterprise_invites (enterprise_id, code_hash, code_label, status) VALUES ($1, $2, $3, 'active') ON CONFLICT (code_hash) DO UPDATE SET enterprise_id = EXCLUDED.enterprise_id, code_label = EXCLUDED.code_label, status = 'active' `, [enterprise.id, hashEnterpriseInviteCode(account.inviteCode), account.inviteCode], ); if (existingAdmin.rows.length > 0) { result.updatedAdmins.push(account.adminUsername); } else { result.createdAdmins.push(account.adminUsername); } result.enterprises.push({ enterpriseCode: account.enterpriseId, adminUsername: account.adminUsername, balanceCents: Number(enterprise.balance_cents), }); } }); return result; } async function ensureDatabase(password) { await ensureSchema(); const createdDefaultAdmin = await ensureDefaultAdmin(password); const enterpriseBetaAccounts = await ensureEnterpriseBetaAccounts(); return { createdDefaultAdmin, enterpriseBetaAccounts }; } module.exports = { ensureSchema, ensureDefaultAdmin, ensureEnterpriseBetaAccounts, ensureDatabase, hasColumn, addColumnIfMissing, }; async function migrateEcommerceVideoHistorySchema(client) { await client.query(` CREATE TABLE IF NOT EXISTS ecommerce_video_history ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(200) NOT NULL DEFAULT '', config_json TEXT NOT NULL DEFAULT '{}', plan_json TEXT NOT NULL DEFAULT '{}', scenes_json TEXT NOT NULL DEFAULT '[]', source_image_urls TEXT NOT NULL DEFAULT '[]', status VARCHAR(32) NOT NULL DEFAULT 'completed', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ecommerce_video_history_user ON ecommerce_video_history(user_id, created_at DESC); `); }