commit 56955e32f7ffa33da6b8f5b41abd1678c3beca9f Author: stringadmin Date: Tue Jun 2 13:14:10 2026 +0800 Initial commit: OmniAI backend server diff --git a/.env.backup.2026-04-21-02-53-17 b/.env.backup.2026-04-21-02-53-17 new file mode 100644 index 0000000..8a2444c --- /dev/null +++ b/.env.backup.2026-04-21-02-53-17 @@ -0,0 +1,12 @@ +PG_HOST=localhost +PG_PORT=5432 +PG_DATABASE=omniai +PG_USER=omniai +PG_PASSWORD=bybyby@123 +PG_POOL_MAX=10 +PORT=3600 +HOST=0.0.0.0 +JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b +DEFAULT_ADMIN_PASSWORD=bybyby@123BY +JWT_EXPIRES_IN=7d +CORS_ORIGINS=* diff --git a/.env.enterprise-beta.20260526012458 b/.env.enterprise-beta.20260526012458 new file mode 100644 index 0000000..4754d24 --- /dev/null +++ b/.env.enterprise-beta.20260526012458 @@ -0,0 +1,17 @@ +PG_HOST=localhost +PG_PORT=5432 +PG_DATABASE=omniai +PG_USER=omniai +PG_PASSWORD=bybyby@123 +PG_POOL_MAX=10 +PORT=3600 +HOST=0.0.0.0 +JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b +DEFAULT_ADMIN_PASSWORD=bybyby@123BY +JWT_EXPIRES_IN=7d +CORS_ORIGINS=* +STS_ACCESS_KEY_ID=LTAI5t7qL3iR9dchydHQ3cmT +STS_ACCESS_KEY_SECRET=ssywO1bUwu2pPZaq3KugXbaE0Za9gi +OSS_ROLE_ARN=acs:ram::1582660594690998:role/omniai-oss-role +OSS_BUCKET=stringtest +OSS_REGION=oss-cn-hangzhou diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..19642c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +PG_HOST=localhost +PG_PORT=5432 +PG_DATABASE=omniai +PG_USER=omniai +PG_PASSWORD=change-me-before-first-start + +# Server +PORT=3600 +HOST=0.0.0.0 + +# JWT Secret (change this!) +JWT_SECRET=change-me-to-a-random-string-at-least-32-chars + +# Default admin password used only for first-time bootstrap +DEFAULT_ADMIN_PASSWORD=change-me-before-first-start + +# Token expiry +JWT_EXPIRES_IN=7d + +# Connection pool +PG_POOL_MAX=10 + +# CORS (comma separated allowed origins, * for all) +CORS_ORIGINS=* + +# Phone verification. Use SMS_PROVIDER=mock for local development. +# SMS_PROVIDER=http sends POST { phone, code, purpose } to SMS_HTTP_ENDPOINT. +SMS_PROVIDER=mock +SMS_HTTP_ENDPOINT= +SMS_HTTP_TOKEN= +SMS_CODE_SECRET=change-me-to-a-random-string-at-least-32-chars +SMS_CODE_TTL_MINUTES=10 +SMS_CODE_COOLDOWN_SECONDS=60 +SMS_DEV_RETURN_CODE=0 + +# WeChat Open Platform login +WECHAT_LOGIN_APP_ID= +WECHAT_LOGIN_APP_SECRET= +# Recommended callback: https://your-key-server.example.com/api/auth/wechat/callback +WECHAT_LOGIN_REDIRECT_URI= + +# Alibaba Cloud STS — for issuing temporary OSS credentials to clients +# The RAM user (STS_ACCESS_KEY_ID) needs AliyunSTSAssumeRoleAccess permission +# The RAM role (OSS_ROLE_ARN) needs OSS read/write permission on the target bucket +STS_ACCESS_KEY_ID= +STS_ACCESS_KEY_SECRET= +OSS_ROLE_ARN=acs:ram::YOUR_ACCOUNT_ID:role/OmniAI-OSS-Upload +OSS_BUCKET=your-bucket-name +OSS_REGION=oss-cn-hangzhou diff --git a/.env.rolefix.2026-04-21-03-26-28 b/.env.rolefix.2026-04-21-03-26-28 new file mode 100644 index 0000000..b23fb0b --- /dev/null +++ b/.env.rolefix.2026-04-21-03-26-28 @@ -0,0 +1,17 @@ +PG_HOST=localhost +PG_PORT=5432 +PG_DATABASE=omniai +PG_USER=omniai +PG_PASSWORD=bybyby@123 +PG_POOL_MAX=10 +PORT=3600 +HOST=0.0.0.0 +JWT_SECRET=499808ef76791e59ab1019f8fbb86d2b +DEFAULT_ADMIN_PASSWORD=bybyby@123BY +JWT_EXPIRES_IN=7d +CORS_ORIGINS=* +STS_ACCESS_KEY_ID=LTAI5t7qL3iR9dchydHQ3cmT +STS_ACCESS_KEY_SECRET=ssywO1bUwu2pPZaq3KugXbaE0Za9gi +OSS_ROLE_ARN=acs:ram::1582660594690998:role/OmniAI-OSS-Upload +OSS_BUCKET=stringtest +OSS_REGION=oss-cn-hangzhou diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..624cb0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +.env.local +*.log +backups/ +src.bak.* +config/internal-beta-codes.md +data/ +*.tmp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d0d35b --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# OmniAI Key Management Server + +API Key 池管理服务,支持多 Key 轮询、并发控制、自动排队。 + +## 部署到阿里云 + +### 1. 服务器准备 + +```bash +# 安装 Node.js 18+ +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt install -y nodejs + +# 安装 PM2 (进程管理) +sudo npm install -g pm2 +``` + +### 2. 上传代码 + +```bash +# 将 server/ 目录上传到服务器 +scp -r server/ root@your-server:/opt/omniai-server/ +``` + +### 3. 配置 + +```bash +cd /opt/omniai-server +cp .env.example .env +nano .env # 修改 JWT_SECRET、DEFAULT_ADMIN_PASSWORD 等配置 +``` + +### 4. 安装 & 初始化 + +```bash +npm install +npm run init-db +``` + +### 5. 添加 Key + +```bash +# 添加 Seedance Key (每个并发上限 10) +npm run add-key -- seedance sk-your-key-1 "Seedance 1号" 10 +npm run add-key -- seedance sk-your-key-2 "Seedance 2号" 10 +npm run add-key -- seedance sk-your-key-3 "Seedance 3号" 10 + +# 添加其他服务的 Key +npm run add-key -- grok sk-grok-key "Grok" 10 +npm run add-key -- dashscope sk-dash-key "DashScope" 10 + +# 查看所有 Key +npm run list-keys +``` + +### 6. 添加用户 + +```bash +npm run add-user -- alice password123 user 30 +npm run add-user -- bob password456 user 30 +``` + +### 6.1 防控账号开通与封控 + +建议给外部/临时用户只开企业子账号,不下发任何本地 API Key,只让客户端通过 Key Server 登录、拉取远程配置和上报用量。 + +```bash +# 1) 用企业管理员账号注册员工账号,或由系统管理员调用接口创建企业用户。 + +# 2) 紧急封控:系统管理员禁用任意账号 +curl -X PUT http://server:3600/api/admin/users/123 \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"enabled":false}' + +# 3) 企业管理员禁用本企业子账号 +curl -X PUT http://server:3600/api/admin/sub-accounts/123 \ + -H "Authorization: Bearer $ENTERPRISE_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"enabled":false}' +``` + +禁用后,`/api/auth/me`、Key 租用、用量上报、云项目同步等受保护接口会拒绝该账号现有 Token。打受控包时可设置 `OMNIAI_DISABLE_PROJECT_EXPORT=1`,在主进程直接禁用项目导出 IPC;也可以在远程配置里下发 `securityPolicy.projectExportDisabled=true`,客户端同步配置后同样会锁定项目导出。 + +### 7. 启动服务 + +```bash +# 开发模式 +npm run dev + +# 生产模式 (PM2) +pm2 start src/index.js --name omniai-server +pm2 save +pm2 startup # 开机自启 +``` + +### 8. 防火墙 + +```bash +# 阿里云安全组放开端口 3600 (或你配置的端口) +# 或使用 Nginx 反向代理到 80/443 +``` + +## API 接口 + +### 认证 + +```bash +# 登录 +curl -X POST http://server:3600/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"alice","password":"password123"}' +# → { "token": "eyJ...", "user": { "id": 1, "username": "alice" } } +``` + +### Key 获取/释放 + +```bash +# 获取一个 Key +curl -X POST http://server:3600/api/keys/acquire \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"seedance"}' +# → { "leaseToken": "uuid", "apiKey": "sk-xxx", "provider": "seedance" } + +# 用完归还 +curl -X POST http://server:3600/api/keys/release \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"leaseToken":"uuid"}' + +# 查看状态 +curl http://server:3600/api/keys/status?provider=seedance \ + -H "Authorization: Bearer $TOKEN" +``` + +### 管理接口 (需 admin) + +```bash +# 查看所有 Key +curl http://server:3600/api/admin/keys -H "Authorization: Bearer $ADMIN_TOKEN" + +# 添加 Key +curl -X POST http://server:3600/api/admin/keys \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"seedance","api_key":"sk-new","label":"新Key","max_concurrency":10}' + +# 禁用 Key +curl -X PUT http://server:3600/api/admin/keys/1 \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"enabled":false}' + +# 查看用量日志 +curl http://server:3600/api/admin/usage?limit=50 -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +## 导入配置 + +将客户端的 settings 模板导入为服务端配置(所有 API key + 模型设置): + +```bash +npm run import-config -- ../resources/templates/settings-default-basic.txt default +``` + +这样 20 个客户端不需要手动配置任何 key,启动时自动从服务端拉取。 + +更新配置后,所有客户端下次启动会自动同步最新配置。 + +### 配置管理 API + +```bash +# 获取当前配置 +curl http://server:3600/api/config/profile?name=default \ + -H "Authorization: Bearer $TOKEN" + +# 更新配置 (需 admin) +curl -X PUT http://server:3600/api/config/profile \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"default","config":{"provider":"gemini","apiKey":"sk-xxx",...}}' + +# 列出所有配置 +curl http://server:3600/api/config/profiles \ + -H "Authorization: Bearer $TOKEN" +``` + +## 客户端对接 + +Electron 客户端设置页只需填写: +- **服务器地址**: `http://your-server:3600` +- **用户名/密码**: 登录获取 Token + +客户端启动时自动从服务端同步全部配置(API key、模型、端点等)。 + +生成视频时: +1. `POST /api/keys/acquire` → 申请并发额度 +2. 直连 AI API 生成视频(用服务端下发的 key) +3. `POST /api/keys/release` → 释放额度 + +Key 集中管理在服务端,客户端不需要手动配置。 + +## 2026-04 Seedance 并发配置补充 + +当前客户端已经按两个独立 provider 申请槽位: + +- `seedance-2.0` +- `seedance-2.0-fast` + +为了保证 `Seedance 2.0` 和 `Seedance 2.0 Fast` 各自独立 `10` 并发,请按下面方式部署: + +```bash +# 1. 初始化数据库 +npm install +npm run init-db + +# 2. 初始化两个独立池 +npm run init-pools + +# 3. 查看池状态 +npm run list-keys +``` + +建议部署要求: + +- `seedance-2.0` 总容量固定为 `10` +- `seedance-2.0-fast` 总容量固定为 `10` +- 两个 provider 不能混用,也不能共用同一池 +- 当某个 provider 达到上限时,客户端会显示“排队中” + +获取槽位示例: + +```bash +curl -X POST http://server:3600/api/keys/acquire \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"seedance-2.0"}' + +curl -X POST http://server:3600/api/keys/acquire \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"seedance-2.0-fast"}' +``` diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..e926f8a --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,13 @@ +module.exports = { + apps: [{ + name: 'omniai-server', + script: './src/index.js', + instances: 4, + exec_mode: 'cluster', + env: { + NODE_ENV: 'production', + }, + max_memory_restart: '512M', + merge_logs: true, + }], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f23bc0a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1754 @@ +{ + "name": "omniai-key-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omniai-key-server", + "version": "1.0.0", + "dependencies": { + "alipay-sdk": "^4.14.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-rate-limit": "^8.4.1", + "helmet": "^8.2.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.0", + "wechatpay-node-v3": "^2.2.1" + } + }, + "node_modules/@fidm/asn1": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz", + "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@fidm/x509": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz", + "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", + "license": "MIT", + "dependencies": { + "@fidm/asn1": "^1.0.4", + "tweetnacl": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/alipay-sdk": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/alipay-sdk/-/alipay-sdk-4.14.0.tgz", + "integrity": "sha512-oiD/VP5Ei0RRacHHmE+N0uqgOj2xzce7c0fHrtyyh1P04O+o9I1r65LdGPzU8960J56xOxS/d3c+R/9lsPUH7g==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "^1.2.1", + "bignumber.js": "^9.1.2", + "camelcase-keys": "^7.0.2", + "crypto-js": "^4.2.0", + "formstream": "^1.5.0", + "snakecase-keys": "^8.0.0", + "sse-decoder": "^1.0.0", + "urllib": "^4", + "utility": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "license": "MIT", + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/formstream": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/formstream/-/formstream-1.5.2.tgz", + "integrity": "sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==", + "license": "MIT", + "dependencies": { + "destroy": "^1.0.4", + "mime": "^2.5.2", + "node-hex": "^1.0.1", + "pause-stream": "~0.0.11" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-hex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-hex/-/node-hex-1.0.1.tgz", + "integrity": "sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snakecase-keys": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-8.1.0.tgz", + "integrity": "sha512-9/Eug2btrCiOi+9+vIXJnxUcKVfcbLy5Uwff4BrO6PQf3Oq/2iYQ/1zkmnrpIIjfel/DAasAlux7OvAmCa+Xnw==", + "license": "MIT", + "dependencies": { + "map-obj": "^4.2.0", + "snake-case": "^3.0.4", + "type-fest": "^4.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/snakecase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sse-decoder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sse-decoder/-/sse-decoder-1.0.0.tgz", + "integrity": "sha512-JPopy3jfNmPcUz5Ru6skKhHNRJbsvcEW6Z4SirKkucLS8Jya1Bmf4FVX8giOkLm8xQJ7kK68P6GXoVSTkbedUA==", + "license": "MIT", + "engines": { + "node": ">= 14.19.3" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/superagent": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz", + "integrity": "sha512-HqSe6DSIh3hEn6cJvCkaM1BLi466f1LHi4yubR0tpewlMpk4RUFFy35bKz8SsPBwYfIIJy5eclp+3tCYAuX0bw==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unescape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unescape/-/unescape-1.0.1.tgz", + "integrity": "sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urllib": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/urllib/-/urllib-4.9.0.tgz", + "integrity": "sha512-Rp3DBvVqy9arSTMH6oN5N7OAN01U3dQ2xc19AadP86MzC4j67MDeqOup5shpWnjkDVGWfLfWPOd8CyqOtd6u3w==", + "license": "MIT", + "dependencies": { + "form-data": "^4.0.1", + "formstream": "^1.5.1", + "mime-types": "^2.1.35", + "qs": "^6.12.1", + "type-fest": "^4.20.1", + "undici": "^7.1.1", + "ylru": "^2.0.0" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/urllib/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/utility": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/utility/-/utility-2.5.0.tgz", + "integrity": "sha512-lDbOVde5UAKgtxrSyZNhqrTA7f7anba6DTqbsDWgUFk6PZlmr7djqPYw0FnL5a6TbJvRt38VmYqt07zVLzXG2A==", + "license": "MIT", + "dependencies": { + "escape-html": "^1.0.3", + "unescape": "^1.0.1", + "ylru": "^2.0.0" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wechatpay-node-v3": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/wechatpay-node-v3/-/wechatpay-node-v3-2.2.1.tgz", + "integrity": "sha512-z+n8Mrzn0UNoLJPBRrY8ZG6yo9xxNihlGvwvAbV8Nlnm4tTap2UjwIikGkhryC8gOmwrlvJfSUd+x1cK3ks1hA==", + "license": "MIT", + "dependencies": { + "@fidm/x509": "1.2.1", + "superagent": "8.0.6" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/ylru": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-2.0.0.tgz", + "integrity": "sha512-T6hTrKcr9lKeUG0MQ/tO72D3UGptWVohgzpHG8ljU1jeBt2RCjcWxvsTPD8ZzUq1t1FvwROAw1kxg2euvg/THg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad2140f --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "omniai-key-server", + "version": "1.0.0", + "description": "OmniAI Key Management & Proxy Server", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "init-db": "node src/initDb.js", + "add-user": "node src/cli/addUser.js", + "add-key": "node src/cli/addKey.js", + "remove-key": "node src/cli/removeKey.js", + "list-keys": "node src/cli/listKeys.js", + "audit-routes": "node src/cli/auditModelRoutes.js", + "import-config": "node src/cli/importConfig.js", + "init-pools": "node src/cli/initPools.js", + "test:community-routes": "node scripts/communityRouteContract.test.js" + }, + "dependencies": { + "alipay-sdk": "^4.14.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-rate-limit": "^8.4.1", + "helmet": "^8.2.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.0", + "wechatpay-node-v3": "^2.2.1" + } +} diff --git a/scripts/aiUpscaleHelpersContract.test.js b/scripts/aiUpscaleHelpersContract.test.js new file mode 100644 index 0000000..a5576c8 --- /dev/null +++ b/scripts/aiUpscaleHelpersContract.test.js @@ -0,0 +1,81 @@ +const assert = require("node:assert/strict"); + +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../src/aiUpscaleHelpers"); +const { extractVideoUrl } = require("../src/aiTaskWorker"); + +function testImageSuperResolveBody() { + const body = buildDashscopeImageSuperResolveBody({ + imageUrl: "https://example.com/input.png", + scale: "4x", + }); + + assert.equal(body.model, "wanx2.1-imageedit"); + assert.deepEqual(body.input, { + function: "super_resolution", + prompt: "图像超分。", + base_image_url: "https://example.com/input.png", + }); + assert.equal(body.parameters.upscale_factor, 4); + assert.equal(body.parameters.n, 1); + assert.equal(Object.hasOwn(body.parameters, "watermark"), false); +} + +function testVideoStyleTransformBody() { + const body = buildDashscopeVideoStyleTransformBody({ + videoUrl: "https://example.com/input.mp4", + style: 7, + videoFps: 99, + minLen: 540, + useSR: true, + animateEmotion: false, + }); + + assert.equal(body.model, "video-style-transform"); + assert.deepEqual(body.input, { + video_url: "https://example.com/input.mp4", + }); + assert.equal(body.parameters.style, 7); + assert.equal(body.parameters.video_fps, 25); + assert.equal(body.parameters.min_len, 540); + assert.equal(body.parameters.use_SR, true); + assert.equal(body.parameters.animate_emotion, false); +} + +function testNormalizers() { + assert.equal(normalizeImageUpscaleFactor("4x"), 4); + assert.equal(normalizeImageUpscaleFactor("3"), 2); + assert.deepEqual(normalizeVideoStyleTransformOptions({ style: "9", videoFps: 8, minLen: 999 }), { + style: 0, + videoFps: 15, + minLen: 720, + useSR: true, + animateEmotion: true, + }); +} + +function testVideoStyleResultExtraction() { + assert.equal( + extractVideoUrl({ + output: { + task_status: "SUCCEEDED", + output_video_url: "https://dashscope-result.example.com/result.mp4", + }, + }), + "https://dashscope-result.example.com/result.mp4", + ); +} + +function main() { + testImageSuperResolveBody(); + testVideoStyleTransformBody(); + testNormalizers(); + testVideoStyleResultExtraction(); + console.log("ai upscale helper contract tests passed"); +} + +main(); diff --git a/scripts/betaInviteCodes.test.js b/scripts/betaInviteCodes.test.js new file mode 100644 index 0000000..0850a6c --- /dev/null +++ b/scripts/betaInviteCodes.test.js @@ -0,0 +1,70 @@ +const assert = require("node:assert/strict"); + +const { + checkBetaInviteCodeForRegistration, + consumeBetaInviteCode, + loadBetaInviteCodesFromText, + normalizeBetaInviteCode, + validateBetaInviteCode, +} = require("../src/betaInviteCodes"); + +const sampleMarkdown = ` +# OmniAI Internal Beta Invite Codes + +| # | HEX | Octal | +|---|-----|-------| +| 1 | A1B2C3D4E5F60718 | 120746072324712600340 | +| 2 | 0011223344556677 | 000104421463210531467 | +`; + +const codes = loadBetaInviteCodesFromText(sampleMarkdown); + +assert.equal(normalizeBetaInviteCode(" a1b2-c3d4 e5f60718 "), "A1B2C3D4E5F60718"); +assert.equal(normalizeBetaInviteCode("120746072324712600340"), "120746072324712600340"); +assert.equal(codes.has("A1B2C3D4E5F60718"), true); +assert.equal(codes.has("120746072324712600340"), true); +assert.equal(validateBetaInviteCode("a1b2c3d4e5f60718", codes), true); +assert.equal(validateBetaInviteCode("000104421463210531467", codes), true); +assert.equal(validateBetaInviteCode("", codes), false); +assert.equal(validateBetaInviteCode("NOT-A-CODE", codes), false); + +function createInviteCodeClient() { + const used = new Map(); + return { + used, + async query(sql, params) { + if (/SELECT 1 FROM beta_invite_code_uses/i.test(sql)) { + return { rows: used.has(params[0]) ? [{ 1: 1 }] : [] }; + } + if (/INSERT INTO beta_invite_code_uses/i.test(sql)) { + const [code, userId] = params; + if (used.has(code)) return { rows: [] }; + used.set(code, userId); + return { rows: [{ code }] }; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; +} + +(async () => { + const client = createInviteCodeClient(); + const firstCheck = await checkBetaInviteCodeForRegistration("a1b2c3d4e5f60718", client, codes); + assert.equal(firstCheck.ok, true); + + const firstConsume = await consumeBetaInviteCode("a1b2c3d4e5f60718", 101, client, codes); + assert.equal(firstConsume.ok, true); + assert.equal(client.used.get("A1B2C3D4E5F60718"), 101); + + const secondCheck = await checkBetaInviteCodeForRegistration("A1B2C3D4E5F60718", client, codes); + assert.equal(secondCheck.ok, false); + assert.equal(secondCheck.status, 409); + + const secondConsume = await consumeBetaInviteCode("A1B2C3D4E5F60718", 102, client, codes); + assert.equal(secondConsume.ok, false); + assert.equal(secondConsume.status, 409); + assert.equal(client.used.get("A1B2C3D4E5F60718"), 101); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/communityRouteContract.test.js b/scripts/communityRouteContract.test.js new file mode 100644 index 0000000..592a646 --- /dev/null +++ b/scripts/communityRouteContract.test.js @@ -0,0 +1,177 @@ +const assert = require("node:assert/strict"); +const { createRequire } = require("node:module"); + +const nodeRequire = createRequire(__filename); + +const routeModulePaths = [ + "../src/routes.js", + "../src/routes/index.js", + "../src/routes/community.js", + "../src/routes/notifications.js", +]; +const contextPath = "../src/routes/context.js"; + +function passThrough(_req, _res, next) { + next(); +} + +function loadRouter(pool) { + const contextResolvedPath = nodeRequire.resolve(contextPath); + const originalContextModule = nodeRequire.cache[contextResolvedPath]; + const resolvedRouteModules = routeModulePaths.map((modulePath) => nodeRequire.resolve(modulePath)); + + for (const resolvedPath of resolvedRouteModules) { + delete nodeRequire.cache[resolvedPath]; + } + + nodeRequire.cache[contextResolvedPath] = { + id: contextResolvedPath, + filename: contextResolvedPath, + loaded: true, + exports: { + express: nodeRequire("express"), + requireAuth: passThrough, + requireAdmin: passThrough, + requireEnterpriseAdmin: passThrough, + requireManagementAccess: passThrough, + pool, + withTransaction: async (fn) => fn(pool), + clampPositiveInteger: (value, fallback) => Math.max(1, Number(value) || fallback), + clampNonNegativeInteger: (value, fallback) => Math.max(0, Number(value) || fallback), + normalizeProjectOssKey: (value) => String(value || "").trim(), + }, + }; + + return { + router: nodeRequire("../src/routes.js"), + restore() { + for (const resolvedPath of resolvedRouteModules) { + delete nodeRequire.cache[resolvedPath]; + } + if (originalContextModule) { + nodeRequire.cache[contextResolvedPath] = originalContextModule; + } else { + delete nodeRequire.cache[contextResolvedPath]; + } + }, + }; +} + +function readRouterInventory(router) { + return router.stack + .filter((layer) => Boolean(layer.route)) + .flatMap((layer) => + Object.keys(layer.route.methods) + .filter((method) => layer.route.methods[method]) + .map((method) => ({ method: method.toUpperCase(), path: layer.route.path })), + ); +} + +function getRouteHandler(router, method, routePath) { + const layer = router.stack.find( + (candidate) => candidate.route?.path === routePath && candidate.route.methods[method.toLowerCase()], + ); + const handler = layer?.route?.stack.at(-1)?.handle; + if (!handler) throw new Error(`Route not found: ${method.toUpperCase()} ${routePath}`); + return handler; +} + +function createMockResponse() { + const res = {}; + res.status = (statusCode) => { + res.statusCode = statusCode; + return res; + }; + res.json = (body) => { + res.body = body; + return res; + }; + return res; +} + +async function testNotificationRoutesAreMounted() { + const { router, restore } = loadRouter({ query: async () => ({ rows: [] }) }); + try { + const inventory = readRouterInventory(router); + assert(inventory.some((route) => route.method === "GET" && route.path === "/notifications")); + assert(inventory.some((route) => route.method === "PATCH" && route.path === "/notifications/:id/read")); + assert(inventory.some((route) => route.method === "POST" && route.path === "/notifications/read-all")); + } finally { + restore(); + } +} + +async function testReviewStatusSurvivesNotificationWriteFailure() { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ sql, params }); + if (/UPDATE community_cases/.test(sql)) { + return { + rows: [ + { + id: 2, + user_id: 9, + username: "creator", + project_id: null, + title: "待审核案例", + description: "desc", + cover_url: null, + tags_json: "[]", + metadata_json: "{}", + status: "approved", + review_note: null, + reviewed_by: 1, + reviewed_at: "2026-05-19T00:00:00.000Z", + published_at: "2026-05-19T00:00:00.000Z", + copy_count: 0, + created_at: "2026-05-19T00:00:00.000Z", + updated_at: "2026-05-19T00:00:00.000Z", + }, + ], + }; + } + if (/INSERT INTO web_notifications/.test(sql)) { + throw new Error("relation web_notifications does not exist"); + } + if (/FROM community_case_assets/.test(sql)) return { rows: [] }; + if (/FROM community_case_reactions/.test(sql)) return { rows: [] }; + return { rows: [] }; + }, + }; + const { router, restore } = loadRouter(pool); + try { + const handler = getRouteHandler(router, "patch", "/admin/community/cases/:id/status"); + const res = createMockResponse(); + + await handler( + { + params: { id: "2" }, + body: { status: "approved" }, + user: { id: 1, role: "admin" }, + }, + res, + ); + + assert.equal(res.statusCode, undefined); + assert.equal(res.body.case.id, 2); + assert.equal(res.body.case.status, "approved"); + const updateCall = calls.find((call) => /UPDATE community_cases/.test(call.sql)); + assert.match(updateCall.sql, /status = \$1::varchar\(24\)/); + assert.match(updateCall.sql, /CASE WHEN \$1::varchar\(24\) = 'approved'/); + assert(calls.some((call) => /INSERT INTO web_notifications/.test(call.sql))); + } finally { + restore(); + } +} + +async function main() { + await testNotificationRoutesAreMounted(); + await testReviewStatusSurvivesNotificationWriteFailure(); + console.log("community route contract tests passed"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/dbSetupContract.test.js b/scripts/dbSetupContract.test.js new file mode 100644 index 0000000..ec6c492 --- /dev/null +++ b/scripts/dbSetupContract.test.js @@ -0,0 +1,11 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const source = fs.readFileSync(path.resolve(__dirname, "../src/dbSetup.js"), "utf8"); + +assert.match(source, /runMigration\("024_generation_tasks_project_queue_index"/); +assert.match(source, /CREATE UNIQUE INDEX IF NOT EXISTS idx_generation_tasks_project_queue_unique/); +assert.match(source, /ON generation_tasks\(project_id, client_queue_id\)\s+WHERE project_id IS NOT NULL/); + +console.log("db setup contract tests passed"); diff --git a/scripts/ossClientContract.test.js b/scripts/ossClientContract.test.js new file mode 100644 index 0000000..4659d3e --- /dev/null +++ b/scripts/ossClientContract.test.js @@ -0,0 +1,52 @@ +const assert = require("node:assert/strict"); + +process.env.STS_ACCESS_KEY_ID = "test-key"; +process.env.STS_ACCESS_KEY_SECRET = "test-secret"; +process.env.OSS_BUCKET = "test-bucket"; +process.env.OSS_REGION = "oss-cn-test"; + +const { getObject, createSignedReadUrl } = require("../src/ossClient"); + +async function main() { + const originalFetch = global.fetch; + global.fetch = async () => ({ + ok: false, + status: 404, + text: async () => ` + + NoSuchKey + The specified key does not exist. +`, + }); + + try { + await assert.rejects( + () => getObject("users/1/projects/missing/current/project.json"), + (error) => { + assert.equal(error.status, 404); + assert.equal(error.code, "oss_no_such_key"); + return true; + }, + ); + } finally { + global.fetch = originalFetch; + } + + const beforeExpires = Math.floor(Date.now() / 1000) + 60; + const signedUrl = createSignedReadUrl("tmp/admin-1/generation-inputs/audios/demo audio.mp3", 60); + const parsedSignedUrl = new URL(signedUrl); + const afterExpires = Math.floor(Date.now() / 1000) + 60; + assert.equal(parsedSignedUrl.host, "test-bucket.oss-cn-test.aliyuncs.com"); + assert.equal(parsedSignedUrl.pathname, "/tmp/admin-1/generation-inputs/audios/demo%20audio.mp3"); + assert.equal(parsedSignedUrl.searchParams.get("OSSAccessKeyId"), "test-key"); + assert.ok(Number(parsedSignedUrl.searchParams.get("Expires")) >= beforeExpires); + assert.ok(Number(parsedSignedUrl.searchParams.get("Expires")) <= afterExpires); + assert.ok(parsedSignedUrl.searchParams.get("Signature")); + + console.log("oss client contract tests passed"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/ossRouteContract.test.js b/scripts/ossRouteContract.test.js new file mode 100644 index 0000000..1bcbaf8 --- /dev/null +++ b/scripts/ossRouteContract.test.js @@ -0,0 +1,131 @@ +const assert = require("node:assert/strict"); +const { createRequire } = require("node:module"); + +const nodeRequire = createRequire(__filename); +const contextPath = "../src/routes/context.js"; +const ossRoutePath = "../src/routes/oss.js"; +const ossClientPath = "../src/ossClient.js"; + +function passThrough(_req, _res, next) { + next(); +} + +function createMockResponse() { + const res = {}; + res.status = (statusCode) => { + res.statusCode = statusCode; + return res; + }; + res.json = (body) => { + res.body = body; + return res; + }; + return res; +} + +function loadOssRouter(ossClient) { + const express = nodeRequire("express"); + const router = express.Router(); + const contextResolvedPath = nodeRequire.resolve(contextPath); + const ossRouteResolvedPath = nodeRequire.resolve(ossRoutePath); + const ossClientResolvedPath = nodeRequire.resolve(ossClientPath); + const originalContextModule = nodeRequire.cache[contextResolvedPath]; + const originalOssRouteModule = nodeRequire.cache[ossRouteResolvedPath]; + const originalOssClientModule = nodeRequire.cache[ossClientResolvedPath]; + + delete nodeRequire.cache[ossRouteResolvedPath]; + + nodeRequire.cache[contextResolvedPath] = { + id: contextResolvedPath, + filename: contextResolvedPath, + loaded: true, + exports: { + requireAuth: passThrough, + }, + }; + nodeRequire.cache[ossClientResolvedPath] = { + id: ossClientResolvedPath, + filename: ossClientResolvedPath, + loaded: true, + exports: ossClient, + }; + + nodeRequire(ossRoutePath).registerOssRoutes(router); + + return { + router, + restore() { + if (originalContextModule) nodeRequire.cache[contextResolvedPath] = originalContextModule; + else delete nodeRequire.cache[contextResolvedPath]; + if (originalOssRouteModule) nodeRequire.cache[ossRouteResolvedPath] = originalOssRouteModule; + else delete nodeRequire.cache[ossRouteResolvedPath]; + if (originalOssClientModule) nodeRequire.cache[ossClientResolvedPath] = originalOssClientModule; + else delete nodeRequire.cache[ossClientResolvedPath]; + }, + }; +} + +function getRouteHandler(router, method, routePath) { + const layer = router.stack.find( + (candidate) => candidate.route?.path === routePath && candidate.route.methods[method.toLowerCase()], + ); + const handler = layer?.route?.stack.at(-1)?.handle; + if (!handler) throw new Error(`Route not found: ${method.toUpperCase()} ${routePath}`); + return handler; +} + +async function uploadWithScope(scope, mimeType) { + const putCalls = []; + const { router, restore } = loadOssRouter({ + isOssConfigured: () => true, + putObject: async (objectKey, body, contentType, headers) => { + putCalls.push({ objectKey, body, contentType, headers }); + }, + createSignedReadUrl: (objectKey) => `https://signed.example.com/${objectKey}?Expires=86400`, + }); + + try { + const handler = getRouteHandler(router, "post", "/oss/upload"); + const res = createMockResponse(); + await handler( + { + body: { + dataUrl: `data:${mimeType};base64,${Buffer.from("{}").toString("base64")}`, + mimeType, + scope, + }, + user: { id: "admin-1" }, + }, + res, + ); + return { res, putCalls }; + } finally { + restore(); + } +} + +async function main() { + process.env.OSS_PUBLIC_BASE_URL = "https://cdn.example.com"; + + const imageUpload = await uploadWithScope("community-case-cover", "image/png"); + assert.equal(imageUpload.res.statusCode, 201); + assert.match(imageUpload.putCalls[0].objectKey, /^community\/images\/.+\.png$/); + assert.equal(imageUpload.res.body.ossKey, imageUpload.putCalls[0].objectKey); + assert.equal(imageUpload.res.body.url, `https://cdn.example.com/${imageUpload.putCalls[0].objectKey}`); + assert.equal( + imageUpload.res.body.signedUrl, + `https://signed.example.com/${imageUpload.putCalls[0].objectKey}?Expires=86400`, + ); + + const workflowUpload = await uploadWithScope("community-case-workflow", "application/json"); + assert.equal(workflowUpload.res.statusCode, 201); + assert.match(workflowUpload.putCalls[0].objectKey, /^community\/canvas\/.+\.json$/); + assert.equal(workflowUpload.putCalls[0].contentType, "application/json"); + + console.log("oss route contract tests passed"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/projectRouteContract.test.js b/scripts/projectRouteContract.test.js new file mode 100644 index 0000000..d1e6ee3 --- /dev/null +++ b/scripts/projectRouteContract.test.js @@ -0,0 +1,123 @@ +const assert = require("node:assert/strict"); +const { createRequire } = require("node:module"); + +const nodeRequire = createRequire(__filename); +const contextPath = "../src/routes/context.js"; +const projectRoutesPath = "../src/routes/projects.js"; +const ossClientPath = "../src/ossClient.js"; + +function passThrough(_req, _res, next) { + next(); +} + +function createMockResponse() { + const res = {}; + res.status = (statusCode) => { + res.statusCode = statusCode; + return res; + }; + res.json = (body) => { + res.body = body; + return res; + }; + return res; +} + +function loadProjectRouter(pool, ossClient) { + const express = nodeRequire("express"); + const router = express.Router(); + const contextResolvedPath = nodeRequire.resolve(contextPath); + const projectsResolvedPath = nodeRequire.resolve(projectRoutesPath); + const ossResolvedPath = nodeRequire.resolve(ossClientPath); + const originalContextModule = nodeRequire.cache[contextResolvedPath]; + const originalProjectsModule = nodeRequire.cache[projectsResolvedPath]; + const originalOssModule = nodeRequire.cache[ossResolvedPath]; + + delete nodeRequire.cache[projectsResolvedPath]; + + nodeRequire.cache[contextResolvedPath] = { + id: contextResolvedPath, + filename: contextResolvedPath, + loaded: true, + exports: { + requireAuth: passThrough, + pool, + withTransaction: async (fn) => fn(pool), + computeNextRevision: () => ({ nextRevision: 1 }), + normalizeRevisionValue: (value) => Number(value || 0), + shouldRejectStaleRevision: () => false, + formatGenerationTaskRow: (row) => row, + normalizeGenerationTaskPayload: (body) => ({ value: body }), + normalizeProjectOssKey: (value) => ({ value }), + buildOssPublicUrl: (key) => `https://cdn.example/${key}`, + requireOwnedProject: async () => true, + upsertGenerationTask: async () => ({}), + }, + }; + nodeRequire.cache[ossResolvedPath] = { + id: ossResolvedPath, + filename: ossResolvedPath, + loaded: true, + exports: ossClient, + }; + + nodeRequire(projectRoutesPath).registerProjectRoutes(router); + + return { + router, + restore() { + if (originalContextModule) nodeRequire.cache[contextResolvedPath] = originalContextModule; + else delete nodeRequire.cache[contextResolvedPath]; + if (originalProjectsModule) nodeRequire.cache[projectsResolvedPath] = originalProjectsModule; + else delete nodeRequire.cache[projectsResolvedPath]; + if (originalOssModule) nodeRequire.cache[ossResolvedPath] = originalOssModule; + else delete nodeRequire.cache[ossResolvedPath]; + }, + }; +} + +function getRouteHandler(router, method, routePath) { + const layer = router.stack.find( + (candidate) => candidate.route?.path === routePath && candidate.route.methods[method.toLowerCase()], + ); + const handler = layer?.route?.stack.at(-1)?.handle; + if (!handler) throw new Error(`Route not found: ${method.toUpperCase()} ${routePath}`); + return handler; +} + +async function main() { + const missing = new Error("OSS GET failed (404): NoSuchKey"); + missing.status = 404; + missing.code = "oss_no_such_key"; + const { router, restore } = loadProjectRouter( + { query: async () => ({ rows: [{ oss_key: "users/1/projects/missing/current/project.json" }] }) }, + { + isOssConfigured: () => true, + getObject: async () => { + throw missing; + }, + putObject: async () => ({}), + }, + ); + + try { + const handler = getRouteHandler(router, "get", "/projects/:id/content"); + const res = createMockResponse(); + await handler( + { params: { id: "workflow-missing" }, query: { resolveMedia: "1" }, user: { id: 1 } }, + res, + ); + + assert.equal(res.statusCode, 404); + assert.equal(res.body.code, "project_content_missing"); + } finally { + restore(); + } + + console.log("project route contract tests passed"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/ai.js b/src/ai.js new file mode 100644 index 0000000..b4d5364 --- /dev/null +++ b/src/ai.js @@ -0,0 +1,1812 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); +const { putObject, isOssConfigured } = require("../ossClient"); +const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); +const { + isEnterpriseVideoBillingUser, + markEnterpriseVideoCreditsAccepted, + prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, + reserveEnterpriseVideoCredits, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, +} = require("../enterpriseVideoBilling"); +const { + startPolling, + updateTaskInDb, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, +} = require("../aiTaskWorker"); +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../aiUpscaleHelpers"); + +const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ + ["gpt-image-2", "1K"], +]); + +const GRSAI_IMAGE_MAX_QUALITY = new Map([ + ["gpt-image-2", "2K"], +]); + +const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ + ["wan2.7-image", "2K"], +]); + +const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; +const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; +const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; +const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; +const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 90_000; +const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; +const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; +const MAX_USER_ACTIVE_GENERATION_TASKS = 3; +const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; + +const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { + "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, + "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, + "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, + "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, + "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, +}; + +function mapAspectRatioToPixels(ratio, quality) { + const q = String(quality || "1K").toUpperCase(); + const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; + return map ? (map[q] || map["1K"]) : "1024x1024"; +} + +function mapAspectRatioToDashscopeSize(ratio, quality) { + return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); +} + +function normalizeQuality(value, fallback = "1K") { + const q = String(value || fallback).trim().toUpperCase(); + if (q === "4K" || q === "2K" || q === "1K") return q; + return fallback; +} + +function clampImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "2K"); + const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function clampGrsaiImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "1K"); + const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function normalizeDuration(value, min = 4, max = 15, fallback = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeRatio(value, fallback = "16:9") { + const ratio = String(value || fallback).trim(); + return ratio === "auto" ? "adaptive" : ratio; +} + +function normalizeVideoResolution(value, allowed, fallback = "720p") { + const resolution = String(value || "").trim().toLowerCase(); + return allowed.includes(resolution) ? resolution : fallback; +} + +function normalizeS2vResolution(value) { + const resolution = String(value || "").trim().toLowerCase(); + return resolution === "480p" ? "480P" : "720P"; +} + +function normalizeS2vStyle(value) { + const style = String(value || "").trim().toLowerCase(); + return ["speech", "sing", "performance"].includes(style) ? style : "speech"; +} + +function normalizePublicHttpUrl(value) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function percentEncodeRpc(value) { + return encodeURIComponent(String(value)) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +function signAliyunRpcParams(method, params, accessKeySecret) { + const canonicalQuery = Object.keys(params) + .sort() + .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) + .join("&"); + const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +function getAliyunVideoEnhanCredentials() { + const accessKeyId = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || + process.env.ALIYUN_ACCESS_KEY_ID || + process.env.STS_ACCESS_KEY_ID || + ""; + const accessKeySecret = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || + process.env.ALIYUN_ACCESS_KEY_SECRET || + process.env.STS_ACCESS_KEY_SECRET || + ""; + return { accessKeyId, accessKeySecret }; +} + +function buildAliyunRpcUrl(action, actionParams = {}) { + const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); + if (!accessKeyId || !accessKeySecret) { + const error = new Error("Aliyun video super-resolution is not configured"); + error.status = 501; + throw error; + } + + const params = { + Action: action, + Version: ALIYUN_VIDEOENHAN_VERSION, + Format: "JSON", + AccessKeyId: accessKeyId, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + ...actionParams, + }; + params.Signature = signAliyunRpcParams("GET", params, accessKeySecret); + + const queryString = Object.entries(params) + .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) + .join("&"); + return `${ALIYUN_VIDEOENHAN_ENDPOINT}?${queryString}`; +} + +function parseAliyunJsonResult(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +async function callAliyunRpc(action, params) { + const response = await fetch(buildAliyunRpcUrl(action, params), { method: "GET" }); + const text = await response.text().catch(() => ""); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); + } + + if (!response.ok || json.Code || json.code) { + throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); + } + + return json; +} + +function normalizeSuperResolveBitRate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 10; + return Math.max(1, Math.min(20, Math.round(numeric))); +} + +function normalizeAliyunJobStatus(value) { + return String(value || "").trim().toUpperCase(); +} + +async function ensureDefaultProject(userId) { + const projectId = `web-default-${userId}`; + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); + if (rows.length === 0) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + await pool.query( + `INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + updated_by_device_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING`, + [ + projectId, + userId, + "Default workbench", + "Web fallback project for legacy generation requests", + `users/${safeUserId}/projects/${projectId}/current/project.json`, + ], + ); + } + return projectId; +} + +async function resolveTaskProject(userId, requestedProjectId) { + const projectId = String(requestedProjectId || "").trim().slice(0, 64); + if (!projectId) { + return ensureDefaultProject(userId); + } + + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + return projectId; +} + +async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { + if (!client) { + return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); + } + + await assertUserGenerationConcurrencyLimit(userId, client); + const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { rows: [row] } = await client.query( + `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, + [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], + ); + return row; +} + +async function assertUserGenerationConcurrencyLimit(userId, client = pool) { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); + const { rows } = await client.query( + "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", + [userId], + ); + const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); + if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + + const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + error.status = 429; + error.code = "GENERATION_CONCURRENCY_LIMIT"; + error.activeCount = activeCount; + error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + throw error; +} + +async function providerPoolExists(provider) { + if (!provider) return false; + const { rows } = await pool.query( + "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", + [provider], + ); + return rows.length > 0; +} + +function releaseLease(slotResult) { + if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); +} + +function sendAiRouteError(res, err) { + res.status(err.status || 500).json({ + error: err.message, + code: err.code, + activeCount: err.activeCount, + maxActiveTasks: err.maxActiveTasks, + }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { + const raw = String(value || "").trim(); + if (!raw) return fallback; + + let message = raw; + try { + const parsed = JSON.parse(raw); + message = + parsed?.error?.message || + parsed?.error_description || + parsed?.message || + parsed?.error || + raw; + } catch {} + + const compact = String(message).replace(/\s+/g, " ").trim(); + const looksLikeMarkup = + /^]/i.test(compact) || + /^<\?xml/i.test(compact) || + /<\/?[a-z][^>]*>/i.test(compact); + + if (looksLikeMarkup) return fallback; + return compact.slice(0, 320); +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatAiTaskRow(row) { + return { + taskId: String(row.id), + projectId: row.project_id, + conversationId: row.conversation_id, + clientQueueId: row.client_queue_id || null, + type: row.type, + status: row.status, + progress: Number(row.progress || 0), + resultUrl: row.result_url || null, + error: row.error || null, + params: parseTaskParams(row.params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function extensionFromContentType(contentType, fallbackType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + return fallbackType === "video" ? "mp4" : "png"; +} + +function contentDispositionFilename(value) { + return String(value || "generated") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, 120) || "generated"; +} + +function isErrorContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function buildDashscopeImageBody(params) { + const content = []; + for (const url of params.referenceUrls || []) { + if (url) content.push({ image: url }); + } + content.push({ text: params.prompt }); + const quality = clampImageQualityForModel(params.model, params.quality); + return { + model: params.model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, + watermark: false, + }, + }; +} + +function buildGrsaiImageBody(params) { + const isGptImage = String(params.model || "").startsWith("gpt-image"); + const modelKey = String(params.model || "").toLowerCase(); + const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); + return isGptImage + ? { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: mapAspectRatioToPixels(params.ratio, quality), + replyType: "json", + } + : { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: params.ratio || "auto", + imageSize: quality, + replyType: "json", + }; +} + +function buildRightcodeImageBody(providerConfig, params) { + const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; + const quality = normalizeQuality(params.quality, "1K"); + + return { + model: providerConfig.model || params.model, + prompt: params.prompt, + image: referenceUrls, + size: mapAspectRatioToPixels(params.ratio, quality), + response_format: "url", + }; +} + +function getGridCount(gridMode) { + if (gridMode === "grid-4") return 4; + if (gridMode === "grid-9") return 9; + if (gridMode === "grid-25") return 25; + return 1; +} + +function buildGeminiImageBody(params) { + const parts = [{ text: String(params.prompt || "").trim() }]; + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + parts.push({ + fileData: { fileUri: url, mimeType: "image/png" }, + }); + } + const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; + const count = getGridCount(params.gridMode); + if (count > 1) generationConfig.candidateCount = count; + return { + contents: [{ parts }], + generationConfig, + }; +} + +function buildOpenAIImageBody(providerConfig, params) { + const userContent = []; + const prompt = String(params.prompt || "").trim(); + if (prompt) userContent.push({ type: "text", text: prompt }); + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + userContent.push({ type: "image_url", image_url: { url } }); + } + const body = { + model: providerConfig.model || params.model, + messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], + }; + const count = getGridCount(params.gridMode); + if (count > 1) body.n = count; + return body; +} + +function buildImageRequest(providerConfig, params, apiKey) { + const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; + if (providerConfig.transport === "dashscope-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; + } + if (providerConfig.transport === "rightcode-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; + } + if (providerConfig.transport === "gemini-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; + } + if (providerConfig.transport === "openai-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; + } + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; +} + +function buildSeedVideoBody(params) { + const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); + const metadata = { + generate_audio: true, + watermark: false, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + resolution, + }; + const body = { + model: params.model, + prompt: params.prompt, + metadata, + }; + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + metadata.first_frame_image = refs[0]; + metadata.last_frame_image = refs[refs.length - 1]; + } else if (refs.length === 1) { + body.image = refs[0]; + } else if (refs.length > 1) { + metadata.reference_images = refs; + } + return body; +} + +function buildArkSeedVideoBody(params) { + const content = []; + if (params.prompt) content.push({ type: "text", text: params.prompt }); + + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); + content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); + } else { + refs.forEach((url, index) => { + content.push({ + type: "image_url", + image_url: { url }, + role: index === 0 ? "first_frame" : "reference_image", + }); + }); + } + + const body = { + model: params.model, + content, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + generate_audio: true, + watermark: false, + }; + body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); + return body; +} + +function buildWanI2vBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + parameters.prompt_extend = true; + + return { + model: params.model, + input, + parameters, + }; +} + +function normalizeHappyHorseResolution(value) { + return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function getReferenceImageUrls(params, limit = 9) { + return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) + .map((url) => normalizePublicHttpUrl(url)) + .filter(Boolean) + .slice(0, limit); +} + +function buildHappyHorseBaseParameters(params, { includeRatio }) { + const parameters = { + resolution: normalizeHappyHorseResolution(params.quality), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); + return parameters; +} + +function createMissingReferenceError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function buildHappyHorseT2vBody(params) { + return { + model: params.model, + input: { + prompt: params.prompt, + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function buildHappyHorseI2vBody(params) { + const [firstFrame] = getReferenceImageUrls(params, 1); + if (!firstFrame) { + throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: [{ type: "first_frame", url: firstFrame }], + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), + }; +} + +function buildHappyHorseR2vBody(params) { + const refs = getReferenceImageUrls(params, 9); + if (!refs.length) { + throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: refs.map((url) => ({ type: "reference_image", url })), + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function getHappyHorseReferenceError(protocol, referenceUrls) { + if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { + return "HappyHorse I2V requires one first-frame image."; + } + if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { + return "HappyHorse R2V requires 1 to 9 reference images."; + } + return ""; +} + +async function assertWanS2vImageDetected(providerConfig, params, apiKey) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + + const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: providerConfig.detectModel || "wan2.2-s2v-detect", + input: { image_url: imageUrl }, + }), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + + if (!response.ok) { + throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); + } + + const output = json && typeof json === "object" ? json.output || json.data || json : {}; + const pass = + output.check_pass === true || + output.checkPass === true || + output.passed === true || + output.pass === true || + String(output.code || "").toLowerCase() === "success"; + + if (!pass) { + const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; + const error = new Error(message); + error.status = 400; + throw error; + } +} + +function extractProviderDetectMessage(output) { + if (!output || typeof output !== "object") return ""; + return String( + output.message || + output.reason || + output.failure_reason || + output.description || + output.error || + "", + ).trim(); +} + +function buildWanS2vBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + const audioUrl = normalizePublicHttpUrl(params.audioUrl); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!audioUrl) { + const error = new Error("Missing audioUrl"); + error.status = 400; + throw error; + } + + const parameters = { + resolution: normalizeS2vResolution(params.quality), + style: normalizeS2vStyle(params.style), + }; + + return { + model: params.model, + input: { + image_url: imageUrl, + audio_url: audioUrl, + }, + parameters, + }; +} + +function buildDashscopeKlingBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const parameters = { + mode: params.quality === "std" ? "std" : "pro", + duration: normalizeDuration(params.duration, 5, 10, 5), + audio: false, + watermark: false, + }; + if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); + + return { model: params.model, input, parameters }; +} + +function buildKlingOmniBody(params) { + const refs = params.referenceUrls || []; + const imageList = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); + } else if (refs[0]) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + } + + const body = { + model_name: "kling-v3-omni", + mode: params.quality === "std" ? "std" : "pro", + sound: "off", + duration: String(normalizeDuration(params.duration, 3, 15, 5)), + watermark_info: { enabled: false }, + prompt: params.prompt, + }; + if (imageList.length) body.image_list = imageList; + else body.aspect_ratio = normalizeRatio(params.ratio); + return body; +} + +function buildVideoRequest(providerConfig, params, apiKey) { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; + let body; + + if (providerConfig.protocol === "seed-video-ark") { + body = buildArkSeedVideoBody(params); + } else if (providerConfig.protocol === "happyhorse-t2v") { + body = buildHappyHorseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-i2v") { + body = buildHappyHorseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-r2v") { + body = buildHappyHorseR2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-i2v") { + body = buildWanI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-s2v") { + body = buildWanS2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-dashscope") { + body = buildDashscopeKlingBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-omni") { + body = buildKlingOmniBody(params); + const credential = parseKlingCredential(apiKey); + if (credential) { + headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; + } + } else { + body = buildSeedVideoBody(params); + } + + return { headers, body }; +} + +function registerAiRoutes(router) { + router.post("/ai/image", requireAuth, async (req, res) => { + const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; + if (!prompt) return res.status(400).json({ error: "Missing prompt" }); + + try { + const providerCandidates = resolveImageProviderCandidates(model); + const primaryProviderConfig = providerCandidates[0]; + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: primaryProviderConfig.model, + requestedModel: primaryProviderConfig.requestedModel, + prompt, + ratio, + quality, + gridMode, + referenceUrls, + }; + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: params.requestedModel || params.model || model, + resolution: [ratio, quality].filter(Boolean).join(" / "), + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + error.costCents = billingResult.costCents; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + imageBilling: { + costCents: imageBilling.costCents, + deductionType: imageBilling.deductionType, + balanceAfterCents: imageBilling.balanceAfterCents, + }, + providerDebug: buildImageProviderDebug(model), + }); + submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { + console.error("[ai/image] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/image] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video", requireAuth, async (req, res) => { + const { + model, + prompt, + ratio, + duration, + quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + resolution, + muted, + hasReferenceVideo, + style, + projectId: requestedProjectId, + conversationId, + } = req.body; + const providerConfig = resolveVideoProvider(model); + const provider = providerConfig.provider; + const isWanS2v = providerConfig.protocol === "wan-s2v"; + const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); + + if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); + if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); + if (isWanS2v) { + if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl(audioUrl)) { + return res.status(400).json({ error: "Missing audioUrl" }); + } + } + + let slotResult = null; + try { + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: providerConfig.model, + requestedModel: providerConfig.requestedModel, + prompt: prompt || "数字人口播视频", + ratio, + duration, + quality: quality || resolution, + resolution: resolution || quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + muted: Boolean(muted), + hasReferenceVideo: Boolean(hasReferenceVideo), + style, + }; + + let enterpriseBilling = null; + let preauth = null; + if (isEnterpriseVideoBillingUser(req.user)) { + enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); + preauth = { + authorized: true, + estimatedCostCents: enterpriseBilling.amountCents, + billingMode: "enterprise", + }; + } else { + preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + if (enterpriseBilling) { + const nextBilling = await reserveEnterpriseVideoCredits(client, { + ...enterpriseBilling, + taskId: nextTaskRow.id, + }); + return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; + } + // Regular user: deduct from personal balance + const credits = calculateEnterpriseVideoCredits({ + model: params.model, + resolution: params.resolution || params.quality, + durationSeconds: params.duration, + muted: params.muted, + hasReferenceVideo: params.hasReferenceVideo, + }); + const costCents = Math.ceil(credits * 100); + const { rows: [deducted] } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [costCents, req.user.id], + ); + if (!deducted) { + throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); + } + await client.query( + "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", + [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], + ); + return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; + }); + + if (reservedBilling) { + params.enterpriseBilling = { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + resolution: reservedBilling.resolution, + durationSeconds: reservedBilling.durationSeconds, + rateCentsPerSecond: reservedBilling.rateCentsPerSecond, + }; + await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ + JSON.stringify(params), + taskRow.id, + ]); + } + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + enterpriseBilling: reservedBilling + ? { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, + } + : undefined, + }); + const activeSlotResult = slotResult; + slotResult = null; + submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) + .then(async () => { + try { + await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); + } catch (settlementError) { + console.error("[ai/video] enterprise ledger settle error:", settlementError.message); + } + }) + .catch(async (err) => { + console.error("[ai/video] submit error:", err.message); + await updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); + releaseLease(activeSlotResult); + }); + } catch (err) { + releaseLease(slotResult); + console.error("[ai/video] error:", err.message); + if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { + return res.status(err.status || 402).json({ + error: err.message, + code: "INSUFFICIENT_ENTERPRISE_BALANCE", + }); + } + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: "wanx2.1-imageedit", + operation: "image-super-resolution", + imageUrl, + scale, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { + const videoUrl = String(req.body?.videoUrl || "").trim(); + const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); + const providerMode = String(req.body?.provider || req.body?.model || "").trim(); + const shouldUseDashscopeStyle = + providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + if (!/^https?:\/\//i.test(videoUrl)) { + return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); + } + + let dashscopeSlotResult; + try { + if (shouldUseDashscopeStyle) { + const provider = "dashscope"; + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!dashscopeSlotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const styleOptions = normalizeVideoStyleTransformOptions(req.body); + const params = { + model: "video-style-transform", + operation: "video-style-super-resolution", + videoUrl, + ...styleOptions, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { + console.error("[ai/video/super-resolve] dashscope submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(dashscopeSlotResult); + }); + return; + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); + console.error("[ai/video/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/chat", requireAuth, async (req, res) => { + const { model, messages, stream = true, temperature } = req.body; + if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); + + const providerConfig = resolveTextProvider(model); + const provider = providerConfig.provider; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const reqHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${slotResult.apiKey}`, + }; + const reqBody = JSON.stringify({ + model: providerConfig.model, + messages, + stream, + temperature: temperature || 0.7, + max_tokens: 4096, + }); + + if (stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); + + try { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => "upstream error"); + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), + done: true, + })}\n\n`, + ); + res.end(); + releaseLease(slotResult); + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + continue; + } + try { + const chunk = JSON.parse(payload); + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); + } catch {} + } + } + + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + res.end(); + releaseLease(slotResult); + } catch (streamErr) { + if (streamErr.name !== "AbortError") { + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(streamErr.message), + done: true, + })}\n\n`, + ); + } + res.end(); + releaseLease(slotResult); + } + } else { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); + const text = await upstream.text().catch(() => ""); + releaseLease(slotResult); + + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + return res.status(502).json({ + error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), + }); + } + + if (!upstream.ok || json.error) { + return res.status(502).json({ + error: sanitizeUpstreamError( + json.error?.message || json.message || json.error || text, + `文本服务返回 HTTP ${upstream.status}`, + ), + }); + } + + const content = json.choices?.[0]?.message?.content || ""; + const usage = json.usage || {}; + res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); + } + } catch (err) { + releaseLease(slotResult); + console.error("[ai/chat] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); + const status = String(req.query.status || "").trim(); + const type = String(req.query.type || "").trim(); + const projectId = String(req.query.projectId || req.query.project_id || "").trim(); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (["image", "video"].includes(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + if (projectId) { + params.push(projectId); + where.push(`project_id = $${params.length}`); + } + + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT * + FROM generation_tasks + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length - 1} + OFFSET $${params.length} + `, + params, + ); + res.json({ tasks: rows.map(formatAiTaskRow) }); + } catch (err) { + console.error("[ai/tasks] list failed:", err.message); + res.status(500).json({ error: "Failed to load task history" }); + } + }); + + router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + const conversationId = Number(req.body?.conversationId); + + if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { + return res.status(400).json({ error: "Invalid task or conversation id" }); + } + + try { + const { rows: conversationRows } = await pool.query( + "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", + [conversationId, req.user.id], + ); + if (conversationRows.length === 0) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const { rows } = await pool.query( + `UPDATE generation_tasks + SET conversation_id = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + RETURNING id, conversation_id`, + [conversationId, taskId, req.user.id], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Task not found" }); + } + + res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + res.json(formatAiTaskRow(rows[0])); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); + + try { + const { rows } = await pool.query( + "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); + res.json({ id: rows[0].id, status: rows[0].status }); + } catch (err) { + console.error("[ai/task-cancel] error:", err.message); + res.status(500).json({ error: "取消任务失败" }); + } + }); + + router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + const task = rows[0]; + const resultUrl = String(task.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl)) { + return res.status(400).json({ error: "Task result is not downloadable" }); + } + + const upstream = await fetch(resultUrl, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); + } + + const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorContentType(contentType)) { + const text = await upstream.text().catch(() => ""); + return res.status(502).json({ + error: text.includes("Expired") || text.includes("AccessDenied") + ? "结果链接已过期,请重新生成后再下载" + : "结果链接返回了错误内容,请重新生成后再下载", + }); + } + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Result download returned empty content" }); + } + + const extension = extensionFromContentType(contentType, task.type); + const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/tasks/download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); +} + +async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { + const errors = [...previousErrors]; + const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; + + for (let index = 0; index < candidates.length; index += 1) { + const providerConfig = candidates[index]; + const provider = providerConfig?.provider; + let slotResult = null; + + if (!provider) continue; + + try { + if (index > 0 && !(await providerPoolExists(provider))) { + throw new Error(`${provider} provider pool is not configured`); + } + + slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + throw new Error(`${provider} concurrency pool is full`); + } + + await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { + onTaskFailed: async (failureMessage) => { + const providerError = `${provider}: ${failureMessage}`; + const remainingCandidates = candidates.slice(index + 1); + if (remainingCandidates.length === 0) { + await updateTaskInDb(taskDbId, { + status: "failed", + error: `All image providers failed: ${[...errors, providerError].join(" | ")}`, + }); + return true; + } + + console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + try { + await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ + ...errors, + providerError, + ]); + return true; + } catch (fallbackErr) { + await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); + return true; + } + }, + }); + if (index > 0) { + console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); + } + return; + } catch (err) { + const message = err?.message || String(err); + errors.push(`${provider}: ${message}`); + console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); + releaseLease(slotResult); + + if (index < candidates.length - 1) { + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + } + } + } + + throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); +} + +async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { + const url = getPostUrl(providerConfig); + const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const submitTimeout = providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; + const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + + // Synchronous transports — extract image URL directly, no polling + if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { + let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); + const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; + console.info( + `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, + ); + if (!directUrl) throw new Error(`${tag} did not return an image url`); + + // Gemini may return base64 data URL — too large for DB, upload to OSS first + if (directUrl.startsWith("data:") && isOssConfigured()) { + const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + const mimeType = match[1]; + const buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "png"; + const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const bucket = process.env.OSS_BUCKET || ""; + const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); + directUrl = process.env.OSS_PUBLIC_BASE_URL + ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` + : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; + console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); + } + } + + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); + releaseLease(slotResult); + return; + } + + const directUrl = extractImageUrl(json); + + const providerTaskId = extractProviderTaskId(json); + if (directUrl) { + console.info(`[ai/image/grsai] task ${taskDbId} completed with direct result from submit response`); + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + onTaskFailed: options.onTaskFailed, + }); +} + +async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + if (providerConfig.protocol === "wan-s2v") { + await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); + await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); + } + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Video provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeImageSuperResolveBody(params); + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeVideoStyleTransformBody(params); + const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig: { + protocol: "wan-i2v", + baseUrl: "https://dashscope.aliyuncs.com", + }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitVideoSuperResolveTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const submitResult = await callAliyunRpc("SuperResolveVideo", { + VideoUrl: params.videoUrl, + BitRate: String(params.bitRate || 10), + }); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun SuperResolveVideo did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun super-resolution completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("Aliyun video super-resolution timed out"); +} + +module.exports = { registerAiRoutes }; diff --git a/src/aiProviderRouter.js b/src/aiProviderRouter.js new file mode 100644 index 0000000..6276b66 --- /dev/null +++ b/src/aiProviderRouter.js @@ -0,0 +1,268 @@ +"use strict"; + +const DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com"; +const KUAIKUAI_IMAGE_BASE_URL = process.env.KUAIKUAI_IMAGE_BASE_URL || "https://ai-api.kkidc.com"; +const KUAIKUAI_GEMINI_ENDPOINT = "/v1beta/models/{model}:generateContent"; +const KUAIKUAI_OPENAI_ENDPOINT = "/v1/chat/completions"; +const GRSAI_IMAGE_BASE_URL = "https://grsai.dakka.com.cn"; +const GRSAI_TEXT_BASE_URL = "https://grsai.dakka.com.cn"; +const RIGHTCODE_IMAGE_BASE_URL = process.env.RIGHTCODE_IMAGE_BASE_URL || process.env.RIGHTCODE_BASE_URL || "https://www.right.codes/draw"; +const SEEDANCE_BASE_URL = "https://ai-api.kkidc.com"; +const SEEDANCE_ARK_BASE_URL = "https://ark.cn-beijing.volces.com"; +const KLING_BASE_URL = "https://api-beijing.klingai.com"; + +const DASHSCOPE_IMAGE_ENDPOINT = "/api/v1/services/aigc/multimodal-generation/generation"; +const DASHSCOPE_VIDEO_ENDPOINT = "/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_S2V_ENDPOINT = "/api/v1/services/aigc/image2video/video-synthesis"; +const DASHSCOPE_S2V_DETECT_ENDPOINT = "/api/v1/services/aigc/image2video/face-detect"; +const DASHSCOPE_ANIMATE_ENDPOINT = "/api/v1/services/aigc/image2video/video-synthesis"; +const GRSAI_IMAGE_ENDPOINT = "/v1/api/generate"; +const GRSAI_RESULT_ENDPOINT = "/v1/api/result"; +const RIGHTCODE_IMAGE_ENDPOINT = process.env.RIGHTCODE_IMAGE_ENDPOINT || "/v1/images/generations"; + +const IMAGE_MODELS = { + "nano-banana-pro": { provider: "grsai", transport: "grsai-image", baseUrl: GRSAI_IMAGE_BASE_URL, endpoint: GRSAI_IMAGE_ENDPOINT, resultEndpoint: GRSAI_RESULT_ENDPOINT }, + "nano-banana-2": { provider: "grsai", transport: "grsai-image", baseUrl: GRSAI_IMAGE_BASE_URL, endpoint: GRSAI_IMAGE_ENDPOINT, resultEndpoint: GRSAI_RESULT_ENDPOINT }, + "nano-banana-fast": { provider: "grsai", transport: "grsai-image", baseUrl: GRSAI_IMAGE_BASE_URL, endpoint: GRSAI_IMAGE_ENDPOINT, resultEndpoint: GRSAI_RESULT_ENDPOINT }, + "gpt-image-2": { provider: "grsai", transport: "grsai-image", baseUrl: GRSAI_IMAGE_BASE_URL, endpoint: GRSAI_IMAGE_ENDPOINT, resultEndpoint: GRSAI_RESULT_ENDPOINT }, + "gpt-image-2-vip": { provider: "grsai", transport: "grsai-image", baseUrl: GRSAI_IMAGE_BASE_URL, endpoint: GRSAI_IMAGE_ENDPOINT, resultEndpoint: GRSAI_RESULT_ENDPOINT }, + "wan2.7-image": { provider: "dashscope-wan2.7", transport: "dashscope-image", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_IMAGE_ENDPOINT }, + "wan2.7-image-pro": { provider: "dashscope-wan2.7-pro", transport: "dashscope-image", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_IMAGE_ENDPOINT }, +}; + +const RIGHTCODE_IMAGE_MODEL_MAP = { + "gpt-image-2": "gpt-image-2", + "gpt-image-2-vip": "gpt-image-2-vip", + "nano-banana-fast": "nano-banana", + "nano-banana-2": "nano-banana-2", + "nano-banana-pro": "nano-banana-pro", +}; + +const KUAIKUAI_IMAGE_MODEL_MAP = { + "nano-banana-fast": "gemini-2.5-flash-image", + "nano-banana-2": "gemini-3.1-flash-image-preview", + "nano-banana-pro": "gemini-3-pro-image-preview", + "gpt-image-2": "gpt-image-2", + "gpt-image-2-vip": "gpt-image-2-vip", +}; + +const VIDEO_MODELS = { + "seedance-2": { provider: "seedance-2.0", protocol: "seed-video", baseUrl: SEEDANCE_BASE_URL, endpoint: "/v1/video/generations", model: "seed-2" }, + "seedance-2-fast": { provider: "seedance-2.0-fast", protocol: "seed-video", baseUrl: SEEDANCE_BASE_URL, endpoint: "/v1/video/generations", model: "seed-2-fast" }, + "seedance-2-official": { provider: "seedance-2.0-ark", protocol: "seed-video-ark", baseUrl: SEEDANCE_ARK_BASE_URL, endpoint: "/api/v3/contents/generations/tasks", model: "doubao-seedance-2-0-260128" }, + "seedance-2-fast-official": { provider: "seedance-2.0-fast-ark", protocol: "seed-video-ark", baseUrl: SEEDANCE_ARK_BASE_URL, endpoint: "/api/v3/contents/generations/tasks", model: "doubao-seedance-2-0-fast-260128" }, + "happyhorse-1.0-t2v": { provider: "dashscope-happyhorse-t2v", protocol: "happyhorse-t2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "happyhorse-1.0-t2v" }, + "happyhorse-1.0-i2v": { provider: "dashscope-happyhorse-i2v", protocol: "happyhorse-i2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "happyhorse-1.0-i2v" }, + "happyhorse-1.0-r2v": { provider: "dashscope-happyhorse-r2v", protocol: "happyhorse-r2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "happyhorse-1.0-r2v" }, + "wan2.7-i2v": { provider: "dashscope-wan-i2v", protocol: "wan-i2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "wan2.7-i2v" }, + "wan2.7-t2v": { provider: "dashscope-wan-t2v", protocol: "wan-t2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "wan2.7-t2v" }, + "wan2.2-s2v": { provider: "dashscope-wan-s2v", protocol: "wan-s2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_S2V_ENDPOINT, detectEndpoint: DASHSCOPE_S2V_DETECT_ENDPOINT, model: "wan2.2-s2v", detectModel: "wan2.2-s2v-detect" }, + "wan2.2-animate-mix": { provider: "dashscope-wan-animate", protocol: "wan-animate-mix", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_ANIMATE_ENDPOINT, model: "wan2.2-animate-mix" }, + "Kling-V3-Omni": { provider: "kling-official", protocol: "kling-omni", baseUrl: KLING_BASE_URL, endpoint: "/v1/videos/omni-video", model: "Kling-V3-Omni" }, + "kling-2-1": { provider: "kling-official", protocol: "kling-omni", baseUrl: KLING_BASE_URL, endpoint: "/v1/videos/omni-video", model: "Kling-V3-Omni" }, + "kling-3.0-dashscope": { provider: "dashscope-kling", protocol: "kling-dashscope", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "kling/kling-v3-omni-video-generation" }, + "Kling-V3-Omni-dashscope": { provider: "dashscope-kling", protocol: "kling-dashscope", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "kling/kling-v3-omni-video-generation" }, + "kling-v3-omni-dashscope": { provider: "dashscope-kling", protocol: "kling-dashscope", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "kling/kling-v3-omni-video-generation" }, + "kling/kling-v3-omni-video-generation": { provider: "dashscope-kling", protocol: "kling-dashscope", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "kling/kling-v3-omni-video-generation" }, + "vidu-q3-turbo-t2v": { provider: "dashscope-vidu-t2v", protocol: "vidu-t2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "vidu/viduq3-turbo_text2video" }, + "vidu-q3-turbo-i2v": { provider: "dashscope-vidu-i2v", protocol: "vidu-i2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "vidu/viduq3-turbo_img2video" }, + "pixverse-c1-t2v": { provider: "dashscope-pixverse-t2v", protocol: "pixverse-t2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "pixverse/pixverse-c1-t2v" }, + "pixverse-c1-i2v": { provider: "dashscope-pixverse-i2v", protocol: "pixverse-i2v", baseUrl: DASHSCOPE_BASE_URL, endpoint: DASHSCOPE_VIDEO_ENDPOINT, model: "pixverse/pixverse-c1-it2v" }, +}; + +const TEXT_MODELS = { + "gemini-3.1-pro": { provider: "grsai", transport: "openai-chat", baseUrl: GRSAI_TEXT_BASE_URL, endpoint: "/v1/chat/completions" }, + "gemini-2.5-pro": { provider: "grsai", transport: "openai-chat", baseUrl: GRSAI_TEXT_BASE_URL, endpoint: "/v1/chat/completions" }, + "gpt-4o": { provider: "grsai", transport: "openai-chat", baseUrl: GRSAI_TEXT_BASE_URL, endpoint: "/v1/chat/completions" }, +}; + +const DASHSCOPE_TEXT_PREFIXES = ["qwen", "qwq", "deepseek-"]; +const DASHSCOPE_TEXT_CONFIG = { provider: "dashscope-text", transport: "dashscope-chat", baseUrl: DASHSCOPE_BASE_URL, endpoint: "/compatible-mode/v1/chat/completions" }; + +function normalizeModel(model, fallback) { + return String(model || fallback || "").trim(); +} + +function withImageBillingProvider(config, billingProvider) { + return { ...config, billingProvider }; +} + +function createUnsupportedImageModelError(model) { + const error = new Error(`Unsupported image model: ${model}`); + error.status = 400; + return error; +} + +function resolveImageProvider(model) { + const requestedModel = normalizeModel(model, "nano-banana-pro"); + const lower = requestedModel.toLowerCase(); + const config = IMAGE_MODELS[requestedModel] || IMAGE_MODELS[lower]; + if (config) return { ...config, model: requestedModel, requestedModel }; + + throw createUnsupportedImageModelError(requestedModel); +} + +function resolveImageProviderCandidates(model) { + const primary = resolveImageProvider(model); + const billingProvider = primary.billingProvider || primary.provider; + if (primary.transport !== "grsai-image" || primary.provider !== "grsai") { + return [withImageBillingProvider(primary, billingProvider)]; + } + + const rightcodeModel = RIGHTCODE_IMAGE_MODEL_MAP[String(primary.requestedModel || primary.model || "").toLowerCase()]; + const fallbackModel = rightcodeModel || primary.requestedModel || primary.model; + const kuaikuaiModel = KUAIKUAI_IMAGE_MODEL_MAP[String(primary.requestedModel || primary.model || "").toLowerCase()] || primary.requestedModel || primary.model; + const modelKey = String(primary.requestedModel || primary.model || "").toLowerCase(); + const isGptModel = modelKey.startsWith("gpt"); + + return [ + withImageBillingProvider(primary, billingProvider), + { + ...primary, + provider: "rightcode", + transport: "rightcode-image", + baseUrl: RIGHTCODE_IMAGE_BASE_URL, + endpoint: RIGHTCODE_IMAGE_ENDPOINT, + model: fallbackModel, + billingProvider, + fallbackOf: primary.provider, + }, + { + ...primary, + provider: isGptModel ? "kuaikuai-gpt" : "kuaikuai-nano", + transport: isGptModel ? "openai-image" : "gemini-image", + baseUrl: KUAIKUAI_IMAGE_BASE_URL, + endpoint: isGptModel ? KUAIKUAI_OPENAI_ENDPOINT : KUAIKUAI_GEMINI_ENDPOINT, + model: kuaikuaiModel, + billingProvider, + fallbackOf: primary.provider, + }, + ]; +} + +function sanitizeImageProviderCandidate(config) { + return { + provider: config.provider, + transport: config.transport, + model: config.model, + requestedModel: config.requestedModel, + billingProvider: config.billingProvider, + fallbackOf: config.fallbackOf, + }; +} + +function buildImageProviderDebug(model) { + const candidates = resolveImageProviderCandidates(model).map(sanitizeImageProviderCandidate); + return { + requestedModel: candidates[0]?.requestedModel || normalizeModel(model, "nano-banana-pro"), + effectiveModel: candidates[0]?.model, + primaryProvider: candidates[0]?.provider, + fallbackProviders: candidates.slice(1).map((candidate) => candidate.provider).filter(Boolean), + route: candidates.map((candidate) => candidate.provider).filter(Boolean), + candidates, + }; +} + +function resolveVideoProvider(model) { + const requestedModel = normalizeModel(model, "seedance-2"); + const lower = requestedModel.toLowerCase(); + const config = VIDEO_MODELS[requestedModel] || VIDEO_MODELS[lower]; + if (config) return { ...config, requestedModel }; + + if (lower.includes("happyhorse") && lower.includes("i2v")) { + return { ...VIDEO_MODELS["happyhorse-1.0-i2v"], model: requestedModel, requestedModel }; + } + if (lower.includes("happyhorse") && lower.includes("r2v")) { + return { ...VIDEO_MODELS["happyhorse-1.0-r2v"], model: requestedModel, requestedModel }; + } + if (lower.includes("happyhorse")) { + return { ...VIDEO_MODELS["happyhorse-1.0-t2v"], model: requestedModel, requestedModel }; + } + if (lower.startsWith("wan2.") && lower.includes("i2v")) { + return { ...VIDEO_MODELS["wan2.7-i2v"], model: requestedModel, requestedModel }; + } + if (lower.startsWith("wan2.")) { + return { ...VIDEO_MODELS["wan2.7-t2v"], model: requestedModel, requestedModel }; + } + if (lower.startsWith("kling/")) { + return { ...VIDEO_MODELS["kling/kling-v3-omni-video-generation"], model: requestedModel, requestedModel }; + } + if (lower.includes("kling")) { + return { ...VIDEO_MODELS["Kling-V3-Omni"], model: requestedModel, requestedModel }; + } + if (lower.includes("vidu") && lower.includes("i2v")) { + return { ...VIDEO_MODELS["vidu-q3-turbo-i2v"], model: requestedModel, requestedModel }; + } + if (lower.includes("vidu")) { + return { ...VIDEO_MODELS["vidu-q3-turbo-t2v"], model: requestedModel, requestedModel }; + } + if (lower.includes("pixverse") && lower.includes("i2v")) { + return { ...VIDEO_MODELS["pixverse-c1-i2v"], model: requestedModel, requestedModel }; + } + if (lower.includes("pixverse")) { + return { ...VIDEO_MODELS["pixverse-c1-t2v"], model: requestedModel, requestedModel }; + } + const error = new Error(`不支持的视频模型: ${requestedModel}`); + error.status = 400; + throw error; +} + +function resolveTextProvider(model) { + const m = normalizeModel(model, "gemini-3.1-pro").toLowerCase(); + if (DASHSCOPE_TEXT_PREFIXES.some((p) => m.startsWith(p))) { + return { ...DASHSCOPE_TEXT_CONFIG, model: m, requestedModel: m }; + } + const config = TEXT_MODELS[m]; + if (config) return { ...config, model: m, requestedModel: m }; + return { ...TEXT_MODELS["gemini-3.1-pro"], model: "gemini-3.1-pro", requestedModel: m }; +} + +function listKnownRoutes() { + return [ + ...Object.keys(IMAGE_MODELS).flatMap((model) => + resolveImageProviderCandidates(model).map((config) => ({ type: "image", ...config })), + ), + ...Object.keys(VIDEO_MODELS).map((model) => ({ type: "video", ...resolveVideoProvider(model) })), + ...Object.keys(TEXT_MODELS).map((model) => ({ type: "text", ...resolveTextProvider(model) })), + { type: "text", ...resolveTextProvider("qwen-plus") }, + ]; +} + +function getPostUrl(config) { + const base = `${config.baseUrl || ""}`; + const endpoint = String(config.endpoint || "").replace("{model}", encodeURIComponent(config.model || "")); + return `${base}${endpoint}`; +} + +function getPollUrl(config) { + if (config.transport === "dashscope-image") return `${DASHSCOPE_BASE_URL}/api/v1/tasks/{task_id}`; + if (config.transport === "grsai-image") return `${config.baseUrl}${config.resultEndpoint || GRSAI_RESULT_ENDPOINT}?id={task_id}`; + if (config.transport === "rightcode-image" || config.transport === "gemini-image" || config.transport === "openai-image") return ""; + if ( + config.protocol === "wan-i2v" || + config.protocol === "wan-t2v" || + config.protocol === "wan-s2v" || + config.protocol === "wan-animate-mix" || + config.protocol === "kling-dashscope" || + String(config.protocol || "").startsWith("happyhorse-") || + String(config.protocol || "").startsWith("vidu-") || + String(config.protocol || "").startsWith("pixverse-") + ) return `${config.baseUrl}/api/v1/tasks/{task_id}`; + if (config.protocol === "kling-omni") return `${config.baseUrl}/v1/videos/omni-video/{task_id}`; + if (config.protocol === "seed-video" || config.protocol === "seed-video-ark") return `${config.baseUrl}${config.endpoint}/{task_id}`; + return ""; +} + +module.exports = { + resolveImageProvider, + resolveImageProviderCandidates, + buildImageProviderDebug, + resolveVideoProvider, + resolveTextProvider, + listKnownRoutes, + getPostUrl, + getPollUrl, + IMAGE_MODELS, + VIDEO_MODELS, + TEXT_MODELS, +}; diff --git a/src/aiTaskWorker.js b/src/aiTaskWorker.js new file mode 100644 index 0000000..ded2557 --- /dev/null +++ b/src/aiTaskWorker.js @@ -0,0 +1,783 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { EventEmitter } = require("node:events"); +const { pool } = require("./db"); +const { refundTaskBillingOnFailure } = require("./billing"); +const { putObject, isOssConfigured } = require("./ossClient"); + +const taskEvents = new EventEmitter(); +taskEvents.setMaxListeners(200); + +const activePollers = new Map(); +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 120; +const GRS_IMAGE_MAX_POLL_ATTEMPTS = Number(process.env.GRSAI_IMAGE_MAX_POLL_ATTEMPTS || 60); + +function normalizeTaskProgress(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return undefined; + return Math.max(0, Math.min(100, Math.round(numeric))); +} + +function formatTaskEvent(row) { + return { + taskId: row.id, + status: row.status, + progress: row.progress, + resultUrl: row.result_url || null, + error: row.error || null, + }; +} + +async function createTaskLifecycleNotification(task) { + if (!task || !task.user_id || !task.id) return; + + const isCompleted = task.status === "completed"; + const isFailed = task.status === "failed"; + if (!isCompleted && !isFailed) return; + + const typeLabel = task.type === "video" ? "视频" : "图像"; + const noticeType = isCompleted ? "task_completed" : "task_failed"; + const title = isCompleted ? `${typeLabel}生成已完成` : `${typeLabel}生成失败`; + const description = isCompleted + ? "生成结果已同步到任务历史,可继续编辑或保存到资产库。" + : String(task.error || "任务处理失败,请稍后重试。").slice(0, 500); + + await pool.query( + ` + INSERT INTO web_notifications ( + user_id, type, title, description, target_type, target_id, metadata_json + ) + SELECT $1::integer, $2::varchar, $3::varchar, $4::text, 'generation_task', $5::varchar, $6::text + WHERE NOT EXISTS ( + SELECT 1 + FROM web_notifications + WHERE user_id = $1 + AND type = $2::varchar + AND target_type = 'generation_task' + AND target_id = $5::varchar + ) + `, + [ + task.user_id, + noticeType, + title, + description, + String(task.id), + JSON.stringify({ taskType: task.type, resultUrl: task.result_url || null }), + ], + ); +} + +async function updateTaskInDb(taskId, updates) { + const nextUpdates = { ...updates }; + + const fields = []; + const values = []; + let idx = 1; + + if (nextUpdates.status !== undefined) { fields.push(`status = $${idx++}`); values.push(nextUpdates.status); } + if (nextUpdates.progress !== undefined) { + const progress = normalizeTaskProgress(nextUpdates.progress); + if (progress !== undefined) { fields.push(`progress = $${idx++}`); values.push(progress); } + } + if (nextUpdates.resultUrl !== undefined) { fields.push(`result_url = $${idx++}`); values.push(nextUpdates.resultUrl); } + if (nextUpdates.error !== undefined) { fields.push(`error = $${idx++}`); values.push(nextUpdates.error); } + if (nextUpdates.providerTaskId !== undefined) { fields.push(`provider_task_id = $${idx++}`); values.push(nextUpdates.providerTaskId); } + if (nextUpdates.status === "completed" || nextUpdates.status === "failed") { + fields.push("completed_at = NOW()"); + } + fields.push("updated_at = NOW()"); + + if (fields.length === 0) return; + values.push(taskId); + const { rows } = await pool.query( + `UPDATE generation_tasks SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, + values, + ); + let updatedTask = rows[0]; + + if (updatedTask) { + taskEvents.emit(`task:${taskId}`, formatTaskEvent(updatedTask)); + } + + if (nextUpdates.status === "completed" && updatedTask?.result_url) { + persistTaskResultUrlToOssInBackground(updatedTask); + } + + if (nextUpdates.status === "completed" || nextUpdates.status === "failed") { + await createTaskLifecycleNotification(updatedTask).catch((err) => { + console.error(`[aiTaskWorker] notification error for task ${taskId}:`, err.message); + }); + } + + if (nextUpdates.status === "failed") { + await refundTaskBillingOnFailure(taskId).catch((err) => { + console.error(`[aiTaskWorker] refund error for task ${taskId}:`, err.message); + }); + } +} + +function persistTaskResultUrlToOssInBackground(task) { + if (!task?.id || !task?.result_url) return; + + Promise.resolve() + .then(async () => { + const durableUrl = await persistResultUrlToOss(task); + if (!durableUrl || durableUrl === task.result_url) return; + + await pool.query( + "UPDATE generation_tasks SET result_url = $1, updated_at = NOW() WHERE id = $2 AND result_url = $3", + [durableUrl, task.id, task.result_url], + ); + console.info(`[aiTaskWorker] task ${task.id} result persisted to OSS after completion`); + }) + .catch((error) => { + console.warn(`[aiTaskWorker] background result persistence failed for task ${task.id}:`, error.message); + }); +} + +function asObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; +} + +function asString(value) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function firstString(...values) { + for (const value of values) { + const stringValue = asString(value); + if (stringValue) return stringValue; + } + return undefined; +} + +function mediaExtensionFromContentType(contentType, fallbackUrl, taskType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + + try { + const matched = new URL(fallbackUrl).pathname.match(/\.([a-z0-9]{2,5})$/i); + if (matched?.[1]) return matched[1].toLowerCase(); + } catch { + // Keep the type fallback below. + } + + return taskType === "video" ? "mp4" : "png"; +} + +function isErrorDocumentContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function isOwnPersistedResultUrl(value) { + return /\/users\/[^/]+\/generation-results\//i.test(String(value || "")); +} + +async function persistIncomingResultUrl(taskId, resultUrl) { + const normalizedUrl = String(resultUrl || "").trim(); + if (!/^https?:\/\//i.test(normalizedUrl) || isOwnPersistedResultUrl(normalizedUrl) || !isOssConfigured()) { + return null; + } + + try { + const { rows } = await pool.query( + "SELECT id, user_id, type, result_url FROM generation_tasks WHERE id = $1", + [taskId], + ); + const task = rows[0]; + if (!task?.user_id || !task?.type) return null; + return persistResultUrlToOss({ ...task, result_url: normalizedUrl }); + } catch (error) { + console.warn(`[aiTaskWorker] result pre-persistence skipped for task ${taskId}:`, error.message); + return null; + } +} + +async function persistResultUrlToOss(task) { + const resultUrl = String(task?.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl) || isOwnPersistedResultUrl(resultUrl) || !isOssConfigured()) { + return null; + } + + try { + const response = await fetch(resultUrl, { method: "GET" }); + if (!response.ok) { + throw new Error(`result fetch returned ${response.status}`); + } + + const contentType = response.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorDocumentContentType(contentType)) { + const text = await response.text().catch(() => ""); + throw new Error(`result fetch returned error document: ${text.slice(0, 120)}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (!buffer.length) { + throw new Error("result fetch returned empty content"); + } + + const safeUserId = String(task.user_id).replace(/[^a-zA-Z0-9_-]/g, ""); + const extension = mediaExtensionFromContentType(contentType, resultUrl, task.type); + const objectKey = `users/${safeUserId}/generation-results/${task.id}-${Date.now()}-${crypto.randomUUID()}.${extension}`; + const uploaded = await putObject(objectKey, buffer, contentType, { "x-oss-object-acl": "public-read" }); + return uploaded.url; + } catch (error) { + console.warn(`[aiTaskWorker] result persistence skipped for task ${task?.id}:`, error.message); + return null; + } +} + +function normalizeImageResultValue(value) { + const stringValue = firstString(value); + if (!stringValue) return undefined; + if (/^(https?:)?\/\//i.test(stringValue) || /^data:image\//i.test(stringValue)) return stringValue; + const markdownImageMatch = stringValue.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i); + if (markdownImageMatch?.[1]) return markdownImageMatch[1]; + const urlMatch = stringValue.match(/https?:\/\/[^\s"'<>))]+/i); + if (urlMatch?.[0]) return urlMatch[0]; + try { + const parsed = JSON.parse(stringValue); + const parsedResult = firstImageResult(parsed); + if (parsedResult) return parsedResult; + } catch {} + if (/^[A-Za-z0-9+/]+={0,2}$/.test(stringValue) && stringValue.length > 128) { + return `data:image/png;base64,${stringValue}`; + } + return undefined; +} + +function firstImageResult(...values) { + for (const value of values) { + if (Array.isArray(value)) { + for (const item of value) { + const result = firstImageResult(item); + if (result) return result; + } + continue; + } + if (value && typeof value === "object") { + const result = firstImageResult( + value.url, + value.image_url, + value.imageUrl, + value.result_url, + value.resultUrl, + value.output_url, + value.outputUrl, + value.b64_image, + value.b64_json, + value.base64, + value.image, + value.content, + value.text, + value.message, + ); + if (result) return result; + continue; + } + const result = normalizeImageResultValue(value); + if (result) return result; + } + return undefined; +} + +function pickDeep(value, keys) { + if (!value || typeof value !== "object") return undefined; + const obj = value; + for (const key of keys) { + const direct = firstString(obj[key]); + if (direct) return direct; + } + for (const child of Object.values(obj)) { + if (child && typeof child === "object") { + const nested = pickDeep(child, keys); + if (nested) return nested; + } + } + return undefined; +} + +function normalizeStatus(value) { + return String(value || "").trim().toLowerCase(); +} + +function isCompletedStatus(status) { + return ["completed", "complete", "succeeded", "success", "succeed", "done", "finished", "successed"].includes(status); +} + +function isFailedStatus(status) { + return ["failed", "failure", "fail", "canceled", "cancelled", "expired", "error", "violation"].includes(status); +} + +function extractProviderTaskId(json) { + const data = asObject(json?.data) || json; + const output = asObject(json?.output) || asObject(data?.output); + return firstString( + output?.task_id, + output?.taskId, + data?.task_id, + data?.taskId, + data?.id, + json?.task_id, + json?.taskId, + json?.id, + ); +} + +function extractImageUrl(json) { + const rawData = json?.data; + const data = asObject(rawData) || json; + const rawOutput = json?.output ?? data?.output; + const output = asObject(rawOutput) || asObject(data?.output); + const rawResult = data?.result ?? json?.result ?? output?.result; + const result = asObject(rawResult); + const choices = + (Array.isArray(output?.choices) && output.choices) || + (Array.isArray(data?.choices) && data.choices) || + (Array.isArray(json?.choices) && json.choices) || + []; + const firstChoice = asObject(choices[0]); + const message = asObject(firstChoice?.message); + const content = Array.isArray(message?.content) ? message.content : []; + const firstContent = asObject(content[0]); + const outputResults = Array.isArray(output?.results) ? output.results : []; + const topLevelResults = Array.isArray(json?.results) ? json.results : []; + const dataResults = Array.isArray(data?.results) ? data.results : []; + const resultResults = Array.isArray(result?.results) ? result.results : []; + const dataImages = Array.isArray(data?.images) ? data.images : []; + const dataImageUrls = Array.isArray(data?.image_urls) ? data.image_urls : []; + const dataUrls = Array.isArray(data?.urls) ? data.urls : []; + const outputImages = Array.isArray(output?.images) ? output.images : []; + const outputImageUrls = Array.isArray(output?.image_urls) ? output.image_urls : []; + const resultImages = Array.isArray(result?.images) ? result.images : []; + const resultUrls = Array.isArray(result?.urls) ? result.urls : []; + const candidates = [ + ...topLevelResults, + ...dataResults, + ...outputResults, + ...resultResults, + ...dataImages, + ...dataImageUrls, + ...dataUrls, + ...outputImages, + ...outputImageUrls, + ...resultImages, + ...resultUrls, + ]; + + return firstImageResult( + rawData, + rawOutput, + rawResult, + firstContent?.image, + firstContent?.image_url, + firstContent?.image_url?.url, + message?.content, + firstChoice?.delta?.content, + candidates, + data?.image_url, + data?.imageUrl, + data?.result_url, + data?.resultUrl, + data?.output_url, + data?.outputUrl, + output?.image_url, + output?.imageUrl, + output?.result_url, + output?.resultUrl, + output?.output_url, + output?.outputUrl, + result?.image_url, + result?.imageUrl, + result?.result_url, + result?.resultUrl, + result?.output_url, + result?.outputUrl, + pickDeep(json, ["image", "image_url", "imageUrl", "result_url", "resultUrl", "output_url", "outputUrl", "url", "b64_image", "b64_json", "base64"]), + ); +} + +function extractGeminiImageUrl(json) { + // Gemini response: candidates[].content.parts[].inlineData (base64) or text (URL) + const candidates = Array.isArray(json?.candidates) ? json.candidates : []; + for (const candidate of candidates) { + const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : []; + for (const part of parts) { + const inlineData = part?.inlineData; + if (inlineData?.data) { + const mimeType = inlineData.mimeType || "image/png"; + return `data:${mimeType};base64,${inlineData.data}`; + } + } + } + // Also check for direct URL in candidate text + for (const candidate of candidates) { + const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : []; + for (const part of parts) { + if (part?.text && /^https?:\/\/.+\.(png|jpg|jpeg|webp|gif)/i.test(part.text)) { + return part.text; + } + } + } + return null; +} + +function extractVideoUrl(json) { + const data = asObject(json?.data) || json; + const output = asObject(json?.output) || asObject(data?.output); + const result = asObject(data?.task_result) || asObject(json?.task_result) || asObject(output?.task_result); + const videos = Array.isArray(result?.videos) ? result.videos : []; + const firstVideo = asObject(videos[0]); + + return firstString( + output?.video_url, + output?.output_video_url, + output?.outputVideoUrl, + output?.watermark_video_url, + data?.video_url, + data?.videoUrl, + data?.output_video_url, + data?.outputVideoUrl, + data?.url, + data?.result?.url, + firstVideo?.url, + firstVideo?.video_url, + pickDeep(json, ["video_url", "output_video_url", "outputVideoUrl", "watermark_video_url", "videoUrl", "download_url", "downloadUrl", "content_url", "contentUrl", "url"]), + ); +} + +function extractErrorMessage(json, fallback) { + const data = asObject(json?.data) || {}; + const output = asObject(json?.output) || {}; + const error = asObject(json?.error) || {}; + return firstString( + output.message, + output.code, + data.task_status_msg, + data.failure_reason, + data.message, + error.message, + error.error, + json?.message, + json?.error, + ) || fallback; +} + +async function fetchJson(url, headers) { + const res = await fetch(url, { method: "GET", headers }); + if (!res.ok) return { ok: false, json: null }; + return { ok: true, json: await res.json() }; +} + +async function pollGrsaiImage(_taskId, providerTaskId, apiKey, baseUrl, resultEndpoint) { + const url = `${baseUrl}${resultEndpoint}?id=${encodeURIComponent(providerTaskId)}`; + const { ok, json } = await fetchJson(url, { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }); + if (!ok) { + console.warn(`[grsai-poll] task ${_taskId} fetch not ok, url=${url}`); + return { status: "running", progress: 50 }; + } + + const data = asObject(json?.data) || json; + const output = asObject(data?.output) || asObject(json?.output) || {}; + const status = normalizeStatus( + output.task_status || + output.status || + data.task_status || + data.status || + data.state || + json?.task_status || + json?.status || + json?.state, + ); + const resultUrl = extractImageUrl(json); + console.info(`[grsai-poll] task ${_taskId} status=${status} resultUrl=${resultUrl ? "yes" : "no"} raw=${JSON.stringify(json).slice(0, 300)}`); + if (resultUrl) { + return { status: "completed", progress: 100, resultUrl }; + } + if (isCompletedStatus(status)) { + const completedUrl = extractImageUrl(json); + if (!completedUrl) return { status: "failed", error: "Image generation completed without a result url" }; + return { status: "completed", progress: 100, resultUrl: completedUrl }; + } + if (isFailedStatus(status)) { + return { status: "failed", error: extractErrorMessage(json, "Image generation failed") }; + } + return { status: "running", progress: Math.min(90, 30 + Math.random() * 40) }; +} + +async function pollDashscopeImage(_taskId, providerTaskId, apiKey) { + const url = `https://dashscope.aliyuncs.com/api/v1/tasks/${encodeURIComponent(providerTaskId)}`; + const { ok, json } = await fetchJson(url, { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }); + if (!ok) return { status: "running", progress: 50 }; + + const output = asObject(json?.output) || {}; + const status = normalizeStatus(output.task_status || json?.task_status); + const resultUrl = extractImageUrl(json); + if (isCompletedStatus(status)) { + if (!resultUrl) return { status: "failed", error: "DashScope image generation completed without a result url" }; + return { status: "completed", progress: 100, resultUrl }; + } + if (isFailedStatus(status)) { + return { status: "failed", error: extractErrorMessage(json, "DashScope image generation failed") }; + } + return { status: "running", progress: Math.min(90, 30 + Math.random() * 40) }; +} + +function base64Url(input) { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function parseKlingCredential(apiKey) { + const raw = String(apiKey || ""); + const colonIndex = raw.indexOf(":"); + if (colonIndex <= 0) return null; + const accessKey = raw.slice(0, colonIndex).trim(); + const secretKey = raw.slice(colonIndex + 1).trim(); + return accessKey && secretKey ? { accessKey, secretKey } : null; +} + +function createKlingJwt(accessKey, secretKey) { + const header = { alg: "HS256", typ: "JWT" }; + const now = Math.floor(Date.now() / 1000); + const payload = { iss: accessKey, exp: now + 1800, nbf: now - 5 }; + const unsigned = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`; + const signature = crypto.createHmac("sha256", secretKey).update(unsigned).digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${unsigned}.${signature}`; +} + +function getPollRequest(providerTaskId, apiKey, providerConfig) { + const protocol = providerConfig.protocol; + const baseUrl = providerConfig.baseUrl || ""; + + if ( + protocol === "wan-i2v" || + protocol === "wan-s2v" || + protocol === "kling-dashscope" || + String(protocol || "").startsWith("happyhorse-") + ) { + return { + url: `${baseUrl}/api/v1/tasks/${encodeURIComponent(providerTaskId)}`, + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }; + } + + if (protocol === "kling-omni") { + const credential = parseKlingCredential(apiKey); + return { + url: `${baseUrl}/v1/videos/omni-video/${encodeURIComponent(providerTaskId)}`, + headers: { + Authorization: `Bearer ${credential ? createKlingJwt(credential.accessKey, credential.secretKey) : apiKey}`, + Accept: "application/json", + }, + }; + } + + return { + url: `${baseUrl}${providerConfig.endpoint}/${encodeURIComponent(providerTaskId)}`, + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }; +} + +async function pollVideoTask(_taskId, providerTaskId, apiKey, providerConfig) { + const { url, headers } = getPollRequest(providerTaskId, apiKey, providerConfig); + const { ok, json } = await fetchJson(url, headers); + if (!ok) return { status: "running", progress: 50 }; + + const data = asObject(json?.data) || json; + const output = asObject(json?.output) || {}; + const status = normalizeStatus( + output.task_status || + data.task_status || + data.status || + json?.task_status || + json?.status, + ); + + const resultUrl = extractVideoUrl(json); + if (isCompletedStatus(status) || resultUrl) { + return { status: "completed", progress: 100, resultUrl: resultUrl || null }; + } + if (isFailedStatus(status)) { + return { status: "failed", error: extractErrorMessage(json, "Video generation failed") }; + } + const progress = Number(data.progress || output.progress); + return { status: "running", progress: Number.isFinite(progress) ? Math.min(95, progress) : Math.min(90, 30 + Math.random() * 30) }; +} + +function getMaxPollAttempts(type, providerConfig) { + if (type === "image" && providerConfig?.transport === "grsai-image") { + return Number.isFinite(GRS_IMAGE_MAX_POLL_ATTEMPTS) && GRS_IMAGE_MAX_POLL_ATTEMPTS > 0 + ? Math.trunc(GRS_IMAGE_MAX_POLL_ATTEMPTS) + : 40; + } + if (type === "video") return 400; + return MAX_POLL_ATTEMPTS; +} + +function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig, leaseToken, keyManager, onTaskFailed }) { + if (activePollers.has(taskDbId)) return; + + let attempts = 0; + const maxPollAttempts = getMaxPollAttempts(type, providerConfig); + const interval = setInterval(async () => { + attempts++; + if (attempts > maxPollAttempts) { + clearInterval(interval); + activePollers.delete(taskDbId); + if (leaseToken && keyManager) await keyManager.releaseKey(leaseToken).catch(() => {}); + if (typeof onTaskFailed === "function") { + const handled = await onTaskFailed("Task timed out").catch((fallbackErr) => { + console.error(`[aiTaskWorker] fallback error for task ${taskDbId}:`, fallbackErr.message); + return false; + }); + if (handled) return; + } + await updateTaskInDb(taskDbId, { status: "failed", error: "Task timed out" }); + return; + } + + try { + // Check if task was cancelled by user + const { rows: [taskRow] } = await pool.query("SELECT status FROM generation_tasks WHERE id = $1", [taskDbId]); + if (!taskRow || taskRow.status === "cancelled") { + clearInterval(interval); + activePollers.delete(taskDbId); + if (leaseToken && keyManager) await keyManager.releaseKey(leaseToken).catch(() => {}); + return; + } + + let result; + if (type === "image") { + if (providerConfig.transport === "dashscope-image") { + result = await pollDashscopeImage(taskDbId, providerTaskId, apiKey); + } else { + result = await pollGrsaiImage(taskDbId, providerTaskId, apiKey, providerConfig.baseUrl, providerConfig.resultEndpoint || "/result"); + } + } else { + result = await pollVideoTask(taskDbId, providerTaskId, apiKey, providerConfig); + } + + if (result.status === "completed" || result.status === "failed") { + clearInterval(interval); + activePollers.delete(taskDbId); + if (leaseToken && keyManager) await keyManager.releaseKey(leaseToken).catch(() => {}); + if (result.status === "failed" && typeof onTaskFailed === "function") { + const handled = await onTaskFailed(result.error || "Task failed").catch((fallbackErr) => { + console.error(`[aiTaskWorker] fallback error for task ${taskDbId}:`, fallbackErr.message); + return false; + }); + if (handled) return; + } + } + + await updateTaskInDb(taskDbId, result); + } catch (err) { + console.error(`[aiTaskWorker] poll error for task ${taskDbId}:`, err.message); + } + }, POLL_INTERVAL_MS); + + activePollers.set(taskDbId, { interval, leaseToken }); +} + +function stopPolling(taskDbId) { + const poller = activePollers.get(taskDbId); + if (poller) { + clearInterval(poller.interval); + activePollers.delete(taskDbId); + } +} + +function getActiveCount() { + return activePollers.size; +} + +// --- Periodic stale task cleanup --- +// Runs every 5 minutes, marks tasks stuck in 'pending'/'running' for too long as 'failed'. +// This catches cases where the worker crashed, the provider API never responded, +// or the cancel request failed silently on the client side. +const STALE_TASK_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +let staleTaskCleanupTimer = null; + +async function runStaleTaskCleanup() { + try { + const { rows } = await pool.query( + `UPDATE generation_tasks + SET status = 'failed', error = '任务超时自动释放', updated_at = NOW() + WHERE status IN ('pending', 'running') + AND GREATEST(updated_at, COALESCE(last_poll_at, created_at)) < NOW() - INTERVAL '10 minutes' + RETURNING id`, + ); + for (const row of rows) { + taskEvents.emit(`task:${row.id}`, { + taskId: row.id, + status: "failed", + progress: null, + resultUrl: null, + error: "任务超时自动释放", + }); + // Also stop any active poller for this task + const poller = activePollers.get(row.id); + if (poller) { + clearInterval(poller.timer); + activePollers.delete(row.id); + } + } + if (rows.length > 0) { + console.log(`[aiTaskWorker] Cleaned up ${rows.length} stale task(s)`); + } + } catch (err) { + console.error("[aiTaskWorker] Stale task cleanup failed:", err.message); + } +} + +function startStaleTaskCleanup() { + if (staleTaskCleanupTimer) return; + staleTaskCleanupTimer = setInterval(runStaleTaskCleanup, STALE_TASK_CLEANUP_INTERVAL_MS); + // Run once shortly after startup + setTimeout(runStaleTaskCleanup, 10_000); +} + +function stopStaleTaskCleanup() { + if (staleTaskCleanupTimer) { + clearInterval(staleTaskCleanupTimer); + staleTaskCleanupTimer = null; + } +} + +module.exports = { + startPolling, + stopPolling, + updateTaskInDb, + getActiveCount, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, + taskEvents, + startStaleTaskCleanup, + stopStaleTaskCleanup, +}; diff --git a/src/aiUpscaleHelpers.js b/src/aiUpscaleHelpers.js new file mode 100644 index 0000000..a6ea5d5 --- /dev/null +++ b/src/aiUpscaleHelpers.js @@ -0,0 +1,72 @@ +"use strict"; + +function normalizeImageUpscaleFactor(value) { + const raw = String(value || "").trim().toLowerCase().replace(/x$/, ""); + const factor = Number(raw); + return factor === 4 ? 4 : 2; +} + +function normalizeVideoStyle(value) { + const style = Number(value); + return Number.isInteger(style) && style >= 0 && style <= 7 ? style : 0; +} + +function normalizeVideoFps(value) { + const fps = Number(value); + if (!Number.isFinite(fps)) return 15; + return Math.max(15, Math.min(25, Math.round(fps))); +} + +function normalizeVideoMinLen(value) { + const minLen = Number(value); + return minLen === 540 ? 540 : 720; +} + +function normalizeVideoStyleTransformOptions(params = {}) { + return { + style: normalizeVideoStyle(params.style), + videoFps: normalizeVideoFps(params.videoFps ?? params.video_fps), + minLen: normalizeVideoMinLen(params.minLen ?? params.min_len), + useSR: params.useSR === false || params.use_SR === false ? false : true, + animateEmotion: params.animateEmotion === false || params.animate_emotion === false ? false : true, + }; +} + +function buildDashscopeImageSuperResolveBody(params = {}) { + return { + model: "wanx2.1-imageedit", + input: { + function: "super_resolution", + prompt: "图像超分。", + base_image_url: params.imageUrl, + }, + parameters: { + upscale_factor: normalizeImageUpscaleFactor(params.scale ?? params.upscaleFactor), + n: 1, + }, + }; +} + +function buildDashscopeVideoStyleTransformBody(params = {}) { + const options = normalizeVideoStyleTransformOptions(params); + return { + model: "video-style-transform", + input: { + video_url: params.videoUrl, + }, + parameters: { + style: options.style, + video_fps: options.videoFps, + animate_emotion: options.animateEmotion, + min_len: options.minLen, + use_SR: options.useSR, + }, + }; +} + +module.exports = { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +}; diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..0829e60 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,322 @@ +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); +const crypto = require("node:crypto"); +const { pool } = require("./db"); +const { getJwtSecret } = require("./securityConfig"); + +const JWT_SECRET = getJwtSecret(); +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; +const MAX_CONCURRENT_SESSIONS = 2; + +const USER_CONTEXT_SELECT = ` + SELECT + u.id, + u.username, + u.avatar_url, + u.bio, + u.profile_background_url, + u.email, + u.email_verified, + u.phone, + u.auth_provider, + u.current_session_id, + u.current_session_started_at, + u.role, + u.max_concurrency, + u.enabled, + u.enterprise_id, + u.is_enterprise_admin, + u.balance_cents AS user_balance_cents, + u.billing_mode, + u.beta_expires_at, + e.name AS enterprise_name, + e.enabled AS enterprise_enabled, + e.balance_cents AS enterprise_balance_cents, + e.enterprise_code, + e.admin_user_id AS enterprise_admin_user_id, + COALESCE( + em.role, + CASE + WHEN u.is_enterprise_admin = 1 THEN 'admin' + WHEN u.enterprise_id IS NOT NULL THEN 'employee' + ELSE NULL + END + ) AS enterprise_role + FROM users u + LEFT JOIN enterprises e ON e.id = u.enterprise_id + LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id +`; + +function generateEnterpriseCode() { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let code = "ENT-"; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; +} + +async function generateUniqueEnterpriseCode() { + for (let attempt = 0; attempt < 10; attempt++) { + const code = generateEnterpriseCode(); + const { rows } = await pool.query("SELECT 1 FROM enterprises WHERE enterprise_code = $1", [ + code, + ]); + if (rows.length === 0) return code; + } + throw new Error("Failed to generate unique enterprise code"); +} + +async function toUserResponse(user) { + if (!user) return null; + + const enterpriseId = user.enterprise_id == null ? null : Number(user.enterprise_id); + const isEnterpriseAdmin = !!user.is_enterprise_admin; + const enterpriseRole = enterpriseId + ? (user.enterprise_role || (isEnterpriseAdmin ? "admin" : "employee")) + : undefined; + + const result = { + id: Number(user.id), + username: user.username, + avatarUrl: user.avatar_url || null, + bio: user.bio || null, + profileBackgroundUrl: user.profile_background_url || null, + email: user.email || null, + emailVerified: !!user.email_verified, + phone: user.phone || null, + authProvider: user.auth_provider || "password", + sessionId: user.current_session_id || null, + sessionStartedAt: user.current_session_started_at || null, + role: user.role, + maxConcurrency: Number(user.max_concurrency || 0), + enabled: !!user.enabled, + enterpriseId, + enterpriseName: user.enterprise_name || null, + isEnterpriseAdmin, + enterpriseRole, + enterpriseAdminUserId: + user.enterprise_admin_user_id == null ? null : Number(user.enterprise_admin_user_id), + accountType: enterpriseId ? "enterprise" : "personal", + balanceCents: user.user_balance_cents != null ? Number(user.user_balance_cents) : 0, + billingMode: user.billing_mode || "credits", + betaExpiresAt: user.beta_expires_at || null, + enterpriseCode: user.enterprise_code || null, + }; + + if (enterpriseId) { + result.enterpriseBalanceCents = + user.enterprise_balance_cents != null ? Number(user.enterprise_balance_cents) : 0; + + const now = new Date().toISOString(); + try { + const { rows: activePackages } = await pool.query( + ` + SELECT ep.remaining_image, ep.remaining_video, ep.remaining_text, ep.expires_at, p.name AS package_name + FROM enterprise_packages ep + JOIN packages p ON ep.package_id = p.id + WHERE ep.enterprise_id = $1 AND ep.expires_at > $2 + ORDER BY ep.expires_at ASC + `, + [enterpriseId, now], + ); + + result.activePackages = activePackages.map((pkg) => ({ + name: pkg.package_name, + expiresAt: pkg.expires_at, + remainingImage: pkg.remaining_image, + remainingVideo: pkg.remaining_video, + remainingText: pkg.remaining_text, + })); + } catch { + result.activePackages = []; + } + } + + return result; +} + +async function getUserContextById(userId) { + const { rows } = await pool.query(`${USER_CONTEXT_SELECT} WHERE u.id = $1 LIMIT 1`, [userId]); + return toUserResponse(rows[0]); +} + +function isSystemAdmin(user) { + return user?.role === "admin"; +} + +function isEnterpriseAdmin(user) { + return Boolean(user?.enterpriseId && user?.isEnterpriseAdmin); +} + +function generateToken(user, sessionId = user.sessionId) { + return jwt.sign( + { + userId: user.id, + sessionId, + username: user.username, + role: user.role, + enterpriseId: user.enterpriseId, + isEnterpriseAdmin: user.isEnterpriseAdmin, + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN }, + ); +} + +function verifyToken(token) { + return jwt.verify(token, JWT_SECRET); +} + +async function startUserSession(userId, userAgent) { + const sessionId = crypto.randomUUID(); + await pool.query( + "INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())", + [sessionId, userId, userAgent || null], + ); + await pool.query( + `DELETE FROM user_sessions + WHERE user_id = $1 + AND id NOT IN ( + SELECT id FROM user_sessions + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 + )`, + [userId, MAX_CONCURRENT_SESSIONS], + ); + await pool.query( + "UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2", + [sessionId, userId], + ); + return sessionId; +} + +async function clearUserSession(userId, sessionId) { + if (!userId || !sessionId) return; + await pool.query( + "DELETE FROM user_sessions WHERE user_id = $1 AND id = $2", + [userId, sessionId], + ); + await pool.query( + `UPDATE users + SET current_session_id = NULL, + current_session_started_at = NULL, + updated_at = NOW() + WHERE id = $1 AND current_session_id = $2`, + [userId, sessionId], + ); +} + +async function isSessionValid(userId, sessionId) { + if (!userId || !sessionId) return false; + const { rows } = await pool.query( + "SELECT 1 FROM user_sessions WHERE user_id = $1 AND id = $2 LIMIT 1", + [userId, sessionId], + ); + if (rows.length > 0) return true; + const { rows: legacy } = await pool.query( + "SELECT 1 FROM users WHERE id = $1 AND current_session_id = $2 LIMIT 1", + [userId, sessionId], + ); + if (legacy.length > 0) { + await pool.query( + "INSERT INTO user_sessions (id, user_id, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (id) DO NOTHING", + [sessionId, userId], + ); + return true; + } + return false; +} + +async function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return res.status(401).json({ error: "未登录" }); + } + + try { + const token = authHeader.slice(7); + const payload = verifyToken(token); + const user = await getUserContextById(payload.userId); + if (!user?.enabled) { + return res.status(403).json({ error: "账号已禁用" }); + } + const sessionOk = await isSessionValid(payload.userId, payload.sessionId); + if (!sessionOk) { + return res.status(401).json({ error: "您已在别处登录", code: "SESSION_REPLACED" }); + } + if (user.enterpriseId && user.enterpriseName == null) { + return res.status(403).json({ error: "企业信息不存在" }); + } + req.user = user; + req.auth = { token, sessionId: payload.sessionId }; + next(); + } catch { + return res.status(401).json({ error: "登录已过期" }); + } +} + +function requireAdmin(req, res, next) { + if (!isSystemAdmin(req.user)) { + return res.status(403).json({ error: "需要系统管理员权限" }); + } + next(); +} + +function requireEnterpriseAdmin(req, res, next) { + if (!isEnterpriseAdmin(req.user)) { + return res.status(403).json({ error: "需要企业管理员权限" }); + } + next(); +} + +function requireManagementAccess(req, res, next) { + if (!isSystemAdmin(req.user) && !isEnterpriseAdmin(req.user)) { + return res.status(403).json({ error: "需要管理权限" }); + } + next(); +} + +async function login(username, password, userAgent) { + const { rows } = await pool.query( + `${USER_CONTEXT_SELECT} WHERE u.username = $1 AND u.enabled = 1 LIMIT 1`, + [username], + ); + const user = rows[0]; + if (!user) return null; + + const { rows: pwRows } = await pool.query("SELECT password_hash FROM users WHERE id = $1", [ + user.id, + ]); + if (!pwRows[0] || !(await bcrypt.compare(password, pwRows[0].password_hash))) return null; + + const safeUser = await toUserResponse(user); + const sessionId = await startUserSession(safeUser.id, userAgent); + const userWithSession = { + ...safeUser, + sessionId, + sessionStartedAt: new Date().toISOString(), + }; + return { + token: generateToken(userWithSession, sessionId), + user: userWithSession, + }; +} + +module.exports = { + generateToken, + verifyToken, + startUserSession, + clearUserSession, + isSessionValid, + login, + getUserContextById, + isSystemAdmin, + isEnterpriseAdmin, + requireAuth, + requireAdmin, + requireEnterpriseAdmin, + requireManagementAccess, + generateUniqueEnterpriseCode, +}; diff --git a/src/betaInviteCodes.js b/src/betaInviteCodes.js new file mode 100644 index 0000000..10775ec --- /dev/null +++ b/src/betaInviteCodes.js @@ -0,0 +1,138 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + ENTERPRISE_BETA_ACCOUNTS, + findEnterpriseBetaAccountByInviteCode, + isEnterpriseBetaInviteCode, +} = require("./enterpriseBetaAccounts"); + +const DEFAULT_BETA_INVITE_CODES_FILE = path.resolve(__dirname, "../config/internal-beta-codes.md"); + +function normalizeBetaInviteCode(value) { + return String(value || "") + .trim() + .replace(/[\s-]/g, "") + .toUpperCase(); +} + +function loadBetaInviteCodesFromText(text) { + const codes = new Set(); + const content = String(text || ""); + const pattern = /\b(?:[0-9A-Fa-f]{16}|[0-7]{16,24})\b/g; + for (const match of content.matchAll(pattern)) { + const code = normalizeBetaInviteCode(match[0]); + if (code) codes.add(code); + } + return codes; +} + +function loadBetaInviteCodes(filePath = process.env.BETA_INVITE_CODES_FILE || DEFAULT_BETA_INVITE_CODES_FILE) { + const codes = new Set(); + + const envCodes = String(process.env.BETA_INVITE_CODES || "") + .split(/[,\s]+/) + .map(normalizeBetaInviteCode) + .filter(Boolean); + envCodes.forEach((code) => codes.add(code)); + + try { + const markdown = fs.readFileSync(filePath, "utf8"); + loadBetaInviteCodesFromText(markdown).forEach((code) => codes.add(code)); + } catch (error) { + if (error?.code !== "ENOENT") { + console.warn("[beta-invite] failed to read invite code file", error); + } + } + + for (const account of ENTERPRISE_BETA_ACCOUNTS) { + codes.add(normalizeBetaInviteCode(account.inviteCode)); + } + + return codes; +} + +function getBetaInviteCodeFromBody(body) { + return normalizeBetaInviteCode( + body?.betaCode ?? + body?.beta_code ?? + body?.inviteCode ?? + body?.invite_code ?? + body?.internalBetaCode ?? + body?.internal_beta_code, + ); +} + +function validateBetaInviteCode(value, allowedCodes = loadBetaInviteCodes()) { + const code = normalizeBetaInviteCode(value); + return Boolean(code && allowedCodes.has(code)); +} + +function validateBetaInviteCodeFromBody(body) { + return validateBetaInviteCode(getBetaInviteCodeFromBody(body)); +} + +async function isBetaInviteCodeUsed(value, client) { + const code = normalizeBetaInviteCode(value); + if (!code || !client?.query) return false; + const { rows } = await client.query( + "SELECT 1 FROM beta_invite_code_uses WHERE code = $1 LIMIT 1", + [code], + ); + return rows.length > 0; +} + +async function checkBetaInviteCodeForRegistration(value, client, allowedCodes = loadBetaInviteCodes()) { + const code = normalizeBetaInviteCode(value); + const enterpriseAccount = findEnterpriseBetaAccountByInviteCode(code); + if (enterpriseAccount) { + return { + ok: true, + code, + enterpriseBeta: true, + account: enterpriseAccount, + }; + } + if (!code || !allowedCodes.has(code)) { + return { ok: false, status: 403, error: "内测码无效或缺失", code }; + } + if (await isBetaInviteCodeUsed(code, client)) { + return { ok: false, status: 409, error: "内测码已被使用", code }; + } + return { ok: true, code }; +} + +async function consumeBetaInviteCode(value, userId, client, allowedCodes = loadBetaInviteCodes()) { + const check = await checkBetaInviteCodeForRegistration(value, client, allowedCodes); + if (!check.ok) return check; + if (check.enterpriseBeta) return check; + + const { rows } = await client.query( + ` + INSERT INTO beta_invite_code_uses (code, user_id) + VALUES ($1, $2) + ON CONFLICT (code) DO NOTHING + RETURNING code + `, + [check.code, userId || null], + ); + + if (rows.length === 0) { + return { ok: false, status: 409, error: "内测码已被使用", code: check.code }; + } + return { ok: true, code: check.code }; +} + +module.exports = { + DEFAULT_BETA_INVITE_CODES_FILE, + normalizeBetaInviteCode, + loadBetaInviteCodesFromText, + loadBetaInviteCodes, + getBetaInviteCodeFromBody, + findEnterpriseBetaAccountByInviteCode, + isEnterpriseBetaInviteCode, + validateBetaInviteCode, + validateBetaInviteCodeFromBody, + isBetaInviteCodeUsed, + checkBetaInviteCodeForRegistration, + consumeBetaInviteCode, +}; diff --git a/src/billing.js b/src/billing.js new file mode 100644 index 0000000..37c5a90 --- /dev/null +++ b/src/billing.js @@ -0,0 +1,764 @@ +/** + * Billing module — handles balance deduction (in cents), package quotas, + * transactions, and key-lease pre-authorization. + * + * Money conventions: + * - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents + * - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills + * - cost calculation: mills → convert to cents at deduction time (divide by 10, floor) + * - transactions: cents — amount_cents, balance_after_cents + * + * Flow: + * - Enterprise admin recharges enterprise pool → distributes to employee users + * - API deductions come from users.balance_cents (per-user) + * - Personal users recharge their own users.balance_cents directly + */ + +const { pool, withTransaction } = require("./db"); +const { calculateCostMills, getModelPrice } = require("./pricing"); + +const IMAGE_GENERATION_FLAT_COST_CENTS = 20; + +function formatCreditsFromCents(amountCents) { + const value = Number(amountCents || 0) / 100; + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); +} + +async function recordEnterpriseCreditLedger(client, entry) { + const enterpriseId = entry?.enterpriseId || null; + const userId = entry?.userId || null; + if (!enterpriseId || !userId) return null; + + const amountCents = Math.max(0, Number(entry.amountCents || 0)); + const taskType = String(entry.taskType || "text").trim() || "text"; + const status = String(entry.status || "charged").trim() || "charged"; + const { + rows: [ledger], + } = await client.query( + ` + INSERT INTO credit_ledger ( + enterprise_id, + user_id, + task_id, + model, + task_type, + resolution, + duration_seconds, + rate_cents_per_second, + amount_cents, + status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `, + [ + enterpriseId, + userId, + entry.taskId || null, + entry.model ? String(entry.model) : null, + taskType, + entry.resolution || null, + entry.durationSeconds == null ? null : Number(entry.durationSeconds), + entry.rateCentsPerSecond == null ? null : Number(entry.rateCentsPerSecond), + amountCents, + status, + ], + ); + return ledger?.id || null; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +async function getEnterpriseBalanceCents(enterpriseId) { + if (!enterpriseId) return null; + const { rows } = await pool.query( + "SELECT balance_cents FROM enterprises WHERE id = $1 AND enabled = 1", + [enterpriseId], + ); + return rows[0] ? rows[0].balance_cents : null; +} + +async function getUserBalanceCents(userId) { + const { rows } = await pool.query( + "SELECT balance_cents FROM users WHERE id = $1 AND enabled = 1", + [userId], + ); + return rows[0] ? rows[0].balance_cents : null; +} + +async function getUserBillingState(userId) { + const { rows } = await pool.query( + "SELECT balance_cents, billing_mode, beta_expires_at FROM users WHERE id = $1 AND enabled = 1", + [userId], + ); + return rows[0] || null; +} + +function isBetaUnlimitedUser(user) { + if (!user || user.billing_mode !== "beta_unlimited") return false; + if (!user.beta_expires_at) return true; + return new Date(user.beta_expires_at).getTime() > Date.now(); +} + +async function getUserEnterpriseId(userId) { + const { rows } = await pool.query( + "SELECT enterprise_id FROM users WHERE id = $1 AND enabled = 1", + [userId], + ); + return rows[0] ? rows[0].enterprise_id : null; +} + +async function getEnterpriseName(enterpriseId) { + if (!enterpriseId) return null; + const { rows } = await pool.query("SELECT name FROM enterprises WHERE id = $1", [enterpriseId]); + return rows[0] ? rows[0].name : null; +} + +function millsToCents(mills) { + return Math.floor(mills / 10); +} + +// ── Atomic balance helpers ─────────────────────────────────────────── + +async function atomicDeductUserBalance(client, userId, amountCents) { + const { rows } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [amountCents, userId], + ); + return rows.length > 0 ? rows[0].balance_cents : null; +} + +async function atomicCreditUserBalance(client, userId, amountCents) { + const { rows } = await client.query( + "UPDATE users SET balance_cents = balance_cents + $1, updated_at = NOW() WHERE id = $2 RETURNING balance_cents", + [amountCents, userId], + ); + return rows.length > 0 ? rows[0].balance_cents : null; +} + +async function atomicDeductEnterpriseBalance(client, enterpriseId, amountCents) { + const { rows } = await client.query( + "UPDATE enterprises SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [amountCents, enterpriseId], + ); + return rows.length > 0 ? rows[0].balance_cents : null; +} + +async function atomicCreditEnterpriseBalance(client, enterpriseId, amountCents) { + const { rows } = await client.query( + "UPDATE enterprises SET balance_cents = balance_cents + $1, updated_at = NOW() WHERE id = $2 RETURNING balance_cents", + [amountCents, enterpriseId], + ); + return rows.length > 0 ? rows[0].balance_cents : null; +} + +// ── Pre-authorization ──────────────────────────────────────────────── + +async function preauthorizeCall(userId, provider) { + const billingState = await getUserBillingState(userId); + if (!billingState) { + return { authorized: false, message: "用户不存在或已禁用" }; + } + if (isBetaUnlimitedUser(billingState)) { + return { authorized: true, estimatedCostCents: 0, billingMode: "beta_unlimited" }; + } + + const balanceCents = billingState.balance_cents; + + const { rows } = await pool.query( + ` + SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 100)::numeric) AS INTEGER), 0) AS avg_cents + FROM api_call_logs + WHERE provider = $1 + AND status = 'success' + AND cost_estimate IS NOT NULL + AND created_at >= NOW() - INTERVAL '7 days' + `, + [provider], + ); + const estimatedCostCents = rows[0]?.avg_cents || 0; + + if (estimatedCostCents <= 0) { + return { authorized: true, estimatedCostCents: 0 }; + } + + const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2); + + if (balanceCents < bufferedEstimate) { + const credits = Math.floor(balanceCents / 100); + return { + authorized: false, + message: `账户积分不足,请充值 (当前 ${credits} 积分,预估需要 ${Math.ceil(bufferedEstimate / 100)} 积分)`, + }; + } + + return { authorized: true, estimatedCostCents: bufferedEstimate }; +} + +// ── Deduction ──────────────────────────────────────────────────────── + +async function deductForApiCall(userId, model, promptTokens, completionTokens) { + const enterpriseId = await getUserEnterpriseId(userId); + + const costMills = calculateCostMills(model, promptTokens, completionTokens); + if (costMills == null || costMills <= 0) { + return { success: true, costCents: 0, deductionType: "none", message: "No pricing" }; + } + + const costCents = millsToCents(costMills); + if (costCents <= 0) { + return { success: true, costCents: 0, deductionType: "none", message: "Cost below 1 cent" }; + } + + const billingState = await getUserBillingState(userId); + if (isBetaUnlimitedUser(billingState)) { + return { + success: true, + costCents, + deductionType: "beta_unlimited", + message: "Beta unlimited billing mode", + }; + } + + // 1. Try package deduction first (enterprise packages) + if (enterpriseId) { + const priceInfo = getModelPrice(model); + const category = priceInfo?.category || "text"; + const packageDeduction = await tryDeductFromPackage(enterpriseId, category); + if (packageDeduction) { + await recordEnterpriseCreditLedger(pool, { + enterpriseId, + userId, + model, + taskType: category, + amountCents: costCents, + status: "charged", + }); + return { + success: true, + costCents, + deductionType: "package", + message: `Package quota used (${category})`, + }; + } + } + + // 2. Try user balance deduction (atomic) + const balanceResult = await tryDeductFromUserBalance(userId, enterpriseId, costCents, { + model, + taskType: getModelPrice(model)?.category || "text", + }); + if (balanceResult.success) { + return { success: true, costCents, deductionType: "balance", message: balanceResult.message }; + } + + // 3. Insufficient + return { + success: false, + costCents, + deductionType: "insufficient", + message: balanceResult.message, + }; +} + +async function deductImageGenerationCredits(userId, client = null, options = {}) { + const runner = async (tx) => { + const { + rows: [billingState], + } = await tx.query( + "SELECT balance_cents, billing_mode, beta_expires_at, enterprise_id FROM users WHERE id = $1 AND enabled = 1 FOR UPDATE", + [userId], + ); + + if (!billingState) { + return { + success: false, + costCents: IMAGE_GENERATION_FLAT_COST_CENTS, + deductionType: "missing_user", + message: "用户不存在或已禁用", + }; + } + + if (isBetaUnlimitedUser(billingState)) { + return { + success: true, + costCents: 0, + deductionType: "beta_unlimited", + message: "Beta unlimited billing mode", + }; + } + + const enterpriseId = billingState.enterprise_id || null; + let enterpriseName = null; + let newBalanceCents = null; + let deductionType = "image_flat"; + if (enterpriseId) { + const { + rows: [enterprise], + } = await tx.query( + "UPDATE enterprises SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND enabled = 1 AND balance_cents >= $1 RETURNING name, balance_cents", + [IMAGE_GENERATION_FLAT_COST_CENTS, enterpriseId], + ); + if (!enterprise) { + return { + success: false, + costCents: IMAGE_GENERATION_FLAT_COST_CENTS, + deductionType: "insufficient", + message: "企业积分不足,请联系管理员充值", + }; + } + enterpriseName = enterprise?.name || null; + newBalanceCents = Number(enterprise.balance_cents || 0); + deductionType = "enterprise_image_flat"; + } else { + newBalanceCents = await atomicDeductUserBalance(tx, userId, IMAGE_GENERATION_FLAT_COST_CENTS); + if (newBalanceCents == null) { + return { + success: false, + costCents: IMAGE_GENERATION_FLAT_COST_CENTS, + deductionType: "insufficient", + message: "账户积分不足,请充值", + }; + } + } + + await tx.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, $2, $3, 'deduct', $4, $5, $6) + `, + [ + enterpriseId, + enterpriseName, + userId, + -IMAGE_GENERATION_FLAT_COST_CENTS, + newBalanceCents, + `图片生成扣费 ${formatCreditsFromCents(IMAGE_GENERATION_FLAT_COST_CENTS)} 积分`, + ], + ); + + await recordEnterpriseCreditLedger(tx, { + enterpriseId, + userId, + taskId: options.taskId || null, + model: options.model || "image-generation-flat", + taskType: "image", + resolution: options.resolution || options.quality || null, + amountCents: IMAGE_GENERATION_FLAT_COST_CENTS, + status: "charged", + }); + + return { + success: true, + costCents: IMAGE_GENERATION_FLAT_COST_CENTS, + deductionType, + message: `Deducted ${formatCreditsFromCents(IMAGE_GENERATION_FLAT_COST_CENTS)} credits`, + balanceAfterCents: newBalanceCents, + }; + }; + + return client ? runner(client) : withTransaction(runner); +} + +async function tryDeductFromPackage(enterpriseId, category) { + const columnMap = { + image: "remaining_image", + video: "remaining_video", + text: "remaining_text", + }; + const columnName = columnMap[category]; + if (!columnName) return false; + + const now = new Date().toISOString(); + const { rows } = await pool.query( + ` + SELECT id, ${columnName} as remaining + FROM enterprise_packages + WHERE enterprise_id = $1 AND expires_at > $2 AND ${columnName} > 0 + ORDER BY expires_at ASC + LIMIT 1 + `, + [enterpriseId, now], + ); + + if (rows.length === 0) return false; + + await pool.query( + `UPDATE enterprise_packages SET ${columnName} = ${columnName} - 1 WHERE id = $1`, + [rows[0].id], + ); + return true; +} + +async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledgerEntry = null) { + const enterpriseName = enterpriseId ? await getEnterpriseName(enterpriseId) : null; + + const newBalanceCents = await withTransaction(async (client) => { + const newBal = await atomicDeductUserBalance(client, userId, amountCents); + if (newBal == null) return null; + + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, $2, $3, 'deduct', $4, $5, $6) + `, + [ + enterpriseId, + enterpriseName, + userId, + -amountCents, + newBal, + `API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`, + ], + ); + + await recordEnterpriseCreditLedger(client, { + enterpriseId, + userId, + model: ledgerEntry?.model || null, + taskType: ledgerEntry?.taskType || "text", + resolution: ledgerEntry?.resolution || null, + durationSeconds: ledgerEntry?.durationSeconds ?? null, + rateCentsPerSecond: ledgerEntry?.rateCentsPerSecond ?? null, + amountCents, + status: "charged", + }); + + return newBal; + }); + + if (newBalanceCents == null) { + const currentBalance = await getUserBalanceCents(userId); + const credits = Math.floor((currentBalance || 0) / 100); + return { + success: false, + message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`, + }; + } + + return { + success: true, + message: `Deducted ${Math.ceil(amountCents / 100)} credits, balance: ${Math.floor(newBalanceCents / 100)} credits`, + }; +} + +// ── Lease settlement ───────────────────────────────────────────────── + +async function settleLease(leaseId, actualCostCents) { + const { rows } = await pool.query("SELECT * FROM key_leases WHERE id = $1", [leaseId]); + const lease = rows[0]; + if (!lease) return false; + if (lease.settled) return true; + + const userId = lease.user_id; + const enterpriseId = lease.enterprise_id; + + if (!userId) { + await pool.query("UPDATE key_leases SET settled = 1 WHERE id = $1", [leaseId]); + return true; + } + + const estimatedCents = lease.estimated_cost_cents || 0; + const diffCents = actualCostCents - estimatedCents; + + const billingState = await getUserBillingState(userId); + if (isBetaUnlimitedUser(billingState)) { + await pool.query("UPDATE key_leases SET settled = 1 WHERE id = $1", [leaseId]); + return true; + } + + await withTransaction(async (client) => { + const eName = enterpriseId ? await getEnterpriseName(enterpriseId) : null; + + if (diffCents > 0) { + const newBal = await atomicDeductUserBalance(client, userId, diffCents); + if (newBal != null) { + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, $2, $3, 'deduct', $4, $5, $6) + `, + [ + enterpriseId, + eName, + userId, + -diffCents, + newBal, + `API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`, + ], + ); + } + } else if (diffCents < 0) { + const refundCents = Math.abs(diffCents); + const newBal = await atomicCreditUserBalance(client, userId, refundCents); + if (newBal != null) { + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, $2, $3, 'refund', $4, $5, $6) + `, + [ + enterpriseId, + eName, + userId, + refundCents, + newBal, + `API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`, + ], + ); + } + } + + await client.query("UPDATE key_leases SET settled = 1 WHERE id = $1", [leaseId]); + }); + + return true; +} + +async function forceSettleLease(leaseId) { + const { rows } = await pool.query("SELECT * FROM key_leases WHERE id = $1", [leaseId]); + const lease = rows[0]; + if (!lease || lease.settled) return false; + + const costCents = lease.estimated_cost_cents || 0; + return settleLease(leaseId, costCents); +} + +// ── Credits / Recharge ─────────────────────────────────────────────── + +async function creditBalance(enterpriseId, amountCents, description, paymentOrderId = null) { + const enterpriseName = await getEnterpriseName(enterpriseId); + + const newBalanceCents = await withTransaction(async (client) => { + const newBal = await atomicCreditEnterpriseBalance(client, enterpriseId, amountCents); + if (newBal == null) throw new Error("Enterprise not found"); + + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, type, amount_cents, balance_after_cents, description, payment_order_id) + VALUES ($1, $2, 'recharge', $3, $4, $5, $6) + `, + [enterpriseId, enterpriseName, amountCents, newBal, description || "充值", paymentOrderId], + ); + + return newBal; + }); + + return newBalanceCents; +} + +async function creditUserBalance(userId, amountCents, description, paymentOrderId = null) { + const enterpriseId = await getUserEnterpriseId(userId); + const enterpriseName = enterpriseId ? await getEnterpriseName(enterpriseId) : null; + + const newBalanceCents = await withTransaction(async (client) => { + const newBal = await atomicCreditUserBalance(client, userId, amountCents); + if (newBal == null) throw new Error("User not found"); + + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description, payment_order_id) + VALUES ($1, $2, $3, 'recharge', $4, $5, $6, $7) + `, + [ + enterpriseId, + enterpriseName, + userId, + amountCents, + newBal, + description || "充值", + paymentOrderId, + ], + ); + + return newBal; + }); + + return newBalanceCents; +} + +// ── Distribution ───────────────────────────────────────────────────── + +async function distributeCredits(enterpriseId, targetUserId, amountCents, adminUserId) { + const targetUser = await pool.query( + "SELECT id, enterprise_id, balance_cents FROM users WHERE id = $1 AND enabled = 1", + [targetUserId], + ); + if (targetUser.rows.length === 0) throw new Error("目标用户不存在"); + if (Number(targetUser.rows[0].enterprise_id) !== Number(enterpriseId)) + throw new Error("目标用户不属于本企业"); + + const enterpriseBalance = await getEnterpriseBalanceCents(enterpriseId); + if (enterpriseBalance == null || enterpriseBalance < amountCents) + throw new Error("企业池积分不足"); + + const enterpriseName = await getEnterpriseName(enterpriseId); + + const result = await withTransaction(async (client) => { + const newEntBal = await atomicDeductEnterpriseBalance(client, enterpriseId, amountCents); + if (newEntBal == null) throw new Error("企业池积分不足"); + + const newUserBal = await atomicCreditUserBalance(client, targetUserId, amountCents); + if (newUserBal == null) throw new Error("目标用户不存在"); + + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description, target_user_id) + VALUES ($1, $2, $3, 'distribute_out', $4, $5, $6, $7) + `, + [ + enterpriseId, + enterpriseName, + adminUserId, + -amountCents, + newEntBal, + `分发积分给用户 #${targetUserId}`, + targetUserId, + ], + ); + + await client.query( + ` + INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description, target_user_id) + VALUES ($1, $2, $3, 'distribute_in', $4, $5, $6, $7) + `, + [ + enterpriseId, + enterpriseName, + targetUserId, + amountCents, + newUserBal, + `从企业池获得 ${Math.floor(amountCents / 100)} 积分`, + adminUserId, + ], + ); + + return { enterpriseNewBalance: newEntBal, targetNewBalance: newUserBal }; + }); + + return result; +} + +// ── Packages ───────────────────────────────────────────────────────── + +async function activatePackage(enterpriseId, packageId) { + const { rows } = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [ + packageId, + ]); + const pkg = rows[0]; + if (!pkg) throw new Error("套餐不存在或已下架"); + + const expiresAt = new Date(Date.now() + pkg.duration_days * 86400000).toISOString(); + + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO enterprise_packages (enterprise_id, package_id, remaining_image, remaining_video, remaining_text, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [enterpriseId, packageId, pkg.image_quota, pkg.video_quota, pkg.text_quota, expiresAt], + ); + + if (pkg.credits_cents > 0) { + await creditBalance(enterpriseId, pkg.credits_cents, `套餐「${pkg.name}」赠送余额`); + } + }); + + return { expiresAt, creditsCents: pkg.credits_cents }; +} + +// ── Queries ────────────────────────────────────────────────────────── + +async function getEnterpriseFinancials(enterpriseId) { + const balanceCents = await getEnterpriseBalanceCents(enterpriseId); + const now = new Date().toISOString(); + + const { rows: activePackages } = await pool.query( + ` + SELECT ep.*, p.name as package_name + FROM enterprise_packages ep + JOIN packages p ON ep.package_id = p.id + WHERE ep.enterprise_id = $1 AND ep.expires_at > $2 + ORDER BY ep.expires_at ASC + `, + [enterpriseId, now], + ); + + const { rows: recentTransactions } = await pool.query( + ` + SELECT * FROM transactions + WHERE enterprise_id = $1 + ORDER BY id DESC LIMIT 20 + `, + [enterpriseId], + ); + + return { balanceCents, activePackages, recentTransactions }; +} + +// ── Failure refund ─────────────────────────────────────────────────── + +async function refundTaskBillingOnFailure(taskId) { + return withTransaction(async (client) => { + const { rows } = await client.query( + `UPDATE generation_tasks + SET billing_refunded = 1, updated_at = NOW() + WHERE id = $1 AND billing_refunded = 0 AND cost_cents > 0 AND status = 'failed' + RETURNING user_id, cost_cents, billing_target, type`, + [taskId], + ); + if (rows.length === 0) return { refunded: false }; + + const { user_id: userId, cost_cents: costCents, billing_target: target, type } = rows[0]; + const label = type === "video" ? "视频" : type === "image" ? "图片" : "任务"; + + if (target === "user") { + const newBal = await atomicCreditUserBalance(client, userId, costCents); + if (newBal == null) return { refunded: false }; + await client.query( + `INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, 'refund', $2, $3, $4)`, + [userId, costCents, newBal, `${label}生成失败退回 ${formatCreditsFromCents(costCents)} 积分`], + ); + return { refunded: true, target, costCents }; + } + + const enterpriseId = await getUserEnterpriseId(userId); + if (!enterpriseId) return { refunded: false }; + + const newEntBal = await atomicCreditEnterpriseBalance(client, enterpriseId, costCents); + await client.query( + `UPDATE credit_ledger SET status = 'refunded', updated_at = NOW() + WHERE task_id = $1 AND status IN ('reserved', 'charged')`, + [taskId], + ); + if (target === "enterprise_image") { + const enterpriseName = await getEnterpriseName(enterpriseId); + await client.query( + `INSERT INTO transactions (enterprise_id, enterprise_name, user_id, type, amount_cents, balance_after_cents, description) + VALUES ($1, $2, $3, 'refund', $4, $5, $6)`, + [enterpriseId, enterpriseName, userId, costCents, newEntBal ?? 0, `${label}生成失败退回 ${formatCreditsFromCents(costCents)} 积分`], + ); + } + return { refunded: true, target, costCents }; + }); +} + +module.exports = { + deductForApiCall, + deductImageGenerationCredits, + refundTaskBillingOnFailure, + creditBalance, + creditUserBalance, + activatePackage, + distributeCredits, + getEnterpriseBalanceCents, + getUserBalanceCents, + getUserBillingState, + getUserEnterpriseId, + getEnterpriseName, + getEnterpriseFinancials, + preauthorizeCall, + settleLease, + forceSettleLease, +}; diff --git a/src/cli/addKey.js b/src/cli/addKey.js new file mode 100644 index 0000000..d0b17c4 --- /dev/null +++ b/src/cli/addKey.js @@ -0,0 +1,23 @@ +require("dotenv").config(); +const { pool } = require("../db"); + +const [, , provider, apiKey, label = "", maxConcurrency = "10"] = process.argv; +if (!provider || !apiKey) { + console.log("Usage: node src/cli/addKey.js [label] [max_concurrency]"); + process.exit(1); +} + +async function main() { + const { + rows: [row], + } = await pool.query( + "INSERT INTO api_keys (provider, api_key, label, max_concurrency) VALUES ($1, $2, $3, $4) RETURNING id", + [provider, apiKey, label, Number(maxConcurrency)], + ); + console.log( + `Added key #${row.id}: provider=${provider} label="${label}" concurrency=${maxConcurrency}`, + ); + await pool.end(); +} + +main(); diff --git a/src/cli/addUser.js b/src/cli/addUser.js new file mode 100644 index 0000000..001c210 --- /dev/null +++ b/src/cli/addUser.js @@ -0,0 +1,27 @@ +require("dotenv").config(); +const bcrypt = require("bcryptjs"); +const { pool } = require("../db"); + +const [, , username, password, role = "user", maxConcurrency = "30"] = process.argv; +if (!username || !password) { + console.log("Usage: node src/cli/addUser.js [role] [max_concurrency]"); + process.exit(1); +} + +async function main() { + const hash = bcrypt.hashSync(password, 10); + try { + const { + rows: [row], + } = await pool.query( + "INSERT INTO users (username, password_hash, role, max_concurrency, balance_cents) VALUES ($1, $2, $3, $4, 0) RETURNING id", + [username, hash, role, Number(maxConcurrency)], + ); + console.log(`Created user #${row.id}: ${username} (${role})`); + } catch { + console.error("User already exists"); + } + await pool.end(); +} + +main(); diff --git a/src/cli/auditModelRoutes.js b/src/cli/auditModelRoutes.js new file mode 100644 index 0000000..ef3ef1f --- /dev/null +++ b/src/cli/auditModelRoutes.js @@ -0,0 +1,80 @@ +require("dotenv").config(); + +const { pool } = require("../db"); +const { listKnownRoutes, getPostUrl, getPollUrl } = require("../aiProviderRouter"); + +function pad(value, length) { + const text = String(value || ""); + return text.length >= length ? text : `${text}${" ".repeat(length - text.length)}`; +} + +function formatPool(row) { + if (!row) return "MISSING_POOL"; + const enabled = Number(row.enabled_count || 0) > 0; + const active = Number(row.active_count || 0); + const capacity = Number(row.max_concurrency || 0); + return `${enabled ? "ON" : "OFF"} ${active}/${capacity}`; +} + +async function main() { + const { rows } = await pool.query(` + SELECT + provider, + COUNT(*) FILTER (WHERE enabled = 1) AS enabled_count, + COALESCE(SUM(active_count), 0) AS active_count, + COALESCE(SUM(CASE WHEN enabled = 1 THEN max_concurrency ELSE 0 END), 0) AS max_concurrency + FROM api_keys + GROUP BY provider + `); + const pools = new Map(rows.map((row) => [row.provider, row])); + const routes = listKnownRoutes(); + + console.log(""); + console.log( + [ + pad("Type", 7), + pad("Model", 34), + pad("Provider Pool", 24), + pad("Pool Status", 16), + pad("Transport", 18), + "POST URL", + ].join(" | "), + ); + console.log("-".repeat(150)); + + let missingCount = 0; + for (const route of routes) { + const poolRow = pools.get(route.provider); + if (!poolRow) missingCount++; + const transport = route.transport || route.protocol || "-"; + const status = poolRow ? "OK" : "MISSING_POOL"; + console.log( + [ + pad(route.type, 7), + pad(route.requestedModel || route.model, 34), + pad(route.provider, 24), + pad(formatPool(poolRow), 16), + pad(transport, 18), + `${getPostUrl(route)} [${status}]`, + ].join(" | "), + ); + const pollUrl = getPollUrl(route); + if (pollUrl) { + console.log(`${pad("", 7)} | ${pad("", 34)} | ${pad("", 24)} | ${pad("", 16)} | ${pad("poll", 18)} | ${pollUrl}`); + } + } + + console.log(""); + console.log(`Routes: ${routes.length}; missing pools: ${missingCount}`); + console.log(""); + await pool.end(); + + if (missingCount > 0) process.exitCode = 1; +} + +main().catch(async (err) => { + const details = err?.message || err?.code || err?.errors?.map((item) => item.message).join("; ") || String(err); + console.error("[audit-routes] failed:", details); + await pool.end().catch(() => {}); + process.exit(1); +}); diff --git a/src/cli/importConfig.js b/src/cli/importConfig.js new file mode 100644 index 0000000..c251626 --- /dev/null +++ b/src/cli/importConfig.js @@ -0,0 +1,82 @@ +require("dotenv").config(); +const fs = require("node:fs"); +const path = require("node:path"); +const { pool } = require("../db"); + +const [, , templateFile, profileName = "default"] = process.argv; +if (!templateFile) { + console.log("Usage: node src/cli/importConfig.js [profile-name]"); + process.exit(1); +} + +const filePath = path.resolve(templateFile); +if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); +} + +const content = fs.readFileSync(filePath, "utf-8"); +const config = {}; + +for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith(";")) + continue; + + const eqIndex = line.indexOf("="); + if (eqIndex < 0) continue; + + const key = line.slice(0, eqIndex).trim(); + const value = line.slice(eqIndex + 1).trim(); + + if (key === "templateName" || key === "templateDescription") continue; + + if (value === "true" || value === "false") { + config[key] = value === "true"; + continue; + } + + const numericFields = [ + "seed", + "timeout", + "retryAttempts", + "pollInterval", + "maxPollAttempts", + "autoSaveInterval", + ]; + if (numericFields.includes(key) && value !== "") { + const num = Number(value); + if (Number.isFinite(num)) { + config[key] = num; + continue; + } + } + + if (value !== "") config[key] = value; +} + +const configJson = JSON.stringify(config, null, 2); + +async function main() { + const { + rows: [existing], + } = await pool.query("SELECT id FROM config_profiles WHERE name = $1", [profileName]); + if (existing) { + await pool.query( + "UPDATE config_profiles SET config_json = $1, updated_at = NOW() WHERE name = $2", + [configJson, profileName], + ); + console.log(`Updated profile "${profileName}" (${Object.keys(config).length} fields)`); + } else { + await pool.query( + "INSERT INTO config_profiles (name, config_json, description) VALUES ($1, $2, $3)", + [profileName, configJson, `Imported from ${path.basename(filePath)}`], + ); + console.log(`Created profile "${profileName}" (${Object.keys(config).length} fields)`); + } + + console.log("Config keys:", Object.keys(config).join(", ")); + await pool.end(); +} + +main(); diff --git a/src/cli/initPools.js b/src/cli/initPools.js new file mode 100644 index 0000000..eef451b --- /dev/null +++ b/src/cli/initPools.js @@ -0,0 +1,60 @@ +require("dotenv").config(); +const { pool } = require("../db"); + +const pools = [ + { provider: "dashscope-wan2.7", label: "百炼 wan2.7 图像", maxConcurrency: 5 }, + { provider: "dashscope-wan2.7-pro", label: "百炼 wan2.7 Pro 图像", maxConcurrency: 5 }, + { provider: "dashscope-happyhorse-t2v", label: "百炼 HappyHorse 文生视频", maxConcurrency: 5 }, + { provider: "dashscope-happyhorse-i2v", label: "百炼 HappyHorse 图生视频", maxConcurrency: 5 }, + { provider: "dashscope-happyhorse-r2v", label: "百炼 HappyHorse 参考生视频", maxConcurrency: 5 }, + { provider: "dashscope-wan-i2v", label: "百炼 wan2.7 图生视频", maxConcurrency: 5 }, + { provider: "dashscope-wan-s2v", label: "百炼 wan2.2 首尾帧视频", maxConcurrency: 5 }, + { provider: "dashscope-wan-animate", label: "百炼 wan2.2 角色迁移", maxConcurrency: 5 }, + { provider: "dashscope-kling", label: "百炼 Kling 视频", maxConcurrency: 5 }, + { provider: "dashscope-text", label: "百炼 文本对话", maxConcurrency: 10 }, + { provider: "kling-official", label: "Kling 官方", maxConcurrency: 5 }, + { provider: "kuaikuai-nano", label: "快快 AI(Nano)", maxConcurrency: 999 }, + { provider: "kuaikuai-gpt", label: "快快 AI(GPT)", maxConcurrency: 999 }, + { provider: "grsai", label: "GRSAI 平台", maxConcurrency: 999 }, + { provider: "rightcode", label: "RightCode platform", maxConcurrency: 999 }, + { provider: "seedance-2.0", label: "Seedance 2.0", maxConcurrency: 10 }, + { provider: "seedance-2.0-fast", label: "Seedance 2.0 Fast", maxConcurrency: 10 }, + { provider: "seedance-2.0-ark", label: "Seedance 2.0(官方)", maxConcurrency: 10 }, + { provider: "seedance-2.0-fast-ark", label: "Seedance 2.0 Fast(官方)", maxConcurrency: 10 }, +]; + +async function main() { + for (const pool_item of pools) { + const { + rows: [existing], + } = await pool.query("SELECT id FROM api_keys WHERE provider = $1", [pool_item.provider]); + if (existing) { + console.log(`[skip] ${pool_item.provider} already exists (id: ${existing.id})`); + continue; + } + + const { + rows: [row], + } = await pool.query( + "INSERT INTO api_keys (provider, api_key, label, max_concurrency) VALUES ($1, $2, $3, $4) RETURNING id", + [pool_item.provider, "pool-slot", pool_item.label, pool_item.maxConcurrency], + ); + console.log( + `[created] ${pool_item.provider} — max_concurrency: ${pool_item.maxConcurrency} (id: ${row.id})`, + ); + } + + console.log("\nCurrent pools:"); + const { rows: all } = await pool.query( + "SELECT provider, label, max_concurrency, active_count, enabled FROM api_keys ORDER BY provider", + ); + for (const row of all) { + console.log( + ` ${row.provider}: ${row.label} — limit: ${row.max_concurrency}, active: ${row.active_count}, enabled: ${row.enabled}`, + ); + } + + await pool.end(); +} + +main(); diff --git a/src/cli/listKeys.js b/src/cli/listKeys.js new file mode 100644 index 0000000..7eb668f --- /dev/null +++ b/src/cli/listKeys.js @@ -0,0 +1,20 @@ +require("dotenv").config(); +const { pool } = require("../db"); + +async function main() { + const { rows: keys } = await pool.query( + "SELECT id, provider, label, max_concurrency, active_count, total_used, enabled FROM api_keys ORDER BY provider, id", + ); + + console.log("\n ID | Provider | Label | Concurrency | Used | Status"); + console.log(" ---|-------------|--------------------| ------------|-------|-------"); + for (const k of keys) { + console.log( + ` ${String(k.id).padEnd(2)} | ${k.provider.padEnd(11)} | ${(k.label || "-").padEnd(18)} | ${k.active_count}/${k.max_concurrency} | ${String(k.total_used).padEnd(5)} | ${k.enabled ? "ON" : "OFF"}`, + ); + } + console.log(`\n Total: ${keys.length} key(s)\n`); + await pool.end(); +} + +main(); diff --git a/src/cli/removeKey.js b/src/cli/removeKey.js new file mode 100644 index 0000000..e9d81b8 --- /dev/null +++ b/src/cli/removeKey.js @@ -0,0 +1,28 @@ +require("dotenv").config(); +const { pool } = require("../db"); + +const [, , rawId] = process.argv; +const id = Number(rawId); + +if (!Number.isInteger(id) || id <= 0) { + console.log("Usage: node src/cli/removeKey.js "); + process.exit(1); +} + +async function main() { + const { + rows: [existing], + } = await pool.query("SELECT id, provider, label FROM api_keys WHERE id = $1", [id]); + if (!existing) { + console.log(`Key not found: id=${id}`); + process.exit(1); + } + + await pool.query("DELETE FROM api_keys WHERE id = $1", [id]); + console.log( + `Removed key #${existing.id}: provider=${existing.provider} label="${existing.label || ""}"`, + ); + await pool.end(); +} + +main(); diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..57ffb46 --- /dev/null +++ b/src/db.js @@ -0,0 +1,34 @@ +const { Pool } = require("pg"); +require("dotenv").config(); + +const pool = new Pool({ + host: process.env.PG_HOST || "localhost", + port: Number(process.env.PG_PORT) || 5432, + database: process.env.PG_DATABASE || "omniai", + user: process.env.PG_USER || "omniai", + password: process.env.PG_PASSWORD || "", + max: Number(process.env.PG_POOL_MAX) || 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}); + +pool.on("error", (err) => { + console.error("[db] Unexpected pool error:", err.message); +}); + +async function withTransaction(fn) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + throw err; + } finally { + client.release(); + } +} + +module.exports = { pool, withTransaction }; diff --git a/src/dbSetup.js b/src/dbSetup.js new file mode 100644 index 0000000..be2e098 --- /dev/null +++ b/src/dbSetup.js @@ -0,0 +1,1107 @@ +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 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, +}; diff --git a/src/enterpriseBetaAccounts.js b/src/enterpriseBetaAccounts.js new file mode 100644 index 0000000..0d30f61 --- /dev/null +++ b/src/enterpriseBetaAccounts.js @@ -0,0 +1,75 @@ +const ENTERPRISE_BETA_INITIAL_CREDITS = 1500; +const ENTERPRISE_BETA_INITIAL_BALANCE_CENTS = ENTERPRISE_BETA_INITIAL_CREDITS * 100; + +const ENTERPRISE_BETA_ACCOUNTS = [ + { + enterpriseId: "ent-beta-001", + enterpriseName: "Beta Enterprise 001", + adminUsername: "enterprise_admin_001", + inviteCode: "ENT-BETA-001", + initialCredits: ENTERPRISE_BETA_INITIAL_CREDITS, + }, + { + enterpriseId: "ent-beta-002", + enterpriseName: "Beta Enterprise 002", + adminUsername: "enterprise_admin_002", + inviteCode: "ENT-BETA-002", + initialCredits: ENTERPRISE_BETA_INITIAL_CREDITS, + }, + { + enterpriseId: "ent-beta-003", + enterpriseName: "Beta Enterprise 003", + adminUsername: "enterprise_admin_003", + inviteCode: "ENT-BETA-003", + initialCredits: ENTERPRISE_BETA_INITIAL_CREDITS, + }, + { + enterpriseId: "ent-beta-004", + enterpriseName: "Beta Enterprise 004", + adminUsername: "enterprise_admin_004", + inviteCode: "ENT-BETA-004", + initialCredits: ENTERPRISE_BETA_INITIAL_CREDITS, + }, +]; + +function normalizeEnterpriseInviteCode(value) { + return String(value || "") + .trim() + .replace(/[\s-]/g, "") + .toUpperCase(); +} + +const ENTERPRISE_BETA_ACCOUNT_BY_CODE = new Map( + ENTERPRISE_BETA_ACCOUNTS.map((account) => [ + normalizeEnterpriseInviteCode(account.inviteCode), + account, + ]), +); + +function findEnterpriseBetaAccountByInviteCode(value) { + return ENTERPRISE_BETA_ACCOUNT_BY_CODE.get(normalizeEnterpriseInviteCode(value)) || null; +} + +function isEnterpriseBetaInviteCode(value) { + return Boolean(findEnterpriseBetaAccountByInviteCode(value)); +} + +function createEnterpriseBetaPasswordMap(env = process.env) { + const sharedPassword = String(env.ENTERPRISE_BETA_ADMIN_PASSWORD || "").trim(); + const entries = ENTERPRISE_BETA_ACCOUNTS.map((account, index) => { + const suffix = String(index + 1).padStart(3, "0"); + const specificPassword = String(env[`ENTERPRISE_BETA_ADMIN_${suffix}_PASSWORD`] || "").trim(); + return [account.adminUsername, specificPassword || sharedPassword]; + }); + return new Map(entries); +} + +module.exports = { + ENTERPRISE_BETA_ACCOUNTS, + ENTERPRISE_BETA_INITIAL_BALANCE_CENTS, + ENTERPRISE_BETA_INITIAL_CREDITS, + createEnterpriseBetaPasswordMap, + findEnterpriseBetaAccountByInviteCode, + isEnterpriseBetaInviteCode, + normalizeEnterpriseInviteCode, +}; diff --git a/src/enterpriseVideoBilling.js b/src/enterpriseVideoBilling.js new file mode 100644 index 0000000..8a01ab1 --- /dev/null +++ b/src/enterpriseVideoBilling.js @@ -0,0 +1,210 @@ +"use strict"; + +const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([ + "happyhorse-1.0", + "happyhorse-1.0-t2v", + "happyhorse-1.0-i2v", + "happyhorse-1.0-r2v", + "wan2.7-i2v", + "wan2.2-animate-mix", + "wan2.2-s2v", + "kling-3.0-dashscope", + "kling-v3-omni-dashscope", + "kling/kling-v3-omni-video-generation", +]); + +function normalizeModel(value) { + return String(value || "").trim().toLowerCase(); +} + +function normalizeEnterpriseVideoResolution(value) { + return String(value || "").trim().toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function normalizeEnterpriseVideoDuration(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 1; + return Math.max(1, Math.ceil(numeric)); +} + +function isEnterpriseVideoBillingUser(user) { + return Boolean(user?.enterpriseId); +} + +function isEnterpriseVideoModelAllowed(providerConfig, model) { + const requestedModel = normalizeModel(model || providerConfig?.requestedModel || providerConfig?.model); + const resolvedModel = normalizeModel(providerConfig?.model); + const protocol = String(providerConfig?.protocol || "").toLowerCase(); + + if (ENTERPRISE_VIDEO_ALLOWED_MODELS.has(requestedModel)) return true; + if (ENTERPRISE_VIDEO_ALLOWED_MODELS.has(resolvedModel)) return true; + if (protocol.startsWith("happyhorse-")) return true; + if (protocol === "wan-i2v") return true; + if (protocol === "wan-animate-mix") return true; + if (protocol === "wan-s2v") return true; + if (protocol === "kling-dashscope") return true; + return false; +} + +function assertEnterpriseVideoModelAllowed(providerConfig, model) { + if (isEnterpriseVideoModelAllowed(providerConfig, model)) return; + const error = new Error("该企业账号不可使用当前视频模型"); + error.status = 403; + error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED"; + throw error; +} + +function getEnterpriseVideoCreditRate(input) { + const resolution = normalizeEnterpriseVideoResolution(input.resolution || input.quality); + const model = normalizeModel(input.model || input.requestedModel); + + if (model.includes("happyhorse")) { + return resolution === "720P" ? 0.72 : 1.28; + } + + if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) { + return resolution === "720P" ? 0.6 : 1; + } + + if (model.includes("animate-mix") || model.includes("s2v")) { + return resolution === "720P" ? 0.6 : 1; + } + + if (model.includes("kling")) { + if (input.muted) { + if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2; + return resolution === "720P" ? 0.6 : 0.8; + } + return resolution === "720P" ? 0.9 : 1.2; + } + + const error = new Error(`Unsupported enterprise video model: ${input.model || input.requestedModel}`); + error.status = 403; + error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED"; + throw error; +} + +function calculateEnterpriseVideoCredits(input) { + const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration); + return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2)); +} + +function calculateEnterpriseVideoCost(input) { + const resolution = normalizeEnterpriseVideoResolution(input.resolution || input.quality); + const durationSeconds = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration); + const rateCreditsPerSecond = getEnterpriseVideoCreditRate({ + ...input, + resolution, + durationSeconds, + }); + const rateCentsPerSecond = Math.round(rateCreditsPerSecond * 100); + return { + resolution, + durationSeconds, + rateCentsPerSecond, + amountCents: rateCentsPerSecond * durationSeconds, + }; +} + +function prepareEnterpriseVideoBilling({ user, providerConfig, params }) { + if (!isEnterpriseVideoBillingUser(user)) return null; + assertEnterpriseVideoModelAllowed(providerConfig, params.requestedModel || params.model); + const hasReferenceVideo = Boolean(params.hasReferenceVideo); + const muted = Boolean(params.muted); + const pricing = calculateEnterpriseVideoCost({ + model: params.model, + requestedModel: params.requestedModel, + resolution: params.resolution || params.quality, + duration: params.duration, + muted, + hasReferenceVideo, + }); + + return { + enterpriseId: user.enterpriseId, + userId: user.id, + model: params.model, + requestedModel: params.requestedModel, + muted, + hasReferenceVideo, + ...pricing, + }; +} + +async function reserveEnterpriseVideoCredits(client, billing) { + if (!billing || billing.amountCents <= 0) return null; + + const { rows: balanceRows } = await client.query( + "UPDATE enterprises SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND enabled = 1 AND balance_cents >= $1 RETURNING balance_cents", + [billing.amountCents, billing.enterpriseId], + ); + if (balanceRows.length === 0) { + const error = new Error( + `企业积分不足,预计需要 ${Number((billing.amountCents / 100).toFixed(2))} 积分`, + ); + error.status = 402; + error.code = "INSUFFICIENT_ENTERPRISE_BALANCE"; + throw error; + } + + const { + rows: [ledger], + } = await client.query( + ` + INSERT INTO credit_ledger ( + enterprise_id, + user_id, + task_id, + model, + task_type, + resolution, + duration_seconds, + rate_cents_per_second, + amount_cents, + status + ) + VALUES ($1, $2, $3, $4, 'video', $5, $6, $7, $8, 'reserved') + RETURNING id + `, + [ + billing.enterpriseId, + billing.userId, + billing.taskId || null, + billing.requestedModel || billing.model, + billing.resolution, + billing.durationSeconds, + billing.rateCentsPerSecond, + billing.amountCents, + ], + ); + + return { + ...billing, + creditLedgerId: ledger.id, + enterpriseBalanceCents: Number(balanceRows[0].balance_cents || 0), + }; +} + +async function markEnterpriseVideoCreditsAccepted(clientOrPool, creditLedgerId) { + if (!creditLedgerId) return false; + const { rowCount } = await clientOrPool.query( + "UPDATE credit_ledger SET status = 'charged', updated_at = NOW() WHERE id = $1 AND status = 'reserved'", + [creditLedgerId], + ); + return rowCount > 0; +} + +module.exports = { + ENTERPRISE_VIDEO_ALLOWED_MODELS, + assertEnterpriseVideoModelAllowed, + calculateEnterpriseVideoCost, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, + isEnterpriseVideoBillingUser, + isEnterpriseVideoModelAllowed, + markEnterpriseVideoCreditsAccepted, + normalizeEnterpriseVideoDuration, + normalizeEnterpriseVideoResolution, + prepareEnterpriseVideoBilling, + reserveEnterpriseVideoCredits, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4621773 --- /dev/null +++ b/src/index.js @@ -0,0 +1,153 @@ +require('dotenv').config() +const express = require('express') +const rateLimit = require('express-rate-limit') +const cors = require('cors') +const helmet = require('helmet') +const { startSettlementWorker } = require('./settlementWorker') +const { startProviderHealthMonitor } = require('./providerHealthMonitor') +const { ensureDatabase } = require('./dbSetup') +const { assertRuntimeSecurityConfig } = require('./securityConfig') +const { loadPriceCache } = require('./pricing') + +assertRuntimeSecurityConfig() + +const routes = require('./routes') + +const PORT = Number(process.env.PORT) || 3600 +const HOST = process.env.HOST || '0.0.0.0' +const IS_PRODUCTION = process.env.NODE_ENV === 'production' + +// CORS: in production, require explicit allowlist; in dev, allow all with credentials +function buildCorsOptions() { + const raw = process.env.CORS_ORIGINS || '' + if (IS_PRODUCTION) { + if (!raw || raw === '*') { + console.warn('[security] CORS_ORIGINS not set in production, defaulting to same-origin only') + return { origin: false, credentials: true } + } + return { + origin: raw.split(',').map((s) => s.trim()), + credentials: true, + } + } + // Development: allow any origin for local testing + return { origin: true, credentials: true } +} + +async function main() { + const { createdDefaultAdmin } = await ensureDatabase() + if (createdDefaultAdmin) { + console.log('[db] Created default admin user: admin (password sourced from configuration)') + } + + await loadPriceCache() + + const app = express() + + // Trust single-hop Nginx reverse proxy (X-Forwarded-For, X-Real-IP) + app.set('trust proxy', 1) + + // Security headers via helmet + app.use(helmet({ + contentSecurityPolicy: false, // Disable CSP for now (SPA needs inline scripts in dev) + crossOriginEmbedderPolicy: false, // Allow OSS images/videos + crossOriginResourcePolicy: { policy: 'cross-origin' }, + })) + + // CORS + app.use(cors(buildCorsOptions())) + + // Rate limiting: global (100 req/min per IP) + const globalLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: '请求过于频繁,请稍后再试' }, + }) + app.use('/api', globalLimiter) + + // Rate limiting: auth endpoints (stricter — 10 req/min per IP) + const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: '登录尝试过于频繁,请1分钟后再试' }, + }) + app.use('/api/auth/login', authLimiter) + app.use('/api/auth/register', authLimiter) + app.use('/api/auth/sms', authLimiter) + + // Rate limiting: AI generation endpoints (20 req/min per IP) + const aiGenerationLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'AI生成请求过于频繁,请稍后再试' }, + }) + app.use('/api/ai/image', aiGenerationLimiter) + app.use('/api/ai/video', aiGenerationLimiter) + + // JSON body limit: 5MB globally (upload routes override locally) + app.use('/api/oss/upload', express.json({ limit: '200mb' })) + app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' })) + + app.use('/api', routes) + app.use('/api', (req, res) => { + res.status(404).json({ + error: 'API route not found', + path: req.originalUrl + }) + }) + app.use('/api', (err, req, res, next) => { + if (res.headersSent) return next(err) + const status = Number(err.status || err.statusCode || 500) + const safeStatus = status >= 400 && status < 600 ? status : 500 + // Log full error internally, but don't leak details to client + console.error('[api] error:', err) + const message = safeStatus >= 500 + ? '服务器内部错误,请稍后重试' + : (err.message || '请求处理失败') + res.status(safeStatus).json({ + error: message, + code: err.code || undefined, + }) + }) + + // Periodic stale lease cleanup (every 5 min) + const { cleanStaleLeases } = require('./keyManager') + setInterval(() => { + cleanStaleLeases().then((cleaned) => { + if (cleaned > 0) console.log(`[cleanup] Released ${cleaned} stale lease(s)`) + }).catch((err) => { + console.error('[cleanup] error:', err) + }) + }, 5 * 60 * 1000) + + startSettlementWorker() + startProviderHealthMonitor() + + const { startStaleTaskCleanup } = require('./aiTaskWorker') + startStaleTaskCleanup() + + app.listen(PORT, HOST, () => { + console.log(`OmniAI Key Server running at http://${HOST}:${PORT}`) + console.log(`Health check: http://${HOST}:${PORT}/api/health`) + }) +} + +main().catch((err) => { + console.error('[startup] Fatal error:', err) + process.exit(1) +}) + +process.on('unhandledRejection', (reason) => { + console.error('[fatal] Unhandled promise rejection:', reason) +}) + +process.on('uncaughtException', (err) => { + console.error('[fatal] Uncaught exception:', err) + process.exit(1) +}) diff --git a/src/initDb.js b/src/initDb.js new file mode 100644 index 0000000..db817d5 --- /dev/null +++ b/src/initDb.js @@ -0,0 +1,21 @@ +require("dotenv").config(); +const { pool } = require("./db"); +const { ensureDatabase } = require("./dbSetup"); + +async function main() { + const result = await ensureDatabase(); + if (result.createdDefaultAdmin) { + console.log( + "[init-db] Created default admin user: admin (password sourced from configuration)", + ); + } else { + console.log("[init-db] Admin user already exists"); + } + console.log("[init-db] Database schema is up to date"); + await pool.end(); +} + +main().catch((err) => { + console.error("[init-db] Fatal error:", err); + process.exit(1); +}); diff --git a/src/keyManager.js b/src/keyManager.js new file mode 100644 index 0000000..6f9b49e --- /dev/null +++ b/src/keyManager.js @@ -0,0 +1,419 @@ +const crypto = require("node:crypto"); +const { pool, withTransaction } = require("./db"); + +const STALE_LEASE_MINUTES = 30; +const DEFAULT_WAIT_TIMEOUT_MS = 25_000; +const MAX_WAIT_TIMEOUT_MS = 5 * 60 * 1000; + +const providerWaitQueues = new Map(); +const providerDrainPromises = new Map(); + +function normalizeWaitTimeoutMs(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + + return Math.min(Math.trunc(numeric), MAX_WAIT_TIMEOUT_MS); +} + +function getProviderWaitQueue(provider) { + const existingQueue = providerWaitQueues.get(provider); + if (existingQueue) { + return existingQueue; + } + + const nextQueue = []; + providerWaitQueues.set(provider, nextQueue); + return nextQueue; +} + +function compactProviderWaitQueue(provider) { + const queue = providerWaitQueues.get(provider); + if (!queue || queue.length === 0) { + providerWaitQueues.delete(provider); + return []; + } + + const activeWaiters = queue.filter((waiter) => !waiter.settled); + if (activeWaiters.length === 0) { + providerWaitQueues.delete(provider); + return []; + } + + providerWaitQueues.set(provider, activeWaiters); + return activeWaiters; +} + +function removeWaiter(waiter) { + const queue = providerWaitQueues.get(waiter.provider); + if (!queue) { + return; + } + + const index = queue.indexOf(waiter); + if (index >= 0) { + queue.splice(index, 1); + } + + compactProviderWaitQueue(waiter.provider); +} + +function settleWaiter(waiter, outcome) { + if (waiter.settled) { + return; + } + + waiter.settled = true; + clearTimeout(waiter.timer); + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } + removeWaiter(waiter); + + if (outcome instanceof Error) { + waiter.reject(outcome); + return; + } + + waiter.resolve(outcome); +} + +function normalizeUserContext(user) { + if (user && typeof user === "object") { + return { + userId: user.id, + enterpriseId: user.enterpriseId ?? null, + accountType: user.accountType ?? "personal", + }; + } + + return { + userId: user, + enterpriseId: null, + accountType: "personal", + }; +} + +async function tryAcquireKey(provider, user, preauthResult) { + const { userId, enterpriseId } = normalizeUserContext(user); + const leaseToken = crypto.randomUUID(); + const estimatedCostCents = preauthResult?.estimatedCostCents || 0; + + return withTransaction(async (client) => { + const { rows } = await client.query( + ` + WITH candidate AS ( + SELECT id, api_key, label + FROM api_keys + WHERE provider = $1 AND enabled = 1 AND active_count < max_concurrency + ORDER BY active_count ASC, id ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ), + updated AS ( + UPDATE api_keys + SET active_count = active_count + 1, + total_used = total_used + 1 + WHERE id = (SELECT id FROM candidate) + RETURNING id, api_key, label + ) + SELECT id, api_key, label FROM updated + `, + [provider], + ); + + const key = rows[0]; + if (!key) { + return null; + } + + await client.query( + ` + INSERT INTO key_leases (key_id, user_id, lease_token, estimated_cost_cents, enterprise_id) + VALUES ($1, $2, $3, $4, $5) + `, + [key.id, userId, leaseToken, estimatedCostCents, enterpriseId], + ); + + await client.query( + ` + INSERT INTO usage_logs (user_id, enterprise_id, provider, key_id, action) + VALUES ($1, $2, $3, $4, $5) + `, + [userId, enterpriseId, provider, key.id, "acquire"], + ); + + return { + leaseToken, + apiKey: key.api_key === "pool-slot" ? "" : key.api_key, + provider, + keyLabel: key.label || `Key #${key.id}`, + }; + }); +} + +async function drainProviderWaitQueue(provider) { + const existingDrain = providerDrainPromises.get(provider); + if (existingDrain) { + return existingDrain; + } + + const drainPromise = (async () => { + while (true) { + const queue = compactProviderWaitQueue(provider); + const nextWaiter = queue[0]; + if (!nextWaiter) { + break; + } + + let acquiredLease = null; + try { + acquiredLease = await tryAcquireKey(provider, nextWaiter.user, nextWaiter.preauthResult); + } catch (error) { + settleWaiter(nextWaiter, error instanceof Error ? error : new Error(String(error))); + continue; + } + + if (!acquiredLease) { + break; + } + + if (nextWaiter.settled || nextWaiter.signal?.aborted) { + await releaseLeaseInternal(acquiredLease.leaseToken, nextWaiter.user, { skipDrain: true }); + continue; + } + + settleWaiter(nextWaiter, acquiredLease); + } + })().finally(() => { + providerDrainPromises.delete(provider); + }); + + providerDrainPromises.set(provider, drainPromise); + await drainPromise; +} + +function enqueueAcquireWaiter(provider, user, preauthResult, options = {}) { + const waitTimeoutMs = normalizeWaitTimeoutMs(options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS); + if (waitTimeoutMs <= 0) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error("Cancelled")); + return; + } + + const waiter = { + provider, + user, + preauthResult, + resolve, + reject, + settled: false, + signal: options.signal, + abortHandler: null, + timer: null, + }; + + waiter.abortHandler = () => { + settleWaiter(waiter, new Error("Cancelled")); + }; + + if (waiter.signal) { + waiter.signal.addEventListener("abort", waiter.abortHandler, { once: true }); + } + + waiter.timer = setTimeout(() => { + settleWaiter(waiter, null); + }, waitTimeoutMs); + + getProviderWaitQueue(provider).push(waiter); + }); +} + +async function cleanStaleLeases() { + const cutoff = new Date(Date.now() - STALE_LEASE_MINUTES * 60 * 1000).toISOString(); + const { rows: stale } = await pool.query( + ` + SELECT l.id, l.key_id, k.provider + FROM key_leases l + JOIN api_keys k ON k.id = l.key_id + WHERE l.released_at IS NULL AND l.leased_at < $1 + `, + [cutoff], + ); + + if (stale.length === 0) return 0; + + await withTransaction(async (client) => { + for (const lease of stale) { + await client.query("UPDATE key_leases SET released_at = NOW() WHERE id = $1", [lease.id]); + await client.query( + "UPDATE api_keys SET active_count = GREATEST(0, active_count - 1) WHERE id = $1", + [lease.key_id], + ); + } + }); + + const affectedProviders = Array.from( + new Set(stale.map((lease) => lease.provider).filter(Boolean)), + ); + await Promise.all(affectedProviders.map((provider) => drainProviderWaitQueue(provider))); + + return stale.length; +} + +async function acquireKey(provider, user, preauthResult, options = {}) { + await cleanStaleLeases(); + + const immediateLease = await tryAcquireKey(provider, user, preauthResult); + if (immediateLease) { + return immediateLease; + } + + return enqueueAcquireWaiter(provider, user, preauthResult, options); +} + +async function releaseLeaseInternal(leaseToken, user, options = {}) { + const { userId, enterpriseId } = normalizeUserContext(user); + + const releaseResult = await withTransaction(async (client) => { + const { rows } = await client.query( + ` + WITH candidate AS ( + SELECT l.id, l.key_id, k.provider + FROM key_leases l + JOIN api_keys k ON k.id = l.key_id + WHERE l.lease_token = $1 AND l.released_at IS NULL + FOR UPDATE SKIP LOCKED + LIMIT 1 + ), + released AS ( + UPDATE key_leases + SET released_at = NOW() + WHERE id = (SELECT id FROM candidate) + RETURNING id, key_id + ) + SELECT r.id, r.key_id, c.provider + FROM released r + JOIN candidate c ON c.key_id = r.key_id + `, + [leaseToken], + ); + + const lease = rows[0]; + if (!lease) { + const { rows: existingRows } = await client.query( + ` + SELECT l.key_id, l.released_at, k.provider + FROM key_leases l + JOIN api_keys k ON k.id = l.key_id + WHERE l.lease_token = $1 + LIMIT 1 + `, + [leaseToken], + ); + + const existing = existingRows[0]; + if (!existing) { + return { released: false, notFound: true, alreadyReleased: false, provider: null }; + } + + return { + released: false, + notFound: false, + alreadyReleased: Boolean(existing.released_at), + provider: existing.provider || null, + }; + } + + await client.query( + "UPDATE api_keys SET active_count = GREATEST(0, active_count - 1) WHERE id = $1", + [lease.key_id], + ); + await client.query( + ` + INSERT INTO usage_logs (user_id, enterprise_id, provider, key_id, action) + VALUES ($1, $2, (SELECT provider FROM api_keys WHERE id = $3), $4, $5) + `, + [userId, enterpriseId, lease.key_id, lease.key_id, "release"], + ); + + return { + released: true, + notFound: false, + alreadyReleased: false, + provider: lease.provider || null, + }; + }); + + if (releaseResult.released && releaseResult.provider) { + if (options.skipDrain) { + return releaseResult; + } + await drainProviderWaitQueue(releaseResult.provider); + } + + return releaseResult; +} + +async function releaseKey(leaseToken, user) { + return releaseLeaseInternal(leaseToken, user); +} + +function getQueuedWaiterCount(provider) { + return compactProviderWaitQueue(provider).length; +} + +async function getKeyStatus(provider) { + const { rows: keys } = await pool.query( + ` + SELECT id, provider, label, max_concurrency, active_count, total_used, enabled + FROM api_keys + WHERE provider = $1 + ORDER BY id + `, + [provider], + ); + + const totalCapacity = keys.reduce((sum, key) => sum + (key.enabled ? key.max_concurrency : 0), 0); + const totalActive = keys.reduce((sum, key) => sum + key.active_count, 0); + + return { + provider, + keys: keys.map((key) => ({ + id: key.id, + label: key.label || `Key #${key.id}`, + active: key.active_count, + capacity: key.max_concurrency, + totalUsed: key.total_used, + enabled: !!key.enabled, + })), + totalActive, + totalCapacity, + queuedCount: getQueuedWaiterCount(provider), + available: totalCapacity - totalActive, + }; +} + +async function getAllStatus() { + const { rows: providers } = await pool.query( + "SELECT DISTINCT provider FROM api_keys WHERE enabled = 1", + ); + const results = []; + for (const providerRow of providers) { + results.push(await getKeyStatus(providerRow.provider)); + } + return results; +} + +module.exports = { + acquireKey, + releaseKey, + getKeyStatus, + getAllStatus, + cleanStaleLeases, +}; diff --git a/src/ossClient.js b/src/ossClient.js new file mode 100644 index 0000000..1dce761 --- /dev/null +++ b/src/ossClient.js @@ -0,0 +1,114 @@ +/** + * OSS object download utility. + * Uses the same credentials as STS to sign GET requests to Aliyun OSS. + */ + +const crypto = require("node:crypto"); + +const OSS_ACCESS_KEY_ID = process.env.STS_ACCESS_KEY_ID || ""; +const OSS_ACCESS_KEY_SECRET = process.env.STS_ACCESS_KEY_SECRET || ""; +const OSS_BUCKET = process.env.OSS_BUCKET || ""; +const OSS_REGION = process.env.OSS_REGION || ""; + +function getOssEndpoint() { + return `https://${OSS_BUCKET}.${OSS_REGION}.aliyuncs.com`; +} + +function isOssConfigured() { + return !!(OSS_ACCESS_KEY_ID && OSS_ACCESS_KEY_SECRET && OSS_BUCKET && OSS_REGION); +} + +function buildCanonicalOssHeaders(headers = {}) { + return Object.entries(headers) + .filter(([key]) => key.toLowerCase().startsWith("x-oss-")) + .map(([key, value]) => [key.toLowerCase(), String(value).trim()]) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}:${value}\n`) + .join(""); +} + +function signOssRequest(method, objectKey, date, contentType = "", extraHeaders = {}) { + const contentMd5 = ""; + const canonicalResource = `/${OSS_BUCKET}/${objectKey}`; + const canonicalHeaders = buildCanonicalOssHeaders(extraHeaders); + const stringToSign = `${method}\n${contentMd5}\n${contentType}\n${date}\n${canonicalHeaders}${canonicalResource}`; + const signature = crypto + .createHmac("sha1", OSS_ACCESS_KEY_SECRET) + .update(stringToSign) + .digest("base64"); + return `OSS ${OSS_ACCESS_KEY_ID}:${signature}`; +} + +function createSignedReadUrl(objectKey, expiresInSeconds = 24 * 60 * 60) { + if (!isOssConfigured()) { + throw new Error("OSS is not configured"); + } + + const expires = Math.floor(Date.now() / 1000) + expiresInSeconds; + const canonicalResource = `/${OSS_BUCKET}/${objectKey}`; + const stringToSign = `GET\n\n\n${expires}\n${canonicalResource}`; + const signature = crypto + .createHmac("sha1", OSS_ACCESS_KEY_SECRET) + .update(stringToSign) + .digest("base64"); + const encodedKey = encodeURIComponent(objectKey).replace(/%2F/g, "/"); + const query = new URLSearchParams({ + OSSAccessKeyId: OSS_ACCESS_KEY_ID, + Expires: String(expires), + Signature: signature, + }); + + return `${getOssEndpoint()}/${encodedKey}?${query.toString()}`; +} + +async function getObject(objectKey) { + if (!isOssConfigured()) { + throw new Error("OSS is not configured"); + } + + const date = new Date().toUTCString(); + const authorization = signOssRequest("GET", objectKey, date); + const url = `${getOssEndpoint()}/${encodeURIComponent(objectKey).replace(/%2F/g, "/")}`; + + const response = await fetch(url, { + method: "GET", + headers: { Date: date, Authorization: authorization }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + const error = new Error(`OSS GET failed (${response.status}): ${text.slice(0, 200)}`); + error.status = response.status; + if (response.status === 404 && /\s*NoSuchKey\s*<\/Code>|NoSuchKey/i.test(text)) { + error.code = "oss_no_such_key"; + } + throw error; + } + + return response.text(); +} + +async function putObject(objectKey, body, contentType = "application/octet-stream", extraHeaders = {}) { + if (!isOssConfigured()) { + throw new Error("OSS is not configured"); + } + + const date = new Date().toUTCString(); + const authorization = signOssRequest("PUT", objectKey, date, contentType, extraHeaders); + const url = `${getOssEndpoint()}/${encodeURIComponent(objectKey).replace(/%2F/g, "/")}`; + + const response = await fetch(url, { + method: "PUT", + headers: { Date: date, Authorization: authorization, "Content-Type": contentType, ...extraHeaders }, + body, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OSS PUT failed (${response.status}): ${text.slice(0, 200)}`); + } + + return { ossKey: objectKey, url }; +} + +module.exports = { getObject, putObject, isOssConfigured, createSignedReadUrl }; diff --git a/src/paymentAlipay.js b/src/paymentAlipay.js new file mode 100644 index 0000000..29c71d7 --- /dev/null +++ b/src/paymentAlipay.js @@ -0,0 +1,159 @@ +const fs = require("node:fs"); +const { AlipaySdk } = require("alipay-sdk"); +const { pool, withTransaction } = require("./db"); +const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); + +let alipayInstance = null; + +function getAlipay() { + if (alipayInstance) return alipayInstance; + + const appId = process.env.ALIPAY_APP_ID; + const privateKeyPath = process.env.ALIPAY_PRIVATE_KEY_PATH; + const publicKeyPath = process.env.ALIPAY_PUBLIC_KEY_PATH; + const privateKeyStr = process.env.ALIPAY_PRIVATE_KEY; + const publicKeyStr = process.env.ALIPAY_PUBLIC_KEY; + + if (!appId) return null; + + let privateKey = null; + let alipayPublicKey = null; + + try { + if (privateKeyStr) { + privateKey = privateKeyStr; + } else if (privateKeyPath && fs.existsSync(privateKeyPath)) { + privateKey = fs.readFileSync(privateKeyPath, "ascii"); + } + + if (publicKeyStr) { + alipayPublicKey = publicKeyStr; + } else if (publicKeyPath && fs.existsSync(publicKeyPath)) { + alipayPublicKey = fs.readFileSync(publicKeyPath, "ascii"); + } + } catch (err) { + console.error("[alipay] failed to read key files:", err.message); + return null; + } + + if (!privateKey || !alipayPublicKey) { + console.warn("[alipay] private key or public key missing, Alipay disabled"); + return null; + } + + alipayInstance = new AlipaySdk({ + appId, + privateKey, + alipayPublicKey, + keyType: "PKCS8", + }); + + return alipayInstance; +} + +function isAlipayEnabled() { + return getAlipay() !== null; +} + +async function createPrecreateOrder(orderNo, amountCents, description, notifyUrl) { + const alipay = getAlipay(); + if (!alipay) throw new Error("支付宝未配置"); + + const amountYuan = (amountCents / 100).toFixed(2); + + const result = await alipay.curl("POST", "/v3/alipay/trade/precreate", { + body: { + out_trade_no: orderNo, + total_amount: amountYuan, + subject: description, + notify_url: notifyUrl, + }, + }); + + if (result?.data?.qr_code) { + return { qrCode: result.data.qr_code }; + } + + const errMsg = + result?.data?.sub_msg || result?.data?.msg || result?.errorMessage || "支付宝预下单失败"; + throw new Error(errMsg); +} + +function verifyCallback(headers, body) { + const alipay = getAlipay(); + if (!alipay) return false; + + try { + const signature = headers["alipay-signature"]; + const timestamp = headers["alipay-timestamp"]; + const nonce = headers["alipay-nonce"]; + + if (!signature || !timestamp) { + console.error("[alipay] callback missing signature headers"); + return false; + } + + const signContent = `${timestamp}\n${nonce}\n${typeof body === "string" ? body : JSON.stringify(body)}\n`; + const verified = alipay.verifySignature(signature, signContent); + + if (!verified) { + console.error("[alipay] callback signature verification failed"); + return false; + } + + return true; + } catch (err) { + console.error("[alipay] signature verification error:", err.message); + return false; + } +} + +async function handlePaymentSuccess(orderNo, tradeNo) { + const { rows } = await pool.query( + "SELECT * FROM payment_orders WHERE order_no = $1 AND status = $2", + [orderNo, "pending"], + ); + const order = rows[0]; + if (!order) { + console.warn("[alipay] no pending order found for", orderNo); + return false; + } + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE payment_orders + SET status = 'paid', payment_trade_no = $1, paid_at = NOW() + WHERE order_no = $2 + `, + [tradeNo, orderNo], + ); + + if (order.type === "personal_recharge" && order.user_id) { + await creditUserBalance( + order.user_id, + order.amount_cents, + `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + orderNo, + ); + } else if (order.type === "recharge") { + await creditBalance( + order.enterprise_id, + order.amount_cents, + `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + orderNo, + ); + } else if (order.type === "package" && order.package_id) { + await activatePackage(order.enterprise_id, order.package_id); + } + }); + + return true; +} + +module.exports = { + isAlipayEnabled, + createPrecreateOrder, + verifyCallback, + handlePaymentSuccess, +}; diff --git a/src/paymentWechat.js b/src/paymentWechat.js new file mode 100644 index 0000000..05374d2 --- /dev/null +++ b/src/paymentWechat.js @@ -0,0 +1,169 @@ +const _crypto = require("node:crypto"); +const fs = require("node:fs"); +const WxPay = require("wechatpay-node-v3"); +const { pool, withTransaction } = require("./db"); +const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); + +let wxPayInstance = null; + +function getWxPay() { + if (wxPayInstance) return wxPayInstance; + + const mchId = process.env.WECHAT_MCH_ID; + const appId = process.env.WECHAT_APP_ID; + const apiKey = process.env.WECHAT_API_KEY_V3; + const certPath = process.env.WECHAT_CERT_PATH; + const keyPath = process.env.WECHAT_KEY_PATH; + + if (!mchId || !appId || !apiKey) return null; + + let publicKey = null; + let privateKey = null; + + try { + if (certPath && fs.existsSync(certPath)) publicKey = fs.readFileSync(certPath); + if (keyPath && fs.existsSync(keyPath)) privateKey = fs.readFileSync(keyPath); + } catch (err) { + console.error("[wechatPay] failed to read cert/key files:", err.message); + return null; + } + + if (!publicKey || !privateKey) { + console.warn("[wechatPay] cert or key file missing, WeChat Pay disabled"); + return null; + } + + wxPayInstance = new WxPay({ appid: appId, mchid: mchId, publicKey, privateKey }); + return wxPayInstance; +} + +function isWechatPayEnabled() { + return getWxPay() !== null; +} + +async function createNativeOrder(orderNo, amountCents, description, notifyUrl) { + const pay = getWxPay(); + if (!pay) throw new Error("微信支付未配置"); + + const nonceStr = Math.random().toString(36).substring(2, 17); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const url = "/v3/pay/transactions/native"; + + const params = { + appid: process.env.WECHAT_APP_ID, + mchid: process.env.WECHAT_MCH_ID, + description, + out_trade_no: orderNo, + notify_url: notifyUrl, + amount: { total: amountCents, currency: "CNY" }, + }; + + const signature = pay.getSignature("POST", nonceStr, timestamp, url, params); + const authorization = pay.getAuthorization(nonceStr, timestamp, signature); + + const superagent = require("superagent"); + const result = await superagent + .post("https://api.mch.weixin.qq.com/v3/pay/transactions/native") + .send(params) + .set({ + Accept: "application/json", + "Content-Type": "application/json", + Authorization: authorization, + }); + + if (!result.body?.code_url) { + throw new Error(result.body?.message || "微信下单失败"); + } + + return { codeUrl: result.body.code_url }; +} + +function verifyAndDecryptNotification(headers, body) { + const pay = getWxPay(); + if (!pay) return null; + + try { + const signature = headers["wechatpay-signature"]; + const timestamp = headers["wechatpay-timestamp"]; + const nonce = headers["wechatpay-nonce"]; + + if (!signature || !timestamp || !nonce) { + console.error("[wechatPay] callback missing required headers"); + return null; + } + + const message = `${timestamp}\n${nonce}\n${typeof body === "string" ? body : JSON.stringify(body)}\n`; + const verified = pay.verifySignature(signature, timestamp, nonce, message); + if (!verified) { + console.error("[wechatPay] callback signature verification failed"); + return null; + } + + const resource = body.resource || body; + if (resource.ciphertext) { + const apiKey = process.env.WECHAT_API_KEY_V3; + const decrypted = pay.decipher_gcm( + resource.ciphertext, + resource.associated_data || "", + resource.nonce || "", + apiKey, + ); + return JSON.parse(decrypted); + } + + return body; + } catch (err) { + console.error("[wechatPay] notification processing failed:", err.message); + return null; + } +} + +async function handlePaymentSuccess(orderNo, transactionId) { + const { rows } = await pool.query( + "SELECT * FROM payment_orders WHERE order_no = $1 AND status = $2", + [orderNo, "pending"], + ); + const order = rows[0]; + if (!order) { + console.warn("[wechatPay] no pending order found for", orderNo); + return false; + } + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE payment_orders + SET status = 'paid', payment_trade_no = $1, paid_at = NOW() + WHERE order_no = $2 + `, + [transactionId, orderNo], + ); + + if (order.type === "personal_recharge" && order.user_id) { + await creditUserBalance( + order.user_id, + order.amount_cents, + `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + orderNo, + ); + } else if (order.type === "recharge") { + await creditBalance( + order.enterprise_id, + order.amount_cents, + `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + orderNo, + ); + } else if (order.type === "package" && order.package_id) { + await activatePackage(order.enterprise_id, order.package_id); + } + }); + + return true; +} + +module.exports = { + isWechatPayEnabled, + createNativeOrder, + verifyAndDecryptNotification, + handlePaymentSuccess, +}; diff --git a/src/pricing.js b/src/pricing.js new file mode 100644 index 0000000..607fe83 --- /dev/null +++ b/src/pricing.js @@ -0,0 +1,202 @@ +/** + * Model pricing helpers backed by the database. + * + * All monetary values are stored as integers: + * - prices: mills (厘, 1/1000 CNY) — allows fine-grained pricing + * - costs calculated in mills, converted to cents only at billing layer + * + * Currency is unified to CNY; USD models are converted at retail rates. + */ + +const { pool } = require("./db"); + +const LEGACY_MODEL_PRICING_MILLS = { + "qwen-max": { input: 20, output: 60, currency: "CNY" }, + "qwen-max-latest": { input: 20, output: 60, currency: "CNY" }, + "qwen-plus": { input: 4, output: 12, currency: "CNY" }, + "qwen-plus-latest": { input: 4, output: 12, currency: "CNY" }, + "qwen-turbo": { input: 2, output: 6, currency: "CNY" }, + "qwen-turbo-latest": { input: 2, output: 6, currency: "CNY" }, + "qwen-long": { input: 0.5, output: 2, currency: "CNY" }, + "qwen3-235b-a22b": { input: 20, output: 60, currency: "CNY" }, + "qwen3-32b": { input: 4, output: 12, currency: "CNY" }, + "qwen3-14b": { input: 2, output: 6, currency: "CNY" }, + + "gemini-3.1-pro": { input: 15, output: 45, currency: "CNY" }, + + "nano-banana-pro": { flat: 200, currency: "CNY" }, + "nano-banana-2": { flat: 200, currency: "CNY" }, + "nano-banana-fast": { flat: 200, currency: "CNY" }, + "gpt-image-2": { flat: 200, currency: "CNY" }, + "gpt-image-2-vip": { flat: 200, currency: "CNY" }, + "wan2.7-image": { flat: 200, currency: "CNY" }, + "wan2.7-image-pro": { flat: 200, currency: "CNY" }, + + "kling-v2-master": { flat: 100, currency: "CNY" }, + "kling-v2-std": { flat: 50, currency: "CNY" }, + "kling-v1-std": { flat: 50, currency: "CNY" }, + "kling-v1-pro": { flat: 100, currency: "CNY" }, + "kling-v3-omni": { flat: 100, currency: "CNY" }, + + "seedance-2.0": { flat: 50, currency: "CNY" }, + "seedance-2.0-fast": { flat: 30, currency: "CNY" }, + "seedance-2.0-pro": { flat: 80, currency: "CNY" }, + "seedance-2.0-lite": { flat: 20, currency: "CNY" }, + "seedance-2.0-ark": { flat: 50, currency: "CNY" }, + "seedance-2.0-fast-ark": { flat: 30, currency: "CNY" }, + + "gpt-4o": { input: 27, output: 108, currency: "CNY" }, + "gpt-4o-mini": { input: 2, output: 6, currency: "CNY" }, + "gpt-4.1": { input: 22, output: 86, currency: "CNY" }, + "gpt-4.1-mini": { input: 4, output: 17, currency: "CNY" }, + "gpt-4.1-nano": { input: 1, output: 4, currency: "CNY" }, +}; + +function inferCategory(modelKey, pricing) { + if (!pricing.flat) return "text"; + + const normalized = modelKey.toLowerCase(); + if ( + normalized.includes("kling") || + normalized.includes("seedance") || + normalized.includes("video") || + normalized.includes("omni") + ) { + return "video"; + } + + return "image"; +} + +const DEFAULT_MODEL_PRICES = [ + ...Object.entries(LEGACY_MODEL_PRICING_MILLS).map(([modelKey, pricing]) => ({ + modelKey, + displayName: modelKey, + category: inferCategory(modelKey, pricing), + pricingType: pricing.flat ? "flat" : "token", + inputPriceMills: pricing.input != null ? Math.round(pricing.input) : null, + outputPriceMills: pricing.output != null ? Math.round(pricing.output) : null, + flatPriceMills: pricing.flat != null ? Math.round(pricing.flat) : null, + inputPrice: pricing.input ?? null, + outputPrice: pricing.output ?? null, + flatPrice: pricing.flat ?? null, + currency: pricing.currency || "CNY", + enabled: 1, + })), + { + modelKey: "image-generation-flat", + displayName: "图片生成统一计费", + category: "image", + pricingType: "flat", + inputPriceMills: null, + outputPriceMills: null, + flatPriceMills: 200, + inputPrice: null, + outputPrice: null, + flatPrice: 200, + currency: "CNY", + enabled: 1, + }, +]; + +function normalizeModelPriceRow(row) { + if (!row) return null; + + return { + id: row.id, + modelKey: row.model_key, + displayName: row.display_name, + category: row.category, + pricingType: row.pricing_type, + inputPriceMills: row.input_price_mills ?? null, + outputPriceMills: row.output_price_mills ?? null, + flatPriceMills: row.flat_price_mills ?? null, + currency: row.currency || "CNY", + enabled: !!row.enabled, + createdAt: row.created_at, + }; +} + +function getModelPrice(modelKey, _options = {}) { + // getModelPrice is still synchronous for use in calculateCostMills + // We cache prices in a module-level map + return _priceCache.get(modelKey) || null; +} + +const _priceCache = new Map(); + +async function loadPriceCache() { + const { rows } = await pool.query("SELECT * FROM model_prices WHERE enabled = 1"); + _priceCache.clear(); + for (const row of rows) { + _priceCache.set(row.model_key, normalizeModelPriceRow(row)); + } +} + +async function getModelPriceAsync(modelKey, options = {}) { + const { includeDisabled = false } = options; + const sql = includeDisabled + ? "SELECT * FROM model_prices WHERE model_key = $1 LIMIT 1" + : "SELECT * FROM model_prices WHERE model_key = $1 AND enabled = 1 LIMIT 1"; + const { rows } = await pool.query(sql, [modelKey]); + return normalizeModelPriceRow(rows[0]); +} + +async function listModelPrices(options = {}) { + const { enabledOnly = false } = options; + const sql = enabledOnly + ? "SELECT * FROM model_prices WHERE enabled = 1 ORDER BY category, model_key" + : "SELECT * FROM model_prices ORDER BY category, model_key"; + const { rows } = await pool.query(sql); + return rows.map(normalizeModelPriceRow); +} + +function calculateCostMills(model, promptTokens, completionTokens) { + const pricing = getModelPrice(model); + if (!pricing) return null; + + if (pricing.pricingType === "flat") return pricing.flatPriceMills; + + const inputCost = Math.round(((promptTokens || 0) / 1000) * (pricing.inputPriceMills || 0)); + const outputCost = Math.round(((completionTokens || 0) / 1000) * (pricing.outputPriceMills || 0)); + return inputCost + outputCost; +} + +function calculateCost(model, promptTokens, completionTokens) { + const mills = calculateCostMills(model, promptTokens, completionTokens); + if (mills == null) return null; + return Math.round(mills) / 1000; +} + +function getCurrency(_model) { + return "CNY"; +} + +async function getAverageCostCents(provider) { + const { rows } = await pool.query( + ` + SELECT CAST(ROUND(AVG(CASE + WHEN cost_estimate IS NOT NULL THEN cost_estimate * 100 + ELSE 0 + END)::numeric) AS INTEGER) AS avg_cents + FROM api_call_logs + WHERE provider = $1 AND status = 'success' AND created_at >= NOW() - INTERVAL '7 days' + `, + [provider], + ); + return rows[0]?.avg_cents || 0; +} + +module.exports = { + DEFAULT_MODEL_PRICES, + LEGACY_MODEL_PRICING_MILLS, + normalizeModelPriceRow, + getModelPrice, + getModelPriceAsync, + listModelPrices, + calculateCostMills, + calculateCost, + getCurrency, + getAverageCostCents, + loadPriceCache, +}; diff --git a/src/projectRevisionLogic.js b/src/projectRevisionLogic.js new file mode 100644 index 0000000..daa5779 --- /dev/null +++ b/src/projectRevisionLogic.js @@ -0,0 +1,30 @@ +function normalizeRevisionValue(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return Math.trunc(numeric); +} + +function computeNextRevision(existingRevision, baseRevision) { + const currentRevision = normalizeRevisionValue(existingRevision); + const normalizedBaseRevision = normalizeRevisionValue(baseRevision); + const nextRevision = currentRevision > 0 ? currentRevision + 1 : 1; + + return { + currentRevision, + normalizedBaseRevision, + nextRevision, + baseWasStale: currentRevision > 0 && normalizedBaseRevision < currentRevision, + }; +} + +function shouldRejectStaleRevision(existingRevision, baseRevision, forceOverwrite) { + return computeNextRevision(existingRevision, baseRevision).baseWasStale && !forceOverwrite; +} + +module.exports = { + computeNextRevision, + normalizeRevisionValue, + shouldRejectStaleRevision, +}; diff --git a/src/providerCircuitBreaker.js b/src/providerCircuitBreaker.js new file mode 100644 index 0000000..c6c1c34 --- /dev/null +++ b/src/providerCircuitBreaker.js @@ -0,0 +1,130 @@ +"use strict"; + +const WINDOW_SIZE = 20; +const FAILURE_THRESHOLD = 3; +const OPEN_DURATION_MS = 120_000; +const HALF_OPEN_PROBE_INTERVAL_MS = 30_000; + +const State = { CLOSED: "CLOSED", OPEN: "OPEN", HALF_OPEN: "HALF_OPEN" }; + +class ProviderBreaker { + constructor(provider) { + this.provider = provider; + this.state = State.CLOSED; + this.window = []; + this.consecutiveFailures = 0; + this.openedAt = 0; + this.lastProbeAt = 0; + } + + getState() { + if (this.state === State.OPEN) { + const elapsed = Date.now() - this.openedAt; + if (elapsed >= OPEN_DURATION_MS) { + this.state = State.HALF_OPEN; + } + } + return this.state; + } + + shouldSkip() { + const s = this.getState(); + if (s === State.CLOSED) return false; + if (s === State.HALF_OPEN) { + if (Date.now() - this.lastProbeAt >= HALF_OPEN_PROBE_INTERVAL_MS) { + this.lastProbeAt = Date.now(); + return false; + } + return true; + } + return true; + } + + recordSuccess(latencyMs) { + this.window.push({ ok: true, latencyMs, ts: Date.now() }); + if (this.window.length > WINDOW_SIZE) this.window.shift(); + this.consecutiveFailures = 0; + if (this.state !== State.CLOSED) { + this.state = State.CLOSED; + } + } + + recordFailure() { + this.window.push({ ok: false, latencyMs: 0, ts: Date.now() }); + if (this.window.length > WINDOW_SIZE) this.window.shift(); + this.consecutiveFailures += 1; + if (this.consecutiveFailures >= FAILURE_THRESHOLD && this.state === State.CLOSED) { + this.state = State.OPEN; + this.openedAt = Date.now(); + } + } + + getAdaptiveTimeoutMs(defaultMs) { + const successes = this.window.filter((r) => r.ok && r.latencyMs > 0); + if (successes.length < 3) return defaultMs; + const sorted = successes.map((r) => r.latencyMs).sort((a, b) => a - b); + const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95)); + const p95 = sorted[p95Index]; + return Math.max(30_000, Math.min(defaultMs, Math.ceil(p95 * 1.3))); + } + + getStats() { + const total = this.window.length; + const failures = this.window.filter((r) => !r.ok).length; + const successes = this.window.filter((r) => r.ok); + const avgLatency = successes.length + ? Math.round(successes.reduce((s, r) => s + r.latencyMs, 0) / successes.length) + : 0; + return { + provider: this.provider, + state: this.getState(), + total, + failures, + successRate: total ? ((total - failures) / total * 100).toFixed(1) + "%" : "N/A", + avgLatencyMs: avgLatency, + adaptiveTimeoutMs: this.getAdaptiveTimeoutMs(150_000), + consecutiveFailures: this.consecutiveFailures, + }; + } +} + +const breakers = new Map(); + +function getBreaker(provider) { + if (!breakers.has(provider)) { + breakers.set(provider, new ProviderBreaker(provider)); + } + return breakers.get(provider); +} + +function shouldSkipProvider(provider) { + return getBreaker(provider).shouldSkip(); +} + +function recordProviderSuccess(provider, latencyMs) { + getBreaker(provider).recordSuccess(latencyMs); +} + +function recordProviderFailure(provider) { + getBreaker(provider).recordFailure(); +} + +function getAdaptiveTimeout(provider, defaultMs) { + return getBreaker(provider).getAdaptiveTimeoutMs(defaultMs); +} + +function getAllBreakerStats() { + const stats = []; + for (const [, breaker] of breakers) { + stats.push(breaker.getStats()); + } + return stats; +} + +module.exports = { + shouldSkipProvider, + recordProviderSuccess, + recordProviderFailure, + getAdaptiveTimeout, + getAllBreakerStats, +}; \ No newline at end of file diff --git a/src/providerHealthMonitor.js b/src/providerHealthMonitor.js new file mode 100644 index 0000000..0b7cd61 --- /dev/null +++ b/src/providerHealthMonitor.js @@ -0,0 +1,259 @@ +/** + * Provider Health Monitor — periodic health checks for DashScope and other providers. + * + * - Every 5 minutes, probes DashScope with a lightweight text call + * - If Arrearage or auth failure detected, logs error + inserts admin notification + * - Tracks provider health status in-memory for the /api/admin/providers/status endpoint + */ + +const { pool } = require("./db"); + +const CHECK_INTERVAL_MS = 5 * 60 * 1000; +const DASHSCOPE_TEST_MODEL = "qwen-max"; +const DASHSCOPE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; +const LOW_BALANCE_THRESHOLD = 500; // cents — alert when balance below this + +let timerId = null; + +// In-memory health cache for the status endpoint +const providerHealthCache = { + dashscope: { status: "unknown", lastCheck: null, lastError: null, details: null }, + grsai: { status: "unknown", lastCheck: null, lastError: null, details: null }, +}; + +async function getDashScopeKey() { + const { rows } = await pool.query( + "SELECT id, api_key FROM api_keys WHERE provider LIKE '%dashscope%' AND enabled = 1 ORDER BY id LIMIT 1" + ); + if (!rows.length) return null; + return rows[0].api_key; +} + +async function getGrsaiKey() { + const { rows } = await pool.query( + "SELECT id, api_key FROM api_keys WHERE provider = 'grsai' AND enabled = 1 ORDER BY id LIMIT 1" + ); + if (!rows.length) return null; + return rows[0].api_key; +} + +async function probeDashScope(apiKey) { + const body = { + model: DASHSCOPE_TEST_MODEL, + messages: [{ role: "user", content: "ping" }], + stream: false, + max_tokens: 4, + enable_thinking: false, + }; + const res = await fetch(DASHSCOPE_URL, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }); + const text = await res.text(); + + if (res.status === 400 || res.status === 403) { + let json = {}; + try { json = JSON.parse(text); } catch {} + const errorCode = json.error?.code || ""; + if (errorCode === "Arrearage") { + return { ok: false, status: "arrears", error: "DashScope 账户欠费,所有 qwen 模型不可用", code: errorCode }; + } + if (errorCode === "AccessDenied" || res.status === 403) { + return { ok: false, status: "denied", error: "DashScope 访问被拒绝", code: errorCode }; + } + return { ok: false, status: "error", error: `DashScope 返回 HTTP ${res.status}: ${errorCode}`, code: errorCode }; + } + + if (!res.ok) { + return { ok: false, status: "error", error: `DashScope 返回 HTTP ${res.status}`, code: "http_error" }; + } + + return { ok: true, status: "healthy", error: null }; +} + +async function probeGrsai(apiKey) { + // GrsAI uses the same OpenAI-compatible endpoint + const GRSAI_BASE = "https://grsai.dakka.com.cn"; + const url = `${GRSAI_BASE}/v1/chat/completions`; + const body = { + model: "gemini-3.1-pro", + messages: [{ role: "user", content: "ping" }], + stream: false, + max_tokens: 4, + }; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + return { ok: false, status: "error", error: `GrsAI 返回 HTTP ${res.status}: ${errText.slice(0, 200)}` }; + } + + return { ok: true, status: "healthy", error: null }; +} + +async function notifyAdmin(title, description) { + // Find admin users to notify + const { rows: admins } = await pool.query( + "SELECT id FROM users WHERE role = 'admin' AND enabled = 1" + ); + if (!admins.length) { + console.error("[providerHealthMonitor] No admin users found for notification"); + return; + } + for (const admin of admins) { + await pool.query( + `INSERT INTO web_notifications (user_id, type, title, description, metadata_json) + VALUES ($1, 'provider_health', $2, $3, '{}')`, + [admin.id, title, description] + ); + } +} + +async function runHealthCheck() { + // ── DashScope ── + const dashKey = await getDashScopeKey(); + if (dashKey) { + try { + const result = await probeDashScope(dashKey); + const prev = providerHealthCache.dashscope.status; + providerHealthCache.dashscope = { + status: result.status, + lastCheck: new Date().toISOString(), + lastError: result.error, + details: result, + }; + + if (!result.ok) { + console.error(`[providerHealthMonitor] DashScope unhealthy: ${result.error}`); + // Only notify on state change (healthy → unhealthy) + if (prev === "healthy" || prev === "unknown") { + await notifyAdmin("DashScope 服务异常", result.error); + } + } else { + // Recovery notification + if (prev !== "healthy" && prev !== "unknown") { + console.log("[providerHealthMonitor] DashScope recovered"); + await notifyAdmin("DashScope 服务恢复正常", "DashScope 已恢复正常可用状态"); + } + } + } catch (err) { + providerHealthCache.dashscope = { + status: "timeout", + lastCheck: new Date().toISOString(), + lastError: err.message, + details: null, + }; + console.error("[providerHealthMonitor] DashScope probe failed:", err.message); + } + } else { + providerHealthCache.dashscope = { + status: "no_key", + lastCheck: new Date().toISOString(), + lastError: "No DashScope API key found in database", + details: null, + }; + } + + // ── GrsAI ── + const grsaiKey = await getGrsaiKey(); + if (grsaiKey) { + try { + const result = await probeGrsai(grsaiKey); + const prev = providerHealthCache.grsai.status; + providerHealthCache.grsai = { + status: result.status, + lastCheck: new Date().toISOString(), + lastError: result.error, + details: result, + }; + + if (!result.ok) { + console.error(`[providerHealthMonitor] GrsAI unhealthy: ${result.error}`); + if (prev === "healthy" || prev === "unknown") { + await notifyAdmin("GrsAI 服务异常", result.error); + } + } else { + if (prev !== "healthy" && prev !== "unknown") { + console.log("[providerHealthMonitor] GrsAI recovered"); + await notifyAdmin("GrsAI 服务恢复正常", "GrsAI 已恢复正常可用状态"); + } + } + } catch (err) { + providerHealthCache.grsai = { + status: "timeout", + lastCheck: new Date().toISOString(), + lastError: err.message, + details: null, + }; + console.error("[providerHealthMonitor] GrsAI probe failed:", err.message); + } + } else { + providerHealthCache.grsai = { + status: "no_key", + lastCheck: new Date().toISOString(), + lastError: "No GrsAI API key found in database", + details: null, + }; + } + + // ── Circuit breaker summary ── + try { + const cb = require("./providerCircuitBreaker"); + providerHealthCache.circuitBreaker = cb.getProviderStatusMap ? cb.getProviderStatusMap() : null; + } catch {} + + // ── Admin low-balance alert ── + try { + const { rows } = await pool.query( + "SELECT id, username, balance_cents FROM users WHERE role = 'admin' AND enabled = 1 AND balance_cents < $1", + [LOW_BALANCE_THRESHOLD] + ); + for (const user of rows) { + console.warn(`[providerHealthMonitor] Admin ${user.username} balance low: ${user.balance_cents} cents`); + } + } catch {} +} + +function startProviderHealthMonitor() { + if (timerId) return; + + runHealthCheck().catch((err) => { + console.error("[providerHealthMonitor] initial run failed:", err.message); + }); + + timerId = setInterval(() => { + runHealthCheck().catch((err) => { + console.error("[providerHealthMonitor] periodic run failed:", err.message); + }); + }, CHECK_INTERVAL_MS); + + if (timerId.unref) timerId.unref(); + + console.log(`[providerHealthMonitor] started (interval=${CHECK_INTERVAL_MS}ms)`); +} + +function stopProviderHealthMonitor() { + if (timerId) { + clearInterval(timerId); + timerId = null; + console.log("[providerHealthMonitor] stopped"); + } +} + +function getProviderHealthCache() { + return providerHealthCache; +} + +module.exports = { + startProviderHealthMonitor, + stopProviderHealthMonitor, + getProviderHealthCache, + runHealthCheck, +}; \ No newline at end of file diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 0000000..3c5b41e --- /dev/null +++ b/src/routes.js @@ -0,0 +1 @@ +module.exports = require("./routes/index"); diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..05f41b4 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,728 @@ +const { + bcrypt, + requireAuth, + requireAdmin, + requireEnterpriseAdmin, + listModelPrices, + loadPriceCache, + creditUserBalance, + pool, + validateUsername, + validatePassword, + validateEnterpriseName, + ensureEnterpriseExists, + formatUserRow, + readModelPricePayload, + getModelPriceById, +} = require("./context"); + +function registerAdminRoutes(router) { + // ── Admin: Users ───────────────────────────────────────────────────── + + router.get("/admin/users", requireAuth, requireAdmin, async (_req, res) => { + const { rows } = await pool.query(` + SELECT + u.id, u.username, u.role, u.max_concurrency, u.enabled, + u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents, + u.billing_mode, u.beta_expires_at, u.created_at, + e.name AS enterprise_name + FROM users u + LEFT JOIN enterprises e ON e.id = u.enterprise_id + ORDER BY u.id + `); + res.json(rows.map(formatUserRow)); + }); + + router.post("/admin/users", requireAuth, requireAdmin, async (req, res) => { + const { + username, + password, + role = "user", + maxConcurrency = 30, + enterpriseId = null, + isEnterpriseAdmin = false, + } = req.body; + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + + if (enterpriseId != null && !(await ensureEnterpriseExists(enterpriseId))) { + return res.status(400).json({ error: "企业不存在" }); + } + + try { + const hash = await bcrypt.hash(password, 10); + const { + rows: [row], + } = 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, 0) + RETURNING id + `, + [ + username, + hash, + role, + Number(maxConcurrency) || 30, + enterpriseId, + isEnterpriseAdmin ? 1 : 0, + ], + ); + + const { + rows: [userRow], + } = await pool.query( + ` + SELECT u.*, e.name AS enterprise_name + FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id + WHERE u.id = $1 + `, + [row.id], + ); + + res.json(formatUserRow(userRow)); + } catch (error) { + console.error("[admin/users:create] failed", error); + res.status(409).json({ error: "用户名已存在" }); + } + }); + + router.put("/admin/users/:id", requireAuth, requireAdmin, async (req, res) => { + const { + enabled, + role, + maxConcurrency, + password, + enterpriseId, + isEnterpriseAdmin, + billingMode, + betaExpiresAt, + } = req.body; + const updates = []; + const params = []; + let idx = 1; + + if (enabled !== undefined) { + updates.push(`enabled = $${idx++}`); + params.push(enabled ? 1 : 0); + } + if (role) { + updates.push(`role = $${idx++}`); + params.push(role); + } + if (maxConcurrency !== undefined) { + updates.push(`max_concurrency = $${idx++}`); + params.push(Number(maxConcurrency) || 30); + } + if (password) { + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + updates.push(`password_hash = $${idx++}`); + params.push(await bcrypt.hash(password, 10)); + } + if (enterpriseId !== undefined) { + if (enterpriseId != null && !(await ensureEnterpriseExists(enterpriseId))) + return res.status(400).json({ error: "企业不存在" }); + updates.push(`enterprise_id = $${idx++}`); + params.push(enterpriseId); + } + if (isEnterpriseAdmin !== undefined) { + updates.push(`is_enterprise_admin = $${idx++}`); + params.push(isEnterpriseAdmin ? 1 : 0); + } + if (billingMode !== undefined) { + const mode = String(billingMode || "credits").trim(); + if (!["credits", "beta_unlimited"].includes(mode)) { + return res.status(400).json({ error: "billingMode 无效" }); + } + updates.push(`billing_mode = $${idx++}`); + params.push(mode); + } + if (betaExpiresAt !== undefined) { + updates.push(`beta_expires_at = $${idx++}`); + params.push(betaExpiresAt || null); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + updates.push(`updated_at = NOW()`); + params.push(req.params.id); + + await pool.query(`UPDATE users SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); + + router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => { + const targetUserId = Number(req.params.id); + const { amountCents } = req.body; + if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" }); + + try { + const newBalance = await creditUserBalance( + targetUserId, + amountCents, + `管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`, + ); + res.json({ success: true, newBalanceCents: newBalance }); + } catch (err) { + res.status(400).json({ error: err.message || "发放积分失败" }); + } + }); + + // ── Admin: Sub-accounts (enterprise admin) ─────────────────────────── + + router.get("/admin/sub-accounts", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { rows } = await pool.query( + ` + SELECT u.id, u.username, u.role, u.max_concurrency, u.enabled, + u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents, + u.billing_mode, u.beta_expires_at, u.created_at, + e.name AS enterprise_name + FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id + WHERE u.enterprise_id = $1 + ORDER BY u.is_enterprise_admin DESC, u.id ASC + `, + [req.user.enterpriseId], + ); + res.json(rows.map(formatUserRow)); + }); + + router.post("/admin/sub-accounts", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { username, password, maxConcurrency = 30 } = req.body; + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + + try { + const hash = await bcrypt.hash(password, 10); + const { + rows: [row], + } = await pool.query( + ` + INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, 'user', $3, $4, 0, 0) + RETURNING id + `, + [username, hash, Number(maxConcurrency) || 30, req.user.enterpriseId], + ); + + const { + rows: [userRow], + } = await pool.query( + ` + SELECT u.id, u.username, u.role, u.max_concurrency, u.enabled, + u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents, + u.billing_mode, u.beta_expires_at, u.created_at, + e.name AS enterprise_name + FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id + WHERE u.id = $1 + `, + [row.id], + ); + + res.json(formatUserRow(userRow)); + } catch (error) { + console.error("[admin/sub-accounts:create] failed", error); + res.status(409).json({ error: "用户名已存在" }); + } + }); + + router.put("/admin/sub-accounts/:id", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { + rows: [target], + } = await pool.query("SELECT id, enterprise_id, is_enterprise_admin FROM users WHERE id = $1", [ + req.params.id, + ]); + if (!target || Number(target.enterprise_id || 0) !== Number(req.user.enterpriseId || 0)) { + return res.status(404).json({ error: "子账号不存在" }); + } + + const { enabled, maxConcurrency, password } = req.body; + const updates = []; + const params = []; + let idx = 1; + + if (enabled !== undefined) { + if (Number(target.id) === Number(req.user.id) && !enabled) + return res.status(400).json({ error: "不能禁用当前企业管理员账号" }); + updates.push(`enabled = $${idx++}`); + params.push(enabled ? 1 : 0); + } + if (maxConcurrency !== undefined) { + updates.push(`max_concurrency = $${idx++}`); + params.push(Number(maxConcurrency) || 30); + } + if (password) { + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + updates.push(`password_hash = $${idx++}`); + params.push(await bcrypt.hash(password, 10)); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + updates.push("updated_at = NOW()"); + params.push(req.params.id); + await pool.query(`UPDATE users SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); + + + // ── Admin: Provider Health ──────────────────────────────────────────── + router.get('/admin/providers/status', requireAuth, requireAdmin, async (_req, res) => { + const { getProviderHealthCache } = require('../providerHealthMonitor'); + const { pool } = require('./context'); + const health = getProviderHealthCache(); + + const { rows: callStats } = await pool.query( + 'SELECT provider, model, status, COUNT(*) AS count, AVG(duration_ms) AS avg_ms, SUM(cost_estimate) AS total_cost FROM api_call_logs WHERE created_at > NOW() - INTERVAL \$1 GROUP BY provider, model, status ORDER BY provider, model', + ['1 hour'] + ); + + const { rows: keyStats } = await pool.query( + 'SELECT provider, COUNT(*) AS total_keys, SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS active_keys, SUM(active_count) AS current_load FROM api_keys GROUP BY provider ORDER BY provider' + ); + + res.json({ health, callStats, keyStats, checkedAt: new Date().toISOString() }); + }); + + // ── Admin: Prices ──────────────────────────────────────────────────── + + router.get("/admin/prices", requireAuth, requireAdmin, async (_req, res) => { + const prices = await listModelPrices(); + res.json(prices); + }); + + router.post("/admin/prices", requireAuth, requireAdmin, async (req, res) => { + const payload = readModelPricePayload(req.body); + if (payload.error) return res.status(400).json({ error: payload.error }); + + try { + const { + rows: [row], + } = 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) + RETURNING id + `, + [ + payload.value.modelKey, + payload.value.displayName, + payload.value.category, + payload.value.pricingType, + payload.value.inputPriceMills, + payload.value.outputPriceMills, + payload.value.flatPriceMills, + payload.value.currency, + payload.value.enabled ? 1 : 0, + ], + ); + + await loadPriceCache(); + res.json(await getModelPriceById(row.id)); + } catch (error) { + console.error("[admin/prices:create] failed", error); + res.status(409).json({ error: "模型标识已存在" }); + } + }); + + router.put("/admin/prices/:id", requireAuth, requireAdmin, async (req, res) => { + const existing = await getModelPriceById(req.params.id); + if (!existing) return res.status(404).json({ error: "定价不存在" }); + + const payload = readModelPricePayload(req.body, existing); + if (payload.error) return res.status(400).json({ error: payload.error }); + + try { + await pool.query( + ` + UPDATE model_prices SET model_key = $1, display_name = $2, category = $3, pricing_type = $4, + input_price_mills = $5, output_price_mills = $6, flat_price_mills = $7, currency = $8, enabled = $9 + WHERE id = $10 + `, + [ + payload.value.modelKey, + payload.value.displayName, + payload.value.category, + payload.value.pricingType, + payload.value.inputPriceMills, + payload.value.outputPriceMills, + payload.value.flatPriceMills, + payload.value.currency, + payload.value.enabled ? 1 : 0, + req.params.id, + ], + ); + + await loadPriceCache(); + res.json(await getModelPriceById(req.params.id)); + } catch (error) { + console.error("[admin/prices:update] failed", error); + res.status(409).json({ error: "模型标识已存在" }); + } + }); + + // ── Admin: Keys ────────────────────────────────────────────────────── + + router.get("/admin/keys", requireAuth, requireAdmin, async (_req, res) => { + const { rows } = await pool.query( + "SELECT id, provider, label, max_concurrency, active_count, total_used, enabled, created_at FROM api_keys ORDER BY provider, id", + ); + res.json(rows); + }); + + router.post("/admin/keys", requireAuth, requireAdmin, async (req, res) => { + const { provider, api_key, label = "", max_concurrency = 10 } = req.body; + if (!provider || !api_key) return res.status(400).json({ error: "缺少 provider 或 api_key" }); + + const { + rows: [row], + } = await pool.query( + "INSERT INTO api_keys (provider, api_key, label, max_concurrency) VALUES ($1, $2, $3, $4) RETURNING id", + [provider, api_key, label, Number(max_concurrency) || 10], + ); + res.json({ id: row.id, provider, label }); + }); + + router.put("/admin/keys/:id", requireAuth, requireAdmin, async (req, res) => { + const { enabled, label, max_concurrency, api_key } = req.body; + const updates = []; + const params = []; + let idx = 1; + + if (enabled !== undefined) { + updates.push(`enabled = $${idx++}`); + params.push(enabled ? 1 : 0); + } + if (label !== undefined) { + updates.push(`label = $${idx++}`); + params.push(label); + } + if (max_concurrency !== undefined) { + updates.push(`max_concurrency = $${idx++}`); + params.push(Number(max_concurrency) || 10); + } + if (api_key) { + updates.push(`api_key = $${idx++}`); + params.push(api_key); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + params.push(req.params.id); + await pool.query(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); + + router.delete("/admin/keys/:id", requireAuth, requireAdmin, async (req, res) => { + await pool.query("DELETE FROM api_keys WHERE id = $1", [req.params.id]); + res.json({ success: true }); + }); + + // ── Admin: Enterprises ─────────────────────────────────────────────── + + router.get("/admin/enterprises", requireAuth, requireAdmin, async (_req, res) => { + const { rows } = await pool.query(` + SELECT e.*, COUNT(u.id) AS user_count + FROM enterprises e + LEFT JOIN users u ON u.enterprise_id = e.id AND u.enabled = 1 + GROUP BY e.id + ORDER BY e.id + `); + res.json( + rows.map((row) => ({ + id: Number(row.id), + name: row.name, + contactName: row.contact_name, + contactPhone: row.contact_phone, + taxId: row.tax_id, + legalPersonName: row.legal_person_name, + legalPersonPhone: row.legal_person_phone, + enterpriseCode: row.enterprise_code, + balanceCents: row.balance_cents, + enabled: !!row.enabled, + userCount: Number(row.user_count), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + ); + }); + + router.get("/admin/enterprises/:id", requireAuth, requireAdmin, async (req, res) => { + const { + rows: [row], + } = await pool.query("SELECT * FROM enterprises WHERE id = $1", [req.params.id]); + if (!row) return res.status(404).json({ error: "企业不存在" }); + + res.json({ + id: Number(row.id), + name: row.name, + contactName: row.contact_name, + contactPhone: row.contact_phone, + taxId: row.tax_id, + legalPersonName: row.legal_person_name, + legalPersonPhone: row.legal_person_phone, + enterpriseCode: row.enterprise_code, + balanceCents: row.balance_cents, + enabled: !!row.enabled, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + }); + + router.put("/admin/enterprises/:id", requireAuth, requireAdmin, async (req, res) => { + const { name, contactName, contactPhone, taxId, legalPersonName, legalPersonPhone, enabled } = + req.body; + const updates = []; + const params = []; + let idx = 1; + + if (name !== undefined) { + const enterpriseError = validateEnterpriseName(name); + if (enterpriseError) return res.status(400).json({ error: enterpriseError }); + updates.push(`name = $${idx++}`); + params.push(name.trim()); + } + if (contactName !== undefined) { + updates.push(`contact_name = $${idx++}`); + params.push(String(contactName || "").trim()); + } + if (contactPhone !== undefined) { + updates.push(`contact_phone = $${idx++}`); + params.push(String(contactPhone || "").trim()); + } + if (taxId !== undefined) { + updates.push(`tax_id = $${idx++}`); + params.push(String(taxId || "").trim() || null); + } + if (legalPersonName !== undefined) { + updates.push(`legal_person_name = $${idx++}`); + params.push(String(legalPersonName || "").trim() || null); + } + if (legalPersonPhone !== undefined) { + updates.push(`legal_person_phone = $${idx++}`); + params.push(String(legalPersonPhone || "").trim() || null); + } + if (enabled !== undefined) { + updates.push(`enabled = $${idx++}`); + params.push(enabled ? 1 : 0); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + updates.push("updated_at = NOW()"); + params.push(req.params.id); + await pool.query(`UPDATE enterprises SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); + + // ── Admin: Packages ────────────────────────────────────────────────── + + router.get("/admin/packages", requireAuth, requireAdmin, async (_req, res) => { + const { rows } = await pool.query("SELECT * FROM packages ORDER BY sort_order, id"); + res.json( + rows.map((row) => ({ + id: Number(row.id), + name: row.name, + description: row.description, + priceCents: row.price_cents, + creditsCents: row.credits_cents, + imageQuota: row.image_quota, + videoQuota: row.video_quota, + textQuota: row.text_quota, + durationDays: row.duration_days, + enabled: !!row.enabled, + sortOrder: row.sort_order, + createdAt: row.created_at, + })), + ); + }); + + router.post("/admin/packages", requireAuth, requireAdmin, async (req, res) => { + const { + name, + description = "", + priceCents, + creditsCents = 0, + imageQuota = 0, + videoQuota = 0, + textQuota = 0, + durationDays = 365, + enabled = true, + sortOrder = 0, + } = req.body; + if (!name) return res.status(400).json({ error: "缺少套餐名称" }); + if (priceCents == null || priceCents <= 0) + return res.status(400).json({ error: "售价必须大于0" }); + + try { + const { + rows: [row], + } = await pool.query( + ` + INSERT INTO packages (name, description, price_cents, credits_cents, image_quota, video_quota, text_quota, duration_days, enabled, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `, + [ + name, + description, + Number(priceCents), + Number(creditsCents || 0), + Number(imageQuota || 0), + Number(videoQuota || 0), + Number(textQuota || 0), + Number(durationDays || 365), + enabled ? 1 : 0, + Number(sortOrder || 0), + ], + ); + + res.json({ id: row.id, success: true }); + } catch (error) { + console.error("[admin/packages:create] failed", error); + res.status(500).json({ error: "创建套餐失败" }); + } + }); + + router.put("/admin/packages/:id", requireAuth, requireAdmin, async (req, res) => { + const { + rows: [pkg], + } = await pool.query("SELECT * FROM packages WHERE id = $1", [req.params.id]); + if (!pkg) return res.status(404).json({ error: "套餐不存在" }); + + const { + name, + description, + priceCents, + creditsCents, + imageQuota, + videoQuota, + textQuota, + durationDays, + enabled, + sortOrder, + } = req.body; + const updates = []; + const params = []; + let idx = 1; + + if (name !== undefined) { + updates.push(`name = $${idx++}`); + params.push(name); + } + if (description !== undefined) { + updates.push(`description = $${idx++}`); + params.push(description); + } + if (priceCents !== undefined) { + updates.push(`price_cents = $${idx++}`); + params.push(Number(priceCents)); + } + if (creditsCents !== undefined) { + updates.push(`credits_cents = $${idx++}`); + params.push(Number(creditsCents)); + } + if (imageQuota !== undefined) { + updates.push(`image_quota = $${idx++}`); + params.push(Number(imageQuota)); + } + if (videoQuota !== undefined) { + updates.push(`video_quota = $${idx++}`); + params.push(Number(videoQuota)); + } + if (textQuota !== undefined) { + updates.push(`text_quota = $${idx++}`); + params.push(Number(textQuota)); + } + if (durationDays !== undefined) { + updates.push(`duration_days = $${idx++}`); + params.push(Number(durationDays)); + } + if (enabled !== undefined) { + updates.push(`enabled = $${idx++}`); + params.push(enabled ? 1 : 0); + } + if (sortOrder !== undefined) { + updates.push(`sort_order = $${idx++}`); + params.push(Number(sortOrder)); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + params.push(req.params.id); + await pool.query(`UPDATE packages SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); + + router.delete("/admin/packages/:id", requireAuth, requireAdmin, async (req, res) => { + await pool.query("UPDATE packages SET enabled = 0 WHERE id = $1", [req.params.id]); + res.json({ success: true }); + }); +} + +function registerAdminInvoiceRoutes(router) { + // ── Admin: Invoices ────────────────────────────────────────────────── + + router.get("/admin/invoices", requireAuth, requireAdmin, async (_req, res) => { + const { rows } = await pool.query(` + SELECT i.*, e.name AS enterprise_name + FROM invoices i LEFT JOIN enterprises e ON e.id = i.enterprise_id + ORDER BY i.id DESC + `); + res.json( + rows.map((row) => ({ + id: Number(row.id), + enterpriseId: Number(row.enterprise_id), + enterpriseName: row.enterprise_name, + type: row.type, + title: row.title, + taxNo: row.tax_no, + amountCents: row.amount_cents, + status: row.status, + invoiceNo: row.invoice_no, + invoiceUrl: row.invoice_url, + issuedAt: row.issued_at, + createdAt: row.created_at, + })), + ); + }); + + router.put("/admin/invoices/:id", requireAuth, requireAdmin, async (req, res) => { + const { invoiceNo, invoiceUrl, status } = req.body; + const updates = []; + const params = []; + let idx = 1; + + if (invoiceNo !== undefined) { + updates.push(`invoice_no = $${idx++}`); + params.push(invoiceNo); + } + if (invoiceUrl !== undefined) { + updates.push(`invoice_url = $${idx++}`); + params.push(invoiceUrl); + } + if (status) { + updates.push(`status = $${idx++}`); + params.push(status); + } + if (status === "issued") { + updates.push("issued_at = NOW()"); + } + + if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" }); + + params.push(req.params.id); + await pool.query(`UPDATE invoices SET ${updates.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true }); + }); +} + +module.exports = { + registerAdminRoutes, + registerAdminInvoiceRoutes, +}; diff --git a/src/routes/ai.js b/src/routes/ai.js new file mode 100644 index 0000000..a664519 --- /dev/null +++ b/src/routes/ai.js @@ -0,0 +1,2276 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); +const { putObject, isOssConfigured } = require("../ossClient"); +const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); +const { shouldSkipProvider, recordProviderSuccess, recordProviderFailure } = require("../providerCircuitBreaker"); +const { + isEnterpriseVideoBillingUser, + markEnterpriseVideoCreditsAccepted, + prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, + reserveEnterpriseVideoCredits, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, +} = require("../enterpriseVideoBilling"); +const { + startPolling, + updateTaskInDb, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, + taskEvents, +} = require("../aiTaskWorker"); +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../aiUpscaleHelpers"); + +const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ + ["gpt-image-2", "1K"], +]); + +const GRSAI_IMAGE_MAX_QUALITY = new Map([ + ["gpt-image-2", "2K"], +]); + +const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ + ["wan2.7-image", "2K"], +]); + +const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; +const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; + +function toViapiAccessibleUrl(url) { + if (!url) return url; + const match = url.match(/^(https?:\/\/)([^.]+)\.oss-cn-(?!shanghai)[^.]+(\.aliyuncs\.com\/.*)$/i); + if (match) { + return `${match[1]}${match[2]}.oss-accelerate${match[3]}`; + } + return url; +} + +const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; +const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 200; +const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 90_000; +const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; +const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; +const MAX_USER_ACTIVE_GENERATION_TASKS = 3; +const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; + +const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { + "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, + "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, + "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, + "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, + "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, +}; + +function mapAspectRatioToPixels(ratio, quality) { + const q = String(quality || "1K").toUpperCase(); + const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; + return map ? (map[q] || map["1K"]) : "1024x1024"; +} + +function mapAspectRatioToDashscopeSize(ratio, quality) { + return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); +} + +function normalizeQuality(value, fallback = "1K") { + const q = String(value || fallback).trim().toUpperCase(); + if (q === "4K" || q === "2K" || q === "1K") return q; + return fallback; +} + +function clampImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "2K"); + const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function clampGrsaiImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "1K"); + const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function normalizeDuration(value, min = 4, max = 15, fallback = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeRatio(value, fallback = "16:9") { + const ratio = String(value || fallback).trim(); + return ratio === "auto" ? "adaptive" : ratio; +} + +function normalizeVideoResolution(value, allowed, fallback = "720p") { + const resolution = String(value || "").trim().toLowerCase(); + return allowed.includes(resolution) ? resolution : fallback; +} + +function normalizeS2vResolution(value) { + const resolution = String(value || "").trim().toLowerCase(); + return resolution === "480p" ? "480P" : "720P"; +} + +function normalizeS2vStyle(value) { + const style = String(value || "").trim().toLowerCase(); + return ["speech", "sing", "performance"].includes(style) ? style : "speech"; +} + +function normalizePublicHttpUrl(value) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function percentEncodeRpc(value) { + return encodeURIComponent(String(value)) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +function signAliyunRpcParams(method, params, accessKeySecret) { + const canonicalQuery = Object.keys(params) + .sort() + .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) + .join("&"); + const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +function getAliyunVideoEnhanCredentials() { + const accessKeyId = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || + process.env.ALIYUN_ACCESS_KEY_ID || + process.env.STS_ACCESS_KEY_ID || + ""; + const accessKeySecret = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || + process.env.ALIYUN_ACCESS_KEY_SECRET || + process.env.STS_ACCESS_KEY_SECRET || + ""; + return { accessKeyId, accessKeySecret }; +} + +function buildAliyunRpcRequest(action, actionParams = {}, method = "GET") { + const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); + if (!accessKeyId || !accessKeySecret) { + const error = new Error("Aliyun video super-resolution is not configured"); + error.status = 501; + throw error; + } + + const params = { + Action: action, + Version: ALIYUN_VIDEOENHAN_VERSION, + Format: "JSON", + AccessKeyId: accessKeyId, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + ...actionParams, + }; + params.Signature = signAliyunRpcParams(method.toUpperCase(), params, accessKeySecret); + + const encoded = Object.entries(params) + .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) + .join("&"); + + if (method.toUpperCase() === "POST") { + return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body: encoded, method: "POST" }; + } + return { url: `${ALIYUN_VIDEOENHAN_ENDPOINT}?${encoded}`, method: "GET" }; +} + +function buildAliyunRpcUrl(action, actionParams = {}) { + const { url } = buildAliyunRpcRequest(action, actionParams, "GET"); + return url; +} + +function parseAliyunJsonResult(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +async function callAliyunRpc(action, params, method = "GET") { + const request = buildAliyunRpcRequest(action, params, method); + const fetchOptions = { method: request.method }; + if (request.body) { + fetchOptions.headers = { "Content-Type": "application/x-www-form-urlencoded" }; + fetchOptions.body = request.body; + } + const response = await fetch(request.url, fetchOptions); + const text = await response.text().catch(() => ""); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); + } + + if (!response.ok || json.Code || json.code) { + throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); + } + + return json; +} + +function normalizeSuperResolveBitRate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 10; + return Math.max(1, Math.min(20, Math.round(numeric))); +} + +function normalizeAliyunJobStatus(value) { + return String(value || "").trim().toUpperCase(); +} + +async function ensureDefaultProject(userId) { + const projectId = `web-default-${userId}`; + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); + if (rows.length === 0) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + await pool.query( + `INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + updated_by_device_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING`, + [ + projectId, + userId, + "Default workbench", + "Web fallback project for legacy generation requests", + `users/${safeUserId}/projects/${projectId}/current/project.json`, + ], + ); + } + return projectId; +} + +async function resolveTaskProject(userId, requestedProjectId) { + const projectId = String(requestedProjectId || "").trim().slice(0, 64); + if (!projectId) { + return ensureDefaultProject(userId); + } + + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + return projectId; +} + +async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { + if (!client) { + return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); + } + + await assertUserGenerationConcurrencyLimit(userId, client); + const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { rows: [row] } = await client.query( + `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, + [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], + ); + return row; +} + +async function assertUserGenerationConcurrencyLimit(userId, client = pool) { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); + + // Expire stale tasks using heartbeat-aware detection. + // A task is considered stale if neither the server nor the client has touched it + // in the last 10 minutes. This is much faster than the old 30/60-minute window + // because: if a client is actively polling, `last_poll_at` keeps the task alive; + // if the client navigated away (or crashed), `last_poll_at` stops updating and + // the task is freed after 10 minutes. + await client.query( + `UPDATE generation_tasks + SET status = 'failed', error = '任务超时自动释放', updated_at = NOW() + WHERE user_id = $1 + AND status IN ('pending', 'running') + AND GREATEST(updated_at, COALESCE(last_poll_at, created_at)) < NOW() - INTERVAL '10 minutes'`, + [userId], + ); + + const { rows } = await client.query( + "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", + [userId], + ); + const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); + if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + + const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + error.status = 429; + error.code = "GENERATION_CONCURRENCY_LIMIT"; + error.activeCount = activeCount; + error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + throw error; +} + +async function providerPoolExists(provider) { + if (!provider) return false; + const { rows } = await pool.query( + "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", + [provider], + ); + return rows.length > 0; +} + +function releaseLease(slotResult) { + if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); +} + +function sendAiRouteError(res, err) { + res.status(err.status || 500).json({ + error: err.message, + code: err.code, + activeCount: err.activeCount, + maxActiveTasks: err.maxActiveTasks, + }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { + const raw = String(value || "").trim(); + if (!raw) return fallback; + + let message = raw; + try { + const parsed = JSON.parse(raw); + message = + parsed?.error?.message || + parsed?.error_description || + parsed?.message || + parsed?.error || + raw; + } catch {} + + const compact = String(message).replace(/\s+/g, " ").trim(); + const looksLikeMarkup = + /^]/i.test(compact) || + /^<\?xml/i.test(compact) || + /<\/?[a-z][^>]*>/i.test(compact); + + if (looksLikeMarkup) return fallback; + return compact.slice(0, 320); +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatAiTaskRow(row) { + return { + taskId: String(row.id), + projectId: row.project_id, + conversationId: row.conversation_id, + clientQueueId: row.client_queue_id || null, + type: row.type, + status: row.status, + progress: Number(row.progress || 0), + resultUrl: row.result_url || null, + error: row.error || null, + params: parseTaskParams(row.params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function extensionFromContentType(contentType, fallbackType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + return fallbackType === "video" ? "mp4" : "png"; +} + +function contentDispositionFilename(value) { + return String(value || "generated") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, 120) || "generated"; +} + +function isErrorContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function buildDashscopeImageBody(params) { + const content = []; + for (const url of params.referenceUrls || []) { + if (url) content.push({ image: url }); + } + content.push({ text: params.prompt }); + const quality = clampImageQualityForModel(params.model, params.quality); + return { + model: params.model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, + watermark: false, + }, + }; +} + +function buildGrsaiImageBody(params) { + const isGptImage = String(params.model || "").startsWith("gpt-image"); + const modelKey = String(params.model || "").toLowerCase(); + const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); + return isGptImage + ? { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: mapAspectRatioToPixels(params.ratio, quality), + replyType: "json", + } + : { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: params.ratio || "auto", + imageSize: quality, + replyType: "json", + }; +} + +function buildRightcodeImageBody(providerConfig, params) { + const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; + const quality = normalizeQuality(params.quality, "1K"); + + return { + model: providerConfig.model || params.model, + prompt: params.prompt, + image: referenceUrls, + size: mapAspectRatioToPixels(params.ratio, quality), + response_format: "url", + }; +} + +function getGridCount(gridMode) { + if (gridMode === "grid-4") return 4; + if (gridMode === "grid-9") return 9; + if (gridMode === "grid-25") return 25; + return 1; +} + +function buildGeminiImageBody(params) { + const parts = [{ text: String(params.prompt || "").trim() }]; + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + parts.push({ + fileData: { fileUri: url, mimeType: "image/png" }, + }); + } + const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; + const count = getGridCount(params.gridMode); + if (count > 1) generationConfig.candidateCount = count; + return { + contents: [{ parts }], + generationConfig, + }; +} + +function buildOpenAIImageBody(providerConfig, params) { + const userContent = []; + const prompt = String(params.prompt || "").trim(); + if (prompt) userContent.push({ type: "text", text: prompt }); + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + userContent.push({ type: "image_url", image_url: { url } }); + } + const body = { + model: providerConfig.model || params.model, + messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], + }; + const count = getGridCount(params.gridMode); + if (count > 1) body.n = count; + return body; +} + +function buildImageRequest(providerConfig, params, apiKey) { + const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; + if (providerConfig.transport === "dashscope-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; + } + if (providerConfig.transport === "rightcode-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; + } + if (providerConfig.transport === "gemini-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; + } + if (providerConfig.transport === "openai-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; + } + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; +} + +function buildSeedVideoBody(params) { + const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); + const metadata = { + generate_audio: true, + watermark: false, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + resolution, + }; + const body = { + model: params.model, + prompt: params.prompt, + metadata, + }; + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + metadata.first_frame_image = refs[0]; + metadata.last_frame_image = refs[refs.length - 1]; + } else if (refs.length === 1) { + body.image = refs[0]; + } else if (refs.length > 1) { + metadata.reference_images = refs; + } + return body; +} + +function buildArkSeedVideoBody(params) { + const content = []; + if (params.prompt) content.push({ type: "text", text: params.prompt }); + + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); + content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); + } else { + refs.forEach((url, index) => { + content.push({ + type: "image_url", + image_url: { url }, + role: index === 0 ? "first_frame" : "reference_image", + }); + }); + } + + const body = { + model: params.model, + content, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + generate_audio: true, + watermark: false, + }; + body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); + return body; +} + +function buildWanT2vBody(params) { + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + prompt_extend: true, + }; + return { + model: params.model, + input: { prompt: params.prompt }, + parameters, + }; +} + +function buildWanI2vBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + if (!media.length) { + throw createMissingReferenceError("wan2.7-i2v 需要提供至少一张参考图片作为首帧"); + } + + const input = { prompt: params.prompt, media }; + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + parameters.prompt_extend = true; + + return { + model: params.model, + input, + parameters, + }; +} + +function normalizeHappyHorseResolution(value) { + return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function getReferenceImageUrls(params, limit = 9) { + return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) + .map((url) => normalizePublicHttpUrl(url)) + .filter(Boolean) + .slice(0, limit); +} + +function buildHappyHorseBaseParameters(params, { includeRatio }) { + const parameters = { + resolution: normalizeHappyHorseResolution(params.quality), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); + return parameters; +} + +function createMissingReferenceError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function buildHappyHorseT2vBody(params) { + return { + model: params.model, + input: { + prompt: params.prompt, + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function buildHappyHorseI2vBody(params) { + const [firstFrame] = getReferenceImageUrls(params, 1); + if (!firstFrame) { + throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: [{ type: "first_frame", url: firstFrame }], + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), + }; +} + +function buildHappyHorseR2vBody(params) { + const refs = getReferenceImageUrls(params, 9); + if (!refs.length) { + throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: refs.map((url) => ({ type: "reference_image", url })), + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function getHappyHorseReferenceError(protocol, referenceUrls) { + if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { + return "HappyHorse I2V requires one first-frame image."; + } + if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { + return "HappyHorse R2V requires 1 to 9 reference images."; + } + return ""; +} + +async function assertWanS2vImageDetected(providerConfig, params, apiKey) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + + const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: providerConfig.detectModel || "wan2.2-s2v-detect", + input: { image_url: imageUrl }, + }), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + + if (!response.ok) { + throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); + } + + const output = json && typeof json === "object" ? json.output || json.data || json : {}; + const pass = + output.check_pass === true || + output.checkPass === true || + output.passed === true || + output.pass === true || + String(output.code || "").toLowerCase() === "success"; + + if (!pass) { + const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; + const error = new Error(message); + error.status = 400; + throw error; + } +} + +function extractProviderDetectMessage(output) { + if (!output || typeof output !== "object") return ""; + return String( + output.message || + output.reason || + output.failure_reason || + output.description || + output.error || + "", + ).trim(); +} + +function buildWanS2vBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + const audioUrl = normalizePublicHttpUrl(params.audioUrl); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!audioUrl) { + const error = new Error("Missing audioUrl"); + error.status = 400; + throw error; + } + + const parameters = { + resolution: normalizeS2vResolution(params.quality), + style: normalizeS2vStyle(params.style), + }; + + return { + model: params.model, + input: { + image_url: imageUrl, + audio_url: audioUrl, + }, + parameters, + }; +} + +function buildWanAnimateMixBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl); + const videoUrl = normalizePublicHttpUrl((params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!videoUrl) { + const error = new Error("Missing videoUrl"); + error.status = 400; + throw error; + } + + const mode = "wan-pro"; + const watermark = params.muted === false; + + return { + model: params.model, + input: { + image_url: imageUrl, + video_url: videoUrl, + watermark, + }, + parameters: { + mode, + }, + }; +} + +function buildDashscopeKlingBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const parameters = { + mode: params.quality === "std" ? "std" : "pro", + duration: normalizeDuration(params.duration, 5, 10, 5), + audio: false, + watermark: false, + }; + if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); + + return { model: params.model, input, parameters }; +} + +function buildKlingOmniBody(params) { + const refs = params.referenceUrls || []; + const imageList = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); + } else if (refs[0]) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + } + + const body = { + model_name: "kling-v3-omni", + mode: params.quality === "std" ? "std" : "pro", + sound: "off", + duration: String(normalizeDuration(params.duration, 3, 15, 5)), + watermark_info: { enabled: false }, + prompt: params.prompt, + }; + if (imageList.length) body.image_list = imageList; + else body.aspect_ratio = normalizeRatio(params.ratio); + return body; +} + +function buildViduT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildViduI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("Vidu I2V 需要提供一张参考图片"); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildPixverseT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + const size = sizeMap[requestedRes] || "1280*720"; + return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildPixverseI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("PixVerse I2V 需要提供一张参考图片"); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildVideoRequest(providerConfig, params, apiKey) { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; + let body; + + if (providerConfig.protocol === "seed-video-ark") { + body = buildArkSeedVideoBody(params); + } else if (providerConfig.protocol === "happyhorse-t2v") { + body = buildHappyHorseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-i2v") { + body = buildHappyHorseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-r2v") { + body = buildHappyHorseR2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-i2v") { + body = buildWanI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-t2v") { + body = buildWanT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-s2v") { + body = buildWanS2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-animate-mix") { + body = buildWanAnimateMixBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-dashscope") { + body = buildDashscopeKlingBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-t2v") { + body = buildViduT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-i2v") { + body = buildViduI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-t2v") { + body = buildPixverseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-i2v") { + body = buildPixverseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-omni") { + body = buildKlingOmniBody(params); + const credential = parseKlingCredential(apiKey); + if (credential) { + headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; + } + } else { + body = buildSeedVideoBody(params); + } + + return { headers, body }; +} + +function registerAiRoutes(router) { + router.post("/ai/image", requireAuth, async (req, res) => { + const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; + if (!prompt) return res.status(400).json({ error: "Missing prompt" }); + + try { + const allCandidates = resolveImageProviderCandidates(model); + const providerCandidates = allCandidates.filter(c => !shouldSkipProvider(c.provider)); + if (!providerCandidates.length) providerCandidates.push(...allCandidates); + const primaryProviderConfig = providerCandidates[0]; + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: primaryProviderConfig.model, + requestedModel: primaryProviderConfig.requestedModel, + prompt, + ratio, + quality, + gridMode, + referenceUrls, + }; + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: params.requestedModel || params.model || model, + resolution: [ratio, quality].filter(Boolean).join(" / "), + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + error.costCents = billingResult.costCents; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + imageBilling: { + costCents: imageBilling.costCents, + deductionType: imageBilling.deductionType, + balanceAfterCents: imageBilling.balanceAfterCents, + }, + providerDebug: buildImageProviderDebug(model), + }); + submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { + console.error("[ai/image] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/image] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video", requireAuth, async (req, res) => { + const { + model, + prompt, + ratio, + duration, + quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + resolution, + muted, + hasReferenceVideo, + style, + projectId: requestedProjectId, + conversationId, + } = req.body; + let providerConfig; + try { + providerConfig = resolveVideoProvider(model); + } catch (err) { + return res.status(err.status || 400).json({ error: err.message }); + } + const provider = providerConfig.provider; + const isWanS2v = providerConfig.protocol === "wan-s2v"; + const isWanAnimateMix = providerConfig.protocol === "wan-animate-mix"; + const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); + + if (!isWanS2v && !isWanAnimateMix && !prompt) return res.status(400).json({ error: "Missing prompt" }); + if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); + if (isWanS2v) { + if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl(audioUrl)) { + return res.status(400).json({ error: "Missing audioUrl" }); + } + } + if (isWanAnimateMix) { + if (!normalizePublicHttpUrl(imageUrl)) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl((Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing reference videoUrl" }); + } + } + + let slotResult = null; + try { + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: providerConfig.model, + requestedModel: providerConfig.requestedModel, + prompt: prompt || "数字人口播视频", + ratio, + duration, + quality: quality || resolution, + resolution: resolution || quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + muted: Boolean(muted), + hasReferenceVideo: Boolean(hasReferenceVideo), + style, + }; + + let enterpriseBilling = null; + let preauth = null; + if (isEnterpriseVideoBillingUser(req.user)) { + enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); + preauth = { + authorized: true, + estimatedCostCents: enterpriseBilling.amountCents, + billingMode: "enterprise", + }; + } else { + preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + if (enterpriseBilling) { + const nextBilling = await reserveEnterpriseVideoCredits(client, { + ...enterpriseBilling, + taskId: nextTaskRow.id, + }); + return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; + } + // Regular user: deduct from personal balance + const credits = calculateEnterpriseVideoCredits({ + model: params.model, + resolution: params.resolution || params.quality, + durationSeconds: params.duration, + muted: params.muted, + hasReferenceVideo: params.hasReferenceVideo, + }); + const costCents = Math.ceil(credits * 100); + const { rows: [deducted] } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [costCents, req.user.id], + ); + if (!deducted) { + throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); + } + await client.query( + "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", + [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], + ); + return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; + }); + + if (reservedBilling) { + params.enterpriseBilling = { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + resolution: reservedBilling.resolution, + durationSeconds: reservedBilling.durationSeconds, + rateCentsPerSecond: reservedBilling.rateCentsPerSecond, + }; + await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ + JSON.stringify(params), + taskRow.id, + ]); + } + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + enterpriseBilling: reservedBilling + ? { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, + } + : undefined, + }); + const activeSlotResult = slotResult; + slotResult = null; + submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) + .then(async () => { + try { + await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); + } catch (settlementError) { + console.error("[ai/video] enterprise ledger settle error:", settlementError.message); + } + }) + .catch(async (err) => { + console.error("[ai/video] submit error:", err.message); + await updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); + releaseLease(activeSlotResult); + }); + } catch (err) { + releaseLease(slotResult); + console.error("[ai/video] error:", err.message); + if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { + return res.status(err.status || 402).json({ + error: err.message, + code: "INSUFFICIENT_ENTERPRISE_BALANCE", + }); + } + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: "wanx2.1-imageedit", + operation: "image-super-resolution", + imageUrl, + scale, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { + const videoUrl = String(req.body?.videoUrl || "").trim(); + const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); + const providerMode = String(req.body?.provider || req.body?.model || "").trim(); + const shouldUseDashscopeStyle = + providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + if (!/^https?:\/\//i.test(videoUrl)) { + return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); + } + + let dashscopeSlotResult; + try { + if (shouldUseDashscopeStyle) { + const provider = "dashscope"; + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!dashscopeSlotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const styleOptions = normalizeVideoStyleTransformOptions(req.body); + const params = { + model: "video-style-transform", + operation: "video-style-super-resolution", + videoUrl, + ...styleOptions, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { + console.error("[ai/video/super-resolve] dashscope submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(dashscopeSlotResult); + }); + return; + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); + console.error("[ai/video/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/erase-subtitles", requireAuth, async (req, res) => { + const videoUrl = normalizePublicHttpUrl(req.body?.videoUrl); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + + try { + await assertUserGenerationConcurrencyLimit(req.user.id); + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const bx = Number(req.body?.bx) || 0; + const by = Number(req.body?.by) || 0; + const bw = Number(req.body?.bw) || 0; + const bh = Number(req.body?.bh) || 0; + const params = { model: "aliyun-erase-subtitles", videoUrl, bx, by, bw, bh }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitEraseSubtitlesTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/erase-subtitles] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/video/erase-subtitles] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/edit", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const editFunction = String(req.body?.function || "description_edit").trim(); + const prompt = String(req.body?.prompt || "").trim(); + const n = Math.max(1, Math.min(4, Number(req.body?.n) || 1)); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "wanx2.1-imageedit", operation: "image-edit", imageUrl, function: editFunction, prompt, n }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageEditTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/edit] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/edit] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/chat", requireAuth, async (req, res) => { + const { model, messages, stream = true, temperature } = req.body; + if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); + + const providerConfig = resolveTextProvider(model); + const provider = providerConfig.provider; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const reqHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${slotResult.apiKey}`, + }; + const reqBody = JSON.stringify({ + model: providerConfig.model, + messages, + stream, + temperature: temperature || 0.7, + max_tokens: 4096, + enable_thinking: false, + }); + + if (stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + const streamTimer = setTimeout(() => abortController.abort(), 60000); + req.on("close", () => { clearTimeout(streamTimer); abortController.abort(); }); + + try { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => "upstream error"); + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), + done: true, + })}\n\n`, + ); + res.end(); + releaseLease(slotResult); + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + continue; + } + try { + const chunk = JSON.parse(payload); + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); + } catch {} + } + } + + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + res.end(); + releaseLease(slotResult); + } catch (streamErr) { + if (streamErr.name !== "AbortError") { + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(streamErr.message), + done: true, + })}\n\n`, + ); + } + res.end(); + releaseLease(slotResult); + } + } else { + const nonStreamAbort = new AbortController(); + const nonStreamTimer = setTimeout(() => nonStreamAbort.abort(), 60000); + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: nonStreamAbort.signal }); + clearTimeout(nonStreamTimer); + const text = await upstream.text().catch(() => ""); + releaseLease(slotResult); + + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + return res.status(502).json({ + error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), + }); + } + + if (!upstream.ok || json.error) { + console.error("[ai/chat] upstream error:", upstream.status, JSON.stringify(json.error || json.message || "").slice(0, 500), "model:", providerConfig.model, "provider:", providerConfig.provider); + if (upstream.status >= 500 && providerConfig.provider && providerConfig.provider.startsWith("dashscope")) { + try { + const fallbackConfig = resolveTextProvider("gemini-3.1-pro"); + const fallbackUrl = fallbackConfig.baseUrl + fallbackConfig.endpoint; + const fallbackHeaders = { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey }; + const fallbackBody = JSON.stringify({ model: fallbackConfig.model, messages, stream: false, temperature: temperature || 0.7, max_tokens: 4096 }); + const fbAbort = new AbortController(); + const fbTimer = setTimeout(() => fbAbort.abort(), 60000); + const fbUpstream = await fetch(fallbackUrl, { method: "POST", headers: fallbackHeaders, body: fallbackBody, signal: fbAbort.signal }); + clearTimeout(fbTimer); + const fbText = await fbUpstream.text().catch(() => ""); + if (fbUpstream.ok) { + const fbJson = fbText ? JSON.parse(fbText) : {}; + const fbContent = fbJson.choices?.[0]?.message?.content || ""; + if (fbContent) { + const fbUsage = fbJson.usage || {}; + return res.json({ content: fbContent, usage: { promptTokens: fbUsage.prompt_tokens, completionTokens: fbUsage.completion_tokens } }); + } + } + } catch (fbErr) { + console.error("[ai/chat] fallback also failed:", fbErr.message); + } + } + return res.status(502).json({ + error: sanitizeUpstreamError( + json.error?.message || json.message || json.error || text, + `文本服务返回 HTTP ${upstream.status}`, + ), + }); + } + + const content = json.choices?.[0]?.message?.content || ""; + const usage = json.usage || {}; + res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); + } + } catch (err) { + releaseLease(slotResult); + console.error("[ai/chat] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); + const status = String(req.query.status || "").trim(); + const type = String(req.query.type || "").trim(); + const projectId = String(req.query.projectId || req.query.project_id || "").trim(); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (["image", "video"].includes(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + const source = String(req.query.source || "").trim(); + if (source) { + params.push(source); + where.push(`params_json->>'source' = $${params.length}`); + } + if (projectId) { + params.push(projectId); + where.push(`project_id = $${params.length}`); + } + + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT * + FROM generation_tasks + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length - 1} + OFFSET $${params.length} + `, + params, + ); + res.json({ tasks: rows.map(formatAiTaskRow) }); + } catch (err) { + console.error("[ai/tasks] list failed:", err.message); + res.status(500).json({ error: "Failed to load task history" }); + } + }); + + router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + const conversationId = Number(req.body?.conversationId); + + if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { + return res.status(400).json({ error: "Invalid task or conversation id" }); + } + + try { + const { rows: conversationRows } = await pool.query( + "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", + [conversationId, req.user.id], + ); + if (conversationRows.length === 0) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const { rows } = await pool.query( + `UPDATE generation_tasks + SET conversation_id = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + RETURNING id, conversation_id`, + [conversationId, taskId, req.user.id], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Task not found" }); + } + + res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + // Heartbeat: track that the client is still polling this task. + // Only update for active tasks to avoid unnecessary writes on completed/failed rows. + if (rows[0].status === "pending" || rows[0].status === "running") { + pool.query( + "UPDATE generation_tasks SET last_poll_at = NOW() WHERE id = $1", + [taskId], + ).catch(() => {}); + } + + res.json(formatAiTaskRow(rows[0])); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId/stream", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + + const row = rows[0]; + const initial = { + taskId: row.id, + status: row.status, + progress: row.progress, + resultUrl: row.result_url || null, + error: row.error || null, + }; + res.write(`data: ${JSON.stringify(initial)}\n\n`); + + if (["completed", "failed", "cancelled"].includes(row.status)) { + res.end(); + return; + } + + const onUpdate = (evt) => { + res.write(`data: ${JSON.stringify(evt)}\n\n`); + if (["completed", "failed", "cancelled"].includes(evt.status)) { + res.end(); + } + }; + taskEvents.on(`task:${taskId}`, onUpdate); + + req.on("close", () => { + taskEvents.off(`task:${taskId}`, onUpdate); + }); + } catch (err) { + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); + + router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); + + try { + const { rows } = await pool.query( + "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); + res.json({ id: rows[0].id, status: rows[0].status }); + } catch (err) { + console.error("[ai/task-cancel] error:", err.message); + res.status(500).json({ error: "取消任务失败" }); + } + }); + + router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + const task = rows[0]; + const resultUrl = String(task.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl)) { + return res.status(400).json({ error: "Task result is not downloadable" }); + } + + const upstream = await fetch(resultUrl, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); + } + + const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorContentType(contentType)) { + const text = await upstream.text().catch(() => ""); + return res.status(502).json({ + error: text.includes("Expired") || text.includes("AccessDenied") + ? "结果链接已过期,请重新生成后再下载" + : "结果链接返回了错误内容,请重新生成后再下载", + }); + } + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Result download returned empty content" }); + } + + const extension = extensionFromContentType(contentType, task.type); + const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/tasks/download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/proxy-download", requireAuth, async (req, res) => { + const url = String(req.query.url || "").trim(); + if (!url || !/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: "Invalid URL" }); + } + if (!/aliyuncs\.com/i.test(url)) { + return res.status(403).json({ error: "Only OSS URLs can be proxied" }); + } + try { + const upstream = await fetch(url, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Proxy download failed (${upstream.status})` }); + } + const contentType = upstream.headers.get("content-type") || "application/octet-stream"; + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Proxy download returned empty content" }); + } + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/proxy-download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); +} + +async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { + const errors = [...previousErrors]; + const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; + + for (let index = 0; index < candidates.length; index += 1) { + const providerConfig = candidates[index]; + const provider = providerConfig?.provider; + let slotResult = null; + + if (!provider) continue; + + try { + if (index > 0 && !(await providerPoolExists(provider))) { + throw new Error(`${provider} provider pool is not configured`); + } + + slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + throw new Error(`${provider} concurrency pool is full`); + } + + await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { + onTaskFailed: async (failureMessage) => { + recordProviderFailure(provider); + const providerError = `${provider}: ${failureMessage}`; + const remainingCandidates = candidates.slice(index + 1); + if (remainingCandidates.length === 0) { + await updateTaskInDb(taskDbId, { + status: "failed", + error: `All image providers failed: ${[...errors, providerError].join(" | ")}`, + }); + return true; + } + + console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + try { + await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ + ...errors, + providerError, + ]); + return true; + } catch (fallbackErr) { + await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); + return true; + } + }, + }); + recordProviderSuccess(provider, 0); + if (index > 0) { + console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); + } + return; + } catch (err) { + const message = err?.message || String(err); + errors.push(`${provider}: ${message}`); + console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); + recordProviderFailure(provider); + releaseLease(slotResult); + + if (index < candidates.length - 1) { + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + } + } + } + + throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); +} + +async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { + const url = getPostUrl(providerConfig); + const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const submitTimeout = providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; + const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + + // Synchronous transports — extract image URL directly, no polling + if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { + let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); + const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; + console.info( + `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, + ); + if (!directUrl) throw new Error(`${tag} did not return an image url`); + + // Gemini may return base64 data URL — too large for DB, upload to OSS first + if (directUrl.startsWith("data:") && isOssConfigured()) { + const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + const mimeType = match[1]; + const buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "png"; + const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const bucket = process.env.OSS_BUCKET || ""; + const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); + directUrl = process.env.OSS_PUBLIC_BASE_URL + ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` + : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; + console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); + } + } + + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); + releaseLease(slotResult); + return; + } + + const directUrl = extractImageUrl(json); + + const providerTaskId = extractProviderTaskId(json); + if (directUrl) { + console.info(`[ai/image/grsai] task ${taskDbId} completed with direct result from submit response`); + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + onTaskFailed: options.onTaskFailed, + }); +} + +async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + if (providerConfig.protocol === "wan-s2v") { + await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); + await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); + } + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Video provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeImageSuperResolveBody(params); + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeVideoStyleTransformBody(params); + const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig: { + protocol: "wan-i2v", + baseUrl: "https://dashscope.aliyuncs.com", + }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitVideoSuperResolveTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const submitResult = await callAliyunRpc("SuperResolveVideo", { + VideoUrl: toViapiAccessibleUrl(params.videoUrl), + BitRate: String(params.bitRate || 10), + }); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun SuperResolveVideo did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun super-resolution completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("Aliyun video super-resolution timed out"); +} + +async function submitEraseSubtitlesTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const rpcParams = { VideoUrl: toViapiAccessibleUrl(params.videoUrl) }; + if (params.bx || params.by || params.bw || params.bh) { + rpcParams.BX = String(params.bx || 0); + rpcParams.BY = String(params.by || 0); + rpcParams.BW = String(params.bw || 0); + rpcParams.BH = String(params.bh || 0); + } + const submitResult = await callAliyunRpc("EraseVideoSubtitles", rpcParams); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun EraseVideoSubtitles did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun subtitle erasure completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "字幕去除失败"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("字幕去除超时"); +} + +async function submitDashscopeImageEditTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const WAN27_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"; + const body = { + model: "wan2.7-image-pro", + input: { + messages: [{ + role: "user", + content: [ + { image: params.imageUrl }, + { text: params.prompt || "去除图像中的水印和文字" }, + ], + }], + }, + parameters: { + size: "2K", + n: params.n || 1, + watermark: false, + }, + }; + const response = await fetch(WAN27_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片编辑服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image edit did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +module.exports = { registerAiRoutes }; diff --git a/src/routes/ai.js.bak b/src/routes/ai.js.bak new file mode 100644 index 0000000..c462c03 --- /dev/null +++ b/src/routes/ai.js.bak @@ -0,0 +1,1956 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); +const { putObject, isOssConfigured } = require("../ossClient"); +const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); +const { + isEnterpriseVideoBillingUser, + markEnterpriseVideoCreditsAccepted, + prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, + reserveEnterpriseVideoCredits, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, +} = require("../enterpriseVideoBilling"); +const { + startPolling, + updateTaskInDb, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, +} = require("../aiTaskWorker"); +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../aiUpscaleHelpers"); + +const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ + ["gpt-image-2", "1K"], +]); + +const GRSAI_IMAGE_MAX_QUALITY = new Map([ + ["gpt-image-2", "2K"], +]); + +const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ + ["wan2.7-image", "2K"], +]); + +const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; +const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; +const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; +const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; +const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 150_000; +const RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS = 70_000; +const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; +const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; +const MAX_USER_ACTIVE_GENERATION_TASKS = 3; +const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; + +const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { + "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, + "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, + "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, + "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, + "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, +}; + +function mapAspectRatioToPixels(ratio, quality) { + const q = String(quality || "1K").toUpperCase(); + const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; + return map ? (map[q] || map["1K"]) : "1024x1024"; +} + +function mapAspectRatioToDashscopeSize(ratio, quality) { + return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); +} + +function normalizeQuality(value, fallback = "1K") { + const q = String(value || fallback).trim().toUpperCase(); + if (q === "4K" || q === "2K" || q === "1K") return q; + return fallback; +} + +function clampImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "2K"); + const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function clampGrsaiImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "1K"); + const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function normalizeDuration(value, min = 4, max = 15, fallback = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeRatio(value, fallback = "16:9") { + const ratio = String(value || fallback).trim(); + return ratio === "auto" ? "adaptive" : ratio; +} + +function normalizeVideoResolution(value, allowed, fallback = "720p") { + const resolution = String(value || "").trim().toLowerCase(); + return allowed.includes(resolution) ? resolution : fallback; +} + +function normalizeS2vResolution(value) { + const resolution = String(value || "").trim().toLowerCase(); + return resolution === "480p" ? "480P" : "720P"; +} + +function normalizeS2vStyle(value) { + const style = String(value || "").trim().toLowerCase(); + return ["speech", "sing", "performance"].includes(style) ? style : "speech"; +} + +function normalizePublicHttpUrl(value) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function percentEncodeRpc(value) { + return encodeURIComponent(String(value)) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +function signAliyunRpcParams(method, params, accessKeySecret) { + const canonicalQuery = Object.keys(params) + .sort() + .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) + .join("&"); + const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +function getAliyunVideoEnhanCredentials() { + const accessKeyId = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || + process.env.ALIYUN_ACCESS_KEY_ID || + process.env.STS_ACCESS_KEY_ID || + ""; + const accessKeySecret = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || + process.env.ALIYUN_ACCESS_KEY_SECRET || + process.env.STS_ACCESS_KEY_SECRET || + ""; + return { accessKeyId, accessKeySecret }; +} + +function buildAliyunRpcUrl(action, actionParams = {}) { + const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); + if (!accessKeyId || !accessKeySecret) { + const error = new Error("Aliyun video super-resolution is not configured"); + error.status = 501; + throw error; + } + + const params = { + Action: action, + Version: ALIYUN_VIDEOENHAN_VERSION, + Format: "JSON", + AccessKeyId: accessKeyId, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + ...actionParams, + }; + params.Signature = signAliyunRpcParams("POST", params, accessKeySecret); + + const body = Object.entries(params) + .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) + .join("&"); + return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body }; +} + +function parseAliyunJsonResult(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +async function callAliyunRpc(action, params) { + const req = buildAliyunRpcUrl(action, params); + const response = await fetch(req.url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: req.body, + }); + const text = await response.text().catch(() => ""); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); + } + + if (!response.ok || json.Code || json.code) { + throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); + } + + return json; +} + +function normalizeSuperResolveBitRate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 10; + return Math.max(1, Math.min(20, Math.round(numeric))); +} + +function normalizeAliyunJobStatus(value) { + return String(value || "").trim().toUpperCase(); +} + +async function ensureDefaultProject(userId) { + const projectId = `web-default-${userId}`; + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); + if (rows.length === 0) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + await pool.query( + `INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + updated_by_device_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING`, + [ + projectId, + userId, + "Default workbench", + "Web fallback project for legacy generation requests", + `users/${safeUserId}/projects/${projectId}/current/project.json`, + ], + ); + } + return projectId; +} + +async function resolveTaskProject(userId, requestedProjectId) { + const projectId = String(requestedProjectId || "").trim().slice(0, 64); + if (!projectId) { + return ensureDefaultProject(userId); + } + + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + return projectId; +} + +async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { + if (!client) { + return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); + } + + await assertUserGenerationConcurrencyLimit(userId, client); + const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { rows: [row] } = await client.query( + `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, + [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], + ); + return row; +} + +async function assertUserGenerationConcurrencyLimit(userId, client = pool) { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); + const { rows } = await client.query( + "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", + [userId], + ); + const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); + if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + + const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + error.status = 429; + error.code = "GENERATION_CONCURRENCY_LIMIT"; + error.activeCount = activeCount; + error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + throw error; +} + +async function providerPoolExists(provider) { + if (!provider) return false; + const { rows } = await pool.query( + "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", + [provider], + ); + return rows.length > 0; +} + +function releaseLease(slotResult) { + if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); +} + +function sendAiRouteError(res, err) { + res.status(err.status || 500).json({ + error: err.message, + code: err.code, + activeCount: err.activeCount, + maxActiveTasks: err.maxActiveTasks, + }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { + const raw = String(value || "").trim(); + if (!raw) return fallback; + + let message = raw; + try { + const parsed = JSON.parse(raw); + message = + parsed?.error?.message || + parsed?.error_description || + parsed?.message || + parsed?.error || + raw; + } catch {} + + const compact = String(message).replace(/\s+/g, " ").trim(); + const looksLikeMarkup = + /^]/i.test(compact) || + /^<\?xml/i.test(compact) || + /<\/?[a-z][^>]*>/i.test(compact); + + if (looksLikeMarkup) return fallback; + return compact.slice(0, 320); +} + +function translateDashscopeContentError(message) { + const msg = String(message || ""); + if (msg.includes("Green net check failed for image input") || msg.includes("Input data may contain inappropriate content")) { + return "\u53c2\u8003\u56fe\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff08\u7eff\u7f51\u68c0\u67e5\uff09\uff0c\u8bf7\u66f4\u6362\u56fe\u7247\u540e\u91cd\u8bd5\u3002\u5e38\u89c1\u539f\u56e0\uff1a\u4eba\u7269\u66b4\u9732\u3001\u654f\u611f\u9762\u90e8\u3001\u56fe\u7247\u542b\u654f\u611f\u6587\u5b57\u7b49\u3002"; + } + if (msg.includes("Output data may contain inappropriate content")) { + return "\u751f\u6210\u7ed3\u679c\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; + } + if (msg.includes("content policies") || msg.includes("prompt may violate")) { + return "\u63d0\u793a\u8bcd\u672a\u901a\u8fc7\u5185\u5bb9\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63cf\u8ff0\u540e\u91cd\u8bd5\u3002\u907f\u514d\u4f7f\u7528\u6d89\u53ca\u66b4\u9732\u3001\u66b4\u529b\u3001\u653f\u6cbb\u654f\u611f\u7b49\u8bcd\u6c47\u3002"; + } + if (msg.includes("inappropriate content")) { + return "\u5185\u5bb9\u672a\u901a\u8fc7\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; + } + return ""; +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatAiTaskRow(row) { + return { + taskId: String(row.id), + projectId: row.project_id, + conversationId: row.conversation_id, + clientQueueId: row.client_queue_id || null, + type: row.type, + status: row.status, + progress: Number(row.progress || 0), + resultUrl: row.result_url || null, + error: row.error || null, + params: parseTaskParams(row.params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function extensionFromContentType(contentType, fallbackType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + return fallbackType === "video" ? "mp4" : "png"; +} + +function contentDispositionFilename(value) { + return String(value || "generated") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, 120) || "generated"; +} + +function isErrorContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function buildDashscopeImageBody(params) { + const content = []; + for (const url of params.referenceUrls || []) { + if (url) content.push({ image: url }); + } + content.push({ text: params.prompt }); + const quality = clampImageQualityForModel(params.model, params.quality); + return { + model: params.model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, + watermark: false, + }, + }; +} + +function buildGrsaiImageBody(params) { + const isGptImage = String(params.model || "").startsWith("gpt-image"); + const modelKey = String(params.model || "").toLowerCase(); + const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); + return isGptImage + ? { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: mapAspectRatioToPixels(params.ratio, quality), + replyType: "json", + } + : { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: params.ratio || "auto", + imageSize: quality, + replyType: "json", + }; +} + +function buildRightcodeImageBody(providerConfig, params) { + const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; + const quality = normalizeQuality(params.quality, "1K"); + + return { + model: providerConfig.model || params.model, + prompt: params.prompt, + image: referenceUrls, + size: mapAspectRatioToPixels(params.ratio, quality), + response_format: "url", + }; +} + +function getGridCount(gridMode) { + if (gridMode === "grid-4") return 4; + if (gridMode === "grid-9") return 9; + if (gridMode === "grid-25") return 25; + return 1; +} + +function buildGeminiImageBody(params) { + const parts = [{ text: String(params.prompt || "").trim() }]; + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + parts.push({ + fileData: { fileUri: url, mimeType: "image/png" }, + }); + } + const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; + const count = getGridCount(params.gridMode); + if (count > 1) generationConfig.candidateCount = count; + return { + contents: [{ parts }], + generationConfig, + }; +} + +function buildOpenAIImageBody(providerConfig, params) { + const userContent = []; + const prompt = String(params.prompt || "").trim(); + if (prompt) userContent.push({ type: "text", text: prompt }); + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + userContent.push({ type: "image_url", image_url: { url } }); + } + const body = { + model: providerConfig.model || params.model, + messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], + }; + const count = getGridCount(params.gridMode); + if (count > 1) body.n = count; + return body; +} + +function buildImageRequest(providerConfig, params, apiKey) { + const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; + if (providerConfig.transport === "dashscope-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; + } + if (providerConfig.transport === "rightcode-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; + } + if (providerConfig.transport === "gemini-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; + } + if (providerConfig.transport === "openai-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; + } + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; +} + +function buildSeedVideoBody(params) { + const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); + const metadata = { + generate_audio: true, + watermark: false, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + resolution, + }; + const body = { + model: params.model, + prompt: params.prompt, + metadata, + }; + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + metadata.first_frame_image = refs[0]; + metadata.last_frame_image = refs[refs.length - 1]; + } else if (refs.length === 1) { + body.image = refs[0]; + } else if (refs.length > 1) { + metadata.reference_images = refs; + } + return body; +} + +function buildArkSeedVideoBody(params) { + const content = []; + if (params.prompt) content.push({ type: "text", text: params.prompt }); + + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); + content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); + } else { + refs.forEach((url, index) => { + content.push({ + type: "image_url", + image_url: { url }, + role: index === 0 ? "first_frame" : "reference_image", + }); + }); + } + + const body = { + model: params.model, + content, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + generate_audio: true, + watermark: false, + }; + body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); + return body; +} + +function buildWanI2vBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + parameters.prompt_extend = true; + + return { + model: params.model, + input, + parameters, + }; +} + +function normalizeHappyHorseResolution(value) { + return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function getReferenceImageUrls(params, limit = 9) { + return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) + .map((url) => normalizePublicHttpUrl(url)) + .filter(Boolean) + .slice(0, limit); +} + +function buildHappyHorseBaseParameters(params, { includeRatio }) { + const parameters = { + resolution: normalizeHappyHorseResolution(params.quality), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); + return parameters; +} + +function createMissingReferenceError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function buildHappyHorseT2vBody(params) { + return { + model: params.model, + input: { + prompt: params.prompt, + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function buildHappyHorseI2vBody(params) { + const [firstFrame] = getReferenceImageUrls(params, 1); + if (!firstFrame) { + throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: [{ type: "first_frame", url: firstFrame }], + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), + }; +} + +function buildHappyHorseR2vBody(params) { + const refs = getReferenceImageUrls(params, 9); + if (!refs.length) { + throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: refs.map((url) => ({ type: "reference_image", url })), + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function getHappyHorseReferenceError(protocol, referenceUrls) { + if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { + return "HappyHorse I2V requires one first-frame image."; + } + if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { + return "HappyHorse R2V requires 1 to 9 reference images."; + } + return ""; +} + +async function assertWanS2vImageDetected(providerConfig, params, apiKey) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + + const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: providerConfig.detectModel || "wan2.2-s2v-detect", + input: { image_url: imageUrl }, + }), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + + if (!response.ok) { + throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); + } + + const output = json && typeof json === "object" ? json.output || json.data || json : {}; + const pass = + output.check_pass === true || + output.checkPass === true || + output.passed === true || + output.pass === true || + String(output.code || "").toLowerCase() === "success"; + + if (!pass) { + const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; + const error = new Error(message); + error.status = 400; + throw error; + } +} + +function extractProviderDetectMessage(output) { + if (!output || typeof output !== "object") return ""; + return String( + output.message || + output.reason || + output.failure_reason || + output.description || + output.error || + "", + ).trim(); +} + +function buildWanS2vBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + const audioUrl = normalizePublicHttpUrl(params.audioUrl); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!audioUrl) { + const error = new Error("Missing audioUrl"); + error.status = 400; + throw error; + } + + const parameters = { + resolution: normalizeS2vResolution(params.quality), + style: normalizeS2vStyle(params.style), + }; + + return { + model: params.model, + input: { + image_url: imageUrl, + audio_url: audioUrl, + }, + parameters, + }; +} + +function buildDashscopeKlingBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const parameters = { + mode: params.quality === "std" ? "std" : "pro", + duration: normalizeDuration(params.duration, 5, 10, 5), + audio: false, + watermark: false, + }; + if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); + + return { model: params.model, input, parameters }; +} + +function buildKlingOmniBody(params) { + const refs = params.referenceUrls || []; + const imageList = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); + } else if (refs[0]) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + } + + const body = { + model_name: "kling-v3-omni", + mode: params.quality === "std" ? "std" : "pro", + sound: "off", + duration: String(normalizeDuration(params.duration, 3, 15, 5)), + watermark_info: { enabled: false }, + prompt: params.prompt, + }; + if (imageList.length) body.image_list = imageList; + else body.aspect_ratio = normalizeRatio(params.ratio); + return body; +} + +function buildViduT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildViduI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("Vidu I2V requires one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildPixverseT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + const size = sizeMap[requestedRes] || "1280*720"; + return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildPixverseI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("PixVerse I2V requires one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} +function buildPixverseKf2vBody(params) { + const refs = getReferenceImageUrls(params, 2); + if (refs.length < 1) throw createMissingReferenceError("PixVerse KF2V requires at least one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; + const media = refs.map(url => ({ type: "image_url", url })); + return { model: params.model, input: { prompt: params.prompt || "", media }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildVideoRequest(providerConfig, params, apiKey) { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; + let body; + + if (providerConfig.protocol === "seed-video-ark") { + body = buildArkSeedVideoBody(params); + } else if (providerConfig.protocol === "happyhorse-t2v") { + body = buildHappyHorseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-i2v") { + body = buildHappyHorseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-r2v") { + body = buildHappyHorseR2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-i2v") { + body = buildWanI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-s2v") { + body = buildWanS2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-dashscope") { + body = buildDashscopeKlingBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-t2v") { + body = buildViduT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-i2v") { + body = buildViduI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-t2v") { + body = buildPixverseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-i2v") { + body = buildPixverseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-kf2v") { + body = buildPixverseKf2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-omni") { + body = buildKlingOmniBody(params); + const credential = parseKlingCredential(apiKey); + if (credential) { + headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; + } + } else { + body = buildSeedVideoBody(params); + } + + return { headers, body }; +} + +function registerAiRoutes(router) { + router.post("/ai/image", requireAuth, async (req, res) => { + const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; + if (!prompt) return res.status(400).json({ error: "Missing prompt" }); + + try { + const providerCandidates = resolveImageProviderCandidates(model); + const primaryProviderConfig = providerCandidates[0]; + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: primaryProviderConfig.model, + requestedModel: primaryProviderConfig.requestedModel, + prompt, + ratio, + quality, + gridMode, + referenceUrls, + }; + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: params.requestedModel || params.model || model, + resolution: [ratio, quality].filter(Boolean).join(" / "), + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + error.costCents = billingResult.costCents; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + imageBilling: { + costCents: imageBilling.costCents, + deductionType: imageBilling.deductionType, + balanceAfterCents: imageBilling.balanceAfterCents, + }, + providerDebug: buildImageProviderDebug(model), + }); + submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { + console.error("[ai/image] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/image] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video", requireAuth, async (req, res) => { + const { + model, + prompt, + ratio, + duration, + quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + resolution, + muted, + hasReferenceVideo, + style, + projectId: requestedProjectId, + conversationId, + } = req.body; + const providerConfig = resolveVideoProvider(model); + const provider = providerConfig.provider; + const isWanS2v = providerConfig.protocol === "wan-s2v"; + const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); + + if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); + if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); + if (isWanS2v) { + if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl(audioUrl)) { + return res.status(400).json({ error: "Missing audioUrl" }); + } + } + + let slotResult = null; + try { + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: providerConfig.model, + requestedModel: providerConfig.requestedModel, + prompt: prompt || "数字人口播视频", + ratio, + duration, + quality: quality || resolution, + resolution: resolution || quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + muted: Boolean(muted), + hasReferenceVideo: Boolean(hasReferenceVideo), + style, + }; + + let enterpriseBilling = null; + let preauth = null; + if (isEnterpriseVideoBillingUser(req.user)) { + enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); + preauth = { + authorized: true, + estimatedCostCents: enterpriseBilling.amountCents, + billingMode: "enterprise", + }; + } else { + preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + if (enterpriseBilling) { + const nextBilling = await reserveEnterpriseVideoCredits(client, { + ...enterpriseBilling, + taskId: nextTaskRow.id, + }); + return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; + } + // Regular user: deduct from personal balance + const credits = calculateEnterpriseVideoCredits({ + model: params.model, + resolution: params.resolution || params.quality, + durationSeconds: params.duration, + muted: params.muted, + hasReferenceVideo: params.hasReferenceVideo, + }); + const costCents = Math.ceil(credits * 100); + const { rows: [deducted] } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [costCents, req.user.id], + ); + if (!deducted) { + throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); + } + await client.query( + "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", + [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], + ); + return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; + }); + + if (reservedBilling) { + params.enterpriseBilling = { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + resolution: reservedBilling.resolution, + durationSeconds: reservedBilling.durationSeconds, + rateCentsPerSecond: reservedBilling.rateCentsPerSecond, + }; + await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ + JSON.stringify(params), + taskRow.id, + ]); + } + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + enterpriseBilling: reservedBilling + ? { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, + } + : undefined, + }); + const activeSlotResult = slotResult; + slotResult = null; + submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) + .then(async () => { + try { + await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); + } catch (settlementError) { + console.error("[ai/video] enterprise ledger settle error:", settlementError.message); + } + }) + .catch(async (err) => { + console.error("[ai/video] submit error:", err.message); + await updateTaskInDb(taskRow.id, { status: "failed", error: translateDashscopeContentError(err.message) || err.message }); + await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); + releaseLease(activeSlotResult); + }); + } catch (err) { + releaseLease(slotResult); + console.error("[ai/video] error:", err.message); + if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { + return res.status(err.status || 402).json({ + error: err.message, + code: "INSUFFICIENT_ENTERPRISE_BALANCE", + }); + } + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + for (let srAttempt = 0; srAttempt < 3; srAttempt++) { + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); + if (slotResult) break; + if (srAttempt < 2) { + console.info(`[ai/image/super-resolve] concurrency full, retry ${srAttempt + 1}/2 after 5s`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + if (!slotResult) { + return res.status(429).json({ error: "\u8d85\u5206\u670d\u52a1\u7e41\u5fd9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: "wanx2.1-imageedit", + operation: "image-super-resolution", + imageUrl, + scale, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { + const videoUrl = String(req.body?.videoUrl || "").trim(); + const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); + const providerMode = String(req.body?.provider || req.body?.model || "").trim(); + const shouldUseDashscopeStyle = + providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + if (!/^https?:\/\//i.test(videoUrl)) { + return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); + } + + let dashscopeSlotResult; + try { + if (shouldUseDashscopeStyle) { + const provider = "dashscope"; + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!dashscopeSlotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const styleOptions = normalizeVideoStyleTransformOptions(req.body); + const params = { + model: "video-style-transform", + operation: "video-style-super-resolution", + videoUrl, + ...styleOptions, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { + console.error("[ai/video/super-resolve] dashscope submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(dashscopeSlotResult); + }); + return; + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); + console.error("[ai/video/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/chat", requireAuth, async (req, res) => { + const { model, messages, stream = true, temperature } = req.body; + if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); + + const providerConfig = resolveTextProvider(model); + const provider = providerConfig.provider; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const reqHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${slotResult.apiKey}`, + }; + const reqBody = JSON.stringify({ + model: providerConfig.model, + messages, + stream, + temperature: temperature || 0.7, + max_tokens: 4096, + }); + + if (stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); + + try { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => "upstream error"); + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), + done: true, + })}\n\n`, + ); + res.end(); + releaseLease(slotResult); + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + continue; + } + try { + const chunk = JSON.parse(payload); + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); + } catch {} + } + } + + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + res.end(); + releaseLease(slotResult); + } catch (streamErr) { + if (streamErr.name !== "AbortError") { + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(streamErr.message), + done: true, + })}\n\n`, + ); + } + res.end(); + releaseLease(slotResult); + } + } else { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); + const text = await upstream.text().catch(() => ""); + releaseLease(slotResult); + + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + return res.status(502).json({ + error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), + }); + } + + if (!upstream.ok || json.error) { + return res.status(502).json({ + error: sanitizeUpstreamError( + json.error?.message || json.message || json.error || text, + `文本服务返回 HTTP ${upstream.status}`, + ), + }); + } + + const content = json.choices?.[0]?.message?.content || ""; + const usage = json.usage || {}; + res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); + } + } catch (err) { + releaseLease(slotResult); + console.error("[ai/chat] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); + const status = String(req.query.status || "").trim(); + const type = String(req.query.type || "").trim(); + const projectId = String(req.query.projectId || req.query.project_id || "").trim(); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (["image", "video"].includes(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + if (projectId) { + params.push(projectId); + where.push(`project_id = $${params.length}`); + } + + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT * + FROM generation_tasks + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length - 1} + OFFSET $${params.length} + `, + params, + ); + res.json({ tasks: rows.map(formatAiTaskRow) }); + } catch (err) { + console.error("[ai/tasks] list failed:", err.message); + res.status(500).json({ error: "Failed to load task history" }); + } + }); + + router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + const conversationId = Number(req.body?.conversationId); + + if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { + return res.status(400).json({ error: "Invalid task or conversation id" }); + } + + try { + const { rows: conversationRows } = await pool.query( + "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", + [conversationId, req.user.id], + ); + if (conversationRows.length === 0) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const { rows } = await pool.query( + `UPDATE generation_tasks + SET conversation_id = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + RETURNING id, conversation_id`, + [conversationId, taskId, req.user.id], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Task not found" }); + } + + res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + res.json(formatAiTaskRow(rows[0])); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); + + try { + const { rows } = await pool.query( + "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); + res.json({ id: rows[0].id, status: rows[0].status }); + } catch (err) { + console.error("[ai/task-cancel] error:", err.message); + res.status(500).json({ error: "取消任务失败" }); + } + }); + + router.get("/ai/proxy-download", requireAuth, async (req, res) => { + const url = String(req.query.url || "").trim(); + if (!url || !/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: "Missing or invalid url parameter" }); + } + // Only allow proxying from our own OSS bucket + if (!url.includes("stringtest.oss") && !url.includes("aliyuncs.com")) { + return res.status(403).json({ error: "URL not allowed for proxy download" }); + } + try { + const upstream = await fetch(url, { method: "GET" }); + if (!upstream.ok) { + return res.status(upstream.status || 502).json({ error: "Upstream returned " + upstream.status }); + } + const contentType = upstream.headers.get("content-type") || "image/png"; + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Upstream returned empty content" }); + } + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.end(buffer); + } catch (err) { + console.error("[ai/proxy-download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + const task = rows[0]; + const resultUrl = String(task.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl)) { + return res.status(400).json({ error: "Task result is not downloadable" }); + } + + const upstream = await fetch(resultUrl, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); + } + + const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorContentType(contentType)) { + const text = await upstream.text().catch(() => ""); + return res.status(502).json({ + error: text.includes("Expired") || text.includes("AccessDenied") + ? "结果链接已过期,请重新生成后再下载" + : "结果链接返回了错误内容,请重新生成后再下载", + }); + } + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Result download returned empty content" }); + } + + const extension = extensionFromContentType(contentType, task.type); + const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/tasks/download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); +} + +async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { + const errors = [...previousErrors]; + const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; + + for (let index = 0; index < candidates.length; index += 1) { + const providerConfig = candidates[index]; + const provider = providerConfig?.provider; + let slotResult = null; + + if (!provider) continue; + + try { + if (index > 0 && !(await providerPoolExists(provider))) { + throw new Error(`${provider} provider pool is not configured`); + } + + slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + throw new Error(`${provider} concurrency pool is full`); + } + + await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { + onTaskFailed: async (failureMessage) => { + const providerError = `${provider}: ${failureMessage}`; + const remainingCandidates = candidates.slice(index + 1); + if (remainingCandidates.length === 0) { + await updateTaskInDb(taskDbId, { + status: "failed", + error: translateDashscopeContentError([...errors, providerError].join(" | ")) || `All image providers failed: ${[...errors, providerError].join(" | ")}`, + }); + return true; + } + + console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + try { + await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ + ...errors, + providerError, + ]); + return true; + } catch (fallbackErr) { + await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); + return true; + } + }, + }); + if (index > 0) { + console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); + } + return; + } catch (err) { + const message = err?.message || String(err); + errors.push(`${provider}: ${message}`); + console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); + releaseLease(slotResult); + + if (index < candidates.length - 1) { + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + } + } + } + + throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); +} + +async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { + const url = getPostUrl(providerConfig); + const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const submitTimeout = providerConfig.transport === "rightcode-image" ? RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS : providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; + const maxAttempts = providerConfig.transport === "rightcode-image" ? 2 : 1; + let response; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { + console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} failed (HTTP ${response.status}), retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + throw new Error(sanitizeUpstreamError(errText, `\u56fe\u7247\u670d\u52a1\u8fd4\u56deHTTP ${response.status}`)); + } + break; + } catch (err) { + if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { + console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} error: ${err.message}, retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + throw err; + } + } + + const json = await response.json(); + + // Synchronous transports — extract image URL directly, no polling + if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { + let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); + const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; + console.info( + `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, + ); + if (!directUrl) { + // Retry once for kuaikuai empty result + if (tag === "kuaikuai") { + console.info(`[ai/image/kuaikuai] task ${taskDbId} retrying after empty result...`); + await new Promise((r) => setTimeout(r, 3000)); + const retryResponse = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (retryResponse.ok) { + const retryJson = await retryResponse.json(); + directUrl = extractImageUrl(retryJson) || extractGeminiImageUrl(retryJson); + console.info(`[ai/image/kuaikuai] task ${taskDbId} retry result ${directUrl ? "parsed" : "still missing"}`); + } + } + if (!directUrl) throw new Error(`${tag} did not return an image url`); + } + + + // Gemini may return base64 data URL — too large for DB, upload to OSS first + if (directUrl.startsWith("data:") && isOssConfigured()) { + const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + const mimeType = match[1]; + const buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "png"; + const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const bucket = process.env.OSS_BUCKET || ""; + const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); + directUrl = process.env.OSS_PUBLIC_BASE_URL + ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` + : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; + console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); + } + } + + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); + releaseLease(slotResult); + return; + } + + const directUrl = extractImageUrl(json); + + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + onTaskFailed: options.onTaskFailed, + }); +} + +async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + if (providerConfig.protocol === "wan-s2v") { + await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); + await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); + } + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Video provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeImageSuperResolveBody(params); + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeVideoStyleTransformBody(params); + const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig: { + protocol: "wan-i2v", + baseUrl: "https://dashscope.aliyuncs.com", + }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitVideoSuperResolveTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const submitResult = await callAliyunRpc("SuperResolveVideo", { + VideoUrl: params.videoUrl, + BitRate: String(params.bitRate || 10), + }); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun SuperResolveVideo did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun super-resolution completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("Aliyun video super-resolution timed out"); +} + +module.exports = { registerAiRoutes }; diff --git a/src/routes/ai.js.bak-classify b/src/routes/ai.js.bak-classify new file mode 100644 index 0000000..e61ee6b --- /dev/null +++ b/src/routes/ai.js.bak-classify @@ -0,0 +1,2098 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); +const { putObject, isOssConfigured } = require("../ossClient"); +const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); +const { shouldSkipProvider, recordProviderSuccess, recordProviderFailure, getAdaptiveTimeout, getAllBreakerStats } = require("../providerCircuitBreaker"); +const { + isEnterpriseVideoBillingUser, + markEnterpriseVideoCreditsAccepted, + prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, + reserveEnterpriseVideoCredits, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, +} = require("../enterpriseVideoBilling"); +const { + startPolling, + updateTaskInDb, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, +} = require("../aiTaskWorker"); +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../aiUpscaleHelpers"); + +const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ + ["gpt-image-2", "1K"], + ["gpt-image-2-vip", "2K"], +]); + +const GRSAI_IMAGE_MAX_QUALITY = new Map([ + ["gpt-image-2", "2K"], +]); + +const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ + ["wan2.7-image", "2K"], +]); + +const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; +const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; +const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; +const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; +const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 150_000; +const RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS = 70_000; +const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; +const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; +const MAX_USER_ACTIVE_GENERATION_TASKS = 3; +const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; + +const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { + "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, + "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, + "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, + "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, + "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, +}; + +function mapAspectRatioToPixels(ratio, quality) { + const q = String(quality || "1K").toUpperCase(); + const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; + return map ? (map[q] || map["1K"]) : "1024x1024"; +} + +function mapAspectRatioToDashscopeSize(ratio, quality) { + return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); +} + +function normalizeQuality(value, fallback = "1K") { + const q = String(value || fallback).trim().toUpperCase(); + if (q === "4K" || q === "2K" || q === "1K") return q; + return fallback; +} + +function clampImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "2K"); + const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function clampGrsaiImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "1K"); + const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function normalizeDuration(value, min = 4, max = 15, fallback = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeRatio(value, fallback = "16:9") { + const ratio = String(value || fallback).trim(); + return ratio === "auto" ? "adaptive" : ratio; +} + +function normalizeVideoResolution(value, allowed, fallback = "720p") { + const resolution = String(value || "").trim().toLowerCase(); + return allowed.includes(resolution) ? resolution : fallback; +} + +function normalizeS2vResolution(value) { + const resolution = String(value || "").trim().toLowerCase(); + return resolution === "480p" ? "480P" : "720P"; +} + +function normalizeS2vStyle(value) { + const style = String(value || "").trim().toLowerCase(); + return ["speech", "sing", "performance"].includes(style) ? style : "speech"; +} + +function normalizePublicHttpUrl(value) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function percentEncodeRpc(value) { + return encodeURIComponent(String(value)) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +function signAliyunRpcParams(method, params, accessKeySecret) { + const canonicalQuery = Object.keys(params) + .sort() + .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) + .join("&"); + const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +function getAliyunVideoEnhanCredentials() { + const accessKeyId = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || + process.env.ALIYUN_ACCESS_KEY_ID || + process.env.STS_ACCESS_KEY_ID || + ""; + const accessKeySecret = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || + process.env.ALIYUN_ACCESS_KEY_SECRET || + process.env.STS_ACCESS_KEY_SECRET || + ""; + return { accessKeyId, accessKeySecret }; +} + +function buildAliyunRpcUrl(action, actionParams = {}) { + const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); + if (!accessKeyId || !accessKeySecret) { + const error = new Error("Aliyun video super-resolution is not configured"); + error.status = 501; + throw error; + } + + const params = { + Action: action, + Version: ALIYUN_VIDEOENHAN_VERSION, + Format: "JSON", + AccessKeyId: accessKeyId, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + ...actionParams, + }; + params.Signature = signAliyunRpcParams("POST", params, accessKeySecret); + + const body = Object.entries(params) + .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) + .join("&"); + return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body }; +} + +function parseAliyunJsonResult(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +async function callAliyunRpc(action, params) { + const req = buildAliyunRpcUrl(action, params); + const response = await fetch(req.url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: req.body, + }); + const text = await response.text().catch(() => ""); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); + } + + if (!response.ok || json.Code || json.code) { + throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); + } + + return json; +} + +function normalizeSuperResolveBitRate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 10; + return Math.max(1, Math.min(20, Math.round(numeric))); +} + +function normalizeAliyunJobStatus(value) { + return String(value || "").trim().toUpperCase(); +} + +async function ensureDefaultProject(userId) { + const projectId = `web-default-${userId}`; + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); + if (rows.length === 0) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + await pool.query( + `INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + updated_by_device_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING`, + [ + projectId, + userId, + "Default workbench", + "Web fallback project for legacy generation requests", + `users/${safeUserId}/projects/${projectId}/current/project.json`, + ], + ); + } + return projectId; +} + +async function resolveTaskProject(userId, requestedProjectId) { + const projectId = String(requestedProjectId || "").trim().slice(0, 64); + if (!projectId) { + return ensureDefaultProject(userId); + } + + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + return projectId; +} + +async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { + if (!client) { + return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); + } + + await assertUserGenerationConcurrencyLimit(userId, client); + const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { rows: [row] } = await client.query( + `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, + [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], + ); + return row; +} + +async function assertUserGenerationConcurrencyLimit(userId, client = pool) { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); + const { rows } = await client.query( + "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", + [userId], + ); + const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); + if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + + const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + error.status = 429; + error.code = "GENERATION_CONCURRENCY_LIMIT"; + error.activeCount = activeCount; + error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + throw error; +} + +async function providerPoolExists(provider) { + if (!provider) return false; + const { rows } = await pool.query( + "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", + [provider], + ); + return rows.length > 0; +} + +function releaseLease(slotResult) { + if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); +} + +function sendAiRouteError(res, err) { + res.status(err.status || 500).json({ + error: err.message, + code: err.code, + activeCount: err.activeCount, + maxActiveTasks: err.maxActiveTasks, + }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { + const raw = String(value || "").trim(); + if (!raw) return fallback; + + let message = raw; + try { + const parsed = JSON.parse(raw); + message = + parsed?.error?.message || + parsed?.error_description || + parsed?.message || + parsed?.error || + raw; + } catch {} + + const compact = String(message).replace(/\s+/g, " ").trim(); + const looksLikeMarkup = + /^]/i.test(compact) || + /^<\?xml/i.test(compact) || + /<\/?[a-z][^>]*>/i.test(compact); + + if (looksLikeMarkup) return fallback; + return compact.slice(0, 320); +} + +function translateDashscopeContentError(message) { + const msg = String(message || ""); + if (msg.includes("Green net check failed for image input") || msg.includes("Input data may contain inappropriate content")) { + return "\u53c2\u8003\u56fe\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff08\u7eff\u7f51\u68c0\u67e5\uff09\uff0c\u8bf7\u66f4\u6362\u56fe\u7247\u540e\u91cd\u8bd5\u3002\u5e38\u89c1\u539f\u56e0\uff1a\u4eba\u7269\u66b4\u9732\u3001\u654f\u611f\u9762\u90e8\u3001\u56fe\u7247\u542b\u654f\u611f\u6587\u5b57\u7b49\u3002"; + } + if (msg.includes("Output data may contain inappropriate content")) { + return "\u751f\u6210\u7ed3\u679c\u672a\u901a\u8fc7\u5185\u5bb9\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; + } + if (msg.includes("content policies") || msg.includes("prompt may violate")) { + return "\u63d0\u793a\u8bcd\u672a\u901a\u8fc7\u5185\u5bb9\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63cf\u8ff0\u540e\u91cd\u8bd5\u3002\u907f\u514d\u4f7f\u7528\u6d89\u53ca\u66b4\u9732\u3001\u66b4\u529b\u3001\u653f\u6cbb\u654f\u611f\u7b49\u8bcd\u6c47\u3002"; + } + if (msg.includes("inappropriate content")) { + return "\u5185\u5bb9\u672a\u901a\u8fc7\u5b89\u5168\u5ba1\u6838\uff0c\u8bf7\u4fee\u6539\u63d0\u793a\u8bcd\u6216\u66f4\u6362\u53c2\u8003\u56fe\u540e\u91cd\u8bd5\u3002"; + } + return ""; +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatAiTaskRow(row) { + return { + taskId: String(row.id), + projectId: row.project_id, + conversationId: row.conversation_id, + clientQueueId: row.client_queue_id || null, + type: row.type, + status: row.status, + progress: Number(row.progress || 0), + resultUrl: row.result_url || null, + error: row.error || null, + params: parseTaskParams(row.params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function extensionFromContentType(contentType, fallbackType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + return fallbackType === "video" ? "mp4" : "png"; +} + +function contentDispositionFilename(value) { + return String(value || "generated") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, 120) || "generated"; +} + +function isErrorContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function buildDashscopeImageBody(params) { + const content = []; + for (const url of params.referenceUrls || []) { + if (url) content.push({ image: url }); + } + content.push({ text: params.prompt }); + const quality = clampImageQualityForModel(params.model, params.quality); + return { + model: params.model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, + watermark: false, + }, + }; +} + +function buildGrsaiImageBody(params) { + const isGptImage = String(params.model || "").startsWith("gpt-image"); + const modelKey = String(params.model || "").toLowerCase(); + const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); + return isGptImage + ? { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: mapAspectRatioToPixels(params.ratio, quality), + replyType: "json", + } + : { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: params.ratio || "auto", + imageSize: quality, + replyType: "json", + }; +} + +function buildRightcodeImageBody(providerConfig, params) { + const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; + const quality = normalizeQuality(params.quality, "1K"); + + return { + model: providerConfig.model || params.model, + prompt: params.prompt, + image: referenceUrls, + size: mapAspectRatioToPixels(params.ratio, quality), + response_format: "url", + }; +} + +function getGridCount(gridMode) { + if (gridMode === "grid-4") return 4; + if (gridMode === "grid-9") return 9; + if (gridMode === "grid-25") return 25; + return 1; +} + +function buildGeminiImageBody(params) { + const parts = [{ text: String(params.prompt || "").trim() }]; + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + parts.push({ + fileData: { fileUri: url, mimeType: "image/png" }, + }); + } + const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; + const count = getGridCount(params.gridMode); + if (count > 1) generationConfig.candidateCount = count; + return { + contents: [{ parts }], + generationConfig, + }; +} + +function buildOpenAIImageBody(providerConfig, params) { + const userContent = []; + const prompt = String(params.prompt || "").trim(); + if (prompt) userContent.push({ type: "text", text: prompt }); + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + userContent.push({ type: "image_url", image_url: { url } }); + } + const body = { + model: providerConfig.model || params.model, + messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], + }; + const count = getGridCount(params.gridMode); + if (count > 1) body.n = count; + return body; +} + +function buildImageRequest(providerConfig, params, apiKey) { + const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; + if (providerConfig.transport === "dashscope-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; + } + if (providerConfig.transport === "rightcode-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; + } + if (providerConfig.transport === "gemini-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; + } + if (providerConfig.transport === "openai-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; + } + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; +} + +function buildSeedVideoBody(params) { + const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); + const metadata = { + generate_audio: true, + watermark: false, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + resolution, + }; + const body = { + model: params.model, + prompt: params.prompt, + metadata, + }; + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + metadata.first_frame_image = refs[0]; + metadata.last_frame_image = refs[refs.length - 1]; + } else if (refs.length === 1) { + body.image = refs[0]; + } else if (refs.length > 1) { + metadata.reference_images = refs; + } + return body; +} + +function buildArkSeedVideoBody(params) { + const content = []; + if (params.prompt) content.push({ type: "text", text: params.prompt }); + + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); + content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); + } else { + refs.forEach((url, index) => { + content.push({ + type: "image_url", + image_url: { url }, + role: index === 0 ? "first_frame" : "reference_image", + }); + }); + } + + const body = { + model: params.model, + content, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + generate_audio: true, + watermark: false, + }; + body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); + return body; +} + +function buildWanI2vBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + parameters.prompt_extend = true; + + return { + model: params.model, + input, + parameters, + }; +} + +function normalizeHappyHorseResolution(value) { + return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function getReferenceImageUrls(params, limit = 9) { + return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) + .map((url) => normalizePublicHttpUrl(url)) + .filter(Boolean) + .slice(0, limit); +} + +function buildHappyHorseBaseParameters(params, { includeRatio }) { + const parameters = { + resolution: normalizeHappyHorseResolution(params.quality), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); + return parameters; +} + +function createMissingReferenceError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function buildHappyHorseT2vBody(params) { + return { + model: params.model, + input: { + prompt: params.prompt, + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function buildHappyHorseI2vBody(params) { + const [firstFrame] = getReferenceImageUrls(params, 1); + if (!firstFrame) { + throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: [{ type: "first_frame", url: firstFrame }], + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), + }; +} + +function buildHappyHorseR2vBody(params) { + const refs = getReferenceImageUrls(params, 9); + if (!refs.length) { + throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: refs.map((url) => ({ type: "reference_image", url })), + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function getHappyHorseReferenceError(protocol, referenceUrls) { + if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { + return "HappyHorse I2V requires one first-frame image."; + } + if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { + return "HappyHorse R2V requires 1 to 9 reference images."; + } + return ""; +} + +async function assertWanS2vImageDetected(providerConfig, params, apiKey) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + + const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: providerConfig.detectModel || "wan2.2-s2v-detect", + input: { image_url: imageUrl }, + }), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + + if (!response.ok) { + throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); + } + + const output = json && typeof json === "object" ? json.output || json.data || json : {}; + const pass = + output.check_pass === true || + output.checkPass === true || + output.passed === true || + output.pass === true || + String(output.code || "").toLowerCase() === "success"; + + if (!pass) { + const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; + const error = new Error(message); + error.status = 400; + throw error; + } +} + +function extractProviderDetectMessage(output) { + if (!output || typeof output !== "object") return ""; + return String( + output.message || + output.reason || + output.failure_reason || + output.description || + output.error || + "", + ).trim(); +} + +function buildWanS2vBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + const audioUrl = normalizePublicHttpUrl(params.audioUrl); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!audioUrl) { + const error = new Error("Missing audioUrl"); + error.status = 400; + throw error; + } + + const parameters = { + resolution: normalizeS2vResolution(params.quality), + style: normalizeS2vStyle(params.style), + }; + + return { + model: params.model, + input: { + image_url: imageUrl, + audio_url: audioUrl, + }, + parameters, + }; +} + +function buildDashscopeKlingBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const parameters = { + mode: params.quality === "std" ? "std" : "pro", + duration: normalizeDuration(params.duration, 5, 10, 5), + audio: false, + watermark: false, + }; + if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); + + return { model: params.model, input, parameters }; +} + +function buildKlingOmniBody(params) { + const refs = params.referenceUrls || []; + const imageList = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); + } else if (refs[0]) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + } + + const body = { + model_name: "kling-v3-omni", + mode: params.quality === "std" ? "std" : "pro", + sound: "off", + duration: String(normalizeDuration(params.duration, 3, 15, 5)), + watermark_info: { enabled: false }, + prompt: params.prompt, + }; + if (imageList.length) body.image_list = imageList; + else body.aspect_ratio = normalizeRatio(params.ratio); + return body; +} + +function buildViduT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildViduI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("Vidu I2V requires one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = requestedRes === "720P" ? "720P" : "1080P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } }; +} + +function buildPixverseT2vBody(params) { + const requestedRes = String(params.quality || "").toUpperCase(); + const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" }; + const size = sizeMap[requestedRes] || "1280*720"; + return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildPixverseI2vBody(params) { + const [img] = getReferenceImageUrls(params, 1); + if (!img) throw createMissingReferenceError("PixVerse I2V requires one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; + return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} +function buildPixverseKf2vBody(params) { + const refs = getReferenceImageUrls(params, 2); + if (refs.length < 1) throw createMissingReferenceError("PixVerse KF2V requires at least one reference image."); + const requestedRes = String(params.quality || "").toUpperCase(); + const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P"; + const media = refs.map(url => ({ type: "image_url", url })); + return { model: params.model, input: { prompt: params.prompt || "", media }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } }; +} + +function buildWanAnimateMixBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl); + const videoUrl = normalizePublicHttpUrl((params.referenceUrls || [])[0]); + if (!imageUrl) { const e = new Error("Missing imageUrl"); e.status = 400; throw e; } + if (!videoUrl) { const e = new Error("Missing videoUrl"); e.status = 400; throw e; } + return { model: params.model, input: { image_url: imageUrl, video_url: videoUrl }, parameters: { mode: "wan-pro" } }; +} +function buildVideoRequest(providerConfig, params, apiKey) { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; + let body; + + if (providerConfig.protocol === "seed-video-ark") { + body = buildArkSeedVideoBody(params); + } else if (providerConfig.protocol === "happyhorse-t2v") { + body = buildHappyHorseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-i2v") { + body = buildHappyHorseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-r2v") { + body = buildHappyHorseR2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-i2v") { + body = buildWanI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-s2v") { + body = buildWanS2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-dashscope") { + body = buildDashscopeKlingBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-t2v") { + body = buildViduT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "vidu-i2v") { + body = buildViduI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-t2v") { + body = buildPixverseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-i2v") { + body = buildPixverseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "pixverse-kf2v") { + body = buildPixverseKf2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-animate-mix") { + body = buildWanAnimateMixBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-omni") { + body = buildKlingOmniBody(params); + const credential = parseKlingCredential(apiKey); + if (credential) { + headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; + } + } else { + body = buildSeedVideoBody(params); + } + + return { headers, body }; +} + +function registerAiRoutes(router) { + router.get("/ai/provider-health", requireAuth, (req, res) => { + res.json({ providers: getAllBreakerStats() }); + }); + + router.post("/ai/image", requireAuth, async (req, res) => { + const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; + if (!prompt) return res.status(400).json({ error: "Missing prompt" }); + + try { + const providerCandidates = resolveImageProviderCandidates(model); + const primaryProviderConfig = providerCandidates[0]; + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: primaryProviderConfig.model, + requestedModel: primaryProviderConfig.requestedModel, + prompt, + ratio, + quality, + gridMode, + referenceUrls, + }; + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: params.requestedModel || params.model || model, + resolution: [ratio, quality].filter(Boolean).join(" / "), + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + error.costCents = billingResult.costCents; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + imageBilling: { + costCents: imageBilling.costCents, + deductionType: imageBilling.deductionType, + balanceAfterCents: imageBilling.balanceAfterCents, + }, + providerDebug: buildImageProviderDebug(model), + }); + submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { + console.error("[ai/image] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/image] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video", requireAuth, async (req, res) => { + const { + model, + prompt, + ratio, + duration, + quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + resolution, + muted, + hasReferenceVideo, + style, + projectId: requestedProjectId, + conversationId, + } = req.body; + const providerConfig = resolveVideoProvider(model); + const provider = providerConfig.provider; + const isWanS2v = providerConfig.protocol === "wan-s2v"; + const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); + + if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); + if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); + if (isWanS2v) { + if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl(audioUrl)) { + return res.status(400).json({ error: "Missing audioUrl" }); + } + } + + let slotResult = null; + try { + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: providerConfig.model, + requestedModel: providerConfig.requestedModel, + prompt: prompt || "数字人口播视频", + ratio, + duration, + quality: quality || resolution, + resolution: resolution || quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + muted: Boolean(muted), + hasReferenceVideo: Boolean(hasReferenceVideo), + style, + }; + + let enterpriseBilling = null; + let preauth = null; + if (isEnterpriseVideoBillingUser(req.user)) { + enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); + preauth = { + authorized: true, + estimatedCostCents: enterpriseBilling.amountCents, + billingMode: "enterprise", + }; + } else { + preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + if (enterpriseBilling) { + const nextBilling = await reserveEnterpriseVideoCredits(client, { + ...enterpriseBilling, + taskId: nextTaskRow.id, + }); + return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; + } + // Regular user: deduct from personal balance + const credits = calculateEnterpriseVideoCredits({ + model: params.model, + resolution: params.resolution || params.quality, + durationSeconds: params.duration, + muted: params.muted, + hasReferenceVideo: params.hasReferenceVideo, + }); + const costCents = Math.ceil(credits * 100); + const { rows: [deducted] } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [costCents, req.user.id], + ); + if (!deducted) { + throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); + } + await client.query( + "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", + [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], + ); + return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; + }); + + if (reservedBilling) { + params.enterpriseBilling = { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + resolution: reservedBilling.resolution, + durationSeconds: reservedBilling.durationSeconds, + rateCentsPerSecond: reservedBilling.rateCentsPerSecond, + }; + await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ + JSON.stringify(params), + taskRow.id, + ]); + } + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + enterpriseBilling: reservedBilling + ? { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, + } + : undefined, + }); + const activeSlotResult = slotResult; + slotResult = null; + submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) + .then(async () => { + try { + await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); + } catch (settlementError) { + console.error("[ai/video] enterprise ledger settle error:", settlementError.message); + } + }) + .catch(async (err) => { + console.error("[ai/video] submit error:", err.message); + await updateTaskInDb(taskRow.id, { status: "failed", error: translateDashscopeContentError(err.message) || err.message }); + await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); + releaseLease(activeSlotResult); + }); + } catch (err) { + releaseLease(slotResult); + console.error("[ai/video] error:", err.message); + if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { + return res.status(err.status || 402).json({ + error: err.message, + code: "INSUFFICIENT_ENTERPRISE_BALANCE", + }); + } + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + for (let srAttempt = 0; srAttempt < 3; srAttempt++) { + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); + if (slotResult) break; + if (srAttempt < 2) { + console.info(`[ai/image/super-resolve] concurrency full, retry ${srAttempt + 1}/2 after 5s`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + if (!slotResult) { + return res.status(429).json({ error: "\u8d85\u5206\u670d\u52a1\u7e41\u5fd9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: "wanx2.1-imageedit", + operation: "image-super-resolution", + imageUrl, + scale, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + + // --- Image Edit (watermark removal, etc.) --- + router.post("/ai/image/edit", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const editFunction = req.body?.function; + const prompt = req.body?.prompt || ""; + const n = req.body?.n || 1; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + if (!editFunction) return res.status(400).json({ error: "Missing function" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + for (let attempt = 0; attempt < 3; attempt++) { + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 20000 }); + if (slotResult) break; + if (attempt < 2) { + console.info(`[ai/image/edit] concurrency full, retry ${attempt + 1}/2 after 5s`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + if (!slotResult) { + return res.status(429).json({ error: "图片编辑服务繁忙,请稍后重试" }); + } + + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const params = { + model: "wanx2.1-imageedit", + operation: editFunction, + imageUrl, + prompt, + n, + }; + const nextTaskRow = await insertTask(req.user.id, null, "image", params, null, client); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: "wanx2.1-imageedit", + resolution: editFunction, + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const params = { model: "wanx2.1-imageedit", operation: editFunction, imageUrl, prompt, n }; + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageEditTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/edit] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/edit] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { + const videoUrl = String(req.body?.videoUrl || "").trim(); + const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); + const providerMode = String(req.body?.provider || req.body?.model || "").trim(); + const shouldUseDashscopeStyle = + providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + if (!/^https?:\/\//i.test(videoUrl)) { + return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); + } + + let dashscopeSlotResult; + try { + if (shouldUseDashscopeStyle) { + const provider = "dashscope"; + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!dashscopeSlotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const styleOptions = normalizeVideoStyleTransformOptions(req.body); + const params = { + model: "video-style-transform", + operation: "video-style-super-resolution", + videoUrl, + ...styleOptions, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { + console.error("[ai/video/super-resolve] dashscope submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(dashscopeSlotResult); + }); + return; + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); + console.error("[ai/video/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/chat", requireAuth, async (req, res) => { + const { model, messages, stream = true, temperature } = req.body; + if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); + + const providerConfig = resolveTextProvider(model); + const provider = providerConfig.provider; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const reqHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${slotResult.apiKey}`, + }; + const reqBody = JSON.stringify({ + model: providerConfig.model, + messages, + stream, + temperature: temperature || 0.7, + max_tokens: 4096, + }); + + if (stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); + + try { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => "upstream error"); + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), + done: true, + })}\n\n`, + ); + res.end(); + releaseLease(slotResult); + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + continue; + } + try { + const chunk = JSON.parse(payload); + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); + } catch {} + } + } + + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + res.end(); + releaseLease(slotResult); + } catch (streamErr) { + if (streamErr.name !== "AbortError") { + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(streamErr.message), + done: true, + })}\n\n`, + ); + } + res.end(); + releaseLease(slotResult); + } + } else { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); + const text = await upstream.text().catch(() => ""); + releaseLease(slotResult); + + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + return res.status(502).json({ + error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), + }); + } + + if (!upstream.ok || json.error) { + return res.status(502).json({ + error: sanitizeUpstreamError( + json.error?.message || json.message || json.error || text, + `文本服务返回 HTTP ${upstream.status}`, + ), + }); + } + + const content = json.choices?.[0]?.message?.content || ""; + const usage = json.usage || {}; + res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); + } + } catch (err) { + releaseLease(slotResult); + console.error("[ai/chat] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); + const status = String(req.query.status || "").trim(); + const type = String(req.query.type || "").trim(); + const projectId = String(req.query.projectId || req.query.project_id || "").trim(); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (["image", "video"].includes(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + if (projectId) { + params.push(projectId); + where.push(`project_id = $${params.length}`); + } + + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT * + FROM generation_tasks + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length - 1} + OFFSET $${params.length} + `, + params, + ); + res.json({ tasks: rows.map(formatAiTaskRow) }); + } catch (err) { + console.error("[ai/tasks] list failed:", err.message); + res.status(500).json({ error: "Failed to load task history" }); + } + }); + + router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + const conversationId = Number(req.body?.conversationId); + + if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { + return res.status(400).json({ error: "Invalid task or conversation id" }); + } + + try { + const { rows: conversationRows } = await pool.query( + "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", + [conversationId, req.user.id], + ); + if (conversationRows.length === 0) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const { rows } = await pool.query( + `UPDATE generation_tasks + SET conversation_id = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + RETURNING id, conversation_id`, + [conversationId, taskId, req.user.id], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Task not found" }); + } + + res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + res.json(formatAiTaskRow(rows[0])); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); + + try { + const { rows } = await pool.query( + "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); + res.json({ id: rows[0].id, status: rows[0].status }); + } catch (err) { + console.error("[ai/task-cancel] error:", err.message); + res.status(500).json({ error: "取消任务失败" }); + } + }); + + router.get("/ai/proxy-download", requireAuth, async (req, res) => { + const url = String(req.query.url || "").trim(); + if (!url || !/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: "Missing or invalid url parameter" }); + } + // Only allow proxying from our own OSS bucket + if (!url.includes("stringtest.oss") && !url.includes("aliyuncs.com")) { + return res.status(403).json({ error: "URL not allowed for proxy download" }); + } + try { + const upstream = await fetch(url, { method: "GET" }); + if (!upstream.ok) { + return res.status(upstream.status || 502).json({ error: "Upstream returned " + upstream.status }); + } + const contentType = upstream.headers.get("content-type") || "image/png"; + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Upstream returned empty content" }); + } + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.end(buffer); + } catch (err) { + console.error("[ai/proxy-download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + const task = rows[0]; + const resultUrl = String(task.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl)) { + return res.status(400).json({ error: "Task result is not downloadable" }); + } + + const upstream = await fetch(resultUrl, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); + } + + const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorContentType(contentType)) { + const text = await upstream.text().catch(() => ""); + return res.status(502).json({ + error: text.includes("Expired") || text.includes("AccessDenied") + ? "结果链接已过期,请重新生成后再下载" + : "结果链接返回了错误内容,请重新生成后再下载", + }); + } + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Result download returned empty content" }); + } + + const extension = extensionFromContentType(contentType, task.type); + const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/tasks/download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); +} + +async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { + const errors = [...previousErrors]; + const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; + + for (let index = 0; index < candidates.length; index += 1) { + const providerConfig = candidates[index]; + const provider = providerConfig?.provider; + let slotResult = null; + + if (!provider) continue; + + if (shouldSkipProvider(provider)) { + errors.push(`${provider}: circuit breaker OPEN (skipped)`); + console.info(`[ai/image] skipping ${provider} for task ${taskDbId} — circuit breaker OPEN`); + continue; + } + + const startTime = Date.now(); + try { + if (index > 0 && !(await providerPoolExists(provider))) { + throw new Error(`${provider} provider pool is not configured`); + } + + slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + throw new Error(`${provider} concurrency pool is full`); + } + + await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { + adaptiveTimeoutMs: getAdaptiveTimeout(provider, IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS), + onTaskFailed: async (failureMessage) => { + recordProviderFailure(provider); + const providerError = `${provider}: ${failureMessage}`; + const remainingCandidates = candidates.slice(index + 1); + if (remainingCandidates.length === 0) { + await updateTaskInDb(taskDbId, { + status: "failed", + error: translateDashscopeContentError([...errors, providerError].join(" | ")) || `All image providers failed: ${[...errors, providerError].join(" | ")}`, + }); + return true; + } + console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + try { + await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [...errors, providerError]); + return true; + } catch (fallbackErr) { + await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); + return true; + } + }, + onTaskCompleted: () => { + recordProviderSuccess(provider, Date.now() - startTime); + }, + }); + if (index > 0) { + console.info(`[ai/image] task ${taskDbId} switched to ${provider} (fallback)`); + } + return; + } catch (err) { + const message = err?.message || String(err); + errors.push(`${provider}: ${message}`); + recordProviderFailure(provider); + console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); + releaseLease(slotResult); + if (index < candidates.length - 1) { + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + } + } + } + + throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); +} + +async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { + const url = getPostUrl(providerConfig); + const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const baseTimeout = providerConfig.transport === "rightcode-image" ? RIGHTCODE_IMAGE_SUBMIT_TIMEOUT_MS : providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; + const submitTimeout = options.adaptiveTimeoutMs || baseTimeout; + const maxAttempts = providerConfig.transport === "rightcode-image" ? 2 : 1; + let response; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { + console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} failed (HTTP ${response.status}), retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + throw new Error(sanitizeUpstreamError(errText, `\u56fe\u7247\u670d\u52a1\u8fd4\u56deHTTP ${response.status}`)); + } + break; + } catch (err) { + if (attempt < maxAttempts && providerConfig.transport === "rightcode-image") { + console.info(`[ai/image/rightcode] task ${taskDbId} attempt ${attempt} error: ${err.message}, retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + throw err; + } + } + + const json = await response.json(); + + // Synchronous transports — extract image URL directly, no polling + if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { + let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); + const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; + console.info( + `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, + ); + if (!directUrl) { + // Retry once for kuaikuai empty result + if (tag === "kuaikuai") { + console.info(`[ai/image/kuaikuai] task ${taskDbId} retrying after empty result...`); + await new Promise((r) => setTimeout(r, 3000)); + const retryResponse = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (retryResponse.ok) { + const retryJson = await retryResponse.json(); + directUrl = extractImageUrl(retryJson) || extractGeminiImageUrl(retryJson); + console.info(`[ai/image/kuaikuai] task ${taskDbId} retry result ${directUrl ? "parsed" : "still missing"}`); + } + } + if (!directUrl) throw new Error(`${tag} did not return an image url`); + } + + + // Gemini may return base64 data URL — too large for DB, upload to OSS first + if (directUrl.startsWith("data:") && isOssConfigured()) { + const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + const mimeType = match[1]; + const buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "png"; + const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const bucket = process.env.OSS_BUCKET || ""; + const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); + directUrl = process.env.OSS_PUBLIC_BASE_URL + ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` + : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; + console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); + } + } + + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); + if (typeof options.onTaskCompleted === "function") { try { options.onTaskCompleted(); } catch (_) {} } + releaseLease(slotResult); + return; + } + + const directUrl = extractImageUrl(json); + + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + onTaskFailed: options.onTaskFailed, + onTaskCompleted: options.onTaskCompleted, + }); +} + +async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + if (providerConfig.protocol === "wan-s2v") { + await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); + await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); + } + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Video provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeImageEditTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = { + model: "wanx2.1-imageedit", + input: { + function: params.operation, + prompt: params.prompt || "去除图像中的文字", + base_image_url: params.imageUrl, + }, + parameters: { n: params.n || 1 }, + }; + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片编辑服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image edit did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + slotResult, + }); +} + +async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeImageSuperResolveBody(params); + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeVideoStyleTransformBody(params); + const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig: { + protocol: "wan-i2v", + baseUrl: "https://dashscope.aliyuncs.com", + }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitVideoSuperResolveTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const submitResult = await callAliyunRpc("SuperResolveVideo", { + VideoUrl: params.videoUrl, + BitRate: String(params.bitRate || 10), + }); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun SuperResolveVideo did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun super-resolution completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("Aliyun video super-resolution timed out"); +} + +module.exports = { registerAiRoutes }; diff --git a/src/routes/ai.js.bak-superresolve b/src/routes/ai.js.bak-superresolve new file mode 100644 index 0000000..b2e7558 --- /dev/null +++ b/src/routes/ai.js.bak-superresolve @@ -0,0 +1,1811 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context"); +const { putObject, isOssConfigured } = require("../ossClient"); +const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter"); +const { + isEnterpriseVideoBillingUser, + markEnterpriseVideoCreditsAccepted, + prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, + reserveEnterpriseVideoCredits, + calculateEnterpriseVideoCredits, + getEnterpriseVideoCreditRate, +} = require("../enterpriseVideoBilling"); +const { + startPolling, + updateTaskInDb, + extractProviderTaskId, + extractImageUrl, + extractGeminiImageUrl, + extractVideoUrl, + parseKlingCredential, + createKlingJwt, +} = require("../aiTaskWorker"); +const { + buildDashscopeImageSuperResolveBody, + buildDashscopeVideoStyleTransformBody, + normalizeImageUpscaleFactor, + normalizeVideoStyleTransformOptions, +} = require("../aiUpscaleHelpers"); + +const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([ + ["gpt-image-2", "1K"], +]); + +const GRSAI_IMAGE_MAX_QUALITY = new Map([ + ["gpt-image-2", "2K"], +]); + +const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([ + ["wan2.7-image", "2K"], +]); + +const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/"; +const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20"; +const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000; +const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 120; +const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 90_000; +const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000; +const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"; +const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis"; +const MAX_USER_ACTIVE_GENERATION_TASKS = 3; +const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务"; + +const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = { + "1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" }, + "16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" }, + "9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" }, + "4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" }, + "3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" }, +}; + +function mapAspectRatioToPixels(ratio, quality) { + const q = String(quality || "1K").toUpperCase(); + const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"]; + return map ? (map[q] || map["1K"]) : "1024x1024"; +} + +function mapAspectRatioToDashscopeSize(ratio, quality) { + return mapAspectRatioToPixels(ratio, quality).replace("x", "*"); +} + +function normalizeQuality(value, fallback = "1K") { + const q = String(value || fallback).trim().toUpperCase(); + if (q === "4K" || q === "2K" || q === "1K") return q; + return fallback; +} + +function clampImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "2K"); + const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function clampGrsaiImageQualityForModel(model, quality) { + const normalized = normalizeQuality(quality, "1K"); + const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); + if (maxQuality === "2K" && normalized === "4K") return "2K"; + if (maxQuality === "1K" && normalized !== "1K") return "1K"; + return normalized; +} + +function normalizeDuration(value, min = 4, max = 15, fallback = 5) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeRatio(value, fallback = "16:9") { + const ratio = String(value || fallback).trim(); + return ratio === "auto" ? "adaptive" : ratio; +} + +function normalizeVideoResolution(value, allowed, fallback = "720p") { + const resolution = String(value || "").trim().toLowerCase(); + return allowed.includes(resolution) ? resolution : fallback; +} + +function normalizeS2vResolution(value) { + const resolution = String(value || "").trim().toLowerCase(); + return resolution === "480p" ? "480P" : "720P"; +} + +function normalizeS2vStyle(value) { + const style = String(value || "").trim().toLowerCase(); + return ["speech", "sing", "performance"].includes(style) ? style : "speech"; +} + +function normalizePublicHttpUrl(value) { + const url = String(value || "").trim(); + return /^https?:\/\//i.test(url) ? url : ""; +} + +function percentEncodeRpc(value) { + return encodeURIComponent(String(value)) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +function signAliyunRpcParams(method, params, accessKeySecret) { + const canonicalQuery = Object.keys(params) + .sort() + .map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`) + .join("&"); + const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +function getAliyunVideoEnhanCredentials() { + const accessKeyId = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID || + process.env.ALIYUN_ACCESS_KEY_ID || + process.env.STS_ACCESS_KEY_ID || + ""; + const accessKeySecret = + process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET || + process.env.ALIYUN_ACCESS_KEY_SECRET || + process.env.STS_ACCESS_KEY_SECRET || + ""; + return { accessKeyId, accessKeySecret }; +} + +function buildAliyunRpcUrl(action, actionParams = {}) { + const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials(); + if (!accessKeyId || !accessKeySecret) { + const error = new Error("Aliyun video super-resolution is not configured"); + error.status = 501; + throw error; + } + + const params = { + Action: action, + Version: ALIYUN_VIDEOENHAN_VERSION, + Format: "JSON", + AccessKeyId: accessKeyId, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + ...actionParams, + }; + params.Signature = signAliyunRpcParams("GET", params, accessKeySecret); + + const queryString = Object.entries(params) + .map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`) + .join("&"); + return `${ALIYUN_VIDEOENHAN_ENDPOINT}?${queryString}`; +} + +function parseAliyunJsonResult(value) { + if (!value) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +async function callAliyunRpc(action, params) { + const response = await fetch(buildAliyunRpcUrl(action, params), { method: "GET" }); + const text = await response.text().catch(() => ""); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`); + } + + if (!response.ok || json.Code || json.code) { + throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`); + } + + return json; +} + +function normalizeSuperResolveBitRate(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 10; + return Math.max(1, Math.min(20, Math.round(numeric))); +} + +function normalizeAliyunJobStatus(value) { + return String(value || "").trim().toUpperCase(); +} + +async function ensureDefaultProject(userId) { + const projectId = `web-default-${userId}`; + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]); + if (rows.length === 0) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + await pool.query( + `INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + updated_by_device_id, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING`, + [ + projectId, + userId, + "Default workbench", + "Web fallback project for legacy generation requests", + `users/${safeUserId}/projects/${projectId}/current/project.json`, + ], + ); + } + return projectId; +} + +async function resolveTaskProject(userId, requestedProjectId) { + const projectId = String(requestedProjectId || "").trim().slice(0, 64); + if (!projectId) { + return ensureDefaultProject(userId); + } + + const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + return projectId; +} + +async function insertTask(userId, projectId, type, params, conversationId = null, client = null) { + if (!client) { + return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx)); + } + + await assertUserGenerationConcurrencyLimit(userId, client); + const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { rows: [row] } = await client.query( + `INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`, + [userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)], + ); + return row; +} + +async function assertUserGenerationConcurrencyLimit(userId, client = pool) { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]); + const { rows } = await client.query( + "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", + [userId], + ); + const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); + if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + + const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + error.status = 429; + error.code = "GENERATION_CONCURRENCY_LIMIT"; + error.activeCount = activeCount; + error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + throw error; +} + +async function providerPoolExists(provider) { + if (!provider) return false; + const { rows } = await pool.query( + "SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1", + [provider], + ); + return rows.length > 0; +} + +function releaseLease(slotResult) { + if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {}); +} + +function sendAiRouteError(res, err) { + res.status(err.status || 500).json({ + error: err.message, + code: err.code, + activeCount: err.activeCount, + maxActiveTasks: err.maxActiveTasks, + }); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") { + const raw = String(value || "").trim(); + if (!raw) return fallback; + + let message = raw; + try { + const parsed = JSON.parse(raw); + message = + parsed?.error?.message || + parsed?.error_description || + parsed?.message || + parsed?.error || + raw; + } catch {} + + const compact = String(message).replace(/\s+/g, " ").trim(); + const looksLikeMarkup = + /^]/i.test(compact) || + /^<\?xml/i.test(compact) || + /<\/?[a-z][^>]*>/i.test(compact); + + if (looksLikeMarkup) return fallback; + return compact.slice(0, 320); +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatAiTaskRow(row) { + return { + taskId: String(row.id), + projectId: row.project_id, + conversationId: row.conversation_id, + clientQueueId: row.client_queue_id || null, + type: row.type, + status: row.status, + progress: Number(row.progress || 0), + resultUrl: row.result_url || null, + error: row.error || null, + params: parseTaskParams(row.params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function extensionFromContentType(contentType, fallbackType) { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/webp") return "webp"; + if (mime === "image/gif") return "gif"; + if (mime === "video/webm") return "webm"; + if (mime === "video/quicktime") return "mov"; + if (mime === "video/mp4") return "mp4"; + return fallbackType === "video" ? "mp4" : "png"; +} + +function contentDispositionFilename(value) { + return String(value || "generated") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, 120) || "generated"; +} + +function isErrorContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function buildDashscopeImageBody(params) { + const content = []; + for (const url of params.referenceUrls || []) { + if (url) content.push({ image: url }); + } + content.push({ text: params.prompt }); + const quality = clampImageQualityForModel(params.model, params.quality); + return { + model: params.model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, + watermark: false, + }, + }; +} + +function buildGrsaiImageBody(params) { + const isGptImage = String(params.model || "").startsWith("gpt-image"); + const modelKey = String(params.model || "").toLowerCase(); + const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality); + return isGptImage + ? { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: mapAspectRatioToPixels(params.ratio, quality), + replyType: "json", + } + : { + model: params.model, + prompt: params.prompt, + images: params.referenceUrls || [], + aspectRatio: params.ratio || "auto", + imageSize: quality, + replyType: "json", + }; +} + +function buildRightcodeImageBody(providerConfig, params) { + const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : []; + const quality = normalizeQuality(params.quality, "1K"); + + return { + model: providerConfig.model || params.model, + prompt: params.prompt, + image: referenceUrls, + size: mapAspectRatioToPixels(params.ratio, quality), + response_format: "url", + }; +} + +function getGridCount(gridMode) { + if (gridMode === "grid-4") return 4; + if (gridMode === "grid-9") return 9; + if (gridMode === "grid-25") return 25; + return 1; +} + +function buildGeminiImageBody(params) { + const parts = [{ text: String(params.prompt || "").trim() }]; + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + parts.push({ + fileData: { fileUri: url, mimeType: "image/png" }, + }); + } + const generationConfig = { responseModalities: ["IMAGE", "TEXT"] }; + const count = getGridCount(params.gridMode); + if (count > 1) generationConfig.candidateCount = count; + return { + contents: [{ parts }], + generationConfig, + }; +} + +function buildOpenAIImageBody(providerConfig, params) { + const userContent = []; + const prompt = String(params.prompt || "").trim(); + if (prompt) userContent.push({ type: "text", text: prompt }); + const refs = (params.referenceUrls || []).filter(Boolean); + for (const url of refs) { + userContent.push({ type: "image_url", image_url: { url } }); + } + const body = { + model: providerConfig.model || params.model, + messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }], + }; + const count = getGridCount(params.gridMode); + if (count > 1) body.n = count; + return body; +} + +function buildImageRequest(providerConfig, params, apiKey) { + const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params; + if (providerConfig.transport === "dashscope-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) }; + } + if (providerConfig.transport === "rightcode-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) }; + } + if (providerConfig.transport === "gemini-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) }; + } + if (providerConfig.transport === "openai-image") { + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) }; + } + return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) }; +} + +function buildSeedVideoBody(params) { + const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]); + const metadata = { + generate_audio: true, + watermark: false, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + resolution, + }; + const body = { + model: params.model, + prompt: params.prompt, + metadata, + }; + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + metadata.first_frame_image = refs[0]; + metadata.last_frame_image = refs[refs.length - 1]; + } else if (refs.length === 1) { + body.image = refs[0]; + } else if (refs.length > 1) { + metadata.reference_images = refs; + } + return body; +} + +function buildArkSeedVideoBody(params) { + const content = []; + if (params.prompt) content.push({ type: "text", text: params.prompt }); + + const refs = params.referenceUrls || []; + if (params.frameMode === "start-end" && refs.length >= 2) { + content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" }); + content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" }); + } else { + refs.forEach((url, index) => { + content.push({ + type: "image_url", + image_url: { url }, + role: index === 0 ? "first_frame" : "reference_image", + }); + }); + } + + const body = { + model: params.model, + content, + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 4, 15, 5), + generate_audio: true, + watermark: false, + }; + body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]); + return body; +} + +function buildWanI2vBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const requestedResolution = String(params.quality || "").toUpperCase(); + const parameters = { + resolution: requestedResolution === "720P" ? "720P" : "1080P", + ratio: normalizeRatio(params.ratio), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + parameters.prompt_extend = true; + + return { + model: params.model, + input, + parameters, + }; +} + +function normalizeHappyHorseResolution(value) { + return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P"; +} + +function getReferenceImageUrls(params, limit = 9) { + return (Array.isArray(params.referenceUrls) ? params.referenceUrls : []) + .map((url) => normalizePublicHttpUrl(url)) + .filter(Boolean) + .slice(0, limit); +} + +function buildHappyHorseBaseParameters(params, { includeRatio }) { + const parameters = { + resolution: normalizeHappyHorseResolution(params.quality), + duration: normalizeDuration(params.duration, 3, 15, 5), + watermark: false, + }; + if (includeRatio) parameters.ratio = normalizeRatio(params.ratio); + return parameters; +} + +function createMissingReferenceError(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function buildHappyHorseT2vBody(params) { + return { + model: params.model, + input: { + prompt: params.prompt, + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function buildHappyHorseI2vBody(params) { + const [firstFrame] = getReferenceImageUrls(params, 1); + if (!firstFrame) { + throw createMissingReferenceError("HappyHorse I2V requires one first-frame image."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: [{ type: "first_frame", url: firstFrame }], + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }), + }; +} + +function buildHappyHorseR2vBody(params) { + const refs = getReferenceImageUrls(params, 9); + if (!refs.length) { + throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images."); + } + + return { + model: params.model, + input: { + prompt: params.prompt, + media: refs.map((url) => ({ type: "reference_image", url })), + }, + parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }), + }; +} + +function getHappyHorseReferenceError(protocol, referenceUrls) { + if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) { + return "HappyHorse I2V requires one first-frame image."; + } + if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) { + return "HappyHorse R2V requires 1 to 9 reference images."; + } + return ""; +} + +async function assertWanS2vImageDetected(providerConfig, params, apiKey) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + + const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: providerConfig.detectModel || "wan2.2-s2v-detect", + input: { image_url: imageUrl }, + }), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch {} + + if (!response.ok) { + throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`)); + } + + const output = json && typeof json === "object" ? json.output || json.data || json : {}; + const pass = + output.check_pass === true || + output.checkPass === true || + output.passed === true || + output.pass === true || + String(output.code || "").toLowerCase() === "success"; + + if (!pass) { + const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。"; + const error = new Error(message); + error.status = 400; + throw error; + } +} + +function extractProviderDetectMessage(output) { + if (!output || typeof output !== "object") return ""; + return String( + output.message || + output.reason || + output.failure_reason || + output.description || + output.error || + "", + ).trim(); +} + +function buildWanS2vBody(params) { + const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]); + const audioUrl = normalizePublicHttpUrl(params.audioUrl); + if (!imageUrl) { + const error = new Error("Missing imageUrl"); + error.status = 400; + throw error; + } + if (!audioUrl) { + const error = new Error("Missing audioUrl"); + error.status = 400; + throw error; + } + + const parameters = { + resolution: normalizeS2vResolution(params.quality), + style: normalizeS2vStyle(params.style), + }; + + return { + model: params.model, + input: { + image_url: imageUrl, + audio_url: audioUrl, + }, + parameters, + }; +} + +function buildDashscopeKlingBody(params) { + const refs = params.referenceUrls || []; + const media = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + media.push({ type: "first_frame", url: refs[0] }); + media.push({ type: "last_frame", url: refs[refs.length - 1] }); + } else if (refs[0]) { + media.push({ type: "first_frame", url: refs[0] }); + } + + const input = { prompt: params.prompt }; + if (media.length) input.media = media; + const parameters = { + mode: params.quality === "std" ? "std" : "pro", + duration: normalizeDuration(params.duration, 5, 10, 5), + audio: false, + watermark: false, + }; + if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio); + + return { model: params.model, input, parameters }; +} + +function buildKlingOmniBody(params) { + const refs = params.referenceUrls || []; + const imageList = []; + if (params.frameMode === "start-end" && refs.length >= 2) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" }); + } else if (refs[0]) { + imageList.push({ image_url: refs[0], type: "first_frame" }); + } + + const body = { + model_name: "kling-v3-omni", + mode: params.quality === "std" ? "std" : "pro", + sound: "off", + duration: String(normalizeDuration(params.duration, 3, 15, 5)), + watermark_info: { enabled: false }, + prompt: params.prompt, + }; + if (imageList.length) body.image_list = imageList; + else body.aspect_ratio = normalizeRatio(params.ratio); + return body; +} + +function buildVideoRequest(providerConfig, params, apiKey) { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; + let body; + + if (providerConfig.protocol === "seed-video-ark") { + body = buildArkSeedVideoBody(params); + } else if (providerConfig.protocol === "happyhorse-t2v") { + body = buildHappyHorseT2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-i2v") { + body = buildHappyHorseI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "happyhorse-r2v") { + body = buildHappyHorseR2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-i2v") { + body = buildWanI2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "wan-s2v") { + body = buildWanS2vBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-dashscope") { + body = buildDashscopeKlingBody(params); + headers["X-DashScope-Async"] = "enable"; + } else if (providerConfig.protocol === "kling-omni") { + body = buildKlingOmniBody(params); + const credential = parseKlingCredential(apiKey); + if (credential) { + headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`; + } + } else { + body = buildSeedVideoBody(params); + } + + return { headers, body }; +} + +function registerAiRoutes(router) { + router.post("/ai/image", requireAuth, async (req, res) => { + const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body; + if (!prompt) return res.status(400).json({ error: "Missing prompt" }); + + try { + const providerCandidates = resolveImageProviderCandidates(model); + const primaryProviderConfig = providerCandidates[0]; + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: primaryProviderConfig.model, + requestedModel: primaryProviderConfig.requestedModel, + prompt, + ratio, + quality, + gridMode, + referenceUrls, + }; + const { taskRow, imageBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + const billingResult = await deductImageGenerationCredits(req.user.id, client, { + taskId: nextTaskRow.id, + model: params.requestedModel || params.model || model, + resolution: [ratio, quality].filter(Boolean).join(" / "), + }); + if (!billingResult.success) { + const error = new Error(billingResult.message || "账户积分不足"); + error.status = 402; + error.code = "INSUFFICIENT_BALANCE"; + error.costCents = billingResult.costCents; + throw error; + } + return { taskRow: nextTaskRow, imageBilling: billingResult }; + }); + const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType }; + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + imageBilling: { + costCents: imageBilling.costCents, + deductionType: imageBilling.deductionType, + balanceAfterCents: imageBilling.balanceAfterCents, + }, + providerDebug: buildImageProviderDebug(model), + }); + submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => { + console.error("[ai/image] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + console.error("[ai/image] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video", requireAuth, async (req, res) => { + const { + model, + prompt, + ratio, + duration, + quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + resolution, + muted, + hasReferenceVideo, + style, + projectId: requestedProjectId, + conversationId, + } = req.body; + const providerConfig = resolveVideoProvider(model); + const provider = providerConfig.provider; + const isWanS2v = providerConfig.protocol === "wan-s2v"; + const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls); + + if (!isWanS2v && !prompt) return res.status(400).json({ error: "Missing prompt" }); + if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError }); + if (isWanS2v) { + if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) { + return res.status(400).json({ error: "Missing imageUrl" }); + } + if (!normalizePublicHttpUrl(audioUrl)) { + return res.status(400).json({ error: "Missing audioUrl" }); + } + } + + let slotResult = null; + try { + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: providerConfig.model, + requestedModel: providerConfig.requestedModel, + prompt: prompt || "数字人口播视频", + ratio, + duration, + quality: quality || resolution, + resolution: resolution || quality, + frameMode, + referenceUrls, + imageUrl, + audioUrl, + muted: Boolean(muted), + hasReferenceVideo: Boolean(hasReferenceVideo), + style, + }; + + let enterpriseBilling = null; + let preauth = null; + if (isEnterpriseVideoBillingUser(req.user)) { + enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params }); + preauth = { + authorized: true, + estimatedCostCents: enterpriseBilling.amountCents, + billingMode: "enterprise", + }; + } else { + preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => { + const nextTaskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + client, + ); + if (enterpriseBilling) { + const nextBilling = await reserveEnterpriseVideoCredits(client, { + ...enterpriseBilling, + taskId: nextTaskRow.id, + }); + return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null }; + } + // Regular user: deduct from personal balance + const credits = calculateEnterpriseVideoCredits({ + model: params.model, + resolution: params.resolution || params.quality, + durationSeconds: params.duration, + muted: params.muted, + hasReferenceVideo: params.hasReferenceVideo, + }); + const costCents = Math.ceil(credits * 100); + const { rows: [deducted] } = await client.query( + "UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents", + [costCents, req.user.id], + ); + if (!deducted) { + throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" }); + } + await client.query( + "INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)", + [req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`], + ); + return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } }; + }); + + if (reservedBilling) { + params.enterpriseBilling = { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + resolution: reservedBilling.resolution, + durationSeconds: reservedBilling.durationSeconds, + rateCentsPerSecond: reservedBilling.rateCentsPerSecond, + }; + await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [ + JSON.stringify(params), + taskRow.id, + ]); + } + + res.status(202).json({ + taskId: String(taskRow.id), + status: "pending", + enterpriseBilling: reservedBilling + ? { + creditLedgerId: reservedBilling.creditLedgerId, + amountCents: reservedBilling.amountCents, + enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents, + } + : undefined, + }); + const activeSlotResult = slotResult; + slotResult = null; + submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params) + .then(async () => { + try { + await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId); + } catch (settlementError) { + console.error("[ai/video] enterprise ledger settle error:", settlementError.message); + } + }) + .catch(async (err) => { + console.error("[ai/video] submit error:", err.message); + await updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + await refundEnterpriseVideoCredits(pool, reservedBilling, err.message); + releaseLease(activeSlotResult); + }); + } catch (err) { + releaseLease(slotResult); + console.error("[ai/video] error:", err.message); + if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") { + return res.status(err.status || 402).json({ + error: err.message, + code: "INSUFFICIENT_ENTERPRISE_BALANCE", + }); + } + sendAiRouteError(res, err); + } + }); + + router.post("/ai/image/super-resolve", requireAuth, async (req, res) => { + const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl); + const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor); + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" }); + + const provider = "dashscope"; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { + model: "wanx2.1-imageedit", + operation: "image-super-resolution", + imageUrl, + scale, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "image", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => { + console.error("[ai/image/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(slotResult); + }); + } catch (err) { + if (slotResult) releaseLease(slotResult); + console.error("[ai/image/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/video/super-resolve", requireAuth, async (req, res) => { + const videoUrl = String(req.body?.videoUrl || "").trim(); + const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate); + const providerMode = String(req.body?.provider || req.body?.model || "").trim(); + const shouldUseDashscopeStyle = + providerMode === "dashscope-style-transform" || providerMode === "video-style-transform"; + const { projectId: requestedProjectId, conversationId } = req.body || {}; + + if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" }); + if (!/^https?:\/\//i.test(videoUrl)) { + return res.status(400).json({ error: "videoUrl must be an HTTP URL" }); + } + + let dashscopeSlotResult; + try { + if (shouldUseDashscopeStyle) { + const provider = "dashscope"; + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + await assertUserGenerationConcurrencyLimit(req.user.id); + dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!dashscopeSlotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const styleOptions = normalizeVideoStyleTransformOptions(req.body); + const params = { + model: "video-style-transform", + operation: "video-style-super-resolution", + videoUrl, + ...styleOptions, + }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => { + console.error("[ai/video/super-resolve] dashscope submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + releaseLease(dashscopeSlotResult); + }); + return; + } + + const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null; + const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate }; + const taskRow = await insertTask( + req.user.id, + projectId, + "video", + params, + Number.isFinite(Number(conversationId)) ? Number(conversationId) : null, + ); + + res.status(202).json({ taskId: String(taskRow.id), status: "pending" }); + submitVideoSuperResolveTask(taskRow.id, params).catch((err) => { + console.error("[ai/video/super-resolve] submit error:", err.message); + updateTaskInDb(taskRow.id, { status: "failed", error: err.message }); + }); + } catch (err) { + if (dashscopeSlotResult) releaseLease(dashscopeSlotResult); + console.error("[ai/video/super-resolve] error:", err.message); + sendAiRouteError(res, err); + } + }); + + router.post("/ai/chat", requireAuth, async (req, res) => { + const { model, messages, stream = true, temperature } = req.body; + if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" }); + + const providerConfig = resolveTextProvider(model); + const provider = providerConfig.provider; + let slotResult; + try { + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` }); + } + + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const reqHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${slotResult.apiKey}`, + }; + const reqBody = JSON.stringify({ + model: providerConfig.model, + messages, + stream, + temperature: temperature || 0.7, + max_tokens: 4096, + }); + + if (stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); + + try { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal }); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => "upstream error"); + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`), + done: true, + })}\n\n`, + ); + res.end(); + releaseLease(slotResult); + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + continue; + } + try { + const chunk = JSON.parse(payload); + const delta = chunk.choices?.[0]?.delta?.content || ""; + if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`); + } catch {} + } + } + + res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`); + res.end(); + releaseLease(slotResult); + } catch (streamErr) { + if (streamErr.name !== "AbortError") { + res.write( + `data: ${JSON.stringify({ + error: sanitizeUpstreamError(streamErr.message), + done: true, + })}\n\n`, + ); + } + res.end(); + releaseLease(slotResult); + } + } else { + const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody }); + const text = await upstream.text().catch(() => ""); + releaseLease(slotResult); + + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + return res.status(502).json({ + error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`), + }); + } + + if (!upstream.ok || json.error) { + return res.status(502).json({ + error: sanitizeUpstreamError( + json.error?.message || json.message || json.error || text, + `文本服务返回 HTTP ${upstream.status}`, + ), + }); + } + + const content = json.choices?.[0]?.message?.content || ""; + const usage = json.usage || {}; + res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } }); + } + } catch (err) { + releaseLease(slotResult); + console.error("[ai/chat] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000); + const status = String(req.query.status || "").trim(); + const type = String(req.query.type || "").trim(); + const projectId = String(req.query.projectId || req.query.project_id || "").trim(); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (["image", "video"].includes(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + if (projectId) { + params.push(projectId); + where.push(`project_id = $${params.length}`); + } + + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT * + FROM generation_tasks + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length - 1} + OFFSET $${params.length} + `, + params, + ); + res.json({ tasks: rows.map(formatAiTaskRow) }); + } catch (err) { + console.error("[ai/tasks] list failed:", err.message); + res.status(500).json({ error: "Failed to load task history" }); + } + }); + + router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + const conversationId = Number(req.body?.conversationId); + + if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) { + return res.status(400).json({ error: "Invalid task or conversation id" }); + } + + try { + const { rows: conversationRows } = await pool.query( + "SELECT id FROM conversations WHERE id = $1 AND user_id = $2", + [conversationId, req.user.id], + ); + if (conversationRows.length === 0) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const { rows } = await pool.query( + `UPDATE generation_tasks + SET conversation_id = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + RETURNING id, conversation_id`, + [conversationId, taskId, req.user.id], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "Task not found" }); + } + + res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + res.json(formatAiTaskRow(rows[0])); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => { + const taskId = Number(req.params.taskId); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" }); + + try { + const { rows } = await pool.query( + "UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" }); + res.json({ id: rows[0].id, status: rows[0].status }); + } catch (err) { + console.error("[ai/task-cancel] error:", err.message); + res.status(500).json({ error: "取消任务失败" }); + } + }); + + router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => { + const { taskId } = req.params; + try { + const { rows } = await pool.query( + "SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2", + [taskId, req.user.id], + ); + if (rows.length === 0) return res.status(404).json({ error: "Task not found" }); + + const task = rows[0]; + const resultUrl = String(task.result_url || "").trim(); + if (!/^https?:\/\//i.test(resultUrl)) { + return res.status(400).json({ error: "Task result is not downloadable" }); + } + + const upstream = await fetch(resultUrl, { method: "GET" }); + if (!upstream.ok || !upstream.body) { + return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` }); + } + + const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png"); + if (isErrorContentType(contentType)) { + const text = await upstream.text().catch(() => ""); + return res.status(502).json({ + error: text.includes("Expired") || text.includes("AccessDenied") + ? "结果链接已过期,请重新生成后再下载" + : "结果链接返回了错误内容,请重新生成后再下载", + }); + } + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (!buffer.length) { + return res.status(502).json({ error: "Result download returned empty content" }); + } + + const extension = extensionFromContentType(contentType, task.type); + const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`); + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", String(buffer.length)); + res.setHeader("Cache-Control", "no-store"); + res.end(buffer); + } catch (err) { + console.error("[ai/tasks/download] failed:", err.message); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); +} + +async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) { + const errors = [...previousErrors]; + const candidates = Array.isArray(providerCandidates) ? providerCandidates : []; + + for (let index = 0; index < candidates.length; index += 1) { + const providerConfig = candidates[index]; + const provider = providerConfig?.provider; + let slotResult = null; + + if (!provider) continue; + + try { + if (index > 0 && !(await providerPoolExists(provider))) { + throw new Error(`${provider} provider pool is not configured`); + } + + slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + throw new Error(`${provider} concurrency pool is full`); + } + + await submitImageToProvider(taskDbId, providerConfig, slotResult, params, { + onTaskFailed: async (failureMessage) => { + const providerError = `${provider}: ${failureMessage}`; + const remainingCandidates = candidates.slice(index + 1); + if (remainingCandidates.length === 0) { + await updateTaskInDb(taskDbId, { + status: "failed", + error: `All image providers failed: ${[...errors, providerError].join(" | ")}`, + }); + return true; + } + + console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`); + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + try { + await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [ + ...errors, + providerError, + ]); + return true; + } catch (fallbackErr) { + await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message }); + return true; + } + }, + }); + if (index > 0) { + console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`); + } + return; + } catch (err) { + const message = err?.message || String(err); + errors.push(`${provider}: ${message}`); + console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`); + releaseLease(slotResult); + + if (index < candidates.length - 1) { + await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null }); + } + } + } + + throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available"); +} + +async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) { + const url = getPostUrl(providerConfig); + const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const submitTimeout = providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS; + const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + + // Synchronous transports — extract image URL directly, no polling + if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") { + let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json); + const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai"; + console.info( + `[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`, + ); + if (!directUrl) throw new Error(`${tag} did not return an image url`); + + // Gemini may return base64 data URL — too large for DB, upload to OSS first + if (directUrl.startsWith("data:") && isOssConfigured()) { + const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/); + if (match) { + const mimeType = match[1]; + const buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "png"; + const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const bucket = process.env.OSS_BUCKET || ""; + const region = (process.env.OSS_REGION || "").replace(/^oss-/, ""); + directUrl = process.env.OSS_PUBLIC_BASE_URL + ? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}` + : `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; + console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`); + } + } + + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`); + releaseLease(slotResult); + return; + } + + const directUrl = extractImageUrl(json); + + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + onTaskFailed: options.onTaskFailed, + }); +} + +async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) { + const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`; + const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey); + + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + if (providerConfig.protocol === "wan-s2v") { + await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey); + await updateTaskInDb(taskDbId, { status: "running", progress: 16 }); + } + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("Video provider did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeImageSuperResolveBody(params); + const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractImageUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "image", + providerConfig: { transport: "dashscope-image" }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 10 }); + const body = buildDashscopeVideoStyleTransformBody(params); + const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + Authorization: `Bearer ${slotResult.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => "provider error"); + throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`)); + } + + const json = await response.json(); + const directUrl = extractVideoUrl(json); + const providerTaskId = extractProviderTaskId(json); + if (directUrl && !providerTaskId) { + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl }); + releaseLease(slotResult); + return; + } + if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId"); + + await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 }); + startPolling(taskDbId, { + providerTaskId, + apiKey: slotResult.apiKey, + type: "video", + providerConfig: { + protocol: "wan-i2v", + baseUrl: "https://dashscope.aliyuncs.com", + }, + leaseToken: slotResult.leaseToken, + keyManager, + }); +} + +async function submitVideoSuperResolveTask(taskDbId, params) { + await updateTaskInDb(taskDbId, { status: "running", progress: 8 }); + const submitResult = await callAliyunRpc("SuperResolveVideo", { + VideoUrl: params.videoUrl, + BitRate: String(params.bitRate || 10), + }); + const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId; + if (!jobId) { + throw new Error("Aliyun SuperResolveVideo did not return a job id"); + } + + await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 }); + + for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS)); + } + + const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId }); + const data = result.Data || result.data || {}; + const status = normalizeAliyunJobStatus(data.Status || data.status); + const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76)); + + if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") { + const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data; + const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url; + if (!videoUrl) { + throw new Error("Aliyun super-resolution completed without a video url"); + } + await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl }); + return; + } + + if ( + status === "PROCESS_FAILED" || + status === "FAIL" || + status === "FAILED" || + status === "TIMEOUT_FAILED" || + status === "LIMIT_RETRY_FAILED" + ) { + throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed"); + } + + await updateTaskInDb(taskDbId, { status: "running", progress }); + } + + throw new Error("Aliyun video super-resolution timed out"); +} + +module.exports = { registerAiRoutes }; diff --git a/src/routes/assets.js b/src/routes/assets.js new file mode 100644 index 0000000..e882c42 --- /dev/null +++ b/src/routes/assets.js @@ -0,0 +1,216 @@ +"use strict"; + +const { requireAuth, pool } = require("./context"); + +const ASSET_TYPES = new Set(["character", "scene", "prop", "video", "image", "asset", "other"]); +const ASSET_STATUSES = new Set(["ready", "draft", "reviewing", "pending", "failed"]); + +function cleanText(value, maxLength) { + return String(value || "").trim().slice(0, maxLength); +} + +function safeJsonString(value, fallback) { + if (value === undefined) return JSON.stringify(fallback); + try { + return JSON.stringify(value ?? fallback); + } catch { + return JSON.stringify(fallback); + } +} + +function parseJson(value, fallback) { + if (!value || typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function normalizeTags(value) { + return Array.isArray(value) + ? value.map((item) => cleanText(item, 40)).filter(Boolean).slice(0, 20) + : []; +} + +function normalizeAssetPayload(body = {}, partial = false) { + const name = cleanText(body.name ?? body.title, 200); + if (!partial && !name) return { error: "Missing asset name" }; + + const type = cleanText(body.type ?? body.assetType, 32); + const status = cleanText(body.status, 32); + const sourceTaskId = Number(body.sourceTaskId ?? body.source_task_id); + + return { + value: { + name: name || undefined, + type: ASSET_TYPES.has(type) ? type : partial ? undefined : "asset", + description: cleanText(body.description, 5000) || null, + url: cleanText(body.url ?? body.imageUrl, 1000) || null, + ossKey: cleanText(body.ossKey ?? body.oss_key, 500) || null, + tagsJson: safeJsonString(normalizeTags(body.tags), []), + status: ASSET_STATUSES.has(status) ? status : partial ? undefined : "ready", + sourceTaskId: Number.isFinite(sourceTaskId) ? sourceTaskId : null, + sourceProjectId: cleanText(body.sourceProjectId ?? body.projectId ?? body.source_project_id, 64) || null, + metadataJson: safeJsonString(body.metadata, {}), + }, + }; +} + +function formatAsset(row) { + return { + id: Number(row.id), + type: row.type, + name: row.name, + description: row.description || "", + url: row.url || null, + ossKey: row.oss_key || null, + imageUrl: row.url || "", + tags: parseJson(row.tags_json, []), + status: row.status, + sourceTaskId: row.source_task_id == null ? null : String(row.source_task_id), + sourceProjectId: row.source_project_id || null, + metadata: parseJson(row.metadata_json, {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function registerAssetRoutes(router) { + router.get("/assets", requireAuth, async (req, res) => { + try { + const type = cleanText(req.query.type, 32); + const status = cleanText(req.query.status, 32); + const q = cleanText(req.query.q, 120).toLowerCase(); + const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200); + const params = [req.user.id]; + const where = ["user_id = $1"]; + + if (ASSET_TYPES.has(type)) { + params.push(type); + where.push(`type = $${params.length}`); + } + if (ASSET_STATUSES.has(status)) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (q) { + params.push(`%${q}%`); + where.push(`(LOWER(name) LIKE $${params.length} OR LOWER(COALESCE(description, '')) LIKE $${params.length} OR LOWER(tags_json) LIKE $${params.length})`); + } + + params.push(limit); + const { rows } = await pool.query( + ` + SELECT * + FROM web_assets + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT $${params.length} + `, + params, + ); + res.json({ assets: rows.map(formatAsset) }); + } catch (err) { + console.error("[assets] list failed:", err.message); + res.status(500).json({ error: "Failed to load assets" }); + } + }); + + router.post("/assets", requireAuth, async (req, res) => { + const payload = normalizeAssetPayload(req.body || {}); + if (payload.error) return res.status(400).json({ error: payload.error }); + + try { + const asset = payload.value; + const { rows } = await pool.query( + ` + INSERT INTO web_assets ( + user_id, type, name, description, url, oss_key, tags_json, + status, source_task_id, source_project_id, metadata_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `, + [ + req.user.id, + asset.type, + asset.name, + asset.description, + asset.url, + asset.ossKey, + asset.tagsJson, + asset.status, + asset.sourceTaskId, + asset.sourceProjectId, + asset.metadataJson, + ], + ); + res.status(201).json({ asset: formatAsset(rows[0]) }); + } catch (err) { + console.error("[assets] create failed:", err.message); + res.status(500).json({ error: "Failed to create asset" }); + } + }); + + router.patch("/assets/:id", requireAuth, async (req, res) => { + const payload = normalizeAssetPayload(req.body || {}, true); + if (payload.error) return res.status(400).json({ error: payload.error }); + + const fieldMap = { + name: "name", + type: "type", + description: "description", + url: "url", + ossKey: "oss_key", + tagsJson: "tags_json", + status: "status", + sourceTaskId: "source_task_id", + sourceProjectId: "source_project_id", + metadataJson: "metadata_json", + }; + const values = []; + const updates = []; + Object.entries(payload.value).forEach(([key, value]) => { + if (value === undefined) return; + values.push(value); + updates.push(`${fieldMap[key]} = $${values.length}`); + }); + + if (!updates.length) return res.status(400).json({ error: "No asset fields to update" }); + + values.push(req.params.id, req.user.id); + try { + const { rows } = await pool.query( + ` + UPDATE web_assets + SET ${updates.join(", ")}, updated_at = NOW() + WHERE id = $${values.length - 1} AND user_id = $${values.length} + RETURNING * + `, + values, + ); + if (!rows[0]) return res.status(404).json({ error: "Asset not found" }); + res.json({ asset: formatAsset(rows[0]) }); + } catch (err) { + console.error("[assets] update failed:", err.message); + res.status(500).json({ error: "Failed to update asset" }); + } + }); + + router.delete("/assets/:id", requireAuth, async (req, res) => { + try { + const { rowCount } = await pool.query("DELETE FROM web_assets WHERE id = $1 AND user_id = $2", [ + req.params.id, + req.user.id, + ]); + if (!rowCount) return res.status(404).json({ error: "Asset not found" }); + res.json({ success: true }); + } catch (err) { + console.error("[assets] delete failed:", err.message); + res.status(500).json({ error: "Failed to delete asset" }); + } + }); +} + +module.exports = { registerAssetRoutes }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..76b393d --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,758 @@ +const { + bcrypt, + requireAuth, + login, + getUserContextById, + clearUserSession, + generateUniqueEnterpriseCode, + crypto, + pool, + withTransaction, + SMS_PURPOSES, + SMS_CODE_TTL_MINUTES, + SMS_CODE_COOLDOWN_SECONDS, + validateUsername, + validatePassword, + normalizePhone, + validatePhone, + normalizeEmail, + validateEmail, + hashSmsCode, + generateSmsCode, + sendSmsCode, + createLoginResultForUserId, + generateUniqueUsername, + consumeSmsCode, + getWechatLoginConfig, + exchangeWechatCode, + findOrCreateWechatUser, + validateEnterpriseName, + buildOssPublicUrl, + normalizeAvatarOssKey, + normalizeProfileMediaUrl, +} = require("./context"); +const { + checkBetaInviteCodeForRegistration, + consumeBetaInviteCode, + findEnterpriseBetaAccountByInviteCode, + getBetaInviteCodeFromBody, +} = require("../betaInviteCodes"); + +async function ensureRegistrationInvite(req, res, client = pool) { + const result = await checkBetaInviteCodeForRegistration(getBetaInviteCodeFromBody(req.body), client); + if (result.ok) return result; + res.status(result.status || 403).json({ error: result.error || "内测码无效或缺失" }); + return null; +} + +async function ensureBetaInviteCode(req, res, client = pool) { + const result = await ensureRegistrationInvite(req, res, client); + return result ? result.code : null; +} + +function createBetaInviteCodeError(result) { + const error = new Error(result.error || "内测码无效或缺失"); + error.status = result.status || 403; + return error; +} + +async function consumeBetaInviteCodeForUser(client, code, userId) { + const result = await consumeBetaInviteCode(code, userId, client); + if (!result.ok) throw createBetaInviteCodeError(result); +} + +function hashRegistrationInviteCode(code) { + const normalized = String(code || "") + .trim() + .replace(/[\s-]/g, "") + .toUpperCase(); + return crypto.createHash("sha256").update(normalized).digest("hex"); +} + +async function resolveEnterpriseBetaRegistrationTarget(invite, client) { + const account = + invite?.account || findEnterpriseBetaAccountByInviteCode(invite?.code || invite); + if (!account) return { enterpriseId: null, isEnterpriseBeta: false }; + + const { rows } = await client.query( + "SELECT id, enabled FROM enterprises WHERE enterprise_code = $1 LIMIT 1", + [account.enterpriseId], + ); + if (rows.length === 0 || !rows[0].enabled) { + const error = new Error("企业内测账号尚未初始化,请先运行服务端数据库初始化"); + error.status = 503; + throw error; + } + + return { + enterpriseId: rows[0].id, + isEnterpriseBeta: true, + account, + }; +} + +async function consumeRegistrationInviteForUser(client, invite, userId, enterpriseTarget) { + if (enterpriseTarget && enterpriseTarget.isEnterpriseBeta) { + await client.query( + ` + INSERT INTO enterprise_members (enterprise_id, user_id, role) + VALUES ($1, $2, 'employee') + ON CONFLICT (enterprise_id, user_id) DO UPDATE SET role = EXCLUDED.role + `, + [enterpriseTarget.enterpriseId, userId], + ); + await client.query( + ` + UPDATE enterprise_invites + SET used_at = COALESCE(used_at, NOW()) + WHERE enterprise_id = $1 AND code_hash = $2 + `, + [enterpriseTarget.enterpriseId, hashRegistrationInviteCode(invite.code)], + ); + return; + } + + await consumeBetaInviteCodeForUser(client, invite.code, userId); +} + +function sendAuthRouteError(res, error, fallback) { + const status = Number.isInteger(error?.status) ? error.status : 500; + if (status >= 400 && status < 500) { + res.status(status).json({ error: error.message || fallback }); + return; + } + res.status(500).json({ error: fallback }); +} + +function registerAuthRoutes(router) { + // ── Auth ───────────────────────────────────────────────────────────── + + router.post("/auth/login", async (req, res) => { + const { username, password } = req.body; + if (!username || !password) return res.status(400).json({ error: "缺少用户名或密码" }); + + try { + const result = await login(username, password, req.headers["user-agent"]); + if (!result) return res.status(401).json({ error: "用户名或密码错误" }); + res.json(result); + } catch (err) { + console.error("[auth/login] failed", err); + res.status(500).json({ error: "登录失败" }); + } + }); + + router.post("/auth/login-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const password = String(req.body?.password || ""); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!password) return res.status(400).json({ error: "缺少密码" }); + + try { + const { rows } = await pool.query( + "SELECT username FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", + [email], + ); + if (rows.length === 0) return res.status(401).json({ error: "邮箱或密码错误" }); + + const result = await login(rows[0].username, password, req.headers["user-agent"]); + if (!result) return res.status(401).json({ error: "邮箱或密码错误" }); + res.json(result); + } catch (err) { + console.error("[auth/login-email] failed", err); + res.status(500).json({ error: "邮箱登录失败" }); + } + }); + + router.post("/auth/register", async (req, res) => { + const { username, password } = req.body; + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ + username, + ]); + if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); + + try { + const hash = await bcrypt.hash(password, 10); + await withTransaction(async (client) => { + const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( + registrationInvite, + client, + ); + const { rows } = await client.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) + RETURNING id + `, + [username, hash, "user", 30, enterpriseTarget.enterpriseId, 0, 0], + ); + await consumeRegistrationInviteForUser(client, registrationInvite, rows[0].id, enterpriseTarget); + }); + + const loginResult = await login(username, password, req.headers["user-agent"]); + res.json(loginResult); + } catch (error) { + console.error("[auth/register] failed", error); + sendAuthRouteError(res, error, "Register failed"); + } + }); + + router.post("/auth/register-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const usernameInput = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const { rows: existingEmail } = await pool.query( + "SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", + [email], + ); + if (existingEmail.length > 0) return res.status(409).json({ error: "该邮箱已注册" }); + + let username = usernameInput; + if (username) { + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const { rows: existingUsername } = await pool.query("SELECT id FROM users WHERE username = $1", [ + username, + ]); + if (existingUsername.length > 0) return res.status(409).json({ error: "用户名已被注册" }); + } else { + username = await generateUniqueUsername(email.split("@")[0], "email"); + } + + const hash = await bcrypt.hash(password, 10); + const { rows } = await withTransaction(async (client) => { + const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( + registrationInvite, + client, + ); + const insertResult = await client.query( + ` + INSERT INTO users (username, password_hash, email, email_verified, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, $3, 0, 'email', 'user', 30, $4, 0, 0) + RETURNING id + `, + [username, hash, email, enterpriseTarget.enterpriseId], + ); + await consumeRegistrationInviteForUser( + client, + registrationInvite, + insertResult.rows[0].id, + enterpriseTarget, + ); + return insertResult; + }); + + const loginResult = await createLoginResultForUserId(rows[0].id, req); + res.json(loginResult); + } catch (error) { + console.error("[auth/register-email] failed", error); + sendAuthRouteError(res, error, "Email register failed"); + } + }); + + router.post("/auth/sms/send", async (req, res) => { + const phone = normalizePhone(req.body?.phone); + const purpose = String(req.body?.purpose || "register"); + const phoneError = validatePhone(phone); + if (phoneError) return res.status(400).json({ error: phoneError }); + if (!SMS_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); + if (purpose === "register" && !(await ensureBetaInviteCode(req, res))) return; + + try { + const { rows: existingUsers } = await pool.query( + "SELECT id FROM users WHERE phone = $1 LIMIT 1", + [phone], + ); + if (purpose === "register" && existingUsers.length > 0) { + return res.status(409).json({ error: "该手机号已注册" }); + } + if (purpose === "login" && existingUsers.length === 0) { + return res.status(404).json({ error: "该手机号尚未注册" }); + } + + const { rows: recentCodes } = await pool.query( + ` + SELECT created_at + FROM sms_verification_codes + WHERE phone = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval + ORDER BY created_at DESC + LIMIT 1 + `, + [phone, purpose, SMS_CODE_COOLDOWN_SECONDS], + ); + if (recentCodes.length > 0) { + return res + .status(429) + .json({ error: `验证码发送太频繁,请 ${SMS_CODE_COOLDOWN_SECONDS} 秒后再试` }); + } + + const code = generateSmsCode(); + const codeHash = hashSmsCode(phone, code); + await pool.query( + ` + INSERT INTO sms_verification_codes (phone, purpose, code_hash, expires_at) + VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval) + `, + [phone, purpose, codeHash, SMS_CODE_TTL_MINUTES], + ); + + const sendResult = await sendSmsCode(phone, code, purpose); + res.json({ + success: true, + provider: sendResult.provider, + ttlSeconds: SMS_CODE_TTL_MINUTES * 60, + cooldownSeconds: SMS_CODE_COOLDOWN_SECONDS, + ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), + }); + } catch (error) { + console.error("[auth/sms/send] failed", error); + res.status(500).json({ error: "验证码发送失败" }); + } + }); + + router.post("/auth/register-phone", async (req, res) => { + const phone = normalizePhone(req.body?.phone); + const code = String(req.body?.code || "").trim(); + const password = String(req.body?.password || ""); + + const phoneError = validatePhone(phone); + if (phoneError) return res.status(400).json({ error: phoneError }); + if (!code) return res.status(400).json({ error: "缺少验证码" }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE phone = $1 LIMIT 1", [ + phone, + ]); + if (existing.length > 0) return res.status(409).json({ error: "该手机号已注册" }); + + const verified = await consumeSmsCode(phone, code, "register"); + if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); + + const username = await generateUniqueUsername(`u${phone.slice(-4)}`, "phone"); + const hash = await bcrypt.hash(password, 10); + const { rows } = await withTransaction(async (client) => { + const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( + registrationInvite, + client, + ); + const insertResult = await client.query( + ` + INSERT INTO users (username, password_hash, phone, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, $3, 'phone', 'user', 30, $4, 0, 0) + RETURNING id + `, + [username, hash, phone, enterpriseTarget.enterpriseId], + ); + await consumeRegistrationInviteForUser( + client, + registrationInvite, + insertResult.rows[0].id, + enterpriseTarget, + ); + return insertResult; + }); + + const loginResult = await createLoginResultForUserId(rows[0].id, req); + res.json(loginResult); + } catch (error) { + console.error("[auth/register-phone] failed", error); + sendAuthRouteError(res, error, "Phone register failed"); + } + }); + + router.post("/auth/login-phone", async (req, res) => { + const phone = normalizePhone(req.body?.phone); + const code = String(req.body?.code || "").trim(); + + const phoneError = validatePhone(phone); + if (phoneError) return res.status(400).json({ error: phoneError }); + if (!code) return res.status(400).json({ error: "缺少验证码" }); + + try { + const verified = await consumeSmsCode(phone, code, "login"); + if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); + + const { rows } = await pool.query("SELECT id, enabled FROM users WHERE phone = $1 LIMIT 1", [ + phone, + ]); + if (rows.length === 0) return res.status(404).json({ error: "该手机号尚未注册" }); + if (!rows[0].enabled) return res.status(403).json({ error: "账号已禁用" }); + + const loginResult = await createLoginResultForUserId(rows[0].id, req); + res.json(loginResult); + } catch (error) { + console.error("[auth/login-phone] failed", error); + res.status(500).json({ error: "手机号登录失败" }); + } + }); + + router.get("/auth/wechat/login-url", async (req, res) => { + const { appId, redirectUri } = getWechatLoginConfig(); + if (!appId || !redirectUri) { + return res.json({ + configured: false, + message: "微信开放平台 AppID 或回调地址未配置", + }); + } + + const state = String(req.query.state || crypto.randomBytes(8).toString("hex")); + try { + await pool.query( + ` + INSERT INTO wechat_login_sessions (state, status, expires_at) + VALUES ($1, 'pending', NOW() + INTERVAL '10 minutes') + ON CONFLICT (state) DO UPDATE + SET status = 'pending', + user_id = NULL, + error = NULL, + consumed_at = NULL, + expires_at = NOW() + INTERVAL '10 minutes', + updated_at = NOW() + `, + [state], + ); + } catch (error) { + console.error("[auth/wechat/login-url] failed to create session", error); + return res.status(500).json({ error: "微信登录会话创建失败" }); + } + + const url = new URL("https://open.weixin.qq.com/connect/qrconnect"); + url.searchParams.set("appid", appId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "snsapi_login"); + url.searchParams.set("state", state); + + res.json({ + configured: true, + url: `${url.toString()}#wechat_redirect`, + state, + }); + }); + + router.get("/auth/wechat/callback", async (req, res) => { + const code = String(req.query?.code || "").trim(); + const state = String(req.query?.state || "").trim(); + if (!code || !state) { + return res.status(400).send('

微信授权参数缺失

'); + } + + try { + const { rows: sessions } = await pool.query( + ` + SELECT state + FROM wechat_login_sessions + WHERE state = $1 AND expires_at > NOW() AND consumed_at IS NULL + LIMIT 1 + `, + [state], + ); + if (sessions.length === 0) { + return res + .status(400) + .send('

微信登录会话已过期,请回到应用重新扫码

'); + } + + const wechatUser = await exchangeWechatCode(code); + const userId = await findOrCreateWechatUser(wechatUser); + await pool.query( + ` + UPDATE wechat_login_sessions + SET status = 'completed', user_id = $2, error = NULL, updated_at = NOW() + WHERE state = $1 + `, + [state, userId], + ); + + res.send('

微信登录成功

请回到 OmniAI 应用继续。

'); + } catch (error) { + console.error("[auth/wechat/callback] failed", error); + await pool + .query( + ` + UPDATE wechat_login_sessions + SET status = 'failed', error = $2, updated_at = NOW() + WHERE state = $1 + `, + [state, error instanceof Error ? error.message : "微信登录失败"], + ) + .catch(() => {}); + res.status(500).send('

微信登录失败

请回到应用重试。

'); + } + }); + + router.get("/auth/wechat/session", async (req, res) => { + const state = String(req.query?.state || "").trim(); + if (!state) return res.status(400).json({ error: "缺少微信登录 state" }); + + try { + const { rows } = await pool.query( + ` + SELECT state, status, user_id, error, consumed_at, expires_at + FROM wechat_login_sessions + WHERE state = $1 + LIMIT 1 + `, + [state], + ); + const session = rows[0]; + if (!session) return res.status(404).json({ status: "missing" }); + if (session.consumed_at) return res.status(409).json({ status: "consumed" }); + if (new Date(session.expires_at).getTime() < Date.now()) + return res.status(410).json({ status: "expired" }); + if (session.status === "failed") + return res.status(400).json({ status: "failed", error: session.error || "微信登录失败" }); + if (session.status !== "completed") return res.json({ status: "pending" }); + + const loginResult = await createLoginResultForUserId(session.user_id, req); + if (!loginResult) return res.status(403).json({ status: "failed", error: "账号不可用" }); + + await pool.query( + "UPDATE wechat_login_sessions SET consumed_at = NOW(), updated_at = NOW() WHERE state = $1", + [state], + ); + res.json({ status: "completed", ...loginResult }); + } catch (error) { + console.error("[auth/wechat/session] failed", error); + res.status(500).json({ error: "微信登录状态查询失败" }); + } + }); + + router.post("/auth/wechat/login", async (req, res) => { + const code = String(req.body?.code || "").trim(); + if (!code) return res.status(400).json({ error: "缺少微信授权 code" }); + + try { + const wechatUser = await exchangeWechatCode(code); + const userId = await findOrCreateWechatUser(wechatUser); + const loginResult = await createLoginResultForUserId(userId, req); + res.json(loginResult); + } catch (error) { + console.error("[auth/wechat/login] failed", error); + const status = typeof error?.status === "number" ? error.status : 500; + res.status(status).json({ error: "微信登录失败" }); + } + }); + + router.post("/auth/register-enterprise", async (req, res) => { + const { + companyName, + contactName = "", + contactPhone = "", + taxId = "", + legalPersonName = "", + legalPersonPhone = "", + username, + password, + } = req.body; + + const enterpriseError = validateEnterpriseName(companyName); + if (enterpriseError) return res.status(400).json({ error: enterpriseError }); + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const betaInviteCode = await ensureBetaInviteCode(req, res); + if (!betaInviteCode) return; + + const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ + username, + ]); + if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); + + try { + const enterpriseCode = await generateUniqueEnterpriseCode(); + const hash = await bcrypt.hash(password, 10); + + await withTransaction(async (client) => { + const eResult = await client.query( + ` + INSERT INTO enterprises (name, contact_name, contact_phone, tax_id, legal_person_name, legal_person_phone, enterprise_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, + [ + companyName.trim(), + String(contactName || "").trim(), + String(contactPhone || "").trim(), + String(taxId || "").trim() || null, + String(legalPersonName || "").trim() || null, + String(legalPersonPhone || "").trim() || null, + enterpriseCode, + ], + ); + + const enterpriseId = eResult.rows[0].id; + + const userResult = await client.query( + ` + INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, 'user', $3, $4, 1, 0) + RETURNING id + `, + [username, hash, 30, enterpriseId], + ); + await consumeBetaInviteCodeForUser(client, betaInviteCode, userResult.rows[0].id); + }); + + const loginResult = await login(username, password, req.headers["user-agent"]); + res.json(loginResult); + } catch (error) { + console.error("[auth/register-enterprise] failed", error); + sendAuthRouteError(res, error, "Enterprise register failed"); + } + }); + + router.post("/auth/register-employee", async (req, res) => { + const { enterpriseCode, username, password } = req.body; + + if (!enterpriseCode) return res.status(400).json({ error: "缺少企业ID" }); + const usernameError = validateUsername(username); + if (usernameError) return res.status(400).json({ error: usernameError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const betaInviteCode = await ensureBetaInviteCode(req, res); + if (!betaInviteCode) return; + + const { rows: entRows } = await pool.query( + "SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1", + [enterpriseCode], + ); + if (entRows.length === 0) return res.status(404).json({ error: "企业ID不存在" }); + if (!entRows[0].enabled) return res.status(403).json({ error: "该企业已被禁用" }); + + const enterpriseId = entRows[0].id; + + const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ + username, + ]); + if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); + + try { + const hash = await bcrypt.hash(password, 10); + await withTransaction(async (client) => { + const { rows } = await client.query( + ` + INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, 'user', $3, $4, 0, 0) + RETURNING id + `, + [username, hash, 30, enterpriseId], + ); + await consumeBetaInviteCodeForUser(client, betaInviteCode, rows[0].id); + }); + + const loginResult = await login(username, password, req.headers["user-agent"]); + res.json(loginResult); + } catch (error) { + console.error("[auth/register-employee] failed", error); + sendAuthRouteError(res, error, "Employee register failed"); + } + }); + + router.get("/auth/enterprise-lookup", async (req, res) => { + const { code } = req.query; + if (!code) return res.status(400).json({ error: "缺少企业ID" }); + + const { rows } = await pool.query( + "SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1", + [code], + ); + if (rows.length === 0 || !rows[0].enabled) return res.json({ valid: false }); + + res.json({ valid: true, enterpriseName: rows[0].name }); + }); + + router.get("/auth/me", requireAuth, (req, res) => { + res.json({ user: req.user }); + }); + + router.post("/auth/logout", requireAuth, async (req, res) => { + try { + await clearUserSession(req.user.id, req.auth?.sessionId); + res.json({ success: true }); + } catch (error) { + console.error("[auth/logout] failed", error); + res.status(500).json({ error: "退出登录失败" }); + } + }); + + router.put("/auth/profile", requireAuth, async (req, res) => { + try { + const normalizedAvatar = normalizeAvatarOssKey(req.body?.avatarOssKey, req.user.id); + if (normalizedAvatar.error) { + return res.status(400).json({ error: normalizedAvatar.error }); + } + + const hasAvatarUrl = req.body && Object.prototype.hasOwnProperty.call(req.body, "avatarUrl"); + const avatarUrlInput = + normalizedAvatar.value !== undefined + ? { value: normalizedAvatar.value ? `${buildOssPublicUrl(normalizedAvatar.value)}?v=${Date.now()}` : null } + : hasAvatarUrl + ? normalizeProfileMediaUrl(req.body?.avatarUrl) + : { value: undefined }; + if (avatarUrlInput.error) { + return res.status(400).json({ error: avatarUrlInput.error }); + } + + const backgroundUrlInput = normalizeProfileMediaUrl(req.body?.profileBackgroundUrl); + if (backgroundUrlInput.error) { + return res.status(400).json({ error: backgroundUrlInput.error }); + } + + const fields = []; + const values = []; + if (avatarUrlInput.value !== undefined) { + values.push(avatarUrlInput.value); + fields.push(`avatar_url = $${values.length}`); + } + if (req.body && Object.prototype.hasOwnProperty.call(req.body, "bio")) { + const bio = String(req.body.bio || "").trim().slice(0, 160) || null; + values.push(bio); + fields.push(`bio = $${values.length}`); + } + if (backgroundUrlInput.value !== undefined) { + values.push(backgroundUrlInput.value); + fields.push(`profile_background_url = $${values.length}`); + } + + if (fields.length > 0) { + values.push(req.user.id); + await pool.query( + `UPDATE users SET ${fields.join(", ")}, updated_at = NOW() WHERE id = $${values.length}`, + values, + ); + } + + const user = await getUserContextById(req.user.id); + res.json({ user }); + } catch (err) { + console.error("[auth/profile] update failed", err); + res.status(500).json({ error: "更新个人资料失败" }); + } + }); +} + +module.exports = { + registerAuthRoutes, +}; diff --git a/src/routes/community.js b/src/routes/community.js new file mode 100644 index 0000000..7496712 --- /dev/null +++ b/src/routes/community.js @@ -0,0 +1,685 @@ +const { + requireAuth, + pool, + withTransaction, + clampPositiveInteger, + clampNonNegativeInteger, + normalizeProjectOssKey, +} = require("./context"); +const { getUserContextById, verifyToken } = require("../auth"); + +const CASE_STATUSES = new Set(["pending", "approved", "rejected"]); +const ASSET_TYPES = new Set(["image", "video", "project", "workflow", "asset", "cover", "other"]); +const REACTION_TYPES = new Set(["favorite", "like"]); +const COMMUNITY_REVIEW_ROLES = new Set(["admin", "staff", "reviewer", "moderator"]); + +async function optionalAuth(req, _res, next) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + next(); + return; + } + + try { + const payload = verifyToken(authHeader.slice(7)); + const user = await getUserContextById(payload.userId); + if (user?.enabled) req.user = user; + } catch { + // Public community browsing still works with an expired or missing token. + } + next(); +} + +function requireCommunityReviewer(req, res, next) { + const role = String(req.user?.role || "").toLowerCase(); + if (!COMMUNITY_REVIEW_ROLES.has(role)) { + return res.status(403).json({ error: "Community review access required" }); + } + next(); +} + +function safeJsonString(value, fallback) { + if (value === undefined) return JSON.stringify(fallback); + try { + return JSON.stringify(value ?? fallback); + } catch { + return JSON.stringify(fallback); + } +} + +function parseJson(value, fallback) { + if (!value || typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function readCasePayload(body = {}) { + const title = String(body.title || "").trim().slice(0, 200); + if (!title) return { error: "Missing title" }; + + const assets = Array.isArray(body.assets) ? body.assets.slice(0, 100) : []; + return { + value: { + projectId: body.projectId || body.project_id || null, + title, + description: String(body.description || "").trim() || null, + coverUrl: String(body.coverUrl || body.cover_url || "").trim() || null, + tagsJson: safeJsonString(Array.isArray(body.tags) ? body.tags.slice(0, 20) : [], []), + metadataJson: safeJsonString(body.metadata, {}), + assets: assets.map((asset, index) => ({ + assetType: ASSET_TYPES.has(String(asset.assetType || asset.asset_type || asset.type || "")) + ? String(asset.assetType || asset.asset_type || asset.type) + : "other", + title: String(asset.title || "").trim().slice(0, 200) || null, + url: String(asset.url || "").trim() || null, + ossKey: String(asset.ossKey || asset.oss_key || "").trim() || null, + metadataJson: safeJsonString(asset.metadata, {}), + sortOrder: Number.isFinite(Number(asset.sortOrder ?? asset.sort_order)) + ? Number(asset.sortOrder ?? asset.sort_order) + : index, + })), + }, + }; +} + +function formatCaseRow(row, assets = [], reactionStats) { + const stats = reactionStats || {}; + return { + id: Number(row.id), + userId: Number(row.user_id), + username: row.username || null, + projectId: row.project_id || null, + title: row.title, + description: row.description || null, + coverUrl: row.cover_url || null, + tags: parseJson(row.tags_json, []), + metadata: parseJson(row.metadata_json, {}), + status: row.status, + reviewNote: row.review_note || null, + reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by), + reviewedAt: row.reviewed_at || null, + publishedAt: row.published_at || null, + copyCount: Number(row.copy_count || 0), + favoriteCount: Number(stats.favoriteCount ?? row.favorite_count ?? 0), + likeCount: Number(stats.likeCount ?? row.like_count ?? 0), + isFavorited: Boolean(stats.isFavorited ?? row.is_favorited), + isLiked: Boolean(stats.isLiked ?? row.is_liked), + createdAt: row.created_at, + updatedAt: row.updated_at, + assets: assets.map((asset) => ({ + id: Number(asset.id), + assetType: asset.asset_type, + title: asset.title || null, + url: asset.url || null, + ossKey: asset.oss_key || null, + metadata: parseJson(asset.metadata_json, {}), + sortOrder: Number(asset.sort_order || 0), + })), + }; +} + +async function loadCaseReactionStats(caseIds, userId) { + if (!caseIds.length) return new Map(); + const { rows } = await pool.query( + ` + SELECT case_id, reaction_type, COUNT(*)::int AS count + FROM community_case_reactions + WHERE case_id = ANY($1::int[]) + GROUP BY case_id, reaction_type + `, + [caseIds], + ); + + const stats = new Map(); + for (const id of caseIds) { + stats.set(Number(id), { + favoriteCount: 0, + likeCount: 0, + isFavorited: false, + isLiked: false, + }); + } + rows.forEach((row) => { + const entry = stats.get(Number(row.case_id)); + if (!entry) return; + if (row.reaction_type === "favorite") entry.favoriteCount = Number(row.count || 0); + if (row.reaction_type === "like") entry.likeCount = Number(row.count || 0); + }); + + if (userId) { + const mine = await pool.query( + ` + SELECT case_id, reaction_type + FROM community_case_reactions + WHERE case_id = ANY($1::int[]) AND user_id = $2 + `, + [caseIds, userId], + ); + mine.rows.forEach((row) => { + const entry = stats.get(Number(row.case_id)); + if (!entry) return; + if (row.reaction_type === "favorite") entry.isFavorited = true; + if (row.reaction_type === "like") entry.isLiked = true; + }); + } + + return stats; +} + +async function createUserNotification(client, userId, input) { + await client.query( + ` + INSERT INTO web_notifications ( + user_id, type, title, description, target_type, target_id, metadata_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, + [ + userId, + input.type, + input.title, + input.description || null, + input.targetType || null, + input.targetId || null, + safeJsonString(input.metadata, {}), + ], + ); +} + +async function tryCreateUserNotification(client, userId, input, contextLabel) { + try { + await createUserNotification(client, userId, input); + } catch (err) { + console.warn(`[community] notification skipped${contextLabel ? ` for ${contextLabel}` : ""}:`, err.message); + } +} + +async function loadCaseAssets(caseIds) { + if (!caseIds.length) return new Map(); + const { rows } = await pool.query( + ` + SELECT * + FROM community_case_assets + WHERE case_id = ANY($1::int[]) + ORDER BY case_id, sort_order, id + `, + [caseIds], + ); + + const byCase = new Map(); + for (const row of rows) { + if (!byCase.has(row.case_id)) byCase.set(row.case_id, []); + byCase.get(row.case_id).push(row); + } + return byCase; +} + +async function assertOwnedProject(client, userId, projectId) { + if (!projectId) return; + const { rows } = await client.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rows.length === 0) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } +} + +async function writeCaseAssets(client, caseId, assets) { + for (const asset of assets) { + await client.query( + ` + INSERT INTO community_case_assets ( + case_id, asset_type, title, url, oss_key, metadata_json, sort_order + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, + [ + caseId, + asset.assetType, + asset.title, + asset.url, + asset.ossKey, + asset.metadataJson, + asset.sortOrder, + ], + ); + } +} + +function registerCommunityRoutes(router) { + router.get("/community/cases", optionalAuth, async (req, res) => { + try { + const limit = clampPositiveInteger(req.query.limit, 20, 100); + const offset = clampNonNegativeInteger(req.query.offset, 0, 100000); + const q = String(req.query.q || "").trim().toLowerCase().slice(0, 120); + const category = String(req.query.category || "").trim().toLowerCase().slice(0, 60); + const tag = String(req.query.tag || req.query.tags || "").trim().toLowerCase().slice(0, 60); + const sort = String(req.query.sort || "latest").trim().toLowerCase(); + const params = []; + const where = ["cc.status = 'approved'"]; + if (q) { + params.push(`%${q}%`); + where.push(`(LOWER(cc.title) LIKE $${params.length} OR LOWER(COALESCE(cc.description, '')) LIKE $${params.length} OR LOWER(cc.tags_json) LIKE $${params.length})`); + } + if (category && category !== "all") { + params.push(`%${category}%`); + where.push(`LOWER(cc.tags_json) LIKE $${params.length}`); + } + if (tag) { + params.push(`%${tag}%`); + where.push(`LOWER(cc.tags_json) LIKE $${params.length}`); + } + const orderBy = + sort === "popular" + ? "cc.copy_count DESC, cc.published_at DESC NULLS LAST, cc.updated_at DESC" + : "cc.published_at DESC NULLS LAST, cc.updated_at DESC"; + params.push(limit, offset); + const { rows } = await pool.query( + ` + SELECT cc.*, u.username + FROM community_cases cc + JOIN users u ON u.id = cc.user_id + WHERE ${where.join(" AND ")} + ORDER BY ${orderBy} + LIMIT $${params.length - 1} OFFSET $${params.length} + `, + params, + ); + const assetsByCase = await loadCaseAssets(rows.map((row) => row.id)); + const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), req.user?.id); + res.json({ + cases: rows.map((row) => + formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))), + ), + limit, + offset, + }); + } catch (err) { + console.error("[community/cases] list failed:", err.message); + res.status(500).json({ error: "Failed to load community cases" }); + } + }); + + router.get("/community/cases/:id", async (req, res) => { + try { + const { rows } = await pool.query( + ` + SELECT cc.*, u.username + FROM community_cases cc + JOIN users u ON u.id = cc.user_id + WHERE cc.id = $1 + LIMIT 1 + `, + [req.params.id], + ); + const row = rows[0]; + if (!row) return res.status(404).json({ error: "Case not found" }); + if (row.status !== "approved") { + return res.status(404).json({ error: "Case not found" }); + } + + const assetsByCase = await loadCaseAssets([row.id]); + const statsByCase = await loadCaseReactionStats([row.id], null); + res.json({ case: formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))) }); + } catch (err) { + console.error("[community/cases] get failed:", err.message); + res.status(500).json({ error: "Failed to load community case" }); + } + }); + + router.post("/community/cases", requireAuth, async (req, res) => { + const payload = readCasePayload(req.body || {}); + if (payload.error) return res.status(400).json({ error: payload.error }); + + try { + const created = await withTransaction(async (client) => { + await assertOwnedProject(client, req.user.id, payload.value.projectId); + const { + rows: [row], + } = await client.query( + ` + INSERT INTO community_cases ( + user_id, project_id, title, description, cover_url, tags_json, metadata_json, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending') + RETURNING * + `, + [ + req.user.id, + payload.value.projectId, + payload.value.title, + payload.value.description, + payload.value.coverUrl, + payload.value.tagsJson, + payload.value.metadataJson, + ], + ); + + await writeCaseAssets(client, row.id, payload.value.assets); + return row; + }); + + await tryCreateUserNotification(pool, req.user.id, { + type: "review_pending", + title: "作品已提交审核", + description: `「${payload.value.title}」已进入社区审核队列。`, + targetType: "community_case", + targetId: String(created.id), + }, `case ${created.id} submission`); + + const assetsByCase = await loadCaseAssets([created.id]); + res.status(201).json({ case: formatCaseRow(created, assetsByCase.get(created.id) || []) }); + } catch (err) { + const status = err.status || 500; + console.error("[community/cases] create failed:", err.message); + res.status(status).json({ error: err.message || "Failed to create community case" }); + } + }); + + router.post("/community/cases/:id/copy", requireAuth, async (req, res) => { + try { + const result = await withTransaction(async (client) => { + const { + rows: [caseRow], + } = await client.query( + "SELECT * FROM community_cases WHERE id = $1 AND status = 'approved' FOR UPDATE", + [req.params.id], + ); + if (!caseRow) { + const error = new Error("Case not found"); + error.status = 404; + throw error; + } + + let projectId = req.body?.projectId || null; + const ossKey = req.body?.ossKey || req.body?.oss_key || null; + const name = String(req.body?.name || caseRow.title || "").trim(); + let projectRow = null; + if (projectId || ossKey) { + if (!projectId || !ossKey || !name) { + const error = new Error("Missing projectId, name or ossKey"); + error.status = 400; + throw error; + } + + const normalizedOssKey = normalizeProjectOssKey(ossKey, req.user.id, projectId); + if (normalizedOssKey.error) { + const error = new Error(normalizedOssKey.error); + error.status = 400; + throw error; + } + + const { + rows: [createdProject], + } = await client.query( + ` + INSERT INTO projects ( + id, user_id, name, description, oss_key, thumbnail_url, + storyboard_count, image_count, video_count, file_size, + current_revision, current_fingerprint, updated_by_device_id, + source_case_id, origin_type, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, $11, $12, $13, 'community_copy', NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + oss_key = EXCLUDED.oss_key, + thumbnail_url = EXCLUDED.thumbnail_url, + storyboard_count = EXCLUDED.storyboard_count, + image_count = EXCLUDED.image_count, + video_count = EXCLUDED.video_count, + file_size = EXCLUDED.file_size, + current_fingerprint = EXCLUDED.current_fingerprint, + updated_by_device_id = EXCLUDED.updated_by_device_id, + source_case_id = EXCLUDED.source_case_id, + origin_type = EXCLUDED.origin_type, + updated_at = NOW() + WHERE projects.user_id = EXCLUDED.user_id + RETURNING + id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision AS revision, + current_fingerprint AS fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + `, + [ + projectId, + req.user.id, + name, + req.body?.description || caseRow.description || null, + normalizedOssKey.value, + req.body?.thumbnailUrl || req.body?.thumbnail_url || caseRow.cover_url || null, + Number(req.body?.storyboardCount ?? req.body?.storyboard_count) || 0, + Number(req.body?.imageCount ?? req.body?.image_count) || 0, + Number(req.body?.videoCount ?? req.body?.video_count) || 0, + Number(req.body?.fileSize ?? req.body?.file_size) || 0, + req.body?.fingerprint || null, + req.body?.deviceId || req.body?.device_id || "web", + caseRow.id, + ], + ); + projectRow = createdProject || null; + if (!projectRow) { + const error = new Error("Project id is already used by another user"); + error.status = 403; + throw error; + } + + await client.query( + ` + INSERT INTO project_revisions ( + project_id, revision_number, oss_key, content_fingerprint, source_device_id, save_reason + ) + VALUES ($1, 1, $2, $3, $4, 'community_copy') + ON CONFLICT (project_id, revision_number) DO UPDATE SET + oss_key = EXCLUDED.oss_key, + content_fingerprint = EXCLUDED.content_fingerprint, + source_device_id = EXCLUDED.source_device_id, + save_reason = EXCLUDED.save_reason + `, + [ + projectId, + normalizedOssKey.value, + req.body?.fingerprint || null, + req.body?.deviceId || req.body?.device_id || "web", + ], + ); + } + + const { + rows: [copyRow], + } = await client.query( + ` + INSERT INTO community_case_copies (case_id, user_id, project_id) + VALUES ($1, $2, $3) + RETURNING * + `, + [caseRow.id, req.user.id, projectId], + ); + await client.query( + "UPDATE community_cases SET copy_count = copy_count + 1, updated_at = NOW() WHERE id = $1", + [caseRow.id], + ); + + return { caseRow, copyRow, projectRow }; + }); + + res.status(201).json({ + project: result.projectRow || null, + copy: { + id: Number(result.copyRow.id), + caseId: Number(result.copyRow.case_id), + projectId: result.copyRow.project_id || null, + createdAt: result.copyRow.created_at, + }, + }); + } catch (err) { + const status = err.status || 500; + console.error("[community/cases] copy failed:", err.message); + res.status(status).json({ error: err.message || "Failed to copy community case" }); + } + }); + + router.post("/community/cases/:id/reactions", requireAuth, async (req, res) => { + const reactionType = String(req.body?.reactionType || req.body?.type || "").trim(); + const active = req.body?.active !== false; + if (!REACTION_TYPES.has(reactionType)) { + return res.status(400).json({ error: "Invalid reaction type" }); + } + + try { + const { rows: caseRows } = await pool.query( + "SELECT id FROM community_cases WHERE id = $1 AND status = 'approved'", + [req.params.id], + ); + if (!caseRows[0]) return res.status(404).json({ error: "Case not found" }); + + if (active) { + await pool.query( + ` + INSERT INTO community_case_reactions (case_id, user_id, reaction_type) + VALUES ($1, $2, $3) + ON CONFLICT (case_id, user_id, reaction_type) DO NOTHING + `, + [req.params.id, req.user.id, reactionType], + ); + } else { + await pool.query( + "DELETE FROM community_case_reactions WHERE case_id = $1 AND user_id = $2 AND reaction_type = $3", + [req.params.id, req.user.id, reactionType], + ); + } + + const statsByCase = await loadCaseReactionStats([Number(req.params.id)], req.user.id); + res.json({ stats: statsByCase.get(Number(req.params.id)) }); + } catch (err) { + console.error("[community/cases] reaction failed:", err.message); + res.status(500).json({ error: "Failed to update reaction" }); + } + }); + + router.get("/community/me/cases", requireAuth, async (req, res) => { + try { + const { rows } = await pool.query( + ` + SELECT cc.*, u.username + FROM community_cases cc + JOIN users u ON u.id = cc.user_id + WHERE cc.user_id = $1 + ORDER BY cc.updated_at DESC + `, + [req.user.id], + ); + const assetsByCase = await loadCaseAssets(rows.map((row) => row.id)); + const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), req.user.id); + res.json({ + cases: rows.map((row) => + formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))), + ), + }); + } catch (err) { + console.error("[community/me/cases] list failed:", err.message); + res.status(500).json({ error: "Failed to load my community cases" }); + } + }); +} + +function registerAdminCommunityRoutes(router) { + router.get("/admin/community/cases", requireAuth, requireCommunityReviewer, async (req, res) => { + try { + const status = String(req.query.status || "").trim(); + const where = CASE_STATUSES.has(status) ? "WHERE cc.status = $1" : ""; + const params = CASE_STATUSES.has(status) ? [status] : []; + const { rows } = await pool.query( + ` + SELECT cc.*, u.username + FROM community_cases cc + JOIN users u ON u.id = cc.user_id + ${where} + ORDER BY cc.updated_at DESC + LIMIT 200 + `, + params, + ); + const assetsByCase = await loadCaseAssets(rows.map((row) => row.id)); + const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), null); + res.json({ + cases: rows.map((row) => + formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))), + ), + }); + } catch (err) { + console.error("[admin/community/cases] list failed:", err.message); + res.status(500).json({ error: "Failed to load community cases" }); + } + }); + + const updateCaseStatus = async (req, res) => { + const status = String(req.body?.status || "").trim(); + if (!CASE_STATUSES.has(status)) return res.status(400).json({ error: "Invalid status" }); + + try { + const { + rows: [row], + } = await pool.query( + ` + UPDATE community_cases + SET status = $1::varchar(24), + review_note = $2, + reviewed_by = $3, + reviewed_at = NOW(), + published_at = CASE WHEN $1::varchar(24) = 'approved' THEN COALESCE(published_at, NOW()) ELSE NULL END, + updated_at = NOW() + WHERE id = $4 + RETURNING * + `, + [status, req.body?.reviewNote || req.body?.review_note || null, req.user.id, req.params.id], + ); + if (!row) return res.status(404).json({ error: "Case not found" }); + await tryCreateUserNotification(pool, row.user_id, { + type: status === "approved" ? "review_passed" : status === "rejected" ? "review_rejected" : "review_pending", + title: status === "approved" ? "作品审核通过" : status === "rejected" ? "作品审核未通过" : "作品审核状态更新", + description: + status === "approved" + ? `「${row.title}」已发布到社区。` + : status === "rejected" + ? req.body?.reviewNote || req.body?.review_note || "作品未通过审核,请修改后重新提交。" + : `「${row.title}」仍在审核中。`, + targetType: "community_case", + targetId: String(row.id), + }, `case ${row.id} review status`); + + const assetsByCase = await loadCaseAssets([row.id]); + const statsByCase = await loadCaseReactionStats([row.id], null); + res.json({ case: formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))) }); + } catch (err) { + console.error("[admin/community/cases] status failed:", err.message); + res.status(500).json({ error: "Failed to update community case status" }); + } + }; + + router.patch("/admin/community/cases/:id/status", requireAuth, requireCommunityReviewer, updateCaseStatus); + router.put("/admin/community/cases/:id/status", requireAuth, requireCommunityReviewer, updateCaseStatus); +} + +module.exports = { + registerCommunityRoutes, + registerAdminCommunityRoutes, +}; diff --git a/src/routes/config.js b/src/routes/config.js new file mode 100644 index 0000000..1fc8338 --- /dev/null +++ b/src/routes/config.js @@ -0,0 +1,82 @@ +const { requireAuth, requireAdmin, pool } = require("./context"); + +function registerConfigRoutes(router) { + // ── Config ─────────────────────────────────────────────────────────── + + router.get("/config/profile", requireAuth, async (req, res) => { + const { name = "default" } = req.query; + const { + rows: [row], + } = await pool.query( + "SELECT config_json, description, updated_at FROM config_profiles WHERE name = $1", + [name], + ); + if (!row && name === "web-model-capabilities") { + return res.json({ + name, + config: { + imageModels: [], + videoModels: [], + chatModels: [], + }, + description: "", + updatedAt: null, + }); + } + if (!row) return res.status(404).json({ error: `配置 "${name}" 不存在` }); + + res.json({ + name, + config: JSON.parse(row.config_json), + description: row.description, + updatedAt: row.updated_at, + }); + }); + + router.get("/config/profiles", requireAuth, async (_req, res) => { + const { rows } = await pool.query( + "SELECT name, description, updated_at FROM config_profiles ORDER BY name", + ); + res.json(rows); + }); + + router.put("/config/profile", requireAuth, requireAdmin, async (req, res) => { + const { name = "default", config, description } = req.body; + if (!config || typeof config !== "object") + return res.status(400).json({ error: "缺少 config 对象" }); + + const { + rows: [existing], + } = await pool.query("SELECT id FROM config_profiles WHERE name = $1", [name]); + if (existing) { + const updates = ["config_json = $1", "updated_at = NOW()", "updated_by = $2"]; + const params = [JSON.stringify(config), req.user.id]; + let idx = 3; + if (description !== undefined) { + updates.push(`description = $${idx++}`); + params.push(description); + } + params.push(name); + await pool.query( + `UPDATE config_profiles SET ${updates.join(", ")} WHERE name = $${idx}`, + params, + ); + } else { + await pool.query( + "INSERT INTO config_profiles (name, config_json, description, updated_by) VALUES ($1, $2, $3, $4)", + [name, JSON.stringify(config), description || "", req.user.id], + ); + } + + res.json({ success: true, name }); + }); + + router.delete("/config/profile/:name", requireAuth, requireAdmin, async (req, res) => { + await pool.query("DELETE FROM config_profiles WHERE name = $1", [req.params.name]); + res.json({ success: true }); + }); +} + +module.exports = { + registerConfigRoutes, +}; diff --git a/src/routes/context.js b/src/routes/context.js new file mode 100644 index 0000000..85a384b --- /dev/null +++ b/src/routes/context.js @@ -0,0 +1,793 @@ +const express = require("express"); +const bcrypt = require("bcryptjs"); +const { + requireAuth, + requireAdmin, + requireEnterpriseAdmin, + requireManagementAccess, + login, + generateToken, + startUserSession, + getUserContextById, + isSystemAdmin, + generateUniqueEnterpriseCode, +} = require("../auth"); +const keyManager = require("../keyManager"); +const { + calculateCost, + calculateCostMills, + listModelPrices, + normalizeModelPriceRow, + getAverageCostCents, + loadPriceCache, +} = require("../pricing"); +const { + deductForApiCall, + deductImageGenerationCredits, + creditBalance, + creditUserBalance, + activatePackage, + distributeCredits, + getEnterpriseFinancials, + getUserEnterpriseId, + getEnterpriseName, + preauthorizeCall, +} = require("../billing"); +const wechatPay = require("../paymentWechat"); +const alipay = require("../paymentAlipay"); +const crypto = require("node:crypto"); +const { pool, withTransaction } = require("../db"); +const { + computeNextRevision, + normalizeRevisionValue, + shouldRejectStaleRevision, +} = require("../projectRevisionLogic"); +const { loadBetaInviteCodes } = require("../betaInviteCodes"); + +const USERNAME_PATTERN = /^[a-zA-Z0-9_\u4e00-\u9fa5]+$/; +const PRICE_CATEGORIES = new Set(["text", "image", "video"]); +const PRICE_TYPES = new Set(["token", "flat"]); +const PHONE_PATTERN = /^\+?[0-9]{6,20}$/; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const SMS_PURPOSES = new Set(["register", "login"]); +const SMS_CODE_TTL_MINUTES = Math.max(1, Number(process.env.SMS_CODE_TTL_MINUTES) || 10); +const SMS_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.SMS_CODE_COOLDOWN_SECONDS) || 60); +const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5); + +function validateUsername(username) { + if (!username) return "缺少用户名"; + if (username.length < 2 || username.length > 30) return "用户名长度必须在 2 到 30 之间"; + if (!USERNAME_PATTERN.test(username)) return "用户名只能包含字母、数字、下划线或中文"; + return null; +} + +function validatePassword(password) { + if (!password) return "缺少密码"; + if (password.length < 6) return "密码至少 6 位"; + return null; +} + +function normalizePhone(phone) { + return String(phone || "") + .trim() + .replace(/[\s-]/g, ""); +} + +function validatePhone(phone) { + const normalized = normalizePhone(phone); + if (!normalized) return "缺少手机号"; + if (!PHONE_PATTERN.test(normalized)) return "手机号格式不正确"; + return null; +} + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function validateEmail(email) { + const normalized = normalizeEmail(email); + if (!normalized) return "缺少邮箱"; + if (normalized.length > 200 || !EMAIL_PATTERN.test(normalized)) return "邮箱格式不正确"; + return null; +} + +function hashSmsCode(phone, code) { + const secret = process.env.SMS_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-sms-secret"; + return crypto.createHash("sha256").update(`${phone}:${code}:${secret}`).digest("hex"); +} + +function generateSmsCode() { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +async function sendSmsCode(phone, code, purpose) { + const provider = String(process.env.SMS_PROVIDER || "mock") + .trim() + .toLowerCase(); + if (provider === "http") { + const endpoint = process.env.SMS_HTTP_ENDPOINT; + if (!endpoint) throw new Error("SMS_HTTP_ENDPOINT 未配置"); + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(process.env.SMS_HTTP_TOKEN + ? { Authorization: `Bearer ${process.env.SMS_HTTP_TOKEN}` } + : {}), + }, + body: JSON.stringify({ phone, code, purpose }), + }); + if (!response.ok) { + throw new Error(`短信平台返回 HTTP ${response.status}`); + } + return { provider }; + } + + console.log(`[sms:${purpose}] ${phone} verification sent (mock provider)`); + return { + provider: "mock", + devCode: process.env.SMS_DEV_RETURN_CODE === "1" ? code : undefined, + }; +} + +async function createLoginResultForUserId(userId, req) { + const user = await getUserContextById(userId); + if (!user?.enabled) return null; + const userAgent = req?.headers?.["user-agent"] || null; + const sessionId = await startUserSession(user.id, userAgent); + const userWithSession = { + ...user, + sessionId, + sessionStartedAt: new Date().toISOString(), + }; + return { + token: generateToken(userWithSession, sessionId), + user: userWithSession, + }; +} + +function sanitizeUsernameSeed(seed, fallback) { + const normalized = String(seed || "") + .trim() + .replace(/[^\w\u4e00-\u9fa5]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const safe = normalized || fallback; + return safe.length > 24 ? safe.slice(0, 24) : safe; +} + +async function generateUniqueUsername(seed, fallback) { + const base = sanitizeUsernameSeed(seed, fallback); + for (let attempt = 0; attempt < 10; attempt++) { + const suffix = crypto.randomBytes(3).toString("hex"); + const username = `${base}_${suffix}`.slice(0, 30); + const { rows } = await pool.query("SELECT 1 FROM users WHERE username = $1", [username]); + if (rows.length === 0) return username; + } + return `${fallback}_${Date.now().toString(36)}`.slice(0, 30); +} + +async function consumeSmsCode(phone, code, purpose) { + const { rows } = await pool.query( + ` + SELECT id, code_hash, attempts + FROM sms_verification_codes + WHERE phone = $1 + AND purpose = $2 + AND consumed_at IS NULL + AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + `, + [phone, purpose], + ); + + const row = rows[0]; + if (!row) return false; + + if (Number(row.attempts || 0) >= SMS_CODE_MAX_ATTEMPTS) { + return false; + } + + const expectedHash = hashSmsCode(phone, String(code || "").trim()); + if (row.code_hash !== expectedHash) { + await pool.query("UPDATE sms_verification_codes SET attempts = attempts + 1 WHERE id = $1", [ + row.id, + ]); + return false; + } + + await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); + return true; +} + +function getWechatLoginConfig() { + const appId = process.env.WECHAT_LOGIN_APP_ID || process.env.WECHAT_APP_ID || ""; + const appSecret = process.env.WECHAT_LOGIN_APP_SECRET || process.env.WECHAT_APP_SECRET || ""; + const redirectUri = process.env.WECHAT_LOGIN_REDIRECT_URI || ""; + return { appId, appSecret, redirectUri }; +} + +async function fetchWechatJson(url) { + const response = await fetch(url); + const payload = await response.json(); + if (!response.ok || payload.errcode) { + throw new Error(payload.errmsg || `微信接口返回 HTTP ${response.status}`); + } + return payload; +} + +async function exchangeWechatCode(code) { + const { appId, appSecret } = getWechatLoginConfig(); + if (!appId || !appSecret) { + throw new Error("微信开放平台 AppID/AppSecret 未配置"); + } + + const tokenUrl = new URL("https://api.weixin.qq.com/sns/oauth2/access_token"); + tokenUrl.searchParams.set("appid", appId); + tokenUrl.searchParams.set("secret", appSecret); + tokenUrl.searchParams.set("code", code); + tokenUrl.searchParams.set("grant_type", "authorization_code"); + + const tokenPayload = await fetchWechatJson(tokenUrl.toString()); + const accessToken = tokenPayload.access_token; + const openid = tokenPayload.openid; + if (!accessToken || !openid) { + throw new Error("微信登录未返回 openid"); + } + + let profile = {}; + try { + const userInfoUrl = new URL("https://api.weixin.qq.com/sns/userinfo"); + userInfoUrl.searchParams.set("access_token", accessToken); + userInfoUrl.searchParams.set("openid", openid); + userInfoUrl.searchParams.set("lang", "zh_CN"); + profile = await fetchWechatJson(userInfoUrl.toString()); + } catch (error) { + console.warn( + "[auth/wechat] userinfo failed", + error instanceof Error ? error.message : String(error), + ); + } + + return { + openid, + unionid: profile.unionid || tokenPayload.unionid || null, + nickname: profile.nickname || null, + }; +} + +async function findOrCreateWechatUser(wechatUser) { + const { rows: existingRows } = await pool.query( + "SELECT id, enabled FROM users WHERE wechat_openid = $1 LIMIT 1", + [wechatUser.openid], + ); + if (existingRows.length > 0) { + if (!existingRows[0].enabled) { + const error = new Error("账号已禁用"); + error.status = 403; + throw error; + } + return existingRows[0].id; + } + + if (loadBetaInviteCodes().size > 0) { + const error = new Error("内测阶段请先使用内测码注册账号后再使用微信登录"); + error.status = 403; + throw error; + } + + const username = await generateUniqueUsername( + wechatUser.nickname || `wx${wechatUser.openid.slice(-6)}`, + "wechat", + ); + const randomPasswordHash = await bcrypt.hash(crypto.randomBytes(32).toString("hex"), 10); + const { rows } = await pool.query( + ` + INSERT INTO users (username, password_hash, wechat_openid, wechat_unionid, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) + VALUES ($1, $2, $3, $4, 'wechat', 'user', 30, null, 0, 0) + RETURNING id + `, + [username, randomPasswordHash, wechatUser.openid, wechatUser.unionid], + ); + return rows[0].id; +} + +function validateEnterpriseName(name) { + if (!name) return "缺少企业名称"; + if (name.trim().length < 2 || name.trim().length > 80) return "企业名称长度必须在 2 到 80 之间"; + return null; +} + +function parseNumericValue(value, fieldLabel, { allowNull = true } = {}) { + if (value === undefined) return { ok: true, value: undefined }; + if (value === null || value === "") { + return allowNull ? { ok: true, value: null } : { ok: false, error: `${fieldLabel}不能为空` }; + } + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) + return { ok: false, error: `${fieldLabel}必须是非负数字` }; + return { ok: true, value: numeric }; +} + +async function ensureEnterpriseExists(enterpriseId) { + if (enterpriseId == null) return null; + const { rows } = await pool.query("SELECT id, name FROM enterprises WHERE id = $1", [ + enterpriseId, + ]); + return rows[0] || null; +} + +function formatUserRow(row) { + return { + id: Number(row.id), + username: row.username, + role: row.role, + avatarUrl: row.avatar_url || null, + maxConcurrency: Number(row.max_concurrency || 0), + enabled: !!row.enabled, + enterpriseId: row.enterprise_id == null ? null : Number(row.enterprise_id), + enterpriseName: row.enterprise_name || null, + isEnterpriseAdmin: !!row.is_enterprise_admin, + balanceCents: row.balance_cents != null ? Number(row.balance_cents) : 0, + billingMode: row.billing_mode || "credits", + betaExpiresAt: row.beta_expires_at || null, + createdAt: row.created_at, + }; +} + +function normalizeOssRegion(region) { + const trimmed = String(region || "").trim(); + return trimmed.startsWith("oss-") ? trimmed.slice(4) : trimmed; +} + +function buildOssPublicUrl(ossKey) { + const publicBaseUrl = String(process.env.OSS_PUBLIC_BASE_URL || "") + .trim() + .replace(/\/+$/, ""); + if (publicBaseUrl) { + return `${publicBaseUrl}/${ossKey}`; + } + + const bucket = String(process.env.OSS_BUCKET || "").trim(); + const region = normalizeOssRegion(process.env.OSS_REGION || ""); + if (!bucket || !region) { + throw new Error("OSS bucket or region is not configured"); + } + + return `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; +} + +function normalizeAvatarOssKey(value, userId) { + if (value === undefined) return { value: undefined }; + if (value === null) return { value: null }; + + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + const ossKey = String(value || "") + .trim() + .replace(/^\/+/, ""); + if (!ossKey) return { value: null }; + + const expectedPrefix = `users/${safeUserId}/profile/avatar/`; + const allowedPattern = new RegExp( + `^users/${safeUserId}/profile/avatar/avatar\\.(jpg|jpeg|png|webp)$`, + "i", + ); + if (!ossKey.startsWith(expectedPrefix) || !allowedPattern.test(ossKey)) { + return { error: "Invalid avatar OSS key" }; + } + + return { value: ossKey }; +} + +function normalizeProfileMediaUrl(value) { + if (value === undefined) return { value: undefined }; + if (value === null || value === "") return { value: null }; + + const url = String(value || "").trim(); + if (!url) return { value: null }; + if (url.length > 2000) return { error: "资料图片地址过长" }; + if (url.startsWith("data:")) return { error: "资料图片请先上传到 OSS" }; + + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { error: "资料图片地址格式不正确" }; + } + } catch { + return { error: "资料图片地址格式不正确" }; + } + + return { value: url }; +} + +function normalizeProjectOssKey(value, userId, projectId) { + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + const safeProjectId = String(projectId || "") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, ""); + const ossKey = String(value || "") + .trim() + .replace(/^\/+/, ""); + + if (!safeUserId || !safeProjectId || safeProjectId !== String(projectId || "").trim()) { + return { error: "Invalid project OSS key scope" }; + } + + const expectedKey = `users/${safeUserId}/projects/${safeProjectId}/current/project.json`; + if (ossKey !== expectedKey) { + return { error: "Invalid project OSS key scope" }; + } + + return { value: ossKey }; +} + +function getManagementEnterpriseId(user) { + if (!user || isSystemAdmin(user)) return null; + return user.enterpriseId || null; +} + +function appendEnterpriseScope(whereClauses, params, user, expression, paramIdx) { + const enterpriseId = getManagementEnterpriseId(user); + if (enterpriseId != null) { + whereClauses.push(`${expression} = $${paramIdx}`); + params.push(enterpriseId); + return paramIdx + 1; + } + return paramIdx; +} + +function readModelPricePayload(body, existing = null) { + const modelKey = String(body.modelKey ?? existing?.modelKey ?? "").trim(); + const displayName = String(body.displayName ?? existing?.displayName ?? "").trim(); + const category = String(body.category ?? existing?.category ?? "text").trim(); + const pricingType = String(body.pricingType ?? existing?.pricingType ?? "token").trim(); + const currency = String(body.currency ?? existing?.currency ?? "CNY").trim() || "CNY"; + const enabled = body.enabled === undefined ? (existing?.enabled ?? true) : !!body.enabled; + + if (!modelKey) return { error: "缺少模型标识" }; + if (!displayName) return { error: "缺少显示名称" }; + if (!PRICE_CATEGORIES.has(category)) return { error: "模型分类无效" }; + if (!PRICE_TYPES.has(pricingType)) return { error: "计费类型无效" }; + + const inputPriceMills = parseNumericValue(body.inputPriceMills, "输入价格(厘)"); + if (!inputPriceMills.ok) return { error: inputPriceMills.error }; + const outputPriceMills = parseNumericValue(body.outputPriceMills, "输出价格(厘)"); + if (!outputPriceMills.ok) return { error: outputPriceMills.error }; + const flatPriceMills = parseNumericValue(body.flatPriceMills, "固定价格(厘)"); + if (!flatPriceMills.ok) return { error: flatPriceMills.error }; + + const merged = { + modelKey, + displayName, + category, + pricingType, + currency, + enabled, + inputPriceMills: + inputPriceMills.value !== undefined + ? inputPriceMills.value + : (existing?.inputPriceMills ?? null), + outputPriceMills: + outputPriceMills.value !== undefined + ? outputPriceMills.value + : (existing?.outputPriceMills ?? null), + flatPriceMills: + flatPriceMills.value !== undefined + ? flatPriceMills.value + : (existing?.flatPriceMills ?? null), + }; + + if (pricingType === "token") { + if (merged.inputPriceMills == null || merged.outputPriceMills == null) + return { error: "按 Token 计费时必须提供输入和输出价格(厘)" }; + merged.flatPriceMills = null; + } else { + if (merged.flatPriceMills == null) return { error: "固定计费时必须提供固定价格(厘)" }; + merged.inputPriceMills = null; + merged.outputPriceMills = null; + } + + return { value: merged }; +} + +async function getModelPriceById(id) { + const { rows } = await pool.query("SELECT * FROM model_prices WHERE id = $1", [id]); + return normalizeModelPriceRow(rows[0]); +} + +function getPeriodStart(period) { + switch (period) { + case "7d": + return "NOW() - INTERVAL '7 days'"; + case "30d": + return "NOW() - INTERVAL '30 days'"; + case "all": + return null; + default: + return "NOW() - INTERVAL '7 days'"; + } +} + +// Fills a SQL day-aggregation result into a continuous 7-day series ending +// today, padding missing days with zeros so the trend chart has no gaps. +function buildDailyTrend(rows, days = 7) { + const byDay = new Map(); + for (const row of rows || []) { + byDay.set(String(row.day), { + usedCents: Number(row.used_cents || 0), + taskCount: Number(row.task_count || 0), + }); + } + const series = []; + const today = new Date(); + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(today); + d.setDate(today.getDate() - i); + const key = d.toISOString().slice(0, 10); + const hit = byDay.get(key) || { usedCents: 0, taskCount: 0 }; + series.push({ date: key, usedCents: hit.usedCents, taskCount: hit.taskCount }); + } + return series; +} + +function clampPositiveInteger(value, fallback, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.min(Math.trunc(numeric), max); +} + +function clampNonNegativeInteger(value, fallback, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) return fallback; + return Math.min(Math.trunc(numeric), max); +} + +function generateOrderNo() { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = crypto.randomBytes(4).toString("hex").toUpperCase(); + return `ORD${timestamp}${random}`; +} + +const GENERATION_TASK_STATUSES = new Set([ + "pending", + "running", + "completed", + "failed", + "cancelled", +]); +const GENERATION_TASK_TYPES = new Set(["image", "video"]); + +function clampTaskProgress(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(100, Math.trunc(numeric))); +} + +function serializeTaskParams(value) { + if (!value || typeof value !== "object") return "{}"; + return JSON.stringify(value); +} + +function parseTaskParams(value) { + if (!value || typeof value !== "string") return {}; + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function formatGenerationTaskRow(row) { + return { + id: Number(row.id), + projectId: row.project_id, + clientQueueId: row.client_queue_id, + type: row.type, + status: row.status, + providerTaskId: row.provider_task_id || null, + params: parseTaskParams(row.params_json), + resultUrl: row.result_url || null, + progress: Number(row.progress || 0), + error: row.error || null, + dedupeKey: row.dedupe_key || null, + sourceDeviceId: row.source_device_id || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at || null, + }; +} + +function normalizeGenerationTaskPayload(body) { + const clientQueueId = String(body.clientQueueId || body.client_queue_id || "") + .trim() + .slice(0, 128); + const type = String(body.type || "").trim(); + const status = String(body.status || "pending").trim(); + + if (!clientQueueId) return { error: "Missing clientQueueId" }; + if (!GENERATION_TASK_TYPES.has(type)) return { error: "Invalid task type" }; + if (!GENERATION_TASK_STATUSES.has(status)) return { error: "Invalid task status" }; + + return { + value: { + clientQueueId, + type, + status, + providerTaskId: body.providerTaskId || body.provider_task_id || null, + paramsJson: serializeTaskParams(body.params || body.paramsJson || body.params_json), + resultUrl: body.resultUrl || body.result_url || null, + progress: clampTaskProgress(body.progress), + error: body.error || null, + dedupeKey: body.dedupeKey || body.dedupe_key || null, + sourceDeviceId: body.sourceDeviceId || body.source_device_id || null, + createdAt: body.createdAt || body.created_at || null, + completedAt: body.completedAt || body.completed_at || null, + }, + }; +} + +async function requireOwnedProject(client, userId, projectId) { + const { rows } = await client.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + return rows.length > 0; +} + +async function upsertGenerationTask(client, userId, projectId, payload) { + const { + rows: [row], + } = await client.query( + ` + INSERT INTO generation_tasks ( + user_id, + project_id, + client_queue_id, + type, + status, + provider_task_id, + params_json, + result_url, + progress, + error, + dedupe_key, + source_device_id, + created_at, + updated_at, + completed_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + COALESCE($13::timestamptz, NOW()), + NOW(), + $14::timestamptz + ) + ON CONFLICT (project_id, client_queue_id) WHERE project_id IS NOT NULL DO UPDATE SET + type = EXCLUDED.type, + status = EXCLUDED.status, + provider_task_id = EXCLUDED.provider_task_id, + params_json = EXCLUDED.params_json, + result_url = EXCLUDED.result_url, + progress = EXCLUDED.progress, + error = EXCLUDED.error, + dedupe_key = EXCLUDED.dedupe_key, + source_device_id = EXCLUDED.source_device_id, + updated_at = NOW(), + completed_at = EXCLUDED.completed_at + RETURNING * + `, + [ + userId, + projectId, + payload.clientQueueId, + payload.type, + payload.status, + payload.providerTaskId, + payload.paramsJson, + payload.resultUrl, + payload.progress, + payload.error, + payload.dedupeKey, + payload.sourceDeviceId, + payload.createdAt, + payload.completedAt, + ], + ); + + return row; +} + +module.exports = { + express, + bcrypt, + requireAuth, + requireAdmin, + requireEnterpriseAdmin, + requireManagementAccess, + login, + generateToken, + startUserSession, + getUserContextById, + isSystemAdmin, + generateUniqueEnterpriseCode, + keyManager, + calculateCost, + calculateCostMills, + listModelPrices, + normalizeModelPriceRow, + getAverageCostCents, + loadPriceCache, + deductForApiCall, + deductImageGenerationCredits, + creditBalance, + creditUserBalance, + activatePackage, + distributeCredits, + getEnterpriseFinancials, + getUserEnterpriseId, + getEnterpriseName, + preauthorizeCall, + wechatPay, + alipay, + crypto, + pool, + withTransaction, + computeNextRevision, + normalizeRevisionValue, + shouldRejectStaleRevision, + USERNAME_PATTERN, + PRICE_CATEGORIES, + PRICE_TYPES, + PHONE_PATTERN, + EMAIL_PATTERN, + SMS_PURPOSES, + SMS_CODE_TTL_MINUTES, + SMS_CODE_COOLDOWN_SECONDS, + SMS_CODE_MAX_ATTEMPTS, + validateUsername, + validatePassword, + normalizePhone, + validatePhone, + normalizeEmail, + validateEmail, + hashSmsCode, + generateSmsCode, + sendSmsCode, + createLoginResultForUserId, + sanitizeUsernameSeed, + generateUniqueUsername, + consumeSmsCode, + getWechatLoginConfig, + fetchWechatJson, + exchangeWechatCode, + findOrCreateWechatUser, + validateEnterpriseName, + parseNumericValue, + ensureEnterpriseExists, + formatUserRow, + normalizeOssRegion, + buildOssPublicUrl, + normalizeAvatarOssKey, + normalizeProfileMediaUrl, + normalizeProjectOssKey, + getManagementEnterpriseId, + appendEnterpriseScope, + readModelPricePayload, + getModelPriceById, + getPeriodStart, + buildDailyTrend, + clampPositiveInteger, + clampNonNegativeInteger, + generateOrderNo, + GENERATION_TASK_STATUSES, + GENERATION_TASK_TYPES, + clampTaskProgress, + serializeTaskParams, + parseTaskParams, + formatGenerationTaskRow, + normalizeGenerationTaskPayload, + requireOwnedProject, + upsertGenerationTask, +}; diff --git a/src/routes/conversations.js b/src/routes/conversations.js new file mode 100644 index 0000000..459b9b3 --- /dev/null +++ b/src/routes/conversations.js @@ -0,0 +1,141 @@ +const { requireAuth } = require("../auth"); +const { pool } = require("../db"); + +function registerConversationRoutes(router) { + router.get("/conversations", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const { rows } = await pool.query( + `SELECT id, title, mode, created_at, updated_at + FROM conversations + WHERE user_id = $1 + ORDER BY updated_at DESC + LIMIT 100`, + [userId] + ); + res.json({ conversations: rows.map(formatRow) }); + } catch (err) { + console.error("[conversations] list failed:", err.message); + res.status(500).json({ error: "获取对话列表失败" }); + } + }); + + router.post("/conversations", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const { title = "新对话", mode = "chat", messages } = req.body || {}; + const safeMessages = Array.isArray(messages) ? JSON.stringify(messages) : "[]"; + const { rows } = await pool.query( + `INSERT INTO conversations (user_id, title, mode, messages_json) + VALUES ($1, $2, $3, $4) + RETURNING id, title, mode, created_at, updated_at`, + [userId, String(title).slice(0, 200), String(mode || "chat").slice(0, 20), safeMessages] + ); + res.status(201).json({ conversation: formatRow(rows[0]) }); + } catch (err) { + console.error("[conversations] create failed:", err.message); + res.status(500).json({ error: "创建对话失败" }); + } + }); + + router.get("/conversations/:id", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const convId = Number(req.params.id); + if (!Number.isFinite(convId)) { + return res.status(400).json({ error: "无效的对话 ID" }); + } + const { rows } = await pool.query( + `SELECT id, title, mode, messages_json, created_at, updated_at + FROM conversations + WHERE id = $1 AND user_id = $2`, + [convId, userId] + ); + if (rows.length === 0) { + return res.status(404).json({ error: "对话不存在" }); + } + const row = rows[0]; + let messages = []; + try { messages = JSON.parse(row.messages_json || "[]"); } catch {} + res.json({ + conversation: { + ...formatRow(row), + messages, + }, + }); + } catch (err) { + console.error("[conversations] get failed:", err.message); + res.status(500).json({ error: "获取对话失败" }); + } + }); + + router.put("/conversations/:id", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const convId = Number(req.params.id); + if (!Number.isFinite(convId)) { + return res.status(400).json({ error: "无效的对话 ID" }); + } + const { title, messages } = req.body || {}; + const sets = []; + const params = [convId, userId]; + let idx = 3; + if (title !== undefined) { + sets.push(`title = $${idx++}`); + params.push(String(title).slice(0, 200)); + } + if (messages !== undefined) { + sets.push(`messages_json = $${idx++}`); + params.push(JSON.stringify(messages)); + } + if (sets.length === 0) { + return res.status(400).json({ error: "没有需要更新的字段" }); + } + sets.push("updated_at = NOW()"); + const { rowCount } = await pool.query( + `UPDATE conversations SET ${sets.join(", ")} WHERE id = $1 AND user_id = $2`, + params + ); + if (rowCount === 0) { + return res.status(404).json({ error: "对话不存在" }); + } + res.json({ success: true }); + } catch (err) { + console.error("[conversations] update failed:", err.message); + res.status(500).json({ error: "更新对话失败" }); + } + }); + + router.delete("/conversations/:id", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const convId = Number(req.params.id); + if (!Number.isFinite(convId)) { + return res.status(400).json({ error: "无效的对话 ID" }); + } + const { rowCount } = await pool.query( + "DELETE FROM conversations WHERE id = $1 AND user_id = $2", + [convId, userId] + ); + if (rowCount === 0) { + return res.status(404).json({ error: "对话不存在" }); + } + res.json({ success: true }); + } catch (err) { + console.error("[conversations] delete failed:", err.message); + res.status(500).json({ error: "删除对话失败" }); + } + }); +} + +function formatRow(row) { + return { + id: row.id, + title: row.title, + mode: row.mode, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +module.exports = { registerConversationRoutes }; diff --git a/src/routes/drafts.js b/src/routes/drafts.js new file mode 100644 index 0000000..6b1ff5c --- /dev/null +++ b/src/routes/drafts.js @@ -0,0 +1,109 @@ +"use strict"; + +const { requireAuth, pool } = require("./context"); + +function cleanText(value, maxLength) { + return String(value || "").trim().slice(0, maxLength); +} + +function safeJsonString(value, fallback) { + if (value === undefined) return JSON.stringify(fallback); + try { + return JSON.stringify(value ?? fallback); + } catch { + return JSON.stringify(fallback); + } +} + +function parseJson(value, fallback) { + if (!value || typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function formatDraft(row) { + return { + id: Number(row.id), + scope: row.scope, + targetId: row.target_id, + payload: parseJson(row.payload_json, {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function registerDraftRoutes(router) { + router.get("/drafts", requireAuth, async (req, res) => { + try { + const scope = cleanText(req.query.scope, 64); + const targetId = cleanText(req.query.targetId ?? req.query.target_id, 128); + const params = [req.user.id]; + const where = ["user_id = $1"]; + if (scope) { + params.push(scope); + where.push(`scope = $${params.length}`); + } + if (targetId) { + params.push(targetId); + where.push(`target_id = $${params.length}`); + } + const { rows } = await pool.query( + ` + SELECT * + FROM web_drafts + WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC + LIMIT 100 + `, + params, + ); + res.json({ drafts: rows.map(formatDraft) }); + } catch (err) { + console.error("[drafts] list failed:", err.message); + res.status(500).json({ error: "Failed to load drafts" }); + } + }); + + router.put("/drafts", requireAuth, async (req, res) => { + const scope = cleanText(req.body?.scope, 64); + const targetId = cleanText(req.body?.targetId ?? req.body?.target_id, 128) || "default"; + if (!scope) return res.status(400).json({ error: "Missing draft scope" }); + + try { + const { rows } = await pool.query( + ` + INSERT INTO web_drafts (user_id, scope, target_id, payload_json) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, scope, target_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = NOW() + RETURNING * + `, + [req.user.id, scope, targetId, safeJsonString(req.body?.payload, {})], + ); + res.json({ draft: formatDraft(rows[0]) }); + } catch (err) { + console.error("[drafts] upsert failed:", err.message); + res.status(500).json({ error: "Failed to save draft" }); + } + }); + + router.delete("/drafts/:id", requireAuth, async (req, res) => { + try { + const { rowCount } = await pool.query("DELETE FROM web_drafts WHERE id = $1 AND user_id = $2", [ + req.params.id, + req.user.id, + ]); + if (!rowCount) return res.status(404).json({ error: "Draft not found" }); + res.json({ success: true }); + } catch (err) { + console.error("[drafts] delete failed:", err.message); + res.status(500).json({ error: "Failed to delete draft" }); + } + }); +} + +module.exports = { registerDraftRoutes }; diff --git a/src/routes/ecommerce.js b/src/routes/ecommerce.js new file mode 100644 index 0000000..f81f0d3 --- /dev/null +++ b/src/routes/ecommerce.js @@ -0,0 +1,133 @@ +const { requireAuth, pool } = require("./context"); +const { resolveTextProvider } = require("../aiProviderRouter"); +const { keyManager, releaseLease } = require("../keyManager"); +const { preauthorizeCall } = require("../billing"); + +function extractJson(text) { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = fenced ? fenced[1].trim() : text.trim(); + const start = raw.search(/[\[{]/); + try { return JSON.parse(start >= 0 ? raw.slice(start) : raw); } + catch { throw new Error("AI 返回内容不是有效的 JSON"); } +} + +function registerEcommerceRoutes(router) { + router.post("/ai/ecommerce/video-plan", requireAuth, async (req, res) => { + const { productImageUrls, manualText, platform, market, language, aspectRatio, durationSeconds, style, needVoiceover, needSubtitle, conversionFocus } = req.body; + if (!productImageUrls?.length && !manualText) { + return res.status(400).json({ error: "Missing productImageUrls or manualText" }); + } + + const config = { + platform: platform || "拖音电商", + market: market || "中国", + language: language || "中文", + aspectRatio: aspectRatio || "9:16", + durationSeconds: durationSeconds || 10, + style: style || "痛点解决", + needVoiceover: needVoiceover !== false, + needSubtitle: needSubtitle !== false, + conversionFocus: conversionFocus || "conversion", + }; + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); + const sendStep = (step, data) => res.write("data: " + JSON.stringify({ step, ...data }) + "\n\n"); + + let slotResult; + try { + const providerConfig = resolveTextProvider("qwen-max"); + const preauth = await preauthorizeCall(req.user.id, providerConfig.provider); + if (!preauth.authorized) { + res.write("data: " + JSON.stringify({ error: preauth.message }) + "\n\n"); + res.end(); return; + } + slotResult = await keyManager.acquireKey(providerConfig.provider, req.user, preauth, { waitTimeoutMs: 15000 }); + if (!slotResult) { + res.write("data: " + JSON.stringify({ error: "AI 服务繁忙" }) + "\n\n"); + res.end(); return; + } + + const chatOnce = async (sys, user, model) => { + const p = resolveTextProvider(model || "qwen-max"); + const r = await fetch(p.baseUrl + p.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey }, + body: JSON.stringify({ model: p.model, messages: [{ role: "system", content: sys }, { role: "user", content: user }], stream: false, temperature: 0.4, max_tokens: 4096 }), + signal: abortController.signal, + }); + const j = await r.json(); + return j.choices?.[0]?.message?.content || j.content || ""; + }; + + const visionChat = async (sys, text, imageUrls) => { + const p = resolveTextProvider("qwen3.6-plus"); + const content = [...imageUrls.map(u => ({ type: "image_url", image_url: { url: u } })), { type: "text", text }]; + const r = await fetch(p.baseUrl + p.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey }, + body: JSON.stringify({ model: p.model, messages: [{ role: "system", content: sys }, { role: "user", content }], stream: false, temperature: 0.3, max_tokens: 4096 }), + signal: abortController.signal, + }); + const j = await r.json(); + return j.choices?.[0]?.message?.content || j.content || ""; + }; + + const cfgStr = "平台:" + config.platform + " 比例:" + config.aspectRatio + " 时长:" + config.durationSeconds + "s 风格:" + config.style; + + sendStep("analyze", { status: "running" }); + let imageDesc = ""; + if (productImageUrls?.length) { + imageDesc = await visionChat("你是电商产品图片分析专家。分析产品图片,识别主体、外观、颜色、材质、卖点。简洁中文描述。", "分析这些产品图片。", productImageUrls); + } + sendStep("analyze", { status: "done" }); + + sendStep("summary", { status: "running" }); + const summaryRaw = await chatOnce('商品信息理解专家。输出JSON:{"product_name":"","category":"","appearance":"","materials":[],"colors":[],"core_features":[],"target_users":[],"usage_scenarios":[],"selling_points":[],"risk_notes":[]}', "图片描述:" + (imageDesc || "无") + "\n说明:" + (manualText || "无")); + const summary = extractJson(summaryRaw); + sendStep("summary", { status: "done", data: summary }); + + sendStep("selling", { status: "running" }); + const sellingRaw = await chatOnce('卖点提炼专家。JSON:{"primary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"secondary_selling_points":[],"unsupported_claims":[],"compliance_warnings":[]}', JSON.stringify(summary)); + const selling = extractJson(sellingRaw); + sendStep("selling", { status: "done", data: selling }); + + sendStep("creative", { status: "running" }); + const creativeRaw = await chatOnce('广告创意专家。生成3个方向。JSON:{"creative_options":[{"creative_id":"A","creative_type":"","hook":"","target_user":"","main_message":"","emotional_tone":"","recommended_platform":"","reason":""}]}', "卖点:" + JSON.stringify(selling.primary_selling_points) + "\n配置:" + cfgStr); + const creativeOptions = (extractJson(creativeRaw).creative_options || []); + sendStep("creative", { status: "done", data: creativeOptions }); + + sendStep("storyboard", { status: "running" }); + const sbRaw = await chatOnce('分镜师。JSON:{"video_title":"","duration":"","aspect_ratio":"","target_platform":"","language":"","scenes":[{"scene_id":1,"duration":"3s","scene_goal":"","visual_description":"","product_focus":"","camera_movement":"","background":"","lighting":"","subtitle":"","voiceover":"","transition":""}]}', "创意:" + JSON.stringify(creativeOptions[0] || {}) + "\n商品:" + JSON.stringify(summary) + "\n配置:" + cfgStr); + const storyboard = extractJson(sbRaw); + sendStep("storyboard", { status: "done", data: storyboard }); + + sendStep("prompts", { status: "running" }); + const promptsRaw = await chatOnce('视频提示词工程师。JSON数组:[{"scene_id":1,"positive_prompt":"","negative_prompt":"","reference_requirements":"","consistency_rules":"","text_overlay":""}]', "分镜:" + JSON.stringify(storyboard.scenes) + "\n外观:" + (summary.appearance || "")); + const videoPrompts = extractJson(promptsRaw); + sendStep("prompts", { status: "done", data: videoPrompts }); + + sendStep("compliance", { status: "running" }); + const compRaw = await chatOnce('合规专家。JSON:{"risk_level":"low","issues":[{"field":"","problem":"","suggestion":""}],"allow_video_generation":true}', "卖点:" + JSON.stringify(selling) + "\n文案:" + JSON.stringify((storyboard.scenes || []).map(s => s.subtitle))); + const compliance = extractJson(compRaw); + sendStep("compliance", { status: "done", data: compliance }); + + res.write("data: " + JSON.stringify({ step: "done", plan: { summary, selling, creatives: creativeOptions, storyboard, videoPrompts, compliance } }) + "\n\n"); + res.end(); + releaseLease(slotResult); + } catch (err) { + releaseLease(slotResult); + if (err.name === "AbortError") { res.end(); return; } + console.error("[ai/ecommerce/video-plan] error:", err.message); + res.write("data: " + JSON.stringify({ error: err.message || "策划失败" }) + "\n\n"); + res.end(); + } + }); +} + +module.exports = { registerEcommerceRoutes }; diff --git a/src/routes/enterprise.js b/src/routes/enterprise.js new file mode 100644 index 0000000..88dd914 --- /dev/null +++ b/src/routes/enterprise.js @@ -0,0 +1,492 @@ +const { + requireAuth, + requireEnterpriseAdmin, + distributeCredits, + getEnterpriseFinancials, + getEnterpriseName, + pool, + getPeriodStart, + buildDailyTrend, + generateOrderNo, + clampPositiveInteger, + clampNonNegativeInteger, +} = require("./context"); + +function registerEnterpriseRoutes(router) { + // ── Enterprise: Dashboard & Financials ──────────────────────────────── + + router.get("/enterprise/dashboard", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const financials = await getEnterpriseFinancials(req.user.enterpriseId); + const { + rows: [countRow], + } = await pool.query( + "SELECT COUNT(*) AS count FROM users WHERE enterprise_id = $1 AND enabled = 1 AND is_enterprise_admin = 0", + [req.user.enterpriseId], + ); + + res.json({ + enterpriseName: req.user.enterpriseName, + enterpriseCode: req.user.enterpriseCode, + balanceCents: financials.balanceCents, + activePackages: financials.activePackages, + subAccountCount: Number(countRow?.count || 0), + }); + }); + + router.get("/enterprise/financials", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const financials = await getEnterpriseFinancials(req.user.enterpriseId); + res.json({ + balanceCents: financials.balanceCents, + activePackages: financials.activePackages.map((p) => ({ + id: p.id, + packageName: p.package_name, + remainingImage: p.remaining_image, + remainingVideo: p.remaining_video, + remainingText: p.remaining_text, + expiresAt: p.expires_at, + activatedAt: p.activated_at, + })), + recentTransactions: financials.recentTransactions.map((t) => ({ + id: t.id, + type: t.type, + amountCents: t.amount_cents, + balanceAfterCents: t.balance_after_cents, + description: t.description, + createdAt: t.created_at, + })), + }); + }); + + router.get("/enterprise/usage/summary", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { period = "30d" } = req.query; + const periodStart = getPeriodStart(period); + const ledgerDateJoin = periodStart ? `AND cl.created_at >= ${periodStart}` : ""; + const ledgerDateWhere = periodStart ? `AND created_at >= ${periodStart}` : ""; + const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200); + + try { + const { + rows: [enterprise], + } = await pool.query( + "SELECT id, name, balance_cents FROM enterprises WHERE id = $1 AND enabled = 1 LIMIT 1", + [req.user.enterpriseId], + ); + if (!enterprise) return res.status(404).json({ error: "企业不存在或已禁用" }); + + const { rows: members } = await pool.query( + ` + SELECT + u.id AS user_id, + u.username, + COALESCE(em.role, CASE WHEN u.is_enterprise_admin = 1 THEN 'admin' ELSE 'employee' END) AS member_role, + COALESCE(SUM(CASE WHEN cl.status IN ('reserved', 'charged') THEN cl.amount_cents ELSE 0 END), 0) AS used_cents, + COUNT(CASE WHEN cl.status IN ('reserved', 'charged') THEN 1 END) AS task_count, + MAX(cl.created_at) AS last_used_at + FROM users u + LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id + LEFT JOIN credit_ledger cl ON cl.enterprise_id = u.enterprise_id AND cl.user_id = u.id ${ledgerDateJoin} + WHERE u.enterprise_id = $1 AND u.enabled = 1 + GROUP BY u.id, u.username, em.role, u.is_enterprise_admin + ORDER BY used_cents DESC, u.id ASC + `, + [req.user.enterpriseId], + ); + + const { rows: modelBreakdown } = await pool.query( + ` + SELECT + COALESCE(model, 'unknown') AS model, + COALESCE(SUM(amount_cents), 0) AS used_cents, + COUNT(*) AS task_count + FROM credit_ledger + WHERE enterprise_id = $1 + AND status IN ('reserved', 'charged') + ${ledgerDateWhere} + GROUP BY COALESCE(model, 'unknown') + ORDER BY used_cents DESC + LIMIT 50 + `, + [req.user.enterpriseId], + ); + + const { + rows: [totalRow], + } = await pool.query( + ` + SELECT COALESCE(SUM(amount_cents), 0) AS total_used_cents + FROM credit_ledger + WHERE enterprise_id = $1 + AND status IN ('reserved', 'charged') + ${ledgerDateWhere} + `, + [req.user.enterpriseId], + ); + + const { rows: records } = await pool.query( + ` + SELECT cl.*, u.username, gt.params_json AS task_params_json + FROM credit_ledger cl + LEFT JOIN users u ON u.id = cl.user_id + LEFT JOIN generation_tasks gt ON gt.id = cl.task_id + WHERE cl.enterprise_id = $1 + AND cl.status IN ('reserved', 'charged') + ${periodStart ? `AND cl.created_at >= ${periodStart}` : ""} + ORDER BY cl.created_at DESC + LIMIT $2 + `, + [req.user.enterpriseId, recordsLimit], + ); + + const { rows: trendRows } = await pool.query( + ` + SELECT + to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day, + COUNT(*) AS task_count, + COALESCE(SUM(amount_cents), 0) AS used_cents + FROM credit_ledger + WHERE enterprise_id = $1 + AND status IN ('reserved', 'charged') + AND created_at >= NOW() - INTERVAL '6 days' + GROUP BY day + ORDER BY day ASC + `, + [req.user.enterpriseId], + ); + const dailyTrend = buildDailyTrend(trendRows); + + res.json({ + enterpriseId: String(enterprise.id), + enterpriseName: enterprise.name, + balanceCents: Number(enterprise.balance_cents || 0), + totalUsedCents: Number(totalRow?.total_used_cents || 0), + members: members.map((row) => ({ + userId: Number(row.user_id), + username: row.username, + role: row.member_role || "employee", + usedCents: Number(row.used_cents || 0), + taskCount: Number(row.task_count || 0), + lastUsedAt: row.last_used_at || null, + })), + modelBreakdown: modelBreakdown.map((row) => ({ + model: row.model, + usedCents: Number(row.used_cents || 0), + taskCount: Number(row.task_count || 0), + })), + records: records.map((row) => { + let prompt = ""; + try { + const params = row.task_params_json ? JSON.parse(row.task_params_json) : {}; + prompt = params.prompt || ""; + } catch {} + return { + id: String(row.id), + userId: row.user_id == null ? "" : Number(row.user_id), + username: row.username || "", + model: row.model || "", + taskType: row.task_type, + resolution: row.resolution || null, + durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), + amountCents: Number(row.amount_cents || 0), + prompt, + status: row.status, + createdAt: row.created_at, + }; + }), + }); + } catch (error) { + console.error("[enterprise/usage/summary] failed", error); + res.status(500).json({ error: "企业用量汇总加载失败" }); + } + }); + + router.get("/enterprise/usage/records", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const limit = clampPositiveInteger(req.query.limit, 50, 200); + const offset = clampNonNegativeInteger(req.query.offset, 0, 100000); + const userId = req.query.userId || req.query.user_id; + const model = String(req.query.model || "").trim(); + const dateFrom = req.query.from || req.query.date_from; + const dateTo = req.query.to || req.query.date_to; + const where = ["cl.enterprise_id = $1"]; + const params = [req.user.enterpriseId]; + + if (userId) { + params.push(userId); + where.push(`cl.user_id = $${params.length}`); + } + if (model) { + params.push(model); + where.push(`cl.model = $${params.length}`); + } + if (dateFrom) { + params.push(`${dateFrom}T00:00:00.000Z`); + where.push(`cl.created_at >= $${params.length}`); + } + if (dateTo) { + params.push(`${dateTo}T23:59:59.999Z`); + where.push(`cl.created_at <= $${params.length}`); + } + + const whereSql = `WHERE ${where.join(" AND ")}`; + + try { + const { + rows: [countRow], + } = await pool.query(`SELECT COUNT(*) AS total FROM credit_ledger cl ${whereSql}`, params); + const { rows } = await pool.query( + ` + SELECT cl.*, u.username, gt.params_json AS task_params_json + FROM credit_ledger cl + LEFT JOIN users u ON u.id = cl.user_id + LEFT JOIN generation_tasks gt ON gt.id = cl.task_id + ${whereSql} + ORDER BY cl.created_at DESC + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} + `, + [...params, limit, offset], + ); + + res.json({ + items: rows.map((row) => { + let prompt = ""; + try { + const params = row.task_params_json ? JSON.parse(row.task_params_json) : {}; + prompt = params.prompt || ""; + } catch {} + return { + id: String(row.id), + userId: row.user_id == null ? "" : Number(row.user_id), + username: row.username || "", + model: row.model || "", + taskType: row.task_type, + resolution: row.resolution || null, + durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), + amountCents: Number(row.amount_cents || 0), + prompt, + status: row.status, + createdAt: row.created_at, + }; + }), + total: Number(countRow?.total || 0), + limit, + offset, + }); + } catch (error) { + console.error("[enterprise/usage/records] failed", error); + res.status(500).json({ error: "企业用量记录加载失败" }); + } + }); + + router.post("/enterprise/recharge", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { amountCents, paymentMethod = "wechat" } = req.body; + if (!amountCents || amountCents <= 0) + return res.status(400).json({ error: "充值金额必须大于0" }); + + const orderNo = generateOrderNo(); + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + await pool.query( + ` + INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, payment_method, status) + VALUES ($1, $2, $3, 'recharge', $4, $5, 'pending') + `, + [orderNo, req.user.enterpriseId, enterpriseName, Number(amountCents), paymentMethod], + ); + + res.json({ orderNo, amountCents, paymentMethod }); + } catch (error) { + console.error("[enterprise/recharge] failed", error); + res.status(500).json({ error: "创建充值订单失败" }); + } + }); + + router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { userId, amountCents, distributions } = req.body; + + try { + if (distributions && Array.isArray(distributions)) { + for (const d of distributions) { + if (!d.userId || !d.amountCents || d.amountCents <= 0) { + return res + .status(400) + .json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" }); + } + await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id); + } + res.json({ success: true, count: distributions.length }); + } else if (userId && amountCents) { + if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); + const result = await distributeCredits( + req.user.enterpriseId, + userId, + amountCents, + req.user.id, + ); + res.json({ success: true, ...result }); + } else { + return res.status(400).json({ error: "缺少分发参数" }); + } + } catch (error) { + console.error("[enterprise/distribute] failed", error); + res.status(400).json({ error: "分发参数处理失败" }); + } + }); + + router.get( + "/enterprise/employee-consumption", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { period = "30d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClause = periodStart ? `AND acl.created_at >= ${periodStart}` : ""; + + const { rows } = await pool.query( + ` + SELECT + u.id AS user_id, + u.username, + u.balance_cents AS current_balance_cents, + COUNT(acl.id) AS total_calls, + COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, + MAX(acl.created_at) AS last_active + FROM users u + LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' + WHERE u.enterprise_id = $1 AND u.enabled = 1 AND u.is_enterprise_admin = 0 ${whereClause} + GROUP BY u.id, u.username, u.balance_cents + ORDER BY total_cost_cents DESC + `, + [req.user.enterpriseId], + ); + + res.json( + rows.map((r) => ({ + userId: Number(r.user_id), + username: r.username, + currentBalanceCents: Number(r.current_balance_cents), + totalCalls: Number(r.total_calls), + totalCostCents: Number(r.total_cost_cents), + lastActive: r.last_active, + })), + ); + }, + ); + + router.post( + "/enterprise/purchase-package", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { packageId, paymentMethod = "wechat" } = req.body; + if (!packageId) return res.status(400).json({ error: "缺少套餐ID" }); + + const { + rows: [pkg], + } = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [packageId]); + if (!pkg) return res.status(404).json({ error: "套餐不存在或已下架" }); + + const orderNo = generateOrderNo(); + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + await pool.query( + ` + INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, package_id, payment_method, status) + VALUES ($1, $2, $3, 'package', $4, $5, $6, 'pending') + `, + [ + orderNo, + req.user.enterpriseId, + enterpriseName, + pkg.price_cents, + packageId, + paymentMethod, + ], + ); + + res.json({ orderNo, amountCents: pkg.price_cents, packageId, paymentMethod }); + } catch (error) { + console.error("[enterprise/purchase-package] failed", error); + res.status(500).json({ error: "创建套餐订单失败" }); + } + }, + ); + + // ── Enterprise: Invoices ────────────────────────────────────────────── + + router.post( + "/enterprise/invoice-apply", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { paymentOrderId, type = "general", title, taxNo } = req.body; + if (!title) return res.status(400).json({ error: "缺少发票抬头" }); + + let amountCents = 0; + if (paymentOrderId) { + const { + rows: [order], + } = await pool.query( + "SELECT * FROM payment_orders WHERE id = $1 AND enterprise_id = $2 AND status = $3", + [paymentOrderId, req.user.enterpriseId, "paid"], + ); + if (!order) return res.status(404).json({ error: "支付订单不存在或未支付" }); + amountCents = order.amount_cents; + } + + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + const { + rows: [row], + } = await pool.query( + ` + INSERT INTO invoices (enterprise_id, enterprise_name, payment_order_id, type, title, tax_no, amount_cents, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending') + RETURNING id + `, + [ + req.user.enterpriseId, + enterpriseName, + paymentOrderId || null, + type, + title, + taxNo || null, + amountCents, + ], + ); + + res.json({ id: row.id, success: true }); + } catch (error) { + console.error("[enterprise/invoice-apply] failed", error); + res.status(500).json({ error: "申请发票失败" }); + } + }, + ); + + router.get("/enterprise/invoices", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { rows } = await pool.query( + "SELECT * FROM invoices WHERE enterprise_id = $1 ORDER BY id DESC", + [req.user.enterpriseId], + ); + res.json( + rows.map((row) => ({ + id: Number(row.id), + type: row.type, + title: row.title, + taxNo: row.tax_no, + amountCents: row.amount_cents, + status: row.status, + invoiceNo: row.invoice_no, + invoiceUrl: row.invoice_url, + issuedAt: row.issued_at, + createdAt: row.created_at, + })), + ); + }); +} + +module.exports = { + registerEnterpriseRoutes, +}; diff --git a/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 b/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 new file mode 100644 index 0000000..e7f23c3 --- /dev/null +++ b/src/routes/enterprise.js.enterprise-usage-fix.20260526013026 @@ -0,0 +1,456 @@ +const { + requireAuth, + requireEnterpriseAdmin, + distributeCredits, + getEnterpriseFinancials, + getEnterpriseName, + pool, + getPeriodStart, + generateOrderNo, + clampPositiveInteger, + clampNonNegativeInteger, +} = require("./context"); + +function registerEnterpriseRoutes(router) { + // ── Enterprise: Dashboard & Financials ──────────────────────────────── + + router.get("/enterprise/dashboard", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const financials = await getEnterpriseFinancials(req.user.enterpriseId); + const { + rows: [countRow], + } = await pool.query( + "SELECT COUNT(*) AS count FROM users WHERE enterprise_id = $1 AND enabled = 1 AND is_enterprise_admin = 0", + [req.user.enterpriseId], + ); + + res.json({ + enterpriseName: req.user.enterpriseName, + enterpriseCode: req.user.enterpriseCode, + balanceCents: financials.balanceCents, + activePackages: financials.activePackages, + subAccountCount: Number(countRow?.count || 0), + }); + }); + + router.get("/enterprise/financials", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const financials = await getEnterpriseFinancials(req.user.enterpriseId); + res.json({ + balanceCents: financials.balanceCents, + activePackages: financials.activePackages.map((p) => ({ + id: p.id, + packageName: p.package_name, + remainingImage: p.remaining_image, + remainingVideo: p.remaining_video, + remainingText: p.remaining_text, + expiresAt: p.expires_at, + activatedAt: p.activated_at, + })), + recentTransactions: financials.recentTransactions.map((t) => ({ + id: t.id, + type: t.type, + amountCents: t.amount_cents, + balanceAfterCents: t.balance_after_cents, + description: t.description, + createdAt: t.created_at, + })), + }); + }); + + router.get("/enterprise/usage/summary", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { period = "30d" } = req.query; + const periodStart = getPeriodStart(period); + const ledgerDateJoin = periodStart ? `AND cl.created_at >= ${periodStart}` : ""; + const ledgerDateWhere = periodStart ? `AND created_at >= ${periodStart}` : ""; + const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200); + + try { + const { + rows: [enterprise], + } = await pool.query( + "SELECT id, name, balance_cents FROM enterprises WHERE id = $1 AND enabled = 1 LIMIT 1", + [req.user.enterpriseId], + ); + if (!enterprise) return res.status(404).json({ error: "企业不存在或已禁用" }); + + const { rows: members } = await pool.query( + ` + SELECT + u.id AS user_id, + u.username, + COALESCE(em.role, CASE WHEN u.is_enterprise_admin = 1 THEN 'admin' ELSE 'employee' END) AS role, + COALESCE(SUM(CASE WHEN cl.status IN ('reserved', 'charged') THEN cl.amount_cents ELSE 0 END), 0) AS used_cents, + COUNT(CASE WHEN cl.status IN ('reserved', 'charged') THEN 1 END) AS task_count, + MAX(cl.created_at) AS last_used_at + FROM users u + LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id + LEFT JOIN credit_ledger cl ON cl.enterprise_id = u.enterprise_id AND cl.user_id = u.id ${ledgerDateJoin} + WHERE u.enterprise_id = $1 AND u.enabled = 1 + GROUP BY u.id, u.username, role + ORDER BY used_cents DESC, u.id ASC + `, + [req.user.enterpriseId], + ); + + const { rows: modelBreakdown } = await pool.query( + ` + SELECT + COALESCE(model, 'unknown') AS model, + COALESCE(SUM(amount_cents), 0) AS used_cents, + COUNT(*) AS task_count + FROM credit_ledger + WHERE enterprise_id = $1 + AND status IN ('reserved', 'charged') + ${ledgerDateWhere} + GROUP BY COALESCE(model, 'unknown') + ORDER BY used_cents DESC + LIMIT 50 + `, + [req.user.enterpriseId], + ); + + const { + rows: [totalRow], + } = await pool.query( + ` + SELECT COALESCE(SUM(amount_cents), 0) AS total_used_cents + FROM credit_ledger + WHERE enterprise_id = $1 + AND status IN ('reserved', 'charged') + ${ledgerDateWhere} + `, + [req.user.enterpriseId], + ); + + const { rows: records } = await pool.query( + ` + SELECT cl.*, u.username + FROM credit_ledger cl + LEFT JOIN users u ON u.id = cl.user_id + WHERE cl.enterprise_id = $1 + AND cl.status IN ('reserved', 'charged') + ${periodStart ? `AND cl.created_at >= ${periodStart}` : ""} + ORDER BY cl.created_at DESC + LIMIT $2 + `, + [req.user.enterpriseId, recordsLimit], + ); + + res.json({ + enterpriseId: String(enterprise.id), + enterpriseName: enterprise.name, + balanceCents: Number(enterprise.balance_cents || 0), + totalUsedCents: Number(totalRow?.total_used_cents || 0), + members: members.map((row) => ({ + userId: Number(row.user_id), + username: row.username, + role: row.role || "employee", + usedCents: Number(row.used_cents || 0), + taskCount: Number(row.task_count || 0), + lastUsedAt: row.last_used_at || null, + })), + modelBreakdown: modelBreakdown.map((row) => ({ + model: row.model, + usedCents: Number(row.used_cents || 0), + taskCount: Number(row.task_count || 0), + })), + records: records.map((row) => ({ + id: String(row.id), + userId: row.user_id == null ? "" : Number(row.user_id), + username: row.username || "", + model: row.model || "", + taskType: row.task_type, + resolution: row.resolution || null, + durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), + amountCents: Number(row.amount_cents || 0), + status: row.status, + createdAt: row.created_at, + })), + }); + } catch (error) { + console.error("[enterprise/usage/summary] failed", error); + res.status(500).json({ error: "企业用量汇总加载失败" }); + } + }); + + router.get("/enterprise/usage/records", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const limit = clampPositiveInteger(req.query.limit, 50, 200); + const offset = clampNonNegativeInteger(req.query.offset, 0, 100000); + const userId = req.query.userId || req.query.user_id; + const model = String(req.query.model || "").trim(); + const dateFrom = req.query.from || req.query.date_from; + const dateTo = req.query.to || req.query.date_to; + const where = ["cl.enterprise_id = $1"]; + const params = [req.user.enterpriseId]; + + if (userId) { + params.push(userId); + where.push(`cl.user_id = $${params.length}`); + } + if (model) { + params.push(model); + where.push(`cl.model = $${params.length}`); + } + if (dateFrom) { + params.push(`${dateFrom}T00:00:00.000Z`); + where.push(`cl.created_at >= $${params.length}`); + } + if (dateTo) { + params.push(`${dateTo}T23:59:59.999Z`); + where.push(`cl.created_at <= $${params.length}`); + } + + const whereSql = `WHERE ${where.join(" AND ")}`; + + try { + const { + rows: [countRow], + } = await pool.query(`SELECT COUNT(*) AS total FROM credit_ledger cl ${whereSql}`, params); + const { rows } = await pool.query( + ` + SELECT cl.*, u.username + FROM credit_ledger cl + LEFT JOIN users u ON u.id = cl.user_id + ${whereSql} + ORDER BY cl.created_at DESC + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} + `, + [...params, limit, offset], + ); + + res.json({ + items: rows.map((row) => ({ + id: String(row.id), + userId: row.user_id == null ? "" : Number(row.user_id), + username: row.username || "", + model: row.model || "", + taskType: row.task_type, + resolution: row.resolution || null, + durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds), + amountCents: Number(row.amount_cents || 0), + status: row.status, + createdAt: row.created_at, + })), + total: Number(countRow?.total || 0), + limit, + offset, + }); + } catch (error) { + console.error("[enterprise/usage/records] failed", error); + res.status(500).json({ error: "企业用量记录加载失败" }); + } + }); + + router.post("/enterprise/recharge", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { amountCents, paymentMethod = "wechat" } = req.body; + if (!amountCents || amountCents <= 0) + return res.status(400).json({ error: "充值金额必须大于0" }); + + const orderNo = generateOrderNo(); + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + await pool.query( + ` + INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, payment_method, status) + VALUES ($1, $2, $3, 'recharge', $4, $5, 'pending') + `, + [orderNo, req.user.enterpriseId, enterpriseName, Number(amountCents), paymentMethod], + ); + + res.json({ orderNo, amountCents, paymentMethod }); + } catch (error) { + console.error("[enterprise/recharge] failed", error); + res.status(500).json({ error: "创建充值订单失败" }); + } + }); + + router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { userId, amountCents, distributions } = req.body; + + try { + if (distributions && Array.isArray(distributions)) { + for (const d of distributions) { + if (!d.userId || !d.amountCents || d.amountCents <= 0) { + return res + .status(400) + .json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" }); + } + await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id); + } + res.json({ success: true, count: distributions.length }); + } else if (userId && amountCents) { + if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); + const result = await distributeCredits( + req.user.enterpriseId, + userId, + amountCents, + req.user.id, + ); + res.json({ success: true, ...result }); + } else { + return res.status(400).json({ error: "缺少分发参数" }); + } + } catch (error) { + console.error("[enterprise/distribute] failed", error); + res.status(400).json({ error: "分发参数处理失败" }); + } + }); + + router.get( + "/enterprise/employee-consumption", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { period = "30d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClause = periodStart ? `AND acl.created_at >= ${periodStart}` : ""; + + const { rows } = await pool.query( + ` + SELECT + u.id AS user_id, + u.username, + u.balance_cents AS current_balance_cents, + COUNT(acl.id) AS total_calls, + COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, + MAX(acl.created_at) AS last_active + FROM users u + LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' + WHERE u.enterprise_id = $1 AND u.enabled = 1 AND u.is_enterprise_admin = 0 ${whereClause} + GROUP BY u.id, u.username, u.balance_cents + ORDER BY total_cost_cents DESC + `, + [req.user.enterpriseId], + ); + + res.json( + rows.map((r) => ({ + userId: Number(r.user_id), + username: r.username, + currentBalanceCents: Number(r.current_balance_cents), + totalCalls: Number(r.total_calls), + totalCostCents: Number(r.total_cost_cents), + lastActive: r.last_active, + })), + ); + }, + ); + + router.post( + "/enterprise/purchase-package", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { packageId, paymentMethod = "wechat" } = req.body; + if (!packageId) return res.status(400).json({ error: "缺少套餐ID" }); + + const { + rows: [pkg], + } = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [packageId]); + if (!pkg) return res.status(404).json({ error: "套餐不存在或已下架" }); + + const orderNo = generateOrderNo(); + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + await pool.query( + ` + INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, package_id, payment_method, status) + VALUES ($1, $2, $3, 'package', $4, $5, $6, 'pending') + `, + [ + orderNo, + req.user.enterpriseId, + enterpriseName, + pkg.price_cents, + packageId, + paymentMethod, + ], + ); + + res.json({ orderNo, amountCents: pkg.price_cents, packageId, paymentMethod }); + } catch (error) { + console.error("[enterprise/purchase-package] failed", error); + res.status(500).json({ error: "创建套餐订单失败" }); + } + }, + ); + + // ── Enterprise: Invoices ────────────────────────────────────────────── + + router.post( + "/enterprise/invoice-apply", + requireAuth, + requireEnterpriseAdmin, + async (req, res) => { + const { paymentOrderId, type = "general", title, taxNo } = req.body; + if (!title) return res.status(400).json({ error: "缺少发票抬头" }); + + let amountCents = 0; + if (paymentOrderId) { + const { + rows: [order], + } = await pool.query( + "SELECT * FROM payment_orders WHERE id = $1 AND enterprise_id = $2 AND status = $3", + [paymentOrderId, req.user.enterpriseId, "paid"], + ); + if (!order) return res.status(404).json({ error: "支付订单不存在或未支付" }); + amountCents = order.amount_cents; + } + + const enterpriseName = await getEnterpriseName(req.user.enterpriseId); + + try { + const { + rows: [row], + } = await pool.query( + ` + INSERT INTO invoices (enterprise_id, enterprise_name, payment_order_id, type, title, tax_no, amount_cents, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending') + RETURNING id + `, + [ + req.user.enterpriseId, + enterpriseName, + paymentOrderId || null, + type, + title, + taxNo || null, + amountCents, + ], + ); + + res.json({ id: row.id, success: true }); + } catch (error) { + console.error("[enterprise/invoice-apply] failed", error); + res.status(500).json({ error: "申请发票失败" }); + } + }, + ); + + router.get("/enterprise/invoices", requireAuth, requireEnterpriseAdmin, async (req, res) => { + const { rows } = await pool.query( + "SELECT * FROM invoices WHERE enterprise_id = $1 ORDER BY id DESC", + [req.user.enterpriseId], + ); + res.json( + rows.map((row) => ({ + id: Number(row.id), + type: row.type, + title: row.title, + taxNo: row.tax_no, + amountCents: row.amount_cents, + status: row.status, + invoiceNo: row.invoice_no, + invoiceUrl: row.invoice_url, + issuedAt: row.issued_at, + createdAt: row.created_at, + })), + ); + }); +} + +module.exports = { + registerEnterpriseRoutes, +}; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..57bd418 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,50 @@ +const express = require('express') +const { registerAuthRoutes } = require('./auth') +const { registerPriceRoutes, registerPackageRoutes, registerHealthRoutes } = require('./public') +const { registerKeyRoutes } = require('./keys') +const { registerAdminRoutes, registerAdminInvoiceRoutes } = require('./admin') +const { registerEnterpriseRoutes } = require('./enterprise') +const { registerUserRoutes } = require('./user') +const { registerUsageReportRoutes, registerAdminUsageRoutes, registerUsageSummaryRoutes } = require('./usage') +const { registerConfigRoutes } = require('./config') +const { registerPaymentRoutes } = require('./payment') +const { registerProjectRoutes } = require('./projects') +const { registerOssRoutes } = require('./oss') +const { registerCommunityRoutes, registerAdminCommunityRoutes } = require('./community') +const { registerAiRoutes } = require('./ai') +const { registerEcommerceRoutes } = require("./ecommerce") +const { registerConversationRoutes } = require('./conversations') +const { registerReportRoutes } = require('./reports') +const { registerAssetRoutes } = require('./assets') +const { registerNotificationRoutes } = require('./notifications') +const { registerDraftRoutes } = require('./drafts') + +const router = express.Router() + +registerAuthRoutes(router) +registerPriceRoutes(router) +registerKeyRoutes(router) +registerAdminRoutes(router) +registerPackageRoutes(router) +registerEnterpriseRoutes(router) +registerAdminInvoiceRoutes(router) +registerUserRoutes(router) +registerUsageReportRoutes(router) +registerAdminUsageRoutes(router) +registerConfigRoutes(router) +registerUsageSummaryRoutes(router) +registerPaymentRoutes(router) +registerProjectRoutes(router) +registerOssRoutes(router) +registerCommunityRoutes(router) +registerAdminCommunityRoutes(router) +registerAiRoutes(router) +registerEcommerceRoutes(router) +registerConversationRoutes(router) +registerReportRoutes(router) +registerAssetRoutes(router) +registerNotificationRoutes(router) +registerDraftRoutes(router) +registerHealthRoutes(router) + +module.exports = router diff --git a/src/routes/keys.js b/src/routes/keys.js new file mode 100644 index 0000000..c8692dc --- /dev/null +++ b/src/routes/keys.js @@ -0,0 +1,99 @@ +const { requireAuth, keyManager, preauthorizeCall } = require("./context"); + +function registerKeyRoutes(router) { + // ── Keys (with pre-authorization) ──────────────────────────────────── + + router.post("/keys/acquire", requireAuth, async (req, res) => { + const requestedWaitTimeoutMs = Number(req.body?.waitTimeoutMs); + const waitTimeoutMs = Number.isFinite(requestedWaitTimeoutMs) + ? Math.max(0, Math.min(Math.trunc(requestedWaitTimeoutMs), 5 * 60 * 1000)) + : 25_000; + const { provider } = req.body; + if (!provider) return res.status(400).json({ error: "缺少 provider" }); + + try { + // Pre-authorization check for all authenticated users + const preauth = await preauthorizeCall(req.user.id, provider); + if (!preauth.authorized) { + return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" }); + } + + const abortController = new AbortController(); + const handleAbort = () => abortController.abort(); + req.once("close", handleAbort); + req.once("aborted", handleAbort); + + let result; + try { + result = await keyManager.acquireKey(provider, req.user, preauth, { + waitTimeoutMs, + signal: abortController.signal, + }); + } finally { + req.off("close", handleAbort); + req.off("aborted", handleAbort); + } + if (!result) { + const status = await keyManager.getKeyStatus(provider); + if ((status?.totalCapacity || 0) <= 0) { + return res.status(404).json({ error: `${provider} 并发池未配置`, status }); + } + return res.status(429).json({ + error: `${provider} 所有 Key 已满 (${status.totalActive}/${status.totalCapacity}),请稍后重试`, + status, + }); + } + + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === "Cancelled") { + return; + } + console.error("[keys/acquire] failed", { provider, userId: req.user?.id, message }); + res.status(500).json({ error: `分配并发槽失败: ${message}` }); + } + }); + + router.post("/keys/release", requireAuth, async (req, res) => { + const { leaseToken } = req.body; + if (!leaseToken) return res.status(400).json({ error: "缺少 leaseToken" }); + + try { + const result = await keyManager.releaseKey(leaseToken, req.user); + if (result.notFound) { + return res.status(404).json({ error: "leaseToken 不存在", released: false }); + } + if (!result.released && !result.alreadyReleased) { + return res.status(409).json({ + error: "并发槽释放处理中,请稍后重试", + released: false, + provider: result.provider, + }); + } + + res.json({ + success: true, + released: result.released, + alreadyReleased: result.alreadyReleased, + provider: result.provider, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[keys/release] failed", { leaseToken, userId: req.user?.id, message }); + res.status(500).json({ error: `释放并发槽失败: ${message}` }); + } + }); + + router.get("/keys/status", requireAuth, async (req, res) => { + const { provider } = req.query; + if (provider) { + return res.json(await keyManager.getKeyStatus(provider)); + } + res.json(await keyManager.getAllStatus()); + }); +} + +module.exports = { + registerKeyRoutes, +}; diff --git a/src/routes/notifications.js b/src/routes/notifications.js new file mode 100644 index 0000000..e8b1dd9 --- /dev/null +++ b/src/routes/notifications.js @@ -0,0 +1,134 @@ +"use strict"; + +const { requireAuth, pool } = require("./context"); + +function cleanText(value, maxLength) { + return String(value || "").trim().slice(0, maxLength); +} + +function safeJsonString(value, fallback) { + if (value === undefined) return JSON.stringify(fallback); + try { + return JSON.stringify(value ?? fallback); + } catch { + return JSON.stringify(fallback); + } +} + +function parseJson(value, fallback) { + if (!value || typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function formatNotification(row) { + return { + id: Number(row.id), + type: row.type, + title: row.title, + description: row.description || "", + targetType: row.target_type || null, + targetId: row.target_id || null, + metadata: parseJson(row.metadata_json, {}), + readAt: row.read_at || null, + isRead: Boolean(row.read_at), + createdAt: row.created_at, + }; +} + +function registerNotificationRoutes(router) { + router.get("/notifications", requireAuth, async (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200); + const unreadOnly = req.query.unread === "1" || req.query.unread === "true"; + const params = [req.user.id, limit]; + const unreadClause = unreadOnly ? "AND read_at IS NULL" : ""; + const { rows } = await pool.query( + ` + SELECT * + FROM web_notifications + WHERE user_id = $1 ${unreadClause} + ORDER BY created_at DESC + LIMIT $2 + `, + params, + ); + res.json({ notifications: rows.map(formatNotification) }); + } catch (err) { + console.error("[notifications] list failed:", err.message); + res.status(500).json({ error: "Failed to load notifications" }); + } + }); + + router.post("/notifications", requireAuth, async (req, res) => { + const type = cleanText(req.body?.type, 64) || "info"; + const title = cleanText(req.body?.title, 200); + const description = cleanText(req.body?.description, 2000) || null; + const targetType = cleanText(req.body?.targetType ?? req.body?.target_type, 64) || null; + const targetId = cleanText(req.body?.targetId ?? req.body?.target_id, 128) || null; + if (!title) return res.status(400).json({ error: "Missing notification title" }); + + try { + const { rows } = await pool.query( + ` + INSERT INTO web_notifications ( + user_id, type, title, description, target_type, target_id, metadata_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, + [ + req.user.id, + type, + title, + description, + targetType, + targetId, + safeJsonString(req.body?.metadata, {}), + ], + ); + res.status(201).json({ notification: formatNotification(rows[0]) }); + } catch (err) { + console.error("[notifications] create failed:", err.message); + res.status(500).json({ error: "Failed to create notification" }); + } + }); + + router.patch("/notifications/:id/read", requireAuth, async (req, res) => { + const markRead = req.body?.isRead !== false; + try { + const { rows } = await pool.query( + ` + UPDATE web_notifications + SET read_at = ${markRead ? "COALESCE(read_at, NOW())" : "NULL"} + WHERE id = $1 AND user_id = $2 + RETURNING * + `, + [req.params.id, req.user.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Notification not found" }); + res.json({ notification: formatNotification(rows[0]) }); + } catch (err) { + console.error("[notifications] mark read failed:", err.message); + res.status(500).json({ error: "Failed to update notification" }); + } + }); + + router.post("/notifications/read-all", requireAuth, async (req, res) => { + try { + await pool.query( + "UPDATE web_notifications SET read_at = COALESCE(read_at, NOW()) WHERE user_id = $1", + [req.user.id], + ); + res.json({ success: true }); + } catch (err) { + console.error("[notifications] read-all failed:", err.message); + res.status(500).json({ error: "Failed to mark notifications read" }); + } + }); +} + +module.exports = { registerNotificationRoutes }; diff --git a/src/routes/oss.js b/src/routes/oss.js new file mode 100644 index 0000000..fcd23c4 --- /dev/null +++ b/src/routes/oss.js @@ -0,0 +1,324 @@ +const crypto = require("node:crypto"); +const dns = require("node:dns"); +const { requireAuth } = require("./context"); +const { putObject, isOssConfigured, createSignedReadUrl } = require("../ossClient"); + +const DATA_URL_PATTERN = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/; +const DEFAULT_MAX_UPLOAD_BYTES = 200 * 1024 * 1024; // 200MB (must match nginx client_max_body_size) +const MAX_UPLOAD_BYTES = Math.max( + 1, + Number(process.env.OSS_UPLOAD_MAX_BYTES || DEFAULT_MAX_UPLOAD_BYTES) || DEFAULT_MAX_UPLOAD_BYTES, +); + +// --- SSRF protection: resolve hostname and check for private/internal IPs --- +const BLOCKED_HOSTNAMES = new Set([ + 'localhost', + 'metadata.google.internal', + 'metadata.internal', + 'instance-data', +]); + +function isPrivateIp(ip) { + if (!ip) return true; + // IPv4 private ranges + if (/^127\./.test(ip)) return true; // loopback + if (/^10\./.test(ip)) return true; // Class A private + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; // Class B private + if (/^192\.168\./.test(ip)) return true; // Class C private + if (/^169\.254\./.test(ip)) return true; // link-local (cloud metadata) + if (/^0\./.test(ip)) return true; // current network + if (ip === '0.0.0.0') return true; + // IPv6 private/special + if (ip === '::1') return true; // loopback + if (ip.startsWith('fe80:')) return true; // link-local + if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // unique local + if (ip === '::' || ip === '::ffff:0:0') return true; // unspecified + // IPv6-mapped IPv4: extract IPv4 part and recheck + const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + if (mapped) return isPrivateIp(mapped[1]); + return false; +} + +function resolveAndCheckHost(hostname) { + // Check hostname blocklist first + if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) { + return Promise.reject(new Error('禁止访问内网地址')); + } + return new Promise((resolve, reject) => { + dns.lookup(hostname, { all: true }, (err, addresses) => { + if (err) return reject(new Error(`DNS 解析失败: ${hostname}`)); + if (!addresses || addresses.length === 0) return reject(new Error(`DNS 无记录: ${hostname}`)); + for (const { address } of addresses) { + if (isPrivateIp(address)) { + return reject(new Error('禁止访问内网地址')); + } + } + resolve(); + }); + }); +} +const MIME_EXTENSIONS = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/mp4": "m4a", + "audio/x-m4a": "m4a", + "audio/aac": "aac", + "application/json": "json", + "application/octet-stream": "bin", +}; +const PROFILE_UPLOAD_SCOPES = new Set(["profile-avatar", "profile-background"]); +const COMMUNITY_CASE_UPLOAD_SCOPES = new Set([ + "community-case-asset", + "community-case-cover", + "community-case-image", + "community-case-media", + "community-case-video", + "community-case-workflow", +]); + +function normalizeMimeType(value) { + const mimeType = String(value || "").trim().toLowerCase(); + return MIME_EXTENSIONS[mimeType] ? mimeType : "application/octet-stream"; +} + +function getAssetDirectory(mimeType) { + if (mimeType.startsWith("image/")) return "images"; + if (mimeType.startsWith("video/")) return "videos"; + if (mimeType.startsWith("audio/")) return "audios"; + return "files"; +} + +function getProfileObjectKey(scope, userId, ext, mimeType) { + if (!PROFILE_UPLOAD_SCOPES.has(scope)) return null; + if (!mimeType.startsWith("image/")) { + const error = new Error("Profile media must be an image"); + error.status = 400; + throw error; + } + + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + if (scope === "profile-avatar") { + return `users/${safeUserId}/profile/avatar/avatar.${ext}`; + } + return `users/${safeUserId}/profile/background/background.${ext}`; +} + +function getCommunityObjectKey(scope, userId, ext, mimeType) { + if (!COMMUNITY_CASE_UPLOAD_SCOPES.has(scope)) return null; + + const uniqueName = `${Date.now()}_${crypto.randomUUID()}.${ext}`; + if (scope === "community-case-cover" || scope === "community-case-image") { + if (!mimeType.startsWith("image/")) { + const error = new Error("Community case image uploads must be an image"); + error.status = 400; + throw error; + } + return `community/images/${uniqueName}`; + } + + if (scope === "community-case-video") { + if (!mimeType.startsWith("video/")) { + const error = new Error("Community case video uploads must be a video"); + error.status = 400; + throw error; + } + return `community/videos/${uniqueName}`; + } + + if (scope === "community-case-workflow") { + if (mimeType !== "application/json") { + const error = new Error("Community workflow uploads must be JSON"); + error.status = 400; + throw error; + } + return `community/canvas/${uniqueName}`; + } + + const assetDir = getAssetDirectory(mimeType); + const communityDir = mimeType === "application/json" ? "canvas" : assetDir; + return `community/${communityDir}/${uniqueName}`; +} + +function buildOssPublicUrl(ossKey) { + const publicBaseUrl = String(process.env.OSS_PUBLIC_BASE_URL || "").trim().replace(/\/+$/, ""); + if (publicBaseUrl) { + return `${publicBaseUrl}/${ossKey}`; + } + + const bucket = String(process.env.OSS_BUCKET || "").trim(); + const region = String(process.env.OSS_REGION || "").trim().replace(/^oss-/, ""); + if (!bucket || !region) { + throw new Error("OSS bucket or region is not configured"); + } + return `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`; +} + +function parseUploadPayload(body) { + const rawData = String(body?.dataUrl || body?.data || ""); + const dataUrlMatch = rawData.match(DATA_URL_PATTERN); + const mimeType = normalizeMimeType(body?.mimeType || dataUrlMatch?.[1]); + const base64 = (dataUrlMatch?.[2] || rawData).replace(/\s+/g, ""); + if (!base64) { + const error = new Error("Missing upload data"); + error.status = 400; + throw error; + } + + const buffer = Buffer.from(base64, "base64"); + if (!buffer.length) { + const error = new Error("Invalid upload data"); + error.status = 400; + throw error; + } + if (buffer.length > MAX_UPLOAD_BYTES) { + const error = new Error("Upload file is too large"); + error.status = 413; + throw error; + } + + return { buffer, mimeType }; +} + +function registerOssRoutes(router) { + // ── OSS / STS ─────────────────────────────────────────────────────── + + const sts = require("../sts"); + + router.post("/oss/sts-token", requireAuth, async (req, res) => { + try { + if (!sts.isSTSConfigured()) { + return res.status(501).json({ error: "STS 未配置" }); + } + + const userId = req.user.id; + const credentials = await sts.assumeRole(userId); + res.set("Cache-Control", "no-store"); + res.json(credentials); + } catch (err) { + console.error("[sts] AssumeRole failed:", err.message); + res.status(500).json({ error: "获取临时凭证失败" }); + } + }); + + router.post("/oss/upload-by-url", requireAuth, async (req, res) => { + try { + if (!isOssConfigured()) { + return res.status(501).json({ error: "OSS 未配置" }); + } + + const sourceUrl = String(req.body?.sourceUrl || "").trim(); + if (!sourceUrl || !/^https?:\/\//.test(sourceUrl)) { + return res.status(400).json({ error: "需要有效的 sourceUrl" }); + } + + const parsed = new URL(sourceUrl); + // SSRF protection: resolve DNS and check all resolved IPs are public + try { + await resolveAndCheckHost(parsed.hostname); + } catch (dnsErr) { + return res.status(400).json({ error: dnsErr.message }); + } + + console.info(`[oss/upload-by-url] fetching ${sourceUrl}`); + const fetchRes = await fetch(sourceUrl, { + signal: AbortSignal.timeout(60_000), + headers: { "User-Agent": "OmniAI-OSS-Proxy/1.0" }, + }); + if (!fetchRes.ok) { + return res.status(502).json({ error: `源文件下载失败: HTTP ${fetchRes.status}` }); + } + + const contentType = fetchRes.headers.get("content-type") || ""; + const mimeType = normalizeMimeType(req.body?.mimeType || contentType); + const ext = MIME_EXTENSIONS[mimeType] || "bin"; + + const arrayBuffer = await fetchRes.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + if (!buffer.length) { + return res.status(400).json({ error: "源文件为空" }); + } + if (buffer.length > MAX_UPLOAD_BYTES) { + return res.status(413).json({ error: "源文件过大" }); + } + + const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); + const assetDir = getAssetDirectory(mimeType); + const scope = String(req.body?.scope || "").trim(); + const objectKey = + getProfileObjectKey(scope, req.user.id, ext, mimeType) || + getCommunityObjectKey(scope, req.user.id, ext, mimeType) || + `tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(objectKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const url = buildOssPublicUrl(objectKey); + const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url; + console.info(`[oss/upload-by-url] done: ${objectKey} (${buffer.length} bytes)`); + res.status(201).json({ ossKey: objectKey, url, signedUrl }); + } catch (err) { + const status = err.status || 500; + console.error("[oss/upload-by-url] failed:", err.message); + res.status(status).json({ error: err.message || "转存失败" }); + } + }); + + router.post("/oss/upload", requireAuth, async (req, res) => { + try { + if (!isOssConfigured()) { + return res.status(501).json({ error: "OSS 未配置" }); + } + + const { buffer, mimeType } = parseUploadPayload(req.body); + const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); + const ext = MIME_EXTENSIONS[mimeType] || "bin"; + const assetDir = getAssetDirectory(mimeType); + const scope = String(req.body?.scope || "").trim(); + const objectKey = + getProfileObjectKey(scope, req.user.id, ext, mimeType) || + getCommunityObjectKey(scope, req.user.id, ext, mimeType) || + `tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(objectKey, buffer, mimeType, { "x-oss-object-acl": "public-read" }); + const url = buildOssPublicUrl(objectKey); + const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url; + res.status(201).json({ ossKey: objectKey, url, signedUrl }); + } catch (err) { + const status = err.status || 500; + console.error("[oss/upload] failed:", err.message); + res.status(status).json({ error: err.message || "上传素材失败" }); + } + }); + + router.get("/oss/proxy", async (req, res) => { + const ossKey = String(req.query.key || "").trim(); + if (!ossKey) return res.status(400).json({ error: "Missing key" }); + const bucket = String(process.env.OSS_BUCKET || "").trim(); + const region = String(process.env.OSS_REGION || "").trim(); + if (!bucket || !region) return res.status(500).json({ error: "OSS not configured" }); + const ossUrl = `https://${bucket}.${region}.aliyuncs.com/${ossKey}`; + try { + const upstream = await fetch(ossUrl); + if (!upstream.ok) return res.status(upstream.status).end(); + const ct = upstream.headers.get("content-type"); + const cl = upstream.headers.get("content-length"); + if (ct) res.setHeader("Content-Type", ct); + if (cl) res.setHeader("Content-Length", cl); + res.setHeader("Cache-Control", "public, max-age=3600"); + const { Readable } = require("node:stream"); + Readable.fromWeb(upstream.body).pipe(res); + } catch (err) { + console.error("[oss/proxy] error:", err.message); + if (!res.headersSent) res.status(502).json({ error: "Proxy failed" }); + } + }); +} + +module.exports = { + registerOssRoutes, +}; diff --git a/src/routes/payment.js b/src/routes/payment.js new file mode 100644 index 0000000..56f60f6 --- /dev/null +++ b/src/routes/payment.js @@ -0,0 +1,145 @@ +const { express, requireAuth, isSystemAdmin, wechatPay, alipay, pool } = require("./context"); + +function registerPaymentRoutes(router) { + // ── Payment ────────────────────────────────────────────────────────── + + function getNotifyBaseUrl() { + return process.env.PAYMENT_NOTIFY_BASE_URL || ""; + } + + router.post("/payment/create", requireAuth, async (req, res) => { + const { orderNo, paymentMethod = "wechat" } = req.body; + if (!orderNo) return res.status(400).json({ error: "缺少订单号" }); + + const { + rows: [order], + } = await pool.query("SELECT * FROM payment_orders WHERE order_no = $1 AND status = $2", [ + orderNo, + "pending", + ]); + if (!order) return res.status(404).json({ error: "订单不存在或已处理" }); + + // Verify order belongs to user + const isOwnOrder = + (order.enterprise_id && + req.user.enterpriseId && + order.enterprise_id === req.user.enterpriseId) || + (order.user_id && order.user_id === req.user.id); + if (!isOwnOrder && !isSystemAdmin(req.user)) { + return res.status(403).json({ error: "无权操作此订单" }); + } + + const notifyBase = getNotifyBaseUrl(); + const description = + order.type === "package" ? `套餐购买 - ${order.order_no}` : `积分充值 - ${order.order_no}`; + + try { + if (paymentMethod === "wechat") { + if (!wechatPay.isWechatPayEnabled()) + return res.status(503).json({ error: "微信支付未配置" }); + const notifyUrl = notifyBase ? `${notifyBase}/api/payment/notify/wechat` : ""; + const result = await wechatPay.createNativeOrder( + orderNo, + order.amount_cents, + description, + notifyUrl, + ); + return res.json({ paymentMethod: "wechat", codeUrl: result.codeUrl, orderNo }); + } + + if (paymentMethod === "alipay") { + if (!alipay.isAlipayEnabled()) return res.status(503).json({ error: "支付宝未配置" }); + const notifyUrl = notifyBase ? `${notifyBase}/api/payment/notify/alipay` : ""; + const result = await alipay.createPrecreateOrder( + orderNo, + order.amount_cents, + description, + notifyUrl, + ); + return res.json({ paymentMethod: "alipay", codeUrl: result.qrCode, orderNo }); + } + + return res.status(400).json({ error: "不支持的支付方式" }); + } catch (err) { + console.error("[payment/create] failed", err.message); + res.status(500).json({ error: `创建支付失败: ${err.message}` }); + } + }); + + router.get("/payment/status", requireAuth, async (req, res) => { + const { orderNo } = req.query; + if (!orderNo) return res.status(400).json({ error: "缺少订单号" }); + + const { + rows: [order], + } = await pool.query( + "SELECT order_no, status, amount_cents, payment_method, paid_at, enterprise_id, user_id FROM payment_orders WHERE order_no = $1", + [orderNo], + ); + if (!order) return res.status(404).json({ error: "订单不存在" }); + + const isOwn = + (order.enterprise_id && + req.user.enterpriseId && + order.enterprise_id === req.user.enterpriseId) || + (order.user_id && order.user_id === req.user.id); + if (!isOwn && !isSystemAdmin(req.user)) { + return res.status(403).json({ error: "无权查看此订单" }); + } + + res.json({ + orderNo: order.order_no, + status: order.status, + amountCents: order.amount_cents, + paymentMethod: order.payment_method, + paidAt: order.paid_at, + }); + }); + + router.post("/payment/notify/wechat", express.raw({ type: "*/*" }), (req, res) => { + try { + let body = req.body; + if (Buffer.isBuffer(body)) body = JSON.parse(body.toString("utf-8")); + + const result = wechatPay.verifyAndDecryptNotification(req.headers, body); + if (!result) return res.status(400).json({ code: "FAIL", message: "签名验证失败" }); + + if (result.trade_state === "SUCCESS") { + wechatPay.handlePaymentSuccess(result.out_trade_no, result.transaction_id); + } + + res.json({ code: "SUCCESS", message: "OK" }); + } catch (err) { + console.error("[payment/notify/wechat] error:", err.message); + res.status(500).json({ code: "FAIL", message: "Internal error" }); + } + }); + + router.post("/payment/notify/alipay", express.urlencoded({ extended: false }), (req, res) => { + try { + const body = req.body; + const verified = alipay.verifyCallback(req.headers, body); + if (!verified) return res.send("fail"); + + const orderNo = body.out_trade_no || body.trade_no; + const tradeNo = body.trade_no || body.trade_id; + + if (body.trade_status === "TRADE_SUCCESS" || body.trade_status === "TRADE_FINISHED") { + alipay.handlePaymentSuccess(orderNo, tradeNo); + } + + res.send("success"); + } catch (err) { + console.error("[payment/notify/alipay] error:", err.message); + res.send("fail"); + } + }); + + router.get("/payment/methods", (_req, res) => { + res.json({ wechat: wechatPay.isWechatPayEnabled(), alipay: alipay.isAlipayEnabled() }); + }); +} + +module.exports = { + registerPaymentRoutes, +}; diff --git a/src/routes/projects.js b/src/routes/projects.js new file mode 100644 index 0000000..0bafe53 --- /dev/null +++ b/src/routes/projects.js @@ -0,0 +1,904 @@ +const { + requireAuth, + pool, + withTransaction, + computeNextRevision, + normalizeRevisionValue, + shouldRejectStaleRevision, + formatGenerationTaskRow, + normalizeGenerationTaskPayload, + normalizeProjectOssKey, + buildOssPublicUrl, + requireOwnedProject, + upsertGenerationTask, +} = require("./context"); +const crypto = require("node:crypto"); +const { getObject, putObject, isOssConfigured } = require("../ossClient"); + +function countArray(value) { + return Array.isArray(value) ? value.length : 0; +} + +function stableProjectFingerprint(json) { + return crypto.createHash("sha256").update(json).digest("hex"); +} + +function isPlainObject(value) { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function mediaUrlToProjectOssKey(mediaUrl, userId) { + if (typeof mediaUrl !== "string" || !mediaUrl.startsWith("media://")) return null; + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + try { + const parsed = new URL(mediaUrl); + const host = decodeURIComponent(parsed.hostname || ""); + const parts = parsed.pathname + .split("/") + .map((part) => decodeURIComponent(part).trim()) + .filter(Boolean); + if (!safeUserId || parts.length === 0 || parts.some((part) => part === "." || part === "..")) { + return null; + } + + if (host.startsWith("u--")) { + const mediaUserId = host.slice(3).replace(/[^a-zA-Z0-9_-]/g, ""); + if (mediaUserId !== safeUserId) return null; + return `users/${safeUserId}/projects/${parts.join("/")}`; + } + + if (host === "images" || host === "videos") { + return `users/${safeUserId}/projects/${host}/${parts.join("/")}`; + } + + return `users/${safeUserId}/projects/${host}/${parts.join("/")}`; + } catch { + return null; + } +} + +function resolveProjectMediaUrls(value, userId) { + if (typeof value === "string") { + const ossKey = mediaUrlToProjectOssKey(value, userId); + if (!ossKey) return value; + try { + return buildOssPublicUrl(ossKey); + } catch { + return value; + } + } + + if (Array.isArray(value)) { + return value.map((item) => resolveProjectMediaUrls(item, userId)); + } + + if (isPlainObject(value)) { + const next = Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, resolveProjectMediaUrls(entry, userId)]), + ); + const ossKey = String(next.ossKey || next.oss_key || "").trim().replace(/^\/+/, ""); + if ( + ossKey && + /^(users|tmp)\/[^/]+\//.test(ossKey) && + !next.url && + !next.imageUrl && + !next.image_url && + !next.previewUrl && + !next.preview_url && + !next.coverUrl && + !next.cover_url + ) { + try { + next.publicUrl = buildOssPublicUrl(ossKey); + } catch { + // Keep the original metadata shape if OSS public URL config is incomplete. + } + } + return next; + } + + return value; +} + +const PROJECT_MEDIA_URL_KEYS = new Set([ + "previewUrl", + "preview_url", + "imageUrl", + "image_url", + "videoUrl", + "video_url", + "coverUrl", + "cover_url", + "thumbnailUrl", + "thumbnail_url", +]); + +const PROJECT_MEDIA_MATERIALIZE_MAX_BYTES = 180 * 1024 * 1024; + +function isProjectMediaUrlKey(key) { + return PROJECT_MEDIA_URL_KEYS.has(String(key || "")); +} + +function isSignedOrTemporaryMediaUrl(value, userId, projectId) { + if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return false; + + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, ""); + try { + const parsed = new URL(value); + const path = decodeURIComponent(parsed.pathname || ""); + if ( + (safeUserId && path.includes(`/users/${safeUserId}/generation-results/`)) || + (safeUserId && safeProjectId && path.includes(`/users/${safeUserId}/projects/${safeProjectId}/`)) || + (safeUserId && path.includes(`/users/${safeUserId}/assets/`)) + ) { + return false; + } + + const queryKeys = Array.from(parsed.searchParams.keys()).join("&").toLowerCase(); + const hasSignedQuery = + /(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=?/i.test(queryKeys) || + /(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=/i.test(parsed.search); + const hostLooksTemporaryProvider = + /(?:dashscope|oss-accelerate|aliyuncs|volces|kling|grsai|dakka|rightcode)/i.test(parsed.hostname); + + return hasSignedQuery || hostLooksTemporaryProvider; + } catch { + return false; + } +} + +function mediaExtensionFromContentType(contentType, url, kindHint = "image") { + const mime = String(contentType || "").split(";")[0].trim().toLowerCase(); + const mimeExtension = { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", + "video/x-msvideo": "avi", + }[mime]; + if (mimeExtension) return mimeExtension; + + try { + const ext = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]; + if (ext) return ext.toLowerCase(); + } catch { + // Use the kind fallback below. + } + + return kindHint === "video" ? "mp4" : "png"; +} + +function isErrorDocumentContentType(contentType) { + return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || "")); +} + +function getProjectMediaKindFromKey(key) { + return String(key || "").toLowerCase().includes("video") ? "video" : "image"; +} + +async function materializeProjectMediaUrl(url, userId, projectId, key) { + if (!isOssConfigured() || !isSignedOrTemporaryMediaUrl(url, userId, projectId)) return url; + + try { + const response = await fetch(url, { method: "GET" }); + if (!response.ok) { + throw new Error(`media fetch returned ${response.status}`); + } + + const contentType = response.headers.get("content-type") || ""; + if (isErrorDocumentContentType(contentType)) { + const text = await response.text().catch(() => ""); + throw new Error(`media fetch returned error document: ${text.slice(0, 120)}`); + } + + const declaredLength = Number(response.headers.get("content-length") || 0); + if (declaredLength > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) { + throw new Error(`media is too large to persist (${declaredLength} bytes)`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (!buffer.length) throw new Error("media fetch returned empty content"); + if (buffer.length > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) { + throw new Error(`media is too large to persist (${buffer.length} bytes)`); + } + + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, ""); + const kind = getProjectMediaKindFromKey(key); + const extension = mediaExtensionFromContentType(contentType, url, kind); + const finalContentType = contentType || (kind === "video" ? "video/mp4" : `image/${extension === "jpg" ? "jpeg" : extension}`); + const objectKey = `users/${safeUserId}/projects/${safeProjectId}/media/${kind}s/${Date.now()}-${crypto.randomUUID()}.${extension}`; + const uploaded = await putObject(objectKey, buffer, finalContentType, { "x-oss-object-acl": "public-read" }); + return uploaded.url; + } catch (error) { + console.warn("[projects] media materialization skipped:", error.message); + return url; + } +} + +async function materializeProjectMediaUrls(value, userId, projectId, key = "") { + if (typeof value === "string") { + if (!isProjectMediaUrlKey(key)) return value; + return materializeProjectMediaUrl(value, userId, projectId, key); + } + + if (Array.isArray(value)) { + const items = []; + for (const item of value) { + items.push(await materializeProjectMediaUrls(item, userId, projectId, key)); + } + return items; + } + + if (isPlainObject(value)) { + const next = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + next[entryKey] = await materializeProjectMediaUrls(entryValue, userId, projectId, entryKey); + } + return next; + } + + return value; +} + +function formatProjectContentMeta(projectId, userId, content, meta = {}) { + const workflowNodes = Array.isArray(content?.workflowData?.nodes) ? content.workflowData.nodes : []; + const imageCount = + Number(meta.imageCount ?? meta.image_count) || + workflowNodes.filter((node) => node?.type === "image" || node?.kind === "image").length; + const videoCount = + Number(meta.videoCount ?? meta.video_count) || + countArray(content?.videos) || + workflowNodes.filter((node) => node?.type === "video" || node?.kind === "video").length; + + return { + id: projectId, + name: String(meta.name || content?.name || content?.projectName || "Untitled project").trim().slice(0, 200), + description: + meta.description === null || meta.description === undefined + ? content?.description || content?.projectDescription || null + : String(meta.description).slice(0, 2000), + ossKey: `users/${String(userId).replace(/[^a-zA-Z0-9_-]/g, "")}/projects/${projectId}/current/project.json`, + thumbnailUrl: + meta.thumbnailUrl || + meta.thumbnail_url || + content?.thumbnailUrl || + content?.thumbnail_url || + null, + storyboardCount: Number(meta.storyboardCount ?? meta.storyboard_count) || countArray(content?.storyboards), + imageCount, + videoCount, + }; +} + +function registerProjectRoutes(router) { + // ── Projects (Cloud Sync) ────────────────────────────────────────── + + router.get("/projects", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const { rows } = await pool.query( + `SELECT + id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision AS revision, + current_fingerprint AS fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + FROM projects + WHERE user_id = $1 + ORDER BY updated_at DESC`, + [userId], + ); + res.json({ projects: rows }); + } catch (err) { + console.error("[projects] list failed:", err.message); + res.status(500).json({ error: "获取项目列表失败" }); + } + }); + + router.post("/projects/upsert", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const { + id, + name, + description, + ossKey, + thumbnailUrl, + storyboardCount, + imageCount, + videoCount, + fileSize, + fingerprint, + deviceId, + baseRevision, + forceOverwrite, + saveReason, + sourceCaseId, + originType, + } = req.body; + + if (!id || !name || !ossKey) { + return res.status(400).json({ error: "缺少必要字段 (id, name, ossKey)" }); + } + + const normalizedOssKey = normalizeProjectOssKey(ossKey, userId, id); + if (normalizedOssKey.error) { + return res.status(400).json({ error: normalizedOssKey.error }); + } + + const saveReasonValue = String(saveReason || "save").slice(0, 32) || "save"; + const originTypeValue = String(originType || (sourceCaseId ? "community_copy" : "manual")) + .trim() + .slice(0, 32); + + const result = await withTransaction(async (client) => { + const { rows: existingRows } = await client.query( + "SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE", + [id], + ); + const existing = existingRows[0] || null; + + if (existing && Number(existing.user_id) !== Number(userId)) { + const error = new Error("无权保存该项目"); + error.status = 403; + throw error; + } + + const revisionInfo = computeNextRevision(existing?.current_revision, baseRevision); + if ( + existing && + shouldRejectStaleRevision( + existing.current_revision, + baseRevision, + Boolean(forceOverwrite), + ) + ) { + const error = new Error("stale_revision"); + error.status = 409; + error.code = "stale_revision"; + error.currentRevision = revisionInfo.currentRevision; + error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision; + error.currentFingerprint = existing.current_fingerprint || null; + error.currentOssKey = existing.oss_key || null; + error.currentUpdatedAt = existing.updated_at || null; + throw error; + } + + const { + rows: [projectRow], + } = await client.query( + ` + INSERT INTO projects ( + id, + user_id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision, + current_fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW() + ) + ON CONFLICT (id) DO UPDATE SET + user_id = EXCLUDED.user_id, + name = EXCLUDED.name, + description = EXCLUDED.description, + oss_key = EXCLUDED.oss_key, + thumbnail_url = EXCLUDED.thumbnail_url, + storyboard_count = EXCLUDED.storyboard_count, + image_count = EXCLUDED.image_count, + video_count = EXCLUDED.video_count, + file_size = EXCLUDED.file_size, + current_revision = EXCLUDED.current_revision, + current_fingerprint = EXCLUDED.current_fingerprint, + updated_by_device_id = EXCLUDED.updated_by_device_id, + source_case_id = COALESCE(projects.source_case_id, EXCLUDED.source_case_id), + origin_type = CASE + WHEN projects.origin_type IS NULL OR projects.origin_type = 'manual' + THEN EXCLUDED.origin_type + ELSE projects.origin_type + END, + updated_at = NOW() + RETURNING + id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision AS revision, + current_fingerprint AS fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + `, + [ + id, + userId, + name, + description || null, + normalizedOssKey.value, + thumbnailUrl || null, + storyboardCount || 0, + imageCount || 0, + videoCount || 0, + fileSize || 0, + revisionInfo.nextRevision, + fingerprint || null, + deviceId || null, + sourceCaseId || null, + originTypeValue || "manual", + ], + ); + + await client.query( + ` + INSERT INTO project_revisions ( + project_id, + revision_number, + oss_key, + content_fingerprint, + source_device_id, + save_reason + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (project_id, revision_number) DO UPDATE SET + oss_key = EXCLUDED.oss_key, + content_fingerprint = EXCLUDED.content_fingerprint, + source_device_id = EXCLUDED.source_device_id, + save_reason = EXCLUDED.save_reason + `, + [ + id, + revisionInfo.nextRevision, + normalizedOssKey.value, + fingerprint || null, + deviceId || null, + saveReasonValue, + ], + ); + + return { + project: projectRow, + appliedRevision: normalizeRevisionValue(projectRow?.revision), + baseWasStale: revisionInfo.baseWasStale, + }; + }); + + res.json({ + project: result.project, + appliedRevision: result.appliedRevision, + baseWasStale: result.baseWasStale, + }); + } catch (err) { + const status = err && typeof err === "object" && err.status ? err.status : 500; + console.error("[projects] upsert failed:", err.message); + if (err.code === "stale_revision") { + return res.status(status).json({ + error: err.message || "stale_revision", + code: err.code, + currentRevision: err.currentRevision, + normalizedBaseRevision: err.normalizedBaseRevision, + currentFingerprint: err.currentFingerprint, + currentOssKey: err.currentOssKey, + currentUpdatedAt: err.currentUpdatedAt, + }); + } + res.status(status).json({ error: err.message || "保存项目元数据失败" }); + } + }); + + router.put("/projects/:id/content", requireAuth, async (req, res) => { + const userId = req.user.id; + const projectId = req.params.id; + const { content, meta, baseRevision, fingerprint, deviceId, saveReason, forceOverwrite } = req.body || {}; + + if (!content || typeof content !== "object" || Array.isArray(content)) { + return res.status(400).json({ error: "content must be an object" }); + } + + if (!isOssConfigured()) { + return res.status(501).json({ error: "OSS is not configured" }); + } + + try { + const durableContent = await materializeProjectMediaUrls(content, userId, projectId); + const durableMeta = await materializeProjectMediaUrls(meta || {}, userId, projectId); + const contentJson = JSON.stringify(durableContent); + const resolvedMeta = formatProjectContentMeta(projectId, userId, durableContent, durableMeta); + const normalizedOssKey = normalizeProjectOssKey(resolvedMeta.ossKey, userId, projectId); + if (normalizedOssKey.error) { + return res.status(400).json({ error: normalizedOssKey.error }); + } + + const contentFingerprint = String(fingerprint || stableProjectFingerprint(contentJson)).slice(0, 128); + const saveReasonValue = String(saveReason || "web-autosave").slice(0, 32) || "web-autosave"; + + const result = await withTransaction(async (client) => { + const { rows: existingRows } = await client.query( + "SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE", + [projectId], + ); + const existing = existingRows[0] || null; + if (!existing || Number(existing.user_id) !== Number(userId)) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + + const revisionInfo = computeNextRevision(existing.current_revision, baseRevision); + if ( + shouldRejectStaleRevision( + existing.current_revision, + baseRevision, + Boolean(forceOverwrite), + ) + ) { + const error = new Error("stale_revision"); + error.status = 409; + error.code = "stale_revision"; + error.currentRevision = revisionInfo.currentRevision; + error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision; + error.currentFingerprint = existing.current_fingerprint || null; + error.currentOssKey = existing.oss_key || null; + error.currentUpdatedAt = existing.updated_at || null; + throw error; + } + + await putObject(normalizedOssKey.value, contentJson, "application/json"); + + const { + rows: [projectRow], + } = await client.query( + ` + UPDATE projects SET + name = $3, + description = $4, + oss_key = $5, + thumbnail_url = $6, + storyboard_count = $7, + image_count = $8, + video_count = $9, + file_size = $10, + current_revision = $11, + current_fingerprint = $12, + updated_by_device_id = $13, + updated_at = NOW() + WHERE id = $1 AND user_id = $2 + RETURNING + id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision AS revision, + current_fingerprint AS fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + `, + [ + projectId, + userId, + resolvedMeta.name, + resolvedMeta.description, + normalizedOssKey.value, + resolvedMeta.thumbnailUrl, + resolvedMeta.storyboardCount, + resolvedMeta.imageCount, + resolvedMeta.videoCount, + Buffer.byteLength(contentJson), + revisionInfo.nextRevision, + contentFingerprint, + deviceId || "web", + ], + ); + + await client.query( + ` + INSERT INTO project_revisions ( + project_id, + revision_number, + oss_key, + content_fingerprint, + source_device_id, + save_reason + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (project_id, revision_number) DO UPDATE SET + oss_key = EXCLUDED.oss_key, + content_fingerprint = EXCLUDED.content_fingerprint, + source_device_id = EXCLUDED.source_device_id, + save_reason = EXCLUDED.save_reason + `, + [ + projectId, + revisionInfo.nextRevision, + normalizedOssKey.value, + contentFingerprint, + deviceId || "web", + saveReasonValue, + ], + ); + + return { + project: projectRow, + appliedRevision: normalizeRevisionValue(projectRow?.revision), + baseWasStale: revisionInfo.baseWasStale, + }; + }); + + res.json(result); + } catch (err) { + const status = err && typeof err === "object" && err.status ? err.status : 500; + console.error("[projects] content save failed:", err.message); + if (err.code === "stale_revision") { + return res.status(status).json({ + error: err.message || "stale_revision", + code: err.code, + currentRevision: err.currentRevision, + normalizedBaseRevision: err.normalizedBaseRevision, + currentFingerprint: err.currentFingerprint, + currentOssKey: err.currentOssKey, + currentUpdatedAt: err.currentUpdatedAt, + }); + } + res.status(status).json({ error: err.message || "Failed to save project content" }); + } + }); + + router.delete("/projects/:id", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + + const { rowCount } = await pool.query("DELETE FROM projects WHERE id = $1 AND user_id = $2", [ + projectId, + userId, + ]); + if (rowCount === 0) { + return res.status(404).json({ error: "项目不存在" }); + } + + res.json({ success: true }); + } catch (err) { + console.error("[projects] delete failed:", err.message); + res.status(500).json({ error: "删除项目失败" }); + } + }); + + router.get("/projects/:id", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + + const { rows } = await pool.query( + `SELECT + id, + name, + description, + oss_key, + thumbnail_url, + storyboard_count, + image_count, + video_count, + file_size, + current_revision AS revision, + current_fingerprint AS fingerprint, + updated_by_device_id, + source_case_id, + origin_type, + created_at, + updated_at + FROM projects + WHERE id = $1 AND user_id = $2`, + [projectId, userId], + ); + if (rows.length === 0) { + return res.status(404).json({ error: "项目不存在" }); + } + + res.json({ project: rows[0] }); + } catch (err) { + console.error("[projects] get failed:", err.message); + res.status(500).json({ error: "获取项目失败" }); + } + }); + + router.get("/projects/:id/tasks", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + const { rows } = await pool.query( + `SELECT gt.* + FROM generation_tasks gt + JOIN projects p ON p.id = gt.project_id + WHERE gt.project_id = $1 AND gt.user_id = $2 AND p.user_id = $2 + ORDER BY gt.updated_at DESC + LIMIT 500`, + [projectId, userId], + ); + res.json({ tasks: rows.map(formatGenerationTaskRow) }); + } catch (err) { + console.error("[projects/tasks] list failed:", err.message); + res.status(500).json({ error: "获取项目任务失败" }); + } + }); + + router.post("/projects/:id/tasks/upsert", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + const normalized = normalizeGenerationTaskPayload(req.body || {}); + if (normalized.error) { + return res.status(400).json({ error: normalized.error }); + } + + const task = await withTransaction(async (client) => { + const ownsProject = await requireOwnedProject(client, userId, projectId); + if (!ownsProject) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + + return upsertGenerationTask(client, userId, projectId, normalized.value); + }); + + res.json({ task: formatGenerationTaskRow(task) }); + } catch (err) { + const status = err && typeof err === "object" && err.status ? err.status : 500; + console.error("[projects/tasks] upsert failed:", err.message); + res.status(status).json({ error: err.message || "保存任务失败" }); + } + }); + + router.post("/projects/:id/tasks/batch-upsert", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + const taskBodies = Array.isArray(req.body?.tasks) ? req.body.tasks : []; + if (taskBodies.length === 0) { + return res.json({ tasks: [] }); + } + + const normalizedTasks = []; + for (const taskBody of taskBodies.slice(0, 500)) { + const normalized = normalizeGenerationTaskPayload(taskBody || {}); + if (normalized.error) { + return res.status(400).json({ error: normalized.error }); + } + normalizedTasks.push(normalized.value); + } + + const tasks = await withTransaction(async (client) => { + const ownsProject = await requireOwnedProject(client, userId, projectId); + if (!ownsProject) { + const error = new Error("Project not found"); + error.status = 404; + throw error; + } + + const rows = []; + for (const task of normalizedTasks) { + rows.push(await upsertGenerationTask(client, userId, projectId, task)); + } + return rows; + }); + + res.json({ tasks: tasks.map(formatGenerationTaskRow) }); + } catch (err) { + const status = err && typeof err === "object" && err.status ? err.status : 500; + console.error("[projects/tasks] batch upsert failed:", err.message); + res.status(status).json({ error: err.message || "批量保存任务失败" }); + } + }); + + router.delete("/projects/:id/tasks/:taskId", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + const taskId = req.params.taskId; + const { rowCount } = await pool.query( + `DELETE FROM generation_tasks + WHERE project_id = $1 AND user_id = $2 AND client_queue_id = $3`, + [projectId, userId, taskId], + ); + + if (rowCount === 0) { + return res.status(404).json({ error: "任务不存在" }); + } + + res.json({ success: true }); + } catch (err) { + console.error("[projects/tasks] delete failed:", err.message); + res.status(500).json({ error: "删除任务失败" }); + } + }); + + router.get("/projects/:id/content", requireAuth, async (req, res) => { + try { + const userId = req.user.id; + const projectId = req.params.id; + + const { rows } = await pool.query( + "SELECT oss_key FROM projects WHERE id = $1 AND user_id = $2", + [projectId, userId], + ); + + if (rows.length === 0) { + return res.status(404).json({ error: "项目不存在" }); + } + + const ossKey = rows[0].oss_key; + if (!ossKey) { + return res.status(404).json({ error: "项目内容未上传" }); + } + + if (!isOssConfigured()) { + return res.status(501).json({ error: "OSS 未配置" }); + } + + const content = await getObject(ossKey); + const parsed = JSON.parse(content); + const shouldResolveMedia = + req.query.resolveMedia === "1" || + req.query.resolveMedia === "true" || + req.query.resolve_media === "1"; + res.json({ content: shouldResolveMedia ? resolveProjectMediaUrls(parsed, userId) : parsed }); + } catch (err) { + const status = err && typeof err === "object" && err.status ? err.status : 500; + console.error("[projects] content download failed:", err.message); + if (status === 404 && err.code === "oss_no_such_key") { + return res.status(404).json({ + error: "项目内容文件不存在,请重新保存或删除该旧项目。", + code: "project_content_missing", + }); + } + res.status(status).json({ error: err.message || "获取项目内容失败" }); + } + }); +} + +module.exports = { + registerProjectRoutes, +}; diff --git a/src/routes/public.js b/src/routes/public.js new file mode 100644 index 0000000..9d3ee5e --- /dev/null +++ b/src/routes/public.js @@ -0,0 +1,49 @@ +const { keyManager, listModelPrices, pool } = require("./context"); + +function registerPriceRoutes(router) { + // ── Public ─────────────────────────────────────────────────────────── + + router.get("/prices", async (_req, res) => { + const prices = await listModelPrices({ enabledOnly: true }); + res.json(prices); + }); +} + +function registerPackageRoutes(router) { + // ── Public: Packages ───────────────────────────────────────────────── + + router.get("/packages", async (_req, res) => { + const { rows } = await pool.query( + "SELECT * FROM packages WHERE enabled = 1 ORDER BY sort_order, id", + ); + res.json( + rows.map((row) => ({ + id: Number(row.id), + name: row.name, + description: row.description, + priceCents: row.price_cents, + creditsCents: row.credits_cents, + imageQuota: row.image_quota, + videoQuota: row.video_quota, + textQuota: row.text_quota, + durationDays: row.duration_days, + sortOrder: row.sort_order, + })), + ); + }); +} + +function registerHealthRoutes(router) { + // ── Health ─────────────────────────────────────────────────────────── + + router.get("/health", async (_req, res) => { + const status = await keyManager.getAllStatus(); + res.json({ status: "ok", uptime: process.uptime(), providers: status }); + }); +} + +module.exports = { + registerPriceRoutes, + registerPackageRoutes, + registerHealthRoutes, +}; diff --git a/src/routes/reports.js b/src/routes/reports.js new file mode 100644 index 0000000..57c432a --- /dev/null +++ b/src/routes/reports.js @@ -0,0 +1,123 @@ +"use strict"; + +const { getUserContextById, requireAuth, requireAdmin, verifyToken } = require("../auth"); +const { pool } = require("../db"); + +function cleanText(value, maxLength) { + return String(value || "").trim().slice(0, maxLength); +} + +function getRequestIp(req) { + const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim(); + return forwardedFor || req.socket?.remoteAddress || ""; +} + +async function optionalAuth(req, _res, next) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + next(); + return; + } + + try { + const payload = verifyToken(authHeader.slice(7)); + const user = await getUserContextById(payload.userId); + if (user?.enabled) req.user = user; + } catch { + // Public report submission should still work when the session is missing or expired. + } + next(); +} + +function registerReportRoutes(router) { + router.post("/reports", optionalAuth, async (req, res) => { + try { + const reportType = cleanText(req.body?.reportType, 64) || "other"; + const targetType = cleanText(req.body?.targetType, 64) || null; + const targetId = cleanText(req.body?.targetId, 128) || null; + const contactName = cleanText(req.body?.contactName, 120) || null; + const contactEmail = cleanText(req.body?.contactEmail, 200) || null; + const contactPhone = cleanText(req.body?.contactPhone, 60) || null; + const title = cleanText(req.body?.title, 200); + const description = cleanText(req.body?.description, 5000); + const pageUrl = cleanText(req.body?.pageUrl, 1000) || null; + + if (!title || !description) { + return res.status(400).json({ error: "请填写举报标题和详细说明" }); + } + + const { rows } = await pool.query( + `INSERT INTO user_reports ( + user_id, report_type, target_type, target_id, + contact_name, contact_email, contact_phone, + title, description, page_url, ip_address, user_agent + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id, status, created_at`, + [ + req.user?.id || null, + reportType, + targetType, + targetId, + contactName, + contactEmail, + contactPhone, + title, + description, + pageUrl, + getRequestIp(req), + cleanText(req.headers["user-agent"], 1000) || null, + ], + ); + + res.status(201).json({ + report: { + id: rows[0].id, + status: rows[0].status, + createdAt: rows[0].created_at, + }, + }); + } catch (err) { + console.error("[reports] create failed:", err.message); + res.status(500).json({ error: "提交举报失败" }); + } + }); + + router.get("/admin/reports", requireAuth, requireAdmin, async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT r.*, u.username + FROM user_reports r + LEFT JOIN users u ON u.id = r.user_id + ORDER BY r.created_at DESC + LIMIT 200`, + ); + res.json({ + reports: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + username: row.username, + reportType: row.report_type, + targetType: row.target_type, + targetId: row.target_id, + contactName: row.contact_name, + contactEmail: row.contact_email, + contactPhone: row.contact_phone, + title: row.title, + description: row.description, + pageUrl: row.page_url, + status: row.status, + ipAddress: row.ip_address, + userAgent: row.user_agent, + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + }); + } catch (err) { + console.error("[reports] list failed:", err.message); + res.status(500).json({ error: "获取举报列表失败" }); + } + }); +} + +module.exports = { registerReportRoutes }; diff --git a/src/routes/usage.js b/src/routes/usage.js new file mode 100644 index 0000000..ed7c155 --- /dev/null +++ b/src/routes/usage.js @@ -0,0 +1,292 @@ +const { + requireAuth, + requireManagementAccess, + calculateCostMills, + deductForApiCall, + pool, + withTransaction, + getManagementEnterpriseId, + appendEnterpriseScope, + getPeriodStart, + clampPositiveInteger, + clampNonNegativeInteger, +} = require("./context"); + +function registerUsageReportRoutes(router) { + // ── Usage Reporting (with settlement) ──────────────────────────────── + + router.post("/usage/report", requireAuth, async (req, res) => { + const records = Array.isArray(req.body) ? req.body : [req.body]; + if (records.length === 0) return res.status(400).json({ error: "缺少上报数据" }); + if (records.length > 50) return res.status(400).json({ error: "单次最多上报 50 条记录" }); + + try { + const billingFailures = []; + + await withTransaction(async (client) => { + for (const row of records) { + if (!row.provider) continue; + const costMills = calculateCostMills( + row.model || null, + row.promptTokens, + row.completionTokens, + ); + const costYuan = costMills != null ? costMills / 1000 : null; + const logStatus = row.status || "success"; + const enterpriseName = req.user.enterpriseName || null; + + const { + rows: [logRow], + } = await client.query( + ` + INSERT INTO api_call_logs (user_id, enterprise_id, enterprise_name, provider, model, display_model, prompt_tokens, completion_tokens, duration_ms, status, cost_estimate, api_client) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id + `, + [ + req.user.id, + req.user.enterpriseId || null, + enterpriseName, + row.provider, + row.model || null, + row.displayModel || row.model || null, + row.promptTokens || null, + row.completionTokens || null, + row.durationMs || null, + logStatus, + costYuan, + row.apiClient || null, + ], + ); + + if (logStatus === "success" && costMills != null && costMills > 0) { + const deduction = await deductForApiCall( + req.user.id, + row.model || null, + row.promptTokens, + row.completionTokens, + ); + if (!deduction.success) { + billingFailures.push({ + logId: logRow?.id, + provider: row.provider, + model: row.model || null, + message: deduction.message, + }); + continue; + } + + if (row.leaseToken) { + await client.query( + "UPDATE key_leases SET settled = 1 WHERE lease_token = $1 AND settled = 0", + [row.leaseToken], + ); + } + } + } + }); + + if (billingFailures.length > 0) { + console.warn("[usage/report] logged calls with unsettled billing", { + userId: req.user?.id, + count: billingFailures.length, + }); + } + + res.json({ success: true, count: records.length, billingFailures }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = err.status || 500; + console.error("[usage/report] failed", { userId: req.user?.id, message }); + if (status === 402) { + res.status(402).json({ error: message, code: "INSUFFICIENT_BALANCE" }); + } else { + res.status(500).json({ error: `使用量上报失败: ${message}` }); + } + } + }); +} + +function registerAdminUsageRoutes(router) { + // ── Admin: Usage ───────────────────────────────────────────────────── + + router.get("/admin/usage", requireAuth, requireManagementAccess, async (req, res) => { + const { limit = 100, user_id, provider } = req.query; + const safeLimit = clampPositiveInteger(limit, 100, 500); + let sql = `SELECT u.*, us.username FROM usage_logs u LEFT JOIN users us ON u.user_id = us.id WHERE 1 = 1`; + const params = []; + let idx = 1; + + const enterpriseId = getManagementEnterpriseId(req.user); + if (enterpriseId != null) { + sql += ` AND COALESCE(u.enterprise_id, us.enterprise_id) = $${idx++}`; + params.push(enterpriseId); + } + if (user_id) { + sql += ` AND u.user_id = $${idx++}`; + params.push(user_id); + } + if (provider) { + sql += ` AND u.provider = $${idx++}`; + params.push(provider); + } + + sql += ` ORDER BY u.id DESC LIMIT $${idx++}`; + params.push(safeLimit); + + const { rows } = await pool.query(sql, params); + res.json(rows); + }); +} + +function registerUsageSummaryRoutes(router) { + // ── Usage Summary ──────────────────────────────────────────────────── + + router.get("/admin/usage/summary", requireAuth, requireManagementAccess, async (req, res) => { + const { period = "7d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClauses = []; + const params = []; + let idx = 1; + + if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`); + idx = appendEnterpriseScope( + whereClauses, + params, + req.user, + "COALESCE(l.enterprise_id, u.enterprise_id)", + idx, + ); + + let sql = ` + SELECT u.id AS user_id, u.username, u.avatar_url, COUNT(*) AS total_calls, + COALESCE(SUM(l.prompt_tokens), 0) AS total_prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) AS total_completion_tokens, + COALESCE(SUM(l.duration_ms), 0) AS total_duration_ms, ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost, + SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) AS error_count, MAX(l.created_at) AS last_active + FROM api_call_logs l JOIN users u ON l.user_id = u.id + `; + if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`; + sql += " GROUP BY u.id, u.username, u.avatar_url ORDER BY total_cost DESC"; + + const { rows } = await pool.query(sql, params); + res.json(rows); + }); + + router.get("/admin/usage/by-model", requireAuth, requireManagementAccess, async (req, res) => { + const { period = "7d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClauses = []; + const params = []; + let idx = 1; + + if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`); + idx = appendEnterpriseScope( + whereClauses, + params, + req.user, + "COALESCE(l.enterprise_id, u.enterprise_id)", + idx, + ); + + let sql = ` + SELECT l.model, l.provider, MAX(COALESCE(l.display_model, l.model, l.provider)) AS display_model, COUNT(*) AS total_calls, + COALESCE(SUM(l.prompt_tokens), 0) AS total_prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) AS total_completion_tokens, + ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost + FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id + `; + if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`; + sql += " GROUP BY l.model, l.provider ORDER BY total_cost DESC"; + + const { rows } = await pool.query(sql, params); + res.json(rows); + }); + + router.get("/admin/usage/daily", requireAuth, requireManagementAccess, async (req, res) => { + const { period = "7d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClauses = []; + const params = []; + let idx = 1; + + if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`); + idx = appendEnterpriseScope( + whereClauses, + params, + req.user, + "COALESCE(l.enterprise_id, u.enterprise_id)", + idx, + ); + + let sql = ` + SELECT l.created_at::date AS date, COUNT(*) AS total_calls, + ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost, + SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) AS error_count + FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id + `; + if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`; + sql += " GROUP BY l.created_at::date ORDER BY date"; + + const { rows } = await pool.query(sql, params); + res.json(rows); + }); + + router.get("/admin/usage/details", requireAuth, requireManagementAccess, async (req, res) => { + const { period = "7d", user_id, model, limit = 50, offset = 0, date_from, date_to } = req.query; + const safeLimit = clampPositiveInteger(limit, 50, 500); + const safeOffset = clampNonNegativeInteger(offset, 0, 100000); + const periodStart = getPeriodStart(period); + const whereClauses = []; + const params = []; + let idx = 1; + + if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`); + idx = appendEnterpriseScope( + whereClauses, + params, + req.user, + "COALESCE(l.enterprise_id, u.enterprise_id)", + idx, + ); + if (user_id) { + whereClauses.push(`l.user_id = $${idx++}`); + params.push(user_id); + } + if (model) { + whereClauses.push(`l.model = $${idx++}`); + params.push(model); + } + if (date_from) { + whereClauses.push(`l.created_at >= $${idx++}`); + params.push(`${date_from}T00:00:00.000Z`); + } + if (date_to) { + whereClauses.push(`l.created_at <= $${idx++}`); + params.push(`${date_to}T23:59:59.999Z`); + } + + let baseSql = `FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id`; + if (whereClauses.length > 0) baseSql += ` WHERE ${whereClauses.join(" AND ")}`; + + const countSql = `SELECT COUNT(*) AS total ${baseSql}`; + const { + rows: [countRow], + } = await pool.query(countSql, params); + + const sql = `SELECT l.*, u.username, u.avatar_url ${baseSql} ORDER BY l.id DESC LIMIT $${idx++} OFFSET $${idx++}`; + const queryParams = [...params, safeLimit, safeOffset]; + + const { rows } = await pool.query(sql, queryParams); + res.json({ + items: rows, + total: Number(countRow?.total || 0), + limit: safeLimit, + offset: safeOffset, + }); + }); +} + +module.exports = { + registerUsageReportRoutes, + registerAdminUsageRoutes, + registerUsageSummaryRoutes, +}; diff --git a/src/routes/user.js b/src/routes/user.js new file mode 100644 index 0000000..70c36f1 --- /dev/null +++ b/src/routes/user.js @@ -0,0 +1,322 @@ +const { + requireAuth, + pool, + getPeriodStart, + buildDailyTrend, + clampPositiveInteger, + clampNonNegativeInteger, + generateOrderNo, +} = require("./context"); + +function registerUserRoutes(router) { + // ── User: Recharge & Usage ─────────────────────────────────────────── + + router.post("/user/recharge", requireAuth, async (req, res) => { + const { amountCents, paymentMethod = "wechat" } = req.body; + if (!amountCents || amountCents <= 0) + return res.status(400).json({ error: "充值金额必须大于0" }); + + if (req.user.enterpriseId && !req.user.isEnterpriseAdmin) { + return res.status(403).json({ error: "企业员工请向管理员申请积分" }); + } + + const orderNo = generateOrderNo(); + const enterpriseName = req.user.enterpriseName || null; + + try { + await pool.query( + ` + INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, user_id, type, amount_cents, payment_method, status) + VALUES ($1, $2, $3, $4, 'personal_recharge', $5, $6, 'pending') + `, + [ + orderNo, + req.user.enterpriseId || null, + enterpriseName, + req.user.id, + Number(amountCents), + paymentMethod, + ], + ); + + res.json({ orderNo, amountCents, paymentMethod }); + } catch (error) { + console.error("[user/recharge] failed", error); + res.status(500).json({ error: "创建充值订单失败" }); + } + }); + + router.get("/user/usage/summary", requireAuth, async (req, res) => { + const { period = "7d" } = req.query; + const periodStart = getPeriodStart(period); + const whereClause = periodStart ? `AND created_at >= ${periodStart}` : ""; + + const { rows } = await pool.query( + ` + SELECT + COUNT(*) AS total_calls, + COALESCE(SUM(prompt_tokens), 0) AS total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS total_completion_tokens, + COALESCE(SUM(duration_ms), 0) AS total_duration_ms, + ROUND(COALESCE(SUM(cost_estimate), 0)::numeric, 4) AS total_cost, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count, + MAX(created_at) AS last_active + FROM api_call_logs + WHERE user_id = $1 ${whereClause} + `, + [req.user.id], + ); + + const summary = rows[0] || {}; + const { rows: generationRows } = await pool.query( + ` + SELECT + COUNT(*) AS total_generation_tasks, + SUM(CASE WHEN type = 'image' AND status = 'completed' THEN 1 ELSE 0 END) AS image_used, + SUM(CASE WHEN type = 'video' AND status = 'completed' THEN 1 ELSE 0 END) AS video_used, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS generation_failed + FROM generation_tasks + WHERE user_id = $1 ${whereClause} + `, + [req.user.id], + ); + const generationSummary = generationRows[0] || {}; + const betaUnlimited = + req.user.billingMode === "beta_unlimited" && + (!req.user.betaExpiresAt || new Date(req.user.betaExpiresAt).getTime() > Date.now()); + + res.json({ + ...summary, + balanceCents: req.user.balanceCents || 0, + enterpriseBalanceCents: req.user.enterpriseBalanceCents || 0, + billingMode: req.user.billingMode || "credits", + betaExpiresAt: req.user.betaExpiresAt || null, + betaUnlimited, + textUsed: Number(summary.total_prompt_tokens || 0) + Number(summary.total_completion_tokens || 0), + imageUsed: Number(generationSummary.image_used || 0), + videoUsed: Number(generationSummary.video_used || 0), + generationTaskCount: Number(generationSummary.total_generation_tasks || 0), + generationFailedCount: Number(generationSummary.generation_failed || 0), + }); + }); + + router.get("/user/usage/credits", requireAuth, async (req, res) => { + const { rows: txRows } = await pool.query( + `SELECT COALESCE(SUM(ABS(amount_cents)) FILTER (WHERE amount_cents < 0), 0) AS total_used_cents + FROM transactions WHERE user_id = $1`, + [req.user.id], + ); + const totalUsedCents = Number(txRows[0]?.total_used_cents || 0); + const { rows: taskRows } = await pool.query( + `SELECT + COUNT(*) AS total_tasks, + SUM(CASE WHEN type = 'image' AND status = 'completed' THEN 1 ELSE 0 END) AS image_tasks, + SUM(CASE WHEN type = 'video' AND status = 'completed' THEN 1 ELSE 0 END) AS video_tasks + FROM generation_tasks WHERE user_id = $1`, + [req.user.id], + ); + const taskSummary = taskRows[0] || {}; + + const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200); + + const { rows: recordRows } = await pool.query( + `SELECT id, type, status, params_json, cost_cents, billing_refunded, created_at, completed_at + FROM generation_tasks + WHERE user_id = $1 + ORDER BY id DESC + LIMIT $2`, + [req.user.id, recordsLimit], + ); + + const { rows: modelRows } = await pool.query( + `SELECT + COALESCE(params_json::jsonb->>'requestedModel', params_json::jsonb->>'model', type, 'unknown') AS model, + COUNT(*) FILTER (WHERE status = 'completed') AS task_count, + COALESCE(SUM( + CASE + WHEN billing_refunded = 1 THEN 0 + WHEN cost_cents > 0 THEN cost_cents + WHEN status = 'completed' AND type = 'image' THEN 20 + WHEN status = 'completed' AND type = 'video' THEN 500 + ELSE 0 + END + ), 0) AS used_cents + FROM generation_tasks + WHERE user_id = $1 AND status IN ('completed', 'running', 'failed') + GROUP BY COALESCE(params_json::jsonb->>'requestedModel', params_json::jsonb->>'model', type, 'unknown') + ORDER BY task_count DESC + LIMIT 50`, + [req.user.id], + ); + + const records = recordRows.map((row) => { + let modelName = ""; + let prompt = ""; + let resolution = null; + let costCents = Number(row.cost_cents || 0); + let estimatedCents = 0; + try { + const params = row.params_json ? JSON.parse(row.params_json) : {}; + modelName = params.requestedModel || params.model || row.type || ""; + prompt = params.prompt || ""; + resolution = params.resolution || params.quality || params.ratio || null; + if (row.status === "completed") { + if (row.type === "image") { + estimatedCents = 20; + } else if (row.type === "video") { + const dur = params.duration || 5; + const res = String(params.resolution || params.quality || "").toUpperCase(); + const model = String(params.model || params.requestedModel || "").toLowerCase(); + let rate = 1; + if (model.includes("happyhorse")) rate = res === "720P" ? 0.72 : 1.28; + else if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) rate = res === "720P" ? 0.6 : 1; + else if (model.includes("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1; + else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8; + estimatedCents = Math.ceil(rate * dur * 100); + } + } + } catch { + modelName = row.type || ""; + } + // Prefer the real charged amount; fall back to fee-rate estimate for + // historical tasks created before cost_cents was recorded. + if (costCents === 0 && row.status === "completed") costCents = estimatedCents; + // Refunded failures cost the user nothing. + if (row.billing_refunded === 1) costCents = 0; + const durationSeconds = row.completed_at && row.created_at + ? Math.round((new Date(row.completed_at).getTime() - new Date(row.created_at).getTime()) / 1000) + : null; + return { + id: String(row.id), + userId: req.user.id, + username: req.user.username || "", + model: modelName, + taskType: row.type || "unknown", + resolution, + durationSeconds, + amountCents: costCents, + prompt, + status: row.status === "completed" ? "completed" : row.status === "failed" ? "failed" : row.status, + createdAt: row.created_at, + }; + }); + + const { rows: trendRows } = await pool.query( + `SELECT + to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day, + COUNT(*) FILTER (WHERE status = 'completed') AS task_count, + COALESCE(SUM( + CASE + WHEN billing_refunded = 1 THEN 0 + WHEN cost_cents > 0 THEN cost_cents + WHEN status = 'completed' AND type = 'image' THEN 20 + WHEN status = 'completed' AND type = 'video' THEN 500 + ELSE 0 + END + ), 0) AS used_cents + FROM generation_tasks + WHERE user_id = $1 AND created_at >= NOW() - INTERVAL '6 days' + GROUP BY day + ORDER BY day ASC`, + [req.user.id], + ); + const dailyTrend = buildDailyTrend(trendRows); + + res.json({ + balanceCents: req.user.balanceCents || 0, + enterpriseBalanceCents: req.user.enterpriseBalanceCents || 0, + totalUsedCents, + billingMode: req.user.billingMode || "credits", + betaUnlimited: + req.user.billingMode === "beta_unlimited" && + (!req.user.betaExpiresAt || new Date(req.user.betaExpiresAt).getTime() > Date.now()), + betaExpiresAt: req.user.betaExpiresAt || null, + totalTasks: Number(taskSummary.total_tasks || 0), + imageTasks: Number(taskSummary.image_tasks || 0), + videoTasks: Number(taskSummary.video_tasks || 0), + members: [], + modelBreakdown: modelRows.map((r) => ({ + model: r.model || "unknown", + usedCents: Number(r.used_cents || 0), + taskCount: Number(r.task_count || 0), + })), + dailyTrend, + records, + }); + }); + + router.get("/user/usage/details", requireAuth, async (req, res) => { + const { period = "7d", limit = 50, offset = 0, date_from, date_to } = req.query; + const safeLimit = clampPositiveInteger(limit, 50, 200); + const safeOffset = clampNonNegativeInteger(offset, 0, 100000); + const periodStart = getPeriodStart(period); + const whereClauses = ["user_id = $1"]; + const params = [req.user.id]; + + if (periodStart) whereClauses.push(`created_at >= ${periodStart}`); + if (date_from) { + whereClauses.push(`created_at >= $${params.length + 1}`); + params.push(`${date_from}T00:00:00.000Z`); + } + if (date_to) { + whereClauses.push(`created_at <= $${params.length + 1}`); + params.push(`${date_to}T23:59:59.999Z`); + } + + const whereSql = `WHERE ${whereClauses.join(" AND ")}`; + + const { + rows: [countRow], + } = await pool.query( + ` + SELECT COUNT(*) AS total FROM api_call_logs + ${whereSql} + `, + params, + ); + + const { rows } = await pool.query( + ` + SELECT * FROM api_call_logs + ${whereSql} + ORDER BY id DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `, + [...params, safeLimit, safeOffset], + ); + + res.json({ + items: rows, + total: Number(countRow?.total || 0), + limit: safeLimit, + offset: safeOffset, + }); + }); + + router.get("/user/transactions", requireAuth, async (req, res) => { + const { limit = 50 } = req.query; + const safeLimit = clampPositiveInteger(limit, 50, 200); + const { rows } = await pool.query( + ` + SELECT * FROM transactions + WHERE user_id = $1 + ORDER BY id DESC LIMIT $2 + `, + [req.user.id, safeLimit], + ); + + res.json( + rows.map((t) => ({ + id: t.id, + type: t.type, + amountCents: t.amount_cents, + balanceAfterCents: t.balance_after_cents, + description: t.description, + createdAt: t.created_at, + })), + ); + }); +} + +module.exports = { + registerUserRoutes, +}; diff --git a/src/securityConfig.js b/src/securityConfig.js new file mode 100644 index 0000000..0d9225c --- /dev/null +++ b/src/securityConfig.js @@ -0,0 +1,74 @@ +const DEFAULT_DEV_JWT_SECRET = "dev-secret-change-me"; +const DEFAULT_DEV_ADMIN_PASSWORD = "changeme"; + +let warnedAboutJwtFallback = false; +let warnedAboutAdminFallback = false; + +function isProductionLike() { + return String(process.env.NODE_ENV || "").toLowerCase() === "production"; +} + +function getJwtSecret() { + const configuredSecret = process.env.JWT_SECRET?.trim(); + + if (configuredSecret) { + if (isProductionLike() && configuredSecret === DEFAULT_DEV_JWT_SECRET) { + throw new Error("JWT_SECRET must not use the development fallback value in production"); + } + if (isProductionLike() && configuredSecret.length < 32) { + throw new Error("JWT_SECRET must be at least 32 characters in production"); + } + return configuredSecret; + } + + if (isProductionLike()) { + throw new Error("JWT_SECRET environment variable is required in production"); + } + + if (!warnedAboutJwtFallback) { + console.warn("[security] JWT_SECRET not set; using development fallback secret"); + warnedAboutJwtFallback = true; + } + + return DEFAULT_DEV_JWT_SECRET; +} + +function getDefaultAdminPassword(explicitPassword) { + const providedPassword = typeof explicitPassword === "string" ? explicitPassword.trim() : ""; + const configuredPassword = providedPassword || process.env.DEFAULT_ADMIN_PASSWORD?.trim() || ""; + + if (configuredPassword) { + if (isProductionLike() && configuredPassword === DEFAULT_DEV_ADMIN_PASSWORD) { + throw new Error( + "DEFAULT_ADMIN_PASSWORD must not use the development fallback value in production", + ); + } + return configuredPassword; + } + + if (isProductionLike()) { + throw new Error( + "DEFAULT_ADMIN_PASSWORD environment variable is required in production when bootstrapping the default admin account", + ); + } + + if (!warnedAboutAdminFallback) { + console.warn("[security] DEFAULT_ADMIN_PASSWORD not set; using development fallback password"); + warnedAboutAdminFallback = true; + } + + return DEFAULT_DEV_ADMIN_PASSWORD; +} + +function assertRuntimeSecurityConfig() { + getJwtSecret(); +} + +module.exports = { + DEFAULT_DEV_ADMIN_PASSWORD, + DEFAULT_DEV_JWT_SECRET, + assertRuntimeSecurityConfig, + getDefaultAdminPassword, + getJwtSecret, + isProductionLike, +}; diff --git a/src/settlementWorker.js b/src/settlementWorker.js new file mode 100644 index 0000000..1d146fb --- /dev/null +++ b/src/settlementWorker.js @@ -0,0 +1,89 @@ +const billing = require("./billing"); + +const SETTLEMENT_INTERVAL_MS = 5 * 60 * 1000; +const LEAK_THRESHOLD_MINUTES = 10; + +let timerId = null; + +async function settleOrphanedLeases() { + const { pool, withTransaction } = require("./db"); + const now = new Date(); + const leakCutoff = new Date(now.getTime() - LEAK_THRESHOLD_MINUTES * 60 * 1000).toISOString(); + + const { rows: releasedUnsettled } = await pool.query(` + SELECT id FROM key_leases + WHERE settled = 0 AND released_at IS NOT NULL + `); + + const { rows: leaked } = await pool.query( + ` + SELECT id, key_id FROM key_leases + WHERE settled = 0 AND released_at IS NULL AND leased_at < $1 + `, + [leakCutoff], + ); + + if (leaked.length > 0) { + await withTransaction(async (client) => { + for (const lease of leaked) { + await client.query("UPDATE key_leases SET released_at = NOW() WHERE id = $1", [lease.id]); + await client.query( + "UPDATE api_keys SET active_count = GREATEST(0, active_count - 1) WHERE id = $1", + [lease.key_id], + ); + } + }); + } + + const allOrphaned = [...releasedUnsettled, ...leaked]; + let settled = 0; + let failed = 0; + + for (const lease of allOrphaned) { + try { + await billing.forceSettleLease(lease.id); + settled++; + } catch (err) { + console.error("[settlementWorker] failed to settle lease", lease.id, err.message); + failed++; + } + } + + if (settled > 0 || failed > 0) { + console.log( + `[settlementWorker] settled=${settled} failed=${failed} released_unsettled=${releasedUnsettled.length} leaked=${leaked.length}`, + ); + } +} + +function startSettlementWorker() { + if (timerId) return; + + settleOrphanedLeases().catch((err) => { + console.error("[settlementWorker] initial run failed", err.message); + }); + + timerId = setInterval(() => { + settleOrphanedLeases().catch((err) => { + console.error("[settlementWorker] periodic run failed", err.message); + }); + }, SETTLEMENT_INTERVAL_MS); + + if (timerId.unref) { + timerId.unref(); + } + + console.log( + `[settlementWorker] started (interval=${SETTLEMENT_INTERVAL_MS}ms, leakThreshold=${LEAK_THRESHOLD_MINUTES}min)`, + ); +} + +function stopSettlementWorker() { + if (timerId) { + clearInterval(timerId); + timerId = null; + console.log("[settlementWorker] stopped"); + } +} + +module.exports = { startSettlementWorker, stopSettlementWorker, settleOrphanedLeases }; diff --git a/src/sts.js b/src/sts.js new file mode 100644 index 0000000..548a602 --- /dev/null +++ b/src/sts.js @@ -0,0 +1,141 @@ +/** + * Alibaba Cloud STS (Security Token Service) integration. + * + * Issues temporary OSS credentials scoped to a specific user's prefix, + * so clients can upload directly to OSS without long-term AccessKeys. + */ + +const crypto = require("node:crypto"); + +const STS_ACCESS_KEY_ID = process.env.STS_ACCESS_KEY_ID || ""; +const STS_ACCESS_KEY_SECRET = process.env.STS_ACCESS_KEY_SECRET || ""; +const OSS_ROLE_ARN = process.env.OSS_ROLE_ARN || ""; +const OSS_BUCKET = process.env.OSS_BUCKET || ""; +const OSS_REGION = process.env.OSS_REGION || ""; + +const STS_ENDPOINT = "sts.aliyuncs.com"; +const STS_API_VERSION = "2015-04-01"; +const DEFAULT_DURATION_SECONDS = 900; // 15 minutes +const MAX_DURATION_SECONDS = 3600; + +function isSTSConfigured() { + return !!(STS_ACCESS_KEY_ID && STS_ACCESS_KEY_SECRET && OSS_ROLE_ARN && OSS_BUCKET && OSS_REGION); +} + +/** + * Build a session policy that restricts the assumed role to only + * access objects under users// in the target bucket. + */ +function buildSessionPolicy(userId) { + return JSON.stringify({ + Version: "1", + Statement: [ + { + Effect: "Allow", + Action: ["oss:PutObject", "oss:PutObjectAcl", "oss:GetObject", "oss:DeleteObject"], + Resource: [ + `acs:oss:*:*:${OSS_BUCKET}/users/${userId}/*`, + `acs:oss:*:*:${OSS_BUCKET}/tmp/${userId}/*`, + ], + }, + { + Effect: "Allow", + Action: ["oss:ListObjects"], + Resource: [`acs:oss:*:*:${OSS_BUCKET}`], + Condition: { + StringLike: { + "oss:Prefix": [`users/${userId}/*`, `tmp/${userId}/*`], + }, + }, + }, + ], + }); +} + +/** + * Percent-encode per RFC 3986 (same as AWS/Alibaba signing). + */ +function percentEncode(str) { + return encodeURIComponent(str) + .replace(/!/g, "%21") + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29") + .replace(/\*/g, "%2A"); +} + +/** + * Compute HMAC-SHA1 signature for STS API request. + */ +function computeSignature(method, params, accessKeySecret) { + const sortedKeys = Object.keys(params).sort(); + const canonicalQuery = sortedKeys + .map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`) + .join("&"); + + const stringToSign = `${method.toUpperCase()}&${percentEncode("/")}&${percentEncode(canonicalQuery)}`; + return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); +} + +/** + * Call STS AssumeRole API to get temporary credentials for a user. + * + * @param {string|number} userId - The user ID to scope credentials to + * @param {number} [durationSeconds] - Credential lifetime (900-3600s) + * @returns {Promise<{accessKeyId, accessKeySecret, securityToken, expiration, bucket, region, ossPathPrefix}>} + */ +async function assumeRole(userId, durationSeconds = DEFAULT_DURATION_SECONDS) { + if (!isSTSConfigured()) { + throw new Error("STS is not configured on the server"); + } + + const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); + if (!safeUserId) { + throw new Error("Invalid userId for STS token"); + } + + const duration = Math.min(Math.max(durationSeconds, 900), MAX_DURATION_SECONDS); + + const params = { + Action: "AssumeRole", + Version: STS_API_VERSION, + RoleArn: OSS_ROLE_ARN, + RoleSessionName: `omniai-user-${safeUserId}-${Date.now()}`, + DurationSeconds: String(duration), + Policy: buildSessionPolicy(safeUserId), + Format: "JSON", + AccessKeyId: STS_ACCESS_KEY_ID, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: crypto.randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + }; + + params.Signature = computeSignature("GET", params, STS_ACCESS_KEY_SECRET); + + const queryString = Object.entries(params) + .map(([k, v]) => `${percentEncode(k)}=${percentEncode(String(v))}`) + .join("&"); + + const url = `https://${STS_ENDPOINT}/?${queryString}`; + + const response = await fetch(url, { method: "GET" }); + const data = await response.json(); + + if (data.RequestId && data.Credentials) { + const cred = data.Credentials; + return { + accessKeyId: cred.AccessKeyId, + accessKeySecret: cred.AccessKeySecret, + securityToken: cred.SecurityToken, + expiration: cred.Expiration, + bucket: OSS_BUCKET, + region: OSS_REGION, + ossPathPrefix: `users/${safeUserId}/assets/`, + }; + } + + throw new Error(data.Message || `STS AssumeRole failed: ${JSON.stringify(data)}`); +} + +module.exports = { assumeRole, isSTSConfigured };