Compare commits

..

158 Commits

Author SHA1 Message Date
FengLee
31e8da4259 fix: pin mobile create composer to bottom 2026-06-07 11:59:51 +08:00
FengLee
7519c0c877 fix: remove mobile composer annotation line 2026-06-07 10:53:15 +08:00
FengLee
8e6a1e162a fix: separate mobile create composer from thread 2026-06-06 23:47:15 +08:00
FengLee
1cc44d64a4 feat: adapt all create modes for mobile 2026-06-06 23:18:40 +08:00
FengLee
98e0573359 fix: recover completed generation jobs after browser close 2026-06-06 22:46:37 +08:00
FengLee
a687958a9d fix: render admin password reset as modal 2026-06-06 22:26:01 +08:00
FengLee
fd18f2de68 fix: show gallery reference images 2026-06-06 22:11:36 +08:00
FengLee
e059d445e2 chore: add guarded reference image backfill progress 2026-06-06 22:00:14 +08:00
FengLee
e9b1993209 fix: load env before reference image backfill 2026-06-06 21:50:49 +08:00
FengLee
0ab469fee9 fix: persist reference images and speed previews 2026-06-06 21:45:58 +08:00
FengLee
2f4288dcf3 fix: restore admin password reset and video history reuse 2026-06-06 21:20:33 +08:00
FengLee
141cb08b36 fix: hide unstable Agnes 18s video duration 2026-06-06 20:49:18 +08:00
FengLee
6609a7dec2 fix: extend Agnes video submit timeout 2026-06-06 20:04:47 +08:00
FengLee
567d264673 feat: add built-in layout composition skill 2026-06-06 18:55:39 +08:00
FengLee
4bd360ef29 fix: split Agnes manifest request timeouts 2026-06-06 18:28:10 +08:00
FengLee
dd1118dfb8 fix: prevent Agnes video worker fetch timeout 2026-06-06 18:09:32 +08:00
FengLee
e1ec52ab86 fix: stabilize Agnes video generation flow 2026-06-06 17:28:15 +08:00
FengLee
7ee5950cf6 perf: scope create history loading 2026-06-06 17:02:30 +08:00
FengLee
f21c668d9e perf: restore instant create navigation 2026-06-06 16:35:42 +08:00
FengLee
dd6b0079bb docs: guard production pm2 config during sync 2026-06-06 14:57:27 +08:00
FengLee
7efbb36783 perf: smooth core navigation and defer heavy panels 2026-06-06 14:46:54 +08:00
FengLee
b15843bbee fix: map Agnes video duration to frames 2026-06-06 14:23:40 +08:00
FengLee
48666447fb feat: support cancellable concurrent generation jobs 2026-06-06 13:58:47 +08:00
FengLee
e0d606a6c1 feat: link reference images in prompts 2026-06-06 12:55:00 +08:00
FengLee
fb5760cb36 fix: mark Agnes system models as free 2026-06-06 12:30:49 +08:00
FengLee
eef35c8f10 feat: add Agnes system model templates 2026-06-06 11:46:43 +08:00
FengLee
f449afb956 feat: allow admins to toggle user watermark downloads 2026-06-06 10:35:46 +08:00
FengLee
26cb0ddbb3 fix: serve thumbnail fallback without redirecting off-host 2026-06-06 00:20:59 +08:00
FengLee
e67bc062cf perf: serve watermarked thumbnails for generated image display 2026-06-06 00:16:34 +08:00
FengLee
2dfbd7098e fix: dedupe concurrent watermark rendering 2026-06-06 00:09:03 +08:00
FengLee
23c13f8274 fix: avoid object head precheck for watermarked media 2026-06-06 00:04:02 +08:00
FengLee
f3d5135e0b feat: watermark generated media downloads 2026-06-05 23:42:30 +08:00
FengLee
3ba90c0933 fix: prevent duplicate generation submissions 2026-06-05 23:00:56 +08:00
FengLee
a0c8c128a2 perf: reduce site config refresh overhead 2026-06-05 22:40:06 +08:00
FengLee
79f00aa8f2 fix: preserve image download format 2026-06-04 12:02:17 +08:00
FengLee
4a00eb7ef5 fix: dedupe recovered generation tasks 2026-06-04 11:37:49 +08:00
FengLee
9f41d2c87a fix: honor generated image output format 2026-06-04 11:10:49 +08:00
FengLee
fee527e1a3 fix: cap image results and dedupe history inserts 2026-05-30 18:55:34 +08:00
FengLee
9461531ff3 perf: dedupe site config client refreshes 2026-05-30 14:04:36 +08:00
FengLee
6cc30347a2 perf: reduce generation recovery polling overhead 2026-05-30 13:16:03 +08:00
FengLee
7eacfe9220 fix: keep recovered generation jobs polling 2026-05-30 11:56:46 +08:00
FengLee
d9c1583c1b fix: sync generation persistence and multimodal flow 2026-05-22 23:53:48 +08:00
FengLee
7f76b6224b fix: generate real video gallery thumbnails 2026-05-21 16:44:32 +08:00
FengLee
db6d63e23a fix: optimize video storage and gallery preview 2026-05-20 23:39:31 +08:00
FengLee
634106740a fix: adapt yuanjie happyhorse video manifest 2026-05-20 23:01:18 +08:00
FengLee
080f2e2b95 fix: match yuanjie pricing provider variants 2026-05-20 22:25:22 +08:00
FengLee
d2cb017a8c fix: track admin logs route 2026-05-20 22:01:59 +08:00
FengLee
d7ebab45af feat: add yuanjie pricing sync 2026-05-20 21:56:21 +08:00
FengLee
5d50c72902 fix: cache image history detail previews 2026-05-20 20:31:24 +08:00
FengLee
d8619fd9e6 fix: map yuanjie media manifest references 2026-05-20 19:51:44 +08:00
FengLee
afd8585882 fix: harden yuanjie image result persistence 2026-05-20 19:13:56 +08:00
FengLee
705b54adfe fix: enforce generation credit policy 2026-05-20 17:25:00 +08:00
FengLee
b508d8df58 fix: harden custom image fallback 2026-05-20 16:21:24 +08:00
FengLee
2137a4b23f fix: avoid storage probe redirect timeouts 2026-05-20 15:42:28 +08:00
FengLee
4320a1499c fix: harden gallery storage and admin ops 2026-05-20 15:25:44 +08:00
FengLee
ca2a009948 fix: reduce gallery first paint payload 2026-05-20 14:32:35 +08:00
FengLee
1554eda37f feat: paginate admin gallery management 2026-05-20 13:58:01 +08:00
FengLee
95a6f5fbb3 docs: index admin gallery moderation 2026-05-20 10:49:42 +08:00
FengLee
632c94be78 feat: add admin gallery management UI 2026-05-20 10:47:13 +08:00
FengLee
518c02f1ba feat: add admin gallery prompt APIs 2026-05-20 10:39:51 +08:00
FengLee
8595cdc6a4 test: cover admin gallery prompt moderation 2026-05-20 10:37:00 +08:00
FengLee
0ceabafb6d docs: plan admin gallery prompt notifications 2026-05-20 10:15:09 +08:00
FengLee
c45c78ac40 docs: design admin gallery prompt notifications 2026-05-20 10:10:37 +08:00
FengLee
f87dab7284 chore: sync source from production 2026-05-20 09:06:52 +08:00
FengLee
6d6fdf286a feat(create): improve mobile creation experience 2026-05-18 17:29:25 +08:00
FengLee
d55cb5bf22 feat(platform): add admin redemption and model management updates 2026-05-16 22:40:55 +08:00
FengLee
81501ade13 feat(api): add smart user API manifest import 2026-05-14 23:39:35 +08:00
FengLee
9966994935 style(navbar): enlarge account avatar 2026-05-14 21:48:45 +08:00
FengLee
2c7bd74bbe fix(navbar): show user avatar in account button 2026-05-14 21:35:49 +08:00
FengLee
8bb839f6fb feat(profile): split username and display nickname 2026-05-14 20:58:16 +08:00
FengLee
50daaf8fb2 Allow trusted iframe embedding 2026-05-14 20:06:41 +08:00
Codex
fdee295098 feat(gallery): add inspiration reuse and image actions 2026-05-14 09:20:14 +00:00
Codex
80a3d3aac8 fix(email): fold MIME body encoding 2026-05-14 03:52:40 +00:00
Codex
cea408fb5d feat(gallery): refine image-sampled border animation 2026-05-14 03:04:51 +00:00
FengLee
57e9fd8459 Remove gallery rotating border frame 2026-05-14 00:04:12 +08:00
FengLee
8c7dbea597 Refine gallery border flow thickness 2026-05-13 23:46:46 +08:00
FengLee
5c5cb6c907 feat(gallery): enhance work card glow effect with vivid color extraction
- Add makeVivid() HSL-based color saturation/lightness boost
- Colors extracted from preview images are now brightened and saturated
- Gallery shell CSS: tighter conic-gradient angle (finer ribbon)
- Added extra .gallery-glow-layer for outer halo bloom
- Slowed rotation from 2.8s to 3.6s for a more elegant flow
- Hover opacity and blur tuned for refined visual depth
2026-05-13 21:58:33 +08:00
FengLee
f06c475034 Refine gallery hover border flow 2026-05-13 20:44:30 +08:00
FengLee
489c4c377a Polish gallery hover and persist create tabs 2026-05-13 20:19:38 +08:00
FengLee
6650f5c6fc Confirm before deleting creation works 2026-05-13 20:01:18 +08:00
FengLee
244439f71f Add creation detail reuse actions 2026-05-13 19:14:27 +08:00
FengLee
997d5dd6e0 Document admin upgrade package readiness 2026-05-13 18:45:40 +08:00
FengLee
baa7bbc79b Prevent manual copy dialog event bleed 2026-05-13 17:11:48 +08:00
FengLee
d5972ad14e Use original image in creation detail preview 2026-05-13 16:24:42 +08:00
FengLee
b263c26ac0 Improve preview quality and create layout 2026-05-13 15:46:24 +08:00
Codex
2fcf9c9773 Avoid original image preview fallback 2026-05-13 05:30:47 +00:00
Codex
a2b2fb82ba Cache compressed image previews 2026-05-13 05:25:36 +00:00
Codex
813ffbfa8b Confirm sync fallback for unsupported image streams 2026-05-13 03:45:22 +00:00
Codex
54e6ab6750 Preserve proxy image API resolution sizes 2026-05-13 03:30:40 +00:00
Codex
4a1a309b4f Render policy markdown and normalize filing links 2026-05-13 03:20:23 +00:00
Codex
bfc98c6a92 Extend image API request templates 2026-05-13 02:40:55 +00:00
Codex
8430b771e1 Route image API requests through templates 2026-05-13 02:30:45 +00:00
Codex
ae6fd626b1 Fit image parameter select labels 2026-05-13 02:19:23 +00:00
Codex
8bc18c6cd8 Split image count into separate generation jobs 2026-05-13 02:10:32 +00:00
Codex
7b3235b218 Avoid false clipboard success on insecure origins 2026-05-13 01:55:34 +00:00
Codex
015184bca7 Fix clipboard copy and model note labels 2026-05-13 01:47:17 +00:00
Codex
6e0c75778e Fix create page generation layout 2026-05-13 01:35:32 +00:00
Codex
17a22f6953 Complete admin data backup coverage 2026-05-13 01:05:19 +00:00
Codex
fa74bac92f Fix fresh deployment build 2026-05-13 00:28:05 +00:00
FengLee
e4b636b85d Harden data portability and backup restore 2026-05-12 23:20:01 +08:00
FengLee
33cc461cc8 Remove nested gallery search input frame 2026-05-12 22:17:43 +08:00
FengLee
11d98a6fc6 Refine previews and concurrent generation UI 2026-05-12 22:07:28 +08:00
FengLee
c52bfa98da Apply glass scrollbar globally 2026-05-12 21:27:40 +08:00
FengLee
19b3eb75cd Style announcement scrollbar 2026-05-12 21:20:33 +08:00
FengLee
4faace0191 Store image style presets with usage ordering 2026-05-12 21:10:01 +08:00
FengLee
901a9ce898 Refine wide layout and disable canvas entry 2026-05-12 20:38:29 +08:00
FengLee
c674f79f07 Make pages full width and center count options 2026-05-12 20:11:17 +08:00
FengLee
618e58b04a Open image count options from input 2026-05-12 20:01:09 +08:00
FengLee
493ae83d2d Allow manual image count entry 2026-05-12 19:54:28 +08:00
FengLee
b9a8521d1b Fix image generation count selector 2026-05-12 19:42:06 +08:00
FengLee
4d5ec0b6b5 Require docs updates for Codex changes 2026-05-12 19:31:47 +08:00
FengLee
c9d6915878 Add Codex development reference docs 2026-05-12 19:27:52 +08:00
FengLee
8ee86a970e Add NewAPI image compatibility and style presets 2026-05-12 17:54:01 +08:00
Codex
5de0e462f0 Simplify canvas connection rendering 2026-05-11 22:52:31 +08:00
Codex
1f721b62b1 Use valid canvas connection colors 2026-05-11 22:29:33 +08:00
Codex
46cca0d4e1 Render canvas connections above flow 2026-05-11 22:25:32 +08:00
Codex
eb7e5fd97e Draw canvas connections in screen overlay 2026-05-11 22:20:11 +08:00
Codex
5cbc0d213d Render canvas connections overlay 2026-05-11 22:11:44 +08:00
Codex
9def9d8664 Make canvas connections visible 2026-05-11 22:06:42 +08:00
Codex
73134516a9 Fix canvas connection interactions 2026-05-11 21:57:10 +08:00
Codex
65b3fe1100 Fix canvas node models and connections 2026-05-11 21:47:06 +08:00
Codex
693fc7cae1 Relax canvas import asset file matching 2026-05-11 21:30:49 +08:00
Codex
dc8bdcdec2 Deduplicate imported canvas assets 2026-05-11 21:27:28 +08:00
Codex
52c7c66cb3 Broaden external canvas field aliases 2026-05-11 21:22:21 +08:00
Codex
5266462603 Import external canvas outputs 2026-05-11 21:13:55 +08:00
Codex
91cba60e5e Resolve external canvas asset references 2026-05-11 21:09:57 +08:00
Codex
1d76cca082 Detect untyped canvas workflow nodes 2026-05-11 21:04:52 +08:00
Codex
4ae03c8caf Support nested canvas workflow imports 2026-05-11 21:00:48 +08:00
Codex
0a1bce06b0 Focus imported canvas workflows 2026-05-11 20:10:20 +08:00
Codex
96555bdf51 Broaden canvas workflow import compatibility 2026-05-11 19:39:01 +08:00
Codex
927faacc5f Fix canvas visibility and workflow import 2026-05-11 19:30:23 +08:00
Codex
723d9832d5 Improve canvas import and pane interactions 2026-05-11 19:23:44 +08:00
Codex
6db64d5161 Fix canvas persistence and reference sizing 2026-05-11 19:16:32 +08:00
FengLee
e3454c7fba Enhance canvas workflow import and node controls 2026-05-11 17:41:45 +08:00
FengLee
0962b4f3fc Show upgrade disk checks by directory 2026-05-10 13:28:39 +08:00
FengLee
ee9516c733 Check disk space before admin upgrades 2026-05-10 13:23:42 +08:00
FengLee
d398ec967f Limit extracted upgrade package size 2026-05-10 13:16:08 +08:00
FengLee
14b7b3afe6 Filter pruned upgrade jobs from responses 2026-05-10 12:37:39 +08:00
FengLee
ffde03bbbc Prune old upgrade job history 2026-05-10 12:35:08 +08:00
FengLee
a68c00ff93 Auto-expire stale upgrade jobs 2026-05-10 09:29:14 +08:00
FengLee
e06fc806f1 Clean upgrade extraction directories 2026-05-10 09:25:22 +08:00
FengLee
adef2da1d9 Preserve known top-level upgrade directories 2026-05-10 09:21:42 +08:00
FengLee
ded16b8778 Verify upgrade backups before applying files 2026-05-10 09:18:19 +08:00
FengLee
61e9fbd6d4 Separate upgrade preflight status 2026-05-10 09:11:29 +08:00
FengLee
8ae28e030d Add admin upgrade package preflight 2026-05-10 00:18:03 +08:00
FengLee
70656562b1 Persist PM2 process list after upgrades 2026-05-10 00:09:50 +08:00
FengLee
1a27177f51 Allow source local-storage route in upgrades 2026-05-10 00:05:08 +08:00
FengLee
66c82fd1ee Make admin upgrade restart non-blocking 2026-05-10 00:01:01 +08:00
FengLee
24be9c550b Add canvas workflow and harden data import 2026-05-09 23:54:18 +08:00
fenglee
1a0607fe8d docs: add detailed project readme 2026-05-09 16:45:30 +08:00
Codex
f2817ab8fd feat: improve admin upgrade logs 2026-05-09 08:07:24 +00:00
Codex
e072f219e4 fix: preserve upgrade runner logs 2026-05-09 07:55:12 +00:00
Codex
8ae0f57488 feat: add admin upgrade workflow 2026-05-09 07:52:57 +00:00
Codex
24eab34305 Handle long running custom image jobs 2026-05-09 06:21:38 +00:00
Codex
c8f0c37cd1 Prefer streaming for custom image generation 2026-05-09 05:42:33 +00:00
Codex
234da90ac6 Fix profile auth token handling 2026-05-09 03:54:46 +00:00
FengLee
e3d274cfd8 Merge remote-tracking branch 'origin/main' 2026-05-09 11:33:15 +08:00
FengLee
d499020d4e Initial miaojingAI project with image resolution guard 2026-05-09 11:32:34 +08:00
376 changed files with 83453 additions and 1 deletions

15
.babelrc Normal file
View File

@@ -0,0 +1,15 @@
{
"presets": [
[
"next/babel",
{
"preset-react": {
"development": true
}
}
]
],
"plugins": [
"@react-dev-inspector/babel-plugin"
]
}

15
.coze Normal file
View File

@@ -0,0 +1,15 @@
[project]
requires = [ "nodejs-24" ]
template = "nextjs"
version = "0.0.18"
appliedPatches = [ ]
[dev]
build = [ "bash", "./scripts/prepare.sh" ]
run = [ "bash", "./scripts/dev.sh" ]
deps = [ "git" ]
[deploy]
build = [ "bash", "./scripts/build.sh" ]
run = [ "bash", "./scripts/start.sh" ]
deps = [ "git" ]

129
.env.example Normal file
View File

@@ -0,0 +1,129 @@
# ============================================================
# 妙境 AI 创作平台 — 环境变量配置模板
# 复制此文件为 .env.local 并填写实际值
# cp .env.example .env.local
# ============================================================
# ----- 本地部署配置 (推荐) -----
# 本地 PostgreSQL 数据库
LOCAL_DB_URL=postgresql://postgres:postgres@localhost:5432/miaojing
LOCAL_DB_ANON_KEY=local-anon-key
LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
# ----- Supabase 云端配置 (可选) -----
# 从 Supabase Dashboard → Settings → API 获取
# 支持 COZE_ 前缀和不带前缀两种变量名
# COZE_SUPABASE_URL=https://your-project.supabase.co
# COZE_SUPABASE_ANON_KEY=your-anon-key-here
# COZE_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
# 也可以使用不带 COZE_ 前缀的变量名 (二选一)
# SUPABASE_URL=https://your-project.supabase.co
# SUPABASE_ANON_KEY=your-anon-key-here
# SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
# ----- 服务端口 (可选) -----
# 默认 5000一般无需修改
# DEPLOY_RUN_PORT=5000
# MIAOJING_API_PORT=5100
# MIAOJING_CONSOLE_PORT=5200
# ----- 管理员注册邀请码 (可选) -----
# 注册时输入此邀请码可创建管理员账号
# 默认值: miaojing-admin-2024
# ADMIN_INVITE_CODE=miaojing-admin-2024
# ADMIN_DEFAULT_PASSWORD=change-this-before-production
# ----- 运行环境 (可选) -----
# DEV = 开发环境, PROD = 生产环境
# COZE_PROJECT_ENV=PROD
# NODE_ENV=production
# APP_BIND_HOST=127.0.0.1
# 部署脚本默认自动安装/切换 Node.js 24 LTS如需使用 22 LTS 可设置为 22
# DEPLOY_NODE_MAJOR=24
# DEPLOY_NODE_INSTALL_DIR=/var/lib/miaojingAI/node
# ----- 项目域名 (可选) -----
# 用于构造回调 URL、分享链接等
# COZE_PROJECT_DOMAIN_DEFAULT=https://your-domain.com
# NEXT_PUBLIC_APP_URL=https://your-domain.com
# APP_BASE_URL=https://your-domain.com
# ----- 第三方平台 iframe 嵌入白名单 (可选) -----
# 留空时默认允许同源和 mozheAPI 域名嵌入。多个来源用逗号或空格分隔。
# MIAOJING_FRAME_ANCESTORS=https://mozhevip.top https://*.mozhevip.top
# ----- 生产安全密钥 (生产环境必须设置) -----
# 建议使用 openssl rand -hex 32 生成
# DATA_ENCRYPTION_KEY=
# JWT_SECRET=
# GENERATION_INTERNAL_SECRET=
# ----- 持久化路径 (生产环境推荐放到项目目录外) -----
# LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage
# BACKUP_DIR=/var/lib/miaojingAI/backups
# ----- 对象存储 (S3/MinIO/OSS 等 S3 兼容服务,可选) -----
# STORAGE_MODE=local # local=仅本地dual=本地+对象存储双写object=仅对象存储
# OBJECT_STORAGE_BUCKET=
# OBJECT_STORAGE_REGION=auto
# OBJECT_STORAGE_ENDPOINT=
# OBJECT_STORAGE_ACCESS_KEY_ID=
# OBJECT_STORAGE_SECRET_ACCESS_KEY=
# OBJECT_STORAGE_FORCE_PATH_STYLE=true
# OBJECT_STORAGE_PREFIX=miaojing
# ----- 雨云 ROS 对象存储控制面 (可选,用于创建/核验桶并生成 OBJECT_STORAGE_* 配置) -----
# RAINYUN_API_BASE_URL=https://api.v2.rainyun.com
# RAINYUN_API_KEY=
# RAINYUN_DEV_TOKEN=
# RAINYUN_ROS_INSTANCE_ID=
# RAINYUN_ROS_BUCKET_NAME=miaojing-prod
# RAINYUN_ROS_OUTPUT_ENV=.env.rainyun-object.generated
# ----- 数据库连接池 (可选) -----
# DB_POOL_MAX=20
# DB_CONNECTION_TIMEOUT_MS=5000
# DB_IDLE_TIMEOUT_MS=30000
# ----- Node HTTP 服务超时 (可选) -----
# HTTP_REQUEST_TIMEOUT_MS=190000
# HTTP_HEADERS_TIMEOUT_MS=65000
# HTTP_KEEP_ALIVE_TIMEOUT_MS=5000
# HTTP_MAX_HEADERS_COUNT=200
# ----- 危险管理功能开关 -----
# 生产环境保持 false。只有完成备份并明确需要清空非管理员用户时才临时改为 true。
# ENABLE_DANGER_ADMIN_CLEAR_USERS=false
# ----- 应用层限流 (可选) -----
# RATE_LIMIT_AUTH_MAX=10
# RATE_LIMIT_EMAIL_MAX=6
# RATE_LIMIT_GENERATION_MAX=20
# RATE_LIMIT_DOWNLOAD_MAX=60
# RATE_LIMIT_ADMIN_MAX=120
# ============================================================
# 说明:
# - 本地部署模式:
# 1. 安装并启动本地 PostgreSQL 数据库
# 2. 创建名为 miaojing 的数据库
# 3. 运行 scripts/init-database.sql 初始化数据库结构
# 4. 配置 LOCAL_DB_URL 等本地数据库环境变量
# 5. 系统默认使用本地存储;配置对象存储后建议先用 STORAGE_MODE=dual 迁移和双写
#
# - Supabase 云端模式:
# 1. 创建 Supabase 项目
# 2. 运行 scripts/init-database.sql 初始化数据库
# 3. 在 Supabase Dashboard 创建 site-assets Storage 桶 (公开读)
# 4. 配置 COZE_SUPABASE_URL 等环境变量
#
# - 无数据库配置时,系统将运行在 Demo 模式:
# Demo 模式下:登录/注册返回模拟数据,公告/网站配置返回默认值
# 管理后台写入操作将返回 503 错误
#
# - AI 图片/视频生成:
# 1. 内置使用 coze-coding-dev-sdk (开发环境可用)
# 2. 用户可在前端"自定义 API"中配置自己的 AI 模型密钥
# 3. 管理员可在管理后台配置系统默认 API
# ============================================================

104
.gitignore vendored Normal file
View File

@@ -0,0 +1,104 @@
.next
# Dependencies
node_modules/
.pnp
.pnp.js
# Production build
dist/
build/
out/
.next/
.rsbuild/
# Testing
coverage/
*.lcov
.nyc_output
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.rainyun-object.generated
# Logs
logs/
!src/app/api/admin/logs/
!src/app/api/admin/logs/route.ts
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Cache
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.npm
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
node-compile-cache/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Temporary files
*.tmp
*.temp
_tmp_*
.tmp/
.temp/
# Optional files
*.tgz
*.gz
*.zip
*.tar
# TypeScript
*.tsbuildinfo
# Misc
.vercel
.turbo
.coze-logs

26
.npmrc Normal file
View File

@@ -0,0 +1,26 @@
loglevel=error
registry=https://registry.npmmirror.com
strictStorePkgContentCheck=false
verifyStoreIntegrity=false
# 网络优化
network-concurrency=16
fetch-retries=3
fetch-timeout=60000
# 严格使用 peer dependencies
strict-peer-dependencies=false
# 自动生成 lockfile
auto-install-peers=true
# lockfile 配置
lockfile=true
prefer-frozen-lockfile=true
# 如果 lockfile 存在但过期,更新而不是失败
resolution-mode=highest
# 不提示 npm 更新
update-notifier=false

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
# Codex Mandatory Project Instructions
This repository is the source code for the MiaoJing AI creation platform.
Before any Codex agent changes code, fixes bugs, reviews behavior, or answers implementation-location questions in this repository, it must first read:
1. `CODEX_MIAOJING_MEMORY.md`
2. `docs/codex-miaojing/README.md`
3. The specific index document linked from the memory file for the task area:
- `docs/codex-miaojing/feature-code-index.md`
- `docs/codex-miaojing/bug-location-guide.md`
- `docs/codex-miaojing/api-reference.md`
- `docs/codex-miaojing/architecture.md`
Do not start by broad-searching the repository when the memory and index documents already identify a likely file path. Use those documents as the first routing layer, then verify against the current source before editing.
Every code adjustment, configuration change, workflow change, API change, UI change, and bug fix must include a documentation check before commit. If the change affects how future agents should locate code, diagnose bugs, call APIs, understand architecture, deploy, or verify behavior, update the matching document in `docs/codex-miaojing/` and, if needed, update `CODEX_MIAOJING_MEMORY.md` in the same commit. Do not leave documentation updates for a later session.
All new development must remain compatible with the production admin upgrade path. Before finishing a change, decide whether it can be delivered as a hot update package or requires a cold update package through the admin console. Changes that touch `src`, server code, API routes, `package.json`, `pnpm-lock.yaml`, database schema, environment variables, PM2/runtime configuration, build scripts, backup/restore scripts, or generated server assets must be treated as cold-update candidates unless the upgrade runner explicitly supports them as safe hot-update payloads. Static/public asset-only changes may be hot-update candidates only after package preflight passes. Never finish deploy-facing work without considering backup, rollback, package preflight, and post-upgrade health checks.

110
CODEX_MIAOJING_MEMORY.md Normal file
View File

@@ -0,0 +1,110 @@
# Codex MiaoJing Memory
Last source audit: 2026-05-12, based on git commit `8ee86a9` (`Add NewAPI image compatibility and style presets`).
This file is the required entry point for Codex work in this repository. Its job is to prevent repeated rediscovery. Every Codex session working on MiaoJing must read this file first, then jump to the referenced index document before editing.
## Required Reading Order
1. Read this file.
2. Read `docs/codex-miaojing/README.md`.
3. Pick the task-specific document:
- Feature or UI location: `docs/codex-miaojing/feature-code-index.md`
- Bug report or regression: `docs/codex-miaojing/bug-location-guide.md`
- API contract, route, auth, request body: `docs/codex-miaojing/api-reference.md`
- System boundaries, data flow, deployment: `docs/codex-miaojing/architecture.md`
- Custom integration keyword such as `元界`, `Agnes`, `mozheAPI`, or `智能配置 API`: also read `docs/codex-miaojing/custom-integrations.md` and search long-term memory for the keyword before editing.
4. Verify the file paths against current source with `rg` or direct file reads.
5. Make the smallest scoped code change that fits the existing architecture.
6. For every adjustment or modification, check whether the change affects any project knowledge document. If it changes code location, UI behavior, API behavior, data shape, schema expectation, deployment flow, verification method, bug-diagnosis path, or provider/platform-specific integration logic, update the corresponding document in the same commit.
7. If a custom integration rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
8. Before finishing new development, classify the production delivery path: admin-console hot update package or admin-console cold update package. Record upgrade, backup, rollback, and health-check implications when the change affects deployable behavior.
## Repository Identity
- Product: MiaoJing AI creation platform.
- Stack: Next.js 16 App Router, React 19, TypeScript, PostgreSQL, local file storage, PM2.
- Package manager: `pnpm` only.
- Primary source directories:
- Pages and API routes: `src/app`
- UI and workflow components: `src/components`
- Business logic and stores: `src/lib`
- Console module entry points: `src/modules`
- Database clients and schema snapshot: `src/storage`
- Deployment and maintenance scripts: `scripts`
## Canonical Local Context
- This local clone was reset to `origin/main` before these docs were written.
- Remote: `https://git.toplee.cn/fenglee/miaojingAI.git`
- If server deployment is requested later, verify the active runtime tree and PM2 cwd before editing. Do not assume a production tree from memory.
- Production access verified on 2026-05-14 used `ssh -p 5238 root@124.174.9.29`; PM2 still served the live tree from `/opt/miaojingAI` through Node under `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`, with web/API/console ports `8000/8100/8200`. `/root/miaojingAI` may coexist and must not be treated as live without PM2 confirmation.
- When syncing source into `/opt/miaojingAI`, preserve production-only runtime files such as `.env.local`, `node_modules`, `.next`, `dist`, `backups`, local storage, and the production `ecosystem.config.cjs`. The repository copy may point at `/root/miaojingAI` and ports `5000/5100/5200`; overwriting production `ecosystem.config.cjs` breaks the live nginx upstream until restored. Manual source archives must exclude `ecosystem.config.cjs`, or the deploy must restore the production copy from the pre-sync backup before PM2 reload.
- New-environment migrations must verify database table ownership as well as grants. If `LOCAL_DB_URL` uses the app user but restored tables are still owned by `postgres`, runtime compatibility checks can fail with `must be owner of table ...`, which can make `/api/model-config` return no `systemApis` even when backend default models exist.
## Fast Routing Map
Use this table before searching.
| Task | Start Here | Then Check |
| --- | --- | --- |
| Home page, shell, navigation, footer, announcement popup | `src/app/page.tsx`, `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, `src/components/announcement-popup.tsx` | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/app/api/announcements/route.ts` |
| Create center tabs | `src/app/create/page.tsx` | `src/components/create/*` |
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/lib/generation-credit-service.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/layout-composition-skill.ts`. Image panels allow multiple active submissions and keep active job cards inside the results column while completed results remain visible. System default model credit deduction is server-side and tied to the selected `system_api_configs` pricing row. The optional 100 Layout Compositions skill is controlled from admin settings and injects composition guidance into image prompts only when enabled. |
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generate/video/route.ts`. Video panels also allow multiple active submissions and keep active job cards inside the results column. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
| Custom integrations (`元界`, `Agnes`, `mozheAPI`, `智能配置 API`) | `docs/codex-miaojing/custom-integrations.md` first | Then use the feature/bug/API/architecture doc that matches the symptom. Search long-term memory for the exact keyword before changing code. Provider-specific built-in template management such as Agnes AI belongs in the `系统默认模型` flow, not in generic smart import. |
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. `/api/gallery/publish` must reuse stable `/api/local-storage/...` generated image/video originals instead of synchronously copying object-backed media during share; external URLs still copy into gallery storage before insertion. History also refreshes on `miaojing_auth_updated` after login/account switch. |
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/lib/media-watermark*.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Generated work media is watermarked server-side before display/download for normal users, using `public/watermark/miaojing-watermark-logo.png` plus `MIAOJING AI`; display requests for generated image originals may redirect to an existing local thumbnail first so pages do not synchronously watermark multi-megabyte object-backed originals, but downloads still use original media. Do not reintroduce raw object-storage redirects for generated images/videos unless the download route has verified an admin role or a user with `profiles.watermark_disabled=true`; admins can toggle that flag per user from `src/components/admin/user-management-tab.tsx` through `/api/admin/users`. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed non-generated files may redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
| Data backup/import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `scripts/migration-integrity-check.mjs`. Export includes `_media` for storage assets; import restores media through the active storage adapter, remaps custom IDs, runs in a transaction, dedupes works by URL/source URL/media SHA only within the same `user_id`, and preserves password hashes, encrypted API keys, Manifest paths, and API pricing fields. |
## Current Known Source Warning
At the time this document was created, the remote source imports `@/lib/model-display` from:
- `src/components/create/text-to-image.tsx`
- `src/components/create/image-to-image.tsx`
But `src/lib/model-display.ts` is not present after resetting to `origin/main`. If `pnpm run ts-check` fails on this import, treat it as a pre-existing source-state issue unless your task is specifically to repair it.
The documentation commit also attempted `corepack pnpm run ts-check`. It failed before any source edits with:
- missing module/type resolution for `ag-psd`
- missing module/type resolution for `@xyflow/react`
- missing `@/lib/model-display`
- canvas callback implicit-`any` errors in `src/components/canvas/react-flow-canvas.tsx`
Before treating `ag-psd` or `@xyflow/react` as source bugs, run `corepack pnpm install --frozen-lockfile` in a clean environment; those may be local dependency-install state. The missing `@/lib/model-display` file is a source-tree issue at this audited commit.
## Required Change Discipline
- Do not bypass auth helpers. Use `getAuthenticatedUser`, `getAuthenticatedUserId`, `requireAdminUser`, or `requireAdmin`.
- Do not write raw API keys to responses or logs. Use encryption helpers in `src/lib/server-crypto.ts` and safe mapping functions.
- Do not create ad hoc storage paths. Use `src/lib/local-storage.ts`, preserve path traversal checks, and keep local/object storage migration compatible with stable `/api/local-storage/<key>` URLs.
- Do not create a second generation flow without checking `src/lib/generation-job-client.ts`, `src/lib/generation-job-runner.ts`, and `src/lib/generation-job-worker.ts`.
- Do not change admin upgrade behavior without checking both `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs`, `scripts/backup-create.sh`, and `scripts/backup-restore.sh`.
- Do not change backup/import/restore behavior without preserving transaction boundaries, media restore behavior, dedupe rules, and pre-restore safety backups.
- Do not change public content rendering without checking both backend persistence (`site-config`, `announcements`) and frontend consumers (`site-config-sync`, policy pages, footer, popup).
- Do not treat a feature as complete until its production update-package path is clear. Hot-update candidates should be limited to payloads the upgrade runner accepts without restart, usually static/public assets. Changes to source code, API routes, server code, dependencies, DB schema or compatibility migrations, environment variables, PM2/runtime config, build scripts, backup/restore scripts, or deployment scripts are cold-update candidates and must preserve package preflight, backup, rollback, rebuild, restart, and `/api/health` verification.
## Documentation Maintenance Rule
Every Codex change must include a documentation impact check. This applies to feature work, refactors, configuration changes, UI copy/behavior changes, API changes, deployment changes, and every bug fix.
If the code or behavior changed in a way that future Codex sessions need to know, update the matching documentation in the same commit. Do not postpone documentation updates.
Required mapping:
- New or changed route: update `docs/codex-miaojing/api-reference.md`.
- Moved, renamed, or newly important code path: update `docs/codex-miaojing/feature-code-index.md`.
- Newly discovered regression pattern or repeat bug: update `docs/codex-miaojing/bug-location-guide.md`.
- New subsystem, persistence model, deployment path, or runtime boundary: update `docs/codex-miaojing/architecture.md`.
- Changed required Codex workflow, canonical source, validation baseline, or cross-document rule: update `CODEX_MIAOJING_MEMORY.md` and usually `AGENTS.md`.
If a code change truly has no documentation impact, state that explicitly in the final response or commit notes. Otherwise, the documentation update is part of the required fix, not optional cleanup.

703
DEPLOY_BACKUP_UPGRADE.md Normal file
View File

@@ -0,0 +1,703 @@
# 妙境 AI 创作平台部署、备份、升级操作文档
本文档用于生产环境的一键部署、日常备份、安全升级和故障回滚。所有命令默认在服务器源码目录执行。
## 1. 适用范围
适用于 Linux 服务器本地化部署架构:
- 前端访问服务:`miaojing-web`
- 后端 API 服务:`miaojing-api`
- 管理后台服务:`miaojing-console`
- 数据库PostgreSQL
- 文件持久化:本地存储目录
- 进程管理PM2
默认路径和端口:
| 项目 | 默认值 |
| --- | --- |
| 项目部署目录 | `/opt/miaojingAI` |
| 数据存储目录 | `/var/lib/miaojingAI` |
| 前端访问端口 | `5000` |
| 后端 API 内部端口 | `5100` |
| 管理后台内部端口 | `5200` |
| 管理后台访问路径 | `/console` |
| 本地存储目录 | `/var/lib/miaojingAI/storage` |
| 备份目录 | `/var/lib/miaojingAI/backups` |
## 2. 部署前准备
### 2.1 推荐服务器配置与操作系统
生产环境推荐使用稳定版 Linux 发行版,不建议直接使用 Windows Server 运行生产服务。Windows 可以作为开发环境,生产环境建议使用 Ubuntu/Debian 系服务器,便于安装 PostgreSQL、PM2、Nginx、Certbot 和系统级守护服务。
推荐操作系统:
| 操作系统 | 推荐版本 | 适用场景 | 说明 |
| --- | --- | --- | --- |
| Ubuntu Server | `24.04 LTS` | 首选生产环境 | 生态成熟Node.js、PostgreSQL、Nginx、Certbot 支持完整 |
| Ubuntu Server | `26.04 LTS` | 新服务器可选 | 适合全新环境,部署前先在测试机完成一次完整构建和功能验证 |
| Ubuntu Server | `22.04 LTS` | 旧服务器可继续使用 | 仍可运行,但新采购服务器优先选择更新 LTS |
| Debian | `13` | 稳定生产环境 | 当前稳定版,适合长期运行 |
| Debian | `12` | 旧服务器可继续使用 | 已进入旧稳定版周期,可用但新部署优先 Debian 13 |
| CentOS Stream / Rocky Linux / AlmaLinux | `9` | 企业内网环境 | 可用,但脚本和文档示例默认以 Ubuntu/Debian 为主 |
服务器配置建议:
| 场景 | CPU | 内存 | 磁盘 | 带宽 | 适用说明 |
| --- | --- | --- | --- | --- | --- |
| 最低测试环境 | 2 核 | 4 GB | 40 GB SSD | 5 Mbps | 仅用于功能验证和少量测试用户,不建议正式上线 |
| 小型生产环境 | 4 核 | 8 GB | 100 GB SSD | 10 Mbps 以上 | 适合早期上线、低到中等访问量,是推荐起步配置 |
| 标准生产环境 | 8 核 | 16 GB | 200 GB SSD | 20 Mbps 以上 | 适合多人同时使用、较多图片/视频结果持久化 |
| 高并发/商用环境 | 16 核以上 | 32 GB 以上 | 500 GB SSD 或独立对象存储 | 50 Mbps 以上 | 建议拆分数据库、对象存储、反向代理和应用服务 |
磁盘规划建议:
| 目录 | 推荐大小 | 用途 |
| --- | --- | --- |
| 项目部署目录,如 `/opt/miaojingAI` | 20 GB 以上 | 源码、依赖、构建产物 |
| 数据存储目录,如 `/var/lib/miaojingAI` | 80 GB 以上 | 上传文件、生成结果、备份、部署日志 |
| PostgreSQL 数据目录 | 50 GB 以上 | 用户、作品、订单、配置、日志等业务数据 |
生产环境基础要求:
- CPU 架构:`x86_64/amd64`
- Node.js推荐 `24.x LTS`;可使用 `22.x LTS`;不建议新生产环境继续使用已过维护周期的 Node.js 20
- PostgreSQL`16+`,推荐 `17``18`;最低可用 `14+`,但需要确认仍在安全维护期
- pnpm`9.x+`
- PM2最新版稳定版
- Nginx建议用于域名反向代理、HTTPS、静态压缩和访问日志
- HTTPS正式上线必须配置有效 TLS 证书
- 时区:建议设置为 `Asia/Shanghai`
- 防火墙:只开放 `80/443` 和必要的 SSH 端口,`5100/5200` 保持内网访问
- 应用服务默认绑定 `127.0.0.1`,通过 Nginx 对外提供访问;不要把 `APP_BIND_HOST` 改为 `0.0.0.0`,除非已有上层网络隔离
- 备份:数据库和 `/var/lib/miaojingAI/storage` 必须有定期离线或异地备份
不建议用于正式生产的环境:
- 非 LTS 版本 Linux例如 Ubuntu 中间版本;这类系统生命周期短,适合测试,不适合长期生产。
- 低于 4 核 8 GB 的服务器Next.js 构建、图片/视频结果持久化和 PostgreSQL 同机运行时容易出现资源不足。
- 只暴露裸 IP 和 HTTP 端口正式上线必须使用域名、Nginx 反向代理和 HTTPS。
- 将数据库、上传文件、生成结果和备份放在项目代码目录内;升级和回滚时容易误删。
- 使用默认管理员密码、默认数据库密码或公开的 SSH 密码。
### 2.2 必需软件
部署脚本会自动安装或切换 Node.js 到生产推荐版本,默认使用 `24.x LTS`。如需固定为 `22.x LTS`,执行脚本前设置:
```bash
DEPLOY_NODE_MAJOR=22 bash scripts/deploy-or-upgrade.sh
```
Node.js 会优先从国内可访问镜像源下载,顺序包括 npmmirror、清华、腾讯、华为最后回退到官方源。默认安装目录为数据目录下的 `node` 子目录,例如 `/var/lib/miaojingAI/node`,不会覆盖系统自带 Node.js。
部署脚本会检查以下命令是否存在:
- `node` / `npm`:没有或版本不符合目标 LTS 时,脚本会自动安装/切换
- `pnpm`
- `pm2`
- `psql`
- `pg_dump`
- `tar`
- `rsync`
- `curl`
Ubuntu/Debian 可参考:
```bash
sudo apt update
sudo apt install -y postgresql-client tar rsync curl
node -v
npm -v
```
如果未安装 `pnpm``pm2`,一键脚本会通过当前 Node.js 对应的 npm 自动安装,并使用国内可访问镜像源。
### 2.3 PostgreSQL 数据库
部署前需要准备好 PostgreSQL 数据库,并确认服务器可以连接。
示例连接地址:
```text
postgresql://postgres:postgres@localhost:5432/miaojing
```
可先手动验证:
```bash
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -c "SELECT 1;"
```
## 3. 首次部署
### 3.1 执行一键部署脚本
在服务器源码目录执行:
```bash
bash scripts/deploy-or-upgrade.sh
```
脚本会自动检测目标部署目录。如果目标目录没有部署记录,会进入首次部署流程。
### 3.2 按提示填写参数
首次部署时需要填写:
| 参数 | 说明 |
| --- | --- |
| 项目部署目录 | 生产运行目录,例如 `/opt/miaojingAI` |
| 数据存储目录 | 持久化数据根目录,例如 `/var/lib/miaojingAI` |
| 前端访问端口 | 浏览器访问端口,例如 `5000` |
| 后端 API 内部端口 | 后端服务端口,例如 `5100` |
| 管理后台内部端口 | 管理后台服务端口,例如 `5200` |
| 管理员账号/昵称 | 管理员登录账号展示名 |
| 管理员邮箱 | 管理员登录邮箱 |
| 管理员密码 | 管理员初始密码 |
| 正式访问地址 | 有域名时填写 `https://域名`,没有域名时可留空使用服务器 IP 和端口 |
| PostgreSQL 连接地址 | 数据库连接字符串 |
### 3.3 部署完成后的输出
部署成功后,脚本会输出:
- 前台访问地址
- 管理后台地址
- 管理员账号
- 管理员邮箱
- 管理员密码
- 项目目录
- 数据目录
- 日志文件路径
管理后台访问地址示例:
```text
https://你的域名/console
```
只有管理员账号可以登录管理后台。
## 4. Nginx、HTTPS 与防火墙
正式上线必须使用 Nginx 反向代理和 HTTPS不建议把 `5000/5100/5200` 直接暴露到公网。仓库已提供生产模板:
```text
deploy/nginx/miaojing-production.conf
```
### 4.1 配置 Nginx
```bash
sudo cp deploy/nginx/miaojing-production.conf /etc/nginx/sites-available/miaojing.conf
sudo nano /etc/nginx/sites-available/miaojing.conf
sudo ln -sf /etc/nginx/sites-available/miaojing.conf /etc/nginx/sites-enabled/miaojing.conf
sudo nginx -t
sudo systemctl reload nginx
```
需要替换模板中的:
- `example.com``www.example.com`
- 证书路径
- 如果一键脚本中修改过前端端口,同步替换 `proxy_pass http://127.0.0.1:5000`
### 4.2 配置 HTTPS 证书
推荐使用 Certbot
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
```
### 4.3 防火墙要求
生产环境只开放 `80/443` 和必要 SSH 端口,应用内部端口只允许本机访问。
Ubuntu UFW 示例:
```bash
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 5000/tcp
sudo ufw deny 5100/tcp
sudo ufw deny 5200/tcp
sudo ufw enable
sudo ufw status verbose
```
云服务器安全组也必须同步只开放 `80/443/SSH`
## 5. 部署后的检查
### 5.1 检查 PM2 服务
```bash
pm2 list
```
正常情况下应看到:
- `miaojing-web`
- `miaojing-api`
- `miaojing-console`
状态应为 `online`
### 5.2 检查访问地址
```bash
curl -I http://127.0.0.1:5000
curl -fsS http://127.0.0.1:5000/api/health
curl -I http://127.0.0.1:5000/console
```
### 5.3 检查日志
```bash
pm2 logs miaojing-web --lines 100
pm2 logs miaojing-api --lines 100
pm2 logs miaojing-console --lines 100
```
部署脚本日志位于:
```text
数据存储目录/logs/deploy-日期时间.log
```
示例:
```text
/var/lib/miaojingAI/logs/deploy-20260503-120000.log
```
## 6. 备份操作
### 6.1 自动备份
执行升级流程时,一键脚本会自动创建升级前备份,备份内容包括:
- PostgreSQL 数据库 dump
- `.env.local`
- 本地存储文件
- `package.json`
- 备份清单 `manifest.json`
默认备份目录:
```text
/var/lib/miaojingAI/backups
```
### 6.2 手动创建备份
进入生产部署目录:
```bash
cd /opt/miaojingAI
pnpm backup:create
```
或直接执行:
```bash
cd /opt/miaojingAI
bash scripts/backup-create.sh
```
如果需要指定备份目录:
```bash
cd /opt/miaojingAI
BACKUP_DIR=/var/lib/miaojingAI/backups bash scripts/backup-create.sh
```
脚本成功后会输出备份文件路径,例如:
```text
/var/lib/miaojingAI/backups/miaojing-backup-20260503-120000.tar.gz
```
### 6.3 查看备份文件
```bash
ls -lh /var/lib/miaojingAI/backups
```
备份脚本默认保留最近 10 个 `miaojing-backup-*.tar.gz` 文件。
## 7. 升级操作
### 7.1 升级前确认
升级前确认:
- 数据库可连接
- 当前服务可访问
- 磁盘空间充足
- 已拿到新版本源码
- 没有正在进行的重要生成任务
建议检查:
```bash
df -h
pm2 list
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -c "SELECT 1;"
```
### 7.2 执行升级
在新版本源码目录执行:
```bash
bash scripts/deploy-or-upgrade.sh
```
当脚本检测到目标部署目录已有 `package.json` 且存在 `.env.local``.miaojing-deployment` 时,会进入升级流程。
升级流程会自动执行:
1. 读取旧部署配置作为默认值
2. 创建升级前备份
3. 迁移旧本地存储到持久化目录
4. 同步新版本代码到部署目录
5. 保留 `.env.local` 中原有非部署配置,只更新数据库、端口、持久化目录和密钥等必要项
6. 补齐数据库结构和索引
7. 可选更新管理员密码
8. 安装依赖
9. 生产构建
10. 执行生产依赖漏洞扫描;`high/critical` 级别漏洞会阻断上线
11. 通过 PM2 重载前端、后端、管理后台
12. 检查 `/api/health``/console`
### 7.3 升级参数说明
升级时管理员密码可以留空:
- 留空:不修改管理员密码
- 输入新密码:更新管理员密码
升级不会删除或覆盖以下数据:
- 用户账号
- 用户资料
- 管理员账号
- 作品记录
- 积分记录
- 订单记录
- 网站配置
- API 供应商配置
- 系统 API 配置
- 用户自定义 API 密钥
- 支付配置
- 公告
- 邮件配置
- 本地存储文件
## 8. 安全与漏洞扫描
一键脚本在构建后会执行:
```bash
pnpm audit --prod --audit-level=high
```
发现 `high``critical` 级别生产依赖漏洞时,脚本会直接失败,必须先升级依赖链并重新构建。`moderate` 级别漏洞会记录在部署日志中,正式上线前仍建议处理。
可手动执行完整审计:
```bash
cd /opt/miaojingAI
pnpm audit --prod --registry=https://registry.npmjs.org
```
项目内置的生产安全措施:
- `/api/admin/clear-users` 默认禁用,只有临时设置 `ENABLE_DANGER_ADMIN_CLEAR_USERS=true` 才能使用。
- `/console` 管理后台登录要求管理员角色,普通用户不能登录。
- 登录、注册、邮箱验证码、生成、下载、管理 API 均有应用层基础限流。
- Nginx 模板提供边缘限流,建议正式生产同时启用应用层和 Nginx 层限流。
- 全局安全响应头包括 `Content-Security-Policy``HSTS``X-Content-Type-Options``X-Frame-Options``Referrer-Policy``Permissions-Policy``Origin-Agent-Cluster`
- 生产构建关闭 `X-Powered-By` 技术指纹,并设置 Node HTTP 请求、请求头、Keep-Alive 超时。
- 下载代理和远程文件持久化会阻断内网、回环和本地地址,降低 SSRF 风险。
- API Key、SMTP 密码等敏感配置采用服务端加密存储,生产环境必须设置 `DATA_ENCRYPTION_KEY``JWT_SECRET`
上线前必须确认:
- `ENABLE_DANGER_ADMIN_CLEAR_USERS=false`
- `.env.local` 权限为 `600`
- 管理员密码不是默认值,也不是弱口令
- 用户注册密码规则至少 8 位,并同时包含字母和数字
- 数据库密码不是默认值
- SSH 禁止公开弱密码,建议使用密钥登录并限制来源 IP
- 云安全组和系统防火墙均未开放 `5000/5100/5200`
- 备份文件目录权限为 `700`,备份文件权限为 `600`
## 9. 回滚操作
### 9.1 使用备份回滚
进入生产部署目录:
```bash
cd /opt/miaojingAI
pnpm backup:restore /var/lib/miaojingAI/backups/miaojing-backup-YYYYMMDD-HHMMSS.tar.gz
```
或直接执行:
```bash
cd /opt/miaojingAI
bash scripts/backup-restore.sh /var/lib/miaojingAI/backups/miaojing-backup-YYYYMMDD-HHMMSS.tar.gz
```
回滚会恢复:
- 数据库
- `.env.local`
- 本地存储文件
### 9.2 回滚后重启服务
```bash
cd /opt/miaojingAI
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
```
### 9.3 回滚后验证
```bash
curl -fsS http://127.0.0.1:5000/api/health
curl -I http://127.0.0.1:5000/console
pm2 list
```
## 10. 数据持久化说明
生产部署中,代码目录和数据目录分离。
代码目录示例:
```text
/opt/miaojingAI
```
数据目录示例:
```text
/var/lib/miaojingAI
```
关键持久化路径:
| 数据 | 路径 |
| --- | --- |
| 本地上传和生成文件 | `/var/lib/miaojingAI/storage` |
| 备份文件 | `/var/lib/miaojingAI/backups` |
| 部署日志 | `/var/lib/miaojingAI/logs` |
| 生产环境变量 | `/opt/miaojingAI/.env.local` |
| 部署标记 | `/opt/miaojingAI/.miaojing-deployment` |
升级同步代码时会排除:
- `.env.local`
- `node_modules`
- `.next`
- `dist`
- `backups`
- `local-storage`
- `.git`
- `.codex_tmp`
因此正常升级不会覆盖用户数据和持久化文件。
## 11. 常用运维命令
### 11.1 查看服务状态
```bash
pm2 list
```
### 11.2 查看服务日志
```bash
pm2 logs miaojing-web --lines 100
pm2 logs miaojing-api --lines 100
pm2 logs miaojing-console --lines 100
```
### 11.3 重启服务
```bash
cd /opt/miaojingAI
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
```
### 11.4 停止服务
```bash
pm2 stop miaojing-web miaojing-api miaojing-console
```
### 11.5 启动服务
```bash
cd /opt/miaojingAI
pm2 start ecosystem.config.cjs --update-env
pm2 save
```
### 11.6 查看部署日志
```bash
ls -lh /var/lib/miaojingAI/logs
tail -n 200 /var/lib/miaojingAI/logs/deploy-*.log
```
## 12. 常见问题处理
### 12.1 Node.js 自动安装失败
脚本默认会自动安装或切换到 Node.js `24.x LTS`。如果服务器无法访问所有 Node.js 镜像源,会提示安装失败。
```bash
node -v
curl -I https://npmmirror.com/mirrors/node/index.json
```
可改用 Node.js `22.x LTS`
```bash
DEPLOY_NODE_MAJOR=22 bash scripts/deploy-or-upgrade.sh
```
### 12.2 依赖安装失败
脚本会依次尝试以下源:
- `https://registry.npmmirror.com`
- `https://registry.npmjs.org`
- `https://mirrors.cloud.tencent.com/npm/`
- `https://mirrors.huaweicloud.com/repository/npm/`
如果全部失败,检查服务器网络和 DNS。
### 12.3 数据库连接失败
检查连接地址:
```bash
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -c "SELECT 1;"
```
检查 PostgreSQL 服务:
```bash
systemctl status postgresql
```
### 12.4 健康检查失败
查看 PM2 日志:
```bash
pm2 logs miaojing-web --lines 120
pm2 logs miaojing-api --lines 120
pm2 logs miaojing-console --lines 120
```
检查端口是否被占用:
```bash
ss -lntp | grep -E ':5000|:5100|:5200'
```
### 12.5 管理后台无法登录
确认访问路径:
```text
http://服务器IP:5000/console
```
确认账号为管理员角色:
```sql
SELECT id, email, nickname, role, is_active FROM profiles WHERE role = 'admin';
```
升级时如果需要重置管理员密码,重新执行:
```bash
bash scripts/deploy-or-upgrade.sh
```
在提示“管理员密码(升级时可留空表示不修改)”时输入新密码。
### 12.6 作品图片或视频无法访问
检查 `.env.local` 中的本地存储目录:
```bash
grep LOCAL_STORAGE_DIR /opt/miaojingAI/.env.local
```
检查文件目录:
```bash
ls -lh /var/lib/miaojingAI/storage
```
升级脚本会自动将旧部署目录中的 `local-storage` 迁移到持久化目录。
## 13. 上线检查清单
上线前逐项确认:
- 数据库连接正常
- 一键部署脚本执行成功
- `pm2 list` 三个服务均为 `online`
- 首页可访问
- `/api/health` 返回正常
- `/console` 可访问
- 管理员可登录后台
- 网站设置可读取和保存
- API 管理配置可读取和保存
- 用户注册、登录正常
- 创作作品可以生成、保存和访问
- 本地存储目录存在且可写
- 手动备份可以成功生成
- 升级前备份路径已记录
- Nginx 已配置域名和 HTTPS
- 系统防火墙和云安全组只开放 `80/443/SSH`
- `5000/5100/5200` 未对公网开放
- `pnpm audit --prod``high/critical` 漏洞
- `.env.local``JWT_SECRET``DATA_ENCRYPTION_KEY``GENERATION_INTERNAL_SECRET` 均已设置
- `ENABLE_DANGER_ADMIN_CLEAR_USERS=false`
- `/console` 响应头不包含 `X-Powered-By`,并包含 `Content-Security-Policy`
- 管理后台“系统日志”可正常筛选查看
- 管理后台仪表盘中数据库、存储、日志状态正常
## 14. 关键文件
| 文件 | 用途 |
| --- | --- |
| `scripts/deploy-or-upgrade.sh` | 一键部署和升级 |
| `scripts/backup-create.sh` | 创建备份 |
| `scripts/backup-restore.sh` | 恢复备份 |
| `scripts/init-database.sql` | 初始化和补齐数据库结构 |
| `scripts/database-optimization-patch.sql` | 数据库优化补丁 |
| `scripts/start.sh` | PM2 调用的启动脚本 |
| `deploy/nginx/miaojing-production.conf` | Nginx 生产反向代理模板 |
| `.env.local` | 生产环境变量 |
| `ecosystem.config.cjs` | PM2 进程配置 |

613
README.md
View File

@@ -1,2 +1,613 @@
# miaojingAI
# 妙境 AI 创作平台
妙境是一个面向个人创作者、内容团队和私有化部署场景的 AI 多模态创作平台。平台围绕“文生图、图生图、文生视频、图生视频、图片反推提示词”构建完整创作链路,提供用户体系、积分/会员、订单支付、作品历史、公开画廊、模型供应商管理、系统配置、数据备份和在线升级能力。
项目基于 Next.js App Router、React、PostgreSQL、本地文件存储和 PM2 运行,支持本地 PostgreSQL 部署,也兼容 Supabase 作为数据库/认证底座。AI 生成能力既支持系统默认供应商,也支持用户自定义 OpenAI/New API 兼容接口。
## 项目截图
以下截图来自开发服务器的真实页面,用于快速了解平台界面和核心工作流。
### 首页
![妙境首页](docs/images/home.png)
### 创作中心
![创作中心](docs/images/create.png)
### 作品画廊
![作品画廊](docs/images/gallery.png)
## 核心能力
### 创作能力
- 文生图:根据文本提示词生成图片,支持尺寸、比例、模型和提示词参数。
- 图生图:上传参考图后进行风格迁移、场景变换、细节重绘和创意延展。
- 文生视频:根据文字描述生成动态视频内容。
- 图生视频:基于静态图片生成动态视频。
- 图片反推提示词:从图片中提取提示词,支持普通提示词、复刻级像素提示词、像素级图生图、像素级文生图等模式。
- 生成任务队列:生成任务写入 `generation_jobs`,前端可轮询任务状态并从历史记录中查看结果。
- 作品管理:保存创作历史、生成参数、结果链接、尺寸、时长、消耗积分等信息。
- 画廊发布:用户可将作品公开到画廊,支持点赞、复制提示词、全屏预览和下载。
### 管理后台
管理后台入口为 `/console`,管理员登录后可进入仪表盘和各类管理模块。
- 仪表盘:统计用户、作品、任务、订单、供应商、公告、日志和系统健康状态。
- API 管理:配置系统供应商、模型推荐、系统 API、New API/OpenAI 兼容站点。
- 用户管理:管理用户资料、角色、会员、积分、账号状态。
- 价格设置:维护会员套餐、积分规则和付费能力。
- 订单管理:查看订单、支付状态和收入统计。
- 支付配置配置微信、支付宝、Stripe 等支付方式的展示和密钥。
- 公告管理:创建站点公告、弹窗公告、有效期和展示策略。
- 数据管理:导出/导入业务数据,适合迁移和人工备份。
- 系统升级:支持热更新和冷更新,自动备份、失败回滚、中文日志和历史记录。
- 系统日志:查看登录、安全、运行、管理操作等平台日志。
- 系统设置维护站点名称、Logo、页脚、邮箱、通知和站点政策内容。
### 运维能力
- 一键部署/升级脚本:`scripts/deploy-or-upgrade.sh`
- 构建脚本:`scripts/build.sh`
- 数据备份:`scripts/backup-create.sh`
- 数据恢复:`scripts/backup-restore.sh`
- 备份列表:`scripts/backup-list.sh`
- 数据库初始化:`scripts/init-database.sql`
- 数据库补丁:`scripts/apply-database-patch.sh`
- 管理后台在线升级 runner`scripts/admin-upgrade-runner.mjs`
- PM2 运行配置:`ecosystem.config.cjs`
## 技术栈
| 层级 | 技术 |
| --- | --- |
| 前端框架 | Next.js 16 App Router、React 19、TypeScript |
| UI 组件 | Radix UI、Tailwind CSS、lucide-react、sonner |
| 服务端 | Next.js Route Handlers、自定义 Node HTTP server、tsup |
| 数据库 | PostgreSQL 14+,可接 Supabase |
| 存储 | 本地文件存储,生产推荐 `LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage` |
| 认证 | 本地 auth schema + session/JWT兼容 Supabase 风格表结构 |
| AI 调用 | coze-coding-dev-sdk、用户自定义 API、系统 API、New API/OpenAI compatible |
| 进程管理 | PM2 |
| 构建工具 | pnpm、Turbopack、tsup、TypeScript |
## 系统架构
```text
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ 首页 / 创作中心 / 画廊 / 个人中心 / 管理后台 Console │
└──────────────────────────────┬──────────────────────────────┘
│ HTTP
┌──────────────────────────────▼──────────────────────────────┐
│ Next.js App Router │
│ app pages + route handlers + middleware + server components │
└───────────────┬──────────────────────┬──────────────────────┘
│ │
┌───────────────▼──────────────┐ │
│ API Route 层 │ │
│ /api/generate/image │ │
│ /api/generate/video │ │
│ /api/generation-jobs │ │
│ /api/admin/* │ │
│ /api/local-storage/* │ │
└───────────────┬──────────────┘ │
│ │
┌───────────────▼──────────────┐ │
│ 业务服务层 │ │
│ auth / model config / jobs │ │
│ credits / orders / storage │ │
│ platform logs / upgrade │ │
└───────┬──────────────┬───────┘ │
│ │ │
┌───────▼──────┐ ┌─────▼────────┐ ┌────▼──────────────────┐
│ PostgreSQL │ │ 本地文件存储 │ │ 上游 AI / New API 站点 │
│ profiles │ │ images/videos│ │ OpenAI compatible │
│ works │ │ avatars │ │ image/video providers │
│ jobs/orders │ │ backups │ │ │
└──────────────┘ └──────────────┘ └───────────────────────┘
```
## 目录结构
```text
.
├── assets/ # 项目内置图片资源
├── docs/images/ # README 使用的项目截图
├── public/ # favicon、logo、公开静态文件
├── scripts/
│ ├── admin-upgrade-runner.mjs # 管理后台热/冷更新执行器
│ ├── apply-database-patch.sh # 执行数据库补丁
│ ├── backup-create.sh # 创建数据库/存储/.env 备份
│ ├── backup-list.sh # 查看备份列表
│ ├── backup-restore.sh # 恢复备份
│ ├── build.sh # Next.js + server 构建
│ ├── deploy-or-upgrade.sh # 一键部署/升级
│ ├── dev.sh # 本地开发启动脚本
│ ├── init-database.sql # PostgreSQL 初始化脚本
│ └── start.sh # 生产启动脚本
├── src/
│ ├── app/ # Next.js App Router 页面与 API
│ ├── components/ # 页面组件、创作组件、管理后台组件、UI 组件
│ ├── hooks/ # 前端 hooks
│ ├── lib/ # 业务逻辑、认证、模型、存储、日志、支付等
│ ├── modules/ # api / console / web 模块入口
│ ├── server.ts # 自定义 Node HTTP server 入口
│ └── storage/ # 数据库客户端和存储适配
├── .env.example # 环境变量模板
├── ecosystem.config.cjs # PM2 配置
├── package.json
└── README.md
```
## 环境要求
### 基础环境
- Linux 服务器,推荐 Ubuntu 22.04+ / Debian 12+
- Node.js 22 或 24部署脚本默认安装/使用 Node.js 24 LTS
- pnpm 9+
- PostgreSQL 14+
- PM2
- curl、tar、rsync
- PostgreSQL 客户端工具:`psql``pg_dump``pg_restore`
### 推荐生产目录
```text
/opt/miaojingAI # 项目代码目录
/var/lib/miaojingAI/storage # 上传文件、生成结果、本地存储
/var/lib/miaojingAI/backups # 数据备份
/var/lib/miaojingAI/upgrade # 管理后台升级状态和升级包
/var/lib/miaojingAI/logs # 部署日志
```
## 环境变量
复制模板:
```bash
cp .env.example .env.local
```
常用变量:
| 变量 | 说明 |
| --- | --- |
| `LOCAL_DB_URL` | PostgreSQL 连接地址,例如 `postgresql://postgres:postgres@localhost:5432/miaojing` |
| `LOCAL_DB_ANON_KEY` | 本地模式 anon key可自定义 |
| `LOCAL_DB_SERVICE_ROLE_KEY` | 本地模式 service role key可自定义 |
| `DATA_ENCRYPTION_KEY` | 生产环境必填,用于加密 API Key 等敏感数据 |
| `JWT_SECRET` | 生产环境必填,用于会话/JWT 签名 |
| `GENERATION_INTERNAL_SECRET` | 生成任务内部密钥 |
| `LOCAL_STORAGE_DIR` | 本地文件存储路径 |
| `BACKUP_DIR` | 备份目录 |
| `DEPLOY_RUN_PORT` | 当前运行角色监听端口 |
| `MIAOJING_API_PORT` | 后端 API 内部端口 |
| `MIAOJING_CONSOLE_PORT` | 管理后台内部端口 |
| `NEXT_PUBLIC_APP_URL` | 前端公开访问地址 |
| `APP_BASE_URL` | 服务端使用的站点地址 |
| `ADMIN_INVITE_CODE` | 管理员邀请码 |
| `ENABLE_DANGER_ADMIN_CLEAR_USERS` | 危险清理功能开关,生产环境应保持 `false` |
| `UPGRADE_STATE_DIR` | 管理后台升级状态目录,默认基于存储目录推导 |
| `UPGRADE_HEALTH_URL` | 冷更新重启后的健康检查 URL |
| `UPGRADE_RESTART_COMMAND` | 冷更新使用的重启命令 |
生成生产密钥示例:
```bash
openssl rand -hex 32
```
## 本地开发
### 1. 安装依赖
```bash
corepack enable
corepack pnpm install --frozen-lockfile
```
### 2. 初始化数据库
创建数据库:
```bash
createdb miaojing
```
执行初始化脚本:
```bash
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -f scripts/init-database.sql
```
### 3. 配置环境变量
```bash
cp .env.example .env.local
```
至少填写:
```env
LOCAL_DB_URL=postgresql://postgres:postgres@localhost:5432/miaojing
LOCAL_DB_ANON_KEY=local-anon-key
LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
LOCAL_STORAGE_DIR=./local-storage
DATA_ENCRYPTION_KEY=替换为 openssl rand -hex 32 的结果
JWT_SECRET=替换为 openssl rand -hex 32 的结果
GENERATION_INTERNAL_SECRET=替换为 openssl rand -hex 32 的结果
```
### 4. 启动开发服务
```bash
corepack pnpm run dev
```
默认开发端口由 `scripts/dev.sh` 控制,可传入端口:
```bash
corepack pnpm run dev -- 5000
```
### 5. 常用开发命令
```bash
# TypeScript 检查
corepack pnpm run ts-check
# ESLint
corepack pnpm run lint
# 完整构建
corepack pnpm run build
# 边界检查
corepack pnpm run check:boundaries
```
## 生产部署
### 方式一:一键部署/升级脚本
推荐使用:
```bash
bash scripts/deploy-or-upgrade.sh
```
脚本会提示填写:
- 项目部署目录
- 数据存储目录
- 前端访问端口
- 后端 API 内部端口
- 管理后台内部端口
- 管理员账号/邮箱/密码
- PostgreSQL 连接地址
- 正式访问地址
首次部署时,脚本会:
1. 检查 tar、rsync、curl、psql、pg_dump 等依赖。
2. 自动安装或切换 Node.js 22/24。
3. 安装 pnpm 和 PM2。
4. 准备 `.env.local`
5. 初始化或检查数据库。
6. 构建 Next.js 和 Node server。
7. 写入 PM2 配置并启动服务。
8. 执行健康检查。
升级已有部署时,脚本会:
1. 检测已有部署目录。
2. 创建升级前备份。
3. 同步新代码。
4. 构建并重启服务。
5. 执行健康检查。
6. 失败时输出备份路径,方便手动恢复。
### 方式二:手动部署
安装依赖:
```bash
corepack enable
corepack pnpm install --frozen-lockfile --prod=false
```
构建:
```bash
corepack pnpm run build
```
启动 PM2
```bash
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
```
健康检查:
```bash
curl http://127.0.0.1:5100/api/health
```
### PM2 服务说明
`ecosystem.config.cjs` 默认包含三个角色:
| PM2 名称 | 角色 | 默认端口 |
| --- | --- | --- |
| `miaojing-api` | 后端 API | `5100` |
| `miaojing-web` | 前端站点 | `5000` |
| `miaojing-console` | 管理后台 | `5200` |
在单进程开发部署中,也可以使用类似 `miaojing-dev` 的 PM2 进程直接运行完整服务。
## 数据库说明
数据库初始化脚本为:
```bash
scripts/init-database.sql
```
核心表包括:
| 表 | 说明 |
| --- | --- |
| `auth.users` | 本地认证用户 |
| `profiles` | 用户资料、角色、会员、积分 |
| `works` | 创作作品、提示词、结果 URL、尺寸、状态 |
| `generation_jobs` | 生成任务队列和进度 |
| `credit_transactions` | 积分流水 |
| `orders` | 订单和支付状态 |
| `user_api_keys` | 用户自定义 API 密钥 |
| `system_api_configs` | 系统默认 API 配置 |
| `api_providers` | 模型供应商 |
| `model_recommendations` | 模型推荐配置 |
| `announcements` | 公告 |
| `site_config` | 站点配置 |
| `platform_logs` | 平台日志 |
执行数据库补丁:
```bash
corepack pnpm run db:patch
```
## 备份与恢复
### 创建备份
```bash
corepack pnpm run backup:create
```
备份包包含:
- PostgreSQL dump`database.dump`
- 本地存储目录:`local-storage`
- 环境变量:`.env.local`
- `package.json`
- 备份 manifest
默认只保留最近 10 个备份。
### 查看备份
```bash
corepack pnpm run backup:list
```
### 恢复备份
```bash
corepack pnpm run backup:restore /path/to/miaojing-backup-YYYYMMDD-HHMMSS.tar.gz
```
恢复动作会:
1. 使用 `pg_restore --clean --if-exists --no-owner` 恢复数据库。
2. 替换本地存储目录。
3. 恢复 `.env.local`
生产环境恢复前建议先停服务,并额外复制当前数据目录。
## 管理后台在线升级
管理后台“系统升级”提供两类升级能力:
### 热更新
用于不影响运行时代码的小补丁,例如 `public/` 下的静态资源。
特点:
- 不重启平台。
- 升级前自动创建数据库/存储/环境备份。
- 升级前自动创建源码快照。
- 失败自动回滚。
- 升级界面实时显示中文日志。
### 冷更新
用于涉及源码、脚本、依赖、配置的大变更。
冷更新流程:
1. 上传升级包。
2. 校验升级包路径,拒绝 `.env``.git``node_modules``backups``local-storage` 等危险路径。
3. 创建数据库、存储、环境配置备份。
4. 创建源码快照。
5. 应用升级包文件。
6. 如依赖变化,执行 `pnpm install --frozen-lockfile --prod=false`
7. 执行 `pnpm run ts-check`
8. 执行 `pnpm run build`
9. 重启平台。
10. 调用 `/api/health` 做健康检查。
11. 失败时自动恢复源码和数据。
升级日志会写入磁盘状态文件。即使冷更新过程中平台重启,管理后台恢复后也能续上日志和状态。升级完成后,当前页面刷新或切换页面会默认收起实时日志,但历史升级记录中可随时查看完整升级内容和日志。
升级包格式:
```text
.tar
.tar.gz
.tgz
```
推荐升级包结构:
```text
upgrade-package/
├── manifest.json
├── src/...
├── public/...
├── scripts/...
├── package.json
└── pnpm-lock.yaml
```
手动运行升级 runner 示例:
```bash
UPGRADE_STATE_DIR=/var/lib/miaojingAI/upgrade \
corepack pnpm run upgrade:run -- \
--job-id manual-001 \
--mode cold \
--package /tmp/upgrade.tgz \
--package-name upgrade.tgz \
--project /opt/miaojingAI
```
## AI 模型与供应商
妙境支持多来源模型配置:
- 系统默认供应商。
- 用户自定义 API Key。
- New API / OpenAI-compatible API 站点。
- 图片模型、视频模型、文本模型分类型管理。
管理员可在管理后台配置供应商、默认模型、API 地址、模型推荐和启用状态。用户可在前端绑定自己的 API Key平台会使用加密存储和尾号预览保护密钥。
## 存储与下载
生成结果会持久化到本地存储目录或配置的存储服务。推荐生产环境把存储放在项目目录之外:
```env
LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage
```
下载链路通过 `/api/download` 或本地存储路由读取原始文件,不应对图片进行二次压缩。作品历史、生成结果、画廊、全屏预览和下载应始终使用原始结果链接或持久化文件路径。
## 健康检查与运维命令
健康检查:
```bash
curl http://127.0.0.1:5100/api/health
```
查看 PM2
```bash
pm2 list
pm2 logs miaojing-api --lines 100
pm2 logs miaojing-web --lines 100
pm2 logs miaojing-console --lines 100
```
重启服务:
```bash
corepack pnpm run pm2:restart
```
保存 PM2 开机配置:
```bash
corepack pnpm run pm2:save
```
## 安全建议
- 生产环境必须设置高强度 `DATA_ENCRYPTION_KEY``JWT_SECRET``GENERATION_INTERNAL_SECRET`
- `.env.local` 不应提交到 git。
- 生产环境不要开启 `ENABLE_DANGER_ADMIN_CLEAR_USERS`
- `LOCAL_STORAGE_DIR``BACKUP_DIR``UPGRADE_STATE_DIR` 建议放在项目目录外。
- 升级包不要包含 `.env`、数据库 dump、用户上传文件或备份目录。
- 对外暴露时建议在 Nginx/Caddy 后面启用 HTTPS。
- 管理后台账号应使用强密码,并限制管理员数量。
- 定期执行 `backup:create` 并把备份复制到异地。
## 常见问题
### 1. 构建时提示缺少 devDependencies
构建需要 devDependencies。部署或 CI 中不要只安装生产依赖,推荐:
```bash
corepack pnpm install --frozen-lockfile --prod=false
```
### 2. 管理后台修改后刷新丢失
优先检查对应接口是否成功写入数据库,再检查 `.env.local` 是否连接到了正确数据库。不要只看前端本地状态。
### 3. 图片下载尺寸不符合预期
排查顺序:
1. 检查上游请求参数是否为目标分辨率。
2. 检查生成任务结果中保存的原始文件尺寸。
3. 检查 `/api/download` 是否直接返回原始文件。
4. 检查前端是否使用缩略图地址下载。
### 4. 冷更新后页面仍是旧版本
检查:
```bash
pm2 list
pm2 describe miaojing-api
pm2 describe miaojing-web
pm2 describe miaojing-console
```
确认 PM2 的 `cwd` 是当前部署目录,并确认构建产物来自同一目录。
### 5. 数据恢复后作品图片丢失
确认备份包内是否包含 `local-storage`,并确认 `.env.local` 中的 `LOCAL_STORAGE_DIR` 指向恢复后的存储目录。
## 版本管理
推荐使用 `main` 作为稳定分支:
```bash
git clone http://172.16.10.127:3000/fenglee/miaojingAI.git
git checkout main
```
提交前建议执行:
```bash
corepack pnpm run ts-check
corepack pnpm run build
```
## License
当前项目为私有项目,未声明开源许可证。未经授权请勿公开分发、复制或商业使用。

View File

@@ -0,0 +1 @@
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS password_hash TEXT;

BIN
assets/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
assets/miaojinglogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,204 @@
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const root = '/root/miaojingAI';
const client = new Client({ connectionString: process.env.LOCAL_DB_URL });
function storageKeyFromUrl(url) {
if (!url || typeof url !== 'string') return null;
const marker = '/api/local-storage/';
const index = url.indexOf(marker);
if (index < 0) return null;
try {
return decodeURIComponent(url.slice(index + marker.length).split('?')[0]);
} catch {
return null;
}
}
function countFiles(dir) {
if (!fs.existsSync(dir)) return 0;
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
count += entry.isDirectory() ? countFiles(full) : 1;
}
return count;
}
async function q(name, sql) {
const result = await client.query(sql);
return { name, rows: result.rows };
}
(async () => {
await client.connect();
const checks = {};
checks.tableCounts = await q('table_counts', `
select 'auth.users' as table_name, count(*)::int as count from auth.users
union all select 'profiles', count(*)::int from profiles
union all select 'user_api_keys', count(*)::int from user_api_keys
union all select 'works', count(*)::int from works
union all select 'generation_jobs', count(*)::int from generation_jobs
union all select 'credit_transactions', count(*)::int from credit_transactions
union all select 'orders', count(*)::int from orders
union all select 'api_providers', count(*)::int from api_providers
union all select 'model_recommendations', count(*)::int from model_recommendations
`);
checks.profiles = await q('profiles', `
select id, email, nickname, role, membership_tier, credits_balance, is_active,
avatar_url is not null as has_avatar, created_at, updated_at
from profiles
order by created_at desc
`);
checks.profileAuthIntegrity = await q('profile_auth_integrity', `
select 'profile_without_auth' as issue, count(*)::int as count
from profiles p left join auth.users u on u.id = p.id where u.id is null
union all
select 'auth_without_profile', count(*)::int
from auth.users u left join profiles p on p.id = u.id where p.id is null
`);
checks.admin = await q('admin', `
select p.id, p.email, p.nickname, p.role, p.membership_tier, p.is_active,
u.password_hash is not null as has_password_hash
from profiles p
left join auth.users u on u.id = p.id
where p.role = 'admin' or p.email = 'admin@example.com'
order by p.created_at
`);
checks.apiKeys = await q('api_keys', `
select id, user_id, provider, supplier_name, type, model_name, note,
api_key_preview, length(api_key_encrypted) as encrypted_len,
api_key_encrypted like 'mjenc:v1:%' as has_enc_prefix,
api_key_encrypted = api_key_preview as encrypted_equals_preview,
is_active, created_at
from user_api_keys
order by created_at desc
`);
checks.apiKeyIntegrity = await q('api_key_integrity', `
select count(*)::int as orphan_api_keys
from user_api_keys k left join profiles p on p.id = k.user_id
where p.id is null
`);
checks.worksDistribution = await q('works_distribution', `
select type, status, is_public, count(*)::int as count,
count(*) filter (where result_url like '/api/local-storage/%')::int as local_urls,
count(*) filter (where result_url like 'data:%')::int as data_urls,
count(*) filter (where result_url like '[%')::int as placeholder_urls
from works
group by type, status, is_public
order by type, status, is_public
`);
checks.worksByUser = await q('works_by_user', `
select p.email, p.nickname, w.user_id, count(*)::int as works
from works w
left join profiles p on p.id = w.user_id
group by p.email, p.nickname, w.user_id
order by works desc nulls last
`);
checks.workIntegrity = await q('work_integrity', `
select 'works_missing_user' as issue, count(*)::int as count
from works w left join profiles p on p.id = w.user_id
where p.id is null
union all
select 'works_null_user', count(*)::int from works where user_id is null
union all
select 'works_bad_url', count(*)::int
from works
where result_url is null or result_url = '' or result_url like 'data:%'
union all
select 'public_not_gallery_url', count(*)::int
from works
where is_public = true and result_url not like '/api/local-storage/gallery/%'
`);
checks.reversePromptWorks = await q('reverse_prompt_works', `
select id, user_id, type,
left(result_url, 100) as result_url,
left(coalesce(params->>'referenceImage', ''), 100) as reference_image,
prompt <> '' as has_prompt,
negative_prompt is not null as has_negative_prompt,
created_at
from works
where type = 'reverse-prompt'
order by created_at desc
limit 20
`);
checks.galleryWorks = await q('gallery_works', `
select id, user_id, type, is_public, left(result_url, 100) as result_url, created_at
from works
where is_public = true
order by created_at desc
`);
checks.generationJobs = await q('generation_jobs', `
select status, count(*)::int as count,
count(*) filter (where user_id is null)::int as null_user_count
from generation_jobs
group by status
order by status
`);
checks.generationJobsByUser = await q('generation_jobs_by_user', `
select p.email, p.nickname, j.user_id, count(*)::int as jobs
from generation_jobs j
left join profiles p on p.id = j.user_id
group by p.email, p.nickname, j.user_id
order by jobs desc nulls last
`);
const works = (await client.query(`
select id, result_url, thumbnail_url, params
from works
order by created_at desc
`)).rows;
const missingFiles = [];
let referencedLocalUrls = 0;
for (const work of works) {
const urls = [
['result_url', work.result_url],
['thumbnail_url', work.thumbnail_url],
['referenceImage', work.params && work.params.referenceImage],
];
if (work.params && Array.isArray(work.params.referenceImages)) {
work.params.referenceImages.forEach((url, index) => urls.push([`referenceImages[${index}]`, url]));
}
for (const [field, url] of urls) {
const key = storageKeyFromUrl(url);
if (!key) continue;
referencedLocalUrls++;
const full = path.join(root, 'local-storage', key);
if (!fs.existsSync(full)) missingFiles.push({ workId: work.id, field, key });
}
}
checks.storage = {
localStorageExists: fs.existsSync(path.join(root, 'local-storage')),
backupsExists: fs.existsSync(path.join(root, 'backups')),
localFiles: countFiles(path.join(root, 'local-storage')),
generatedFiles: countFiles(path.join(root, 'local-storage', 'generated')),
galleryFiles: countFiles(path.join(root, 'local-storage', 'gallery')),
reversePromptFiles: countFiles(path.join(root, 'local-storage', 'reverse-prompt')),
referencedLocalUrls,
missingFiles,
};
console.log(JSON.stringify(checks, null, 2));
await client.end();
})().catch((error) => {
console.error(error);
process.exit(1);
});

67
audit_db_storage.js Normal file
View File

@@ -0,0 +1,67 @@
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const c = new Client({ connectionString: process.env.LOCAL_DB_URL });
const root = '/root/miaojingAI';
function storageKeyFromUrl(url) {
if (!url || typeof url !== 'string') return null;
const marker = '/api/local-storage/';
const idx = url.indexOf(marker);
if (idx < 0) return null;
try { return decodeURIComponent(url.slice(idx + marker.length).split('?')[0]); } catch { return null; }
}
async function query(name, sql, params=[]) {
try { const r = await c.query(sql, params); return { name, rows: r.rows }; }
catch (e) { return { name, error: e.message }; }
}
(async () => {
await c.connect();
const out = {};
out.db = await query('db', `select current_database() as database, current_user as user, now() as checked_at`);
out.tables = await query('tables', `select schemaname, tablename from pg_tables where schemaname in ('public','auth') order by schemaname, tablename`);
const tableNames = out.tables.rows || [];
out.counts = [];
for (const t of tableNames) {
const full = t.schemaname === 'public' ? t.tablename : `${t.schemaname}.${t.tablename}`;
const r = await query(full, `select count(*)::int as count from ${full}`);
out.counts.push({ table: full, count: r.rows?.[0]?.count ?? null, error: r.error });
}
out.admins = await query('admins', `select id,email,nickname,role,membership_tier,is_active,created_at,updated_at from profiles where role='admin' or email in ('admin@example.com','admin@miaojing.ai') order by created_at`);
out.authAdmins = await query('auth_admins', `select id,email,created_at, password_hash is not null as has_password from auth.users where email in ('admin@example.com','admin@miaojing.ai') order by created_at`);
out.profileWithoutAuth = await query('profile_without_auth', `select p.id,p.email,p.role from profiles p left join auth.users u on u.id=p.id where u.id is null order by p.created_at limit 50`);
out.authWithoutProfile = await query('auth_without_profile', `select u.id,u.email from auth.users u left join profiles p on p.id=u.id where p.id is null order by u.created_at limit 50`);
out.worksMissingUser = await query('works_missing_user', `select w.id,w.user_id,w.title,w.type,w.status,w.created_at from works w left join profiles p on p.id=w.user_id where w.user_id is not null and p.id is null order by w.created_at desc limit 50`);
out.worksNullUser = await query('works_null_user', `select id,title,type,status,created_at from works where user_id is null order by created_at desc limit 50`);
out.worksByStatus = await query('works_by_status', `select status, count(*)::int from works group by status order by status`);
out.worksByUser = await query('works_by_user', `select p.email,p.nickname,w.user_id,count(*)::int as works from works w left join profiles p on p.id=w.user_id group by p.email,p.nickname,w.user_id order by works desc nulls last limit 20`);
out.workLikesMissing = await query('work_likes_missing_refs', `select wl.id,wl.user_id,wl.work_id from work_likes wl left join profiles p on p.id=wl.user_id left join works w on w.id=wl.work_id where p.id is null or w.id is null limit 50`);
out.creditMissingUser = await query('credit_missing_user', `select ct.id,ct.user_id,ct.amount,ct.type,ct.created_at from credit_transactions ct left join profiles p on p.id=ct.user_id where p.id is null limit 50`);
out.ordersMissingUser = await query('orders_missing_user', `select o.id,o.user_id,o.order_no,o.status,o.created_at from orders o left join profiles p on p.id=o.user_id where o.user_id is not null and p.id is null limit 50`);
out.apiKeysMissingUser = await query('api_keys_missing_user', `select k.id,k.user_id,k.provider,k.type from user_api_keys k left join profiles p on p.id=k.user_id where p.id is null limit 50`);
out.jobsMissingUser = await query('jobs_missing_user', `select j.id,j.user_id,j.type,j.status,j.created_at from generation_jobs j left join profiles p on p.id=j.user_id where j.user_id is not null and p.id is null order by j.created_at desc limit 50`);
out.jobsNullUser = await query('jobs_null_user', `select id,type,status,created_at from generation_jobs where user_id is null order by created_at desc limit 50`);
out.jobsByUser = await query('jobs_by_user', `select p.email,p.nickname,j.user_id,count(*)::int as jobs from generation_jobs j left join profiles p on p.id=j.user_id group by p.email,p.nickname,j.user_id order by jobs desc nulls last limit 20`);
out.worksFileCheck = { totalLocalUrls: 0, missing: [] };
const works = await query('works_urls', `select id,result_url,thumbnail_url from works order by created_at desc`);
for (const w of works.rows || []) {
for (const field of ['result_url','thumbnail_url']) {
const key = storageKeyFromUrl(w[field]);
if (!key) continue;
out.worksFileCheck.totalLocalUrls++;
const abs = path.join(root, 'local-storage', key);
if (!fs.existsSync(abs)) out.worksFileCheck.missing.push({ workId: w.id, field, key });
}
}
out.storage = {
localStorageExists: fs.existsSync(path.join(root,'local-storage')),
backupsExists: fs.existsSync(path.join(root,'backups')),
localFiles: 0,
galleryFiles: 0,
};
function walk(dir) { if (!fs.existsSync(dir)) return 0; let n=0; for (const e of fs.readdirSync(dir,{withFileTypes:true})) { const p=path.join(dir,e.name); n += e.isDirectory()?walk(p):1; } return n; }
out.storage.localFiles = walk(path.join(root,'local-storage'));
out.storage.galleryFiles = walk(path.join(root,'local-storage','gallery'));
console.log(JSON.stringify(out, null, 2));
await c.end();
})().catch(e => { console.error(e); process.exit(1); });

206
audit_recovered_data.js Normal file
View File

@@ -0,0 +1,206 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
function short(value, length = 160) {
if (value == null) return value;
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.length > length ? `${text.slice(0, length)}...` : text;
}
async function main() {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const tableColumns = await client.query(`
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE (table_schema = 'public' AND table_name IN ('profiles', 'works', 'user_api_keys', 'orders', 'credit_transactions'))
OR (table_schema = 'auth' AND table_name = 'users')
ORDER BY table_schema, table_name, ordinal_position
`);
const userSummary = await client.query(`
SELECT
(SELECT COUNT(*)::int FROM auth.users) AS auth_users,
(SELECT COUNT(*)::int FROM profiles) AS profiles,
(SELECT COUNT(*)::int FROM profiles WHERE role = 'admin') AS admins,
(SELECT COUNT(*)::int FROM profiles WHERE role <> 'admin') AS non_admin_profiles,
(SELECT COUNT(*)::int FROM auth.users u LEFT JOIN profiles p ON p.id = u.id WHERE p.id IS NULL) AS auth_without_profile,
(SELECT COUNT(*)::int FROM profiles p LEFT JOIN auth.users u ON u.id = p.id WHERE u.id IS NULL) AS profile_without_auth,
(SELECT COUNT(*)::int FROM auth.users WHERE password_hash IS NULL OR password_hash = '') AS auth_without_password_hash,
(SELECT COUNT(*)::int FROM auth.users WHERE password_hash IS NOT NULL AND password_hash <> '') AS auth_with_password_hash
`);
const userSamples = await client.query(`
SELECT p.id, p.email, p.nickname, p.role, p.membership_tier, p.is_active,
u.id IS NOT NULL AS has_auth,
(u.password_hash IS NOT NULL AND u.password_hash <> '') AS has_password_hash,
p.created_at
FROM profiles p
LEFT JOIN auth.users u ON u.id = p.id
ORDER BY p.created_at DESC NULLS LAST
LIMIT 80
`);
const workSummary = await client.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = 'completed')::int AS completed,
COUNT(*) FILTER (WHERE is_public = true AND status = 'completed')::int AS public_completed,
COUNT(*) FILTER (WHERE is_public = false AND status = 'completed')::int AS private_completed,
COUNT(*) FILTER (WHERE user_id IS NULL)::int AS null_user_id,
COUNT(*) FILTER (WHERE user_id = $1)::int AS system_user_id,
COUNT(*) FILTER (WHERE p.id IS NULL)::int AS missing_profile,
COUNT(*) FILTER (WHERE p.id IS NOT NULL)::int AS linked_profile
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
`, [SYSTEM_USER_ID]);
const publicWorkSummary = await client.query(`
SELECT
COUNT(*)::int AS public_total,
COUNT(*) FILTER (WHERE w.user_id IS NULL)::int AS null_user_id,
COUNT(*) FILTER (WHERE w.user_id = $1)::int AS system_user_id,
COUNT(*) FILTER (WHERE p.id IS NULL)::int AS missing_profile,
COUNT(*) FILTER (WHERE p.id IS NOT NULL)::int AS linked_profile
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
WHERE w.is_public = true AND w.status = 'completed'
`, [SYSTEM_USER_ID]);
const workByUser = await client.query(`
SELECT
COALESCE(p.email, '[missing-profile]') AS email,
COALESCE(p.nickname, '') AS nickname,
COALESCE(p.role, '') AS role,
w.user_id,
COUNT(*)::int AS total_works,
COUNT(*) FILTER (WHERE w.status = 'completed')::int AS completed_works,
COUNT(*) FILTER (WHERE w.is_public = true AND w.status = 'completed')::int AS public_works,
COUNT(*) FILTER (WHERE w.is_public = false AND w.status = 'completed')::int AS history_works
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
GROUP BY w.user_id, p.email, p.nickname, p.role
ORDER BY total_works DESC
LIMIT 120
`);
const orphanSamples = await client.query(`
SELECT w.id, w.user_id, w.type, w.status, w.is_public, w.result_url,
LEFT(COALESCE(w.prompt, ''), 140) AS prompt,
w.params,
w.created_at
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
WHERE p.id IS NULL OR w.user_id = $1 OR w.user_id IS NULL
ORDER BY w.created_at DESC NULLS LAST
LIMIT 80
`, [SYSTEM_USER_ID]);
const paramKeys = await client.query(`
SELECT key, COUNT(*)::int AS count
FROM works w
CROSS JOIN LATERAL jsonb_object_keys(COALESCE(w.params, '{}'::jsonb)) AS key
LEFT JOIN profiles p ON p.id = w.user_id
WHERE w.is_public = true
AND w.status = 'completed'
AND (p.id IS NULL OR w.user_id = $1 OR w.user_id IS NULL)
GROUP BY key
ORDER BY count DESC, key
LIMIT 80
`, [SYSTEM_USER_ID]);
const possibleOwnerFields = await client.query(`
SELECT
id,
user_id,
params->>'user_id' AS params_user_id,
params->>'userId' AS params_user_id_camel,
params->>'publisher_id' AS publisher_id,
params->>'publisherId' AS publisher_id_camel,
params->>'owner_id' AS owner_id,
params->>'ownerId' AS owner_id_camel,
params->>'created_by' AS created_by,
params->>'createdBy' AS created_by_camel,
params->>'email' AS params_email,
params->>'userEmail' AS params_user_email,
params->>'publisherEmail' AS params_publisher_email,
params->>'nickname' AS params_nickname,
params->>'userName' AS params_user_name,
LEFT(COALESCE(prompt, ''), 120) AS prompt
FROM works
WHERE is_public = true
AND status = 'completed'
AND (user_id IS NULL OR user_id = $1 OR NOT EXISTS (SELECT 1 FROM profiles p WHERE p.id = works.user_id))
ORDER BY created_at DESC NULLS LAST
LIMIT 80
`, [SYSTEM_USER_ID]);
const duplicateCandidates = await client.query(`
SELECT
public.id AS orphan_id,
public.user_id AS orphan_user_id,
private.id AS owned_id,
private.user_id AS owner_user_id,
p.email,
p.nickname,
CASE
WHEN private.result_url = public.result_url THEN 'result_url'
WHEN COALESCE(private.thumbnail_url, '') <> '' AND private.thumbnail_url = public.thumbnail_url THEN 'thumbnail_url'
WHEN COALESCE(private.prompt, '') <> '' AND private.prompt = public.prompt THEN 'prompt_time'
ELSE 'unknown'
END AS match_type,
ABS(EXTRACT(EPOCH FROM (private.created_at - public.created_at)))::int AS seconds_apart,
LEFT(COALESCE(public.prompt, ''), 120) AS prompt
FROM works public
JOIN works private
ON private.id <> public.id
AND private.user_id IS NOT NULL
AND private.user_id <> $1
AND (
private.result_url = public.result_url
OR (
COALESCE(public.thumbnail_url, '') <> ''
AND private.thumbnail_url = public.thumbnail_url
)
OR (
COALESCE(private.prompt, '') <> ''
AND private.prompt = public.prompt
AND private.created_at BETWEEN public.created_at - INTERVAL '30 minutes' AND public.created_at + INTERVAL '30 minutes'
)
)
JOIN profiles p ON p.id = private.user_id
LEFT JOIN profiles public_profile ON public_profile.id = public.user_id
WHERE public.is_public = true
AND public.status = 'completed'
AND (public_profile.id IS NULL OR public.user_id = $1 OR public.user_id IS NULL)
ORDER BY public.created_at DESC NULLS LAST, match_type, seconds_apart
LIMIT 100
`, [SYSTEM_USER_ID]);
const output = {
columns: tableColumns.rows,
userSummary: userSummary.rows[0],
userSamples: userSamples.rows,
workSummary: workSummary.rows[0],
publicWorkSummary: publicWorkSummary.rows[0],
workByUser: workByUser.rows,
orphanSamples: orphanSamples.rows.map(row => ({ ...row, result_url: short(row.result_url, 120), params: short(row.params, 300) })),
anonymousParamKeys: paramKeys.rows,
possibleOwnerFields: possibleOwnerFields.rows,
duplicateCandidates: duplicateCandidates.rows,
};
console.log(JSON.stringify(output, null, 2));
} finally {
client.release();
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
WITH candidates AS (
SELECT DISTINCT ON (public.id)
public.id AS public_id,
private.user_id AS owner_user_id,
CASE
WHEN private.result_url = public.result_url THEN 1
WHEN private.thumbnail_url = public.thumbnail_url THEN 2
ELSE 3
END AS confidence_rank,
ABS(EXTRACT(EPOCH FROM (private.created_at - public.created_at))) AS time_distance
FROM works public
JOIN works private
ON private.id <> public.id
AND private.user_id <> $1
AND (
private.result_url = public.result_url
OR (
public.thumbnail_url IS NOT NULL
AND private.thumbnail_url = public.thumbnail_url
)
OR (
COALESCE(private.prompt, '') = COALESCE(public.prompt, '')
AND private.created_at BETWEEN public.created_at - INTERVAL '10 minutes' AND public.created_at + INTERVAL '10 minutes'
)
)
JOIN profiles p ON p.id = private.user_id
WHERE public.is_public = true
AND public.status = 'completed'
AND public.user_id = $1
ORDER BY public.id, confidence_rank, time_distance
),
updated AS (
UPDATE works w
SET user_id = candidates.owner_user_id
FROM candidates
WHERE w.id = candidates.public_id
RETURNING w.id, w.user_id
)
SELECT COUNT(*)::int AS fixed_count FROM updated
`, [SYSTEM_USER_ID]);
await client.query('COMMIT');
console.log(JSON.stringify(result.rows[0] || { fixed_count: 0 }, null, 2));
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,46 @@
const { Client } = require('pg');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
(async () => {
const client = new Client({ connectionString: process.env.LOCAL_DB_URL });
await client.connect();
try {
await client.query('BEGIN');
const admin = await client.query(
`SELECT id FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1`,
);
if (!admin.rows[0]?.id) {
throw new Error('No admin profile found');
}
const result = await client.query(
`UPDATE generation_jobs
SET user_id = $1
WHERE user_id IS NULL
RETURNING id`,
[admin.rows[0].id],
);
await client.query('COMMIT');
const summary = await client.query(
`SELECT status,
count(*)::int AS count,
count(*) FILTER (WHERE user_id IS NULL)::int AS null_user_count
FROM generation_jobs
GROUP BY status
ORDER BY status`,
);
console.log(JSON.stringify({
backfilled: result.rowCount,
adminUserId: admin.rows[0].id,
summary: summary.rows,
}, null, 2));
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
await client.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
function collectLocalStorageKeys(value, keys = new Set()) {
if (typeof value === 'string') {
const marker = '/api/local-storage/';
const index = value.indexOf(marker);
if (index >= 0) {
keys.add(decodeURIComponent(value.slice(index + marker.length).split('?')[0]));
}
return keys;
}
if (Array.isArray(value)) {
value.forEach(item => collectLocalStorageKeys(item, keys));
return keys;
}
if (value && typeof value === 'object') {
Object.values(value).forEach(item => collectLocalStorageKeys(item, keys));
}
return keys;
}
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const works = await client.query(
`SELECT id, result_url, thumbnail_url, params
FROM works
WHERE title LIKE 'codex-import-edge-%' OR prompt LIKE 'codex-import-edge-%'`,
);
const keys = new Set();
for (const row of works.rows) {
collectLocalStorageKeys(row.result_url, keys);
collectLocalStorageKeys(row.thumbnail_url, keys);
collectLocalStorageKeys(row.params, keys);
}
const deletedWorks = await client.query(
`DELETE FROM works
WHERE title LIKE 'codex-import-edge-%' OR prompt LIKE 'codex-import-edge-%'`,
);
const deletedAnnouncements = await client.query(
`DELETE FROM announcements
WHERE title LIKE 'codex-import-edge-%'`,
);
let deletedFiles = 0;
const base = path.join(process.cwd(), 'local-storage');
for (const key of keys) {
const filePath = path.normalize(path.join(base, key));
if (!filePath.startsWith(base)) continue;
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
deletedFiles += 1;
}
}
console.log(JSON.stringify({
deletedWorks: deletedWorks.rowCount || 0,
deletedAnnouncements: deletedAnnouncements.rowCount || 0,
deletedFiles,
}, null, 2));
} finally {
client.release();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,109 @@
# 妙境 AI 创作平台 Nginx 生产配置模板
# 使用前替换:
# - example.com
# - /etc/letsencrypt/live/example.com/fullchain.pem
# - /etc/letsencrypt/live/example.com/privkey.pem
# - 如脚本中修改了前端端口,请同步 proxy_pass 的 5000
limit_req_zone $binary_remote_addr zone=miaojing_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=miaojing_email:10m rate=6r/m;
limit_req_zone $binary_remote_addr zone=miaojing_generation:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=miaojing_download:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=miaojing_admin:10m rate=120r/m;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
client_max_body_size 80m;
keepalive_timeout 65;
proxy_connect_timeout 60s;
proxy_send_timeout 360s;
proxy_read_timeout 360s;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
access_log /var/log/nginx/miaojing-access.log;
error_log /var/log/nginx/miaojing-error.log warn;
location = /api/auth/login {
limit_req zone=miaojing_auth burst=20 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location = /api/auth/register {
limit_req zone=miaojing_auth burst=20 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location ^~ /api/email/ {
limit_req zone=miaojing_email burst=10 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location ^~ /api/generate/ {
limit_req zone=miaojing_generation burst=30 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location = /api/download {
limit_req zone=miaojing_download burst=120 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location ^~ /api/admin/ {
limit_req zone=miaojing_admin burst=120 nodelay;
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_set_header X-Forwarded-Proto https;
}
location / {
proxy_pass http://127.0.0.1:5000;
include proxy_params;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-Proto https;
}
}

View File

@@ -0,0 +1,36 @@
# MiaoJing Codex Developer Index
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
This folder contains the permanent Codex routing documents for MiaoJing development. They are intentionally source-control tracked so every future agent can start from the same map.
## Documents
| Document | Purpose |
| --- | --- |
| `feature-code-index.md` | Feature-to-file map for UI, workflows, stores, services, scripts, and database code. Use this first for implementation work. |
| `bug-location-guide.md` | Symptom-to-code diagnostic manual. Use this first for bug reports and regressions. |
| `api-reference.md` | Route Handler reference for `/api/**`: method, auth, payload, response, storage side effects. |
| `architecture.md` | System architecture, runtime boundaries, data flow, persistence, deployment, and risk points. |
| `custom-integrations.md` | Named rules for non-generic integrations such as 元界, mozheAPI, and 智能配置 API. Use this whenever a request includes a custom integration keyword. |
## Required Workflow For Codex Agents
1. Read `AGENTS.md` and `CODEX_MIAOJING_MEMORY.md` at repo root.
2. Read the document in this folder that matches the task.
3. If the request includes a custom integration keyword such as `元界`, `mozheAPI`, or `智能配置 API`, read `custom-integrations.md` and search long-term memory for that keyword before editing.
4. Use direct file reads or `rg` only after choosing likely files from the index.
5. For every adjustment, modification, or bug fix, check whether these docs need to change.
6. Update the corresponding doc in the same commit whenever code location, diagnosis path, API behavior, architecture, deployment, or verification knowledge changes.
## Quick Commands
```bash
pnpm run ts-check
pnpm run build
pnpm run lint
pnpm run check:boundaries
pnpm run pm2:restart
```
Use `pnpm`; the project rejects other package managers through `preinstall`.

View File

@@ -0,0 +1,193 @@
# API Reference
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`.
## Auth Conventions
- Bearer token: `Authorization: Bearer <token>`.
- Token creation and verification: `src/lib/session-auth.ts`.
- Authenticated user helpers:
- `getAuthenticatedUser(request)`
- `getAuthenticatedUserId(request)`
- `requireAdminUser(request)`
- `requireAdmin(request)` from `src/lib/admin-auth.ts`
- Internal generation requests use header `x-miaojing-generation-internal`, generated by `src/lib/server-api-config.ts`.
## Response Conventions
- Success responses are JSON unless the route explicitly streams/downloads a file.
- Error responses usually use `{ "error": "message" }` with an HTTP status.
- Admin-only routes return 401 when not logged in and 403 when role is not `admin` or `enterprise_admin`.
## Public/System Routes
| Method | Path | Auth | Source | Purpose |
| --- | --- | --- | --- | --- |
| GET | `/api/health` | Public | `src/app/api/health/route.ts` | Health check. |
| GET | `/api/site-stats` | Public | `src/app/api/site-stats/route.ts` | Read total visits. |
| POST | `/api/site-stats` | Public | `src/app/api/site-stats/route.ts` | Increment visits. |
| GET | `/api/site-config` | Public | `src/app/api/site-config/route.ts` | Read site config, policy Markdown, filing, membership flag, and redeem-code mall URL. |
| PUT | `/api/site-config` | Admin | `src/app/api/site-config/route.ts` | Update site config, redeem-code mall URL, and upload logo/favicon data URLs. |
| GET | `/api/announcements` | Public | `src/app/api/announcements/route.ts` | List announcements. |
| POST | `/api/announcements` | Admin | `src/app/api/announcements/route.ts` | Create announcement. Body: `title`, `content`, `startDate`, `endDate`, `enabled`. |
| PUT | `/api/announcements` | Admin | `src/app/api/announcements/route.ts` | Update announcement. Body includes `id` and changed fields. |
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
| GET | `/api/model-config` | Public, optional bearer token | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. System APIs are filtered to active platform-default models allowed for the current user's membership tier; anonymous users are treated as `free`. |
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Generated work images/videos and their generated/gallery/work thumbnails are watermarked server-side before display, using `src/lib/media-watermark*.ts`; object-backed generated originals must not redirect raw to object storage. When a generated image original has an existing local `works.thumbnail_url`, display requests may 302 to that thumbnail first and then watermark the thumbnail, keeping page loads fast while preserving the stable original URL for fullscreen/download actions. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; non-generated object-backed originals can return a short-lived signed object-storage redirect when configured. Video frame thumbnails are WEBP files, while fallback SVG video thumbnails under `thumbnails/.../*.svg` may be rasterized when watermarked. The public URL shape remains stable across migration. |
| GET | `/api/download?url=...&filename=...` | Public by URL, optional bearer/downloadToken | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Generated local-storage media returns watermarked bytes by default; raw generated media is allowed only after the route authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
## Auth And Account Routes
| Method | Path | Auth | Source | Request | Response |
| --- | --- | --- | --- | --- | --- |
| POST | `/api/auth/login` | Public | `src/app/api/auth/login/route.ts` | `account` or `email` or `phone`, `password`, optional `adminOnly` | User profile data and session token. |
| POST | `/api/auth/register` | Public | `src/app/api/auth/register/route.ts` | `email`, `password`, `nickname` as login username, `phone`, `inviteCode`, `emailCode`, `acceptedTerms` | Created profile/session flow. New normal users receive a random Chinese display nickname and default 3D cartoon avatar. |
| GET | `/api/auth/admin-exists` | Public | `src/app/api/auth/admin-exists/route.ts` | None | Whether an admin profile exists. |
| POST | `/api/auth/test-api` | Public/auth context depends caller | `src/app/api/auth/test-api/route.ts` | Provider/API config | Tests upstream API. |
| POST | `/api/auth/fetch-models` | Public/auth context depends caller | `src/app/api/auth/fetch-models/route.ts` | Endpoint/API key | Fetch model list from provider. |
| GET | `/api/profile` | User | `src/app/api/profile/route.ts` | None | `{ profile }`, including `watermark_disabled` for the user's no-watermark download preference. |
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, optional `watermarkDisabled`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. `watermarkDisabled=true` is accepted only for members/admins as a self-service preference; free users cannot self-enable it, and an accidental/old-client `false` payload must not clear an admin-granted per-user authorization. The flag controls download-original entitlement, not platform display. |
| PUT | `/api/profile/theme` | User | `src/app/api/profile/theme/route.ts` | `theme` | `{ success, preferred_theme }`. |
| GET | `/api/credit-transactions` | User | `src/app/api/credit-transactions/route.ts` | Optional `limit` | Latest user credit transaction records as `{ records }`, used by the profile credits tab. |
| GET | `/api/invitations/me` | User | `src/app/api/invitations/me/route.ts` | None | Returns the current user's stable `inviteCode` plus invitee records for the profile credits tab. Creates `profiles.invite_code` if missing. |
| POST | `/api/redeem-codes/redeem` | User | `src/app/api/redeem-codes/redeem/route.ts` | `{ code }` | Atomically redeems one active unused redeem code. Credit codes increment `profiles.credits_balance`, mark the code used, write a `credit_transactions` row of type `redeem`, and return the new credit balance. Membership codes update `profiles.membership_tier`, extend `membership_expires_at` by the configured day/month/year duration, mark the code used, and return the membership result. |
| GET | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | None | `{ keys }`, with previews only. Rows may include `manifestPath`, which points to that key/model's independent API Manifest file. |
| POST | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Single key or `{ keys: [...] }`; fields `provider`, `supplierName`, `apiUrl`, `modelName`, `apiKey`, `type`, `isActive`, optional `manifestPath` | Saved keys. Updating an imported key preserves its existing `manifest_path` when omitted. |
| PUT | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Same as POST | Saved keys. |
| DELETE | `/api/user-api-keys?id=...` | User | `src/app/api/user-api-keys/route.ts` | Query `id` | `{ success: true }`. |
| POST | `/api/user-api-keys/smart-import` | User | `src/app/api/user-api-keys/smart-import/route.ts` | `{ configText }`, containing either `{ customProviders, profiles }` or a single provider Manifest | Creates one `user_api_keys` row per profile/model and writes a separate `user-api-manifests/<userId>/<keyId>.json` file. The imported row uses the Manifest provider name as the editable provider/supplier display value and resolves `apiUrl` from `profile.baseUrl + submit.path` for synchronous endpoints. The route rejects configs that do not contain enough data to resolve a relay API request URL. Optional `profile.capabilities` is returned to the client and filters selected-model image options. API Key is intentionally left blank with preview `待填写` until the user edits the row. |
## Email Routes
| Method | Path | Auth | Source | Purpose |
| --- | --- | --- | --- | --- |
| POST | `/api/email/send-register-code` | Public | `src/app/api/email/send-register-code/route.ts` | Send registration verification code. |
| POST | `/api/email/send-reset-code` | Public | `src/app/api/email/send-reset-code/route.ts` | Send password reset code. |
| POST | `/api/email/reset-password` | Public | `src/app/api/email/reset-password/route.ts` | Reset password with verification code. |
| POST | `/api/email/send-profile-code` | User | `src/app/api/email/send-profile-code/route.ts` | Send profile email-binding code. |
| POST | `/api/email/verify-profile` | User | `src/app/api/email/verify-profile/route.ts` | Verify profile email code. |
| POST | `/api/email/send-notification` | Internal/admin-oriented | `src/app/api/email/send-notification/route.ts` | Send notification email. |
All email sends route through `src/lib/email-service.ts`, which renders HTML and plain-text multipart messages and sends them via the configured SMTP server. Keep MIME body encoding standards-compliant: base64 body parts must be folded to safe line lengths and both text/plain and text/html parts should be non-empty, otherwise SMTP can accept the message while some mailbox clients render a blank email.
## Generation Routes
| Method | Path | Auth | Source | Request | Response/Side Effects |
| --- | --- | --- | --- | --- | --- |
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. Duplicate active jobs are deduped semantically while ignoring top-level `clientRequestId`, but users may submit a different task while another task is running. |
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. Status may be `queued`, `running`, `succeeded`, `failed`, or `cancelled`. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab; client-side pending job ids also use it to recover terminal results/errors that happened while the browser was closed. |
| PATCH | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID plus `{ action: "cancel" }` | Owner or admin can cancel a `queued`/`running` job. The route marks the row `cancelled`, clears payload/result, writes a cancellation progress payload and `finished_at`, and returns the updated job row with `jobId`; if the job already settled it returns the existing row. Workers re-check that the job is still `running` before charging credits, persisting history, or writing success/failure so late upstream responses cannot resurrect a cancelled job. |
## Admin Invitation Routes
| Method | Path | Auth | Source | Request | Response |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images (`image` plus `extraImages`), optional `referenceImageAnnotations`, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails`, `thumbnailUrls`, and `dimensions` `{ [originalUrl]: { width, height } }`, updates job progress when headers include job ID. When `referenceImageAnnotations` is present, `src/lib/reference-image-prompt.ts` adds a model-readable `@参考图N` mapping block to the upstream prompt before style prompts and Manifest execution. |
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference images (`image`, `images`, `extraImages`), optional `referenceImageAnnotations`, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or Manifest/custom endpoint, polls async Manifest providers such as 元界 media tasks, then persists generated video media as object-backed `/api/local-storage/generated/videos/...` URLs when object storage is configured. When `referenceImageAnnotations` is present, `src/lib/reference-image-prompt.ts` adds a model-readable `@参考图N` mapping block to the upstream prompt before Manifest/custom/SDK execution. |
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. When the input image is a data URL, the route persists it under `reverse-prompt/reference-images/...` and sends the public `/api/local-storage/...` URL upstream when available so the multimodal model sees a normal fetchable image URL instead of a raw upload blob. This route sends a multimodal `chat/completions` payload with `image_url`, so 524 errors here reflect multimodal upstream latency/capability rather than image-generation sync behavior. |
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. This route also uses a multimodal `chat/completions` path, so 524 should be interpreted as a multimodal upstream timeout. |
Important generation helpers:
- `src/lib/generation-job-client.ts`: frontend create/poll helper.
- `src/lib/generation-job-worker.ts`: queued job processor.
- `src/lib/generation-job-runner.ts`: internal call into generate routes.
- `src/lib/generation-job-estimates.ts`: ETA/progress schema and estimates.
- `src/lib/server-api-config.ts`: resolves `customApiKeyId` and `systemApiId`.
- `src/lib/custom-api-fetch.ts`: upstream request/retry/error parsing.
- `src/lib/user-api-manifest.ts` and `src/lib/user-api-manifest-executor.ts`: parse/import per-key user and system API Manifests and execute the selected model's JSON/multipart/poll mapping before falling back to legacy custom API compatibility. User Manifest files are never merged per user; the chosen `customApiKeyId` controls the exact `manifest_path`. Admin system imports use separate `system-api-manifests/<systemApiId>.json` files and generation resolves them from the selected `systemApiId`.
- `pnpm run migration:check` runs `scripts/migration-integrity-check.mjs` as a read-only production migration gate. It checks auth/profile parity, password hash presence, user-owned table references, API key preview metadata, same-user work dedupe state, required schema columns, and `/api/local-storage/*` URL availability without printing secret values. The default probe base URL is the production web port `http://127.0.0.1:8000`; override with `MIGRATION_CHECK_BASE_URL` when checking another runtime. Storage URL probes are bounded by timeout/concurrency helpers so one slow media URL is counted as a blocker instead of crashing the whole check.
- `pnpm run rainyun:ros-prepare -- --create` runs `scripts/rainyun-ros-prepare.mjs` against Rainyun's ROS control-plane API (`POST https://api.v2.rainyun.com/product/ros/bucket`, body `{ bucket_name, instance_id }`, `x-api-key` header). It writes standard S3-compatible `OBJECT_STORAGE_*` values to `.env.rainyun-object.generated`; secrets are redacted from console output.
## Creation History And Gallery
| Method | Path | Auth | Source | Request | Response/Side Effects |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Optional query `mode=text2img|img2img|text2video|img2video|reverse-prompt`, `limit` up to 300 | Latest completed user works as `records`, including optional `thumbnailUrl`, `width`, `height`, `referenceImages`, `referenceImageThumbnails`, `published`, and `publishedAt`. Without query params it returns the latest 300 for the profile history tab. Create panels should pass their current mode plus a small limit so page navigation does not compete with multi-MB full-history responses. The mode filter checks stored work type, explicit mode params, and legacy reference-image inference. Missing image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/works`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails. |
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl`, `width`, `height`, `referenceImage`, and `referenceImages` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works and video works without thumbnails. Data URL or remote reference images are persisted into stable `/api/local-storage/works/references/...` URLs with local thumbnails under `thumbnails/works/references`; the route writes `params.referenceImages` and `params.referenceImageThumbnails`, and can patch an existing same-URL row whose first insert came from the background worker before frontend reference metadata arrived. Imported/local records are only inserted as public when both `published` and `publishedAt` are present, so stale local published flags do not create or block gallery state. |
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/gallery`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails; SVG fallback profiles such as `video-svg-v1` and `video-fallback-svg-v2` stay replaceable and do not count as current. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields, optional `referenceImage`/`referenceImages` plus matching `params.referenceImage`/`params.referenceImages` | Reuses stable `/api/local-storage/...` image and video originals instead of synchronously copying object-backed generated media during share. External image/video URLs are still copied into object-backed gallery storage before insertion. For image-to-image and image-to-video shares, data URL or remote reference images are persisted under stable `gallery/references/...` local-storage URLs and stored in `works.params.referenceImage/referenceImages`; stable `/api/local-storage/...` references are reused as-is. The response includes `referenceImages` so clients can keep local published state aligned. Existing image thumbnails are reused; gallery/history reads can lazily backfill missing or stale thumbnails. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`; a client-provided thumbnail is copied only after frame extraction fails. If media preparation fails, the route returns an error instead of inserting a public row that `/api/gallery` will filter out. Clients should show success and mark local history as shared only after this route returns 2xx. |
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
| PUT | `/api/admin/gallery/prompt` | Admin | `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts` | `{ workId, prompt, emailSubject, emailBody, reasonKey }` | Sends the author notification email first, then updates `works.prompt` only after email success, and writes a platform log without storing full prompt text. Missing/invalid author email, unchanged prompt, non-public work, or email failure blocks the update. |
## Admin Routes
All routes in this section require admin unless noted.
| Method | Path | Source | Purpose |
| --- | --- | --- | --- |
| GET | `/api/admin/dashboard` | `src/app/api/admin/dashboard/route.ts` | Console dashboard aggregate stats and recent activity. |
| GET | `/api/admin/stats` | `src/app/api/admin/stats/route.ts` | Additional admin stats. |
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. GET returns `watermark_disabled`; PUT accepts profile fields, `watermarkDisabled`/`watermark_disabled`, and `newPassword`. `newPassword` is admin-only and upserts `auth.users.password_hash` with PostgreSQL `crypt(..., gen_salt('bf'))` so the user can immediately log in with the reset password. |
| POST | `/api/admin/clear-users` | `src/app/api/admin/clear-users/route.ts` | Dangerous user cleanup controlled by env switch. |
| GET/POST/PUT | `/api/admin/orders` | `src/app/api/admin/orders/route.ts` | List/create/update orders. |
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. All methods require admin bearer auth. |
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful system-default image/video generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`; queued/running system-default jobs are counted during new-job balance preflight, and failed jobs do not write consume transactions. |
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key empty for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
| GET | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in template preview for the system-default-model flow. Returns `capabilitiesText`, image templates from `src/lib/agnes-model-templates.ts`, video templates, and text templates. It covers Agnes Image 2.1 Flash, Agnes Image 2.0 Flash, Agnes Video V2.0, Agnes 2.0 Flash, and Agnes 1.5 Flash without calling upstream. |
| POST | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in installer. `{ syncImageModels, syncVideoModels, syncTextModels }` resets only matching `provider = 'Agnes AI'` rows by media type. Rows are installed as inactive free system default templates (`billing_mode = free`, 0 credits) with empty API Key. Image/video rows also get independent `system-api-manifests/<systemApiId>.json` files. Text rows use OpenAI-compatible `chat/completions` directly and do not need a Manifest. Admins must edit rows to fill the API Key and enable them before users can generate. |
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
| GET/POST | `/api/admin/system-apis/yuanjie-pricing` | `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` | Admin-only manual 元界 AI pricing sync. GET previews built-in pricing targets from `src/lib/yuanjie-pricing-sync.ts`. POST updates only existing 元界 image/video system API rows by `model_name`, matching compatible provider spellings such as `元界 AI`/`元界AI` plus `yuanjie-*` model groups, and synchronizes `billing_mode` plus the 元界计费同步 `price_note` while preserving administrator-entered numeric prices and non-元界 providers such as mozheAPI. Optional body `{ type: "image"|"video" }` limits the sync. |
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. All methods require admin bearer auth. |
| GET/DELETE | `/api/admin/generation-jobs` | `src/app/api/admin/generation-jobs/route.ts` | Admin task listing and deletion. |
| GET | `/api/admin/gallery/works` | `src/app/api/admin/gallery/works/route.ts` | Admin public gallery work listing for prompt moderation. |
| PUT | `/api/admin/gallery/prompt` | `src/app/api/admin/gallery/prompt/route.ts` | Admin prompt moderation endpoint. Requires email notification success before updating `works.prompt`. |
| GET | `/api/admin/data-export` | `src/app/api/admin/data-export/route.ts` | Export business data plus `_media` entries for storage assets referenced by works and site config. `_meta` reports media count/bytes/missing/skipped. |
| POST | `/api/admin/data-import` | `src/app/api/admin/data-import/route.ts` | Import business data. Accepts optional `_media`; restores media to sha-based keys, remaps users/custom API keys/works, imports in a transaction with per-row savepoints, preserves password hashes/encrypted secrets, and dedupes works by URL/source URL/media SHA only inside the same `user_id`. |
| GET/PUT/POST | `/api/admin/email-settings` | `src/app/api/admin/email-settings/route.ts` | Read/update/test email settings. |
| GET | `/api/admin/email-recipients` | `src/app/api/admin/email-recipients/route.ts` | Email recipient list. |
| POST | `/api/admin/send-email` | `src/app/api/admin/send-email/route.ts` | Send admin email. |
| GET/PUT | `/api/admin/logs` | `src/app/api/admin/logs/route.ts` | Admin system log listing and retention settings. GET supports type, level, user, keyword, startTime, endTime, page, and pageSize filters and returns logs plus retention settings. PUT updates the retention days, cleans expired rows, and writes a platform log. |
| GET/POST | `/api/admin/upgrade` | `src/app/api/admin/upgrade/route.ts` | Read upgrade runtime/status/history; upload/start dry run or upgrade package. New deployable work should be classified against this path: static/public asset-only payloads may be hot-update candidates after preflight, while source/API/server/dependency/schema/env/runtime/script changes are cold-update candidates. |
## Persistence Tables Mentioned By APIs
Primary SQL tables touched directly in API routes include:
- `profiles`
- `auth.users`
- `works`
- `credit_transactions`
- `redeem_codes`
- `orders`
- `user_api_keys`
- `site_config`
- `site_stats`
- `announcements`
- `generation_jobs`
- `api_providers`
- `system_api_configs`
- `platform_logs`
`src/storage/database/shared/schema.ts` contains a Drizzle snapshot of core tables, while several runtime APIs add compatibility columns/tables with `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
`user_api_keys.manifest_path` is an optional local-storage key for an imported JSON Manifest. The storage convention is `user-api-manifests/<userId>/<keyId>.json`, so even the same user can have multiple isolated request configs. Generation must load the manifest linked to the selected model/key row instead of looking up a user-level shared config.
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. System image candidates retry stream-timeout 524 responses once with `stream:false`, and shared custom API transport retries 502/503/504 once before surfacing a concise gateway error. `model_name` stays provider-specific and is used as the upstream request model value.
`site_config.image_composition_skill_enabled` controls the built-in 100 Layout Compositions image composition skill. `/api/site-config` returns and updates it as `imageCompositionSkillEnabled`, and `/api/generate/image` reads it through `src/lib/layout-composition-skill.ts` before calling SDK, Manifest, custom API, or system default polling providers. The skill source is `nevertoday/100-layout-compositions` under CC BY 4.0; prompt injection must preserve attribution internally and avoid adding literal text/logo/poster elements.
`redeem_codes` stores admin-generated single-use credit and membership redemption codes. Runtime code generation and redemption go through `src/lib/redeem-code-service.ts`; redemption must lock both the code row and profile row in one transaction before updating `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes. Credit-code redemptions also insert a `credit_transactions` record.
`src/lib/yuanjie-image-model-templates.ts` is the canonical source for built-in 元界 AI image model definitions. It maps each documented model to its Manifest request body and stores capability flags so the create page only shows the documented aspect ratio, resolution, image format, and quality controls for the selected model. For 元界 GPT Image 2 / GPT Image 2 官转 and other `size`-enum models, the create page hides the separate aspect-ratio control and shows the documented pixel size values as the resolution list. 元界媒体轮询 uses `is_final === true` as the final-state gate and `state` for success/failure, matching the documented media task contract.
`src/lib/yuanjie-video-model-templates.ts` is the canonical source for built-in 元界 AI video model definitions from the local video docs. It maps each model to a Manifest request body, records whether the model supports 文生视频 and/or 图生视频, and stores aspect ratio, resolution, duration, and quality/mode capability options for the selected system video model. Video submit bodies must follow 元界 media docs as `model`, `prompt`, and `params`; HappyHorse text-to-video maps UI ratio to `params.ratio`, resolution to `params.resolution`, duration to `params.duration`, and reads async task IDs from `output.task_id`. Video templates use the documented `is_final` plus `state` polling rule.
`src/lib/yuanjie-pricing-sync.ts` is the canonical source for manual 元界 AI pricing metadata sync. It derives billing modes from the built-in image/video templates and local docs: image models default to fixed per-use pricing, duration-sensitive video models sync to `duration`, Seedance token-billed video models sync to `token`, and special variable-cost video models sync to `ratio` with a warning note. The sync is manual from the admin system-default-model page and only updates existing 元界 rows, including legacy provider spellings such as `元界AI`; update SQL still includes a 元界 provider/model-group guard so mozheAPI rows cannot be touched by the sync.
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; for image-to-video, `/api/generate/video` reads `image`, `images`, and `extraImages` before Manifest execution. The executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, `reference_urls`, or `base64Array`. `referenceImageAnnotations` is an API payload field rather than a Manifest variable; image/video routes use `src/lib/reference-image-prompt.ts` to merge `@参考图N` token mappings into the upstream prompt so existing Manifest templates receive the mapping through `$prompt`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
`src/lib/agnes-model-templates.ts` is the canonical source for Agnes AI built-in free templates. Agnes Video V2.0 uses Manifest `POST /v1/videos` plus `/agnesapi` polling, but duration must be sent as `num_frames` rather than `duration`. `/api/generate/video` maps the currently stable Agnes UI durations 3/5/10 seconds to 24fps frame counts 81/121/241 and sends `frame_rate: 24`; 18 seconds is intentionally hidden and rejected because production evidence showed the upstream task moves past creation but returns `failed` for that length. In image-to-video mode the top-level `image` is the provider's starting/first frame field, not a generic non-first-frame reference slot. The Manifest executor keeps Agnes-style total polling budgets separate from per-request submit/poll timeouts, so one slow or transiently failed poll request does not end the whole async video job before the full video budget expires.
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved directly or as a default-model polling candidate it writes missing or stale `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.

View File

@@ -0,0 +1,322 @@
# Architecture
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
## System Overview
MiaoJing is a self-hostable AI multimodal creation platform built on:
- Next.js 16 App Router
- React 19
- TypeScript
- PostgreSQL via `pg`
- Local disk storage through `/api/local-storage/*`
- PM2 process management
- Optional upstream AI providers through SDK, custom API keys, and admin system API configs
The repository contains frontend pages, API route handlers, business services, database helpers, storage helpers, deployment scripts, and PM2 config in one app.
## Runtime Shape
```text
Browser
|
| HTTP
v
Next.js App Router
|
+-- src/app pages
+-- src/app/api route handlers
|
+-- src/components UI and workflow components
|
+-- src/lib business logic and stores
|
+-- src/storage database clients
|
+-- local disk storage via src/lib/local-storage.ts
|
+-- PostgreSQL via src/storage/database/local-db.ts
|
+-- Upstream AI providers via SDK/custom fetch
```
Production is controlled by `ecosystem.config.cjs` and the scripts under `scripts/`. Always verify PM2 cwd and runtime environment before editing a live server.
## Directory Boundaries
| Directory | Responsibility |
| --- | --- |
| `src/app` | Next.js pages, layouts, and API route handlers. |
| `src/components` | Reusable UI and product workflow components. |
| `src/components/ui` | Radix/shadcn-style primitives. Keep generic. |
| `src/components/create` | Create center panels and shared generation UI. |
| `src/components/admin` | Admin console tab components. |
| `src/components/profile` | Profile page tabs and API key manager. |
| `src/lib` | Business logic, stores, model config, auth, storage, email, generation jobs, provider resolution. |
| `src/modules` | Thin module export and console page wrappers. |
| `src/storage` | Database client/schema compatibility. |
| `scripts` | Build, deploy, backup, restore, DB patch, admin upgrade runner. |
| `public` | Public static assets. |
| `assets` | Project-owned source assets. |
## Frontend Architecture
The app is route-driven through `src/app`:
- Public shell: `src/app/layout.tsx` and `src/components/app-shell.tsx`.
- Navigation and global UI: navbar, footer, announcement popup, site config sync, visit tracker.
- Product workflows:
- Create center: `src/app/create/page.tsx` plus `src/components/create/*`.
- Gallery: `src/app/gallery/page.tsx`.
- Profile: `src/app/profile/page.tsx` plus `src/components/profile/*`.
- Admin console: `src/app/console/*`, `src/modules/console/pages/*`, `src/components/admin/*`.
Mobile adaptation is handled primarily through page-level structure classes plus `src/app/globals.css`. The create center uses `.create-chat-layout`, `.create-chat-thread`, and `.create-chat-composer` so phones behave like a modern AI chat client: the single mode switch is the sticky icon row, the page title and duplicate text mode strip are hidden, and text-to-image reads as a chronological conversation from oldest to newest so the latest work sits above the fixed composer. Text-to-image suppresses the empty result placeholder until the user submits a prompt, then renders the generating task as the newest prompt-plus-progress message. `src/components/create/mobile-creation-composer.tsx` is the fixed bottom input shell for text-to-image; it holds extra-compact labeled ratio/resolution/count controls and similar parameters, the optional style strip, the prompt input, and the right-side send button, and intentionally does not duplicate mode switching. Gallery masonry keeps at least two columns on phone widths. The admin console keeps the drawer navigation from `console-dashboard-page.tsx` and uses `console-mobile-*` shell rules to constrain cards while allowing dense admin tables to scroll horizontally instead of overflowing the viewport.
Client stores in `src/lib/*-store.ts` mediate API calls and local UI state. When fixing a UI persistence bug, inspect both the component and the matching store/API route.
Navigation performance is handled as part of the frontend architecture, not only by backend route timing. `src/components/navbar.tsx` defers its initial logged-in profile refresh so a fresh page load does not compete with the user's first navigation, but it should not eagerly prefetch every core route from the navbar because production web users can see that extra resource pressure as slower visible page switches. `src/components/visit-tracker.tsx` posts site statistics with `keepalive` after idle time because analytics should not block first paint. `src/app/create/page.tsx` keeps the primary creation workflow panels in the initial client bundle so switching between text/image/video/reverse-prompt modes does not wait on extra `ssr:false` chunks or show fallback flashes. The create panels must request lightweight scoped history through `useCreationHistory({ mode, limit })`; production users with large histories can otherwise trigger repeated multi-MB `/api/creation-history` responses that compete with navigation and image loads. `src/lib/creation-history-store.ts` owns short-lived in-flight request reuse and merges scoped responses into local history rather than replacing unrelated modes. `src/app/profile/page.tsx` keeps the parent account shell light and lets creation history, credit records, and orders mount their stores only inside their respective tab components. Keep `scripts/test-navigation-performance-policy.mjs` aligned with these constraints when changing the app shell, create center, profile page, or history store.
## API Architecture
All APIs are route handlers in `src/app/api`.
Major groups:
- `auth/*`: login, register, admin existence, provider API testing.
- `profile/*`: user profile and theme.
- `user-api-keys`: user-owned custom API credentials.
- `model-config`: public model/provider config.
- `style-presets`: public DB-backed image style presets ordered by usage count.
- `generate/*`: direct generation, reverse prompt, prompt suggestion.
- `generation-jobs/*`: queued generation job creation/status.
- `creation-history`: user works/history.
- `gallery/*`: public gallery and publishing.
- `admin/*`: console dashboard, users, providers, system APIs, orders, payment, upgrade, data import/export, email settings.
- `redeem-codes/*` and `credit-transactions`: user credit redemption and credit record APIs.
- `site-config`, `site-stats`, `announcements`: public site content and counters.
- `local-storage/*`, `download`: file serving and download proxy.
Auth is not implicit. Each route must call the correct helper:
- User route: `getAuthenticatedUserId` or `getAuthenticatedUser`.
- Admin route: `requireAdmin` or `requireAdminUser`.
- Internal generation route: `isTrustedInternalGenerationRequest`.
## Generation Flow
```text
Create panel
-> src/lib/generation-job-client.ts
-> POST /api/generation-jobs
-> system-default credit preflight counts selected job plus same-user queued/running jobs
-> generation_jobs row inserted
-> src/lib/generation-job-worker.ts
-> src/lib/generation-job-runner.ts
-> POST /api/generate/image or /api/generate/video
-> SDK or custom/system API upstream call
-> src/lib/local-storage.ts persists result
-> src/lib/generation-credit-service.ts deducts selected system API credits only after success
-> generation_jobs updated with result/error/progress
-> client polls GET /api/generation-jobs/[id]
-> create panels can recover queued/running jobs from GET /api/generation-jobs after refresh, auth change, or tab switch
(anonymous recovery list polling is skipped, and same-token/type list requests are briefly deduped client-side)
-> client-side pending job ids also query GET /api/generation-jobs/[id] so jobs that reached succeeded/failed/cancelled while the browser was closed still display their terminal state once before being cleared
-> history/gallery persistence via works APIs
```
Key source files:
- `src/components/create/text-to-image.tsx`
- `src/components/create/image-to-image.tsx`
- `src/components/create/text-to-video.tsx`
- `src/components/create/image-to-video.tsx`
- `src/lib/generation-job-client.ts`
- `src/app/api/generation-jobs/route.ts`
- `src/app/api/style-presets/route.ts`
- `src/lib/style-preset-store.ts`
- `src/lib/generation-job-worker.ts`
- `src/lib/generation-job-runner.ts`
- `src/app/api/generate/image/route.ts`
- `src/app/api/generate/video/route.ts`
Do not bypass the job flow unless intentionally implementing synchronous/internal-only behavior.
## Provider Resolution
There are three provider sources:
1. Built-in model config: `src/lib/model-config.ts`.
2. User custom API keys: `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`.
3. Admin system APIs: `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`.
`src/lib/server-api-config.ts` resolves:
- `customApiKeyId` into a user-owned decrypted API config.
- `systemApiId` into an active admin-managed decrypted API config after checking platform-default visibility and the requesting user's membership tier.
- `systemApiId` polling candidates for admin default models by matching media type plus admin display name (`system_api_configs.name`) across active/default system API rows and ordering them by `polling_mode` plus `polling_order`/`sort_order`; each candidate still sends its own provider-specific `model_name` upstream.
- direct `apiKey` passthrough for legacy/custom callers.
Secrets must be encrypted at rest with `src/lib/server-crypto.ts` and never returned in API responses.
User-level intelligent API imports add a fourth data artifact tied to source 2: a per-key JSON Manifest in local storage. `src/app/api/user-api-keys/smart-import/route.ts` parses either a full `{ customProviders, profiles }` bundle or one provider Manifest, creates a separate `user_api_keys` row for every profile/model, and writes `user-api-manifests/<userId>/<keyId>.json`. `user_api_keys.manifest_path` is the only runtime pointer. The imported row keeps a human-readable provider/supplier name for editing and derives the visible request URL from the Manifest profile/provider; incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` is stored in the Manifest and returned to the client so the selected model can constrain or hide image aspect ratio, resolution, image format, and quality choices. Manifest poll endpoints should put task IDs in `query: { task_id: "{task_id}" }` when the upstream documents a query string, so the executor sends a real query parameter instead of embedding `?task_id=` into the pathname. Even for the same user, different request configuration files must remain separate because generation dispatch is selected-model based, not user based.
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. For image Manifest results, the route persists returned result URLs through `src/lib/media-storage.ts`; external result URL downloads use `src/lib/remote-fetch.ts` with browser-like headers and limited retry so provider/CDN-side 403, 429, 5xx, or timeout failures are distinguished from upstream generation failures. If the provider returned a result but MiaoJing cannot download or save the image media, the API should report a platform download/save failure instead of a resolution mismatch. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
Manifest template rendering exposes input images in two forms: `$inputImages.dataUrls` keeps the raw uploaded data for multipart/file manifests, while `$inputImages.urls` is normalized for providers that require URL references. The executor converts data URL references to storage-backed public URLs before rendering JSON templates, using object-storage signed URLs when available or the app public base URL plus `/api/local-storage/<key>` otherwise.
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. 元界 price and billing metadata sync is also provider-specific and manual: `/api/admin/system-apis/yuanjie-pricing` uses `src/lib/yuanjie-pricing-sync.ts` to update only existing `provider = '元界 AI'` rows with derived billing mode and price notes, preserving API keys, Manifest paths, mozheAPI rows, and administrator-entered numeric prices. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
After production migration, app runtime tables in `public` should be owned by the app DB user from `LOCAL_DB_URL`. Runtime compatibility helpers use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and index creation; if restored tables remain owned by `postgres`, public routes such as `/api/model-config`, profile refresh, or generation jobs can fail with `must be owner of table ...`.
Admin console navigation state is intentionally short-lived. `src/modules/console/pages/console-dashboard-page.tsx` stores the active console view in `sessionStorage`: page refresh stays on the current admin page, logout clears the stored view, and a new browser tab/session opens the dashboard first.
## Storage Architecture
`src/lib/local-storage.ts` is the storage abstraction. It keeps the public URL shape stable while allowing the backend to move from local disk to object storage. Generated media delivery is layered through `src/lib/media-watermark-policy.ts` and `src/lib/media-watermark.ts` so compliance-visible watermarks are applied at the server boundary rather than as a frontend overlay.
- Public URL shape remains `/api/local-storage/<key>` for local and object-backed files. Existing DB rows and frontend image URLs do not need rewriting during migration.
- `STORAGE_MODE=local`: read/write only `LOCAL_STORAGE_DIR`, or repo-local `local-storage` if unset.
- `STORAGE_MODE=dual`: read object storage first, fall back to local disk, write every new file to local disk first, then mirror it to object storage. Object mirror failures are logged instead of breaking the user request. This is the recommended production migration mode because existing local backups and rollback stay useful while object storage is populated.
- `STORAGE_MODE=object`: read/write only the configured S3-compatible bucket. Use this only after `scripts/storage-sync-to-object.mjs --verify-only` passes and rollback expectations are clear.
- Object storage config uses `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_REGION`, optional `OBJECT_STORAGE_ENDPOINT`, access keys, `OBJECT_STORAGE_FORCE_PATH_STYLE`, and optional `OBJECT_STORAGE_PREFIX`.
- Rainyun ROS is handled as a control-plane preparation step, not as a runtime storage backend. `scripts/rainyun-ros-prepare.mjs` calls `POST https://api.v2.rainyun.com/product/ros/bucket` with `x-api-key` and `{ bucket_name, instance_id }`, then writes `.env.rainyun-object.generated` with standard `OBJECT_STORAGE_*` values derived from `access_key`, `secret_key`, and `public_api_url`. Copy those values into production `.env.local` only after review, keep `STORAGE_MODE=dual`, and keep `.env.rainyun-object.generated` private.
- File serving route: `src/app/api/local-storage/[...path]/route.ts`. Generated images/videos under generated/gallery/imported work media paths, plus generated/gallery/work thumbnails, are served as watermarked bytes using `public/watermark/miaojing-watermark-logo.png` and `MIAOJING AI` at 50% opacity; this route should not expose raw object-storage redirects for generated media because browser extensions or scripts can call the same URL. For generated image originals that already have a local `works.thumbnail_url`, display requests can redirect to the thumbnail and watermark that smaller file first; download requests still go through `/api/download` for the original media.
- Download route: `src/app/api/download/route.ts`. Downloads also return watermarked files by default; a raw generated file is allowed only when the request authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Frontend helpers pass the session via Authorization for fetch downloads and a same-origin `downloadToken` for anchor-triggered downloads.
- Storage key validation prevents traversal through `normalizeKey`, `path.resolve`, and `..` checks.
Generation routes persist generated media through the storage adapter. Image originals and video originals are object-first when object storage is configured: images go through `src/lib/media-storage.ts`, while videos from `src/app/api/generate/video/route.ts` are stored with `uploadFileObjectOnly(...)` under `generated/videos`. Gallery publish uses `src/lib/gallery-publish-media.ts`: stable `/api/local-storage/...` image and video originals are reused rather than synchronously copying object-backed generated media again, while external media URLs are copied into gallery storage before insertion. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`; the storage/download routes decide whether that original URL returns watermarked bytes or raw bytes. Sharing an already generated `/api/local-storage/...` image should reuse that original URL and existing thumbnail instead of copying the object into `gallery/images` or recompressing a gallery thumbnail synchronously; missing/stale thumbnails are backfilled later by `/api/gallery` reads. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; generated image original display requests can also redirect to that existing thumbnail before watermarking to avoid first-view object GET and full-size raster work. `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed generated originals should not return raw signed URLs from public display paths; non-generated object-backed originals can still return a short-lived 302 signed object-storage URL instead of buffering through Next.js.
Video originals and previews also have separate storage rules. Generated videos are stored as object-backed `/api/local-storage/generated/videos/...` URLs. Video thumbnails are local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; lightweight SVG profiles such as `video-svg-v1` and `video-fallback-svg-v2` are only fallbacks when frame extraction fails. SVG fallback rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background; publish also tries frame extraction before copying any client-provided thumbnail. Object-backed videos are streamed from the storage adapter into a bounded temporary local file before ffmpeg extraction, with retry around transient object-stream termination; this avoids passing signed object-storage URLs directly to the bundled `ffmpeg-static` binary, which can crash or return no stderr for some remote inputs. Gallery video cards and detail overlays render the thumbnail first; the original video element is mounted only after the user clicks play, so opening the gallery detail does not immediately download the object-storage video. `/api/download` can redirect non-generated object-backed local-storage downloads to signed object URLs with content-disposition, but generated videos return watermarked bytes unless the authenticated member/admin no-watermark preference allows the raw file; video buttons use a normal anchor-triggered download and pass a same-origin download token.
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. Client sharing flows must call `/api/gallery/publish` first and only then mark local history as shared with `publishedAt`; stale local `published=true` without that confirmation must not disable retry. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, uses cached rows up to the 7-day prune window while revalidating page 0 in the background, and shows a masonry skeleton instead of a blocking centered loading message when no cache exists. Public gallery serialization in `src/lib/gallery-response.ts` filters generated default `data:` avatars and oversized avatar URLs so repeated `publisherAvatarUrl` fields do not bloat `/api/gallery` responses or exceed localStorage quota. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works` with page/pageSize pagination; `src/lib/admin-gallery-works-pagination.ts` keeps the route compatible with older limit/offset callers. Prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
Fullscreen image overlays should accept a thumbnail fallback and display it immediately while the original object-storage image loads. If object storage is slow or the original fails, the user still sees the high-quality local preview and the fullscreen controls stay usable; copy/download/share actions still receive the original URL.
`/api/health` caches storage health briefly and bounds object bucket probing, so health checks do not block page monitoring on a slow object-storage HEAD request. Optional runtime schema checks cache success or non-owner skips; production migrations should still apply schema changes explicitly, but request paths should not repeatedly run DDL.
For a production move from local disk to cloud server plus object storage, use this order: create a full DB/file backup, run `pnpm run migration:check` against the source runtime, prepare Rainyun ROS with `pnpm run rainyun:ros-prepare -- --create` if a bucket still needs to be created, copy reviewed `OBJECT_STORAGE_*` values into `.env.local` with `STORAGE_MODE=dual`, run `pnpm run storage:sync-object -- --dry-run`, run `pnpm run storage:sync-object`, run `pnpm run storage:sync-object -- --verify-only`, deploy/reload, run `pnpm run migration:check` again, and verify `/api/health`, gallery/history images, downloads, login, and API generation. The migration checker defaults to `http://127.0.0.1:8000` and uses bounded storage URL probe helpers; override `MIGRATION_CHECK_BASE_URL`, timeout, or concurrency only when intentionally checking a different runtime. Only switch to `STORAGE_MODE=object` after the object bucket and migration integrity checks have passed and a rollback plan exists.
When syncing source into production, exclude the repo-root runtime storage directory as `/local-storage/` only. A broad `local-storage/` rsync exclude also skips `src/app/api/local-storage/[...path]/route.ts`, leaving production on stale file-serving code while the local repo appears fixed.
## Database Architecture
Main DB entry:
- `src/storage/database/local-db.ts`: PostgreSQL pool from `LOCAL_DB_URL`.
Schema sources:
- `src/storage/database/shared/schema.ts`: Drizzle snapshot for core tables.
- `scripts/init-database.sql`: full initialization.
- Runtime routes/services also ensure compatible columns/tables with `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
Core data areas:
- Users: `auth.users`, `profiles`. `profiles.watermark_disabled` is the per-user authorization for downloading raw generated media. Free users cannot enable it from their own profile, but admins can toggle it in user management without changing membership; platform display still stays watermarked.
- Works: `works`.
- Credits: `credit_transactions`, `redeem_codes`.
- Orders: `orders`.
- API credentials: `user_api_keys`, `system_api_configs`, `api_providers`.
- Site content: `site_config`, `announcements`, `site_stats`.
`site_config.image_composition_skill_enabled` is a platform-wide feature switch for the built-in image composition skill. When enabled, image generation uses `src/lib/layout-composition-skill.ts` to deterministically select one of 100 `nevertoday/100-layout-compositions` references and append composition-only guidance to the prompt before any upstream image provider call. The source is CC BY 4.0, so admin UI and docs should keep attribution visible; the generation request should use it as layout guidance rather than as downloadable gallery/reference media.
- Jobs/logs: `generation_jobs`, `platform_logs`.
Because several routes self-migrate compatibility columns, DB bugs often require checking both SQL scripts and route-level `ensure...Schema` functions.
Credit redemption uses `src/lib/redeem-code-service.ts`. Admin-generated codes are unique single-use rows in `redeem_codes`; user redemption locks the code row and profile row in one transaction and marks the code used. Credit codes increment `profiles.credits_balance` and write a `credit_transactions` record. Membership codes set/extend `profiles.membership_tier` and `membership_expires_at`; duration can be configured by day, month, or year. The external mall URL for obtaining codes/upgrading membership is stored as `site_config.redeem_code_mall_url`, edited from the admin redeem-code tab, returned publicly by `/api/site-config`, and preserved by data export/import. Invitation rewards use `src/lib/invitation-service.ts`: each profile owns a stable `invite_code`, `/auth/register?invite=...` stores the relationship in `invitation_referrals`, sets `profiles.referred_by_user_id`, and grants 50 credits to both users in the registration transaction. These tables and fields are included in admin data export/import and production migration checks so unused/used redemption state and invitation history survive server moves.
User display identity: `profiles.nickname` is retained as the login username so existing username/phone/email login and `works.user_id` ownership remain stable. Public display uses `profiles.display_nickname`, surfaced to clients as `nickname`; `src/lib/user-profile-defaults.ts` owns runtime schema creation plus random Chinese nickname/default 3D cartoon avatar generation. Existing users without `display_nickname` or `avatar_url` can be backfilled with `scripts/backfill-user-display-profile.mjs`.
## Admin Console Architecture
Admin console UI is split across:
- Page/wrapper: `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`.
- Module pages: `src/modules/console/pages/console-login-page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx`.
- Tabs: `src/components/admin/*`.
- APIs: `src/app/api/admin/*`.
Admin auth flows through the same login endpoint with admin role checks. API routes should use `requireAdmin`.
Gallery prompt moderation uses `requireAdminUser` when the route needs the admin user ID for platform logs, and `requireAdmin` for read-only admin list APIs. The moderation endpoint should fail closed when the work is no longer public, the author email is missing/invalid, the prompt is unchanged, or SMTP sending fails.
## Upgrade And Deployment Architecture
Scripts:
- `scripts/build.sh`: builds Next.js and server.
- `scripts/start.sh`: production start path.
- `scripts/deploy-or-upgrade.sh`: deployment/upgrade automation.
- `scripts/admin-upgrade-runner.mjs`: package extraction/build/restart runner.
- `scripts/backup-create.sh`, `backup-list.sh`, `backup-restore.sh`: backups. Backup creation validates `pg_dump` output and tar integrity; restore validates archive/dump contents, creates a pre-restore safety dump/copy, uses a single DB transaction, and swaps local storage atomically.
- `scripts/apply-database-patch.sh`: DB patch execution.
Runtime:
- `ecosystem.config.cjs` defines PM2 processes and environment roles.
- `src/server.ts` is the custom Node server entry.
- Admin upgrade API and UI:
- `src/app/api/admin/upgrade/route.ts`
- `src/components/admin/system-upgrade-tab.tsx`
Production note from the 2026-05-14 update: the reachable SSH endpoint was `root@124.174.9.29 -p 5238`, while PM2 still served `/opt/miaojingAI` with Node/PM2 under `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`. The live ports were `8000` for web, `8100` for API, and `8200` for console. Do not overwrite production `ecosystem.config.cjs` with a repository or dev-server copy during rsync-style source updates; it can switch PM2 back to `/root/miaojingAI` and ports `5000/5100/5200`.
When changing deploy/upgrade behavior, validate package limits, disk checks, backup creation, rollback paths, restore safety backups, PM2 restart command, and health checks.
All new development must be designed so the production server can be updated later through the admin console upgrade package flow. Classify every deployable change before handoff:
- Hot update candidate: static/public asset-only payloads that the upgrade runner accepts without restart. Preflight must reject source, dependency, script, lockfile, secret, backup, storage, and runtime paths.
- Cold update candidate: any change involving `src`, API routes, server code, dependencies, `package.json`, `pnpm-lock.yaml`, DB schema or compatibility migration behavior, environment variables, PM2/runtime config, build scripts, backup/restore scripts, deployment scripts, or generated server assets.
Cold updates must preserve this safety chain: package preflight, disk checks, data backup, source snapshot, build/type verification, PM2 reload/restart, `/api/health`, and rollback through source restore plus backup restore when needed. When a feature introduces new persistent data, schema expectations, file-storage paths, background jobs, or environment variables, update the upgrade/package notes in this architecture document so future production packages can be prepared safely.
## Data Portability
Admin data export/import is a portability layer, separate from the full tar backup scripts:
- `src/app/api/admin/data-export/route.ts` exports database business tables and bundles local-storage media referenced by `works` and `site_config` under `_media`.
- `src/app/api/admin/data-import/route.ts` accepts older DB-only exports and newer media-inclusive exports. Newer exports restore media files to stable sha-based local-storage keys before writing work rows.
- Import runs one DB transaction with per-row savepoints, remaps user/work/custom API key IDs, preserves old source URL/media SHA markers in `works.params`, and merges repeated imports by URL/source URL/media SHA.
- Older exports without `_media` can restore database rows but cannot recreate missing local files by themselves; copy `LOCAL_STORAGE_DIR` or use a newer export for full gallery/history image recovery.
## Security Boundaries
- API key storage: encrypted in DB, preview-only responses.
- User auth: bearer session token from `src/lib/session-auth.ts`.
- Admin auth: role must be `admin` or `enterprise_admin`.
- Internal generation: protected by `x-miaojing-generation-internal`.
- Local file serving: must preserve storage key normalization and path traversal guards.
- Browser embedding: `src/proxy.ts` owns CSP security headers. `frame-ancestors` defaults to self plus mozheAPI origins and can be overridden with `MIAOJING_FRAME_ANCESTORS`; if an external ancestor is allowed, omit `X-Frame-Options` because `SAMEORIGIN` would still block the iframe.
- Admin destructive actions: keep environment gates, admin checks, and limits.
## Verification Strategy
Use the narrowest useful check, then broaden as needed.
| Change Type | Minimum Verification |
| --- | --- |
| Docs only | Link/path sanity with `rg`, `git diff --check`. |
| TypeScript source | `pnpm run ts-check`. |
| API route | `pnpm run ts-check`, route smoke with `curl` where runtime exists. |
| UI workflow | `pnpm run ts-check`, `pnpm run build`, browser/manual or Playwright check if visual. |
| Generation path | `pnpm run ts-check`, `pnpm run build`, job create/poll route check, storage result check. |
| Static hot-update candidate | Admin upgrade dry run/preflight, verify runner accepts the payload without restart, then route/static asset smoke check. |
| Cold-update candidate | `pnpm run ts-check`, `pnpm run build`, admin upgrade dry run/preflight, backup/rollback readiness, PM2 reload/restart, health checks. |
| Deployment/upgrade tooling | `pnpm run ts-check`, `pnpm run build`, package/upgrade dry run, PM2 reload, health checks. |
## Known Risk Points
- Some source files import `@/lib/model-display`, but `src/lib/model-display.ts` is absent at the audited commit. This can break `pnpm run ts-check` independent of documentation changes.
- Generation routes are large and mix upstream adapter logic, persistence, and job progress. Prefer small surgical edits.
- Several DB schema changes are applied lazily at runtime; verify migration behavior on fresh and upgraded DBs.
- Production/dev servers can have different checkout paths. Always verify PM2 cwd before live edits.
- Admin upgrade touches file system, build, backup, disk, and PM2 state. Treat it as high-risk.

View File

@@ -0,0 +1,176 @@
# Bug Location Guide
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
Use this guide when the user reports behavior. Start from the symptom row, inspect the listed files, then verify with the current runtime.
## First Five Checks
1. Confirm whether the bug is frontend-only, API-only, persistence, generation worker, or deployment/runtime.
2. Read the task row below and open the smallest likely file set.
3. Search for the exact UI text, endpoint path, DB table, or log prefix with `rg`.
4. Check auth state and request payload before changing business logic.
5. After a fix, run the narrowest useful verification first, then `pnpm run ts-check` and `pnpm run build` when source changes affect TypeScript/runtime.
## Auth, Login, Profile
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| User cannot log in | `src/app/auth/login/page.tsx`, `src/lib/auth-store.ts`, `src/app/api/auth/login/route.ts`, `src/lib/session-auth.ts` | Request body fields (`account`, `email`, `phone`, `password`), password hash verification, bearer token storage, inactive profile. |
| Admin console opens without login or redirects incorrectly | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/auth/login/route.ts` | `adminOnly` behavior, admin role check, route redirect logic. |
| Registration fails or registration verification email arrives blank | `src/app/auth/register/page.tsx`, `src/app/api/auth/register/route.ts`, `src/app/api/email/send-register-code/route.ts`, `src/lib/email-service.ts` | `acceptedTerms`, email code, password strength, invite code, duplicate profile, SMTP settings, send logs, and MIME body encoding. Verification emails use HTML plus plain-text multipart content; base64 bodies must be folded to 76-character lines and multipart blank separators must be preserved. Do not use `.filter(Boolean)` on MIME message arrays because it removes required empty separator lines and can make mailbox clients render a blank email despite SMTP accepting it. |
| Profile changes disappear after refresh | `src/app/profile/page.tsx`, `src/app/api/profile/route.ts`, `src/lib/auth-store.ts` | PUT writes both `profiles` and `auth.users` where needed; client refreshes returned profile. |
| Navbar or gallery shows login username instead of public nickname | `src/lib/auth-store.ts`, `src/app/api/profile/route.ts`, `src/app/api/gallery/route.ts`, `src/lib/user-profile-defaults.ts` | `profiles.nickname` is login username; public UI should use returned `nickname` from `profiles.display_nickname`. Gallery SQL should select `display_nickname` first. |
| Navbar user avatar is missing and only shows an initial | `src/components/navbar.tsx`, `src/lib/auth-store.ts`, `src/app/api/profile/route.ts` | The navbar user button should read `AuthUser.avatarUrl`; confirm `/api/profile` or login returns `avatar_url`, `parseApiUser` maps it, and `UserAvatar` only falls back to initials after image load failure. |
| Existing users have blank/default avatar after display-profile migration | `src/lib/user-profile-defaults.ts`, `scripts/backfill-user-display-profile.mjs`, `src/app/api/auth/login/route.ts` | Run the backfill script with `LOCAL_DB_URL`; login also lazily fills missing `avatar_url` with a generated SVG data URL. |
| Theme does not persist | `src/components/account-theme-sync.tsx`, `src/app/api/profile/theme/route.ts`, `src/lib/profile-preferences.ts` | `preferred_theme` schema, token auth, theme normalization. |
## Site Config, Footer, Policies, Announcements
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx`, `src/app/create/page.tsx`, `src/components/create/*`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/app/profile/page.tsx`, `src/components/visit-tracker.tsx` | Compare production local route timings with real browser navigation and check OpenResty access logs for large competing API responses. If pages and `/api/site-config` are fast on the server but browser clicks still feel delayed, check whether multiple `useSiteConfig()` consumers are issuing duplicate concurrent config requests, and confirm the shared snapshot, 5-minute fresh-cache network skip, and in-flight request dedupe in `src/lib/site-config.ts` are present. Also confirm `/api/site-config` is not running schema/default compatibility DDL on every GET; it should cache that check once per server process and retry only after failure. Then verify `Navbar` is not doing eager all-route `router.prefetch(...)`, `VisitTracker` defers `/api/site-stats`, `/create` does not put the five primary creation panels behind `ssr:false` dynamic chunks, and the create panels call `useCreationHistory({ mode, limit })` instead of pulling full `/api/creation-history` responses. A production smell is repeated `/api/creation-history` responses of many MB from `/create`; those requests compete with visible navigation and image loading and should be scoped by `mode`/`limit` plus deduped in `src/lib/creation-history-store.ts`. `/profile` should not mount creation-history, credit-record, or order stores from the parent page. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. Run `node --no-warnings ./scripts/test-navigation-performance-policy.mjs` after changing this area. |
| Console reports CSP blocking `https://fonts.googleapis.cn/...` | `src/app/globals.css`, `src/proxy.ts` | `globals.css` imports Noto Serif SC from `fonts.googleapis.cn`; CSP must allow that stylesheet domain in `style-src` and the matching font CDN in `font-src`. |
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
| Announcement admin edit fails | `src/components/admin/announcement-tab.tsx`, `src/app/api/announcements/route.ts` | Admin token, required fields, `starts_at`/`expires_at` compatibility. |
| Third-party platform iframe shows `miaojing.toplee.cn refused to connect` | `src/proxy.ts`, `.env.example`, reverse-proxy response headers | Check `Content-Security-Policy frame-ancestors` and `X-Frame-Options`. External iframe embedding requires the parent origin in `MIAOJING_FRAME_ANCESTORS` or the default mozheAPI allowlist, and `X-Frame-Options` must not be sent when third-party ancestors are allowed. Also verify the outer nginx/CDN is not adding its own stricter frame headers. |
## Creation And Generation
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Create button does nothing | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/generation-job-client.ts` | Client validation, auth token, `/api/generation-jobs` POST response, UI disabled/loading state. |
| Refreshing `/create` resets to the wrong creation tab | `src/app/create/page.tsx` | Active tab should persist in `miaojing:create-active-tab` and mirror to `/create?type=...`. Verify all creation tabs (`text2img`, `img2img`, `text2video`, `img2video`, `reversePrompt`) restore after refresh and query-param links still override storage. |
| 手机端创作提示词输入框没有固定在底部,或固定后遮住提示词/参考图/任务状态 | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css`, `src/components/navbar.tsx`, `scripts/test-mobile-create-ui-policy.mjs` | Mobile composer should be `position: fixed` and should use `ResizeObserver` to publish `--create-mobile-composer-height` to `.create-chat-layout`; `.create-chat-thread` must reserve that measured height through `padding-bottom`. The mobile bottom nav must be rendered outside the sticky header, because a sticky/backdrop-filter header can trap fixed children and make the nav appear near the top instead of the viewport bottom. Run `node --no-warnings ./scripts/test-mobile-create-ui-policy.mjs` and verify a mobile viewport such as 390x844. |
| Create button is disabled while another task is still running, or active job cards overflow horizontally | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | Create panels should keep submit enabled whenever models are available so users can start a different task while previous tasks run. Identical in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
| User cannot cancel a queued/running generation task, or a cancelled task still writes history | `src/components/create/generation-task-list.tsx`, `src/lib/generation-job-client.ts`, `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, create panel component | Task cards should pass `onCancelTask`, the client should call `cancelGenerationJob`, and `PATCH /api/generation-jobs/[id]` should set `status='cancelled'`. The worker must check the job is still `running` before charging credits, persisting history, or updating success/failure so late upstream responses do not resurrect cancelled jobs. |
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
| One submitted generation shows two running cards, or current results show the same media twice while refreshed history has one row | `src/app/api/generation-jobs/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/components/create/generation-task-list.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Check production `generation_jobs` first. If there are two queued/running jobs whose payload only differs by `clientRequestId`, inspect the API semantic dedupe query and each create panel's `activeSubmissionSignaturesRef`. If there is only one job and one result URL, the duplicate is frontend recovery state, not backend creation. Locally submitted tasks use temporary ids before the server job id is known; recovery must treat both `jobId` and `payload.clientRequestId` as the same task identity, and result appenders should filter duplicate URLs so a recovery poll cannot add the same completed media twice. |
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
| One submitted image task shows extra images, or the same generated URL appears twice in history | `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-worker.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | First check production API logs for `count:1` with upstream messages such as `Got 2 images`, then query `generation_jobs.result.images` and `works` grouped by `user_id,result_url`. The image route should cap persisted response images to the requested count because some upstream/custom providers can return more images than `n`; creation-history POST should serialize same-user same-URL inserts before the existing lookup so concurrent completion/local persistence cannot insert duplicate `works` rows. |
| User selects JPEG/WebP but the returned generated image is PNG | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/utils.ts` | First check PM2 logs for `[Image Generation] Params` and upstream request logs to confirm `outputFormat`/`output_format` reached the server/provider. Then query `works.params->>'outputFormat'` with `result_url` and inspect the object-storage response `Content-Type`/file magic for a recent key. Some providers may ignore `output_format` and still return PNG, so generated-image persistence must normalize the downloaded bytes to the selected format before `persistOriginalImageWithThumbnail(...)` uploads the object and writes history. If the object headers/magic bytes are already JPEG/WebP but the downloaded file still appears as PNG, check frontend `downloadFile(...)` callers and ensure filenames use `getImageDownloadExtension(...)` instead of a hard-coded `.png`. |
| Admin enables 100 Layout Compositions but generated images ignore composition guidance, or prompts show unwanted text/logo/poster layout | `src/components/admin/settings-tab.tsx`, `src/app/api/site-config/route.ts`, `src/lib/site-config.ts`, `src/lib/layout-composition-skill.ts`, `src/app/api/generate/image/route.ts` | Verify `/api/site-config` returns `imageCompositionSkillEnabled: true` and `site_config.image_composition_skill_enabled` exists. The image route should call `applyLayoutCompositionSkillToPrompt(...)` after `buildReferenceImagePrompt(...)` and before `mergeStylePrompt(...)`. The skill should append composition-only instructions referencing `nevertoday/100-layout-compositions` CC BY 4.0, but it must explicitly say not to add text, logos, brand marks, or literal poster elements. |
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
| 图生图/图生视频参考图或创作历史详情参考图加载慢,或详情不显示参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/components/reference-preview-image.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/api/creation-history/route.ts`, `src/lib/reference-image-storage.ts`, `src/lib/generation-job-worker.ts` | Upload/reference cards should render `ReferencePreviewImage` so large uploaded data URLs or remote URLs are downsampled for the card while click/fullscreen still uses the original. `/api/creation-history` should persist data URL or remote reference images into stable `/api/local-storage/works/references/...` URLs, write `params.referenceImages` and `params.referenceImageThumbnails`, and patch existing same-URL rows that were first inserted by the background worker without references. The generation worker must pass data URL reference inputs through to creation-history persistence instead of filtering them out before the server can store them. Creation detail should prefer `referenceImageThumbnails[index]` for the small grid and should not expose reference-image downloads. |
| Custom API image-to-image logs `Failed to download reference image from URL`, sends a 56-character `/api/local-storage/...` reference, or all URL-based strategies fail | `src/app/api/generate/image/route.ts`, `src/lib/local-storage.ts`, `src/lib/remote-fetch.ts` | Custom API img2img should read existing `/api/local-storage/...` references through `localStorage.readFileAsync(...)` for the FormData `images/edits` strategy instead of fetching back through public HTTP. When a data URL reference is uploaded for URL-based strategies, return `localStorage.generateObjectReadUrl(...)` when object storage is configured; only fall back to an absolute `APP_BASE_URL + /api/local-storage/...` URL, never a relative URL. |
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
| Generated video is not in object storage or video download/share feels slow | `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/app/gallery/page.tsx` | Video generation should persist remote/data video results via `uploadFileObjectOnly(...)` under `generated/videos`. `/api/download` should redirect object-backed `/api/local-storage/*` downloads to signed object URLs instead of buffering large videos, and video buttons should call `triggerDownloadFile(...)`. Gallery publish should reuse object-backed video URLs rather than copying large videos again; missing video thumbnails should be local WEBP frame thumbnails generated through `ffmpeg-static`, falling back to local SVG only if frame extraction fails. |
| A single generated video appears twice in text-to-video or image-to-video history | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | The client creates an optimistic local history row, then the server returns a DB row with a different id. Local storage and `/api/creation-history` must de-duplicate by `url`/`result_url`, preserving only one visible record for the same generated video. |
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. Detail metadata badges must use stored width/height with `loadMetadata={false}` rather than requesting original images. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/gallery/publish` should reuse stable `/api/local-storage/...` generated image originals and existing thumbnails instead of synchronously reading the object, copying it to `gallery/images`, or recompressing a gallery thumbnail. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts`, `src/lib/gallery-cache-policy.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when available, even when older than the short freshness TTL, then revalidate page 0 in the background. It should show the masonry skeleton instead of the old centered `加载中...` when no cache exists, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Check `/api/gallery` response size with curl; generated default avatar `data:` URLs in `publisherAvatarUrl` can make every page hundreds of KB larger and can break localStorage caching, so public gallery serialization must filter `data:`/oversized avatars. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Gallery video detail says `下载图片`, shows the generic play-card instead of a real thumbnail, or opens the original video too early | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/media-storage.ts`, `src/app/api/gallery/publish/route.ts`, `package.json` | Use `isVideoWork(...)` for labels and filenames. Video cards/details should render `thumbnailUrl` first and mount the original `<video>` only after the user clicks play. If thumbnails are missing or still use `video-svg-v1` or `video-fallback-svg-v2`, `/api/gallery` and publish should backfill local WEBP frame thumbnails under `thumbnails/gallery/videos` via `ffmpeg-static`; only `video-frame-m1280q86-v1.webp` is the current video thumbnail profile. SVG is only the fallback when extraction fails and must remain replaceable later. If PM2 logs show `spawn /ROOT/node_modules/.../ffmpeg ENOENT`, check `src/lib/media-storage.ts` runtime cwd fallback for `ffmpeg-static`; bundled route contexts can resolve the package from a synthetic path even though `/opt/miaojingAI/node_modules/.../ffmpeg` exists. If ffmpeg exits with code `139`/`unknown` and no stderr for an object-backed video, verify the thumbnail path is streaming the object through `localStorage.openFileStreamAsync(...)` into a temporary local file before extraction, not passing a signed object URL directly to ffmpeg. If object-storage reads intermittently terminate mid-stream, `src/lib/media-storage.ts` should retry bounded temporary input writes before falling back to SVG. |
| Gallery or history detail logs/requests original generated URLs while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` or `/api/creation-history` response first. The console line/request can be caused by metadata probing rather than the preview image. Gallery and history detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
| Create page loads slowly and console shows CORS errors for historical `coze-codingproject.tos.coze.site` images | `src/components/create/cached-preview-image.tsx`, `src/components/create/text-to-image.tsx`, `src/app/api/download/route.ts` | Hidden mobile history must not mount desktop-side image effects, and cross-origin historical result images should render through the same-origin `/api/download?disposition=inline` proxy before canvas preview generation. Otherwise every hidden history image can issue a blocked browser request and slow the page. |
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Creation detail's `复用配置` must also support video history: text-to-video records write `text2video`, image-to-video records write `img2video`. Image-to-image and image-to-video reuse should include stored reference images from the work; when intentionally using the generated output as the new reference, fall back to the original output `url`, never `thumbnailUrl`. |
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
| Generated image result hover actions are unreadable in light theme | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | The result-card hover overlay owns the Preview/Share/Download buttons. These buttons should use fixed dark translucent backgrounds and white text/icons so light and dark themes have the same readable hover action style. |
| Generated image is pushed down by a long prompt in the desktop result column | `src/components/create/text-to-image.tsx` | The result prompt above new images should be a compact two-line summary with the full prompt only in the title tooltip/history detail. Do not render the entire prompt as an unbounded paragraph in the live result column. |
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |
| Reverse prompt says `请先登录后再使用自定义 API` while the user is already logged in | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/auth-store.ts`, `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/server-api-config.ts` | The reverse-prompt fetch must send `Authorization: Bearer <accessToken>` from `readStoredAuth()`. The server resolves `customApiKeyId`/`systemApiId` through `getAuthenticatedUserId`, which reads the bearer token rather than browser localStorage. |
| Reverse prompt keeps disappearing after refresh, relogin, or tab switch | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/app/api/generation-jobs/route.ts`, `src/app/api/creation-history/route.ts` | Reverse prompt now uses the shared generation job queue and should recover queued/running jobs from `/api/generation-jobs`, keep the loading state alive until the worker actually finishes, and rely on the normal creation-history writeback when the worker completes. |
| Reverse prompt reaches login successfully but then times out on upstream `chat/completions` | `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/local-storage.ts`, `src/lib/custom-api-fetch.ts` | If the input is a data URL, persist it first and send the public `/api/local-storage/...` URL upstream instead of the raw blob. Reverse-prompt is a multimodal chat/completions request, so a 524 here means the upstream multimodal endpoint or its latency is the problem, not frontend auth or image-generation routing. |
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. This route also uses a multimodal chat/completions path, so 524 should be read as a multimodal upstream timeout rather than a synchronous image-generation failure. |
## Models, Providers, API Keys
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Model list empty in create/profile | `src/app/api/model-config/route.ts`, `src/lib/model-config.ts`, `src/lib/managed-model-store.ts`, `src/lib/custom-api-store.ts` | Public model config response, admin recommendations, local client store mapping. |
| Default model group shows raw API model name instead of the admin display name | `src/lib/model-display.ts`, `src/app/api/model-config/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Frontend system model labels should use `system_api_configs.name` first. `model_name` is the upstream request model identifier and should remain available for generation dispatch, but it should not override the admin-facing display name in the create-page default model group. |
| Backend default models are configured but `/api/model-config` returns only `{"providers":[],"recommendations":[]}` or no `systemApis` | `src/app/api/model-config/route.ts`, `src/lib/server-api-config.ts`, production database owner/grants | Check PM2 logs for `must be owner of table system_api_configs`. After migration, runtime tables must be owned by the app DB user, or optional schema checks should not be allowed to empty the public model-config response. Fix ownership/grants first, then verify `/api/model-config` includes `systemApis`. |
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same media type plus same admin display name (`system_api_configs.name`) polling candidates, `polling_mode`, and `polling_order`; `model_name` is only the upstream request model. User custom APIs should not enter this polling path. |
| System default model generates successfully but user credits do not decrease | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-credit-service.ts`, `src/lib/server-api-config.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Credit deduction must happen on the server after a successful system-default image/video generation, using the selected frontend `systemApiId` row in `system_api_configs` for pricing. Failed jobs must not deduct credits. New-job balance preflight should subtract queued/running system-default jobs for that user so rapid repeated submissions cannot overbook credits. Create buttons should not show predicted credits; completed result cards should show the `creditsCost` returned in the generation job result, and the profile balance should refresh from `creditsBalance`. |
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
| Intelligent API dialog is too narrow, clipped, or shows only JSON | `src/components/profile/api-key-manager.tsx`, `src/components/ui/dialog.tsx` | Smart import dialogs must override the shared dialog's `sm:max-w-lg` with explicit wide sizing such as `w-[min(...)] max-w-none sm:max-w-none`, cap height to the viewport, and keep the JSON editor inside an internal scrollable/flexible area so title, actions, and footer remain visible. |
| Intelligent API import creates wrong or mixed requests | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Imported edit forms should show a human-readable provider name and a non-empty API request URL derived from `profile.baseUrl + submit.path` only when the Manifest provides enough endpoint data; never invent an OpenAI default URL for a third-party relay document. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
| Create model dropdown shows many `导入的 API Key` entries | `src/lib/model-display.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts` | These are user custom API key rows, not admin default models. Generic import placeholder notes must be ignored/cleared so labels show provider plus model or a real custom note plus model. Do not delete user custom API rows unless explicitly requested. |
| Admin intelligent API import is missing or generated system models ignore Manifest | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | Admin imports must create one `system_api_configs` row per Manifest profile, write `system-api-manifests/<systemApiId>.json`, persist `manifest_path`, and resolve that path from the selected `systemApiId`. Imported rows still need API Key and pricing review before use. |
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills``/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403``fetch failed``Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
| Agnes 视频任务先显示 `in_progress` 后失败,错误为裸 `fetch failed` 或历史不写入 | `src/lib/agnes-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/video/route.ts`, `src/lib/generation-job-worker.ts` | 先区分提交、轮询、结果视频下载保存、历史写入四段。Agnes V2.0 使用 `POST /v1/videos``GET /agnesapi?video_id=...&model_name=agnes-video-v2.0``remixed_from_video_id` 在官方完成响应中是视频 URL。Manifest 执行器会把网络异常包装成“上游任务创建/轮询网络连接失败”;视频结果保存失败应显示“上游已返回视频地址,但平台下载或保存结果视频失败”。若 job 成功但 `works` 没有记录,查 `[generation-worker] creation history persistence failed` 中的内部 URL。Agnes 时长不要传 `duration`,当前仅开放稳定的 3/5/10 秒并映射为 `num_frames` 81/121/241`frame_rate=24`18 秒在生产中会进入轮询后返回上游 `failed`应从能力列表隐藏并在后端拒绝旧请求。Agnes 视频是后台异步任务,`/api/generate/video` 给它单独 20 分钟轮询窗口,刷新/切页由 generation job 恢复链路继续显示状态。Manifest 总轮询预算要和单次请求超时分开,单次轮询 502/503/504、`fetch failed` 或网络超时应先更新任务进度并继续轮询,只有总预算耗尽才标记超时失败。 |
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/server-api-config.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. For system default polling, `resolveSystemApiPollingCandidates(...)` must also run `ensureYuanjieSystemApiManifest(...)`; otherwise stale production `system-api-manifests/<id>.json` files can keep old `$inputImages.dataUrls` and old task-id paths even when source templates are fixed. |
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
| 兑换码无法生成、重复、兑换后积分或会员不到账,或可重复兑换 | `src/components/admin/redeem-code-management-tab.tsx`, `src/app/api/admin/redeem-codes/route.ts`, `src/components/profile/credits-tab.tsx`, `src/app/api/redeem-codes/redeem/route.ts`, `src/lib/redeem-code-service.ts` | Codes should be generated server-side with unique `normalized_code`. Redemption must use a DB transaction with `FOR UPDATE` locks on `redeem_codes` and `profiles`, then mark `used_by/used_at`. Credit codes update `profiles.credits_balance` and insert a `credit_transactions` row. Membership codes update `profiles.membership_tier` plus `membership_expires_at`; duration units are `day`, `month`, and `year`. If the profile page is stale, inspect `/api/profile` refresh and `/api/credit-transactions`. |
| 获取兑换码或会员升级不跳转商城 | `src/components/admin/redeem-code-management-tab.tsx`, `src/components/profile/credits-tab.tsx`, `src/app/profile/page.tsx`, `src/app/api/site-config/route.ts`, `src/lib/site-config.ts` | The admin redeem-code tab saves the shared mall URL as `site_config.redeem_code_mall_url` through `/api/site-config`. Frontend buttons read `siteConfig.redeemCodeMallUrl`; empty config should show a toast instead of navigating. Verify the DB column exists, `/api/site-config` returns `redeemCodeMallUrl`, and the URL starts with `http` or `https`. |
| 邀请链接不生成、邀请注册未发积分、后台看不到邀请关系 | `src/components/profile/credits-tab.tsx`, `src/app/api/invitations/me/route.ts`, `src/app/auth/register/page.tsx`, `src/app/api/auth/register/route.ts`, `src/app/api/admin/invitations/route.ts`, `src/lib/invitation-service.ts` | `profiles.invite_code` must be unique and stable. Registration links use `/auth/register?invite=...`; successful invited registration writes `invitation_referrals`, sets `profiles.referred_by_user_id`, grants 50 credits to inviter and invitee, and writes `credit_transactions` rows in the registration transaction. |
| New API image endpoint incompatible | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts` | Provider is `newapi`/`new api`, endpoint normalization, model-specific size/count/quality handling. |
| API key leaked in UI/API | `src/app/api/user-api-keys/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-crypto.ts`, `src/lib/server-api-config.ts` | Response mapping must return preview/empty key only. |
| Yuanjie GPT Image 2 image-to-image ignores reference images or behaves like text-to-image | `src/components/create/image-to-image.tsx`, `src/app/api/generate/image/route.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts` | Check whether the route reads `extraImages`, normalizes `image + extraImages` into Manifest `inputImages`, and whether Yuanjie templates use `$inputImages.urls` for `params.images`, top-level `images`, and `base64Array`. The executor should upload data URL references into storage and expose public references as `$inputImages.urls`; do not fix this by changing mozheAPI or generic OpenAI-compatible fallbacks. |
## Gallery And Creation History
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes; they also store created job ids in per-user browser localStorage and query `/api/generation-jobs/[id]` for terminal `succeeded`/`failed`/`cancelled` jobs that finished while the browser was closed. Create-page history hooks intentionally request only the current mode and recent limit; if a create panel misses old records, check the API `mode` filter against `type`, `params.creationMode`, `params.workType`, `params.mode`, and legacy reference-image inference before raising the limit or reverting to full history. If the task card reappears after refresh but never turns into a result/error, inspect `src/components/create/use-generation-job-recovery.ts`; active-task state updates must not be part of the polling effect dependency list, or the recovery poller can be cancelled immediately after reattaching a job. |
| Detail delete removes only local history, skips confirmation, or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/components/ui/alert-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | The detail action is labeled `删除作品` and must open a confirmation dialog warning that deletion cannot be recovered. Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
| Published work not in gallery or share to gallery is slow | `src/lib/creation-history-store.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, stable `/api/local-storage/...` `result_url`, media copied/reused into gallery storage, and current filters. New generated `/api/local-storage/...` image/video URLs should use the publish fast path in `gallery-publish-media` and must not synchronously copy object-backed originals during share; external URLs still need copying and should fail the publish request if media preparation fails. Also check whether the browser marked the work shared before `/api/gallery/publish` returned success; local `published=true` without `publishedAt` is stale and should not block retry. For older incidents, inspect server logs/API status for publish failures that the previous frontend swallowed. |
| 图生图/图生视频分享到画廊后看不到参考图,或复用/获取灵感没有带上参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts` | The create panels should send `referenceImage`, `referenceImages`, `refImageCount`, and `referenceImageAnnotations` to `shareToGallery`. `/api/gallery/publish` should persist data URL or remote reference images into stable `/api/local-storage/gallery/references/...` URLs before storing them in `works.params`. Public gallery detail and inspiration detail may preview reference images but must not expose reference-image download actions; reuse drafts should prefer original `referenceImages` and only fall back to output media as reference when no references exist. |
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media through the active storage adapter. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. For object migration, run `pnpm run storage:sync-object -- --verify-only` before switching to `STORAGE_MODE=object`. |
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
| Admin gallery prompt edit fails, sends no email, or prompt changes without audit trail | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/email-service.ts`, `src/lib/platform-logs.ts` | Prompt moderation is console-only and requires a valid author email. `/api/admin/gallery/prompt` must send the email before updating `works.prompt`; SMTP failure, unchanged prompt, non-public work, or invalid author email should block the update. Platform logs should include reason key and prompt length metadata, not full prompt text. |
| Admin gallery management pagination wrong or page buttons skip records | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/lib/admin-gallery-works-pagination.ts` | The admin table uses `page` and `pageSize`; the route converts those to SQL `LIMIT/OFFSET` and still accepts legacy `limit/offset`. Verify `total`, `page`, `pageSize`, `totalPages`, `nextOffset`, and `hasMore` in the response, and reset page to 1 when search/type/page size changes. |
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `category`, `limit`, `offset`, `sort`, `q/search`; SQL where/order, browser cache signature, and pagination append state. |
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
## Admin Console And Ops
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Dashboard stats wrong | `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts` | SQL source tables, safe query fallbacks, admin auth. |
| User management bug | `src/components/admin/user-management-tab.tsx`, `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts` | Role/tier mapping, active flag, admin auth, password reset, and row actions. Admin reset-password must be reachable directly from the user row, not only behind the edit modal, and should clear/close overlapping edit state before showing the reset form. `/api/admin/users` PUT with `newPassword` must upsert `auth.users.password_hash` with `crypt(..., gen_salt('bf'))` so missing auth rows cannot silently return success without a usable password. |
| API/model management list opens for anonymous users or admin tab loads empty provider/recommendation rows | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/providers/route.ts`, `src/app/api/admin/model-recommendations/route.ts`, `src/lib/admin-auth.ts` | Provider and recommendation GET routes require admin auth just like mutations. The admin tab's initial `fetch('/api/admin/providers')` and `fetch('/api/admin/model-recommendations')` must include bearer auth headers; anonymous requests should return 401. |
| Order/payment bug | `src/components/admin/order-management-tab.tsx`, `src/components/admin/payment-tab.tsx`, `src/app/api/admin/orders/route.ts`, `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` | Payment config encryption, order status, request shape. |
| Data import/export bug | `src/components/admin/data-management-tab.tsx`, `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs` | JSON format, table coverage, admin auth, `_media` coverage, import transaction/savepoints, password hash/encrypted secret preservation, and work dedupe by URL/source URL/media SHA scoped to the same `user_id` so one user's private work cannot collapse into another user's row. Run `pnpm run migration:check` for the read-only migration gate. The checker should default to the web port 8000 and use bounded timeout/concurrency helpers for `/api/local-storage/*` probes so one slow or missing media URL is reported rather than crashing the whole script. |
| Admin email send/settings bug, test email blank, or user notification email blank | `src/components/admin/settings-tab.tsx`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`, `src/lib/email-service.ts` | SMTP config, template rendering, send logs, MIME multipart assembly, and body encoding. Keep both text/plain and text/html parts non-empty, preserve required MIME blank separator lines, and fold base64 body lines to MIME-safe lengths before the SMTP DATA terminator. |
| Upgrade page stuck/fails | `src/components/admin/system-upgrade-tab.tsx`, `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs`, `scripts/backup-create.sh`, `scripts/backup-restore.sh` | State dir, package limits, disk checks, backup validation, restore safety backup, PM2 restart command, stale status. |
| Platform logs missing or system log page says loading failed | `src/components/admin/log-management-tab.tsx`, `src/app/api/admin/logs/route.ts`, `src/lib/platform-logs.ts`, routes that call `writePlatformLog` | The page loads `/api/admin/logs`; verify the route exists, admin bearer auth is valid, `ensurePlatformLogSchema()` runs before querying, log retention cleanup is not failing, and API calls actually write logs. |
## Storage, Download, Files
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| `/api/local-storage/...` 404 | `src/app/api/local-storage/[...path]`, `src/lib/local-storage.ts` | `STORAGE_MODE`, `LOCAL_STORAGE_DIR`, `OBJECT_STORAGE_*`, normalized key, and whether the object exists in local disk or bucket. Dual mode should fall back to local disk if object storage is missing a migrated key. |
| Production `/api/local-storage/...` still buffers originals after deployment | `src/app/api/local-storage/[...path]/route.ts`, deploy rsync command | Confirm the live production route contains `generateObjectReadUrl(...)` and `NextResponse.redirect(...)`. If local code is correct but production is stale, check whether rsync used a broad `local-storage/` exclude; use `/local-storage/` for the repo-root runtime directory so `src/app/api/local-storage/[...path]/route.ts` is not skipped. |
| Download says remote fetch failed | `src/app/api/download/route.ts`, `src/lib/remote-fetch.ts` | URL is http(s), same-origin, or local-storage; upstream reachable; timeout. |
| Path traversal/security concern | `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/local-storage/[...path]/route.ts` | Keep `normalizeKey`, `path.resolve`, and `..` guards. |
## Deployment And Build
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Build or type check fails | `package.json`, `scripts/build.sh`, `next.config.ts`, failing source file, `CODEX_MIAOJING_MEMORY.md` | First run `corepack pnpm install --frozen-lockfile` if dependency modules are missing. Current audited baseline already recorded failures for missing `@/lib/model-display` and canvas type errors. Distinguish pre-existing source errors from your docs/change. |
| PM2 app not updated | `ecosystem.config.cjs`, `scripts/start.sh`, `scripts/deploy-or-upgrade.sh` | Process cwd, role ports, environment variables, `pm2 startOrReload ecosystem.config.cjs --update-env`. |
| Production uses different checkout | `ecosystem.config.cjs`, PM2 process env/cwd | Always verify PM2 cwd before editing production. |
| Production returns 502 after source sync or PM2 reload | `ecosystem.config.cjs`, `scripts/start.sh`, PM2 env, nginx upstream ports | Check whether production `ecosystem.config.cjs` was overwritten by a repo/dev copy. Live production on 2026-05-14 used `/opt/miaojingAI`, Node `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`, and ports `8000/8100/8200`; a repo copy pointing at `/root/miaojingAI` and `5000/5100/5200` breaks nginx until the production config is restored and PM2 reloaded. |
| Upgrade package cleanup failed | `scripts/deploy-or-upgrade.sh`, `scripts/admin-upgrade-runner.mjs`, `src/app/api/admin/upgrade/route.ts` | Cleanup trap, backup paths, state dir, disk space guards. |
| Unsure whether a change is safe for hot update or needs cold update | `scripts/admin-upgrade-runner.mjs`, `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx`, `docs/codex-miaojing/architecture.md` | Treat source/API/server/dependency/schema/env/runtime/script changes as cold-update candidates. Hot updates should be static/public asset-only and must pass runner preflight without restart. Verify backup, rollback, package limits, disk checks, PM2 restart expectations, and `/api/health` before marking deploy-facing work complete. |
## Useful Search Patterns
```bash
rg -n "export async function (GET|POST|PUT|DELETE)" src/app/api
rg -n "writePlatformLog|console\\.error|console\\.warn" src
rg -n "generation_jobs|works|profiles|site_config|system_api_configs" src scripts *.sql
rg -n "localStorage|/api/local-storage|/api/download" src
rg -n "requireAdmin|getAuthenticatedUser|getAuthenticatedUserId" src/app/api src/lib
```

View File

@@ -0,0 +1,54 @@
# Custom Integrations
Use this document before changing non-generic provider/platform behavior. If a user request includes a custom keyword such as `元界`, `mozheAPI`, or `智能配置 API`, first check long-term memory and the relevant rows in the Codex docs, then verify the current source/runtime before editing.
## Required Workflow
1. Search existing long-term memory and this docs folder for the exact custom keyword.
2. Read the matching feature, bug, API, or architecture entry before touching code.
3. Treat custom provider/platform behavior as a named integration boundary, not as generic fallback logic.
4. Preserve the provider-specific contract unless the user explicitly changes that contract.
5. When a fix reveals a new reusable rule, update this file or the matching Codex doc in the same change set.
6. If the rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
## 元界 AI
- Start with `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/lib/user-api-manifest-executor.ts`, and the selected create panel.
- Built-in 元界 templates are not generic OpenAI-compatible models. Their manifests may map UI fields to provider-specific params such as `size`, `aspect_ratio`, `aspectRatio`, `imageSize`, `resolution`, `quality`, `images`, or task polling fields.
- Built-in 元界 video media requests should stay in Manifest form and should not fall back to the generic OpenAI-compatible video parser. `/v1/media/generate` submit bodies use only `model`, `prompt`, and `params`; HappyHorse text-to-video specifically sends `params.resolution`, `params.ratio`, and `params.duration`, then reads `output.task_id` and polls `/v1/media/status?task_id=...`.
- Some image models expose orientation through a `size`/`resolution` value instead of a separate aspect-ratio field. In those cases the create panel must derive the ratio from the selected option label or pixel dimensions, rather than requiring the user to write the ratio in the prompt.
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
- If 元界后台 shows a successful image but MiaoJing marks the job failed, treat it as a result-media download/persistence issue before changing submit/poll config. Check `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, and `src/lib/remote-fetch.ts` for 403, timeout, object-storage, or thumbnail errors. Result URL fetches should use browser-like headers plus limited retry, and Manifest result persistence failures should be reported as platform download/save failures, not as image-resolution mismatch.
- Do not add `自动` back to controls where the user explicitly asked for explicit manual choices. Image count should default to `1` when automatic inference is not part of the requested workflow.
- Admin default models must use `system_api_configs.name` as the frontend display name, while `model_name` remains the upstream request model.
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. New-job balance preflight should include same-user queued/running system-default jobs. Image and video create UI should display only the completed job's returned `creditsCost` and refresh the profile balance from `creditsBalance`, not a separate predicted button cost. Failed jobs must not write consume transactions.
- Yuanjie image and video templates that accept reference media should render provider-facing references with `$inputImages.urls`, not `$inputImages.dataUrls`. The Manifest executor uploads data URL references into storage first, then exposes object/local-storage public URLs as `$inputImages.urls` while keeping raw data URLs available as `$inputImages.dataUrls` for multipart/file-upload manifests.
- Yuanjie GPT Image 2 / GPT Image 2 official-transfer image-to-image must pass all frontend references. `src/components/create/image-to-image.tsx` sends the primary image as `image` and additional references as `extraImages`; `src/app/api/generate/image/route.ts` must normalize them into the Manifest `inputImages` array before calling `src/lib/user-api-manifest-executor.ts`.
- Yuanjie video templates must stay in `src/lib/yuanjie-video-model-templates.ts` and map documented model-specific fields there instead of changing generic mozheAPI/custom-API request builders. Current documented special mappings include `sora-2.params.input_reference`, `wan2.6-cankaosheng.params.reference_urls`, `wan2.6-shouzheng.params.img_url`, `kling-v3-omni-shouweizhen.params.image/image_tail`, `happyhorse-r2v.params.ratio`, `grok-video-3.params.size`, and `veo3.1.params.generation_mode/enhance_prompt/enable_upsample`.
- Existing admin-created 元界 system rows may have an empty `manifest_path` and a submit endpoint in `api_url`. `src/lib/yuanjie-system-manifest.ts` is responsible for exposing built-in frontend capabilities for those rows and repairing them at generation time by writing the missing Manifest and normalizing `api_url` to the 元界 base URL. This repair must remain scoped to 元界 provider/model-group checks and must not rewrite mozheAPI.
- 元界价格/计费方式同步 is manual only. Admins trigger it from the `系统默认模型` provider view; the route is `/api/admin/system-apis/yuanjie-pricing` and the logic lives in `src/lib/yuanjie-pricing-sync.ts`. It updates only existing 元界 image/video rows, accepting provider spellings such as `元界 AI`/`元界AI` and `yuanjie-*` model groups, synchronizing `billing_mode` and a `元界计费同步` price note from local built-in template metadata. It must not delete, create, or rewrite mozheAPI rows, generic smart-import rows, API keys, Manifest paths, or administrator-entered numeric prices.
## Agnes AI
- Start with `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, and `src/components/admin/api-management-tab.tsx`.
- Agnes built-in templates belong to the `系统默认模型` management flow. Do not expose them as a generic `智能配置 API` import; keep one system API row per model and one independent `system-api-manifests/<systemApiId>.json` file for each image/video row.
- The API base is `https://apihub.agnes-ai.com`. Image models `agnes-image-2.1-flash` and `agnes-image-2.0-flash` use `POST /v1/images/generations` with `model`, `prompt`, `size`, and optional top-level `image: string[]` for image-to-image. URL output must be requested as `extra_body.response_format = "url"`; do not put `response_format` at the top level. Read `data.*.url`, with `data.*.b64_json` as a fallback.
- Video model `agnes-video-v2.0` uses `POST /v1/videos` to create an async task and `GET /agnesapi?video_id={video_id}&model_name=agnes-video-v2.0` to poll. Treat `video_id`, `task_id`, or `id` as the task identifier, `completed` as success, `failed` as failure, and read the final video from `remixed_from_video_id`, `video_url`, or `url`.
- Agnes Video duration is controlled by `num_frames`, not a `duration` request field. The create route currently exposes only production-stable UI durations 3/5/10 seconds, maps them to the documented 24fps frame counts 81/121/241, and sends `frame_rate: 24`. Do not re-enable 18 seconds from stale Manifests or older docs until production evidence shows Agnes no longer returns upstream `failed` for that length; backend requests for 18 seconds should be rejected quickly with a clear user-facing message.
- Agnes Video generation can spend minutes in the async task. Keep the Manifest total polling budget separate from per-request submit/poll timeouts, and treat single poll-side 502/503/504, `fetch failed`, or network timeouts as transient until the total budget expires.
- For image-to-video, Agnes uses the top-level `image` field as the starting/first frame. Do not treat Agnes Video as a generic multi-reference video model unless Agnes adds a separate reference-image field.
- Text/multimodal models `agnes-2.0-flash` and `agnes-1.5-flash` use OpenAI-compatible `POST /v1/chat/completions`; they do not need Manifest files and can be used by prompt optimization or reverse prompt through the existing system text API path.
- The installer creates Agnes rows as inactive, 0-credit templates with empty API Key fields so admins can fill the Key in the existing system API edit form, review visibility/member scope, and then enable the model.
## mozheAPI
- Start with `src/proxy.ts` for iframe/embed failures before changing page components.
- Third-party embedding depends on CSP `frame-ancestors` and `X-Frame-Options`; `SAMEORIGIN` blocks external parents even if app pages render correctly.
- Keep the allowlist explicit. Do not globally weaken security headers for unrelated origins.
## 智能配置 API
- User-level manifests must stay one model/key row to one JSON Manifest file. Do not merge multiple request configs under one user-level shared file.
- Admin global default manifests can be shared through system API configuration, but generation must still resolve the selected model row and charge according to that model's pricing rules.
- Imported rows should preserve `manifest_path`, provider/model display metadata, and per-model request templates.

View File

@@ -0,0 +1,173 @@
# Feature Code Location Index
Last source audit: 2026-05-20, based on git commit `632c94b`.
Use this document to jump directly to code before broad searching.
## Global Application Shell
| Feature | Primary Files | Notes |
| --- | --- | --- |
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav should not include removed feature routes. Logged-in desktop/mobile user buttons should show `AuthUser.avatarUrl` first and fall back to the display nickname initial only when the avatar is missing or fails to load. Avoid eager all-route `router.prefetch(...)` from the navbar; on production web it can compete with visible route/resource loading. The initial logged-in profile refresh is idle and one-shot so it does not compete with the first route transition. Mobile bottom navigation must render as a sibling after the sticky header, not inside it, because header backdrop/filter contexts can trap `position: fixed` children away from the viewport bottom. |
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/app/globals.css` | Frontend popup behavior plus backend announcement CRUD. Desktop dialog is intentionally wide (`max-w-5xl`) for long Markdown notices; scrollbar styling is inherited from the global glass scrollbar rules. |
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. `useSiteConfig()` keeps a shared browser snapshot, skips network refresh while that snapshot is still within the 5-minute TTL, and reuses the in-flight `/api/site-config` refresh so global consumers such as navbar, footer, site-brand, and policy pages do not each create their own concurrent config request during navigation. The API route runs legacy schema/default compatibility checks once per server process and retries on failure. |
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. The client posts with `keepalive` after browser idle time so statistics collection does not block first paint or navbar route transitions. |
| Security headers and iframe embedding | `src/proxy.ts`, `.env.example` | CSP is set in the Next proxy. `frame-ancestors` controls which external platforms may embed MiaoJing in an iframe; `MIAOJING_FRAME_ANCESTORS` can override the default self + mozheAPI allowlist. When external ancestors are allowed, do not send `X-Frame-Options: SAMEORIGIN`, because it blocks third-party iframes. |
## Public Pages
| Route | Primary Files | Supporting Files |
| --- | --- | --- |
| `/` | `src/app/page.tsx` | `src/components/app-shell.tsx` |
| `/create` | `src/app/create/page.tsx` | `src/components/create/*` |
| `/gallery` | `src/app/gallery/page.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/route.ts` |
| `/image-viewer` | `src/app/image-viewer/page.tsx` | Fullscreen original-image share page opened from image right-click share links. |
| `/profile` | `src/app/profile/page.tsx` | `src/components/profile/*`, `src/app/api/profile/route.ts` |
| `/about` | `src/app/about/page.tsx` | `src/components/site-policy-page.tsx`, `src/lib/site-policy-defaults.ts` |
| `/terms` | `src/app/terms/page.tsx` | `src/components/site-policy-page.tsx` |
| `/privacy` | `src/app/privacy/page.tsx` | `src/components/site-policy-page.tsx` |
| `/help` | `src/app/help/page.tsx` | `src/components/site-policy-page.tsx` |
## Auth And User Account
| Feature | Primary Files | Notes |
| --- | --- | --- |
| Login UI | `src/app/auth/login/page.tsx` | Calls `/api/auth/login`. |
| Register UI | `src/app/auth/register/page.tsx`, `src/components/auth/registration-agreement-dialog.tsx` | Requires accepted terms and email code except admin invite path. |
| Auth store | `src/lib/auth-store.ts` | Client auth state and token persistence. `AuthUser.username` is the login username from `profiles.nickname`; `AuthUser.nickname` is the public display nickname from `profiles.display_nickname`. |
| Session tokens | `src/lib/session-auth.ts` | HMAC token format, bearer parsing, admin checks. |
| Login API | `src/app/api/auth/login/route.ts` | Handles admin fallback and normal users. |
| Register API | `src/app/api/auth/register/route.ts` | Creates `auth.users`, `profiles`, initial credits, random Chinese display nickname, and default 3D cartoon avatar. The submitted register `nickname` is treated as login username for compatibility. |
| Admin exists | `src/app/api/auth/admin-exists/route.ts` | Admin setup checks. |
| API test/model fetch | `src/app/api/auth/test-api/route.ts`, `src/app/api/auth/fetch-models/route.ts` | Used by provider/API configuration UI. |
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts` | Profile edits, password/email/theme. |
## Creation Center
| Feature | Primary Files | Server/API Files |
| --- | --- | --- |
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. Keep the five primary creation panels statically imported in this page: production users switch between these modes constantly, and `ssr:false` dynamic splitting adds visible chunk waits and fallback flashes on direct web access. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow for all five modes: history/status cards render above the fixed composer, hidden desktop result/history regions should not keep loading large media, and `src/components/create/mobile-creation-composer.tsx` owns the bottom send surface with compact params, optional styles/references, prompt or custom input, and right send button. The mobile thread reserves the measured composer height through `--create-mobile-composer-height` so fixed-bottom input does not cover prompts or previews. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The create-panel history hook must stay scoped, e.g. `useCreationHistory({ mode: 'text2img', limit: 60 })`, so opening `/create` does not download the user's full history payload. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Reference thumbnails single-click into a bare image overlay and use lightweight local preview rendering instead of painting the full uploaded data URL in every card. Active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 Mobile uses the fixed composer with an upload/reference thumbnail strip, mention-aware prompt input, compact ratio/resolution/format/count controls, and a mobile-only status/history flow. |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. Mobile uses the fixed composer with compact ratio/duration/resolution/camera controls, a horizontal style strip, and thumbnail/placeholder history cards instead of preloading hidden desktop videos. |
| Image to video | `src/components/create/image-to-video.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image and use lightweight local preview rendering. Active jobs render through `src/components/create/generation-task-list.tsx`, the create button remains available while active tasks exist, and running tasks can be cancelled. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 Mobile uses the fixed composer with an upload/reference thumbnail strip, mention-aware prompt input, compact video controls, and thumbnail/placeholder history cards instead of preloading hidden desktop videos. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`. Reverse prompt now runs as a background job, survives refresh/auth change/tab switch, and writes the completed result back into the normal creation history flow instead of relying on an optimistic local-only row. Mobile uses the fixed composer custom input slot for upload/change-image, prompt mode, language controls, and a mobile status/result/history flow. |
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by all five creation panels to match chat-style clients: top parameter strip with compact dropdown buttons, optional reference/upload strip, optional style strip, default prompt input or a custom input slot, and right send button. It uses `ResizeObserver` to publish `--create-mobile-composer-height` on the nearest `.create-chat-layout`; `.create-chat-thread` must keep a matching bottom padding so the fixed composer stays at the bottom without covering prompts, references, or status cards. The mobile creation center uses one 16px UI font size across selected values, style chips, composer input, and conversation prompts. The mobile text/image parameter strips hide long labels and remove `自动` from compact ratio/resolution/count choices where the panel already defaults to explicit values. The mobile style strip shows only one horizontal row when collapsed and expands upward for search/more presets after tapping `展开`; video styles use a horizontal chip strip. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls, while mobile result/history/status flows mount only on mobile viewports. |
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a one-row collapsed strip and an expanded list of at least several rows inside the bottom composer. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column, exposes cancel buttons for normal active tasks, and image/video create panels render active tasks plus completed result cards together so earlier finished jobs do not disappear while later jobs keep running. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. If a reuse action intentionally uses a generated output as a new reference image, it must use the original `url` rather than `thumbnailUrl`; thumbnails are display-only and must not be sent back into image-to-image or image-to-video generation. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/components/create/cached-preview-image.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Fullscreen/lightbox components can receive a thumbnail fallback to display immediately while the original object-storage URL loads. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge; detail dialogs must pass stored `width`/`height` with `loadMetadata={false}` so the badge does not fetch the original image just to compute size. `BareImagePreview` is the no-container overlay for uploaded reference image previews. `CachedPreviewImage` generates same-origin cached previews and proxies cross-origin historical URLs through `/api/download?disposition=inline` to avoid browser CORS failures. |
## Generation System
| Responsibility | Primary Files | Notes |
| --- | --- | --- |
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. Active-job recovery skips anonymous list polling and reuses same-token, same-type list requests briefly, so refresh/auth-change recovery does not add duplicate `/api/generation-jobs` pressure while tasks keep polling individually until success/failure. Created job ids are also stored per logged-in user in browser localStorage until the job reaches `succeeded`, `failed`, or `cancelled`; `useGenerationJobRecovery` merges that pending list with server queued/running jobs and queries `/api/generation-jobs/[id]` so jobs that finish while the browser is closed can still reappear with their terminal result/error before being cleared. |
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. Active queued/running jobs are semantically deduped while ignoring top-level `clientRequestId`, so a double-click or fast retry returns the existing job instead of creating a second one. |
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
| Image route | `src/app/api/generate/image/route.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/layout-composition-skill.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. Generated image originals are normalized to the user-selected output format before upload, so providers that ignore `output_format` and return PNG still produce `.jpg`/`.webp` objects when JPEG/WebP was requested. When `site_config.image_composition_skill_enabled` is true, `src/lib/layout-composition-skill.ts` deterministically selects one of the 100 CC BY 4.0 `nevertoday/100-layout-compositions` references and appends composition guidance before style prompts and upstream requests; it should not add text, logos, or literal poster elements. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. For image-to-image, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
| Video route | `src/app/api/generate/video/route.ts`, `src/lib/reference-image-prompt.ts` | SDK + custom/system API video, persistence. Generated video data URLs and upstream video URLs are persisted through `localStorage.uploadFileObjectOnly(...)` under `generated/videos`, so production video originals live in object storage when configured. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. For image-to-video, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI and Agnes AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. 元界价格/计费方式手动同步 uses `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` and `src/lib/yuanjie-pricing-sync.ts`; it updates only existing 元界 image/video rows, tolerates provider spellings such as `元界AI`, and leaves mozheAPI/global smart-import configs untouched. |
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
| Manifest input image URLs | `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/reference-image-prompt.ts` | Manifest templates can use `$inputImages.dataUrls` for raw uploaded data and `$inputImages.urls` for provider-facing public references. The executor converts data URL input images into storage-backed URLs before rendering templates. Image-to-image and image-to-video generation normalize the primary `image` plus `extraImages`/`images` into Manifest `inputImages`, so multi-reference providers such as Yuanjie GPT Image 2 receive all references. `referenceImageAnnotations` are not a Manifest variable; routes fold them into `$prompt` before execution so existing templates inherit the mapping. |
## Models And Providers
| Feature | Files | Notes |
| --- | --- | --- |
| Built-in model options | `src/lib/model-config.ts`, `src/lib/model-config-types.ts` | Image/video model lists, ratios, sizes, inference helpers, and fallback style preset seed labels. Runtime style ordering comes from DB. |
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. Reads and mutations require admin bearer auth; the admin tab must send `Authorization` for the initial list fetch too. |
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts`, `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. When browsing 元界 AI, admins can manually click `同步元界价格` to call `/api/admin/system-apis/yuanjie-pricing`, which syncs billing mode and a 元界 pricing note from built-in template metadata without overwriting manually entered numeric prices; the sync matches 元界 provider variants such as `元界AI` and keeps a provider/model-group guard to avoid mozheAPI. The same system-default page can install Agnes AI free templates through `/api/admin/system-apis/agnes-capabilities`; Agnes rows are inactive `free` billing templates with 0 credits, image/video rows use isolated `system-api-manifests/<systemApiId>.json` files, and text rows use `chat/completions` directly while waiting for admin-entered API Keys before activation. For legacy 元界 rows without Manifest, built-in capabilities still drive frontend options and generation resolution can write the missing Manifest. Models can be free (`free`), priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), ratio, or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts`, `src/components/admin/api-management-tab.tsx` | Admin-controlled displayed/recommended model lists. Reads and mutations require admin bearer auth. |
## Profile, Credits, Orders
| Feature | Files |
| --- | --- |
| Profile page | `src/app/profile/page.tsx` | The profile shell should stay light on first mount: it reads only a local creation-history count for the top summary, while creation history, credit records, and orders mount their heavier stores inside the corresponding tab components. |
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/profile-preferences.ts` | `profiles.watermark_disabled` stores no-watermark download authorization. Free users cannot enable it from their own profile, but admins can grant or revoke it per user from user management; platform display remains watermarked even when the flag is on. |
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` | User-private completed works. History storage and the API de-duplicate repeated rows by result URL so a single generated video does not appear twice after the local optimistic record is replaced by the server row. `GET /api/creation-history` supports optional `mode` and `limit` for lightweight create-panel history, while the profile history tab can still read the default latest 300. The client store reuses short-lived in-flight requests and merges scoped responses into local history instead of replacing unrelated modes. Video records without a thumbnail receive a local SVG thumbnail under `thumbnails/works/videos` for fast list/detail preview. |
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. The credit record store is mounted by this tab itself, not by the profile page parent. |
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` | The order store is mounted by this tab itself, not by the profile page parent, so account/profile navigation does not fetch orders until the user opens the order view. |
| Billing guard | `src/components/billing-plan-guard.tsx`, `src/lib/admin-store.ts` |
## Gallery
| Feature | Files | Notes |
| --- | --- | --- |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css`, `src/lib/gallery-cache-policy.ts` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached rows remain usable for instant first paint until the 7-day prune window; page 0 is refreshed in the background for freshness, and a masonry skeleton replaces the old centered `加载中...` state when no cache exists. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. Video gallery cards and detail surfaces render `thumbnailUrl` first; `src/lib/media-storage.ts` should provide a local WEBP video-frame thumbnail when `ffmpeg-static` can extract one and only fall back to SVG when extraction fails. The detail overlay only mounts `<video src=original>` after the user clicks play, so object-storage originals are not fetched during list browsing or detail open. Video detail/download labels must say `下载视频`, not `下载图片`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
| Public gallery API | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails plus stale video SVG fallback thumbnails for background backfill without delaying the response, admin DELETE unpublishes. For videos, only `video-frame-m1280q86-v1.webp` counts as a current thumbnail; `video-svg-v1` and `video-fallback-svg-v2` are temporary fallback assets and should not block a later real-frame backfill. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. Public list serialization filters `data:` and oversized `publisherAvatarUrl` values so generated default avatars do not bloat the gallery JSON payload or localStorage cache. |
| Publish API | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Inserts public work after resolving gallery media. Stable `/api/local-storage/...` image and video originals are reused instead of synchronously copying object-backed generated media during share; external media is still copied into gallery storage first. Existing image thumbnails are reused so image sharing does not block on object-storage reads or thumbnail recompression; `/api/gallery` can lazily backfill missing/stale thumbnails. Video publishing first tries to generate a local WEBP frame preview under `thumbnails/gallery/videos` via `ffmpeg-static`, and only copies a client-provided thumbnail when real-frame extraction fails. Client code must treat `/api/gallery/publish` as authoritative and mark local works as shared only after a 2xx response. |
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, stored width/height, and published state. Missing image thumbnails and old-profile video thumbnails are queued for background backfill instead of blocking the history response. Video history thumbnails use local WEBP frames when `ffmpeg-static` can extract one, with SVG as the failure fallback; fallback SVG files are not considered current thumbnails. `mode=text2img|img2img|text2video|img2video|reverse-prompt` plus `limit` should be used by create panels to avoid repeated multi-MB history payloads during navigation; the API mode filter also covers legacy rows by checking params and reference-image inference. Local sharing state requires `publishedAt`, which is set only after confirmed server publish, so stale `published=true` flags from older clients do not disable retrying a failed gallery share. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
## Admin Console
| Feature | Frontend | API |
| --- | --- | --- |
| Console login | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx` | `src/app/api/auth/login/route.ts` with `adminOnly` |
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts`. The dashboard page owns the mobile admin shell classes (`console-mobile-page`, `console-mobile-main`, `console-mobile-content`) used by `src/app/globals.css` to keep cards constrained and admin tables horizontally scrollable on phones. |
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. Row actions include recharge, reset password, edit, and delete. Admin reset-password opens a separate reset form and `/api/admin/users` PUT upserts `auth.users.password_hash` for the target user. Admins can edit an individual user's `下载无水印` switch, which writes `profiles.watermark_disabled` through `/api/admin/users` without changing the membership tier. |
| API/model management | `src/components/admin/api-management-tab.tsx` | `src/app/api/admin/providers/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/model-recommendations/route.ts` |
| Pricing | `src/components/admin/pricing-tab.tsx` | Admin store/site config related routes |
| Redeem codes | `src/components/admin/redeem-code-management-tab.tsx` | `src/app/api/admin/redeem-codes/route.ts`, `src/lib/redeem-code-service.ts`, `src/app/api/site-config/route.ts`. Admins can generate one or many unique single-use redeem codes, choose credit-code or membership-code type, set credit amount or membership tier plus duration in days/months/years, copy generated codes, and manage unused code status. The same tab has a `商城链接配置` dialog that saves `site_config.redeem_code_mall_url`; frontend credit get-code buttons and membership upgrade buttons open that URL. |
| Payment | `src/components/admin/payment-tab.tsx` | `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` |
| Orders | `src/components/admin/order-management-tab.tsx` | `src/app/api/admin/orders/route.ts` |
| Announcements | `src/components/admin/announcement-tab.tsx` | `src/app/api/announcements/route.ts` |
| Gallery management | `src/components/admin/gallery-management-tab.tsx` | `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`. Lists public works with admin page/pageSize pagination, edits prompt text, opens a required notification email dialog with built-in reason templates, and only completes the update after email send success. |
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration; the checker defaults to port 8000 and counts bounded media probe failures instead of aborting on the first slow URL. |
| System upgrade | `src/components/admin/system-upgrade-tab.tsx` | `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs` |
| Logs/tasks | `src/components/admin/log-management-tab.tsx`, `src/components/admin/task-management-tab.tsx` | `src/lib/platform-logs.ts`, `src/app/api/admin/logs/route.ts`, `src/app/api/admin/generation-jobs/route.ts` |
| Settings | `src/components/admin/settings-tab.tsx` | `src/app/api/site-config/route.ts`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`. The feature toggles section includes membership enablement and the 100 Layout Compositions image composition skill switch. |
## Storage And Downloads
| Feature | Files | Notes |
| --- | --- | --- |
| Storage adapter | `src/lib/local-storage.ts` | Uses stable `/api/local-storage/<key>` URLs while the backend can be `STORAGE_MODE=local`, `dual`, or `object`. Object mode uses S3-compatible `OBJECT_STORAGE_*` config; dual mode writes local disk first and mirrors to object storage for safe migration. |
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/lib/media-watermark-policy.ts`, `src/lib/media-watermark.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Generated work media under `generated/`, `gallery/`, `imported/works`, and generated/gallery/work thumbnails is watermarked on the server before display, including object-backed originals, so page display and browser save-as cannot reach raw generated images/videos. Generated image original display requests should use an existing local `works.thumbnail_url` redirect before watermarking when available; downloads still use the original through `/api/download`. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Non-generated originals can still redirect to short-lived object-storage signed URLs when configured. |
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. Generated local-storage media returns watermarked bytes unless the request authenticates an admin role or a user whose `profiles.watermark_disabled` is true; normal users without that flag must not receive raw object-storage redirects for generated images/videos. The frontend passes the session through `downloadFile(...)` headers or `triggerDownloadFile(...)` download tokens. For non-generated object-backed local-storage files, it can redirect to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js. |
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. It blocks private/local network targets, sends browser-like public-resource headers by default, and exposes `fetchPublicHttpUrlWithRetry` for generated image/result URL downloads that may transiently return 403, 429, 5xx, or timeout. |
## Database And Persistence
| Area | Files |
| --- | --- |
| Database connection pool | `src/storage/database/local-db.ts` |
| Image style preset store | `src/lib/style-preset-store.ts`, `src/app/api/style-presets/route.ts` |
| Schema snapshot | `src/storage/database/shared/schema.ts` |
| Supabase compatibility client | `src/storage/database/supabase-client.ts` |
| Init SQL | `scripts/init-database.sql` |
| DB patch runner | `scripts/apply-database-patch.sh` |
| Migration SQL files | `account-profile-migration.sql`, `model-config-migration.sql`, `persistence_migration.sql`, `scripts/database-optimization-patch.sql` |
## Deployment And Runtime
| Feature | Files |
| --- | --- |
| Custom Node server | `src/server.ts` |
| PM2 config | `ecosystem.config.cjs` |
| Build | `scripts/build.sh` |
| Start | `scripts/start.sh` |
| Dev | `scripts/dev.sh` |
| Deploy/upgrade | `scripts/deploy-or-upgrade.sh` | Sync excludes must target repo-root runtime artifacts only. Use root-anchored `/local-storage` so the source route `src/app/api/local-storage/[...path]/route.ts` is still deployed. Production keeps a host-specific `ecosystem.config.cjs`; manual source archives or rsync payloads must not overwrite it, or must restore the production copy from the pre-sync backup before `pm2 startOrReload`. |
| Backup | `scripts/backup-create.sh`, `scripts/backup-list.sh`, `scripts/backup-restore.sh`. Restore uses `pg_restore --single-transaction`, validates archive/dump contents, atomically swaps local storage, and keeps a pre-restore safety backup. |
| Object storage migration | `scripts/storage-sync-to-object.mjs` | Copies existing `LOCAL_STORAGE_DIR` files into the configured S3-compatible bucket, supports `--dry-run` and `--verify-only`, and should be run before switching production from local-only to object-backed storage. |
| Admin upgrade API/UI | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
| Admin upgrade runner | `scripts/admin-upgrade-runner.mjs`. Use this when deciding whether a change can be packaged as a hot update or must be a cold update. Source/API/server/dependency/schema/env/runtime/script changes should be treated as cold-update candidates; static/public asset-only packages are hot-update candidates only if runner preflight accepts them without restart. |
| Boundary checks | `scripts/check-boundaries.sh` |
| User display profile backfill | `scripts/backfill-user-display-profile.mjs` |

BIN
docs/images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/images/gallery.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
docs/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -0,0 +1,450 @@
# Admin Gallery Prompt Notification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an admin console workflow that edits public gallery work prompts only after successfully emailing the work author.
**Architecture:** Put the moderation rule in a small service module with injected database/email/log dependencies, expose thin admin API routes, and add a dedicated admin console tab. The public gallery page remains unchanged except for shared data compatibility through the `works.prompt` field.
**Tech Stack:** Next.js route handlers, React client components, PostgreSQL via existing `getDbClient`, existing `sendTemplatedEmail`, existing `writePlatformLog`, TypeScript, and a lightweight Node test script.
---
## File Structure
- Create `src/lib/admin-gallery-prompt-service.ts`: validates prompt edit requests, loads a public work and author, sends email first, updates `works.prompt`, writes moderation logs.
- Create `src/app/api/admin/gallery/works/route.ts`: admin-only list API for public gallery works.
- Create `src/app/api/admin/gallery/prompt/route.ts`: admin-only prompt update API that delegates to the service.
- Create `src/components/admin/gallery-management-tab.tsx`: admin UI for listing public works and editing prompts with required email notification.
- Modify `src/modules/console/pages/console-dashboard-page.tsx`: register the new `gallery` view in navigation and content routing.
- Create `scripts/test-admin-gallery-prompt-service.mjs`: service-level TDD tests using an in-memory fake database adapter.
- Modify `package.json`: add `test:admin-gallery-prompt`.
- Update `docs/codex-miaojing/api-reference.md`: document new admin gallery APIs.
- Update `docs/codex-miaojing/feature-code-index.md`: index the new feature files.
- Update `docs/codex-miaojing/architecture.md` only if moderation workflow context is missing.
- Update `docs/codex-miaojing/bug-location-guide.md` only if implementation reveals useful troubleshooting notes.
## Task 1: Service Tests
**Files:**
- Create: `scripts/test-admin-gallery-prompt-service.mjs`
- Modify: `package.json`
- [ ] **Step 1: Add a test script entry**
Add this script to `package.json`:
```json
"test:admin-gallery-prompt": "tsx ./scripts/test-admin-gallery-prompt-service.mjs"
```
- [ ] **Step 2: Write failing service tests**
Create `scripts/test-admin-gallery-prompt-service.mjs` that imports `updateAdminGalleryPrompt` from `src/lib/admin-gallery-prompt-service.ts` and tests:
```js
import assert from 'node:assert/strict';
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
function createServiceHarness({ work, emailFails = false } = {}) {
const state = {
work: work || {
id: '11111111-1111-1111-1111-111111111111',
user_id: '22222222-2222-2222-2222-222222222222',
type: 'text2img',
title: 'public work',
prompt: 'old public prompt',
negative_prompt: null,
result_url: '/api/local-storage/gallery/image.webp',
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
likes_count: 3,
is_public: true,
status: 'completed',
created_at: '2026-05-20T00:00:00.000Z',
author_email: 'author@example.com',
author_nickname: 'Author',
author_display_nickname: 'Author Display',
author_avatar_url: null,
},
updates: [],
emails: [],
logs: [],
};
return {
state,
deps: {
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
updatePrompt: async (workId, prompt) => {
state.updates.push({ workId, prompt });
state.work = { ...state.work, prompt };
return state.work;
},
sendEmail: async (message) => {
state.emails.push(message);
if (emailFails) throw new Error('SMTP down');
},
writeLog: async (entry) => {
state.logs.push(entry);
},
},
};
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
const baseInput = {
workId: '11111111-1111-1111-1111-111111111111',
prompt: 'new compliant prompt',
emailSubject: '公开作品提示词已调整',
emailBody: '你的公开作品提示词已根据平台规范调整。',
reasonKey: 'remove_sensitive_words',
};
await runTest('rejects non-public works', async () => {
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, is_public: false } });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects missing author email', async () => {
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, author_email: '' } });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects unchanged prompt', async () => {
const { deps, state } = createServiceHarness();
await assert.rejects(
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
/提示词没有变化/,
);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('does not update prompt when email sending fails', async () => {
const { deps, state } = createServiceHarness({ emailFails: true });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 1);
});
await runTest('sends email before updating prompt', async () => {
const { deps, state } = createServiceHarness();
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.emails.length, 1);
assert.equal(state.updates.length, 1);
assert.equal(state.updates[0].prompt, 'new compliant prompt');
assert.equal(result.work.prompt, 'new compliant prompt');
});
await runTest('writes moderation log metadata without full prompt text', async () => {
const { deps, state } = createServiceHarness();
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.logs.length, 1);
const logText = JSON.stringify(state.logs[0]);
assert.match(logText, /remove_sensitive_words/);
assert.doesNotMatch(logText, /old public prompt/);
assert.doesNotMatch(logText, /new compliant prompt/);
});
if (process.exitCode) process.exit(process.exitCode);
```
- [ ] **Step 3: Run tests and confirm RED**
Run: `pnpm run test:admin-gallery-prompt`
Expected: failure because `src/lib/admin-gallery-prompt-service.ts` does not exist or does not export `updateAdminGalleryPrompt`.
## Task 2: Service Implementation
**Files:**
- Create: `src/lib/admin-gallery-prompt-service.ts`
- Test: `scripts/test-admin-gallery-prompt-service.mjs`
- [ ] **Step 1: Implement the tested service**
Create `src/lib/admin-gallery-prompt-service.ts` with:
```ts
import type { AuthenticatedUser } from '@/lib/session-auth';
import { isValidEmail, normalizeEmail } from '@/lib/email-service';
export type AdminGalleryPromptReasonKey =
| 'remove_sensitive_words'
| 'improve_wording'
| 'remove_private_info'
| 'platform_policy_adjustment'
| 'custom';
export interface AdminGalleryPromptWorkRow {
id: string;
user_id: string | null;
type: string | null;
title: string | null;
prompt: string | null;
negative_prompt?: string | null;
result_url: string | null;
thumbnail_url?: string | null;
likes_count?: number | null;
is_public: boolean | null;
status: string | null;
created_at: string | Date | null;
author_email: string | null;
author_nickname?: string | null;
author_display_nickname?: string | null;
author_avatar_url?: string | null;
}
export interface AdminGalleryPromptInput {
workId: string;
prompt: string;
emailSubject: string;
emailBody: string;
reasonKey?: string;
}
export interface AdminGalleryPromptDeps {
admin: AuthenticatedUser;
loadWork: (workId: string) => Promise<AdminGalleryPromptWorkRow | null>;
updatePrompt: (workId: string, prompt: string) => Promise<AdminGalleryPromptWorkRow>;
sendEmail: (message: { to: string; subject: string; body: string; work: AdminGalleryPromptWorkRow; reasonKey: AdminGalleryPromptReasonKey }) => Promise<void>;
writeLog: (entry: Record<string, unknown>) => Promise<void>;
}
export class AdminGalleryPromptError extends Error {
constructor(message: string, public status = 400) {
super(message);
}
}
export async function updateAdminGalleryPrompt(input: AdminGalleryPromptInput, deps: AdminGalleryPromptDeps) {
const workId = normalizeUuid(input.workId, '缺少作品 ID');
const prompt = normalizeRequiredText(input.prompt, '请填写新的提示词', 8000);
const emailSubject = normalizeRequiredText(input.emailSubject, '请填写邮件标题', 120);
const emailBody = normalizeRequiredText(input.emailBody, '请填写邮件正文', 5000);
const reasonKey = normalizeReasonKey(input.reasonKey);
const work = await deps.loadWork(workId);
if (!work || work.is_public !== true || work.status !== 'completed' || !work.result_url) {
throw new AdminGalleryPromptError('作品不存在或不是公开作品', 404);
}
const oldPrompt = String(work.prompt || '').trim();
if (oldPrompt === prompt) throw new AdminGalleryPromptError('提示词没有变化', 400);
const authorEmail = normalizeEmail(work.author_email);
if (!isValidEmail(authorEmail)) throw new AdminGalleryPromptError('作者邮箱不可用,无法完成邮件通知', 400);
await deps.sendEmail({ to: authorEmail, subject: emailSubject, body: emailBody, work, reasonKey });
const updated = await deps.updatePrompt(workId, prompt);
await deps.writeLog({
type: 'admin',
level: 'info',
action: 'admin_gallery_prompt_update',
message: '管理员修改公开画廊作品提示词并发送邮件通知',
userId: deps.admin.userId,
targetType: 'work',
targetId: workId,
metadata: {
workId,
authorId: work.user_id,
authorEmail,
reasonKey,
oldPromptLength: oldPrompt.length,
newPromptLength: prompt.length,
notificationSent: true,
},
});
return { work: toAdminGalleryPromptWork(updated, authorEmail), notificationSent: true };
}
export function toAdminGalleryPromptWork(row: AdminGalleryPromptWorkRow, authorEmail = normalizeEmail(row.author_email)) {
return {
id: row.id,
type: row.type,
title: row.title,
prompt: row.prompt,
negativePrompt: row.negative_prompt || null,
url: row.result_url,
thumbnailUrl: row.thumbnail_url || null,
likes: Number(row.likes_count || 0),
authorId: row.user_id,
authorEmail,
authorNickname: row.author_display_nickname || row.author_nickname || (authorEmail ? authorEmail.split('@')[0] : '匿名用户'),
authorAvatarUrl: row.author_avatar_url || null,
publishedAt: row.created_at,
};
}
function normalizeUuid(value: unknown, message: string) {
const text = typeof value === 'string' ? value.trim() : '';
if (!/^[0-9a-fA-F-]{36}$/.test(text)) throw new AdminGalleryPromptError(message, 400);
return text;
}
function normalizeRequiredText(value: unknown, message: string, maxLength: number) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) throw new AdminGalleryPromptError(message, 400);
return text.slice(0, maxLength);
}
function normalizeReasonKey(value: unknown): AdminGalleryPromptReasonKey {
if (
value === 'remove_sensitive_words'
|| value === 'improve_wording'
|| value === 'remove_private_info'
|| value === 'platform_policy_adjustment'
) {
return value;
}
return 'custom';
}
```
- [ ] **Step 2: Run service tests and confirm GREEN**
Run: `pnpm run test:admin-gallery-prompt`
Expected: all service tests print `PASS`.
## Task 3: Admin API Routes
**Files:**
- Create: `src/app/api/admin/gallery/works/route.ts`
- Create: `src/app/api/admin/gallery/prompt/route.ts`
- Modify if needed: `src/lib/admin-gallery-prompt-service.ts`
- [ ] **Step 1: Implement `GET /api/admin/gallery/works`**
Route requirements:
- Authenticate with `requireAdmin`.
- Query `works` joined with `profiles`.
- Filter public, completed, result-backed works.
- Support `q`, `type`, `limit`, `offset`, `sort`.
- Return `works`, `total`, `nextOffset`, `hasMore`.
- [ ] **Step 2: Implement `PUT /api/admin/gallery/prompt`**
Route requirements:
- Authenticate with `requireAdminUser`.
- Parse JSON request body.
- Call `updateAdminGalleryPrompt`.
- Implement real dependencies using `getDbClient`, `sendTemplatedEmail`, `getRequestBaseUrl`, and `writePlatformLog`.
- Map `AdminGalleryPromptError.status` to JSON error status.
- [ ] **Step 3: Run service tests and type check**
Run:
```powershell
pnpm run test:admin-gallery-prompt
pnpm run ts-check
```
Expected: tests pass and TypeScript reports no errors.
## Task 4: Admin Console UI
**Files:**
- Create: `src/components/admin/gallery-management-tab.tsx`
- Modify: `src/modules/console/pages/console-dashboard-page.tsx`
- [ ] **Step 1: Add the admin gallery tab component**
Component requirements:
- Uses `useAuth()` for the bearer token.
- Loads `/api/admin/gallery/works`.
- Shows search, type filter, refresh, and load-more.
- Shows compact public work rows with media preview, author, prompt summary, likes, and edit action.
- Provides prompt edit dialog and required email notification dialog.
- Provides the four reason templates and custom editing.
- Calls `PUT /api/admin/gallery/prompt` only from the email dialog.
- Updates the row prompt from the API response on success.
- [ ] **Step 2: Wire the console shell**
Modify `ConsoleView`, `CONSOLE_VIEWS`, `VIEW_TITLES`, nav groups, dynamic import list, and `ConsoleContent` to include `gallery`.
- [ ] **Step 3: Run TypeScript check**
Run: `pnpm run ts-check`
Expected: no TypeScript errors.
## Task 5: Project Documentation
**Files:**
- Modify: `docs/codex-miaojing/api-reference.md`
- Modify: `docs/codex-miaojing/feature-code-index.md`
- Modify: `docs/codex-miaojing/architecture.md` if needed
- Modify: `docs/codex-miaojing/bug-location-guide.md` if needed
- [ ] **Step 1: Document API changes**
Add `GET /api/admin/gallery/works` and `PUT /api/admin/gallery/prompt`, including auth, request, response, and failure semantics.
- [ ] **Step 2: Update feature index**
Add the new admin tab, API routes, service module, and test script to the Admin Console and Gallery sections.
- [ ] **Step 3: Update architecture/bug guide only for real new operational knowledge**
If the implementation creates notable operational failure modes, document them. Otherwise leave those files unchanged.
## Task 6: Final Verification
**Files:**
- All touched files.
- [ ] **Step 1: Run focused tests**
Run: `pnpm run test:admin-gallery-prompt`
Expected: all tests pass.
- [ ] **Step 2: Run type check**
Run: `pnpm run ts-check`
Expected: no TypeScript errors.
- [ ] **Step 3: Run build**
Run: `pnpm run build`
Expected: production build completes.
- [ ] **Step 4: Review git diff**
Run:
```powershell
git diff --check
git status --short
```
Expected: no whitespace errors and only intended files changed.
- [ ] **Step 5: Final deployment note**
Final response must mention this is a cold-update candidate and production rollout needs backup, PM2 reload/restart, `/api/health`, `/console`, new gallery management smoke check, `/gallery`, and rollback readiness.

View File

@@ -0,0 +1,237 @@
# Admin Gallery Prompt Notification Design
## Goal
Add an admin-only workflow for editing prompts on public gallery works and notifying the work author by email. The feature is for moderation tasks such as removing sensitive words, privacy details, or misleading public display text.
The confirmed product rule is: an admin prompt edit must send an email successfully before the prompt change is completed. There is no skip-notification path.
## Current Context
- Public gallery UI lives in `src/app/gallery/page.tsx`.
- Public gallery API lives in `src/app/api/gallery/route.ts`; admin DELETE currently unpublishes works.
- Admin console shell lives in `src/modules/console/pages/console-dashboard-page.tsx`.
- Admin tabs live under `src/components/admin/*`.
- Email sending is centralized in `src/lib/email-service.ts`.
- Existing admin email APIs include `src/app/api/admin/send-email/route.ts` and `src/app/api/email/send-notification/route.ts`.
- Admin auth helpers live in `src/lib/admin-auth.ts` and `src/lib/session-auth.ts`.
- Platform logs are written through `src/lib/platform-logs.ts`.
- The `works` table already stores `prompt`, `is_public`, `user_id`, `result_url`, `thumbnail_url`, and related work metadata.
## Recommended Approach
Add a dedicated admin console page instead of extending the public gallery page. This keeps moderation work out of the public browsing experience and avoids further growth in the already-large public gallery component.
The feature will add:
- `src/components/admin/gallery-management-tab.tsx`
- `GET /api/admin/gallery/works`
- `PUT /api/admin/gallery/prompt`
- `src/lib/admin-gallery-prompt-service.ts`
- A small TDD script for the service layer.
## Admin UI
Add a new `gallery` console view in `src/modules/console/pages/console-dashboard-page.tsx`.
Navigation:
- Group: creation/admin content group.
- Label: `画廊管理`.
- Icon: use a lucide gallery/image icon.
`src/components/admin/gallery-management-tab.tsx` will provide:
- Search input for prompt, author email/nickname, and work ID.
- Type filter for all/image/video/text2img/img2img/text2video/img2video where practical.
- Refresh button.
- Paginated or load-more public works list.
- Rows showing preview thumbnail, work type, author, public time, prompt summary, likes, and actions.
- `编辑提示词` action.
Edit flow:
1. Admin opens the prompt editor for a public work.
2. Dialog shows media preview, author email, original prompt, and editable new prompt.
3. Admin clicks save.
4. UI opens a required email notification dialog.
5. Admin selects a reason template or writes custom email subject/body.
6. UI submits one request to `PUT /api/admin/gallery/prompt`.
7. On success, dialogs close, the row prompt updates, and a success toast is shown.
8. On failure, the dialog stays open and shows the server error.
Built-in reason templates:
- `remove_sensitive_words`: 删除敏感词,确保公开展示合规
- `improve_wording`: 优化提示词表述,避免误导或不适内容
- `remove_private_info`: 移除个人信息或隐私相关描述
- `platform_policy_adjustment`: 根据平台内容规范调整公开展示文案
Templates fill title/body quickly, but admins may manually edit or replace both fields.
## API Design
### `GET /api/admin/gallery/works`
Auth:
- Requires admin session via existing admin auth helpers.
Query params:
- `q`: optional search text.
- `type`: optional work type/category filter.
- `limit`: bounded page size.
- `offset`: page offset.
- `sort`: `newest` by default; optionally `popular`.
Selection rules:
- Only `works.is_public = true`.
- Only completed works.
- Only rows with a non-empty result URL.
Response shape:
```json
{
"works": [
{
"id": "uuid",
"type": "text2img",
"title": null,
"prompt": "current prompt",
"negativePrompt": null,
"url": "/api/local-storage/...",
"thumbnailUrl": "/api/local-storage/thumbnails/...",
"likes": 0,
"authorId": "uuid",
"authorEmail": "user@example.com",
"authorNickname": "display name",
"publishedAt": "2026-05-20T00:00:00.000Z"
}
],
"total": 1,
"nextOffset": 1,
"hasMore": false
}
```
### `PUT /api/admin/gallery/prompt`
Auth:
- Uses `requireAdminUser(request)` so the handler gets the admin user ID for logging.
Request body:
```json
{
"workId": "uuid",
"prompt": "new public prompt",
"emailSubject": "邮件标题",
"emailBody": "邮件正文",
"reasonKey": "remove_sensitive_words"
}
```
Validation:
- `workId` must be a UUID-like string.
- `prompt` must be non-empty after trim.
- `emailSubject` and `emailBody` must be non-empty after trim.
- The work must still be public.
- The author must have a valid email address.
- The new prompt must differ from the old prompt after trim.
Processing order:
1. Authenticate admin and parse input.
2. Load work plus author profile.
3. Validate public status and author email.
4. Send email through `sendTemplatedEmail`.
5. Only after email success, update `works.prompt`.
6. Write a platform log with moderation metadata.
7. Return the updated work summary.
The SMTP send should not be wrapped in a long database transaction. Consistency is enforced by order: email failure blocks prompt update. If email succeeds but the database update fails, return an error and write an error log; the admin can retry or inspect logs.
Platform log metadata should include work ID, author ID/email, reason key, old/new prompt lengths, and whether the notification was sent. It must not store the full original or new prompt.
## Service Boundary
Create `src/lib/admin-gallery-prompt-service.ts` for the core workflow. The API route should remain thin: auth, request parsing, service call, JSON response.
The service accepts injected dependencies for tests:
- query-capable database client or adapter.
- email sender function.
- log writer function.
This keeps the rule "email success before prompt update" testable without SMTP or a real database.
## Error Handling
Expected errors:
- 401/403 when not admin.
- 400 for invalid request body.
- 400 when prompt is unchanged.
- 400 when author email is missing or invalid.
- 404 when the work does not exist or is not public.
- 502 or 400 when email sending fails, with prompt unchanged.
- 500 when update/logging has unexpected failures.
Frontend behavior:
- Keep the email dialog open on failure.
- Preserve typed subject/body so the admin can retry.
- Refresh the row after success.
## Testing
The project currently has no first-party test script. Add a minimal service-level TDD script rather than introducing a large framework.
Add:
- `scripts/test-admin-gallery-prompt-service.mjs`
- `package.json` script: `test:admin-gallery-prompt`
TDD sequence:
1. Write failing tests for `src/lib/admin-gallery-prompt-service.ts`.
2. Implement the service until tests pass.
3. Add API route and UI.
4. Run `pnpm run test:admin-gallery-prompt`.
5. Run `pnpm run ts-check`.
6. Run `pnpm run build` if environment allows.
Service tests must cover:
- Non-public works cannot be modified.
- Missing or invalid author email blocks modification.
- Unchanged prompt is rejected.
- Email failure leaves prompt unchanged.
- Email success updates prompt.
- Logs include metadata but not full prompt text.
## Documentation Updates
Because this is a source/API/UI change, update project docs after implementation:
- `docs/codex-miaojing/api-reference.md`
- `docs/codex-miaojing/feature-code-index.md`
- `docs/codex-miaojing/architecture.md` if the moderation workflow needs architecture context.
- `docs/codex-miaojing/bug-location-guide.md` if implementation reveals useful troubleshooting notes.
## Deployment Notes
This is a cold-update candidate because it changes source, UI, and API routes. Final delivery must mention:
- Create backup before production upgrade.
- Do not overwrite production `.env.local`, `ecosystem.config.cjs`, runtime storage, database, or secrets.
- Run type/build checks before deploy.
- Restart/reload PM2 as required by the production process.
- Health check `/api/health`.
- Smoke check `/console`, the new gallery management tab, and `/gallery`.
- Roll back with previous code/build and PM2 config if admin API, email, or gallery display fails.

54
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,54 @@
module.exports = {
apps: [
{
name: 'miaojing-api',
cwd: '/root/miaojingAI',
script: 'npm',
args: 'run start',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '512M',
restart_delay: 3000,
env: {
NODE_ENV: 'production',
COZE_PROJECT_ENV: 'PROD',
APP_RUNTIME_ROLE: 'backend',
DEPLOY_RUN_PORT: '5100',
},
},
{
name: 'miaojing-web',
cwd: '/root/miaojingAI',
script: 'npm',
args: 'run start',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '512M',
restart_delay: 3000,
env: {
NODE_ENV: 'production',
COZE_PROJECT_ENV: 'PROD',
APP_RUNTIME_ROLE: 'frontend',
BACKEND_INTERNAL_URL: 'http://127.0.0.1:5100',
CONSOLE_INTERNAL_URL: 'http://127.0.0.1:5200',
DEPLOY_RUN_PORT: '5000',
},
},
{
name: 'miaojing-console',
cwd: '/root/miaojingAI',
script: 'npm',
args: 'run start',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '512M',
restart_delay: 3000,
env: {
NODE_ENV: 'production',
COZE_PROJECT_ENV: 'PROD',
APP_RUNTIME_ROLE: 'console',
DEPLOY_RUN_PORT: '5200',
},
},
],
};

52
eslint.config.mjs Normal file
View File

@@ -0,0 +1,52 @@
import nextTs from 'eslint-config-next/typescript';
import nextVitals from 'eslint-config-next/core-web-vitals';
import { defineConfig, globalIgnores } from 'eslint/config';
const syntaxRules = [
{
selector: 'JSXOpeningElement[name.name="head"]',
message:
'禁止使用 head 标签,优先使用 metadata。三方 CSS、字体等资源可以在 globals.css 中顶部通过 @import 引入或者使用 next/fontpreload, preconnect, dns-prefetch 通过 ReactDOM 的 preload、preconnect、dns-prefetch 方法引入json-ld 可阅读 https://nextjs.org/docs/app/guides/json-ld',
},
];
const nextConfigRestrictedSyntaxRules = [
{
selector:
'Property[key.name=/^(root|outputFileTracingRoot)$/] > Literal[value=/^\\//]',
message:
'禁止在 next.config 中写死绝对路径,请改用 path.resolve(__dirname, ...)、import.meta.dirname 或 process.cwd() 动态拼接。',
},
];
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
'react-hooks/set-state-in-effect': 'off',
'no-restricted-syntax': ['error', ...syntaxRules],
},
},
{
files: ['next.config.ts'],
rules: {
'no-restricted-syntax': ['error', ...nextConfigRestrictedSyntaxRules],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
// Build artifacts:
'server.js',
'dist/**',
// Script files (CommonJS):
'scripts/**/*.js',
]),
]);
export default eslintConfig;

59
fix_persistence_data.js Normal file
View File

@@ -0,0 +1,59 @@
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const root = '/root/miaojingAI';
const c = new Client({ connectionString: process.env.LOCAL_DB_URL });
function keyFromUrl(url) {
if (!url) return null;
const marker = '/api/local-storage/';
const idx = String(url).indexOf(marker);
if (idx < 0) return null;
try { return decodeURIComponent(String(url).slice(idx + marker.length).split('?')[0]); } catch { return null; }
}
function publicUrl(key) { return `/api/local-storage/${key}`; }
function copyToGallery(url, type, id, field) {
const key = keyFromUrl(url);
if (!key) return url;
if (key.startsWith('gallery/')) return url;
const src = path.join(root, 'local-storage', key);
if (!fs.existsSync(src)) return url;
const ext = path.extname(key) || '.bin';
const folder = field === 'thumbnail_url' ? 'gallery/thumbnails' : (type === 'text2video' || type === 'img2video' || type === 'video' ? 'gallery/videos' : 'gallery/images');
const destDir = path.join(root, 'local-storage', folder);
fs.mkdirSync(destDir, { recursive: true });
const destKey = `${folder}/${id}-${field}${ext}`;
const dest = path.join(root, 'local-storage', destKey);
if (!fs.existsSync(dest)) fs.copyFileSync(src, dest);
return publicUrl(destKey);
}
(async () => {
await c.connect();
await c.query('BEGIN');
try {
const adminRes = await c.query(`select id,email from profiles where role='admin' order by case when email='admin@example.com' then 0 else 1 end, created_at asc limit 1`);
if (!adminRes.rows.length) throw new Error('No admin profile found');
const admin = adminRes.rows[0];
const password = process.env.ADMIN_DEFAULT_PASSWORD || 'admin123';
await c.query(`update auth.users set password_hash = crypt($1, gen_salt('bf')) where id=$2`, [password, admin.id]);
const worksFixed = await c.query(`update works set user_id=$1 where user_id is null or user_id not in (select id from profiles) returning id`, [admin.id]);
const creditDeleted = await c.query(`delete from credit_transactions where user_id not in (select id from profiles) returning id`);
const publicWorks = await c.query(`select id,type,result_url,thumbnail_url from works where is_public=true order by created_at asc`);
let copied = 0;
for (const w of publicWorks.rows) {
const nextResult = copyToGallery(w.result_url, w.type, w.id, 'result_url');
const nextThumb = w.thumbnail_url ? copyToGallery(w.thumbnail_url, w.type, w.id, 'thumbnail_url') : null;
if (nextResult !== w.result_url || nextThumb !== w.thumbnail_url) {
await c.query(`update works set result_url=$1, thumbnail_url=$2, updated_at=now() where id=$3`, [nextResult, nextThumb, w.id]);
copied++;
}
}
await c.query('COMMIT');
console.log(JSON.stringify({ admin: admin.email, passwordHashSet: true, worksFixed: worksFixed.rowCount, orphanCreditsDeleted: creditDeleted.rowCount, publicWorksCopiedToGallery: copied }, null, 2));
} catch (e) {
await c.query('ROLLBACK');
throw e;
} finally {
await c.end();
}
})().catch(e => { console.error(e); process.exit(1); });

12
inspect_before_fix.js Normal file
View File

@@ -0,0 +1,12 @@
const { Client } = require('pg');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const c = new Client({ connectionString: process.env.LOCAL_DB_URL });
(async () => {
await c.connect();
for (const [name, sql] of [
['works_public', `select is_public, count(*)::int from works group by is_public order by is_public`],
['works_urls', `select id,user_id,is_public,type,result_url,thumbnail_url from works order by created_at desc`],
['orphan_credit', `select id,user_id,amount,type,description from credit_transactions where user_id not in (select id from profiles)`]
]) { const r=await c.query(sql); console.log('--- '+name); console.table(r.rows); }
await c.end();
})().catch(e=>{console.error(e);process.exit(1)});

19
inspect_db.js Normal file
View File

@@ -0,0 +1,19 @@
const { Client } = require('pg');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const c = new Client({ connectionString: process.env.LOCAL_DB_URL });
(async () => {
await c.connect();
const queries = [
['profiles_count', 'select count(*)::int as n from profiles'],
['profiles', 'select id,email,nickname,role,membership_tier,is_active,created_at from profiles order by created_at desc limit 20'],
['jobs_count', 'select count(*)::int as n from generation_jobs'],
['jobs', 'select id,user_id,type,status,created_at from generation_jobs order by created_at desc limit 20'],
['jobs_join_profiles', `select j.id,j.user_id,p.email,p.nickname,p.role,j.type,j.status,j.created_at from generation_jobs j left join profiles p on p.id=j.user_id order by j.created_at desc limit 20`]
];
for (const [name, sql] of queries) {
const r = await c.query(sql);
console.log('--- ' + name);
console.table(r.rows);
}
await c.end();
})().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,61 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const apiKeys = await client.query(`
SELECT id, user_id, provider, supplier_name, model_name, note, type, created_at
FROM user_api_keys
ORDER BY created_at DESC
LIMIT 200
`);
const modelCounts = await client.query(`
SELECT params->>'model' AS model, COUNT(*)::int AS count
FROM works
WHERE is_public = true
AND status = 'completed'
AND user_id = $1
GROUP BY params->>'model'
ORDER BY count DESC
`, [SYSTEM_USER_ID]);
const directMatches = await client.query(`
SELECT
w.params->>'model' AS work_model,
COUNT(*)::int AS work_count,
k.id AS api_key_id,
k.user_id,
p.email,
p.nickname,
k.provider,
k.supplier_name,
k.model_name,
k.note
FROM works w
JOIN user_api_keys k ON w.params->>'model' = CONCAT('custom:', k.id::text)
JOIN profiles p ON p.id = k.user_id
WHERE w.is_public = true
AND w.status = 'completed'
AND w.user_id = $1
GROUP BY w.params->>'model', k.id, k.user_id, p.email, p.nickname, k.provider, k.supplier_name, k.model_name, k.note
ORDER BY work_count DESC
`, [SYSTEM_USER_ID]);
console.log(JSON.stringify({
userApiKeys: apiKeys.rows,
anonymousGalleryModelCounts: modelCounts.rows,
directMatches: directMatches.rows,
}, null, 2));
} finally {
client.release();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,53 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const summary = await client.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE w.user_id IS NULL)::int AS null_user_id,
COUNT(*) FILTER (WHERE p.id IS NULL)::int AS missing_profile,
COUNT(*) FILTER (WHERE p.id IS NOT NULL)::int AS linked_profile
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
WHERE w.is_public = true AND w.status = 'completed'
`);
const samples = await client.query(`
SELECT
w.id,
w.user_id,
w.title,
LEFT(COALESCE(w.prompt, ''), 80) AS prompt_preview,
w.params,
w.created_at
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
WHERE w.is_public = true AND w.status = 'completed' AND p.id IS NULL
ORDER BY w.created_at DESC
LIMIT 20
`);
const profileSamples = await client.query(`
SELECT id, email, nickname, role, created_at
FROM profiles
ORDER BY created_at DESC
LIMIT 20
`);
console.log(JSON.stringify({
summary: summary.rows[0],
missingSamples: samples.rows,
profileSamples: profileSamples.rows,
}, null, 2));
} finally {
client.release();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

9
inspect_payload.js Normal file
View File

@@ -0,0 +1,9 @@
const { Client } = require('pg');
require('dotenv').config({ path: '/root/miaojingAI/.env.local' });
const c = new Client({ connectionString: process.env.LOCAL_DB_URL });
(async () => {
await c.connect();
const r = await c.query(`select id, payload from generation_jobs order by created_at desc limit 3`);
for (const row of r.rows) { console.log('---', row.id); console.log(JSON.stringify(row.payload).slice(0, 1200)); }
await c.end();
})().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,111 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const byModelOwner = await client.query(`
SELECT
COALESCE(w.params->>'model', '') AS model,
COALESCE(p.email, '[missing-profile]') AS email,
COALESCE(p.nickname, '') AS nickname,
w.user_id,
COUNT(*)::int AS count,
COUNT(*) FILTER (WHERE w.is_public = true)::int AS public_count,
COUNT(*) FILTER (WHERE w.is_public = false)::int AS private_count,
MIN(w.created_at) AS first_at,
MAX(w.created_at) AS last_at
FROM works w
LEFT JOIN profiles p ON p.id = w.user_id
WHERE w.status = 'completed'
GROUP BY model, w.user_id, p.email, p.nickname
ORDER BY model, count DESC
`);
const orphanModelTotals = await client.query(`
SELECT params->>'model' AS model, COUNT(*)::int AS count
FROM works
WHERE status = 'completed'
AND is_public = true
AND user_id = $1
GROUP BY params->>'model'
ORDER BY count DESC
`, [SYSTEM_USER_ID]);
const ownersByPrompt = await client.query(`
WITH orphan AS (
SELECT id, prompt, created_at
FROM works
WHERE status = 'completed'
AND is_public = true
AND user_id = $1
AND COALESCE(prompt, '') <> ''
),
owned AS (
SELECT w.id, w.user_id, p.email, p.nickname, w.prompt, w.created_at
FROM works w
JOIN profiles p ON p.id = w.user_id
WHERE w.status = 'completed'
AND w.user_id <> $1
AND COALESCE(w.prompt, '') <> ''
)
SELECT
orphan.id AS orphan_id,
owned.user_id,
owned.email,
owned.nickname,
COUNT(*)::int AS matches,
MIN(ABS(EXTRACT(EPOCH FROM (owned.created_at - orphan.created_at))))::int AS best_seconds_apart
FROM orphan
JOIN owned ON owned.prompt = orphan.prompt
GROUP BY orphan.id, owned.user_id, owned.email, owned.nickname
ORDER BY matches DESC, best_seconds_apart
LIMIT 120
`, [SYSTEM_USER_ID]);
const maybeAdminByDates = await client.query(`
SELECT
w.id,
w.created_at,
w.params->>'model' AS model,
LEFT(w.prompt, 100) AS prompt,
(
SELECT json_agg(json_build_object(
'user_id', nearby.user_id,
'email', p.email,
'nickname', p.nickname,
'seconds_apart', ABS(EXTRACT(EPOCH FROM (nearby.created_at - w.created_at)))::int,
'prompt', LEFT(nearby.prompt, 80)
) ORDER BY ABS(EXTRACT(EPOCH FROM (nearby.created_at - w.created_at))))
FROM works nearby
JOIN profiles p ON p.id = nearby.user_id
WHERE nearby.user_id <> $1
AND nearby.status = 'completed'
AND nearby.created_at BETWEEN w.created_at - INTERVAL '1 hour' AND w.created_at + INTERVAL '1 hour'
LIMIT 8
) AS nearby_owned
FROM works w
WHERE w.status = 'completed'
AND w.is_public = true
AND w.user_id = $1
ORDER BY w.created_at DESC
LIMIT 80
`, [SYSTEM_USER_ID]);
console.log(JSON.stringify({
orphanModelTotals: orphanModelTotals.rows,
byModelOwner: byModelOwner.rows,
ownersByPrompt: ownersByPrompt.rows,
nearbyOwnedByTime: maybeAdminByDates.rows,
}, null, 2));
} finally {
client.release();
await pool.end();
}
})().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
const duplicates = await client.query(`
SELECT
public.id AS public_id,
public.user_id AS public_user_id,
private.id AS private_id,
private.user_id AS private_user_id,
p.email,
p.nickname,
public.result_url AS public_url,
private.result_url AS private_url,
LEFT(COALESCE(public.prompt, ''), 80) AS prompt_preview
FROM works public
JOIN works private
ON private.id <> public.id
AND private.user_id <> '00000000-0000-0000-0000-000000000000'
AND (
private.result_url = public.result_url
OR private.thumbnail_url = public.thumbnail_url
OR (
COALESCE(private.prompt, '') = COALESCE(public.prompt, '')
AND private.created_at BETWEEN public.created_at - INTERVAL '10 minutes' AND public.created_at + INTERVAL '10 minutes'
)
)
JOIN profiles p ON p.id = private.user_id
WHERE public.is_public = true
AND public.status = 'completed'
AND public.user_id = '00000000-0000-0000-0000-000000000000'
LIMIT 50
`);
const paramKeys = await client.query(`
SELECT id, params
FROM works
WHERE is_public = true
AND status = 'completed'
AND user_id = '00000000-0000-0000-0000-000000000000'
LIMIT 10
`);
console.log(JSON.stringify({
duplicateCandidateCount: duplicates.rowCount,
duplicateCandidates: duplicates.rows,
paramSamples: paramKeys.rows,
}, null, 2));
} finally {
client.release();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS api_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(128) NOT NULL UNIQUE,
default_api_url TEXT,
default_model VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
website TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS model_recommendations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_name VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
provider_id UUID REFERENCES api_providers(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS api_providers_active_sort_idx ON api_providers (is_active, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_active_type_sort_idx ON model_recommendations (is_active, type, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_provider_idx ON model_recommendations (provider_id);
INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
VALUES
('硅基流动', 'https://api.siliconflow.cn/v1/images/generations', 'black-forest-labs/FLUX.1-schnell', 'image', 'https://cloud.siliconflow.cn', true, 10),
('mozheAPI', 'https://openai.mozhevip.top', '', 'image', 'https://openai.mozhevip.top', true, 20),
('OpenAI', 'https://api.openai.com/v1/images/generations', 'dall-e-3', 'image', NULL, true, 30),
('Stability AI', 'https://api.stability.ai/v1/generation/stable-diffusion-xl/text-to-image', 'stable-diffusion-xl', 'image', NULL, true, 40),
('Midjourney', '', 'midjourney-v6', 'image', NULL, true, 50),
('Runway', 'https://api.runwayml.com/v1/image_to_video', 'gen-3-alpha', 'video', NULL, true, 60),
('Pika', '', 'pika-1.0', 'video', NULL, true, 70),
('Kling', '', 'kling-v1', 'video', NULL, true, 80),
('DeepSeek', 'https://api.deepseek.com/v1/chat/completions', 'deepseek-chat', 'text', NULL, true, 90),
('OpenAI GPT', 'https://api.openai.com/v1/chat/completions', 'gpt-4o', 'text', NULL, true, 100),
('自定义', '', '', 'image', NULL, true, 999)
ON CONFLICT (name) DO UPDATE SET
default_api_url = EXCLUDED.default_api_url,
default_model = EXCLUDED.default_model,
type = EXCLUDED.type,
website = EXCLUDED.website,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order,
updated_at = NOW();
INSERT INTO model_recommendations (model_name, display_name, type, provider_id, is_active, sort_order)
SELECT 'gpt-image-2', 'gpt-image-2', 'image', NULL, true, 10
WHERE NOT EXISTS (
SELECT 1 FROM model_recommendations
WHERE model_name = 'gpt-image-2' AND type = 'image' AND provider_id IS NULL
);

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

19
next.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// outputFileTracingRoot: path.resolve(__dirname, '../../'), // Uncomment and add 'import path from "path"' if needed
/* config options here */
poweredByHeader: false,
allowedDevOrigins: ['*.dev.coze.site'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*',
pathname: '/**',
},
],
},
};
export default nextConfig;

132
package.json Normal file
View File

@@ -0,0 +1,132 @@
{
"name": "projects",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "bash ./scripts/build.sh",
"build:with-install": "INSTALL_DEPS=1 bash ./scripts/build.sh",
"backup:create": "bash ./scripts/backup-create.sh",
"backup:list": "bash ./scripts/backup-list.sh",
"backup:restore": "bash ./scripts/backup-restore.sh",
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
"db:patch": "bash ./scripts/apply-database-patch.sh",
"dev": "bash ./scripts/dev.sh",
"preinstall": "npx only-allow pnpm",
"lint": "eslint",
"start": "bash ./scripts/start.sh",
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
"test:custom-img2img-reference-url": "node --no-warnings ./scripts/test-custom-img2img-reference-url.mjs",
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
"test:creation-thumbnail-policy": "tsx ./scripts/test-creation-thumbnail-policy.mjs",
"test:video-object-storage-actions": "tsx ./scripts/test-video-object-storage-actions.mjs",
"test:gallery-publish-fast-path": "tsx ./scripts/test-gallery-publish-fast-path.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:media-watermark-policy": "tsx ./scripts/test-media-watermark-policy.mjs",
"test:reference-image-prompt-links": "tsx ./scripts/test-reference-image-prompt-links.mjs",
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
"test:yuanjie-pricing-sync": "tsx ./scripts/test-yuanjie-pricing-sync.mjs",
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
"pm2:save": "pm2 save",
"migration:check": "node ./scripts/migration-integrity-check.mjs",
"rainyun:ros-prepare": "node ./scripts/rainyun-ros-prepare.mjs",
"storage:sync-object": "node ./scripts/storage-sync-to-object.mjs",
"ts-check": "tsc -p tsconfig.json",
"check:boundaries": "bash ./scripts/check-boundaries.sh"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/lib-storage": "^3.958.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "2.95.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"coze-coding-dev-sdk": "^0.7.21",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"ffmpeg-static": "^5.3.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.468.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"pg": "^8.17.2",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.70.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@react-dev-inspector/babel-plugin": "^2.0.1",
"@react-dev-inspector/middleware": "^2.0.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"only-allow": "^1.2.2",
"react-dev-inspector": "^2.0.1",
"recharts": "2.15.4",
"shadcn": "latest",
"tailwindcss": "^4",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5"
},
"packageManager": "pnpm@9.0.0",
"pnpm": {
"overrides": {
"@langchain/core": "1.1.44",
"langsmith": "0.6.0",
"fast-xml-parser": "5.7.0",
"postcss": "8.5.10",
"uuid": "14.0.0"
}
},
"engines": {
"pnpm": ">=9.0.0"
}
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
CREATE INDEX IF NOT EXISTS user_api_keys_user_active_idx ON user_api_keys (user_id, is_active);
CREATE INDEX IF NOT EXISTS works_user_result_url_idx ON works (user_id, result_url);

13440
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,6 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 10L20 20L10 30" stroke="#3B82F6" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 10L30 20L20 30" stroke="#3B82F6" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15L25 25L15 35" stroke="#3B82F6" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25 15L35 25L25 35" stroke="#3B82F6" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,98 @@
const { Pool } = require('pg');
require('dotenv').config({ path: '.env.local' });
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
(async () => {
const pool = new Pool({ connectionString: process.env.LOCAL_DB_URL });
const client = await pool.connect();
try {
await client.query('BEGIN');
const byUniqueCustomModel = await client.query(`
WITH model_owners AS (
SELECT
params->>'model' AS model,
ARRAY_AGG(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND user_id <> $1) AS owner_ids
FROM works
WHERE status = 'completed'
AND params->>'model' LIKE 'custom:%'
AND EXISTS (SELECT 1 FROM profiles p WHERE p.id = works.user_id)
GROUP BY params->>'model'
),
unique_model_owners AS (
SELECT model, owner_ids[1] AS owner_id
FROM model_owners
WHERE CARDINALITY(owner_ids) = 1
),
updated AS (
UPDATE works w
SET user_id = umo.owner_id,
updated_at = NOW()
FROM unique_model_owners umo
WHERE w.user_id = $1
AND w.status = 'completed'
AND w.is_public = true
AND w.params->>'model' = umo.model
RETURNING w.id, w.user_id, w.params->>'model' AS model
)
SELECT COUNT(*)::int AS fixed_count, json_agg(updated) AS rows
FROM updated
`, [SYSTEM_USER_ID]);
const byExactPromptTime = await client.query(`
WITH candidates AS (
SELECT DISTINCT ON (public.id)
public.id AS public_id,
private.user_id AS owner_user_id,
ABS(EXTRACT(EPOCH FROM (private.created_at - public.created_at))) AS time_distance
FROM works public
JOIN works private
ON private.id <> public.id
AND private.user_id IS NOT NULL
AND private.user_id <> $1
AND COALESCE(private.prompt, '') <> ''
AND private.prompt = public.prompt
AND private.created_at BETWEEN public.created_at - INTERVAL '30 minutes' AND public.created_at + INTERVAL '30 minutes'
JOIN profiles p ON p.id = private.user_id
WHERE public.user_id = $1
AND public.is_public = true
AND public.status = 'completed'
ORDER BY public.id, time_distance
),
unambiguous AS (
SELECT public_id, MIN(owner_user_id::text)::uuid AS owner_user_id
FROM candidates
GROUP BY public_id
HAVING COUNT(DISTINCT owner_user_id) = 1
),
updated AS (
UPDATE works w
SET user_id = unambiguous.owner_user_id,
updated_at = NOW()
FROM unambiguous
WHERE w.id = unambiguous.public_id
AND w.user_id = $1
RETURNING w.id, w.user_id, w.params->>'model' AS model
)
SELECT COUNT(*)::int AS fixed_count, json_agg(updated) AS rows
FROM updated
`, [SYSTEM_USER_ID]);
await client.query('COMMIT');
console.log(JSON.stringify({
byUniqueCustomModel: byUniqueCustomModel.rows[0] || { fixed_count: 0, rows: [] },
byExactPromptTime: byExactPromptTime.rows[0] || { fixed_count: 0, rows: [] },
}, null, 2));
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
await pool.end();
}
})().catch(error => {
console.error(error);
process.exit(1);
});

720
scripts/admin-upgrade-runner.mjs Executable file
View File

@@ -0,0 +1,720 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const args = parseArgs(process.argv.slice(2));
const projectRoot = path.resolve(args.project || process.cwd());
loadEnvFile(path.join(projectRoot, '.env.local'));
const stateRoot = path.resolve(
process.env.UPGRADE_STATE_DIR ||
(process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')),
);
const jobId = requireArg(args, 'job-id');
const mode = requireArg(args, 'mode');
const dryRun = args['dry-run'] === 'true';
const packagePath = path.resolve(requireArg(args, 'package'));
const packageName = args['package-name'] || path.basename(packagePath);
const jobDir = path.join(stateRoot, 'jobs', jobId);
const stateFile = path.join(jobDir, 'state.json');
const extractDir = path.join(jobDir, 'extract');
const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`);
const HOT_ALLOWED_PREFIXES = ['public/'];
const HOT_ALLOWED_FILES = new Set([
'manifest.json',
'robots.txt',
'sitemap.xml',
'favicon.ico',
'icon.png',
'apple-icon.png',
]);
const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/'];
const COLD_ALLOWED_FILES = new Set([
'manifest.json',
'package.json',
'pnpm-lock.yaml',
'next.config.js',
'next.config.mjs',
'next.config.ts',
'tsconfig.json',
'postcss.config.mjs',
'components.json',
'ecosystem.config.cjs',
]);
const BLOCKED_TOP_LEVEL_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']);
const BLOCKED_ANYWHERE_NAMES = new Set(['.git', 'node_modules', '.next']);
const MAX_EXTRACTED_FILES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILES || 5000);
const MAX_EXTRACTED_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_BYTES || 500 * 1024 * 1024);
const MAX_EXTRACTED_FILE_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILE_BYTES || 200 * 1024 * 1024);
const MIN_FREE_BYTES = Number(process.env.UPGRADE_MIN_FREE_BYTES || 1024 * 1024 * 1024);
const BUILD_FREE_BYTES = Number(process.env.UPGRADE_BUILD_FREE_BYTES || 1024 * 1024 * 1024);
const PAYLOAD_TOP_LEVEL_DIRECTORIES = new Set([
...HOT_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
...COLD_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
...BLOCKED_TOP_LEVEL_NAMES,
]);
let state = readState() || {
id: jobId,
mode,
status: 'queued',
step: 'queued',
message: '升级任务已创建',
progress: 0,
packageName,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
logs: [],
};
main().catch(error => {
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`);
if (dryRun) {
cleanupExtractDir();
updateState({
status: 'failed',
step: 'preflight_failed',
progress: 100,
message: '升级包预检失败,请按错误信息调整升级包',
error: error instanceof Error ? error.message : '升级包预检异常退出',
finishedAt: new Date().toISOString(),
});
return;
}
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
updateState({
status: 'rollback_failed',
step: 'rollback_failed',
progress: 100,
message: '升级失败,自动回滚也失败,请立即人工检查',
error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
finishedAt: new Date().toISOString(),
});
});
});
async function main() {
ensureDir(jobDir);
updateState({
status: 'running',
step: 'preflight',
progress: 5,
message: '正在检查升级包与运行环境',
startedAt: state.startedAt || new Date().toISOString(),
});
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`);
if (mode !== 'hot' && mode !== 'cold') {
throw new Error('升级方式无效');
}
if (!fs.existsSync(packagePath)) {
throw new Error(`升级包不存在: ${packagePath}`);
}
if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) {
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
}
const packageBytes = fs.statSync(packagePath).size;
const preExtractDiskChecks = validatePreExtractDiskSpace(packageBytes);
updateState({ diskChecks: preExtractDiskChecks });
logStep('校验升级包', '正在读取压缩包目录并检查格式');
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
resetDir(extractDir);
run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' });
const payloadRoot = resolvePayloadRoot(extractDir);
const packageStats = collectPackageStats(payloadRoot);
const files = packageStats.files;
if (files.length === 0) {
throw new Error('升级包为空');
}
validatePackageSize(packageStats);
const diskChecks = validateUpgradeDiskSpace(packageStats, packageBytes);
const validation = validateFiles(files, mode);
logStep('升级包内容', `校验通过,共 ${files.length} 个文件,解压后 ${formatBytes(packageStats.totalBytes)}${files.slice(0, 20).join('、')}${files.length > 20 ? `${files.length} 个文件` : ''}`);
updateState({
step: 'validated',
progress: 14,
message: `升级包校验通过,共 ${files.length} 个文件`,
restartRequired: mode === 'cold' || validation.requiresRestart,
packageHash: sha256(packagePath),
changedFiles: files,
extractedFileCount: packageStats.files.length,
extractedBytes: packageStats.totalBytes,
largestFileBytes: packageStats.largestFileBytes,
diskChecks,
dryRun,
});
if (dryRun) {
logStep('预检完成', `升级包可用于${mode === 'hot' ? '热更新' : '冷更新'}${mode === 'cold' || validation.requiresRestart ? '需要重启平台' : '无需重启平台'}`);
cleanupExtractDir();
updateState({
status: 'succeeded',
step: 'preflight_completed',
progress: 100,
message: `预检通过:共 ${files.length} 个文件,${mode === 'cold' || validation.requiresRestart ? '执行时需要重启平台' : '执行时无需重启平台'}`,
finishedAt: new Date().toISOString(),
restartRequired: mode === 'cold' || validation.requiresRestart,
dryRun: true,
});
return;
}
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
cwd: projectRoot,
label: '创建数据备份',
env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot },
}).trim().split('\n').pop();
if (!backupFile || !fs.existsSync(backupFile)) {
throw new Error('数据备份创建失败');
}
verifyTarArchive(backupFile, '校验数据备份');
const backupHash = sha256(backupFile);
updateState({ backupFile, backupHash });
logStep('数据备份完成', `备份文件:${backupFile}SHA256${backupHash}`);
updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' });
logStep('创建源码快照', '开始保存升级前源码状态');
createSourceBackup(sourceBackupFile);
verifyTarArchive(sourceBackupFile, '校验源码快照');
const sourceBackupHash = sha256(sourceBackupFile);
updateState({ sourceBackupFile, sourceBackupHash });
logStep('源码快照完成', `快照文件:${sourceBackupFile}SHA256${sourceBackupHash}`);
updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' });
logStep('应用升级文件', '开始覆盖升级包中的文件');
updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) });
applyFiles(payloadRoot, files);
logStep('升级文件应用完成', `已应用 ${files.filter(file => file !== 'manifest.json').length} 个文件`);
if (mode === 'hot') {
updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' });
logStep('热更新验证', '正在执行 TypeScript 校验,确认补丁不会破坏现有代码');
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
logStep('热更新完成', '升级成功,平台未重启,前端业务不中断');
cleanupExtractDir();
updateState({
status: 'succeeded',
step: 'completed',
progress: 100,
message: '热更新成功,平台未重启',
finishedAt: new Date().toISOString(),
restartRequired: false,
});
return;
}
const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml');
if (dependencyChanged) {
updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' });
logStep('安装依赖', '检测到 package.json 或 pnpm-lock.yaml 变化,开始安装依赖');
run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' });
logStep('依赖安装完成', '依赖安装已完成');
}
updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' });
logStep('代码校验', '开始执行 TypeScript 校验');
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
logStep('代码校验完成', 'TypeScript 校验已通过');
updateState({ step: 'build', progress: 75, message: '正在构建平台' });
logStep('平台构建', '开始构建生产版本');
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' });
logStep('平台构建完成', '生产构建已完成');
updateState({ step: 'restart', progress: 94, message: '构建已完成,正在后台重启平台进程' });
logStep('冷更新完成', '升级文件已应用并完成构建,将在后台重启平台进程');
cleanupExtractDir();
updateState({
status: 'succeeded',
step: 'completed',
progress: 100,
message: '冷更新成功,平台正在后台重启',
finishedAt: new Date().toISOString(),
restartRequired: true,
});
restartPlatform({ detached: true });
}
async function rollbackAfterFailure(message) {
const originalError = message;
logStep('升级失败', `失败原因:${originalError}`);
updateState({
status: 'rolling_back',
step: 'rolling_back',
progress: 96,
message: '升级失败,正在自动回滚到升级前状态',
error: originalError,
});
if (fs.existsSync(sourceBackupFile)) {
logStep('回滚源码', '正在恢复升级前源码快照,并移除升级中新建的文件');
restoreSourceBackup(sourceBackupFile);
logStep('源码回滚完成', '源码已恢复到升级开始前状态');
}
if (state.backupFile && fs.existsSync(state.backupFile)) {
logStep('回滚数据', '正在恢复数据库、存储目录和环境配置备份');
run('bash', ['./scripts/backup-restore.sh', state.backupFile], {
cwd: projectRoot,
label: '恢复数据备份',
env: {
COZE_WORKSPACE_PATH: projectRoot,
RESTORE_SAFETY_DIR: path.join(stateRoot, 'restore-safety'),
},
});
logStep('数据回滚完成', '数据库、存储目录和环境配置已恢复');
}
if (mode === 'cold') {
try {
logStep('回滚后重建', '冷更新失败后正在重新构建回滚版本');
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' });
logStep('回滚后重启', '将后台重启回滚后的平台版本');
} catch (error) {
throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
logStep('自动回滚完成', '升级失败,但已自动恢复到升级开始前状态');
cleanupExtractDir();
updateState({
status: 'rolled_back',
step: 'rolled_back',
progress: 100,
message: '升级失败,已自动回滚到升级开始前状态',
error: originalError,
finishedAt: new Date().toISOString(),
});
if (mode === 'cold') {
restartPlatform({ detached: true });
}
}
function parseArgs(argv) {
const parsed = {};
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (!item.startsWith('--')) continue;
const key = item.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
parsed[key] = 'true';
} else {
parsed[key] = next;
index += 1;
}
}
return parsed;
}
function requireArg(parsed, key) {
const value = parsed[key];
if (!value) throw new Error(`missing --${key}`);
return value;
}
function loadEnvFile(file) {
if (!fs.existsSync(file)) return;
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
const index = trimmed.indexOf('=');
const key = trimmed.slice(0, index).trim();
let value = trimmed.slice(index + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
}
function readState() {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
function updateState(patch) {
state = {
...state,
...patch,
updatedAt: new Date().toISOString(),
logs: patch.logs || state.logs || [],
};
ensureDir(path.dirname(stateFile));
const tempFile = `${stateFile}.tmp`;
fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`);
fs.renameSync(tempFile, stateFile);
}
function log(line) {
const timestamped = `[${new Date().toISOString()}] ${line}`;
const logs = [...(state.logs || []), timestamped].slice(-1000);
updateState({ logs });
}
function logStep(title, detail = '') {
log(detail ? `${title}${detail}` : title);
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
function resetDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
ensureDir(dir);
}
function cleanupExtractDir() {
if (!fs.existsSync(extractDir)) return;
fs.rmSync(extractDir, { recursive: true, force: true });
logStep('清理解压目录', `已删除临时目录:${extractDir}`);
}
function run(command, commandArgs, options = {}) {
runCapture(command, commandArgs, options);
}
function runCapture(command, commandArgs, options = {}) {
const label = options.label || command;
logStep(label, `执行命令 ${command} ${commandArgs.join(' ')}`);
const result = spawnSync(command, commandArgs, {
cwd: options.cwd || projectRoot,
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) },
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
});
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (output) {
for (const line of output.split(/\r?\n/).slice(-180)) log(`${label}输出:${line}`);
}
if (result.status !== 0) {
throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`);
}
return result.stdout || '';
}
function isAllowedArchive(file) {
return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz');
}
function resolvePayloadRoot(root) {
const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX');
if (entries.length === 1 && entries[0].isDirectory() && !PAYLOAD_TOP_LEVEL_DIRECTORIES.has(entries[0].name)) {
return path.join(root, entries[0].name);
}
return root;
}
function collectPackageStats(root) {
const files = [];
let totalBytes = 0;
let largestFileBytes = 0;
walk(root, '');
return { files: files.sort(), totalBytes, largestFileBytes };
function walk(currentRoot, relativeRoot) {
for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) {
if (entry.name === '.DS_Store') continue;
const relative = toPosix(path.join(relativeRoot, entry.name));
const absolute = path.join(currentRoot, entry.name);
if (entry.isDirectory()) {
walk(absolute, relative);
} else if (entry.isFile()) {
const stat = fs.statSync(absolute);
totalBytes += stat.size;
largestFileBytes = Math.max(largestFileBytes, stat.size);
files.push(relative);
} else {
throw new Error(`升级包包含不支持的文件类型: ${relative}`);
}
}
}
}
function validatePackageSize(stats) {
if (Number.isFinite(MAX_EXTRACTED_FILES) && stats.files.length > MAX_EXTRACTED_FILES) {
throw new Error(`升级包文件数量过多:${stats.files.length} 个,最多允许 ${MAX_EXTRACTED_FILES}`);
}
if (Number.isFinite(MAX_EXTRACTED_BYTES) && stats.totalBytes > MAX_EXTRACTED_BYTES) {
throw new Error(`升级包解压后过大:${formatBytes(stats.totalBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_BYTES)}`);
}
if (Number.isFinite(MAX_EXTRACTED_FILE_BYTES) && stats.largestFileBytes > MAX_EXTRACTED_FILE_BYTES) {
throw new Error(`升级包包含过大的单个文件:${formatBytes(stats.largestFileBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_FILE_BYTES)}`);
}
}
function validatePreExtractDiskSpace(packageBytes) {
const stateCheck = buildDiskCheck({
label: '升级状态目录',
targetPath: stateRoot,
requiredBytes: packageBytes + MAX_EXTRACTED_BYTES + MIN_FREE_BYTES,
});
assertDiskSpace(stateCheck);
logDiskCheck(stateCheck);
return [stateCheck];
}
function validateUpgradeDiskSpace(stats, packageBytes) {
const checks = [
buildDiskCheck({
label: '升级状态目录',
targetPath: stateRoot,
requiredBytes: packageBytes + stats.totalBytes + MIN_FREE_BYTES,
}),
buildDiskCheck({
label: '项目目录',
targetPath: projectRoot,
requiredBytes: stats.totalBytes + (mode === 'cold' ? BUILD_FREE_BYTES : MIN_FREE_BYTES),
}),
];
for (const check of checks) {
assertDiskSpace(check);
logDiskCheck(check);
}
return checks;
}
function buildDiskCheck({ label, targetPath, requiredBytes }) {
const usage = readDiskUsage(targetPath);
return {
label,
path: path.resolve(targetPath),
mountPath: usage.mountPath,
totalBytes: usage.totalBytes,
availableBytes: usage.availableBytes,
requiredBytes,
usedPercent: usage.usedPercent,
};
}
function readDiskUsage(targetPath) {
ensureDir(targetPath);
const result = spawnSync('df', ['-Pk', targetPath], { encoding: 'utf8', timeout: 5000 });
if (result.status !== 0 || !result.stdout) {
const detail = `${result.stderr || result.stdout || ''}`.trim();
throw new Error(`读取磁盘空间失败:${targetPath}${detail ? `${detail}` : ''}`);
}
const lines = result.stdout.trim().split(/\r?\n/);
const row = lines[lines.length - 1]?.trim().split(/\s+/);
if (!row || row.length < 6) {
throw new Error(`读取磁盘空间失败:${targetPath}`);
}
const totalBytes = Number(row[1]) * 1024;
const availableBytes = Number(row[3]) * 1024;
const usedPercent = Number(row[4].replace('%', ''));
if (!Number.isFinite(totalBytes) || !Number.isFinite(availableBytes)) {
throw new Error(`读取磁盘空间失败:${targetPath}`);
}
return {
mountPath: row.slice(5).join(' ') || targetPath,
totalBytes,
availableBytes,
usedPercent: Number.isFinite(usedPercent) ? usedPercent : null,
};
}
function assertDiskSpace(check) {
if (!Number.isFinite(check.requiredBytes) || check.requiredBytes <= 0) return;
if (check.availableBytes >= check.requiredBytes) return;
throw new Error(`升级前磁盘空间不足:${check.label} ${check.path} 可用 ${formatBytes(check.availableBytes)},需要至少 ${formatBytes(check.requiredBytes)}`);
}
function logDiskCheck(check) {
const mountDetail = check.mountPath && check.mountPath !== check.path ? `(挂载点 ${check.mountPath}` : '';
logStep('磁盘空间检查', `${check.label} ${check.path}${mountDetail} 可用 ${formatBytes(check.availableBytes)},需要 ${formatBytes(check.requiredBytes)}`);
}
function validateFiles(files, updateMode) {
for (const file of files) {
assertSafeRelativePath(file);
if (isBlockedPackagePath(file)) {
throw new Error(`升级包包含禁止覆盖的路径: ${file}`);
}
if (updateMode === 'hot' && !isHotAllowed(file)) {
throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`);
}
if (updateMode === 'cold' && !isColdAllowed(file)) {
throw new Error(`冷更新包包含未授权路径: ${file}`);
}
}
return { requiresRestart: files.some(file => !isHotAllowed(file)) };
}
function isBlockedPackagePath(file) {
const parts = file.split('/');
return (
parts.some(part => part.startsWith('.env')) ||
BLOCKED_TOP_LEVEL_NAMES.has(parts[0]) ||
parts.some(part => BLOCKED_ANYWHERE_NAMES.has(part))
);
}
function assertSafeRelativePath(file) {
if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) {
throw new Error(`升级包包含非法路径: ${file}`);
}
const normalized = path.posix.normalize(file);
if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
throw new Error(`升级包包含目录穿越路径: ${file}`);
}
}
function isHotAllowed(file) {
return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
}
function isColdAllowed(file) {
return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
}
function applyFiles(root, files) {
for (const file of files) {
if (file === 'manifest.json') continue;
const source = path.join(root, file);
const target = path.join(projectRoot, file);
ensureDir(path.dirname(target));
fs.copyFileSync(source, target);
}
}
function createSourceBackup(target) {
ensureDir(path.dirname(target));
run('tar', [
'-czf',
target,
'--exclude=.git',
'--exclude=node_modules',
'--exclude=.next',
'--exclude=dist',
'--exclude=backups',
'--exclude=./local-storage',
'--exclude=upgrade-state',
'--exclude=tsconfig.tsbuildinfo',
'-C',
projectRoot,
'.',
], { cwd: projectRoot, label: '创建源码快照' });
}
function restoreSourceBackup(source) {
log(`恢复源码快照: ${source}`);
const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []);
const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : [];
for (const file of changedFiles) {
if (file === 'manifest.json' || preExistingFiles.has(file)) continue;
const target = path.join(projectRoot, file);
if (target.startsWith(projectRoot)) {
fs.rmSync(target, { force: true });
}
}
run('tar', [
'-xzf',
source,
'--exclude=.git',
'--exclude=node_modules',
'--exclude=.next',
'--exclude=dist',
'-C',
projectRoot,
], { cwd: projectRoot, label: '恢复源码快照' });
}
function restartPlatform(options = {}) {
const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand();
if (options.detached) {
const logFile = path.join(jobDir, 'restart.log');
const detachedCommand = `nohup bash -lc ${JSON.stringify(restartCommand)} >> ${JSON.stringify(logFile)} 2>&1 &`;
spawnSync('bash', ['-lc', detachedCommand], {
cwd: projectRoot,
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack' },
encoding: 'utf8',
});
logStep('后台重启平台', `已触发后台重启命令,日志:${logFile}`);
return;
}
run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' });
}
function detectRestartCommand() {
const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], {
cwd: projectRoot,
label: '检测 PM2 进程',
});
if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env';
if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save';
return 'pm2 restart miaojing-dev --update-env';
}
function tarReadArgs(action, archivePath) {
const flag = action === 'list' ? '-tf' : '-xf';
const gzipFlag = action === 'list' ? '-tzf' : '-xzf';
return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath];
}
function verifyTarArchive(archivePath, label) {
if (!fs.existsSync(archivePath)) {
throw new Error(`${label}失败,文件不存在: ${archivePath}`);
}
run('tar', tarReadArgs('list', archivePath), { cwd: projectRoot, label });
}
function waitForHealth() {
const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health';
const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000);
const startedAt = Date.now();
let lastError = '';
while (Date.now() - startedAt < timeoutMs) {
const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 });
if (result.status === 0) {
log(`健康检查通过: ${healthUrl}`);
return;
}
lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim();
sleep(3000);
}
throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`);
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function sha256(file) {
const hash = createHash('sha256');
hash.update(fs.readFileSync(file));
return hash.digest('hex');
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return 'unknown';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
function toPosix(file) {
return file.split(path.sep).join('/');
}

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
if [ -f "${COZE_WORKSPACE_PATH}/.env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source "${COZE_WORKSPACE_PATH}/.env.local"
set +a
set -u
fi
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is not set" >&2
exit 1
fi
psql "${LOCAL_DB_URL}" -v ON_ERROR_STOP=1 -f "${COZE_WORKSPACE_PATH}/scripts/database-optimization-patch.sql"

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env node
import pg from 'pg';
const { Client } = pg;
const adjectives = ['云朵', '星河', '松风', '月白', '晴川', '青柚', '琥珀', '小满', '竹影', '橘光', '海盐', '霁蓝'];
const nouns = ['画师', '旅人', '造梦家', '观察员', '收藏家', '调色师', '冒险家', '灵感师', '策展人', '星愿者', '小导演', '光影客'];
const kinds = ['person', 'cat', 'bear', 'bunny', 'fox'];
const palettes = [
['#7dd3fc', '#c084fc', '#fdf2f8', '#0f172a'],
['#fbbf24', '#fb7185', '#fff7ed', '#3b1d0f'],
['#86efac', '#38bdf8', '#f0fdf4', '#052e2b'],
['#f9a8d4', '#a78bfa', '#fdf4ff', '#312e81'],
['#fdba74', '#60a5fa', '#eff6ff', '#1e3a8a'],
];
function hashString(value) {
let hash = 2166136261;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function pick(items, seed, offset = 0) {
return items[(seed + offset) % items.length];
}
function escapeXml(value) {
return value.replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
})[char] || char);
}
function generateChineseNickname(seedValue) {
const seed = hashString(seedValue);
return `${pick(adjectives, seed)}${pick(nouns, seed >>> 5)}${String(seed % 1000).padStart(3, '0')}`;
}
function generateDefaultAvatarDataUrl(seedValue, labelValue) {
const seed = hashString(seedValue);
const [primary, secondary, surface, ink] = pick(palettes, seed);
const kind = pick(kinds, seed >>> 3);
const label = escapeXml(String(labelValue || '').trim().slice(0, 1) || '妙');
const blush = seed % 2 === 0 ? '#fb7185' : '#f472b6';
const earLeft = kind === 'cat'
? '<path d="M76 92 L110 44 L126 108 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
: kind === 'bunny'
? '<ellipse cx="105" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(-16 105 54)"/>'
: kind === 'bear' || kind === 'fox'
? '<circle cx="103" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
: '';
const earRight = kind === 'cat'
? '<path d="M180 108 L196 44 L232 92 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
: kind === 'bunny'
? '<ellipse cx="205" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(16 205 54)"/>'
: kind === 'bear' || kind === 'fox'
? '<circle cx="213" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
: '';
const nose = kind === 'person'
? `<path d="M160 147 c-7 10 -1 18 10 16" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" opacity=".44"/>`
: `<path d="M151 151 q9 -8 18 0 q-9 11 -18 0Z" fill="${ink}" opacity=".72"/>`;
const hair = kind === 'person'
? `<path d="M90 133 c16 -58 58 -83 112 -57 c29 14 40 44 35 70 c-23 -20 -42 -17 -66 -36 c-26 26 -52 28 -81 23Z" fill="${secondary}" opacity=".92"/>`
: '';
const muzzle = kind === 'person' ? '' : '<ellipse cx="160" cy="165" rx="39" ry="26" fill="rgba(255,255,255,.54)"/>';
const whiskers = kind === 'cat' || kind === 'fox'
? `<path d="M101 155 h36 M101 174 h36 M183 155 h36 M183 174 h36" stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".38"/>`
: '';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><defs><radialGradient id="bg" cx="34%" cy="25%" r="78%"><stop offset="0%" stop-color="${surface}"/><stop offset="48%" stop-color="${primary}"/><stop offset="100%" stop-color="${secondary}"/></radialGradient><linearGradient id="face" x1="72" y1="64" x2="236" y2="246" gradientUnits="userSpaceOnUse"><stop stop-color="#fff8f0"/><stop offset=".58" stop-color="#ffd7b5"/><stop offset="1" stop-color="#f8a978"/></linearGradient><filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#111827" flood-opacity=".22"/></filter></defs><rect width="320" height="320" rx="80" fill="url(#bg)"/><circle cx="254" cy="58" r="34" fill="rgba(255,255,255,.34)"/><circle cx="68" cy="250" r="44" fill="rgba(255,255,255,.20)"/><g filter="url(#soft)">${earLeft}${earRight}<circle cx="160" cy="153" r="83" fill="url(#face)" stroke="rgba(255,255,255,.68)" stroke-width="6"/>${hair}<circle cx="128" cy="144" r="9" fill="${ink}"/><circle cx="192" cy="144" r="9" fill="${ink}"/><circle cx="125" cy="142" r="3" fill="#fff"/><circle cx="189" cy="142" r="3" fill="#fff"/>${muzzle}${nose}${whiskers}<path d="M137 184 q23 18 46 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round" opacity=".62"/><circle cx="105" cy="169" r="13" fill="${blush}" opacity=".30"/><circle cx="215" cy="169" r="13" fill="${blush}" opacity=".30"/></g><g transform="translate(218 222)"><circle cx="34" cy="34" r="30" fill="rgba(255,255,255,.78)" stroke="rgba(255,255,255,.86)" stroke-width="3"/><text x="34" y="45" text-anchor="middle" font-size="30" font-weight="800" font-family="Arial, sans-serif" fill="${ink}">${label}</text></g></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
async function main() {
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
const client = new Client({ connectionString });
await client.connect();
try {
await client.query('ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)');
const result = await client.query(`
SELECT id, email, nickname, display_nickname, avatar_url
FROM profiles
WHERE display_nickname IS NULL OR display_nickname = ''
OR avatar_url IS NULL OR avatar_url = ''
ORDER BY created_at ASC
`);
let nicknameCount = 0;
let avatarCount = 0;
for (const row of result.rows) {
const displayNickname = row.display_nickname || row.nickname || generateChineseNickname(`${row.id}:${row.email}`);
const avatarUrl = row.avatar_url || generateDefaultAvatarDataUrl(`${row.id}:${row.email}`, displayNickname);
if (!row.display_nickname) nicknameCount += 1;
if (!row.avatar_url) avatarCount += 1;
await client.query(
`UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), $2),
avatar_url = COALESCE(NULLIF(avatar_url, ''), $3),
updated_at = NOW()
WHERE id = $1`,
[row.id, displayNickname, avatarUrl],
);
}
console.log(JSON.stringify({ scanned: result.rowCount, displayNicknamesBackfilled: nicknameCount, avatarsBackfilled: avatarCount }));
} finally {
await client.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import pg from 'pg';
loadEnvFile(path.join(process.cwd(), '.env.local'));
const { Client } = pg;
const args = new Set(process.argv.slice(2));
const dryRun = args.has('--dry-run');
const referenceImageStorage = await import('../src/lib/reference-image-storage.ts');
const persistReferenceImages = referenceImageStorage.persistReferenceImages
|| referenceImageStorage.default?.persistReferenceImages;
const verbose = args.has('--verbose');
if (args.has('--check-import')) {
if (typeof persistReferenceImages !== 'function') {
throw new Error('persistReferenceImages import failed');
}
console.log(JSON.stringify({ ok: true, import: 'persistReferenceImages' }));
process.exit(0);
}
const limitArg = [...args].find(arg => arg.startsWith('--limit='));
const limit = Math.max(1, Math.min(5000, Number(limitArg?.split('=')[1] || 500)));
const timeoutArg = [...args].find(arg => arg.startsWith('--item-timeout-ms='));
const itemTimeoutMs = Math.max(5_000, Math.min(300_000, Number(timeoutArg?.split('=')[1] || 90_000)));
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const raw = fs.readFileSync(filePath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, rawValue] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = rawValue.replace(/^['"]|['"]$/g, '');
}
}
function normalizeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getReferenceInputs(params) {
const values = [
params.referenceImage,
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
params.image,
...(Array.isArray(params.images) ? params.images : []),
...(Array.isArray(params.extraImages) ? params.extraImages : []),
params.sourceImage,
params.source_image,
params.inputImage,
params.input_image,
];
return [...new Set(values.map(normalizeString).filter(value => value && !value.startsWith('[')))];
}
function shouldBackfill(params) {
const references = getReferenceInputs(params);
const hasThumbnails = Array.isArray(params.referenceImageThumbnails) && params.referenceImageThumbnails.length > 0;
return references.some(value => value.startsWith('data:image/') || /^https?:\/\//i.test(value) || value.startsWith('/api/local-storage/'))
&& (!Array.isArray(params.referenceImages) || params.referenceImages.length === 0 || !hasThumbnails || references.some(value => value.startsWith('data:image/')));
}
function withTimeout(promise, timeoutMs, label) {
let timeoutId;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
}
async function main() {
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
const client = new Client({ connectionString });
await client.connect();
try {
const result = await client.query(
`SELECT id, params
FROM works
WHERE status = 'completed'
AND (
type IN ('img2img', 'img2video')
OR params->>'creationMode' IN ('img2img', 'img2video')
OR params->>'workType' IN ('img2img', 'img2video')
OR params->>'referenceImage' IS NOT NULL
OR jsonb_typeof(params->'referenceImages') = 'array'
)
ORDER BY created_at DESC
LIMIT $1`,
[limit],
);
let candidates = 0;
let updated = 0;
let skipped = 0;
for (const row of result.rows) {
const params = row.params || {};
if (!shouldBackfill(params)) {
skipped += 1;
continue;
}
candidates += 1;
if (dryRun) continue;
const references = getReferenceInputs(params);
if (verbose) {
console.log(JSON.stringify({
event: 'backfill-reference-images:start',
id: row.id,
index: candidates,
references: references.length,
}));
}
let persisted;
try {
persisted = await withTimeout(
persistReferenceImages(references),
itemTimeoutMs,
`work ${row.id}`,
);
} catch (error) {
skipped += 1;
console.warn('[backfill-work-reference-images] skipped row:', row.id, error instanceof Error ? error.message : error);
continue;
}
if (persisted.length === 0) {
skipped += 1;
continue;
}
const referenceImages = persisted.map(item => item.url);
const referenceImageThumbnails = persisted.map(item => item.thumbnailUrl || item.url);
await client.query(
`UPDATE works
SET params = $2::jsonb
WHERE id = $1`,
[
row.id,
JSON.stringify({
...params,
referenceImage: referenceImages[0],
referenceImages,
referenceImageThumbnails,
refImageCount: Math.max(Number(params.refImageCount || 0), referenceImages.length),
}),
],
);
updated += 1;
if (verbose) {
console.log(JSON.stringify({
event: 'backfill-reference-images:updated',
id: row.id,
index: candidates,
persisted: persisted.length,
}));
}
}
console.log(JSON.stringify({
dryRun,
scanned: result.rowCount,
candidates,
updated,
skipped,
limit,
}, null, 2));
} finally {
await client.end();
}
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

80
scripts/backup-create.sh Normal file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
REQUESTED_BACKUP_DIR="${BACKUP_DIR:-}"
REQUESTED_LOCAL_DB_URL="${LOCAL_DB_URL:-}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
[ -n "${REQUESTED_LOCAL_DB_URL}" ] && LOCAL_DB_URL="${REQUESTED_LOCAL_DB_URL}"
BACKUP_DIR="${REQUESTED_BACKUP_DIR:-${BACKUP_DIR:-${COZE_WORKSPACE_PATH}/backups}}"
BACKUP_FILE="${BACKUP_DIR}/miaojing-backup-${TIMESTAMP}.tar.gz"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is required in .env.local or environment." >&2
exit 1
fi
command -v pg_dump >/dev/null 2>&1 || {
echo "pg_dump is required to create backups." >&2
exit 1
}
command -v pg_restore >/dev/null 2>&1 || {
echo "pg_restore is required to verify backups." >&2
exit 1
}
pg_dump "${LOCAL_DB_URL}" --format=custom --file "${TMP_DIR}/database.dump"
pg_restore --list "${TMP_DIR}/database.dump" >/dev/null
STORAGE_SOURCE="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
if [ -d "${STORAGE_SOURCE}" ]; then
cp -a "${STORAGE_SOURCE}" "${TMP_DIR}/local-storage"
fi
if [ -f ".env.local" ]; then
cp ".env.local" "${TMP_DIR}/.env.local"
fi
if [ -f "package.json" ]; then
cp "package.json" "${TMP_DIR}/package.json"
fi
cat > "${TMP_DIR}/manifest.json" <<EOF
{
"app": "miaojingAI",
"formatVersion": 2,
"createdAt": "$(date -Iseconds)",
"hostname": "$(hostname)",
"storagePath": "${STORAGE_SOURCE}",
"includes": ["database.dump", "local-storage", ".env.local", "package.json"]
}
EOF
tar -czf "${BACKUP_FILE}" -C "${TMP_DIR}" .
tar -tzf "${BACKUP_FILE}" >/dev/null
chmod 600 "${BACKUP_FILE}"
find "${BACKUP_DIR}" -maxdepth 1 -name 'miaojing-backup-*.tar.gz' -type f \
-printf '%T@ %p\n' | sort -rn | awk 'NR>10 {print $2}' | xargs -r rm -f
echo "${BACKUP_FILE}"

32
scripts/backup-list.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
REQUESTED_BACKUP_DIR="${BACKUP_DIR:-}"
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
BACKUP_DIR="${REQUESTED_BACKUP_DIR:-${BACKUP_DIR:-${COZE_WORKSPACE_PATH}/backups}}"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
if ! compgen -G "${BACKUP_DIR}/miaojing-backup-*.tar.gz" >/dev/null; then
echo "No backups found in ${BACKUP_DIR}"
exit 0
fi
printf '%-40s %-12s %s\n' "FILE" "SIZE" "MODIFIED"
find "${BACKUP_DIR}" -maxdepth 1 -name 'miaojing-backup-*.tar.gz' -type f \
-printf '%T@ %f %s %TY-%Tm-%Td %TH:%TM\n' \
| sort -rn \
| awk '{printf "%-40s %-12s %s %s\n", $2, $3, $4, $5}'

113
scripts/backup-restore.sh Normal file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
BACKUP_FILE="${1:-}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
TMP_DIR="$(mktemp -d)"
RESTORE_SAFETY_DIR="${RESTORE_SAFETY_DIR:-}"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
if [ -z "${BACKUP_FILE}" ]; then
echo "Usage: pnpm backup:restore <backup-file.tar.gz>" >&2
exit 2
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "Backup file not found: ${BACKUP_FILE}" >&2
exit 2
fi
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is required in .env.local or environment." >&2
exit 1
fi
command -v pg_restore >/dev/null 2>&1 || {
echo "pg_restore is required to restore backups." >&2
exit 1
}
command -v pg_dump >/dev/null 2>&1 || {
echo "pg_dump is required to create restore safety backups." >&2
exit 1
}
tar -tzf "${BACKUP_FILE}" >/dev/null
tar -xzf "${BACKUP_FILE}" -C "${TMP_DIR}"
if [ ! -f "${TMP_DIR}/database.dump" ]; then
echo "Invalid backup: missing database.dump." >&2
exit 2
fi
pg_restore --list "${TMP_DIR}/database.dump" >/dev/null
SAFETY_ROOT="${RESTORE_SAFETY_DIR:-${COZE_WORKSPACE_PATH}/backups/restore-safety}"
SAFETY_DIR="${SAFETY_ROOT}/pre-restore-${TIMESTAMP}"
mkdir -p "${SAFETY_DIR}"
chmod 700 "${SAFETY_ROOT}" "${SAFETY_DIR}"
pg_dump "${LOCAL_DB_URL}" --format=custom --file "${SAFETY_DIR}/database-before-restore.dump"
pg_restore --list "${SAFETY_DIR}/database-before-restore.dump" >/dev/null
STORAGE_TARGET="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
if [ -e "${STORAGE_TARGET}" ]; then
mkdir -p "${SAFETY_DIR}/storage-parent"
cp -a "${STORAGE_TARGET}" "${SAFETY_DIR}/storage-parent/$(basename "${STORAGE_TARGET}")"
fi
STORAGE_PARENT="$(dirname "${STORAGE_TARGET}")"
STORAGE_NAME="$(basename "${STORAGE_TARGET}")"
PREVIOUS_STORAGE="${SAFETY_DIR}/${STORAGE_NAME}.previous"
STAGED_STORAGE="${TMP_DIR}/${STORAGE_NAME}.staged"
if [ -d "${TMP_DIR}/local-storage" ]; then
rm -rf "${STAGED_STORAGE}"
cp -a "${TMP_DIR}/local-storage" "${STAGED_STORAGE}"
fi
if [ -f ".env.local" ]; then
cp ".env.local" "${SAFETY_DIR}/.env.local.before-restore"
chmod 600 "${SAFETY_DIR}/.env.local.before-restore"
fi
pg_restore --clean --if-exists --no-owner --single-transaction --dbname "${LOCAL_DB_URL}" "${TMP_DIR}/database.dump"
if [ -d "${TMP_DIR}/local-storage" ]; then
mkdir -p "${STORAGE_PARENT}"
if [ -e "${STORAGE_TARGET}" ]; then
mv "${STORAGE_TARGET}" "${PREVIOUS_STORAGE}"
fi
if ! mv "${STAGED_STORAGE}" "${STORAGE_PARENT}/${STORAGE_NAME}"; then
rm -rf "${STORAGE_PARENT:?}/${STORAGE_NAME}"
if [ -e "${PREVIOUS_STORAGE}" ]; then
mv "${PREVIOUS_STORAGE}" "${STORAGE_PARENT}/${STORAGE_NAME}"
fi
echo "Storage restore failed; previous storage was restored." >&2
exit 1
fi
fi
if [ -f "${TMP_DIR}/.env.local" ]; then
cp "${TMP_DIR}/.env.local" ".env.local.restore-next"
mv ".env.local.restore-next" ".env.local"
chmod 600 ".env.local"
fi
find "${SAFETY_ROOT}" -maxdepth 1 -type d -name 'pre-restore-*' \
-printf '%T@ %p\n' | sort -rn | awk 'NR>10 {print $2}' | xargs -r rm -rf
echo "Restore completed from ${BACKUP_FILE}"
echo "Pre-restore safety backup: ${SAFETY_DIR}"

21
scripts/build.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
cd "${COZE_WORKSPACE_PATH}"
if [ "${INSTALL_DEPS:-0}" = "1" ] || [ ! -d node_modules ]; then
echo "Installing dependencies..."
pnpm install --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
else
echo "Skipping dependency install. Set INSTALL_DEPS=1 to force it."
fi
echo "Building the Next.js project..."
pnpm next build
echo "Bundling server with tsup..."
pnpm tsup src/server.ts --format cjs --platform node --target node20 --outDir dist --no-splitting --no-minify
echo "Build completed successfully!"

View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -Eeuo pipefail
fail=0
search_pattern() {
local pattern="$1"
shift
if command -v rg >/dev/null 2>&1; then
rg -n "$pattern" "$@" || true
else
grep -RInE "$pattern" "$@" || true
fi
}
check_no_match() {
local label="$1"
local pattern="$2"
shift 2
local output
output="$(search_pattern "$pattern" "$@")"
if [ -n "$output" ]; then
echo "Boundary violation: ${label}" >&2
echo "$output" >&2
fail=1
fi
}
check_no_match \
"web module must not import server database/storage internals" \
"@/storage|@/lib/local-storage|@/lib/session-auth|@/lib/admin-auth|@/lib/runtime-env|@/lib/server-crypto" \
src/modules/web
check_no_match \
"console module must not import server database/storage internals directly" \
"@/storage|@/lib/local-storage|@/lib/runtime-env|@/lib/server-crypto" \
src/modules/console
check_no_match \
"shared module must not depend on app-specific modules" \
"@/modules/(web|console|api)|@/app/|@/components/admin" \
src/modules/shared
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "Module boundaries OK"

View File

@@ -0,0 +1,169 @@
-- Idempotent local PostgreSQL patch for production maintenance.
-- It creates missing application tables and adds indexes used by hot paths.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE TABLE IF NOT EXISTS works (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
title VARCHAR(255),
type VARCHAR(32) NOT NULL,
prompt TEXT,
negative_prompt TEXT,
params JSONB DEFAULT '{}'::jsonb,
result_url TEXT,
thumbnail_url TEXT,
width INTEGER,
height INTEGER,
duration NUMERIC(6, 2),
is_public BOOLEAN NOT NULL DEFAULT false,
likes_count INTEGER NOT NULL DEFAULT 0,
credits_cost INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'completed',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
order_no VARCHAR(64) NOT NULL UNIQUE,
product_type VARCHAR(32) NOT NULL,
product_name VARCHAR(255) NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
credits_amount INTEGER,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
payment_method VARCHAR(32),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
provider VARCHAR(64) NOT NULL,
api_url TEXT,
model_name VARCHAR(128),
api_key_encrypted TEXT NOT NULL,
api_key_preview VARCHAR(20),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS work_likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
work_id UUID NOT NULL REFERENCES works(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS generation_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
error TEXT,
user_id UUID,
provider VARCHAR(128),
model_name VARCHAR(255),
api_url TEXT,
progress JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS provider VARCHAR(128);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS model_name VARCHAR(255);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS api_url TEXT;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS works_user_created_idx ON works (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS works_public_status_created_idx ON works (is_public, status, created_at DESC);
CREATE INDEX IF NOT EXISTS works_public_status_likes_idx ON works (is_public, status, likes_count DESC);
CREATE INDEX IF NOT EXISTS works_type_created_idx ON works (type, created_at DESC);
CREATE INDEX IF NOT EXISTS works_status_created_idx ON works (status, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_transactions_user_created_idx ON credit_transactions (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_transactions_type_created_idx ON credit_transactions (type, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_user_created_idx ON orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_status_created_idx ON orders (status, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_order_no_idx ON orders (order_no);
CREATE INDEX IF NOT EXISTS user_api_keys_user_active_idx ON user_api_keys (user_id, is_active);
CREATE INDEX IF NOT EXISTS user_api_keys_provider_idx ON user_api_keys (provider);
CREATE INDEX IF NOT EXISTS work_likes_user_id_idx ON work_likes (user_id);
CREATE INDEX IF NOT EXISTS work_likes_work_id_idx ON work_likes (work_id);
CREATE UNIQUE INDEX IF NOT EXISTS work_likes_user_work_uniq ON work_likes (user_id, work_id);
CREATE INDEX IF NOT EXISTS announcements_active_window_idx ON announcements (is_active, starts_at, expires_at);
CREATE INDEX IF NOT EXISTS profiles_email_trgm_idx ON profiles USING GIN (LOWER(email) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS profiles_nickname_trgm_idx ON profiles USING GIN (LOWER(COALESCE(nickname, '')) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS profiles_phone_trgm_idx ON profiles USING GIN (LOWER(COALESCE(phone, '')) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS generation_jobs_status_created_idx ON generation_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_status_updated_idx ON generation_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_running_timeout_idx ON generation_jobs (updated_at) WHERE status = 'running';
CREATE INDEX IF NOT EXISTS generation_jobs_created_idx ON generation_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
CREATE TABLE IF NOT EXISTS platform_log_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
retention_days INTEGER NOT NULL DEFAULT 30,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO platform_log_settings (id, retention_days)
VALUES (1, 30)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS platform_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(32) NOT NULL,
level VARCHAR(16) NOT NULL DEFAULT 'info',
action VARCHAR(128) NOT NULL,
message TEXT NOT NULL,
user_id UUID,
user_name VARCHAR(255),
user_email VARCHAR(255),
target_type VARCHAR(64),
target_id VARCHAR(255),
ip_address VARCHAR(64),
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS platform_logs_type_created_idx ON platform_logs (type, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_level_created_idx ON platform_logs (level, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_created_idx ON platform_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_created_idx ON platform_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_name_idx ON platform_logs (LOWER(COALESCE(user_name, '')));
CREATE INDEX IF NOT EXISTS platform_logs_user_email_idx ON platform_logs (LOWER(COALESCE(user_email, '')));
DROP POLICY IF EXISTS "site_config_write_auth" ON site_config;
DROP POLICY IF EXISTS "announcements_write_auth" ON announcements;
DROP POLICY IF EXISTS "site_stats_write_auth" ON site_stats;
DROP POLICY IF EXISTS "site_config_admin_write" ON site_config;
DROP POLICY IF EXISTS "announcements_admin_write" ON announcements;
CREATE POLICY "site_config_admin_write" ON site_config FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "announcements_admin_write" ON announcements FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
ANALYZE;

1213
scripts/deploy-or-upgrade.sh Normal file

File diff suppressed because it is too large Load Diff

34
scripts/dev.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -Eeuo pipefail
PORT=${1:-5000}
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
DEPLOY_RUN_PORT=$PORT
cd "${COZE_WORKSPACE_PATH}"
kill_port_if_listening() {
local pids
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${DEPLOY_RUN_PORT}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | paste -sd' ' - || true)
if [[ -z "${pids}" ]]; then
echo "Port ${DEPLOY_RUN_PORT} is free."
return
fi
echo "Port ${DEPLOY_RUN_PORT} in use by PIDs: ${pids} (SIGKILL)"
echo "${pids}" | xargs -I {} kill -9 {}
sleep 1
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${DEPLOY_RUN_PORT}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | paste -sd' ' - || true)
if [[ -n "${pids}" ]]; then
echo "Warning: port ${DEPLOY_RUN_PORT} still busy after SIGKILL, PIDs: ${pids}"
else
echo "Port ${DEPLOY_RUN_PORT} cleared."
fi
}
echo "Clearing port ${PORT} before start."
kill_port_if_listening
echo "Starting HTTP service on port ${PORT} for dev..."
PORT=$PORT pnpm tsx watch src/server.ts

781
scripts/init-database.sql Normal file
View File

@@ -0,0 +1,781 @@
-- ============================================================
-- 妙境 AI 创作平台 — 数据库初始化脚本
-- 适用于: PostgreSQL 14+ (Supabase / 自托管)
-- 执行方式: 在 Supabase SQL Editor 或 psql 中运行
-- ============================================================
-- 0. 启用必要扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 1. 创建 auth 模式和 users 表
CREATE SCHEMA IF NOT EXISTS auth;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'auth' AND p.proname = 'uid'
) THEN
EXECUTE 'CREATE FUNCTION auth.uid() RETURNS UUID AS $fn$ SELECT NULLIF(current_setting(''request.jwt.claim.sub'', true), '''')::UUID; $fn$ LANGUAGE SQL STABLE';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'auth' AND p.proname = 'role'
) THEN
EXECUTE 'CREATE FUNCTION auth.role() RETURNS TEXT AS $fn$ SELECT COALESCE(NULLIF(current_setting(''request.jwt.claim.role'', true), ''''), ''anon''); $fn$ LANGUAGE SQL STABLE';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE,
password_hash TEXT,
raw_user_meta_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS auth_users_email_idx ON auth.users (email);
-- ============================================================
-- 1. 用户资料表 (profiles)
-- 与 Supabase Auth 的 auth.users 表关联
-- ============================================================
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(128),
display_nickname VARCHAR(128),
avatar_url TEXT,
phone VARCHAR(20),
role VARCHAR(32) NOT NULL DEFAULT 'user', -- guest, user, vip, enterprise_admin, enterprise_member, admin
membership_tier VARCHAR(32) NOT NULL DEFAULT 'free', -- free, basic, pro, enterprise
membership_expires_at TIMESTAMPTZ,
credits_balance INTEGER NOT NULL DEFAULT 0,
invite_code VARCHAR(32),
referred_by_user_id UUID,
daily_quota_used INTEGER NOT NULL DEFAULT 0,
daily_quota_limit INTEGER NOT NULL DEFAULT 5,
is_active BOOLEAN NOT NULL DEFAULT true,
email_verified BOOLEAN NOT NULL DEFAULT false,
email_verified_at TIMESTAMPTZ,
email_bound_at TIMESTAMPTZ,
email_sender_domain VARCHAR(255),
preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
watermark_disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS profiles_email_idx ON profiles (email);
CREATE INDEX IF NOT EXISTS profiles_role_idx ON profiles (role);
CREATE UNIQUE INDEX IF NOT EXISTS profiles_invite_code_unique_idx ON profiles (invite_code) WHERE invite_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS profiles_referred_by_user_id_idx ON profiles (referred_by_user_id);
-- ============================================================
-- 2. 创作作品表 (works)
-- ============================================================
CREATE TABLE IF NOT EXISTS works (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
title VARCHAR(255),
type VARCHAR(32) NOT NULL, -- text2img, img2img, text2video, img2video
prompt TEXT,
negative_prompt TEXT,
params JSONB, -- 生成参数 (画面比例、分辨率、模型等)
result_url TEXT, -- 生成文件的 URL
thumbnail_url TEXT,
width INTEGER,
height INTEGER,
duration NUMERIC(6, 2), -- 视频时长 (秒)
is_public BOOLEAN NOT NULL DEFAULT false,
likes_count INTEGER NOT NULL DEFAULT 0,
views_count INTEGER NOT NULL DEFAULT 0,
credits_cost INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'completed', -- pending, processing, completed, failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS works_user_id_idx ON works (user_id);
CREATE INDEX IF NOT EXISTS works_type_idx ON works (type);
CREATE INDEX IF NOT EXISTS works_is_public_idx ON works (is_public);
CREATE INDEX IF NOT EXISTS works_created_at_idx ON works (created_at);
CREATE INDEX IF NOT EXISTS works_status_idx ON works (status);
-- ============================================================
-- 3. 积分记录表 (credit_transactions)
-- ============================================================
CREATE TABLE IF NOT EXISTS credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
amount INTEGER NOT NULL, -- 正数=入账, 负数=消费
balance_after INTEGER NOT NULL,
type VARCHAR(32) NOT NULL, -- purchase, consume, gift, reward, refund
description VARCHAR(500),
related_work_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS credit_transactions_user_id_idx ON credit_transactions (user_id);
CREATE INDEX IF NOT EXISTS credit_transactions_type_idx ON credit_transactions (type);
CREATE INDEX IF NOT EXISTS credit_transactions_created_at_idx ON credit_transactions (created_at);
-- ============================================================
-- 4. 邀请注册记录表 (invitation_referrals)
-- ============================================================
CREATE TABLE IF NOT EXISTS invitation_referrals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invite_code VARCHAR(32) NOT NULL,
inviter_user_id UUID NOT NULL,
invitee_user_id UUID NOT NULL UNIQUE,
inviter_bonus_credits INTEGER NOT NULL DEFAULT 50,
invitee_bonus_credits INTEGER NOT NULL DEFAULT 50,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS invitation_referrals_inviter_idx ON invitation_referrals (inviter_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS invitation_referrals_invitee_idx ON invitation_referrals (invitee_user_id);
CREATE INDEX IF NOT EXISTS invitation_referrals_created_at_idx ON invitation_referrals (created_at DESC);
-- ============================================================
-- 5. 兑换码表 (redeem_codes)
-- ============================================================
CREATE TABLE IF NOT EXISTS redeem_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(64) NOT NULL UNIQUE,
normalized_code VARCHAR(64) NOT NULL UNIQUE,
code_type VARCHAR(16) NOT NULL DEFAULT 'credits',
credits_amount INTEGER NOT NULL DEFAULT 0,
membership_tier VARCHAR(32),
membership_duration_value INTEGER,
membership_duration_unit VARCHAR(16),
batch_id UUID NOT NULL DEFAULT gen_random_uuid(),
note VARCHAR(255) NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT true,
created_by UUID,
used_by UUID,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_credits_amount_check;
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_payload_check;
ALTER TABLE redeem_codes
ADD CONSTRAINT redeem_codes_payload_check CHECK (
(code_type = 'credits' AND credits_amount > 0)
OR (
code_type = 'membership'
AND credits_amount >= 0
AND membership_tier IN ('pro', 'max', 'ultra', 'enterprise')
AND membership_duration_value > 0
AND membership_duration_unit IN ('day', 'month', 'year')
)
);
CREATE INDEX IF NOT EXISTS redeem_codes_created_at_idx ON redeem_codes (created_at DESC);
CREATE INDEX IF NOT EXISTS redeem_codes_batch_id_idx ON redeem_codes (batch_id);
CREATE INDEX IF NOT EXISTS redeem_codes_used_by_idx ON redeem_codes (used_by);
CREATE INDEX IF NOT EXISTS redeem_codes_status_idx ON redeem_codes (is_active, used_at);
CREATE INDEX IF NOT EXISTS redeem_codes_type_idx ON redeem_codes (code_type);
-- ============================================================
-- 5. 订单表 (orders)
-- ============================================================
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
order_no VARCHAR(64) NOT NULL UNIQUE,
product_type VARCHAR(32) NOT NULL, -- membership, credits, api
product_name VARCHAR(255) NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
credits_amount INTEGER, -- 购买的积分数
status VARCHAR(32) NOT NULL DEFAULT 'pending', -- pending, paid, cancelled, refunded
payment_method VARCHAR(32), -- wechat, alipay, stripe
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS orders_user_id_idx ON orders (user_id);
CREATE INDEX IF NOT EXISTS orders_order_no_idx ON orders (order_no);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status);
CREATE INDEX IF NOT EXISTS orders_created_at_idx ON orders (created_at);
-- ============================================================
-- 5. 生成任务队列表 (generation_jobs)
-- ============================================================
CREATE TABLE IF NOT EXISTS generation_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
error TEXT,
user_id UUID,
provider VARCHAR(128),
model_name VARCHAR(255),
api_url TEXT,
progress JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS generation_jobs_status_created_idx ON generation_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_status_updated_idx ON generation_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_running_timeout_idx ON generation_jobs (updated_at) WHERE status = 'running';
CREATE INDEX IF NOT EXISTS generation_jobs_created_idx ON generation_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
-- ============================================================
-- 6. 用户自定义 API 密钥表 (user_api_keys)
-- ============================================================
CREATE TABLE IF NOT EXISTS user_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
provider VARCHAR(64) NOT NULL, -- openai, stabilityai, runway, etc.
api_url TEXT, -- 完整 API 端点 URL
model_name VARCHAR(128), -- 具体模型名称
api_key_encrypted TEXT NOT NULL, -- 加密存储的 API Key
api_key_preview VARCHAR(20), -- Key 尾号 (如 sk-...4f3e)
supplier_name VARCHAR(128),
note TEXT NOT NULL DEFAULT '',
manifest_path TEXT,
type VARCHAR(16) NOT NULL DEFAULT 'image',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS user_api_keys_user_id_idx ON user_api_keys (user_id);
CREATE INDEX IF NOT EXISTS user_api_keys_provider_idx ON user_api_keys (provider);
-- ============================================================
-- 7. 作品点赞表 (work_likes)
-- ============================================================
CREATE TABLE IF NOT EXISTS work_likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
work_id UUID NOT NULL REFERENCES works(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS work_likes_user_id_idx ON work_likes (user_id);
CREATE INDEX IF NOT EXISTS work_likes_work_id_idx ON work_likes (work_id);
-- 唯一约束:每个用户对每个作品只能点赞一次
CREATE UNIQUE INDEX IF NOT EXISTS work_likes_user_work_uniq ON work_likes (user_id, work_id);
-- ============================================================
-- 8. 网站配置表 (site_config)
-- ============================================================
CREATE TABLE IF NOT EXISTS site_config (
id INTEGER PRIMARY KEY DEFAULT 1,
site_name VARCHAR(128) NOT NULL DEFAULT '妙境',
site_tab_title VARCHAR(255) NOT NULL DEFAULT '妙境 - AI创作平台',
site_description TEXT NOT NULL DEFAULT '',
site_keywords TEXT NOT NULL DEFAULT '',
logo_url TEXT,
favicon_url TEXT,
announcement TEXT NOT NULL DEFAULT '',
membership_enabled BOOLEAN NOT NULL DEFAULT TRUE,
terms_of_service TEXT NOT NULL DEFAULT '',
privacy_policy TEXT NOT NULL DEFAULT '',
about_us TEXT NOT NULL DEFAULT '',
help_center TEXT NOT NULL DEFAULT '',
filing_info TEXT NOT NULL DEFAULT '',
filing_url TEXT NOT NULL DEFAULT '',
public_security_filing_info TEXT NOT NULL DEFAULT '',
public_security_filing_url TEXT NOT NULL DEFAULT '',
redeem_code_mall_url TEXT NOT NULL DEFAULT '',
log_retention_days INTEGER NOT NULL DEFAULT 30,
image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
-- 插入默认配置
INSERT INTO site_config (id, site_name, site_tab_title)
VALUES (1, '妙境', '妙境 - AI创作平台')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 公告表 (announcements)
-- ============================================================
CREATE TABLE IF NOT EXISTS announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL, -- 支持 Markdown
type VARCHAR(32) NOT NULL DEFAULT 'site',
is_active BOOLEAN NOT NULL DEFAULT true,
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS announcements_is_active_idx ON announcements (is_active);
CREATE INDEX IF NOT EXISTS announcements_expires_at_idx ON announcements (expires_at);
-- ============================================================
-- 10. 网站统计表 (site_stats)
-- ============================================================
CREATE TABLE IF NOT EXISTS site_stats (
id INTEGER PRIMARY KEY DEFAULT 1,
total_visits BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO site_stats (id, total_visits) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 11. 平台日志
-- ============================================================
CREATE TABLE IF NOT EXISTS platform_log_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
retention_days INTEGER NOT NULL DEFAULT 30,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO platform_log_settings (id, retention_days)
VALUES (1, 30)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS platform_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(32) NOT NULL,
level VARCHAR(16) NOT NULL DEFAULT 'info',
action VARCHAR(128) NOT NULL,
message TEXT NOT NULL,
user_id UUID,
user_name VARCHAR(255),
user_email VARCHAR(255),
target_type VARCHAR(64),
target_id VARCHAR(255),
ip_address VARCHAR(64),
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS platform_logs_type_created_idx ON platform_logs (type, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_level_created_idx ON platform_logs (level, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_created_idx ON platform_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_created_idx ON platform_logs (created_at DESC);
-- ============================================================
-- 12. API 供应商与推荐模型配置
-- ============================================================
CREATE TABLE IF NOT EXISTS api_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(128) NOT NULL UNIQUE,
default_api_url TEXT,
default_model VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
website TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS model_recommendations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_name VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
provider_id UUID REFERENCES api_providers(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS api_providers_active_sort_idx ON api_providers (is_active, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_active_type_sort_idx ON model_recommendations (is_active, type, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_provider_idx ON model_recommendations (provider_id);
CREATE TABLE IF NOT EXISTS image_style_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label VARCHAR(128) NOT NULL UNIQUE,
prompt TEXT NOT NULL,
usage_count INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS image_style_presets_active_usage_idx ON image_style_presets (is_active, usage_count DESC, sort_order ASC);
INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
VALUES
('硅基流动', 'https://api.siliconflow.cn/v1/images/generations', 'black-forest-labs/FLUX.1-schnell', 'image', 'https://cloud.siliconflow.cn', true, 10),
('mozheAPI', 'https://openai.mozhevip.top', '', 'image', 'https://openai.mozhevip.top', true, 20),
('New API', 'https://your-newapi-domain.com/v1/images/generations', 'gpt-image-1', 'image', 'https://docs.newapi.pro', true, 25),
('OpenAI', 'https://api.openai.com/v1/images/generations', 'dall-e-3', 'image', NULL, true, 30),
('Stability AI', 'https://api.stability.ai/v1/generation/stable-diffusion-xl/text-to-image', 'stable-diffusion-xl', 'image', NULL, true, 40),
('Midjourney', '', 'midjourney-v6', 'image', NULL, true, 50),
('Runway', 'https://api.runwayml.com/v1/image_to_video', 'gen-3-alpha', 'video', NULL, true, 60),
('Pika', '', 'pika-1.0', 'video', NULL, true, 70),
('Kling', '', 'kling-v1', 'video', NULL, true, 80),
('DeepSeek', 'https://api.deepseek.com/v1/chat/completions', 'deepseek-chat', 'text', NULL, true, 90),
('OpenAI GPT', 'https://api.openai.com/v1/chat/completions', 'gpt-4o', 'text', NULL, true, 100),
('自定义', '', '', 'image', NULL, true, 999)
ON CONFLICT (name) DO NOTHING;
INSERT INTO model_recommendations (model_name, display_name, type, provider_id, is_active, sort_order)
SELECT 'gpt-image-2', 'gpt-image-2', 'image', NULL, true, 10
WHERE NOT EXISTS (
SELECT 1 FROM model_recommendations
WHERE model_name = 'gpt-image-2' AND type = 'image' AND provider_id IS NULL
);
-- ============================================================
-- 兼容旧版本库结构的幂等补丁
-- ============================================================
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128),
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false;
UPDATE profiles
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
WHERE display_nickname IS NULL OR display_nickname = '';
UPDATE profiles
SET preferred_theme = 'dark'
WHERE preferred_theme IS NULL
OR preferred_theme NOT IN ('dark', 'light');
ALTER TABLE works
ADD COLUMN IF NOT EXISTS views_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
ALTER TABLE user_api_keys
ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128),
ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS manifest_path TEXT,
ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE site_config
ADD COLUMN IF NOT EXISTS site_description TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS site_keywords TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS privacy_policy TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS about_us TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS help_center TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS filing_info TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS redeem_code_mall_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30,
ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE generation_jobs
ADD COLUMN IF NOT EXISTS user_id UUID,
ADD COLUMN IF NOT EXISTS provider VARCHAR(128),
ADD COLUMN IF NOT EXISTS model_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS api_url TEXT,
ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
CREATE TABLE IF NOT EXISTS system_api_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(128),
name VARCHAR(255) NOT NULL,
api_url TEXT NOT NULL DEFAULT '',
model_name VARCHAR(255) NOT NULL,
note TEXT NOT NULL DEFAULT '',
manifest_path TEXT NOT NULL DEFAULT '',
is_default BOOLEAN NOT NULL DEFAULT true,
allowed_membership_tiers JSONB NOT NULL DEFAULT '["free","pro","max","ultra"]'::jsonb,
polling_mode VARCHAR(16) NOT NULL DEFAULT 'sequential',
polling_order INTEGER NOT NULL DEFAULT 0,
api_key_encrypted TEXT NOT NULL DEFAULT '',
api_key_preview VARCHAR(64) NOT NULL DEFAULT '',
type VARCHAR(16) NOT NULL DEFAULT 'image',
credits_per_use INTEGER NOT NULL DEFAULT 10,
billing_mode VARCHAR(24) NOT NULL DEFAULT 'fixed',
fixed_price NUMERIC(12, 4) NOT NULL DEFAULT 0,
duration_price_per_second NUMERIC(12, 6) NOT NULL DEFAULT 0,
input_price_per_1k NUMERIC(12, 6) NOT NULL DEFAULT 0,
output_price_per_1k NUMERIC(12, 6) NOT NULL DEFAULT 0,
model_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
completion_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
group_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
price_note TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order);
CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order);
CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order);
CREATE TABLE IF NOT EXISTS payment_methods (
id VARCHAR(64) PRIMARY KEY,
type VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
public_config JSONB NOT NULL DEFAULT '{}'::jsonb,
secret_config_encrypted JSONB NOT NULL DEFAULT '{}'::jsonb,
secret_config_preview JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
INSERT INTO payment_methods (id, type, name, is_active) VALUES
('pm-alipay', 'alipay', '支付宝', true),
('pm-wechat', 'wechat', '微信支付', false),
('pm-manual', 'manual', '手动转账', false),
('pm-stripe', 'stripe', 'Stripe', false)
ON CONFLICT (id) DO NOTHING;
CREATE INDEX IF NOT EXISTS platform_logs_user_name_idx ON platform_logs (LOWER(COALESCE(user_name, '')));
CREATE INDEX IF NOT EXISTS platform_logs_user_email_idx ON platform_logs (LOWER(COALESCE(user_email, '')));
ALTER TABLE announcements
ADD COLUMN IF NOT EXISTS type VARCHAR(32) NOT NULL DEFAULT 'site';
ALTER TABLE platform_log_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE platform_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_providers ENABLE ROW LEVEL SECURITY;
ALTER TABLE model_recommendations ENABLE ROW LEVEL SECURITY;
ALTER TABLE system_api_configs ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_methods ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- Row Level Security (RLS) 策略
-- ============================================================
-- 启用所有表的 RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE works ENABLE ROW LEVEL SECURITY;
ALTER TABLE credit_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE redeem_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_api_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_likes ENABLE ROW LEVEL SECURITY;
ALTER TABLE site_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE announcements ENABLE ROW LEVEL SECURITY;
ALTER TABLE site_stats ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "profiles_read_own" ON profiles;
DROP POLICY IF EXISTS "profiles_update_own" ON profiles;
DROP POLICY IF EXISTS "profiles_admin_all" ON profiles;
DROP POLICY IF EXISTS "works_read_public" ON works;
DROP POLICY IF EXISTS "works_insert_own" ON works;
DROP POLICY IF EXISTS "works_update_own" ON works;
DROP POLICY IF EXISTS "works_delete_own" ON works;
DROP POLICY IF EXISTS "works_admin_all" ON works;
DROP POLICY IF EXISTS "credit_transactions_read_own" ON credit_transactions;
DROP POLICY IF EXISTS "credit_transactions_admin_all" ON credit_transactions;
DROP POLICY IF EXISTS "redeem_codes_admin_all" ON redeem_codes;
DROP POLICY IF EXISTS "orders_read_own" ON orders;
DROP POLICY IF EXISTS "orders_insert_own" ON orders;
DROP POLICY IF EXISTS "orders_admin_all" ON orders;
DROP POLICY IF EXISTS "user_api_keys_read_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_insert_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_update_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_delete_own" ON user_api_keys;
DROP POLICY IF EXISTS "work_likes_read_all" ON work_likes;
DROP POLICY IF EXISTS "work_likes_insert_own" ON work_likes;
DROP POLICY IF EXISTS "work_likes_delete_own" ON work_likes;
DROP POLICY IF EXISTS "site_config_read_all" ON site_config;
DROP POLICY IF EXISTS "site_config_write_auth" ON site_config;
DROP POLICY IF EXISTS "site_config_admin_write" ON site_config;
DROP POLICY IF EXISTS "announcements_read_all" ON announcements;
DROP POLICY IF EXISTS "announcements_write_auth" ON announcements;
DROP POLICY IF EXISTS "announcements_admin_write" ON announcements;
DROP POLICY IF EXISTS "site_stats_read_all" ON site_stats;
DROP POLICY IF EXISTS "site_stats_write_auth" ON site_stats;
-- profiles: 用户可读自己的资料,管理员可读写所有
CREATE POLICY "profiles_read_own" ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "profiles_update_own" ON profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "profiles_admin_all" ON profiles FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- works: 用户可管理自己的作品,公开作品所有人可读
CREATE POLICY "works_read_public" ON works FOR SELECT USING (is_public = true OR auth.uid() = user_id);
CREATE POLICY "works_insert_own" ON works FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "works_update_own" ON works FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "works_delete_own" ON works FOR DELETE USING (auth.uid() = user_id);
CREATE POLICY "works_admin_all" ON works FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- credit_transactions: 用户可读自己的记录
CREATE POLICY "credit_transactions_read_own" ON credit_transactions FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "credit_transactions_admin_all" ON credit_transactions FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- redeem_codes: 只有管理员可直接管理,用户兑换走后端服务事务
CREATE POLICY "redeem_codes_admin_all" ON redeem_codes FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- orders: 用户可读自己的订单
CREATE POLICY "orders_read_own" ON orders FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "orders_insert_own" ON orders FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "orders_admin_all" ON orders FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- user_api_keys: 用户可管理自己的密钥
CREATE POLICY "user_api_keys_read_own" ON user_api_keys FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "user_api_keys_insert_own" ON user_api_keys FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "user_api_keys_update_own" ON user_api_keys FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "user_api_keys_delete_own" ON user_api_keys FOR DELETE USING (auth.uid() = user_id);
-- work_likes: 认证用户可点赞,所有人可读
CREATE POLICY "work_likes_read_all" ON work_likes FOR SELECT USING (true);
CREATE POLICY "work_likes_insert_own" ON work_likes FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "work_likes_delete_own" ON work_likes FOR DELETE USING (auth.uid() = user_id);
-- site_config: 所有人可读,认证用户可写 (管理员操作通过 service role key)
CREATE POLICY "site_config_read_all" ON site_config FOR SELECT USING (true);
CREATE POLICY "site_config_admin_write" ON site_config FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- announcements: 所有人可读,认证用户可写 (管理员操作)
CREATE POLICY "announcements_read_all" ON announcements FOR SELECT USING (true);
CREATE POLICY "announcements_admin_write" ON announcements FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- site_stats: 公开读,访问量递增走 SECURITY DEFINER 函数
CREATE POLICY "site_stats_read_all" ON site_stats FOR SELECT USING (true);
-- ============================================================
-- Supabase Storage 桶 (通过 Supabase Dashboard 或 API 创建)
-- ============================================================
-- 需要在 Supabase Dashboard 中手动创建以下 Storage 桶:
-- 1. site-assets (公开读) — 存放网站 Logo、Favicon
-- 2. works (私有) — 存放用户生成的图片/视频文件
--
-- 或者通过 SQL (需要 service_role 权限):
-- INSERT INTO storage.buckets (id, name, public) VALUES ('site-assets', 'site-assets', true) ON CONFLICT DO NOTHING;
-- INSERT INTO storage.buckets (id, name, public) VALUES ('works', 'works', false) ON CONFLICT DO NOTHING;
-- ============================================================
-- 触发器: 自动更新 updated_at 字段
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
DROP TRIGGER IF EXISTS works_updated_at ON works;
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
DROP TRIGGER IF EXISTS user_api_keys_updated_at ON user_api_keys;
DROP TRIGGER IF EXISTS site_config_updated_at ON site_config;
DROP TRIGGER IF EXISTS announcements_updated_at ON announcements;
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER works_updated_at BEFORE UPDATE ON works FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER user_api_keys_updated_at BEFORE UPDATE ON user_api_keys FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER site_config_updated_at BEFORE UPDATE ON site_config FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER announcements_updated_at BEFORE UPDATE ON announcements FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ============================================================
-- 触发器: 新用户注册时自动创建 profile
-- (仅在使用 Supabase Auth 时生效)
-- ============================================================
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO profiles (id, email, nickname, display_nickname, avatar_url, role, membership_tier, credits_balance, daily_quota_limit)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
COALESCE(NEW.raw_user_meta_data->>'display_nickname', NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
NEW.raw_user_meta_data->>'avatar_url',
'user',
'free',
10, -- 新用户赠送 10 积分
5 -- 每日配额 5 次
)
ON CONFLICT (id) DO NOTHING;
-- 记录注册赠送积分
INSERT INTO credit_transactions (user_id, amount, balance_after, type, description)
SELECT NEW.id, 10, 10, 'gift', '新用户注册奖励'
WHERE NOT EXISTS (
SELECT 1 FROM credit_transactions
WHERE user_id = NEW.id AND type = 'gift' AND description = '新用户注册奖励'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- ============================================================
-- 初始化管理员账户 (可选)
-- 请在注册管理员后,手动执行以下 SQL 将角色设为 admin:
-- UPDATE profiles SET role = 'admin' WHERE email = 'your-admin@example.com';
-- ============================================================
-- ============================================================
-- 原子递增访问量的 SQL 函数
-- ============================================================
CREATE OR REPLACE FUNCTION increment_visits()
RETURNS BIGINT AS $$
DECLARE
new_count BIGINT;
BEGIN
UPDATE site_stats SET total_visits = total_visits + 1, updated_at = now() WHERE id = 1
RETURNING total_visits INTO new_count;
RETURN new_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 完成
SELECT 'Database initialization completed successfully!' AS status;

View File

@@ -0,0 +1,62 @@
export function getMigrationCheckBaseUrl(env = process.env) {
const explicit = String(env.MIGRATION_CHECK_BASE_URL || env.APP_BASE_URL || '').trim();
if (explicit) return explicit.replace(/\/+$/, '');
const port = String(env.MIGRATION_CHECK_WEB_PORT || env.WEB_PORT || env.PORT || '8000').trim();
return `http://127.0.0.1:${port}`;
}
export function getMigrationStorageUrlTimeoutMs(env = process.env) {
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_TIMEOUT_MS || 10_000);
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 60_000) : 10_000;
}
export function getMigrationStorageUrlConcurrency(env = process.env) {
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_CONCURRENCY || 8);
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 20) : 8;
}
export async function checkStorageUrl(baseUrl, storageUrl, options = {}) {
const timeoutMs = Number(options.timeoutMs || 10_000);
const fetchImpl = options.fetchImpl || fetch;
const targetUrl = `${baseUrl}${storageUrl}`;
try {
const response = await fetchImpl(targetUrl, {
method: 'HEAD',
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
});
await response.body?.cancel?.();
if (isReachableStorageResponse(response)) {
return { ok: true };
}
if (response.status !== 405) {
return { ok: false, error: `HTTP ${response.status}` };
}
const fallback = await fetchImpl(targetUrl, {
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
});
await fallback.body?.cancel?.();
if (!isReachableStorageResponse(fallback)) {
return { ok: false, error: `HTTP ${fallback.status}` };
}
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
function isReachableStorageResponse(response) {
if (response.ok) return true;
return response.status >= 300
&& response.status < 400
&& Boolean(response.headers?.get?.('location'));
}

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
import fs from 'fs';
import { Pool } from 'pg';
import {
checkStorageUrl,
getMigrationCheckBaseUrl,
getMigrationStorageUrlConcurrency,
getMigrationStorageUrlTimeoutMs,
} from './migration-integrity-check-helpers.mjs';
loadEnvFile('.env.local');
const connectionString = process.env.LOCAL_DB_URL;
if (!connectionString) {
console.error('LOCAL_DB_URL is required');
process.exit(1);
}
const baseUrl = getMigrationCheckBaseUrl();
const maxStorageUrls = Number(process.env.MIGRATION_CHECK_STORAGE_URL_LIMIT || 200);
const storageUrlTimeoutMs = getMigrationStorageUrlTimeoutMs();
const storageUrlConcurrency = getMigrationStorageUrlConcurrency();
const pool = new Pool({ connectionString, max: 2 });
const checks = [];
try {
await collectChecks();
const blockers = checks.filter(check => check.severity === 'blocker' && check.value > 0);
const warnings = checks.filter(check => check.severity === 'warning' && check.value > 0);
console.log(JSON.stringify({
ok: blockers.length === 0,
baseUrl,
checkedAt: new Date().toISOString(),
blockers,
warnings,
checks,
}, null, 2));
process.exit(blockers.length === 0 ? 0 : 1);
} finally {
await pool.end().catch(() => undefined);
}
async function collectChecks() {
await scalar('profiles_total', 'info', 'select count(*) from profiles');
await scalar('auth_users_total', 'info', 'select count(*) from auth.users');
await scalar('works_total', 'info', 'select count(*) from works');
await scalar('private_works_total', 'info', 'select count(*) from works where is_public = false');
await scalar('profiles_without_auth', 'blocker', 'select count(*) from profiles p left join auth.users au on au.id = p.id where au.id is null');
await scalar('auth_without_profile', 'blocker', 'select count(*) from auth.users au left join profiles p on p.id = au.id where p.id is null');
await scalar('missing_password_hash', 'blocker', "select count(*) from auth.users where coalesce(password_hash, '') = ''");
await scalar('works_missing_profile', 'blocker', 'select count(*) from works w left join profiles p on p.id = w.user_id where w.user_id is not null and p.id is null');
await scalar('works_missing_user_id', 'blocker', 'select count(*) from works where user_id is null');
await scalar('credit_tx_missing_profile', 'blocker', 'select count(*) from credit_transactions ct left join profiles p on p.id = ct.user_id where ct.user_id is not null and p.id is null');
await scalar('credit_tx_missing_work', 'blocker', 'select count(*) from credit_transactions ct left join works w on w.id = ct.related_work_id where ct.related_work_id is not null and w.id is null');
await scalar('credit_tx_user_work_mismatch', 'blocker', 'select count(*) from credit_transactions ct join works w on w.id = ct.related_work_id where ct.user_id is not null and w.user_id is not null and ct.user_id <> w.user_id');
await scalar('orders_missing_profile', 'blocker', 'select count(*) from orders o left join profiles p on p.id = o.user_id where o.user_id is not null and p.id is null');
await scalar('redeem_codes_created_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.created_by where rc.created_by is not null and p.id is null) end");
await scalar('redeem_codes_used_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.used_by where rc.used_by is not null and p.id is null) end");
await scalar('invitation_referrals_missing_inviter', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.inviter_user_id where p.id is null) end");
await scalar('invitation_referrals_missing_invitee', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.invitee_user_id where p.id is null) end");
await scalar('user_api_keys_missing_profile', 'blocker', 'select count(*) from user_api_keys k left join profiles p on p.id = k.user_id where k.user_id is not null and p.id is null');
await scalar('user_api_keys_missing_preview', 'blocker', "select count(*) from user_api_keys where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
await scalar('system_api_missing_preview', 'blocker', "select count(*) from system_api_configs where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
await scalar('work_likes_missing_profile', 'blocker', 'select count(*) from work_likes wl left join profiles p on p.id = wl.user_id where wl.user_id is not null and p.id is null');
await scalar('work_likes_missing_work', 'blocker', 'select count(*) from work_likes wl left join works w on w.id = wl.work_id where wl.work_id is not null and w.id is null');
await scalar('generation_jobs_missing_profile', 'blocker', 'select count(*) from generation_jobs gj left join profiles p on p.id = gj.user_id where gj.user_id is not null and p.id is null');
await scalar('same_url_different_users', 'info', "select count(*) from (select result_url from works where coalesce(result_url, '') <> '' group by result_url having count(distinct user_id) > 1) t");
await scalar('duplicate_url_same_user', 'warning', "select count(*) from (select user_id, result_url from works where coalesce(result_url, '') <> '' group by user_id, result_url having count(*) > 1) t");
for (const [table, column] of [
['user_api_keys', 'manifest_path'],
['system_api_configs', 'manifest_path'],
['system_api_configs', 'billing_mode'],
['system_api_configs', 'fixed_price'],
['system_api_configs', 'duration_price_per_second'],
['system_api_configs', 'input_price_per_1k'],
['system_api_configs', 'output_price_per_1k'],
['system_api_configs', 'is_default'],
['system_api_configs', 'allowed_membership_tiers'],
['system_api_configs', 'polling_mode'],
['system_api_configs', 'polling_order'],
['profiles', 'invite_code'],
['profiles', 'referred_by_user_id'],
['invitation_referrals', 'invite_code'],
['invitation_referrals', 'inviter_user_id'],
['invitation_referrals', 'invitee_user_id'],
]) {
await requiredColumn(table, column);
}
await checkLocalStorageUrls();
}
async function scalar(name, severity, sql, params = []) {
const res = await pool.query(sql, params);
checks.push({
name,
severity,
value: Number(res.rows[0]?.count ?? res.rows[0]?.value ?? 0),
});
}
async function requiredColumn(table, column) {
const [schema, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
const res = await pool.query(
'select count(*)::int as count from information_schema.columns where table_schema = $1 and table_name = $2 and column_name = $3',
[schema, tableName, column],
);
checks.push({
name: `column_${table}_${column}`,
severity: 'blocker',
value: Number(res.rows[0]?.count || 0) === 1 ? 0 : 1,
});
}
async function checkLocalStorageUrls() {
const res = await pool.query(`
with urls as (
select result_url as url from works where result_url like '/api/local-storage/%'
union select thumbnail_url as url from works where thumbnail_url like '/api/local-storage/%'
union select logo_url as url from site_config where logo_url like '/api/local-storage/%'
union select favicon_url as url from site_config where favicon_url like '/api/local-storage/%'
)
select url from urls where url is not null limit $1
`, [Number.isFinite(maxStorageUrls) && maxStorageUrls > 0 ? maxStorageUrls : 200]);
let missing = 0;
let checked = 0;
let cursor = 0;
const workers = Array.from({ length: Math.min(storageUrlConcurrency, Math.max(1, res.rows.length)) }, async () => {
while (cursor < res.rows.length) {
const row = res.rows[cursor++];
const result = await checkStorageUrl(baseUrl, row.url, { timeoutMs: storageUrlTimeoutMs });
checked += 1;
if (!result.ok) missing += 1;
}
});
await Promise.all(workers);
checks.push({ name: 'local_storage_urls_checked', severity: 'info', value: res.rows.length });
checks.push({ name: 'local_storage_urls_probe_completed', severity: 'info', value: checked });
checks.push({ name: 'local_storage_urls_missing', severity: 'blocker', value: missing });
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const raw = fs.readFileSync(filePath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, value] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = value.replace(/^['"]|['"]$/g, '');
}
}

12
scripts/prepare.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
cd "${COZE_WORKSPACE_PATH}"
echo "Installing dependencies..."
pnpm install --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
if command -v coze > /dev/null 2>&1 && coze check-bins --help > /dev/null 2>&1; then
coze check-bins --fix
fi

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
loadEnvFile(path.join(process.cwd(), '.env.local'));
const args = new Set(process.argv.slice(2));
const create = args.has('--create');
const list = args.has('--list');
const printEnv = args.has('--print-env');
const apiBaseUrl = trimTrailingSlash(process.env.RAINYUN_API_BASE_URL || 'https://api.v2.rainyun.com');
const apiKey = process.env.RAINYUN_API_KEY?.trim() || '';
const devToken = process.env.RAINYUN_DEV_TOKEN?.trim();
const bucketName = process.env.RAINYUN_ROS_BUCKET_NAME?.trim() || process.env.OBJECT_STORAGE_BUCKET?.trim();
const instanceId = Number(process.env.RAINYUN_ROS_INSTANCE_ID || 0);
const outputEnvPath = process.env.RAINYUN_ROS_OUTPUT_ENV || '.env.rainyun-object.generated';
if (!create && !list) {
console.log(JSON.stringify({
ok: true,
dryRun: true,
message: 'No network request was sent. Pass --list to list ROS buckets or --create to create one.',
apiBaseUrl,
createEndpoint: `${apiBaseUrl}/product/ros/bucket`,
requiredEnv: ['RAINYUN_API_KEY', 'RAINYUN_ROS_BUCKET_NAME', 'RAINYUN_ROS_INSTANCE_ID'],
outputEnvPath,
}, null, 2));
process.exit(0);
}
if (list) {
const buckets = await rainyunRequest('/product/ros/bucket', { method: 'GET' });
console.log(JSON.stringify({
ok: true,
action: 'list',
buckets: sanitizeForLog(buckets),
}, null, 2));
}
if (create) {
if (!bucketName) throw new Error('RAINYUN_ROS_BUCKET_NAME or OBJECT_STORAGE_BUCKET is required');
if (!Number.isInteger(instanceId) || instanceId <= 0) throw new Error('RAINYUN_ROS_INSTANCE_ID must be a positive integer');
const bucket = await rainyunRequest('/product/ros/bucket', {
method: 'POST',
body: {
bucket_name: bucketName,
instance_id: instanceId,
},
});
const env = buildObjectStorageEnv(bucket);
if (!env.OBJECT_STORAGE_ENDPOINT) {
throw new Error('Rainyun response did not include public_api_url; set OBJECT_STORAGE_ENDPOINT manually before migration');
}
writeEnvFile(outputEnvPath, env);
console.log(JSON.stringify({
ok: true,
action: 'create',
bucket: sanitizeForLog(bucket),
outputEnvPath,
printedEnv: printEnv ? redactEnv(env) : undefined,
nextSteps: [
`Review ${outputEnvPath} and copy the OBJECT_STORAGE_* values into production .env.local`,
'Set STORAGE_MODE=dual first, not object',
'Run pnpm run migration:check before migration',
'Run pnpm run storage:sync-object -- --dry-run',
'Run pnpm run storage:sync-object',
'Run pnpm run storage:sync-object -- --verify-only',
'Reload PM2 and run pnpm run migration:check again',
],
}, null, 2));
}
async function rainyunRequest(endpoint, options = {}) {
if (!apiKey) throw new Error('RAINYUN_API_KEY is required');
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
method: options.method || 'GET',
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
...(devToken ? { 'rain-dev-token': devToken } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!response.ok) {
throw new Error(`Rainyun API ${response.status}: ${typeof parsed === 'string' ? parsed.slice(0, 500) : JSON.stringify(sanitizeForLog(parsed))}`);
}
return unwrapRainyunData(parsed);
}
function unwrapRainyunData(value) {
if (value && typeof value === 'object') {
if ('data' in value && value.data && typeof value.data === 'object') return value.data;
if ('Data' in value && value.Data && typeof value.Data === 'object') return value.Data;
}
return value;
}
function buildObjectStorageEnv(bucket) {
const source = bucket && typeof bucket === 'object' ? bucket : {};
const instance = source.instance && typeof source.instance === 'object' ? source.instance : {};
const endpoint = normalizeEndpoint(
firstString(source.public_api_url, instance.public_api_url, process.env.OBJECT_STORAGE_ENDPOINT),
);
return {
STORAGE_MODE: 'dual',
OBJECT_STORAGE_BUCKET: firstString(source.name, source.bucket_name, bucketName),
OBJECT_STORAGE_REGION: process.env.OBJECT_STORAGE_REGION || 'auto',
OBJECT_STORAGE_ENDPOINT: endpoint,
OBJECT_STORAGE_ACCESS_KEY_ID: firstString(source.access_key, instance.access_key, process.env.OBJECT_STORAGE_ACCESS_KEY_ID),
OBJECT_STORAGE_SECRET_ACCESS_KEY: firstString(source.secret_key, instance.secret_key, process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY),
OBJECT_STORAGE_FORCE_PATH_STYLE: process.env.OBJECT_STORAGE_FORCE_PATH_STYLE || 'true',
OBJECT_STORAGE_PREFIX: process.env.OBJECT_STORAGE_PREFIX || 'miaojing',
};
}
function normalizeEndpoint(value) {
const raw = firstString(value);
if (!raw) return '';
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(withProtocol);
url.pathname = url.pathname.replace(/\/+$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
} catch {
return withProtocol.replace(/\/+$/, '');
}
}
function writeEnvFile(filePath, env) {
const lines = [
'# Generated by scripts/rainyun-ros-prepare.mjs',
'# Keep this file private. It contains object storage credentials.',
...Object.entries(env).map(([key, value]) => `${key}=${quoteEnvValue(value)}`),
'',
];
fs.writeFileSync(filePath, lines.join('\n'), { mode: 0o600 });
}
function redactEnv(env) {
return Object.fromEntries(Object.entries(env).map(([key, value]) => [
key,
/SECRET|KEY/i.test(key) ? redact(value) : value,
]));
}
function sanitizeForLog(value) {
if (Array.isArray(value)) return value.map(sanitizeForLog);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
key,
/secret|access_key|api[_-]?key|token/i.test(key) ? redact(String(nested || '')) : sanitizeForLog(nested),
]));
}
function redact(value) {
if (!value) return '';
return `${value.slice(0, 4)}...${value.slice(-4)}`;
}
function firstString(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function quoteEnvValue(value) {
const text = String(value ?? '');
if (/^[A-Za-z0-9._~:/@-]*$/.test(text)) return text;
return JSON.stringify(text);
}
function trimTrailingSlash(value) {
return value.replace(/\/+$/, '');
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const raw = fs.readFileSync(filePath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, value] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = value.replace(/^['"]|['"]$/g, '');
}
}

46
scripts/start.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
# Load environment variables from .env.local if it exists. PM2 role-specific
# values are restored afterwards so backend/console services keep their ports.
PM2_DEPLOY_RUN_PORT="${DEPLOY_RUN_PORT:-}"
PM2_APP_RUNTIME_ROLE="${APP_RUNTIME_ROLE:-}"
PM2_BACKEND_INTERNAL_URL="${BACKEND_INTERNAL_URL:-}"
PM2_CONSOLE_INTERNAL_URL="${CONSOLE_INTERNAL_URL:-}"
if [ -f "${COZE_WORKSPACE_PATH}/.env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source "${COZE_WORKSPACE_PATH}/.env.local"
set +a
set -u
fi
[ -n "${PM2_DEPLOY_RUN_PORT}" ] && DEPLOY_RUN_PORT="${PM2_DEPLOY_RUN_PORT}"
[ -n "${PM2_APP_RUNTIME_ROLE}" ] && APP_RUNTIME_ROLE="${PM2_APP_RUNTIME_ROLE}"
[ -n "${PM2_BACKEND_INTERNAL_URL}" ] && BACKEND_INTERNAL_URL="${PM2_BACKEND_INTERNAL_URL}"
[ -n "${PM2_CONSOLE_INTERNAL_URL}" ] && CONSOLE_INTERNAL_URL="${PM2_CONSOLE_INTERNAL_URL}"
if [ -n "${DEPLOY_NODE_BIN_DIR:-}" ] && [ -d "${DEPLOY_NODE_BIN_DIR}" ]; then
export PATH="${DEPLOY_NODE_BIN_DIR}:${PATH}"
fi
PORT=${1:-5000}
DEPLOY_RUN_PORT="${DEPLOY_RUN_PORT:-$PORT}"
APP_RUNTIME_ROLE="${APP_RUNTIME_ROLE:-full}"
start_service() {
cd "${COZE_WORKSPACE_PATH}"
echo "Starting ${APP_RUNTIME_ROLE} HTTP service on port ${DEPLOY_RUN_PORT} for deploy..."
echo "COZE_PROJECT_ENV: ${COZE_PROJECT_ENV}"
export NODE_ENV="${NODE_ENV:-production}"
export APP_RUNTIME_ROLE
PORT=${DEPLOY_RUN_PORT} node dist/server.js
}
echo "Starting ${APP_RUNTIME_ROLE} HTTP service on port ${DEPLOY_RUN_PORT} for deploy..."
start_service

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import {
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
loadEnvFile(path.join(process.cwd(), '.env.local'));
const args = new Set(process.argv.slice(2));
const dryRun = args.has('--dry-run');
const verifyOnly = args.has('--verify-only');
const localRoot = path.resolve(process.env.LOCAL_STORAGE_DIR || path.join(process.cwd(), 'local-storage'));
const bucket = requiredEnv('OBJECT_STORAGE_BUCKET');
const region = process.env.OBJECT_STORAGE_REGION || 'auto';
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT || undefined;
const prefix = normalizePrefix(process.env.OBJECT_STORAGE_PREFIX || '');
const forcePathStyle = booleanEnv(process.env.OBJECT_STORAGE_FORCE_PATH_STYLE, true);
const client = new S3Client({
region,
endpoint,
forcePathStyle,
credentials: process.env.OBJECT_STORAGE_ACCESS_KEY_ID && process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY
? {
accessKeyId: process.env.OBJECT_STORAGE_ACCESS_KEY_ID,
secretAccessKey: process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY,
}
: undefined,
});
if (!fs.existsSync(localRoot) || !fs.statSync(localRoot).isDirectory()) {
console.error(`Local storage directory does not exist: ${localRoot}`);
process.exit(1);
}
const files = walk(localRoot);
let uploaded = 0;
let skipped = 0;
let verified = 0;
const failures = [];
for (const filePath of files) {
const key = toObjectKey(path.relative(localRoot, filePath));
const stat = fs.statSync(filePath);
try {
const existing = await headObject(key);
if (existing && Number(existing.ContentLength || 0) === stat.size) {
skipped++;
verified++;
continue;
}
if (verifyOnly) {
failures.push(`${key}: missing or size mismatch`);
continue;
}
if (!dryRun) {
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fs.createReadStream(filePath),
ContentType: getContentType(key),
}));
const after = await headObject(key);
if (!after || Number(after.ContentLength || 0) !== stat.size) {
failures.push(`${key}: uploaded size mismatch`);
continue;
}
}
uploaded++;
verified++;
} catch (error) {
failures.push(`${key}: ${error instanceof Error ? error.message : String(error)}`);
}
}
console.log(JSON.stringify({
dryRun,
verifyOnly,
localRoot,
bucket,
endpoint,
prefix,
totalFiles: files.length,
uploaded,
skipped,
verified,
failures,
}, null, 2));
if (failures.length > 0) process.exit(1);
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const raw = fs.readFileSync(filePath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, value] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = value.replace(/^['"]|['"]$/g, '');
}
}
function requiredEnv(key) {
const value = process.env[key]?.trim();
if (!value) {
console.error(`${key} is required`);
process.exit(1);
}
return value;
}
function booleanEnv(value, fallback) {
if (value == null || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
function normalizePrefix(value) {
const normalized = path.posix.normalize(value.replace(/\\/g, '/')).replace(/^\/+|\/+$/g, '');
if (!normalized || normalized === '.') return '';
if (normalized.startsWith('../') || normalized.includes('/../') || normalized.includes('\0')) {
throw new Error('Invalid OBJECT_STORAGE_PREFIX');
}
return normalized;
}
function toObjectKey(relativePath) {
const key = relativePath.split(path.sep).join('/');
return prefix ? `${prefix}/${key}` : key;
}
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap(entry => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(fullPath);
return entry.isFile() ? [fullPath] : [];
});
}
async function headObject(key) {
try {
return await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
} catch {
return null;
}
}
function getContentType(key) {
const ext = key.split('.').pop()?.toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
if (ext === 'png') return 'image/png';
if (ext === 'webp') return 'image/webp';
if (ext === 'gif') return 'image/gif';
if (ext === 'mp4') return 'video/mp4';
if (ext === 'webm') return 'video/webm';
if (ext === 'json') return 'application/json';
return 'application/octet-stream';
}

View File

@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
import {
buildAdminGalleryWorksPaginationMeta,
parseAdminGalleryWorksPagination,
} from '../src/lib/admin-gallery-works-pagination.ts';
function createWork(overrides = {}) {
return {
id: '11111111-1111-1111-1111-111111111111',
user_id: '22222222-2222-2222-2222-222222222222',
type: 'text2img',
title: 'public work',
prompt: 'old public prompt',
negative_prompt: null,
result_url: '/api/local-storage/gallery/image.webp',
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
likes_count: 3,
is_public: true,
status: 'completed',
created_at: '2026-05-20T00:00:00.000Z',
author_email: 'author@example.com',
author_nickname: 'Author',
author_display_nickname: 'Author Display',
author_avatar_url: null,
...overrides,
};
}
function createServiceHarness({ work, emailFails = false } = {}) {
const state = {
work: work || createWork(),
updates: [],
emails: [],
logs: [],
};
return {
state,
deps: {
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
updatePrompt: async (workId, prompt) => {
state.updates.push({ workId, prompt });
state.work = { ...state.work, prompt };
return state.work;
},
sendEmail: async (message) => {
state.emails.push(message);
if (emailFails) throw new Error('SMTP down');
},
writeLog: async (entry) => {
state.logs.push(entry);
},
},
};
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
const baseInput = {
workId: '11111111-1111-1111-1111-111111111111',
prompt: 'new compliant prompt',
emailSubject: '公开作品提示词已调整',
emailBody: '你的公开作品提示词已根据平台规范调整。',
reasonKey: 'remove_sensitive_words',
};
await runTest('rejects non-public works', async () => {
const { deps, state } = createServiceHarness({ work: createWork({ is_public: false }) });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects missing author email', async () => {
const { deps, state } = createServiceHarness({ work: createWork({ author_email: '' }) });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects unchanged prompt', async () => {
const { deps, state } = createServiceHarness();
await assert.rejects(
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
/提示词没有变化/,
);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('does not update prompt when email sending fails', async () => {
const { deps, state } = createServiceHarness({ emailFails: true });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 1);
});
await runTest('sends email before updating prompt', async () => {
const { deps, state } = createServiceHarness();
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.emails.length, 1);
assert.equal(state.updates.length, 1);
assert.equal(state.updates[0].prompt, 'new compliant prompt');
assert.equal(result.work.prompt, 'new compliant prompt');
});
await runTest('writes moderation log metadata without full prompt text', async () => {
const { deps, state } = createServiceHarness();
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.logs.length, 1);
const logText = JSON.stringify(state.logs[0]);
assert.match(logText, /remove_sensitive_words/);
assert.doesNotMatch(logText, /old public prompt/);
assert.doesNotMatch(logText, /new compliant prompt/);
});
await runTest('parses admin gallery page and pageSize into limit and offset', async () => {
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('page=3&pageSize=50'));
assert.deepEqual(pagination, {
page: 3,
pageSize: 50,
limit: 50,
offset: 100,
});
});
await runTest('keeps limit and offset compatibility for admin gallery works', async () => {
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('limit=15&offset=30'));
assert.deepEqual(pagination, {
page: 3,
pageSize: 15,
limit: 15,
offset: 30,
});
});
await runTest('builds admin gallery pagination metadata', async () => {
const meta = buildAdminGalleryWorksPaginationMeta({ total: 46, page: 2, pageSize: 20, resultCount: 20 });
assert.deepEqual(meta, {
total: 46,
page: 2,
pageSize: 20,
totalPages: 3,
nextOffset: 40,
hasMore: true,
});
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('admin user management exposes reset password without hiding it behind edit modal', () => {
const source = read('src/components/admin/user-management-tab.tsx');
assert.match(source, /const startResetPassword = \(user: ManagedUser\) => \{/);
assert.match(source, /setResetPwUser\(user\)/);
assert.match(source, /setNewPassword\(''\)/);
assert.match(source, /setEditingUser\(null\)/);
assert.match(source, /onClick=\{\(\) => startResetPassword\(user\)\}/);
assert.match(source, /<KeyRound className="h-3\.5 w-3\.5" \/>重置密码/);
assert.match(source, /onClick=\{\(\) => startResetPassword\(editingUser\)\}/);
});
await runTest('admin reset password form is rendered as an overlay dialog', () => {
const source = read('src/components/admin/user-management-tab.tsx');
const resetSection = source.slice(source.indexOf('{resetPwUser && ('), source.indexOf('{editingUser && ('));
assert.match(resetSection, /fixed inset-0 z-50/);
assert.match(resetSection, /max-h-\[90vh\] overflow-y-auto/);
assert.doesNotMatch(resetSection, /\{resetPwUser && \(\s*<Card className="border-primary\/30">/);
assert.match(source, /setRechargeUser\(null\)/);
assert.match(source, /setShowAddForm\(false\)/);
});
await runTest('admin password reset upserts auth credentials instead of silently updating zero rows', () => {
const source = read('src/lib/admin-users-service.ts');
assert.match(source, /INSERT INTO auth\.users \(id, email, password_hash, created_at\)/);
assert.match(source, /VALUES \(\$1, \$2, crypt\(\$3, gen_salt\('bf'\)\), NOW\(\)\)/);
assert.match(source, /ON CONFLICT \(id\) DO UPDATE SET password_hash = crypt\(\$3, gen_salt\('bf'\)\)/);
assert.match(source, /\[userId,\s*currentResult\.rows\[0\]\.email,\s*newPassword\]/);
});
await runTest('creation detail reuse supports text-to-video and image-to-video history records', () => {
const source = read('src/components/creation-detail-dialog.tsx');
assert.match(source, /buildCreationReuseDraft,\s*writeCreationReuseDraft/);
assert.match(source, /function getReuseTarget\(record: CreationRecord\)/);
assert.match(source, /return mode === 'img2video' \? 'img2video' : 'text2video'/);
assert.match(source, /const target = getReuseTarget\(record\)/);
assert.match(source, /writeCreationReuseDraft\(target,\s*draft\)/);
assert.match(source, /router\.push\(`\/create\?type=\$\{target\}&reuse=\$\{encodeURIComponent\(record\.id\)\}`\)/);
assert.doesNotMatch(source, /disabled=\{record\.type !== 'image'\}/);
assert.doesNotMatch(source, /当前仅支持将图片创作配置复用到文生图/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,221 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
const {
AGNES_BASE_URL,
AGNES_PROVIDER_NAME,
AGNES_IMAGE_MODEL_GROUP,
AGNES_VIDEO_MODEL_GROUP,
AGNES_TEXT_MODEL_GROUP,
AGNES_IMAGE_MODEL_TEMPLATES,
AGNES_VIDEO_MODEL_TEMPLATES,
AGNES_TEXT_MODEL_TEMPLATES,
AGNES_VIDEO_FRAME_RATE,
normalizeAgnesVideoDuration,
getAgnesVideoNumFrames,
getAgnesModelCapabilities,
buildAgnesImageManifestBundle,
buildAgnesVideoManifestBundle,
buildAgnesCapabilitiesText,
} = await import('../src/lib/agnes-model-templates.ts');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('Agnes templates cover documented image, video, and text models', () => {
assert.equal(AGNES_BASE_URL, 'https://apihub.agnes-ai.com');
assert.equal(AGNES_PROVIDER_NAME, 'Agnes AI');
assert.equal(AGNES_IMAGE_MODEL_GROUP, 'agnes-image');
assert.equal(AGNES_VIDEO_MODEL_GROUP, 'agnes-video');
assert.equal(AGNES_TEXT_MODEL_GROUP, 'agnes-text');
assert.deepEqual(AGNES_IMAGE_MODEL_TEMPLATES.map(item => item.modelName), [
'agnes-image-2.1-flash',
'agnes-image-2.0-flash',
]);
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-video-v2.0']);
assert.deepEqual(AGNES_TEXT_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-2.0-flash', 'agnes-1.5-flash']);
});
await runTest('Agnes image Manifest maps documented OpenAI-compatible image fields', () => {
const template = AGNES_IMAGE_MODEL_TEMPLATES.find(item => item.modelName === 'agnes-image-2.1-flash');
assert.ok(template, 'missing Agnes Image 2.1 Flash template');
const bundle = buildAgnesImageManifestBundle(template);
const provider = bundle.customProviders[0];
const profile = bundle.profiles[0];
assert.equal(profile.baseUrl, AGNES_BASE_URL);
assert.equal(profile.apiMode, 'images');
assert.equal(profile.capabilities?.supportsAspectRatio, false);
assert.deepEqual(profile.capabilities?.resolutions?.map(item => item.value), [
'1024x768',
'1024x1024',
'768x1024',
'1152x768',
'768x1152',
]);
assert.equal(provider.submit?.path, 'v1/images/generations');
assert.equal(provider.submit?.method, 'POST');
assert.equal(provider.submit?.contentType, 'json');
assert.equal(provider.submit?.body?.model, '$profile.model');
assert.equal(provider.submit?.body?.prompt, '$prompt');
assert.equal(provider.submit?.body?.size, '$params.size');
assert.equal(provider.submit?.body?.image, '$inputImages.urls');
assert.deepEqual(provider.submit?.body?.extra_body, { response_format: 'url' });
assert.equal(provider.submit?.body?.response_format, undefined);
assert.deepEqual(provider.submit?.result?.imageUrlPaths, ['data.*.url']);
assert.deepEqual(provider.submit?.result?.b64JsonPaths, ['data.*.b64_json']);
});
await runTest('Agnes video Manifest creates async task and polls by video_id', () => {
const template = AGNES_VIDEO_MODEL_TEMPLATES[0];
const bundle = buildAgnesVideoManifestBundle(template);
const provider = bundle.customProviders[0];
assert.equal(bundle.profiles[0].baseUrl, AGNES_BASE_URL);
assert.equal(bundle.profiles[0].apiMode, 'videos');
assert.equal(provider.submit?.path, 'v1/videos');
assert.equal(provider.submit?.body?.model, '$profile.model');
assert.equal(provider.submit?.body?.prompt, '$prompt');
assert.equal(provider.submit?.body?.image, '$inputImages.urls.0');
assert.equal(provider.submit?.body?.num_frames, '$params.num_frames');
assert.equal(provider.submit?.body?.negative_prompt, '$params.negative_prompt');
assert.equal(provider.submit?.body?.frame_rate, '$params.fps');
assert.equal(provider.submit?.body?.width, '$params.width');
assert.equal(provider.submit?.body?.height, '$params.height');
assert.match(provider.submit?.taskIdPath || '', /video_id/);
assert.equal(provider.poll?.path, 'agnesapi');
assert.deepEqual(provider.poll?.query, {
video_id: '{task_id}',
model_name: '$profile.model',
});
assert.equal(provider.poll?.statusPath, 'status');
assert.deepEqual(provider.poll?.successValues, ['completed']);
assert.deepEqual(provider.poll?.failureValues, ['failed']);
assert.deepEqual(provider.poll?.result?.videoUrlPaths, ['remixed_from_video_id', 'video_url', 'url']);
});
await runTest('Agnes video duration options map to documented frame counts at 24fps', () => {
assert.equal(AGNES_VIDEO_FRAME_RATE, 24);
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES[0].capabilities.durations?.map(item => item.value), ['3', '5', '10']);
assert.equal(normalizeAgnesVideoDuration(18), null);
assert.equal(getAgnesVideoNumFrames(3), 81);
assert.equal(getAgnesVideoNumFrames(5), 121);
assert.equal(getAgnesVideoNumFrames(10), 241);
assert.deepEqual(getAgnesModelCapabilities('agnes-video-v2.0')?.durations?.map(item => item.value), ['3', '5', '10']);
const videoRoute = read('src/app/api/generate/video/route.ts');
assert.match(videoRoute, /normalizeAgnesVideoDuration\(duration\)/);
assert.match(videoRoute, /Agnes Video V2\.0 当前仅开放 3、5、10 秒/);
assert.match(videoRoute, /const useAgnesVideoParams = isAgnesVideoApi\(resolvedCustomApiConfig\)/);
assert.match(videoRoute, /getAgnesVideoNumFrames\(resolvedAgnesDuration\)/);
assert.match(videoRoute, /fps:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_FRAME_RATE\s*:\s*fps/);
assert.match(videoRoute, /num_frames:\s*useAgnesVideoParams\s*\?\s*getAgnesVideoNumFrames\(resolvedAgnesDuration\)\s*:\s*undefined/);
assert.match(videoRoute, /timeoutMs:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_GENERATION_TIMEOUT\s*:\s*GENERATION_TIMEOUT/);
});
await runTest('Agnes video failures are reported by stage instead of raw fetch failed', () => {
const executor = read('src/lib/user-api-manifest-executor.ts');
const videoRoute = read('src/app/api/generate/video/route.ts');
const worker = read('src/lib/generation-job-worker.ts');
const runner = read('src/lib/generation-job-runner.ts');
assert.match(executor, /const stage = method === 'GET' \? '上游任务轮询' : '上游任务创建'/);
assert.match(executor, /网络连接失败,请稍后重试/);
assert.match(videoRoute, /上游已返回视频地址,但平台下载或保存结果视频失败/);
assert.match(worker, /creation history persistence failed:/);
assert.match(worker, /\(\$\{url\}\)/);
assert.match(runner, /内部生成请求网络连接失败/);
assert.match(runner, /requestInternalGenerationJson/);
});
await runTest('Agnes video polling progress is forwarded into generation job status', () => {
const executor = read('src/lib/user-api-manifest-executor.ts');
assert.match(executor, /function getManifestProgress/);
assert.match(executor, /getPathValue\(raw,\s*'progress'\)/);
assert.match(executor, /remainingSeconds/);
assert.match(executor, /上游任务创建中/);
assert.match(executor, /上游任务已创建,等待生成结果/);
assert.match(executor, /await input\.onProgress\?\.\(getManifestProgress\(raw,\s*status\)\)/);
});
await runTest('Agnes video manifest splits per-request timeout from total polling budget', () => {
const executor = read('src/lib/user-api-manifest-executor.ts');
assert.match(executor, /function getManifestRequestTimeoutMs/);
assert.match(executor, /USER_API_MANIFEST_SUBMIT_TIMEOUT_MS/);
assert.match(executor, /USER_API_MANIFEST_POLL_REQUEST_TIMEOUT_MS/);
assert.match(executor, /AGNES_VIDEO_MANIFEST_SUBMIT_TIMEOUT_MS/);
assert.match(executor, /function isAgnesVideoManifestRequest/);
assert.match(executor, /getManifestRequestTimeoutMs\(input\.timeoutMs,\s*method,\s*input\)/);
assert.match(executor, /while \(Date\.now\(\) < deadline\)/);
assert.match(executor, /isTransientPollError/);
});
await runTest('Agnes installer source creates free inactive rows with empty API key and per-row Manifest files', () => {
const installer = read('src/lib/agnes-template-installer.ts');
assert.match(installer, /encryptApiKeyForStorage\(''\)/);
assert.match(installer, /credits_per_use/);
assert.match(installer, /billingMode:\s*'free'/);
assert.match(installer, /is_active,\s*sort_order/);
assert.match(installer, /false,\s*input\.sortOffset/s);
assert.match(installer, /attachManifest\(client,\s*row,\s*bundle,\s*saveManifestFile\)/);
assert.match(installer, /syncImageModels/);
assert.match(installer, /syncVideoModels/);
assert.match(installer, /syncTextModels/);
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/images\/generations`/);
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/chat\/completions`/);
assert.match(installer, /const apiUrl = resolveImportedProfileApiUrl\(bundle,\s*profile\) \|\| AGNES_BASE_URL/);
assert.match(installer, /saveSystemApiManifestFile/);
assert.match(installer, /Agnes 免费模型/);
});
await runTest('Agnes system model capabilities use built-in fallback so stale manifests do not expose unstable 18s', () => {
const serverConfig = read('src/lib/server-api-config.ts');
assert.match(serverConfig, /getAgnesModelCapabilities/);
assert.match(serverConfig, /getAgnesSystemApiCapabilitiesFallback/);
assert.match(serverConfig, /getAgnesSystemApiCapabilitiesFallback\(row\)\s*\|\|\s*readManifestCapabilities/);
});
await runTest('admin UI and docs expose Agnes as system-default built-in templates, not smart import', () => {
const adminTab = read('src/components/admin/api-management-tab.tsx');
const apiReference = read('docs/codex-miaojing/api-reference.md');
const customIntegrations = read('docs/codex-miaojing/custom-integrations.md');
const featureIndex = read('docs/codex-miaojing/feature-code-index.md');
assert.match(adminTab, /agnes-capabilities/);
assert.match(adminTab, /安装 Agnes 免费模型/);
assert.match(adminTab, /免费模型/);
assert.match(apiReference, /\/api\/admin\/system-apis\/agnes-capabilities/);
assert.match(customIntegrations, /Agnes AI/);
assert.match(featureIndex, /agnes-model-templates/);
});
await runTest('Agnes capabilities text summarizes documented modules', () => {
const text = buildAgnesCapabilitiesText();
assert.match(text, /Agnes Image 2\.1 Flash/);
assert.match(text, /Agnes Image 2\.0 Flash/);
assert.match(text, /Agnes Video V2\.0/);
assert.match(text, /Agnes 2\.0 Flash/);
assert.match(text, /https:\/\/apihub\.agnes-ai\.com/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
const {
buildCreationReuseDraft,
} = await import('../src/lib/creation-reuse.ts');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('creation detail renders thumbnail while fullscreen and actions keep original image', () => {
const source = read('src/components/creation-detail-dialog.tsx');
assert.match(source, /src=\{record\.thumbnailUrl \|\| record\.url\}/);
assert.match(source, /openFullscreenPreview\(record\.url,\s*record\.thumbnailUrl\)/);
assert.match(source, /openImageMenu\(event,\s*record\.url\)/);
assert.match(source, /downloadFile\(url,\s*filename\)/);
});
await runTest('creation detail metadata badge does not load original image for dimensions', () => {
const source = read('src/components/creation-detail-dialog.tsx');
assert.match(source, /<ImageMetadataBadge[\s\S]*?src=\{record\.url\}[\s\S]*?width=\{record\.width\}[\s\S]*?height=\{record\.height\}[\s\S]*?loadMetadata=\{false\}/);
});
await runTest('creation history API preserves stored image dimensions for detail metadata', () => {
const source = read('src/app/api/creation-history/route.ts');
assert.match(source, /width:\s*row\.width/);
assert.match(source, /height:\s*row\.height/);
assert.match(source, /SELECT[\s\S]*\bwidth,\s*height[\s\S]*FROM works/);
assert.match(source, /INSERT INTO works[\s\S]*width,\s*height/);
});
await runTest('image generation response exposes persisted dimensions for history records', () => {
const routeSource = read('src/app/api/generate/image/route.ts');
const textSource = read('src/components/create/text-to-image.tsx');
const imageSource = read('src/components/create/image-to-image.tsx');
assert.match(routeSource, /dimensions:\s*Object\.fromEntries\(images\.map\(image => \[image\.url,\s*\{\s*width:\s*image\.width,\s*height:\s*image\.height\s*\}\]\)\)/);
assert.match(textSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
assert.match(textSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
assert.match(textSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
assert.match(imageSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
assert.match(imageSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
assert.match(imageSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
});
await runTest('reuse drafts use original output as generated-reference fallback, never thumbnail', () => {
const record = {
id: 'work-1',
url: '/api/local-storage/generated/images/original.webp',
thumbnailUrl: '/api/local-storage/thumbnails/works/thumb.webp',
prompt: 'prompt',
negativePrompt: '',
model: 'model',
params: {},
};
const imageDraft = buildCreationReuseDraft(record, 'img2img', { source: 'creation-detail', useOutputAsReference: true });
const videoDraft = buildCreationReuseDraft(record, 'img2video', { source: 'gallery', useOutputAsReference: true });
assert.deepEqual(imageDraft.referenceImages, [record.url]);
assert.equal(imageDraft.referenceImage, record.url);
assert.deepEqual(videoDraft.referenceImages, [record.url]);
assert.equal(videoDraft.referenceImage, record.url);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const {
parseCustomApiError,
} = await import('../src/lib/custom-api-fetch.ts');
const {
buildSynchronousImageRequestBody,
getSystemPollingFailureMessage,
shouldRetryImageRequestWithoutStream,
STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX,
} = await import('../src/lib/custom-image-fallback.ts');
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('detects stream timeout confirmation errors for synchronous fallback', () => {
assert.equal(
shouldRetryImageRequestWithoutStream(
{ model: 'gpt-image-2', prompt: 'test', stream: true },
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
),
true,
);
assert.equal(
shouldRetryImageRequestWithoutStream(
{ model: 'gpt-image-2', prompt: 'test', stream: false },
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
),
false,
);
});
await runTest('builds a synchronous retry body without mutating the original request', () => {
const original = { model: 'gpt-image-2', prompt: 'test', n: 1, stream: true };
const next = buildSynchronousImageRequestBody(original);
assert.deepEqual(next, { model: 'gpt-image-2', prompt: 'test', n: 1, stream: false });
assert.equal(original.stream, true);
});
await runTest('system polling exposes actionable upstream errors instead of generic busy message', () => {
assert.equal(
getSystemPollingFailureMessage('上游 API 同步生图请求超时Cloudflare 524。请降低分辨率后重试。'),
'上游 API 同步生图请求超时Cloudflare 524。请降低分辨率后重试。',
);
assert.equal(
getSystemPollingFailureMessage(`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`),
'上游流式生图没有持续返回数据',
);
assert.equal(
getSystemPollingFailureMessage(''),
'因使用人数较多,模型繁忙,请稍后再试',
);
});
await runTest('Cloudflare gateway errors are shown as concise retryable upstream messages', () => {
const message = parseCustomApiError(502, '<!DOCTYPE html><title>mozhevip.top | 502: Bad gateway</title>');
assert.equal(message.includes('<!DOCTYPE html>'), false);
assert.match(message, /上游网关/);
assert.match(message, /502/);
});
await runTest('text-to-image custom fetch enables one retry for 502 503 504 gateway failures', () => {
const source = read('src/app/api/generate/image/route.ts');
assert.match(
source,
/fetchWithRetry\(\s*endpoint,\s*\{ method: 'POST', headers: buildCustomApiHeaders\(apiKey\), body: JSON\.stringify\(requestBody\) \},\s*GENERATION_TIMEOUT,\s*1,\s*\)/s,
);
});
await runTest('multimodal 524 errors do not reuse image-generation timeout wording', () => {
const message = parseCustomApiError(524, '<!DOCTYPE html><title>mozhevip.top | 524: A timeout occurred</title>', 'multimodal');
assert.match(message, /多模态模型同步请求超时/);
assert.equal(message.includes('生图请求超时'), false);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const routeSource = fs.readFileSync('src/app/api/generate/image/route.ts', 'utf8');
assert.match(
routeSource,
/generateObjectReadUrl\(fileKey,\s*3600\)/,
'img2img uploaded reference images should expose object signed URLs to upstream providers',
);
assert.match(
routeSource,
/toAbsolutePublicUrl\(publicUrl\)/,
'img2img fallback public URLs should be absolute when object signed URLs are unavailable',
);
assert.match(
routeSource,
/localStorage\.getKeyFromPublicUrl\(image\)/,
'img2img should detect stored /api/local-storage reference images before fetching over HTTP',
);
assert.match(
routeSource,
/localStorage\.readFileAsync\(storedReferenceKey\)/,
'img2img should read stored reference images through the storage adapter for FormData uploads',
);
console.log('custom img2img reference URL policy ok');

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
const galleryPublishMediaModule = await import('../src/lib/gallery-publish-media.ts');
const { resolveGalleryPublishMedia } = galleryPublishMediaModule.default || galleryPublishMediaModule;
function createDeps() {
const calls = {
copy: [],
imageThumbnail: [],
videoThumbnail: [],
};
return {
calls,
deps: {
copyPublicUrlToFolder: async (url, folder, options) => {
calls.copy.push({ url, folder, options });
return `/api/local-storage/${folder}/copied.png`;
},
ensureLocalImageThumbnail: async (url, prefix) => {
calls.imageThumbnail.push({ url, prefix });
return `/api/local-storage/${prefix}/generated-m1280q86.webp`;
},
ensureLocalVideoThumbnail: async (url, prefix) => {
calls.videoThumbnail.push({ url, prefix });
return `/api/local-storage/${prefix}/frame-video-frame-m1280q86-v1.webp`;
},
},
};
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('new object-backed image publishes reuse the existing local-storage URL and current thumbnail', async () => {
const { calls, deps } = createDeps();
const result = await resolveGalleryPublishMedia({
type: 'image',
resultUrl: '/api/local-storage/generated/images/source.png',
thumbnailUrl: '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp',
prompt: 'image prompt',
}, deps);
assert.equal(result.resultUrl, '/api/local-storage/generated/images/source.png');
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp');
assert.deepEqual(calls.copy, []);
assert.deepEqual(calls.imageThumbnail, []);
});
await runTest('external image publishes still copy into gallery storage before thumbnailing', async () => {
const { calls, deps } = createDeps();
const result = await resolveGalleryPublishMedia({
type: 'image',
resultUrl: 'https://example.com/source.png',
thumbnailUrl: null,
prompt: 'image prompt',
}, deps);
assert.equal(result.resultUrl, '/api/local-storage/gallery/images/copied.png');
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/generated-m1280q86.webp');
assert.deepEqual(calls.copy, [
{
url: 'https://example.com/source.png',
folder: 'gallery/images',
options: { storageTarget: 'object' },
},
]);
assert.deepEqual(calls.imageThumbnail, [
{
url: '/api/local-storage/gallery/images/copied.png',
prefix: 'thumbnails/gallery',
},
]);
});
await runTest('object-backed video publishes keep reusing the existing local-storage URL', async () => {
const { calls, deps } = createDeps();
const result = await resolveGalleryPublishMedia({
type: 'video',
resultUrl: '/api/local-storage/generated/videos/source.mp4',
thumbnailUrl: null,
prompt: 'video prompt',
}, deps);
assert.equal(result.resultUrl, '/api/local-storage/generated/videos/source.mp4');
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/videos/frame-video-frame-m1280q86-v1.webp');
assert.deepEqual(calls.copy, []);
assert.deepEqual(calls.videoThumbnail, [
{
url: '/api/local-storage/generated/videos/source.mp4',
prefix: 'thumbnails/gallery/videos',
},
]);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import {
getPublicGalleryAvatarUrl,
toPublicGalleryWork,
} from '../src/lib/gallery-response.ts';
import {
GALLERY_CACHE_MAX_AGE_MS,
GALLERY_CACHE_TTL_MS,
isGalleryCacheEntryFresh,
isGalleryCacheEntryUsable,
} from '../src/lib/gallery-cache-policy.ts';
function createGalleryRow(overrides = {}) {
return {
id: '11111111-1111-1111-1111-111111111111',
type: 'text2img',
title: 'public work',
prompt: 'prompt',
negative_prompt: null,
result_url: '/api/local-storage/gallery/image.webp',
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
width: 1024,
height: 1024,
duration: null,
likes_count: 7,
credits_cost: 2,
params: {
creationMode: 'text2img',
referenceImages: ['/api/local-storage/reference.webp', ''],
referenceImageThumbnails: ['/api/local-storage/thumbnails/reference.webp', ''],
},
user_id: '22222222-2222-2222-2222-222222222222',
nickname: 'login-name',
display_nickname: '公开昵称',
email: 'user@example.com',
avatar_url: null,
created_at: '2026-05-20T00:00:00.000Z',
...overrides,
};
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('filters data URL avatars from public gallery rows', () => {
const dataAvatar = `data:image/svg+xml;base64,${'a'.repeat(40000)}`;
const work = toPublicGalleryWork(createGalleryRow({ avatar_url: dataAvatar }));
assert.equal(work.publisherAvatarUrl, null);
assert.equal(JSON.stringify(work).includes(dataAvatar), false);
});
await runTest('keeps short URL avatars for public gallery rows', () => {
assert.equal(
getPublicGalleryAvatarUrl('/api/local-storage/avatars/user.webp'),
'/api/local-storage/avatars/user.webp',
);
assert.equal(
getPublicGalleryAvatarUrl('https://example.com/avatar.webp'),
'https://example.com/avatar.webp',
);
});
await runTest('uses display nickname before login nickname', () => {
const work = toPublicGalleryWork(createGalleryRow());
assert.equal(work.publisherNickname, '公开昵称');
});
await runTest('maps reference images without blank entries', () => {
const work = toPublicGalleryWork(createGalleryRow());
assert.deepEqual(work.referenceImages, ['/api/local-storage/reference.webp']);
assert.equal(work.referenceImage, '/api/local-storage/reference.webp');
assert.deepEqual(work.referenceImageThumbnails, ['/api/local-storage/thumbnails/reference.webp']);
});
await runTest('allows stale gallery cache rows for instant first paint', () => {
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
const staleButUsable = now - GALLERY_CACHE_TTL_MS - 1;
assert.equal(isGalleryCacheEntryFresh(staleButUsable, now), false);
assert.equal(isGalleryCacheEntryUsable(staleButUsable, now), true);
});
await runTest('rejects gallery cache rows older than max age', () => {
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
const expired = now - GALLERY_CACHE_MAX_AGE_MS - 1;
assert.equal(isGalleryCacheEntryUsable(expired, now), false);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,227 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const {
chargeGenerationCredits,
ensureGenerationCreditsAvailable,
resolveGenerationCreditCost,
} = await import('../src/lib/generation-credit-service.ts');
const repoRoot = path.resolve(import.meta.dirname, '..');
const SYSTEM_API_ID = '11111111-1111-1111-1111-111111111111';
const USER_ID = '22222222-2222-2222-2222-222222222222';
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
function createFakeClient({ apiRow, creditsBalance = 100, pendingJobs = [] } = {}) {
const calls = [];
const client = {
calls,
async query(sql, params = []) {
const text = String(sql);
calls.push({ sql: text, params });
if (text.includes('FROM generation_jobs')) {
return { rows: pendingJobs };
}
if (text.includes('FROM system_api_configs')) {
return { rows: apiRow ? [apiRow] : [] };
}
if (text.includes('SELECT credits_balance FROM profiles') && text.includes('FOR UPDATE')) {
return { rows: [{ credits_balance: creditsBalance }] };
}
if (text.includes('SELECT credits_balance FROM profiles')) {
return { rows: [{ credits_balance: creditsBalance }] };
}
if (text.includes('UPDATE profiles SET credits_balance')) {
return { rows: [], rowCount: 1 };
}
if (text.includes('INSERT INTO credit_transactions')) {
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
};
return client;
}
await runTest('calculates fixed system image credits from backend system_api_configs pricing', async () => {
const client = createFakeClient({
apiRow: {
id: SYSTEM_API_ID,
provider: 'mozheAPI',
name: 'gpt-image-2',
model_name: 'gpt-image-2',
type: 'image',
credits_per_use: 3,
billing_mode: 'fixed',
fixed_price: '3.0000',
},
});
const cost = await resolveGenerationCreditCost(client, {
type: 'image',
payload: { customApiConfig: { systemApiId: SYSTEM_API_ID } },
result: { images: ['a', 'b'] },
});
assert.equal(cost?.creditsCost, 6);
assert.equal(cost?.description, '图片生成 - gpt-image-2mozheAPI');
});
await runTest('calculates duration video credits from backend system_api_configs pricing', async () => {
const client = createFakeClient({
apiRow: {
id: SYSTEM_API_ID,
provider: '元界AI',
name: '视频模型',
model_name: 'video-model',
type: 'video',
credits_per_use: 0,
billing_mode: 'duration',
fixed_price: '0',
duration_price_per_second: '2.5',
},
});
const cost = await resolveGenerationCreditCost(client, {
type: 'video',
payload: { duration: '6', customApiConfig: { systemApiId: SYSTEM_API_ID } },
result: { videos: ['v'] },
});
assert.equal(cost?.creditsCost, 15);
assert.equal(cost?.description, '视频生成 - 视频模型元界AI');
});
await runTest('does not charge user custom or platform SDK generation without systemApiId', async () => {
const client = createFakeClient();
const charge = await chargeGenerationCredits(client, {
userId: USER_ID,
type: 'image',
payload: { customApiConfig: { customApiKeyId: '33333333-3333-3333-3333-333333333333' } },
result: { images: ['a'] },
});
assert.equal(charge, null);
assert.equal(client.calls.some(call => call.sql.includes('UPDATE profiles SET credits_balance')), false);
assert.equal(client.calls.some(call => call.sql.includes('INSERT INTO credit_transactions')), false);
});
await runTest('blocks queued system generation before running when credits are insufficient', async () => {
const client = createFakeClient({
creditsBalance: 2,
apiRow: {
id: SYSTEM_API_ID,
provider: 'mozheAPI',
name: 'gpt-image-2',
model_name: 'gpt-image-2',
type: 'image',
credits_per_use: 3,
billing_mode: 'fixed',
fixed_price: '3.0000',
},
});
await assert.rejects(
() => ensureGenerationCreditsAvailable(client, USER_ID, {
type: 'image',
payload: { count: 1, customApiConfig: { systemApiId: SYSTEM_API_ID } },
}),
/积分不足/,
);
});
await runTest('counts queued and running system generation cost before accepting a new job', async () => {
const apiRow = {
id: SYSTEM_API_ID,
provider: 'mozheAPI',
name: 'gpt-image-2',
model_name: 'gpt-image-2',
type: 'image',
credits_per_use: 3,
billing_mode: 'fixed',
fixed_price: '3.0000',
};
const client = createFakeClient({
creditsBalance: 5,
apiRow,
pendingJobs: [
{
type: 'image',
payload: {
prompt: 'pending image',
count: 1,
customApiConfig: { systemApiId: SYSTEM_API_ID },
},
},
],
});
await assert.rejects(
() => ensureGenerationCreditsAvailable(client, USER_ID, {
type: 'image',
payload: {
prompt: 'new image',
count: 1,
customApiConfig: { systemApiId: SYSTEM_API_ID },
},
}),
/积分不足/,
);
});
await runTest('job creation keeps credit preflight and insertion in one database transaction', () => {
const source = read('src/app/api/generation-jobs/route.ts');
const begin = source.indexOf("await client.query('BEGIN')");
const preflight = source.indexOf('await ensureGenerationCreditsAvailable');
const insert = source.indexOf('INSERT INTO generation_jobs');
const commit = source.lastIndexOf("await client.query('COMMIT')");
const rollback = source.indexOf("await client.query('ROLLBACK')");
assert.ok(begin > -1, 'job creation should start a transaction');
assert.ok(preflight > begin, 'credit preflight should run inside the transaction');
assert.ok(insert > preflight, 'job insertion should happen after credit preflight');
assert.ok(commit > insert, 'job creation should commit after insertion');
assert.ok(rollback > -1, 'job creation should rollback failed transactions');
});
await runTest('worker charges credits only after upstream generation returns a successful result', () => {
const source = read('src/lib/generation-job-worker.ts');
const successPath = source.indexOf('const result = await runGenerationPayload');
const chargePath = source.indexOf('const creditCharge = await settleJobCredits');
const failurePath = source.indexOf("status: 'failed'");
assert.ok(successPath > -1, 'worker should call upstream generation');
assert.ok(chargePath > successPath, 'credit charge must happen after successful upstream result');
assert.ok(failurePath > chargePath, 'failure handler must be outside the success charge path');
});
await runTest('video panels use backend returned creditsCost and creditsBalance instead of local predicted deduction', () => {
for (const relativePath of [
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /creditsCost\?: number; creditsBalance\?: number/, relativePath);
assert.match(source, /const creditsCost = Math\.max\(0, Number\(data\.creditsCost \|\| 0\)\)/, relativePath);
assert.match(source, /updateProfile\(\{ creditsBalance: data\.creditsBalance \}\)/, relativePath);
assert.doesNotMatch(source, /addCreditRecord\(/, relativePath);
assert.doesNotMatch(source, /balanceAfter: Math\.max\(0, currentCredits - credits\)/, relativePath);
}
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('generation job runner can dispatch reverse-prompt payloads to the reverse prompt route', () => {
const source = read('src/lib/generation-job-runner.ts');
assert.match(source, /type GenerationJobType = 'image' \| 'video' \| 'reverse-prompt';/);
assert.match(source, /const endpoint = type === 'image' \? '\/api\/generate\/image' : type === 'video' \? '\/api\/generate\/video' : '\/api\/generate\/reverse-prompt';/);
});
await runTest('generation job runner uses long-lived internal HTTP requests for slow video jobs', () => {
const source = read('src/lib/generation-job-runner.ts');
assert.match(source, /requestInternalGenerationJson/);
assert.match(source, /GENERATION_INTERNAL_REQUEST_TIMEOUT_MS/);
assert.match(source, /25 \* 60_000/);
assert.match(source, /req\.setTimeout\(timeoutMs/);
assert.doesNotMatch(source, /await fetch\(`\$\{baseUrl\}\$\{endpoint\}`/);
});
await runTest('generation jobs route can list active jobs and accept reverse-prompt submissions', () => {
const source = read('src/app/api/generation-jobs/route.ts');
assert.match(source, /export async function GET\(request: NextRequest\)/);
assert.match(source, /status IN \('queued', 'running'\)/);
assert.match(source, /type !== 'image' && type !== 'video' && type !== 'reverse-prompt'/);
});
await runTest('creation history post accepts trusted internal generation requests', () => {
const source = read('src/app/api/creation-history/route.ts');
assert.match(source, /isTrustedInternalGenerationRequest/);
assert.match(source, /x-miaojing-generation-user-id/);
assert.match(source, /if \(!userId\) return NextResponse\.json\(\{ error: '请先登录' \}, \{ status: 401 \}\);/);
});
await runTest('generation worker persists completed jobs back into creation history', () => {
const source = read('src/lib/generation-job-worker.ts');
assert.match(source, /\/api\/creation-history/);
assert.match(source, /persistGenerationHistoryRecord|saveGenerationHistoryRecord|creation history/i);
assert.match(source, /status: 'succeeded'/);
});
await runTest('image generation caps persisted images to the requested count', () => {
const source = read('src/app/api/generate/image/route.ts');
assert.match(source, /function capPersistedImagesToRequestedCount/);
assert.match(source, /imageResponsePayload\([^,\n]+,\s*n\)/);
assert.match(source, /persistQualifiedImageUrls\([^)]*requestedCount/s);
});
await runTest('creation history serializes same-user same-url inserts to prevent duplicate rows', () => {
const source = read('src/app/api/creation-history/route.ts');
assert.match(source, /pg_advisory_xact_lock/);
assert.match(source, /historyRecordDedupeLockKey/);
assert.match(source, /WHERE user_id = \$1 AND result_url = \$2/);
});
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
'src/components/create/reverse-prompt-panel.tsx',
]) {
const source = read(relativePath);
assert.match(source, /useGenerationJobRecovery|fetchActiveGenerationJobs|\/api\/generation-jobs\?status=queued%2Crunning|\/api\/generation-jobs\?status=queued,running/);
}
});
await runTest('recovered job polling is not cancelled by active task state updates', () => {
const source = read('src/components/create/use-generation-job-recovery.ts');
assert.match(source, /knownJobIdsRef/);
const effectMatches = [...source.matchAll(/useEffect\(\(\) => \{[\s\S]*?void recover\(\);[\s\S]*?\}, \[([^\]]*)\]\);/g)];
assert.ok(effectMatches.length > 0, 'expected to find the recovery polling effect');
const dependencies = effectMatches.at(-1)?.[1] || '';
assert.doesNotMatch(dependencies, /\btypes\b/);
assert.doesNotMatch(dependencies, /\bnormalizedKnownJobIds\b/);
});
await runTest('active generation job recovery avoids anonymous polling and dedupes short-lived list requests', () => {
const source = read('src/lib/generation-job-client.ts');
assert.match(source, /const ACTIVE_JOBS_REQUEST_TTL_MS = \d+;/);
assert.match(source, /activeJobsRequestCache/);
assert.match(source, /if \(!authToken\) return \[\];/);
assert.match(source, /getActiveJobsRequestKey\(normalizedTypes, authToken\)/);
});
await runTest('generation jobs stay recoverable after the browser closes before the result is consumed', () => {
const clientSource = read('src/lib/generation-job-client.ts');
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
assert.match(clientSource, /PENDING_GENERATION_JOBS_STORAGE_PREFIX/);
assert.match(clientSource, /rememberPendingGenerationJob/);
assert.match(clientSource, /forgetPendingGenerationJob/);
assert.match(clientSource, /fetchGenerationJobStatus/);
assert.match(clientSource, /fetchRecoverableGenerationJobs/);
assert.match(clientSource, /rememberPendingGenerationJob\(type,\s*createData\.jobId/);
assert.match(recoverySource, /fetchRecoverableGenerationJobs/);
assert.doesNotMatch(recoverySource, /const jobs = await fetchActiveGenerationJobs\(typesRef\.current\);/);
});
await runTest('terminal recovered generation jobs clear pending browser recovery state', () => {
const clientSource = read('src/lib/generation-job-client.ts');
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
assert.match(clientSource, /statusData\.status === 'succeeded'[\s\S]*forgetPendingGenerationJob/);
assert.match(clientSource, /statusData\.status === 'failed'[\s\S]*forgetPendingGenerationJob/);
assert.match(clientSource, /statusData\.status === 'cancelled'[\s\S]*forgetPendingGenerationJob/);
assert.match(clientSource, /cancelGenerationJob[\s\S]*forgetPendingGenerationJob/);
assert.match(recoverySource, /forgetPendingGenerationJob/);
});
await runTest('active job recovery dedupes locally submitted tasks by client request id', () => {
const taskListSource = read('src/components/create/generation-task-list.tsx');
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
const textToImageSource = read('src/components/create/text-to-image.tsx');
assert.match(taskListSource, /clientRequestId\?: string;/);
assert.match(recoverySource, /payload\?\.clientRequestId/);
assert.match(recoverySource, /getJobIdentityIds/);
assert.match(recoverySource, /identityIds\.some\(id => activeJobIdsRef\.current\.has\(id\) \|\| knownJobIdsRef\.current\.has\(id\)\)/);
assert.match(textToImageSource, /clientRequestId: taskId/);
assert.match(textToImageSource, /task\.clientRequestId/);
});
await runTest('generation job API dedupes active jobs by semantic payload without client request id', () => {
const source = read('src/app/api/generation-jobs/route.ts');
assert.match(source, /payload - 'clientRequestId'/);
assert.match(source, /\$3::jsonb - 'clientRequestId'/);
assert.match(source, /deduplicated: true/);
});
await runTest('create panels do not prepend duplicate completed media urls', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /filter\(url => !prev\.includes\(url\)\)/, `${relativePath} should filter duplicate result URLs before prepending`);
}
});
await runTest('create panels block duplicate in-flight submissions before creating another job', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /activeSubmissionSignaturesRef = useRef\(new Set<string>\(\)\)/, `${relativePath} should keep in-flight submission signatures`);
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should check an in-flight duplicate signature`);
assert.match(source, /activeSubmissionSignaturesRef\.current\.add\(submissionSignature\)/, `${relativePath} should mark the signature before creating the job`);
assert.match(source, /activeSubmissionSignaturesRef\.current\.delete\(submissionSignature\)/, `${relativePath} should clear the signature after the job settles`);
assert.match(source, /相同任务正在生成中,请勿重复提交/, `${relativePath} should explain duplicate submit prevention`);
}
});
await runTest('create panels do not label active generation as another submit action', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
'src/components/create/mobile-creation-composer.tsx',
]) {
const source = read(relativePath);
assert.doesNotMatch(source, /继续提交任务/, `${relativePath} should not invite duplicate submits while a task is running`);
}
});
await runTest('generation job client builds auth headers from one parsed token per request', () => {
const source = read('src/lib/generation-job-client.ts');
assert.match(source, /function getAuthHeaders\(authToken = getAuthToken\(\)\)/);
assert.match(source, /const authHeaders = getAuthHeaders\(\);/);
assert.doesNotMatch(source, /\.\.\.\(getAuthToken\(\) \? \{ Authorization: `Bearer \$\{getAuthToken\(\)\}` \} : \{\}\)/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
const createPanels = [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
];
await runTest('create panels allow a new different submission while another task is active', () => {
for (const relativePath of createPanels) {
const source = read(relativePath);
assert.doesNotMatch(source, /disabled=\{!hasModels \|\| generating\}/, `${relativePath} should not disable submit only because active tasks exist`);
assert.doesNotMatch(source, /任务生成中/, `${relativePath} should keep the submit action available while tasks are running`);
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should still block the same in-flight submission`);
}
});
await runTest('generation job status supports user cancellation end to end', () => {
assert.match(read('src/lib/generation-job-client.ts'), /'cancelled'/);
assert.match(read('src/lib/generation-job-client.ts'), /cancelGenerationJob/);
assert.match(read('src/components/create/generation-task-list.tsx'), /onCancelTask/);
assert.match(read('src/components/create/generation-task-list.tsx'), /取消任务/);
const statusRoute = read('src/app/api/generation-jobs/[id]/route.ts');
assert.match(statusRoute, /export async function (PATCH|DELETE)/);
assert.match(statusRoute, /status = 'cancelled'/);
const worker = read('src/lib/generation-job-worker.ts');
assert.match(worker, /isJobStillRunning/);
assert.match(worker, /cancelled/);
assert.match(worker, /skip/i);
});
await runTest('image-to-image and image-to-video share reference images to gallery', () => {
for (const relativePath of [
'src/components/create/image-to-image.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /referenceImage:\s*refImages\[0\]\?\.dataUrl/, `${relativePath} should share the primary reference`);
assert.match(source, /referenceImages:\s*refImages\.map\(img => img\.dataUrl\)/, `${relativePath} should share all references`);
}
});
await runTest('gallery publish persists reference images as stable local-storage URLs', () => {
const publishRoute = read('src/app/api/gallery/publish/route.ts');
const mediaHelper = read('src/lib/gallery-publish-media.ts');
assert.match(mediaHelper, /resolveGalleryReferenceImages/);
assert.match(mediaHelper, /gallery\/references/);
assert.match(publishRoute, /resolveGalleryReferenceImages/);
assert.match(publishRoute, /galleryReferenceImages/);
});
await runTest('gallery detail shows reference images but does not expose reference downloads', () => {
const source = read('src/app/gallery/page.tsx');
assert.match(source, /getWorkReferenceImages/);
assert.match(source, /getWorkReferenceImageThumbnails/);
assert.match(source, /ReferencePreviewImage/);
assert.match(source, /thumbnailSrc=\{selectedReferenceImageThumbnails\[index\]\}/);
assert.match(source, /参考图/);
assert.match(source, /referencePreviewSrc/);
assert.match(source, /disableContextMenu/);
assert.match(source, /onContextMenu=\{[^}]*preventDefault/s);
assert.doesNotMatch(source, /handleDownload\([^)]*reference/i);
});
await runTest('gallery api merges reference metadata from duplicate result rows', () => {
const route = read('src/app/api/gallery/route.ts');
assert.match(route, /mergeGalleryRowMetadata/);
assert.match(route, /dedupeGalleryRowsByResultUrl/);
assert.match(route, /referenceImageThumbnails/);
});
await runTest('inspiration reuse preserves original reference images when available', () => {
const reuseSource = read('src/lib/creation-reuse.ts');
assert.match(reuseSource, /explicitReferences/);
assert.match(reuseSource, /useOutputAsReference/);
const inspirationSource = read('src/components/create/inspiration-gallery-dialog.tsx');
assert.match(inspirationSource, /referenceImages/);
assert.match(inspirationSource, /referencePreviewSrc/);
assert.match(inspirationSource, /disableContextMenu/);
assert.doesNotMatch(inspirationSource, /window\.open/);
assert.match(inspirationSource, /buildCreationReuseDraft/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const { normalizeImageBufferForOutputFormat } = await import(`../src/lib/media-storage.ts?test=${Date.now()}`);
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('converts upstream PNG bytes to JPEG when JPEG is requested', async () => {
const upstreamPng = await sharp({
create: {
width: 12,
height: 8,
channels: 4,
background: { r: 64, g: 128, b: 192, alpha: 1 },
},
}).png().toBuffer();
const converted = await normalizeImageBufferForOutputFormat({
buffer: upstreamPng,
mimeType: 'image/png',
ext: 'png',
}, 'jpeg', 'high');
assert.equal(converted.mimeType, 'image/jpeg');
assert.equal(converted.ext, 'jpg');
assert.deepEqual([...converted.buffer.subarray(0, 3)], [0xff, 0xd8, 0xff]);
const metadata = await sharp(converted.buffer).metadata();
assert.equal(metadata.format, 'jpeg');
assert.equal(metadata.width, 12);
assert.equal(metadata.height, 8);
});
await runTest('image generation persistence passes the requested output format to storage', () => {
const source = fs.readFileSync(path.join(repoRoot, 'src/app/api/generate/image/route.ts'), 'utf8');
assert.match(source, /persistImageWithMetadata\(url,\s*prefix,\s*outputFormat,\s*imageQuality\)/);
assert.match(source, /requestQualifiedCustomImages\([\s\S]*resolvedOutputFormat,\s*resolvedImageQuality,\s*handleUpstreamProgress/);
assert.match(source, /User API Manifest Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
assert.match(source, /Custom API img2img strategy1'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /Custom API img2img strategy2'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /Custom API img2img strategy3'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /SDK Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
});
await runTest('image downloads derive filename extension from URL or selected output format', () => {
const utils = fs.readFileSync(path.join(repoRoot, 'src/lib/utils.ts'), 'utf8');
const textToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/text-to-image.tsx'), 'utf8');
const imageToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-image.tsx'), 'utf8');
const detail = fs.readFileSync(path.join(repoRoot, 'src/components/creation-detail-dialog.tsx'), 'utf8');
const lightbox = fs.readFileSync(path.join(repoRoot, 'src/components/lightbox.tsx'), 'utf8');
assert.match(utils, /export function getImageDownloadExtension\(/);
assert.match(utils, /jpeg['"]?\s*\)\s*return ['"]jpg['"]/);
assert.doesNotMatch(textToImage, /downloadFile\(url,\s*`miaojing-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
assert.doesNotMatch(imageToImage, /downloadFile\(url,\s*`miaojing-img2img-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
assert.match(textToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
assert.match(imageToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
assert.match(detail, /getImageDownloadExtension\(\s*url,[\s\S]*record\.params\?\.outputFormat/);
assert.match(lightbox, /getImageDownloadExtension\(src\)/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
function read(path) {
return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('built-in layout composition skill records source, license, and 100 layout references', () => {
const source = read('src/lib/layout-composition-skill.ts');
assert.match(source, /100-layout-compositions/);
assert.match(source, /CC BY 4\.0/);
assert.match(source, /TOTAL_LAYOUT_COMPOSITION_COUNT\s*=\s*100/);
assert.match(source, /layoutNumber\.toString\(\)\.padStart\(3,\s*'0'\)/);
assert.match(source, /images\/\$\{id\}\.png/);
assert.match(source, /thumbnails\/\$\{id\}\.jpg/);
assert.match(source, /不要添加文字、Logo、品牌标识或海报排版/);
});
await runTest('site config exposes an admin-controlled image composition skill toggle', () => {
const route = read('src/app/api/site-config/route.ts');
const client = read('src/lib/site-config.ts');
const settings = read('src/components/admin/settings-tab.tsx');
assert.match(route, /image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE/);
assert.match(route, /imageCompositionSkillEnabled/);
assert.match(client, /imageCompositionSkillEnabled: boolean/);
assert.match(settings, /100 Layout Compositions/);
assert.match(settings, /handleImageCompositionSkillToggle/);
});
await runTest('image generation route applies the layout composition skill before upstream requests', () => {
const route = read('src/app/api/generate/image/route.ts');
assert.match(route, /applyLayoutCompositionSkillToPrompt/);
assert.match(route, /promptWithCompositionSkill/);
assert.match(route, /promptForGeneration = mergeStylePrompt\(promptWithCompositionSkill, stylePrompt\)/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const repoRoot = path.resolve(import.meta.dirname, '..');
const policyModule = await import('../src/lib/media-watermark-policy.ts');
const watermarkModule = await import('../src/lib/media-watermark.ts');
const {
canAccessOriginalMedia,
getWatermarkedStorageKey,
isWatermarkableStorageKey,
shouldWatermarkStorageResponse,
shouldWatermarkDownloadResponse,
} = policyModule.default || policyModule;
const { applyImageWatermark } = watermarkModule.default || watermarkModule;
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('watermark policy targets generated work media without touching site assets or avatars', () => {
assert.equal(isWatermarkableStorageKey('generated/images/work.png'), true);
assert.equal(isWatermarkableStorageKey('generated/videos/work.mp4'), true);
assert.equal(isWatermarkableStorageKey('gallery/images/work.webp'), true);
assert.equal(isWatermarkableStorageKey('gallery/videos/work.mp4'), true);
assert.equal(isWatermarkableStorageKey('thumbnails/generated/images/work-m1280q86.webp'), true);
assert.equal(isWatermarkableStorageKey('thumbnails/works/videos/frame-video-frame-m1280q86-v1.webp'), true);
assert.equal(isWatermarkableStorageKey('imported/works/results/images/imported.jpg'), true);
assert.equal(isWatermarkableStorageKey('site-assets/logo.png'), false);
assert.equal(isWatermarkableStorageKey('avatars/user.webp'), false);
assert.equal(isWatermarkableStorageKey('user-api-manifests/user/key.json'), false);
assert.equal(isWatermarkableStorageKey('reverse-prompt/reference-images/input.png'), false);
});
await runTest('admin-authorized users can access original media while others receive watermarked downloads', () => {
assert.equal(canAccessOriginalMedia(null), false);
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: true }), true);
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: false }), false);
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: false }), false);
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: true }), true);
assert.equal(canAccessOriginalMedia({ role: 'admin', membershipTier: 'free', watermarkDisabled: false }), true);
});
await runTest('storage responses default to watermarked generated media', () => {
assert.equal(shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', null), true);
assert.equal(
shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: true,
}),
true,
);
assert.equal(shouldWatermarkStorageResponse('site-assets/logo.png', 'image/png', null), false);
});
await runTest('download responses only skip watermark for privileged users who disabled it', () => {
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', null), true);
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: false,
}), true);
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
role: 'vip',
membershipTier: 'pro',
watermarkDisabled: true,
}), false);
assert.equal(shouldWatermarkDownloadResponse('site-assets/logo.png', 'image/png', null), false);
});
await runTest('watermarked cache keys are deterministic and separated by media kind', () => {
assert.match(getWatermarkedStorageKey('generated/images/work.png', 'image/png'), /^watermarked\/images\/[a-f0-9]{64}\.png$/);
assert.match(getWatermarkedStorageKey('gallery/images/work.webp', 'image/webp'), /^watermarked\/images\/[a-f0-9]{64}\.webp$/);
assert.match(getWatermarkedStorageKey('generated/videos/work.mp4', 'video/mp4'), /^watermarked\/videos\/[a-f0-9]{64}\.mp4$/);
});
await runTest('image watermark renderer visibly changes raster media', async () => {
const input = await sharp({
create: {
width: 640,
height: 360,
channels: 4,
background: { r: 36, g: 50, b: 72, alpha: 1 },
},
})
.png()
.toBuffer();
const output = await applyImageWatermark(input, {
key: 'generated/images/work.png',
contentType: 'image/png',
});
assert.notDeepEqual(output, input);
const metadata = await sharp(output).metadata();
assert.equal(metadata.width, 640);
assert.equal(metadata.height, 360);
});
await runTest('watermark renderer dedupes concurrent generation for the same media', () => {
const source = read('src/lib/media-watermark.ts');
assert.match(source, /inflightWatermarkJobs/);
assert.match(source, /inflightWatermarkJobs\.get\(outputKey\)/);
assert.match(source, /inflightWatermarkJobs\.delete\(outputKey\)/);
});
await runTest('local storage route uses watermark access instead of exposing raw object URLs by default', () => {
const source = read('src/app/api/local-storage/[...path]/route.ts');
assert.match(source, /shouldWatermarkStorageResponse\(/);
assert.match(source, /serveWatermarkedStorageFile\(/);
assert.match(source, /getStoredThumbnailResponse\(/);
assert.match(source, /thumbnailResponse/);
const thumbnailResponseFunction = source.slice(
source.indexOf('async function getStoredThumbnailResponse'),
source.indexOf('function normalizeStoragePath'),
);
assert.doesNotMatch(thumbnailResponseFunction, /NextResponse\.redirect/);
assert.doesNotMatch(
source,
/shouldWatermarkStorageResponse[\s\S]+?fileExistsAsync\(/,
'storage display route should not require a slow object HEAD before watermark rendering',
);
});
await runTest('download route applies watermark and checks authenticated no-watermark entitlement', () => {
const source = read('src/app/api/download/route.ts');
assert.match(source, /resolveMediaWatermarkAccess\(request\)/);
assert.match(source, /serveWatermarkedDownloadFile\(/);
assert.match(source, /canAccessOriginalMedia\(/);
});
await runTest('profile API and auth store carry the member no-watermark preference', () => {
const preferenceSource = read('src/lib/profile-preferences.ts');
const profileRouteSource = read('src/app/api/profile/route.ts');
const authStoreSource = read('src/lib/auth-store.ts');
assert.match(preferenceSource, /watermark_disabled BOOLEAN NOT NULL DEFAULT false/);
assert.match(profileRouteSource, /watermark_disabled/);
assert.match(profileRouteSource, /watermarkDisabled/);
assert.match(profileRouteSource, /COALESCE\(watermark_disabled,\s*false\) AS watermark_disabled/);
assert.match(authStoreSource, /watermarkDisabled:\s*boolean/);
assert.match(authStoreSource, /watermark_disabled === true/);
});
await runTest('profile page exposes a VIP-only no-watermark download switch', () => {
const source = read('src/app/profile/page.tsx');
assert.match(source, /import \{ Switch \} from '@\/components\/ui\/switch'/);
assert.match(source, /watermarkDisabled/);
assert.match(source, /checked=\{accountForm\.watermarkDisabled\}/);
assert.match(source, /disabled=\{!canDisableWatermark/);
assert.doesNotMatch(source, /watermarkDisabled:\s*canDisableWatermark && accountForm\.watermarkDisabled/);
assert.match(source, /if \(canDisableWatermark\) \{/);
assert.match(source, /payload\.watermarkDisabled = accountForm\.watermarkDisabled === true/);
assert.match(source, /下载无水印/);
});
await runTest('profile API preserves admin-granted no-watermark access for free users', () => {
const source = read('src/app/api/profile/route.ts');
assert.match(source, /const canManageOwnWatermark = canDisableWatermarkForProfile/);
assert.match(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canManageOwnWatermark\) \{/);
assert.match(source, /watermarkDisabled && !canManageOwnWatermark/);
assert.match(source, /const shouldUpdateWatermark = hasWatermarkDisabled && canManageOwnWatermark/);
assert.match(source, /shouldUpdateWatermark,\s*watermarkDisabled,\s*tokenUserId/s);
assert.doesNotMatch(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canDisableWatermarkForProfile/);
});
await runTest('admin users API and UI can toggle no-watermark downloads per user', () => {
const serviceSource = read('src/lib/admin-users-service.ts');
const uiSource = read('src/components/admin/user-management-tab.tsx');
const adminStoreSource = read('src/lib/admin-store.ts');
assert.match(serviceSource, /ensureProfilePreferenceSchema/);
assert.match(serviceSource, /COALESCE\(p\.watermark_disabled,\s*false\) AS watermark_disabled/);
assert.match(serviceSource, /updates\.watermarkDisabled/);
assert.match(serviceSource, /watermark_disabled = \$\$\{paramIdx\+\+\}/);
assert.match(adminStoreSource, /watermarkDisabled\??:\s*boolean/);
assert.match(uiSource, /import \{ Switch \} from '@\/components\/ui\/switch'/);
assert.match(uiSource, /watermark_disabled:\s*boolean/);
assert.match(uiSource, /watermarkDisabled:\s*u\.watermark_disabled === true/);
assert.match(uiSource, /setEditWatermarkDisabled\(user\.watermarkDisabled === true\)/);
assert.match(uiSource, /watermarkDisabled:\s*editWatermarkDisabled/);
assert.match(uiSource, /checked=\{editWatermarkDisabled\}/);
assert.match(uiSource, /下载无水印/);
});
await runTest('download helpers forward the current session to the download API', () => {
const source = read('src/lib/utils.ts');
assert.match(source, /function getStoredAccessTokenForDownload\(/);
assert.match(source, /Authorization: `Bearer \$\{token\}`/);
assert.match(source, /downloadToken/);
assert.match(source, /includeDownloadToken: false/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
const panelFiles = [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
'src/components/create/reverse-prompt-panel.tsx',
];
await runTest('mobile composer supports custom input content for non-text-only creation modes', () => {
const source = read('src/components/create/mobile-creation-composer.tsx');
assert.match(source, /input\?: ReactNode/, 'MobileCreationComposer should expose an input slot');
assert.match(source, /\{input \?\? \(/, 'MobileCreationComposer should render the custom input instead of the default textarea');
});
await runTest('all creation panels render the mobile composer', () => {
for (const relativePath of panelFiles) {
const source = read(relativePath);
assert.match(source, /MobileCreationComposer/, `${relativePath} should import and render MobileCreationComposer`);
}
});
await runTest('non-text-to-image panels keep mobile conversation status flows', () => {
for (const relativePath of panelFiles.slice(1)) {
const source = read(relativePath);
assert.match(source, /create-mobile-history-flow/, `${relativePath} should render a mobile history/status flow above the fixed composer`);
assert.match(source, /useIsMobile/, `${relativePath} should only mount the mobile flow on mobile viewports`);
}
});
await runTest('mobile image reference modes preserve mention-aware prompt input', () => {
for (const relativePath of [
'src/components/create/image-to-image.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /ReferenceImageMentionControls/, `${relativePath} should still use @ reference controls`);
assert.match(source, /create-mobile-reference-strip/, `${relativePath} should show uploaded references in the mobile composer`);
assert.match(source, /input=\{\(/, `${relativePath} should pass the mention-aware input into MobileCreationComposer`);
}
});
await runTest('reverse prompt mobile composer keeps upload and mode controls reachable', () => {
const source = read('src/components/create/reverse-prompt-panel.tsx');
assert.match(source, /create-mobile-reverse-upload/, 'reverse prompt should expose mobile upload/change-image controls');
assert.match(source, /create-mobile-reverse-controls/, 'reverse prompt should expose mobile mode/language controls');
assert.match(source, /MobileCreationComposer/, 'reverse prompt should render MobileCreationComposer');
});
await runTest('mobile conversation is separated from the bottom composer instead of being covered by it', () => {
const css = read('src/app/globals.css');
const composerSource = read('src/components/create/mobile-creation-composer.tsx');
assert.match(css, /\.create-mobile-shell\s*\{[^}]*height:\s*calc\(/, 'mobile create shell should own the available viewport-height region');
assert.match(css, /\.create-chat-layout\s*\{[^}]*height:\s*100%/, 'mobile create layout should fill the shell instead of growing under the composer');
assert.match(css, /\.create-chat-layout\s*\{[^}]*overflow:\s*hidden/, 'mobile create layout should clip children to the conversation/composer split');
assert.match(css, /\.create-chat-thread\s*\{[^}]*overflow-y:\s*auto/, 'mobile conversation thread should scroll independently above the composer');
assert.match(css, /\.create-chat-thread\s*\{[^}]*padding-bottom:\s*calc\(var\(--create-mobile-composer-height/, 'mobile conversation thread should reserve the measured composer height');
assert.match(css, /\.create-mobile-dialog-composer\s*\{[^}]*position:\s*fixed/, 'mobile composer should stay fixed to the bottom like a chat input');
assert.doesNotMatch(css, /\.create-mobile-dialog-composer\s*\{[^}]*position:\s*sticky/, 'mobile composer should not drift inside the thread layout');
assert.match(composerSource, /ResizeObserver/, 'mobile composer should measure its height when params, styles, or references change');
assert.match(composerSource, /--create-mobile-composer-height/, 'mobile composer should publish its measured height to the layout');
assert.doesNotMatch(css, /\.create-mobile-dialog-composer::before/, 'mobile composer should not render the user screenshot annotation as a red divider');
assert.doesNotMatch(css, /rgb\(219 73 50/, 'mobile create UI should not include a hard-coded red annotation line');
});
await runTest('mobile bottom navigation is not trapped by the sticky header', () => {
const source = read('src/components/navbar.tsx');
assert.match(source, /return\s*\(\s*<>/, 'Navbar should wrap the sticky header and fixed mobile nav as siblings');
assert.match(
source,
/<\/header>\s*<nav className="fixed inset-x-0 bottom-0/,
'fixed mobile bottom navigation should be rendered outside the sticky header backdrop context',
);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('create page keeps primary creation panels in the initial client bundle for instant mode switches', () => {
const source = read('src/app/create/page.tsx');
assert.match(source, /import\s+\{\s*TextToImagePanel\s*\}\s+from\s+'@\/components\/create\/text-to-image'/);
assert.match(source, /import\s+\{\s*ImageToImagePanel\s*\}\s+from\s+'@\/components\/create\/image-to-image'/);
assert.match(source, /import\s+\{\s*TextToVideoPanel\s*\}\s+from\s+'@\/components\/create\/text-to-video'/);
assert.match(source, /import\s+\{\s*ImageToVideoPanel\s*\}\s+from\s+'@\/components\/create\/image-to-video'/);
assert.match(source, /import\s+ReversePromptPanel\s+from\s+'@\/components\/create\/reverse-prompt-panel'/);
assert.doesNotMatch(source, /ssr:\s*false/);
});
await runTest('create page avoids server/client tab hydration mismatch on type links', () => {
const source = read('src/app/create/page.tsx');
assert.match(source, /const \[activeTab, setActiveTab\] = useState\(DEFAULT_CREATE_TAB\)/);
assert.doesNotMatch(source, /useState\(\(\) => normalizeCreateTab\(typeParam\)/);
assert.match(source, /useEffect\(\(\) => \{\s*const nextTab = normalizeCreateTab\(typeParam\)/s);
});
await runTest('primary navigation avoids eager all-route prefetch pressure', () => {
const source = read('src/components/navbar.tsx');
assert.doesNotMatch(source, /router\.prefetch\('/);
assert.doesNotMatch(source, /prefetch=\{true\}/);
});
await runTest('non-critical visit tracking waits for browser idle time', () => {
const source = read('src/components/visit-tracker.tsx');
assert.match(source, /requestIdleCallback/);
assert.match(source, /keepalive:\s*true/);
});
await runTest('profile account page does not eagerly mount heavy record stores for inactive tabs', () => {
const source = read('src/app/profile/page.tsx');
assert.doesNotMatch(source, /useCreationHistory\(/);
assert.doesNotMatch(source, /useCreditRecords\(/);
assert.doesNotMatch(source, /useUserOrders\(/);
assert.match(source, /getCreationRecordCount\(/);
assert.doesNotMatch(source, /<CreditsTab[^>]*creditRecords=/);
assert.doesNotMatch(source, /<OrdersTab[^>]*orders=/);
});
await runTest('creation panels request scoped lightweight history instead of repeated full history payloads', () => {
const expectations = [
['src/components/create/text-to-image.tsx', "useCreationHistory({ mode: 'text2img', limit: 60 })"],
['src/components/create/image-to-image.tsx', "useCreationHistory({ mode: 'img2img', limit: 60 })"],
['src/components/create/text-to-video.tsx', "useCreationHistory({ mode: 'text2video', limit: 60 })"],
['src/components/create/image-to-video.tsx', "useCreationHistory({ mode: 'img2video', limit: 60 })"],
['src/components/create/reverse-prompt-panel.tsx', "useCreationHistory({ mode: 'reverse-prompt', limit: 60 })"],
];
for (const [file, expected] of expectations) {
assert.ok(read(file).includes(expected), `${file} should use scoped creation history`);
}
const storeSource = read('src/lib/creation-history-store.ts');
assert.match(storeSource, /inflightHistoryRequests/);
assert.match(storeSource, /buildHistoryUrl\(scope/);
const routeSource = read('src/app/api/creation-history/route.ts');
assert.match(routeSource, /searchParams\.get\('limit'\)/);
assert.match(routeSource, /searchParams\.get\('mode'\)/);
});

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
checkStorageUrl,
getMigrationCheckBaseUrl,
} from './migration-integrity-check-helpers.mjs';
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('local-storage route serves thumbnails from local disk and redirects object originals', () => {
const source = read('src/app/api/local-storage/[...path]/route.ts');
assert.match(source, /filePath\.startsWith\('thumbnails\/'\)/);
assert.match(source, /localStorage\.fileExists\(filePath\)/);
assert.match(source, /localStorage\.readFile\(filePath\)/);
assert.match(source, /localStorage\.generateObjectReadUrl\(filePath,\s*300\)/);
assert.match(source, /NextResponse\.redirect\(objectUrl/);
});
await runTest('admin provider and recommendation reads require admin auth', () => {
for (const relativePath of [
'src/app/api/admin/providers/route.ts',
'src/app/api/admin/model-recommendations/route.ts',
]) {
const source = read(relativePath);
assert.match(source, /export async function GET\(request: NextRequest\)/, relativePath);
assert.match(source, /const authError = await requireAdmin\(request\)/, relativePath);
assert.match(source, /if \(authError\) return authError;/, relativePath);
}
const tabSource = read('src/components/admin/api-management-tab.tsx');
assert.match(tabSource, /fetch\('\/api\/admin\/providers', \{ headers: authHeaders\(accessToken\) \}\)/);
assert.match(tabSource, /fetch\('\/api\/admin\/model-recommendations', \{ headers: authHeaders\(accessToken\) \}\)/);
});
await runTest('migration check defaults to production web port unless overridden', () => {
assert.equal(getMigrationCheckBaseUrl({}), 'http://127.0.0.1:8000');
assert.equal(
getMigrationCheckBaseUrl({ MIGRATION_CHECK_BASE_URL: 'http://127.0.0.1:5000' }),
'http://127.0.0.1:5000',
);
});
await runTest('migration storage URL check records fetch failures instead of throwing', async () => {
const result = await checkStorageUrl('http://127.0.0.1:8000', '/api/local-storage/missing.webp', {
timeoutMs: 10,
fetchImpl: async () => {
throw new Error('connect timeout');
},
});
assert.deepEqual(result, {
ok: false,
error: 'connect timeout',
});
});
await runTest('migration storage URL check treats local-storage redirects as reachable', async () => {
const calls = [];
const result = await checkStorageUrl('http://127.0.0.1:8000', '/api/local-storage/gallery/images/work.png', {
timeoutMs: 10,
fetchImpl: async (_url, init) => {
calls.push(init);
return new Response(null, {
status: 302,
headers: { Location: 'https://object-storage.example/work.png' },
});
},
});
assert.deepEqual(result, { ok: true });
assert.equal(calls[0].method, 'HEAD');
assert.equal(calls[0].redirect, 'manual');
});
await runTest('migration integrity script uses resilient storage URL helpers', () => {
const source = read('scripts/migration-integrity-check.mjs');
assert.match(source, /getMigrationCheckBaseUrl\(\)/);
assert.match(source, /getMigrationStorageUrlTimeoutMs\(\)/);
assert.match(source, /getMigrationStorageUrlConcurrency\(\)/);
assert.match(source, /checkStorageUrl\(baseUrl, row\.url/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('creation history persists and backfills reference image URLs and thumbnails', () => {
const route = read('src/app/api/creation-history/route.ts');
assert.match(route, /persistReferenceImages/);
assert.match(route, /getReferenceImageInputs/);
assert.match(route, /referenceImageThumbnails/);
assert.match(route, /shouldPatchReferences/);
assert.match(route, /mergeWorkRowMetadata/);
assert.match(route, /hasReferenceMetadata/);
assert.match(route, /params = \$4::jsonb/);
});
await runTest('reference image backfill script can persist old data-url history rows', () => {
const script = read('scripts/backfill-work-reference-images.mjs');
assert.match(script, /--dry-run/);
assert.match(script, /persistReferenceImages/);
assert.match(script, /referenceImageThumbnails/);
assert.match(script, /params->>'creationMode' IN \('img2img', 'img2video'\)/);
});
await runTest('generation worker keeps data-url reference inputs for server-side persistence', () => {
const worker = read('src/lib/generation-job-worker.ts');
assert.match(worker, /function safeReferenceInput/);
assert.match(worker, /function getReferenceInputs/);
assert.match(worker, /const references = getReferenceInputs\(payload\)/);
assert.doesNotMatch(worker, /const references = getSafeReferenceImages\(payload\);\n return \{/);
});
await runTest('reference previews use lightweight thumbnails and do not expose downloads in detail', () => {
const detail = read('src/components/creation-detail-dialog.tsx');
const imageToImage = read('src/components/create/image-to-image.tsx');
const imageToVideo = read('src/components/create/image-to-video.tsx');
const preview = read('src/components/reference-preview-image.tsx');
assert.match(detail, /ReferencePreviewImage/);
assert.match(detail, /thumbnailSrc=\{referenceImageThumbnails\[index\]\}/);
assert.doesNotMatch(detail, /miaojing-reference-\$\{record\.id\}/);
assert.match(imageToImage, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
assert.match(imageToVideo, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
assert.match(preview, /const MAX_EDGE = 360/);
assert.match(preview, /canvas\.toDataURL\('image\/webp', QUALITY\)/);
});
await runTest('image-to-video history cards avoid eager original video metadata loads', () => {
const source = read('src/components/create/image-to-video.tsx');
assert.match(source, /record\.thumbnailUrl/);
assert.doesNotMatch(source, /<video src=\{record\.url\} className="w-full h-full object-cover" preload="metadata" \/>/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
const {
buildReferenceImagePrompt,
normalizeReferenceImageAnnotations,
} = await import('../src/lib/reference-image-prompt.ts');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('adds model-readable mappings for referenced uploaded images', () => {
const prompt = '让 @参考图2 的外套穿到 @参考图1 的人物身上,保持 @参考图1 的脸部特征';
const result = buildReferenceImagePrompt(prompt, 2, [
{ index: 1, token: '@参考图1', name: 'person.jpg', width: 1024, height: 1536 },
{ index: 2, token: '@参考图2', name: 'coat.png', width: 800, height: 800 },
]);
assert.ok(result.startsWith(prompt));
assert.match(result, /参考图标注说明/);
assert.match(result, /@参考图1 对应上传的第1张参考图/);
assert.match(result, /文件名person\.jpg/);
assert.match(result, /尺寸1024x1536/);
assert.match(result, /@参考图2 对应上传的第2张参考图/);
assert.match(result, /文件名coat\.png/);
assert.match(result, /尺寸800x800/);
assert.match(result, /当提示词提到 @参考图2 时/);
});
await runTest('normalizes annotations and ignores impossible image indexes', () => {
const annotations = normalizeReferenceImageAnnotations([
{ index: 2, token: '@衣服', name: 'coat.png' },
{ index: 9, token: '@不存在', name: 'missing.png' },
{ index: 1, token: '人物', name: 'person.jpg', width: 'bad', height: 1024 },
], 2);
assert.deepEqual(annotations, [
{ index: 2, token: '@衣服', name: 'coat.png' },
{ index: 1, token: '@参考图1', name: 'person.jpg', height: 1024 },
]);
});
await runTest('does not alter prompts without reference images', () => {
assert.equal(buildReferenceImagePrompt('一只杯子', 0, []), '一只杯子');
assert.equal(buildReferenceImagePrompt('', 2, []), '');
});
await runTest('image-to-image and image-to-video send reference annotations from the @ picker', () => {
const imageToImageSource = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-image.tsx'), 'utf8');
const imageToVideoSource = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-video.tsx'), 'utf8');
for (const source of [imageToImageSource, imageToVideoSource]) {
assert.match(source, /ReferenceImageMentionControls/);
assert.match(source, /referenceImageAnnotations/);
assert.match(source, /buildReferenceImageAnnotations/);
}
});

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const routePath = path.join(process.cwd(), 'src/app/api/generate/reverse-prompt/route.ts');
const source = fs.readFileSync(routePath, 'utf8');
assert.match(
source,
/const upstreamImage\s*=\s*toPublicImageUrl\(persistedReferenceImage,\s*request\)\s*\|\|\s*image/,
'reverse-prompt should prefer the public persisted platform URL for upstream image_url payloads',
);
assert.match(
source,
/image_url:\s*\{\s*url:\s*upstreamImage\s*\}/,
'reverse-prompt upstream chat payload should send upstreamImage instead of the raw upload data URL',
);
console.log('reverse-prompt upstream image URL policy ok');

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('site config hook shares in-flight refresh requests across global components', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /let siteConfigSnapshot: SiteConfig \| null = null;/);
assert.match(source, /let inFlightSiteConfigRequest: Promise<SiteConfig \| null> \| null = null;/);
assert.match(source, /function fetchFreshSiteConfig\(\): Promise<SiteConfig \| null>/);
assert.match(source, /if \(inFlightSiteConfigRequest\) return inFlightSiteConfigRequest;/);
assert.match(source, /inFlightSiteConfigRequest = null;/);
});
await runTest('site config hook uses a shared snapshot for instant repeated mounts', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /function getInitialSiteConfig\(\): \{ config: SiteConfig; loaded: boolean \}/);
assert.match(source, /if \(siteConfigSnapshot\) return \{ config: siteConfigSnapshot, loaded: true \};/);
assert.match(source, /siteConfigSnapshot = config;/);
});
await runTest('site config hook skips fresh-cache network refreshes on route remounts', () => {
const source = read('src/lib/site-config.ts');
assert.match(source, /let siteConfigSnapshotTimestamp = 0;/);
assert.match(source, /function isSiteConfigSnapshotFresh\(\): boolean/);
assert.match(source, /if \(isSiteConfigSnapshotFresh\(\)\) \{/);
assert.match(source, /siteConfigSnapshotTimestamp = Date\.now\(\);/);
});
await runTest('site config API caches schema compatibility checks after startup', () => {
const source = read('src/app/api/site-config/route.ts');
assert.match(source, /let siteConfigColumnsReady = false;/);
assert.match(source, /let siteConfigColumnsPromise: Promise<void> \| null = null;/);
assert.match(source, /async function ensureSiteConfigColumnsOnce/);
assert.match(source, /if \(siteConfigColumnsReady\) return;/);
assert.match(source, /siteConfigColumnsReady = true;/);
assert.match(source, /await ensureSiteConfigColumnsOnce\(client\);/);
});
if (process.exitCode) process.exit(process.exitCode);

Some files were not shown because too many files have changed in this diff Show More