2026-06-02 13:14:10 +08:00
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 ) ;
2026-06-04 18:58:45 +08:00
await runMigration ( "032_ecommerce_video_history" , migrateEcommerceVideoHistorySchema ) ;
2026-06-02 13:14:10 +08:00
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 ,
} ;
2026-06-04 18:58:45 +08:00
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);
` ) ;
}