Compare commits

...

100 Commits

Author SHA1 Message Date
WuYongJie
028ab2ad17 完成 paid-core 付费核心拆分 2026-06-03 18:41:52 +08:00
WuYongJie
fcd8a83e8d 把免费版限额判断下沉到授权模块 2026-06-03 12:13:42 +08:00
WuYongJie
9f355ded27 推进付费模块运行边界拆分 2026-06-03 11:37:55 +08:00
WuYongJie
504a1d93e3 加固生产调试入口和许可证审计 2026-06-02 22:37:52 +08:00
WuYongJie
73b367e75e 加强付费模块拉包凭证和部署链路 2026-06-02 19:25:48 +08:00
WuYongJie
f92109e34f 完善客户升级 runtime 预拉 2026-06-02 14:22:24 +08:00
WuYongJie
4696215999 加固许可证链路和客户交付安全边界 2026-06-02 13:53:38 +08:00
WuYongJie
3b5da1c2c2 支持飞牛在线升级场景 2026-06-02 11:48:39 +08:00
WuYongJie
97e3e6351c 发布安全加固版 1.0.7 2026-06-02 10:32:42 +08:00
WuYongJie
3f5085e740 清理已关停测试服务器记录 2026-06-02 10:26:20 +08:00
WuYongJie
ccc25629c2 加固登录与高危接口安全边界 2026-06-02 10:16:28 +08:00
WuYongJie
a052494eed 校验客户访问入口版本 2026-06-01 17:28:00 +08:00
WuYongJie
88303e21db 收口商业功能许可证边界 2026-06-01 17:26:04 +08:00
WuYongJie
d359a411d2 强化客户验收版本校验 2026-06-01 17:16:29 +08:00
WuYongJie
b6246044b8 强化升级回滚和外部备份确认 2026-06-01 17:06:57 +08:00
WuYongJie
3cdfaa89a9 强化首次改密商业化门禁 2026-06-01 17:00:05 +08:00
WuYongJie
3256524271 补齐 NAS 客户首装备份验收脚本 2026-06-01 15:26:33 +08:00
WuYongJie
80ad51e3dd 调整付费模块镜像站分发链路 2026-06-01 14:21:32 +08:00
WuYongJie
12f248dc69 强化商业化升级备份与权限 UI 地基 2026-06-01 13:01:57 +08:00
WuYongJie
7026508a4a 收敛上传和授权提示界面 2026-06-01 11:51:55 +08:00
WuYongJie
00ce993b0b 增加客户首装向导入口 2026-06-01 11:51:29 +08:00
WuYongJie
e839ef100a 完善升级恢复指引与镜像体积基线 2026-06-01 11:40:34 +08:00
WuYongJie
7068732479 补强后台权限和旧素材更新边界 2026-06-01 11:40:02 +08:00
WuYongJie
e3cc3a428d 统一后台弹窗和分类窄屏布局 2026-06-01 11:25:57 +08:00
WuYongJie
2756925698 增强自定义模板校验与迁移稳定性 2026-06-01 11:24:54 +08:00
WuYongJie
3d7fb9e081 收紧素材权限与会话边界 2026-06-01 11:23:44 +08:00
WuYongJie
2e35feb1d8 收紧 Go Agent 安装入口配置 2026-06-01 10:21:13 +08:00
WuYongJie
70a2a20006 优化客户镜像与安装前检查 2026-06-01 10:02:30 +08:00
WuYongJie
77cc027b9e 补齐远程升级对象存储备份 2026-06-01 09:47:35 +08:00
WuYongJie
fe11d65784 收紧文档入口并更新部署真相 2026-05-30 20:17:36 +08:00
WuYongJie
e9c5ad10c3 补齐客户首装备份恢复验收链路 2026-05-30 19:33:59 +08:00
WuYongJie
9ca4940935 修复客户首装升级关键坑位 2026-05-30 18:25:51 +08:00
WuYongJie
28ae9a6462 记录客户首装升级坑位排查 2026-05-30 17:31:30 +08:00
WuYongJie
487be0bf02 继续收口商业化地基八项问题 2026-05-30 17:09:42 +08:00
WuYongJie
5be1d5ba45 加固历史恢复入口默认禁用 2026-05-30 15:21:42 +08:00
WuYongJie
2e926451a4 收紧邀请管理和用户列表样式 2026-05-30 14:59:22 +08:00
WuYongJie
7e9dee3817 收口私有化注册入口口径 2026-05-30 14:48:38 +08:00
WuYongJie
39ae249ce7 夯实上传权限和交付地基 2026-05-28 23:27:15 +08:00
WuYongJie
4d9e2e1af9 收敛旧样式工具导出 2026-05-28 22:05:08 +08:00
WuYongJie
46e1b67655 收敛作者页作品卡片样式 2026-05-28 21:58:21 +08:00
WuYongJie
da8580659c 收敛首页筛选标签组件样式 2026-05-28 21:55:17 +08:00
WuYongJie
eda68b6c90 调整首页素材日期展示 2026-05-28 21:51:07 +08:00
WuYongJie
79de454ac0 收敛激活页临时授权口径 2026-05-28 21:49:16 +08:00
WuYongJie
f95c121afc 收敛完善资料页入口样式 2026-05-28 21:44:00 +08:00
WuYongJie
88a43ad902 对齐激活页品牌入口 2026-05-28 21:42:15 +08:00
WuYongJie
e04da3ea1e 对齐注册页私有化入口 2026-05-28 21:37:59 +08:00
WuYongJie
1920c05f51 调整首页榜单标签位置 2026-05-28 21:34:08 +08:00
WuYongJie
20c1fc53d1 收敛素材操作按钮样式 2026-05-28 21:29:22 +08:00
WuYongJie
55c1ffd6aa 收敛首页空状态文案 2026-05-28 21:23:05 +08:00
WuYongJie
6c18ebd476 更新下载统计问题状态 2026-05-28 21:20:30 +08:00
WuYongJie
a3432cec26 修复邀请注册链路并收敛登录注册页 2026-05-28 21:17:08 +08:00
WuYongJie
e2bf426c65 收敛首页上传按钮样式 2026-05-28 21:08:11 +08:00
WuYongJie
d4f5bce994 收敛上传弹窗状态样式 2026-05-28 20:59:57 +08:00
WuYongJie
a5b56c5d7d 收敛监控页趋势图样式 2026-05-28 20:54:51 +08:00
WuYongJie
b14e52fe39 收敛分类管理工具按钮样式 2026-05-28 20:49:56 +08:00
WuYongJie
8354894dae 收敛许可证信息面板样式 2026-05-28 20:43:12 +08:00
WuYongJie
583d50d3bc 收敛授权提示弹窗样式 2026-05-28 20:37:23 +08:00
WuYongJie
9a2a651a14 收敛部门管理页头部样式 2026-05-28 20:27:09 +08:00
WuYongJie
5d86c2fab5 收敛首页音频素材卡片 2026-05-28 20:19:53 +08:00
WuYongJie
ed3eb6f72e 收敛首页筛选按钮样式 2026-05-28 20:14:01 +08:00
WuYongJie
0650260f3d 收敛模板编辑器内层样式 2026-05-28 20:09:09 +08:00
WuYongJie
09cab536a3 清理前端Hook依赖告警 2026-05-28 19:34:17 +08:00
WuYongJie
bad838fe8b 收敛模板管理弹窗外壳样式 2026-05-28 19:27:14 +08:00
WuYongJie
4a2e979c8d 收敛模板管理入口样式 2026-05-28 19:22:45 +08:00
WuYongJie
3fabd02ef3 收敛个人页和编辑弹窗素材卡片样式 2026-05-28 19:19:01 +08:00
WuYongJie
ff2baea41e 收敛素材管理目标用户弹窗 2026-05-28 19:14:01 +08:00
WuYongJie
92cdd5943a 修复首页自定义筛选计数查询 2026-05-28 19:09:11 +08:00
WuYongJie
4adeaa0fa1 收敛外观设置页工作台样式 2026-05-28 19:02:56 +08:00
WuYongJie
5e47ca3731 收敛浮动榜单面板样式 2026-05-28 18:48:08 +08:00
WuYongJie
9f6ff7789f 收口授权确认弹窗私有化口径 2026-05-28 18:46:07 +08:00
WuYongJie
36c35227ec 收敛首页素材卡片样式 2026-05-28 18:43:12 +08:00
WuYongJie
75e55ffa97 收敛聊天助手工作台样式 2026-05-28 18:39:06 +08:00
WuYongJie
7f5d735700 收敛弹幕控件工作台样式 2026-05-28 18:32:59 +08:00
WuYongJie
066f93621d 统一PRO标签中性样式 2026-05-28 18:29:32 +08:00
WuYongJie
d0fa4eb7fd 收敛素材预览弹窗样式 2026-05-28 18:27:37 +08:00
WuYongJie
fbe6e9ffce 收敛上传弹窗工作台样式 2026-05-28 18:24:24 +08:00
WuYongJie
bf80d3d801 收口登录激活私有化入口 2026-05-28 18:18:03 +08:00
WuYongJie
91825ee591 收敛顶部导航工作台风格 2026-05-28 18:10:48 +08:00
WuYongJie
e25d1aaad9 收敛公开分享页预留入口 2026-05-28 18:02:59 +08:00
WuYongJie
5ac2e11c3e 限制系统LOGO上传格式和大小 2026-05-28 17:57:37 +08:00
WuYongJie
f282d6aa73 跳过回收站素材模板迁移 2026-05-28 17:52:44 +08:00
WuYongJie
5bb9d893fc 移除公开调试接口 2026-05-28 17:47:49 +08:00
WuYongJie
d8218da21b 修正健康检查就绪状态 2026-05-28 17:42:48 +08:00
WuYongJie
ec440c5c60 统一授权提示私有化口径 2026-05-28 17:37:27 +08:00
WuYongJie
7b9260d8d7 接入模板迁移前端预览 2026-05-28 17:26:20 +08:00
WuYongJie
0fcbd3bcb3 补充模板迁移预览 2026-05-28 17:12:32 +08:00
WuYongJie
a2776f68ae 收紧AI改图素材权限 2026-05-28 16:08:49 +08:00
WuYongJie
965871f8dc 前移上传模板可用性校验 2026-05-28 15:58:10 +08:00
WuYongJie
7157cd318a 补齐模板保存基础校验 2026-05-28 15:50:54 +08:00
WuYongJie
c52aff5245 保护使用中模板搜索和填写字段 2026-05-28 15:36:24 +08:00
WuYongJie
ebabeb94e2 阻断使用中模板取消分类字段 2026-05-28 15:23:32 +08:00
WuYongJie
5a1affe830 强化商业化地基权限与模板一致性 2026-05-28 15:11:36 +08:00
WuYongJie
111bb29a1a 完善付费模块包下载校验闭环 2026-05-26 17:25:48 +08:00
WuYongJie
2f6f051daf 接入付费模块凭证门槛 2026-05-26 16:58:44 +08:00
WuYongJie
3c9591cdeb 完善许可证防破解收尾闭环 2026-05-26 16:47:57 +08:00
WuYongJie
074b1e1bdc 加入本地时间回拨检测 2026-05-26 16:04:30 +08:00
WuYongJie
fc5c4fefe6 加入许可证请求签名防重放 2026-05-26 15:33:14 +08:00
WuYongJie
07192cf779 加入许可证在线租约校验 2026-05-26 15:10:35 +08:00
WuYongJie
b267b36486 启用生产镜像服务端混淆构建 2026-05-26 14:51:00 +08:00
WuYongJie
c7d93111c4 补充公开分享总开关并兼容转码恢复缺表场景 2026-05-26 11:18:18 +08:00
332 changed files with 32738 additions and 4681 deletions

View File

@ -59,9 +59,11 @@ docs
*.md
!VERSION.txt
# 脚本(保留 docker-entrypoint.sh)
scripts
# 脚本(只保留生产构建和容器启动必需入口)
scripts/*
!scripts/docker-entrypoint.sh
!scripts/build-obfuscated.js
!scripts/obfuscate-config.js
# 临时文件
uploads
@ -89,6 +91,8 @@ gongxiangp2-source-reference-old-style-20260513-132942
license-server
flnxi-agent
flnxi-agent-go
premium-modules
deploy/registry-proxy
# 数据库文件
*.sql

View File

@ -1,5 +1,5 @@
# Base application configuration
NODE_ENV=development
NODE_ENV=production
# Public URLs
SERVER_IP=192.168.x.x
@ -8,6 +8,13 @@ NEXTAUTH_URL=https://your-domain.example.com
NEXTAUTH_SECRET=replace-with-strong-secret
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
# Shared HMAC secret between material app and license server (minimum 32 characters)
LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret
# Paid module service URL. Keep the default only when the paid-core container runs on the same Docker network.
PAID_CORE_MODULE_URL=http://flnxi-module-paid-core:8787
# Agent -> app callback token for POST /api/license/premium-modules/runtime-status.
MODULE_RUNTIME_STATUS_TOKEN=replace-with-strong-module-runtime-status-token
# Versioned deployment images
# Customer delivery must pin the full application image tag. Do not rely on latest.
@ -27,23 +34,42 @@ POSTGRES_VOLUME_NAME=flnxi-postgres-data
MINIO_VOLUME_NAME=flnxi-minio-data
APP_NETWORK_NAME=flnxi-app-network
APP_PORT=4320
MINIO_API_BIND_HOST=0.0.0.0
MINIO_API_PORT=9000
MINIO_CONSOLE_BIND_HOST=127.0.0.1
MINIO_CONSOLE_PORT=9001
# Upload limits, in bytes. Defaults: single file 10GB, total request 10GB, text fields 2MB.
MAX_UPLOAD_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
# Login protection
# Defaults: same IP 5 password-login attempts per minute; same account locks for 15 minutes after 5 failures.
AUTH_LOGIN_IP_WINDOW_MS=60000
AUTH_LOGIN_IP_MAX_ATTEMPTS=5
AUTH_LOGIN_ACCOUNT_MAX_FAILURES=5
AUTH_LOGIN_ACCOUNT_LOCK_MS=900000
# Database settings
DATABASE_URL=postgresql://username:password@host:5432/database
# Leave DATABASE_URL empty when using the PostgreSQL service from docker-compose.prod.yml.
# Fill it only when using an external PostgreSQL database, for example:
# DATABASE_URL=postgresql://flnxi:<strong-password>@192.168.1.10:5432/flnxi?sslmode=disable
DATABASE_URL=
POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-strong-postgres-password
POSTGRES_DB=flnxi
# Object storage (S3 compatible)
STORAGE_PROVIDER=s3
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=your-bucket
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
# Default values below match the built-in MinIO service in docker-compose.prod.yml.
# For external S3/MinIO, replace S3_ENDPOINT, S3_PUBLIC_DOMAIN, access key, and secret key.
S3_ENDPOINT=http://minio:9000
S3_BUCKET=flnxi-materials
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_PUBLIC_DOMAIN=https://s3.example.com/your-bucket
S3_PUBLIC_DOMAIN=http://192.168.x.x:9000/flnxi-materials
S3_SET_ACL=0
S3_FILE_PATH=materials/
S3_ENV_PREFIX=
@ -82,6 +108,18 @@ SYSTEM_INIT_TOKEN=
INIT_ADMIN_EMAIL=admin@local.flnxi
INIT_ADMIN_PASSWORD=
# Legacy one-off data restore endpoint. Keep disabled in customer environments unless doing a controlled restore window.
RESTORE_DATA_ENABLED=0
RESTORE_DATA_TOKEN=
RESTORE_DATA_ALLOWED_IPS=127.0.0.1
# Only set to 1 when the app is behind a trusted reverse proxy that strips untrusted forwarding headers.
RESTORE_DATA_TRUST_PROXY=0
RESTORE_ADMIN_EMAIL=admin@local.flnxi
RESTORE_ADMIN_USERNAME=admin
RESTORE_ADMIN_PASSWORD=
RESTORE_S3_BUCKET_URL=
RESTORE_S3_PUBLIC_URL=
# AI model (optional)
GEMINI_API_KEY=
GEMINI_API_BASE_URL=https://api.huandutech.com/v1

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.sh text eol=lf

18
.gitignore vendored
View File

@ -6,10 +6,9 @@ node_modules/
out/
# Env
.env
.env.production
.env.local
.env.*.local
.env*
!.env.example
!**/.env.example
server.env
env-backups/
@ -75,9 +74,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Vercel
.vercel
@ -97,6 +93,14 @@ next-env.d.ts
*.bak-*
*.ps1.bak-*
# Local deployment/security test artifacts
deploy-artifacts/
hack_*.js
hook.js
hack_*.sql
reset_pw.sql
update_pw.sql
# Docker images (large files, use Git LFS if needed)
/docker-images/**/*.tar
/docker-images/**/*.tar.gz

View File

@ -158,7 +158,7 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \
fi
# 构建 Next.js 应用(standalone 模式)
# 商业交付镜像必须在构建阶段暴露失败,不能吞掉构建错误继续出包。
# 商业交付镜像必须完成服务端混淆;混淆失败不能继续出包。
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
@ -172,7 +172,7 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \
else \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
npm run build
npm run build:prod
# 验证构建产物(允许 ESLint 警告,但不允许致命错误)
RUN if [ ! -f ".next/standalone/server.js" ]; then \
@ -238,6 +238,10 @@ RUN addgroup --system --gid 1001 nodejs && \
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# standalone 可能会把构建期的 prisma/engine-cache、dev.db 一起追踪进来;
# 运行镜像只保留 schema.prisma 和 migrations,避免客户每次升级重复拉无用缓存。
RUN rm -rf ./prisma
# 复制必要的额外文件
# 1. public 目录(静态资源)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
@ -245,15 +249,15 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# 2. 额外静态资源(如默认 Logo)
COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo
# 3. Prisma schema 和迁移文件(运行时可能需要)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# 3. Prisma schema 和迁移文件(运行时执行 migrate deploy 必需)
# 不复制 prisma/engine-cache、dev.db 等构建/开发产物,避免客户镜像重复携带大体积缓存。
RUN mkdir -p ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma/migrations ./prisma/migrations
# 4. 版本文件(许可证系统版本检查)
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
# 4. lib 目录(包含 configLoader 等运行时模块)
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
# 5. 补充关键模块(standalone 输出可能未完整包含)
# 复制 Prisma Client(必需)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
@ -287,7 +291,7 @@ RUN mkdir -p /app/uploads /app/data && \
# 复制启动脚本
COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
RUN sed -i 's/\r$//' ./docker-entrypoint.sh && chmod +x ./docker-entrypoint.sh
# 切换到非 root 用户
USER nextjs

View File

@ -28,6 +28,7 @@
├── scripts/ # 构建、迁移、修复、部署辅助脚本
├── docs/ # 当前项目文档入口
├── license-server/ # 独立许可证服务
├── premium-modules/ # 付费模块镜像源码
├── flnxi-agent/ # Node 版安装与运维 Agent
└── flnxi-agent-go/ # Go 版安装与运维 Agent
```
@ -94,6 +95,8 @@ gongxiangp2-source-reference-old-style-20260513-132942/
- `flnxi-agent/`:Node.js / Express / Socket.IO
- `flnxi-agent-go/`:Go / Gin / Docker SDK
Agent 是实施和运维编排工具,不是普通客户日常入口,也不是当前唯一正式安装器。正式客户首装仍以 `docker-compose.prod.yml + .env` 为主;NAS / Linux 现场优先用 `scripts/customer-install.sh` / `scripts/smoke-install.sh` 做检查和验收,Windows 实施机可用对应 `.ps1` 脚本。Agent 只在交付方案明确采用时启用。
## 6. 本地开发
### 安装依赖
@ -111,8 +114,8 @@ cp .env.example .env
至少需要确认。下面只表示变量形状,不要把示例占位值用于客户环境:
```env
DATABASE_URL=postgresql://<user>:<strong-password>@<host>:5432/flnxi
NEXTAUTH_SECRET=<generate-a-strong-random-secret>
APP_PUBLIC_URL=http://localhost:3000
NEXTAUTH_URL=http://localhost:3000
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=flnxi-materials
@ -120,9 +123,18 @@ S3_ACCESS_KEY_ID=<object-storage-access-key>
S3_SECRET_ACCESS_KEY=<object-storage-secret-key>
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
LICENSE_REQUEST_SIGNING_SECRET=<same-secret-as-license-server>
LICENSE_PUBLIC_KEY=<license-server-rsa-public-key>
LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-key>
```
`LICENSE_PROJECT_CODE` 必须和 `license-server` 中的项目编码一致。
内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。
授权模块采用“免费基础镜像 + 统一 `paid-core` 付费增强包”的分工。FREE 不拉取模块,人数/素材数上限由基础镜像读取本地已验签许可证执行;STANDARD/PRO 才会从镜像站拉取 `paid-core`,验证服务器只负责发签名模块清单、实例凭证和短期拉包 token。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、`PREMIUM_MODULE_PAID_CORE_IMAGE`、模块版本和模块 digest 指向当前可用镜像站。
当前模块源码入口是 `premium-modules/paid-core`,模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId paid-core` 发布,发布后把脚本输出的 `PREMIUM_MODULE_PAID_CORE_IMAGE`、`_TAG` 和 `_DIGEST` 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求 `paid-core` 处于 `running`、模块 `/health` 正常,并通过 `paid-core` 的 `/authorize` 放行自定义模板、部门隔离、系统设置、内网穿透、API 访问等付费能力;自定义模板字段保存前校验/格式化、模板保存请求整体标准化、模板删除前使用中拦截规划、素材提交的自定义字段内容校验、素材 metadata 准备和大小检查、已有素材模板的结构变更风险判断、公开模板展示投影、模板字段填写率统计、素材列表搜索/筛选查询计划、素材列表自定义字段搜索规划、首页自定义筛选字段统计规划、部门隔离范围规划、部门结构创建/重命名/删除联动计划、API 访问运行门禁、模板迁移目标字段投影、兼容性判断、metadata 准备、旧字段备份计算、迁移批次规划、迁移预览和备份字段影响统计已迁入 `paid-core`。其中 `/templates/normalize-save` 负责创建/更新模板前的名称、状态、素材类型、素材大类、字段组、展示配置和字段列表标准化,`/templates/plan-delete` 负责删除模板前按使用素材数量生成放行或拦截计划,`/materials/validate-template-fields` 负责上传/编辑素材时的自定义字段可见性、必填和规则校验,`/materials/prepare-metadata` 负责自定义字段写入 metadata 前的整理、压缩和大小检查,`/templates/plan-migration-batch` 负责迁移批次大小、安全确认、预览模式和分批执行规划,`/templates/prepare-migration-metadata` 负责迁移时从旧素材字段中选出目标模板字段、判断目标模板必填字段是否可自动补齐、生成目标 metadata、计算丢弃字段备份并拦截超限,`/templates/build-migration-preview` 负责迁移预览、备份字段影响统计和样例生成,`/templates/project-public-options` 负责公开模板列表、上传页模板列表和素材类型 tag 的展示投影,`/templates/calculate-field-stats` 负责后台模板详情里的字段填写率和低填写率字段统计,`/materials/plan-list-query` 负责素材列表搜索字段、自定义字段搜索、首页筛选字段和 metadata 筛选的查询计划,`/materials/plan-custom-search` 负责把可搜索字段快照转换成素材列表应查询的自定义字段 key,`/materials/plan-home-filter-fields` 负责把首页筛选字段快照转换成标准列或 metadata 统计来源计划,`/departments/plan-scope` 负责计算部门管理员的可管理部门范围和单个目标部门是否在范围内,`/departments/plan-structure-change` 负责部门创建、重命名、删除时的部门和用户联动计划。注意:`paid-core` 的边界是承接付费能力的规则、计划、校验和展示投影;主程序继续保留登录、权限、Prisma 查询、SQL 执行、数据库事务和最终写入。这是当前商业化安全边界,不表示付费功能没有拆分完成,也不能表述为所有业务执行都离开主程序。
许可证和授权模块不是“客户每次使用都实时联网验证”。联网主要发生在激活、续期心跳、授权变更同步、短期拉包 token 获取、模块镜像拉取和版本升级时;日常上传、创建用户、模板使用等动作优先依赖本地已验签许可证缓存、已缓存的模块授权凭证和本机正在运行的授权模块。也就是说,客户现场短时间断网不应影响已经授权且本地模块正常运行的日常使用,但许可证过期、授权被吊销、需要拉取新模块或升级时仍需要重新连验证服务器。
### 初始化 Prisma
@ -200,15 +212,23 @@ docker compose -f docker-compose.prod.yml up -d
内部维护环境仍保留:
- `deploy-registry.ps1`
- `scripts/customer-install.sh`
- `scripts/smoke-install.sh`
- `scripts/backup-restore.sh`
- `scripts/customer-acceptance-smoke.mjs`
- `scripts/customer-install.ps1`
- `scripts/smoke-install.ps1`
- `scripts/backup-restore.ps1`
- `scripts/measure-image-size.ps1`
- `deploy-new-fixed.ps1`
- `发布管理.ps1`
- `license-server/deploy.ps1`
这些脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。
其中 `scripts/customer-install.sh` 是 NAS / Linux 客户首装分步骤入口,内部调用 `scripts/smoke-install.sh` 做预检、启动和验收;`scripts/backup-restore.sh` 用于 NAS / Linux 客户环境备份、恢复和恢复后验收;`scripts/customer-acceptance-smoke.mjs` 用于客户业务验收,可只读检查,也可用临时测试账号执行一次真实小素材上传;`.ps1` 版本用于 Windows 实施机或已安装 PowerShell 的现场;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
## 10. 文档入口
优先阅读:
主入口优先看:
- `docs/README.md`
- `docs/deployment/部署说明.md`
@ -218,8 +238,15 @@ docker compose -f docker-compose.prod.yml up -d
- `docs/参考源码功能吸收整理_20260513.md`
- `scripts/README.md`
需要追排障、历史收口或版本差异时,再看:
- `docs/客户首装升级坑位排查_20260530.md`
- `docs/商业化地基8项收口记录_20260530.md`
- `docs/商业化地基排查问题清单_20260524.md`
- `docs/许可证链路安全评估_20260312.md`
原则:
- 当前项目事实以当前代码、配置、部署文件和这些入口文档为准。
- 旧部署记录、一次性排障报告、旧交接文档和历史总结不作为当前事实来源。
- 当前项目事实以当前代码、配置、部署文件和主入口文档为准。
- 阶段记录、旧排障报告、旧交接文档和历史总结不作为默认事实来源。
- 参考源码只用于查思路和做功能吸收判断,不直接参与当前项目运行链路。

View File

@ -1 +1 @@
1.0.6
1.0.13

View File

@ -19,4 +19,9 @@ describe('docker-compose.prod.yml', () => {
expect(compose).toContain('mc anonymous set download "myminio/$${S3_BUCKET}"')
expect(compose).toContain('- S3_SET_ACL=${S3_SET_ACL:-0}')
})
it('MinIO 控制台默认只绑定本机,素材访问端口仍可按客户入口开放', () => {
expect(compose).toContain('${MINIO_API_BIND_HOST:-0.0.0.0}:${MINIO_API_PORT:-9000}:9000')
expect(compose).toContain('${MINIO_CONSOLE_BIND_HOST:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001')
})
})

View File

@ -4,6 +4,16 @@ jest.mock('next/server', () => {
return new MockNextResponse(null, { status: 200 })
}
static json(body: unknown, init?: ResponseInit) {
return new MockNextResponse(JSON.stringify(body), {
...init,
headers: {
'content-type': 'application/json',
...(init?.headers || {}),
},
})
}
static redirect(url: URL | string) {
return new MockNextResponse(null, {
status: 307,
@ -126,4 +136,30 @@ describe('middleware admin pages', () => {
expect(response.status).toBe(200)
})
it('首次改密前访问后台页面会被带回首页', async () => {
const { getToken } = require('next-auth/jwt')
getToken.mockResolvedValue({ role: 'ADMIN', mustChangePassword: true })
const { middleware } = await import('../middleware')
const response = await middleware(createRequest('https://app.example.com/admin/settings'))
expect(response.status).toBe(307)
expect(response.headers.get('Location')).toBe('https://app.example.com/')
})
it('首次改密前业务 API 会被拦截,但改密接口仍可访问', async () => {
const { getToken } = require('next-auth/jwt')
getToken.mockResolvedValue({ role: 'ADMIN', mustChangePassword: true })
const { middleware } = await import('../middleware')
const blocked = await middleware(createRequest('https://app.example.com/api/admin/users'))
expect(blocked.status).toBe(403)
await expect(blocked.json()).resolves.toMatchObject({
code: 'PASSWORD_CHANGE_REQUIRED',
})
const allowed = await middleware(createRequest('https://app.example.com/api/user/change-password'))
expect(allowed.status).toBe(200)
})
})

View File

@ -0,0 +1,56 @@
import fs from 'fs'
import path from 'path'
describe('production build hardening', () => {
const root = process.cwd()
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8')
const dockerignore = fs.readFileSync(path.join(root, '.dockerignore'), 'utf8')
const buildScript = fs.readFileSync(path.join(root, 'scripts/build-obfuscated.js'), 'utf8')
it('Docker 生产镜像必须走混淆构建', () => {
expect(dockerfile).toContain('npm run build:prod')
expect(dockerfile).not.toMatch(/\bnpm run build\s*$/m)
})
it('Docker 构建上下文必须包含混淆脚本和配置', () => {
expect(dockerignore).toContain('scripts/*')
expect(dockerignore).toContain('!scripts/build-obfuscated.js')
expect(dockerignore).toContain('!scripts/obfuscate-config.js')
expect(dockerignore).toContain('!scripts/docker-entrypoint.sh')
})
it('生产运行镜像只复制 Prisma 运行迁移所需文件', () => {
expect(dockerfile).toContain('RUN rm -rf ./prisma')
expect(dockerfile).toContain('/app/prisma/schema.prisma ./prisma/schema.prisma')
expect(dockerfile).toContain('/app/prisma/migrations ./prisma/migrations')
expect(dockerfile).not.toContain('/app/prisma ./prisma')
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[\s\S]*\/app\/prisma\s+\.\/prisma/)
})
it('生产运行镜像不能额外复制未混淆源码目录', () => {
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/lib\s+\.\/lib/)
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/app\s+\.\/app/)
})
it('混淆依赖缺失时不能静默跳过', () => {
expect(buildScript).toContain('生产混淆不能跳过')
expect(buildScript).toContain('process.exit(1)')
expect(buildScript).not.toContain('跳过混淆步骤')
})
it('混淆目标缺失或混淆失败时不能继续出包', () => {
expect(buildScript).toContain('failedFiles')
expect(buildScript).toContain('没有找到需要混淆的目标文件')
expect(buildScript).toContain('混淆失败的目标文件')
})
it('混淆配置保持保守,避免破坏 Next.js 服务端产物', () => {
const { obfuscatorOptions } = require('../scripts/obfuscate-config')
expect(obfuscatorOptions.compact).toBe(true)
expect(obfuscatorOptions.stringArray).toBe(true)
expect(obfuscatorOptions.controlFlowFlattening).toBe(false)
expect(obfuscatorOptions.deadCodeInjection).toBe(false)
expect(obfuscatorOptions.renameGlobals).toBe(false)
})
})

View File

@ -3,7 +3,7 @@
*/
/**
* 激活页面测试
* 测试许可证激活和免费试用功能
* 测试许可证激活和临时授权申请功能
*/
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import ActivatePage from '../page';
@ -28,6 +28,14 @@ const createJsonResponse = (data: any, init?: { ok?: boolean; status?: number; s
});
const mockFetchRoutes = (routes: Record<string, any>) => {
const routesWithDefaults: Record<string, any> = {
'/api/system-settings': createJsonResponse({
siteName: 'FLNXI素材库',
loginLogoUrl: '',
}),
...routes,
};
(global.fetch as jest.Mock).mockImplementation((input: RequestInfo | URL) => {
const url =
typeof input === 'string'
@ -36,7 +44,7 @@ const mockFetchRoutes = (routes: Record<string, any>) => {
? input.toString()
: input.url;
const handler = routes[url] ?? routes['*'];
const handler = routesWithDefaults[url] ?? routesWithDefaults['*'];
if (handler instanceof Error) {
return Promise.reject(handler);
}
@ -53,13 +61,14 @@ const renderPage = async () => {
await act(async () => {
render(<ActivatePage />);
});
await screen.findByText('FLNXI 素材系统');
await screen.findByText('FLNXI素材库');
};
describe('ActivatePage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockReset();
delete process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION;
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'*': createJsonResponse({ success: true }),
@ -70,21 +79,21 @@ describe('ActivatePage', () => {
it('应该正确渲染页面标题和表单', async () => {
await renderPage();
expect(screen.getByText('FLNXI 素材系统')).toBeInTheDocument();
expect(screen.getByText('请激活您的许可证')).toBeInTheDocument();
expect(screen.getByText('FLNXI素材库')).toBeInTheDocument();
expect(screen.getByText('请输入交付给您的许可证密钥')).toBeInTheDocument();
expect(screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument();
expect(screen.getByText('激活许可证')).toBeInTheDocument();
expect(screen.getByText('免费试用')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '申请临时授权' })).not.toBeInTheDocument();
});
it('应该显示免费版限制说明', async () => {
it('默认不显示临时授权说明', async () => {
await renderPage();
expect(screen.getByText('免费版限制:10 个用户、100 个素材')).toBeInTheDocument();
expect(screen.queryByText('临时授权会按当前授权服务规则生成受限许可证')).not.toBeInTheDocument();
});
it('应该显示联系信息', async () => {
await renderPage();
expect(screen.getByText('需要购买许可证?请联系我们')).toBeInTheDocument();
expect(screen.getByText('如需许可证,请联系系统交付人员或管理员')).toBeInTheDocument();
});
});
@ -285,24 +294,28 @@ describe('ActivatePage', () => {
});
});
describe('免费试用', () => {
it('点击免费试用按钮应该显示客户名称输入弹窗', async () => {
describe('临时授权申请(显式开关开启时)', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION = 'true';
});
it('点击临时授权按钮应该显示交付单位输入弹窗', async () => {
await renderPage();
const button = screen.getByText('免费试用');
const button = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(button);
});
expect(screen.getByText('申请免费试用')).toBeInTheDocument();
expect(screen.getByPlaceholderText('请输入您的公司或个人名称')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '申请临时授权' })).toBeInTheDocument();
expect(screen.getByPlaceholderText('请输入单位或团队名称')).toBeInTheDocument();
expect(screen.getByText('确认申请')).toBeInTheDocument();
expect(screen.getByText('取消')).toBeInTheDocument();
});
it('未输入客户名称时确认申请按钮应该禁用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
@ -314,13 +327,13 @@ describe('ActivatePage', () => {
it('只输入客户名称未输入联系方式时确认申请按钮应该禁用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
const confirmButton = screen.getByText('确认申请');
@ -329,13 +342,13 @@ describe('ActivatePage', () => {
it('输入客户名称和联系方式后确认申请按钮应该启用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -346,23 +359,23 @@ describe('ActivatePage', () => {
it('点击取消按钮应该关闭弹窗', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
expect(screen.getByText('申请免费试用')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '申请临时授权' })).toBeInTheDocument();
const cancelButton = screen.getByText('取消');
await act(async () => {
fireEvent.click(cancelButton);
});
expect(screen.queryByText('申请免费试用')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '申请临时授权' })).not.toBeInTheDocument();
});
it('免费试用成功后应该显示成功页面', async () => {
it('临时授权申请成功后应该显示成功页面', async () => {
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'/api/license/activate-free': createJsonResponse({
@ -378,14 +391,14 @@ describe('ActivatePage', () => {
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击临时授权按钮打开弹窗
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -408,22 +421,22 @@ describe('ActivatePage', () => {
});
});
it('免费试用失败应该显示错误信息', async () => {
it('临时授权申请失败应该显示错误信息', async () => {
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'/api/license/activate-free': createJsonResponse({ success: false, error: '试用已过期' }),
'/api/license/activate-free': createJsonResponse({ success: false, error: '临时授权已过期' }),
});
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击临时授权按钮打开弹窗
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -435,7 +448,7 @@ describe('ActivatePage', () => {
});
await waitFor(() => {
expect(screen.getByText('试用已过期')).toBeInTheDocument();
expect(screen.getByText('临时授权已过期')).toBeInTheDocument();
});
});
@ -447,14 +460,14 @@ describe('ActivatePage', () => {
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击临时授权按钮打开弹窗
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -472,7 +485,7 @@ describe('ActivatePage', () => {
it('应该显示联系方式输入框', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('申请临时授权');
await act(async () => {
fireEvent.click(freeTrialButton);

View File

@ -2,9 +2,18 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
const DEFAULT_LOGIN_LOGO = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '';
type PublicSystemSettings = {
loginLogoUrl?: string | null;
siteName?: string | null;
};
export default function ActivatePage() {
const router = useRouter();
const freeTrialActivationEnabled = process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION === 'true';
const [initialAdmin, setInitialAdmin] = useState<{
email: string;
username: string;
@ -20,6 +29,54 @@ export default function ActivatePage() {
const [showFreeTrialModal, setShowFreeTrialModal] = useState(false);
const [customerName, setCustomerName] = useState('');
const [contactInfo, setContactInfo] = useState('');
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO);
const [siteName, setSiteName] = useState('FLNXI素材库');
const [logoError, setLogoError] = useState(false);
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings');
if (response.ok) {
const settings = await response.json() as PublicSystemSettings;
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO);
setSiteName(settings.siteName || 'FLNXI素材库');
setLogoError(false);
}
} catch (error) {
console.error('加载系统设置失败:', error);
}
};
loadSettings();
}, []);
useEffect(() => {
if (!loginLogoUrl) return;
if (loginLogoUrl.startsWith('/')) {
setLogoError(false);
return;
}
try {
const parsed = new URL(loginLogoUrl);
const host = parsed.hostname.toLowerCase();
const isLocal = host === 'localhost' || host === '127.0.0.1';
if (isLocal && typeof window !== 'undefined') {
const currentHost = window.location.hostname.toLowerCase();
if (currentHost !== host) {
console.warn(
`[Activate] Logo 地址 ${loginLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位符`
);
setLogoError(true);
}
}
} catch (error) {
console.warn('[Activate] Logo 地址解析失败,自动降级为占位符:', loginLogoUrl, error);
setLogoError(true);
}
}, [loginLogoUrl]);
// 页面加载时检查数据库中是否已有许可证
useEffect(() => {
@ -90,15 +147,25 @@ export default function ActivatePage() {
}
};
// 打开免费试用弹窗
// 打开临时授权申请弹窗
const openFreeTrialModal = () => {
if (!freeTrialActivationEnabled) {
setError('当前部署未开放临时授权申请,请使用许可证密钥或联系系统交付人员');
return;
}
setCustomerName('');
setContactInfo('');
setShowFreeTrialModal(true);
};
// 免费试用
// 临时授权申请
const handleFreeTrial = async () => {
if (!freeTrialActivationEnabled) {
setError('当前部署未开放临时授权申请,请使用许可证密钥或联系系统交付人员');
setShowFreeTrialModal(false);
return;
}
const trimmedName = customerName.trim();
const trimmedContact = contactInfo.trim();
@ -237,8 +304,25 @@ export default function ActivatePage() {
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">FLNXI 素材系统</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">请激活您的许可证</p>
<div className="flex justify-center mb-4">
{loginLogoUrl && !logoError ? (
<Image
src={loginLogoUrl}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm"
onError={() => setLogoError(true)}
/>
) : (
<div className="w-20 h-20 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{siteName.charAt(0) || 'F'}</span>
</div>
)}
</div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{siteName}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">请输入交付给您的许可证密钥</p>
</div>
{/* 许可证输入 */}
@ -273,36 +357,40 @@ export default function ActivatePage() {
{loading ? '激活中...' : '激活许可证'}
</button>
{/* 分隔线 */}
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
<span className="px-4 text-sm text-slate-400">或</span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
</div>
{freeTrialActivationEnabled && (
<>
{/* 分隔线 */}
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
<span className="px-4 text-sm text-slate-400">或</span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
</div>
{/* 免费试用 */}
<button
onClick={openFreeTrialModal}
disabled={loading}
className="w-full py-3 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg font-medium hover:bg-slate-50 dark:hover:bg-slate-800 disabled:cursor-not-allowed transition-colors"
>
免费试用
</button>
{/* 临时授权 */}
<button
onClick={openFreeTrialModal}
disabled={loading}
className="w-full py-3 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg font-medium hover:bg-slate-50 dark:hover:bg-slate-800 disabled:cursor-not-allowed transition-colors"
>
申请临时授权
</button>
{/* 免费版说明 */}
<p className="mt-4 text-center text-xs text-slate-400">
免费版限制:10 个用户、100 个素材
</p>
{/* 免费版说明 */}
<p className="mt-4 text-center text-xs text-slate-400">
临时授权会按当前授权服务规则生成受限许可证
</p>
</>
)}
</div>
{/* 联系信息 */}
<p className="mt-6 text-center text-sm text-slate-500">
需要购买许可证?请联系我们
如需许可证,请联系系统交付人员或管理员
</p>
</div>
{/* 免费试用弹窗 */}
{showFreeTrialModal && (
{/* 临时授权弹窗 */}
{freeTrialActivationEnabled && showFreeTrialModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<form
className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6 w-full max-w-sm mx-4"
@ -311,16 +399,16 @@ export default function ActivatePage() {
handleFreeTrial();
}}
>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">申请免费试用</h3>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">申请临时授权</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
客户名称 <span className="text-red-500">*</span>
交付单位名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="请输入您的公司或个人名称"
placeholder="请输入单位或团队名称"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
autoFocus
required
@ -338,17 +426,18 @@ export default function ActivatePage() {
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
required
/>
<p className="mt-1 text-xs text-slate-500">用于许可证管理和售后服务</p>
<p className="mt-1 text-xs text-slate-500">用于授权联系和交付核验</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowFreeTrialModal(false)}
className="flex-1 py-2 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
取消
</button>
<button
onClick={handleFreeTrial}
type="submit"
disabled={!customerName.trim() || !contactInfo.trim()}
className="flex-1 py-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg hover:bg-slate-800 dark:hover:bg-white disabled:bg-slate-300 dark:disabled:bg-slate-700 disabled:cursor-not-allowed transition-colors"
>

View File

@ -56,9 +56,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const [s3PublicUrl, setS3PublicUrl] = useState('')
const [enableUserActivation, setEnableUserActivation] = useState(false)
const [enableRegistration, setEnableRegistration] = useState(true)
const [enableRegistration, setEnableRegistration] = useState(false)
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
const [enableTunnelService, setEnableTunnelService] = useState(false)
const [enablePublicShare, setEnablePublicShare] = useState(true)
// 首页删除按钮:true = 仅管理员可见,false = 所有人可见
const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true)
const [premiumRatio, setPremiumRatio] = useState(10)
@ -109,9 +110,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
setS3Region(data.s3Region || 'us-east-1')
setS3PublicUrl(data.s3PublicUrl || '')
setEnableUserActivation(data.enableUserActivation ?? false)
setEnableRegistration(data.enableRegistration ?? true)
setEnableRegistration(data.enableRegistration ?? false)
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
setEnableTunnelService(data.enableTunnelService ?? false)
setEnablePublicShare(data.enablePublicShare ?? true)
setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL')
setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10)
setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null)
@ -161,6 +163,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
enableRegistration,
enableAiImageEdit,
enableTunnelService,
enablePublicShare,
homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL',
premiumRatio: normalizedRatio,
}),
@ -248,11 +251,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
activeNav === key ? styles.navItemStyles.active : styles.navItemStyles.hover
)
// 更精致的光晕效果 - 使用更相近的颜色,减少数量
if (!sessionUser || sessionUser.role !== 'ADMIN') {
return (
<div className={cn(
'rounded-2xl border px-6 py-4 text-sm',
'rounded-lg border px-6 py-4 text-sm',
theme === 'dark'
? 'border-red-800 bg-red-900/30 text-red-400'
: 'border-red-200 bg-red-50/60 text-red-600'
@ -265,30 +267,27 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
if (loading) {
return (
<div className={cn(styles.cardStyles.base, 'flex flex-col items-center gap-4')}>
<div className="h-12 w-12 rounded-full border-4 border-slate-200 border-t-indigo-500 animate-spin" />
<div className="h-12 w-12 rounded-full border-4 border-slate-200 border-t-slate-600 animate-spin" />
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>正在加载外观设置...</p>
</div>
)
}
return (
<div className="space-y-8">
<div className="space-y-6">
<section
className={cn(
'rounded-2xl border p-5',
'border-b pb-5',
theme === 'dark'
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
? 'border-slate-800 text-white'
: 'border-slate-200 text-slate-900',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3 max-w-2xl">
<p className={cn('text-xs font-medium text-slate-500', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
外观
</p>
<h2 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌、标识与系统开关</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-100/90' : 'text-slate-700/90')}>
这里集中处理品牌主题、LOGO、功能开关与对象存储。保存后会同步到后台界面与素材上传入口。
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-2 max-w-2xl">
<h2 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>外观设置</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
管理站点名称、LOGO、功能开关和对象存储配置。
</p>
{lastSavedAt && (
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
@ -296,7 +295,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2">
{variant === 'standalone' && (
<button
type="button"
@ -365,7 +364,9 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
{section.label}
<svg
className={`w-4 h-4 text-slate-400 transition-colors duration-300 ${
activeNav === section.key ? 'text-indigo-500' : 'group-hover:text-indigo-500'
activeNav === section.key
? 'text-slate-700 dark:text-slate-200'
: 'group-hover:text-slate-600 dark:group-hover:text-slate-300'
}`}
fill="none"
stroke="currentColor"
@ -379,40 +380,6 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
))}
</div>
</div>
<div
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 flex flex-col'
)}
>
<div>
<p className="text-xs font-medium text-slate-500">状态概览</p>
<p className={cn('text-base font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
</div>
<div className={cn(
'w-full h-24 rounded-xl border shadow-inner',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)} style={{ backgroundColor: headerBgColor }}>
<div className="flex items-center justify-between h-full px-4 text-white/90">
<div>
<p className="text-sm font-semibold">{siteName}</p>
<p className="text-xs opacity-80 font-mono">{headerBgColor}</p>
</div>
<span className={cn(
'text-xs font-semibold px-3 py-1 rounded-full',
theme === 'dark' ? 'bg-white/15 text-white/80' : 'bg-white/25 text-white/90'
)}>导航示例</span>
</div>
</div>
<button
onClick={handleColorReset}
className={cn(styles.buttonStyles.secondary, 'w-full')}
>
恢复默认主题色
</button>
</div>
</div>
<div className="space-y-8">
@ -446,8 +413,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
<div className="flex items-center gap-3">
<label
className={cn(
'relative w-16 h-16 border rounded-xl shadow-inner cursor-pointer',
theme === 'dark' ? 'border-slate-600 shadow-black/60' : 'border-slate-200 shadow-black/10'
'relative w-16 h-16 border rounded-lg cursor-pointer',
theme === 'dark' ? 'border-slate-600' : 'border-slate-200'
)}
style={{ backgroundColor: headerBgColor }}
>
@ -465,9 +432,16 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
onChange={(e) => setHeaderBgColor(e.target.value)}
className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')}
/>
<button
type="button"
onClick={handleColorReset}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'shrink-0')}
>
恢复默认
</button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{['#1f2937', '#4f46e5', '#1e40af', '#7c3aed', '#0891b2', '#059669', '#dc2626', '#d97706'].map((color) => (
{['#1f2937', '#334155', '#0f766e', '#166534', '#b45309', '#b91c1c'].map((color) => (
<button
key={color}
onClick={() => setHeaderBgColor(color)}
@ -524,7 +498,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</div>
<div
className={cn(
'w-full aspect-square max-w-[220px] rounded-2xl border-2 border-dashed flex items-center justify-center overflow-hidden',
'w-full aspect-square max-w-[220px] rounded-lg border border-dashed flex items-center justify-center overflow-hidden',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}
style={{ backgroundColor: item.background }}
@ -606,7 +580,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
desc: '关闭后仅能通过邀请进入系统。',
value: enableRegistration,
onChange: setEnableRegistration,
disabled: false,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '首页删除按钮仅管理员可见',
@ -615,6 +589,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
onChange: setHomeDeleteButtonAdminOnly,
disabled: false,
},
{
title: '公开分享链接',
desc: '关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。',
value: enablePublicShare,
onChange: setEnablePublicShare,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '图片编辑能力(预留)',
desc: '控制素材详情页的图片编辑入口(功能预留)。',
@ -624,7 +605,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
},
].map((item) => (
<div key={item.title} className={cn(
'flex items-center justify-between gap-4 rounded-2xl border px-4 py-3',
'flex items-center justify-between gap-4 rounded-lg border px-4 py-3',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200',
item.disabled && 'opacity-60'
)}>
@ -644,20 +625,20 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
disabled={item.disabled}
/>
<div className={cn(
'w-11 h-6 peer-focus:outline-none peer-focus:ring-4 rounded-full peer peer-checked:after:translate-x-full after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:rounded-full after:h-5 after:w-5 after:transition-all',
'w-11 h-6 peer-focus:outline-none peer-focus:ring-2 rounded-full peer peer-checked:after:translate-x-full after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:rounded-full after:h-5 after:w-5 after:transition-transform',
item.disabled
? 'peer-checked:bg-slate-400 dark:peer-checked:bg-slate-600 bg-slate-300 dark:bg-slate-700'
: 'peer-checked:bg-indigo-600',
: 'peer-checked:bg-slate-700 dark:peer-checked:bg-slate-500',
theme === 'dark'
? !item.disabled && 'bg-slate-700 peer-focus:ring-indigo-800'
: !item.disabled && 'bg-slate-200 peer-focus:ring-indigo-200'
? !item.disabled && 'bg-slate-700 peer-focus:ring-slate-600'
: !item.disabled && 'bg-slate-200 peer-focus:ring-slate-300'
)}></div>
</label>
</div>
))}
<div
className={cn(
'rounded-2xl border px-4 py-2.5 flex items-center justify-between gap-4',
'rounded-lg border px-4 py-2.5 flex items-center justify-between gap-4',
theme === 'dark' ? 'border-slate-700/80 bg-slate-800/60' : 'border-slate-200/80 bg-slate-50/80'
)}
>
@ -679,7 +660,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
}}
disabled={!sessionUser?.isSuperAdmin}
className={cn(
'!w-16 text-center font-semibold text-sm px-2 py-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 outline-none transition-all',
'!w-16 text-center font-semibold text-sm px-2 py-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:border-slate-500 dark:focus:border-slate-300 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
!sessionUser?.isSuperAdmin && 'opacity-60 cursor-not-allowed'
)}
@ -698,7 +679,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
{/* 付费功能开关 - 使用 RestrictedToggle */}
<div className={cn(
'rounded-2xl border px-4',
'rounded-lg border px-4',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}>
<RestrictedToggle
@ -733,7 +714,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
type="button"
onClick={() => setShowStorageConfirmModal(true)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
'hover:bg-amber-200 dark:hover:bg-amber-900/50',
'border border-amber-200 dark:border-amber-800'
@ -747,7 +728,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
type="button"
onClick={() => setStorageEditEnabled(false)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600'
)}
@ -760,13 +741,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
{/* 锁定状态提示 */}
{!storageEditEnabled && (
<div className={cn(
'rounded-2xl border p-4',
'rounded-lg border p-4',
theme === 'dark'
? 'border-amber-900/60 bg-amber-900/20'
: 'border-amber-100 bg-amber-50/80'
)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center flex-shrink-0">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
@ -854,13 +835,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</div>
</div>
<div className={cn(
'rounded-2xl border p-4',
'rounded-lg border p-4',
theme === 'dark'
? 'border-indigo-900/60 bg-indigo-900/20'
: 'border-indigo-100 bg-indigo-50/80'
? 'border-slate-700 bg-slate-800/60'
: 'border-slate-200 bg-slate-50'
)}>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-indigo-300' : 'text-indigo-700')}>配置提示</p>
<ul className={cn('text-xs list-disc list-inside space-y-1 mt-2', theme === 'dark' ? 'text-indigo-200/90' : 'text-indigo-600/90')}>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-slate-100' : 'text-slate-800')}>配置提示</p>
<ul className={cn('text-xs list-disc list-inside space-y-1 mt-2', theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
<li>S3 端点通常为对象存储服务器地址</li>
<li>Access/Secret Key 需保证与存储桶权限一致</li>
<li>如使用 CDN,请将加速域名填入公网 URL</li>

View File

@ -7,9 +7,9 @@
* 使用三层架构样式系统,支持深浅色主题
*
* 布局结构:
* - 顶部:Header区域(红色框框)
* - 左侧:分类列表(黄色框框,固定不滚动)
* - 右侧:选项卡片列表(绿色框框,可滚动)
* - 顶部:说明区域
* - 左侧:分类列表
* - 右侧:选项卡片列表
*
* @created 2025-01-XX(拆分自 page.tsx)
* @component
@ -52,6 +52,7 @@ interface CategoryFieldMeta {
interface CategoriesTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
onDataChange?: () => void
isSuperAdmin?: boolean
}
const sanitizeOptionLabel = (value: string): string => {
@ -115,7 +116,7 @@ const normalizeOptionArray = (values: string[]): string[] => {
return normalized
}
export default function CategoriesTab({ showToast, onDataChange }: CategoriesTabProps) {
export default function CategoriesTab({ showToast, onDataChange, isSuperAdmin = false }: CategoriesTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const { confirm, prompt } = useDialog()
@ -127,6 +128,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
const [renamingOption, setRenamingOption] = useState<Map<string, { fieldKey: string; oldValue: string; newValue: string }>>(new Map())
const [categoryFieldOptions, setCategoryFieldOptions] = useState<CategoryFieldMeta[]>([])
const [categoryFieldLoading, setCategoryFieldLoading] = useState(false)
const [categoryFieldSyncing, setCategoryFieldSyncing] = useState(false)
const [savingFieldKey, setSavingFieldKey] = useState<string | null>(null)
const [isEditingFields, setIsEditingFields] = useState(false)
const [categoryOrder, setCategoryOrder] = useState<string[]>([])
@ -268,6 +270,33 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
fetchCategoryFieldOptions()
}, [fetchCategoryFieldOptions])
const handleSyncCategoryFields = useCallback(async () => {
if (!isSuperAdmin) {
showToast('仅最高权限账号可同步分类字段', 'error')
return
}
try {
setCategoryFieldSyncing(true)
const response = await fetch('/api/category-fields', {
method: 'POST',
cache: 'no-store',
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || '同步失败')
}
showToast('字段池已同步')
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
onDataChange?.()
} catch (error: any) {
console.error('[CategoriesTab] Failed to sync category fields:', error)
showToast(error?.message || '同步分类字段失败', 'error')
} finally {
setCategoryFieldSyncing(false)
}
}, [fetchCategoryFieldOptions, fetchData, isSuperAdmin, onDataChange, showToast])
const handleCategoryLabelChange = useCallback((fieldKey: string, value: string) => {
setCategoryLabelDrafts((prev) => {
const next = new Map(prev)
@ -554,14 +583,14 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</div>
</section>
{/* 主要内容区域:左右分栏 */}
<div className="flex-1 flex gap-6 min-h-0">
{/* 黄色框框:左侧分类列表(固定不滚动) */}
<div className="w-[320px] flex-shrink-0 space-y-5">
{/* 主要内容区域:桌面左右分栏,窄屏上下排列 */}
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
{/* 分类列表 */}
<div className="w-full lg:w-[320px] lg:flex-shrink-0 space-y-5">
<div className={cn(
styles.cardStyles.base,
'p-5 flex flex-col',
'sticky top-24 max-h-[calc(100vh-12rem)]',
'max-h-[24rem] lg:sticky lg:top-24 lg:max-h-[calc(100vh-12rem)]',
)}>
<p className={cn('text-sm font-medium mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-200' : 'text-slate-600')}>
分类列表
@ -591,7 +620,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
<svg
className={cn(
'w-4 h-4 text-slate-400 transition-colors duration-300',
isSelected ? 'text-indigo-500' : 'group-hover:text-indigo-500'
isSelected ? 'text-slate-600 dark:text-slate-300' : 'group-hover:text-slate-600 dark:group-hover:text-slate-300'
)}
fill="none"
stroke="currentColor"
@ -644,12 +673,12 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
ref={setNodeRef}
className={cn(
'rounded-lg border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center gap-3 bg-white dark:bg-slate-900/60',
isDragging ? 'border-indigo-300 bg-indigo-50/70 dark:border-indigo-700 dark:bg-indigo-950/30' : '',
isDragging ? 'border-slate-400 bg-slate-50 dark:border-slate-500 dark:bg-slate-800' : '',
)}
>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded-full border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-indigo-500 bg-white/80 dark:bg-slate-900/60 cursor-grab active:cursor-grabbing"
className="flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700 active:cursor-grabbing dark:border-slate-700 dark:bg-slate-900/60 dark:hover:border-slate-600 dark:hover:text-slate-300 cursor-grab"
aria-label="拖动排序"
title="拖动排序"
{...attributes}
@ -669,7 +698,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</div>
<button
type="button"
className="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-700 bg-white text-slate-500 flex items-center justify-center hover:text-indigo-500 transition"
className="flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/60 dark:hover:border-slate-600 dark:hover:text-slate-300"
title="重命名"
onClick={() => void handleFieldRenamePrompt(field.key)}
>
@ -705,11 +734,11 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
{disabledFieldMeta.map((field) => (
<div
key={`${field.key}-disabled`}
className="rounded-lg border border-dashed border-slate-300 dark:border-slate-700 px-4 py-3 flex items-center justify-between gap-3"
className="rounded-lg border border-dashed border-slate-300 dark:border-slate-700 px-4 py-3 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center"
>
<div>
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{field.label}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
<p className="text-xs text-slate-400 dark:text-slate-500 break-all">
{field.key} · {field.source === 'standard' ? '标准字段' : '模板字段'}
</p>
</div>
@ -728,7 +757,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</>
)}
</div>
<div className="pt-4 mt-4 border-t border-slate-200/80 dark:border-slate-700/70 flex gap-3">
<div className="pt-4 mt-4 border-t border-slate-200/80 dark:border-slate-700/70 flex flex-col gap-2 sm:flex-row sm:gap-3">
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex-1')}
@ -746,6 +775,16 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
>
{isEditingFields ? (categoryConfigSaving ? '保存中…' : '保存') : '编辑'}
</button>
{!isEditingFields && isSuperAdmin && (
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex-1')}
onClick={handleSyncCategoryFields}
disabled={categoryFieldLoading || categoryFieldSyncing || categoryConfigSaving}
>
{categoryFieldSyncing ? '同步中…' : '同步字段'}
</button>
)}
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex-1')}
@ -755,7 +794,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
}}
disabled={categoryFieldLoading || categoryConfigSaving}
disabled={categoryFieldLoading || categoryFieldSyncing || categoryConfigSaving}
>
{isEditingFields ? '取消' : '刷新'}
</button>
@ -763,7 +802,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
</div>
</div>
{/* 绿色框框:右侧选项卡片列表(页面级滚动,显示所有字段) */}
{/* 选项卡片列表(页面级滚动,显示所有字段) */}
<div className="flex-1 min-w-0">
<div className="space-y-6">
{orderedCategories.map((category) => {
@ -918,8 +957,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}}
className={cn(
styles.cardStyles.flat,
'p-2 border-2 border-dashed transition-all duration-300 min-h-[60px] flex flex-col items-center justify-center',
cardAnimations.lift,
'p-2 border-2 border-dashed transition-colors duration-200 min-h-[60px] flex flex-col items-center justify-center',
theme === 'dark'
? 'border-slate-600/50 text-slate-400 hover:border-slate-500/50 hover:text-slate-300 hover:bg-slate-800/30'
: 'border-slate-300/50 text-slate-500 hover:border-slate-400/50 hover:text-slate-600 hover:bg-slate-50/50',

View File

@ -50,7 +50,7 @@ export default function DepartmentsTab({
// 免费版限制:不能添加子分支(部门隔离功能)
const canAddSubDepartment = !isFree && isFeatureEnabled('departmentIsolation')
const borderInputIndigo = theme === 'dark' ? 'border-indigo-600' : 'border-indigo-300'
const borderInputNeutral = theme === 'dark' ? 'border-slate-600' : 'border-slate-300'
const bgInput = theme === 'dark' ? 'bg-slate-900' : 'bg-white'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-300' : 'text-slate-600'
@ -206,8 +206,8 @@ export default function DepartmentsTab({
className={cn(
styles.inputStyles.base,
styles.inputModifiers.compact,
'flex-1 max-w-xs', // 限制最大宽度,避免输入框太宽
borderInputIndigo,
'flex-1 max-w-xs',
borderInputNeutral,
bgInput,
)}
autoFocus
@ -225,7 +225,7 @@ export default function DepartmentsTab({
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
'bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-100 dark:hover:bg-white dark:text-slate-900',
)}
>
保存
@ -239,7 +239,7 @@ export default function DepartmentsTab({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
)}
>
取消
@ -260,7 +260,7 @@ export default function DepartmentsTab({
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white relative', // 显式添加背景色和文字色
'bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-100 dark:hover:bg-white dark:text-slate-900 relative',
!canAddSubDepartment && 'opacity-80'
)}
>
@ -276,7 +276,7 @@ export default function DepartmentsTab({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
)}
>
重命名
@ -290,7 +290,6 @@ export default function DepartmentsTab({
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compact,
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除
@ -312,29 +311,26 @@ export default function DepartmentsTab({
<div className="space-y-6">
<section
className={cn(
'relative overflow-hidden rounded-2xl border mb-6 flex-shrink-0 p-5',
'rounded-lg border mb-4 flex-shrink-0 p-4',
theme === 'dark'
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
'transition-colors',
)}
>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-xs font-medium', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
部门管理
</p>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<h2 className={cn('text-xl font-semibold', textTitle)}>组织结构</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。
<p className={cn('text-sm', textDesc)}>
搜索、折叠或维护团队分支。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<div className="relative w-full md:w-[300px] lg:w-[320px]">
<input
type="text"
value={departmentSearchTerm}
onChange={(e) => setDepartmentSearchTerm(e.target.value)}
placeholder="搜索分支名称,例如:一部 / 技术部 / 主公司"
placeholder="搜索分支名称"
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -160,7 +160,7 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
<div>
<h2 className={cn('text-xl font-semibold', textTitle)}>邀请管理</h2>
<p className={cn('text-sm mt-1', textDesc)}>
创建和管理邀请链接,用户可以通过邀请链接注册账号
创建和管理邀请链接,成员通过邀请链接加入团队。
</p>
</div>
<button
@ -184,13 +184,13 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
<table className="w-full">
<thead>
<tr className={cn('border-b', borderColor)}>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请码</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请链接</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>使用次数</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>过期时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>状态</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>创建时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>操作</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>邀请码</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>邀请链接</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>使用次数</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>过期时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>状态</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>创建时间</th>
<th className={cn('px-4 py-3 text-left text-xs font-medium', textDesc)}>操作</th>
</tr>
</thead>
<tbody className={cn('divide-y', divideColor)}>
@ -231,7 +231,7 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
</td>
<td className="px-4 py-3 text-sm">
<span
className={cn('px-2 py-1 rounded-full text-xs font-medium', isValid ? badgeSuccess : badgeDanger)}
className={cn('px-2 py-1 rounded-md text-xs font-medium', isValid ? badgeSuccess : badgeDanger)}
>
{!invitation.isActive
? '已禁用'
@ -261,7 +261,6 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compactXs,
'bg-red-500 hover:bg-red-600 text-white' // 显式添加背景色和文字色
)}
>
删除

View File

@ -661,7 +661,7 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
</div>
)}
{!hasMore && materials.length > 0 && (
<div className="text-center py-4 text-sm text-slate-400 dark:text-slate-500">已经到底啦~</div>
<div className="text-center py-4 text-sm text-slate-400 dark:text-slate-500">已加载全部素材</div>
)}
</div>
)}
@ -684,26 +684,26 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
{/* 选择目标用户弹窗 */}
{isUserSelectOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center px-4 sm:px-6">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setIsUserSelectOpen(false)} />
<div className="absolute inset-0 bg-black/30" onClick={() => setIsUserSelectOpen(false)} />
<div
className={cn(
'relative z-50 w-full max-w-xl rounded-2xl border shadow-xl',
'relative z-50 w-full max-w-xl rounded-lg border shadow-sm',
theme === 'dark'
? 'bg-slate-900/95 border-slate-700/80 shadow-black/60'
: 'bg-white/95 border-slate-200 shadow-slate-900/5',
? 'bg-slate-900 border-slate-700/80 shadow-black/40'
: 'bg-white border-slate-200 shadow-slate-900/5',
)}
>
<div className="px-5 pt-4 pb-3 border-b border-slate-200/70 dark:border-slate-700/80 flex items-center justify-between">
<div>
<div className="text-base font-semibold text-slate-900 dark:text-slate-50">选择目标用户</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
支持按用户名、邮箱或部门关键字搜索,然后点选要接收素材的用户。
搜索并选择接收素材的用户。
</div>
</div>
<button
type="button"
onClick={() => setIsUserSelectOpen(false)}
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-200"
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<span className="sr-only">关闭</span>
×
@ -725,17 +725,17 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
'flex-1 rounded-lg border px-3 py-1.5 text-sm outline-none',
'placeholder:text-slate-400',
theme === 'dark'
? 'bg-slate-900 border-slate-700 text-slate-100 focus:border-indigo-400 focus:ring-1 focus:ring-indigo-500/70'
: 'bg-white border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40',
? 'bg-slate-900 border-slate-700 text-slate-100 focus:border-slate-400 focus:ring-1 focus:ring-slate-600'
: 'bg-white border-slate-300 text-slate-900 focus:border-slate-500 focus:ring-1 focus:ring-slate-300',
)}
/>
</div>
<p className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
小提示:可以直接输入邮箱或完整用户名,结果会自动高亮匹配。
支持用户名、邮箱或部门。
</p>
</div>
<div className="border rounded-xl max-h-72 overflow-y-auto border-slate-200 dark:border-slate-700/80">
<div className="border rounded-lg max-h-72 overflow-y-auto border-slate-200 dark:border-slate-700/80">
{userSearchLoading ? (
<div className="px-4 py-6 text-sm text-slate-500 dark:text-slate-400 text-center">搜索中...</div>
) : userSearchResults.length === 0 ? (
@ -760,8 +760,8 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
'w-full px-4 py-2.5 flex items-center justify-between text-left text-sm transition-colors',
isSelected
? theme === 'dark'
? 'bg-indigo-700/40 text-slate-50'
: 'bg-indigo-50 text-slate-900'
? 'bg-slate-700 text-slate-50'
: 'bg-slate-100 text-slate-900'
: theme === 'dark'
? 'hover:bg-slate-800/70 text-slate-100'
: 'hover:bg-slate-50 text-slate-800',
@ -771,12 +771,12 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
<div className="flex items-center gap-2">
<span className="truncate max-w-[180px] font-medium">{label}</span>
{user.isSuperAdmin && (
<span className="inline-flex items-center rounded-full bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-amber-500/15 dark:text-amber-300">
<span className="inline-flex items-center rounded-md bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-amber-500/15 dark:text-amber-300">
超级管理员
</span>
)}
{user.role === 'ADMIN' && !user.isSuperAdmin && (
<span className="inline-flex items-center rounded-full bg-indigo-100 text-indigo-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-indigo-500/15 dark:text-indigo-300">
<span className="inline-flex items-center rounded-md bg-slate-100 text-slate-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-slate-700 dark:text-slate-200">
管理员
</span>
)}
@ -793,7 +793,7 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
)}
</div>
{isSelected && (
<span className="ml-3 inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500 text-white text-xs">
<span className="ml-3 inline-flex h-5 w-5 items-center justify-center rounded-md bg-slate-700 text-white text-xs dark:bg-slate-500">
✓
</span>
)}
@ -887,4 +887,3 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
</>
)
}

View File

@ -492,7 +492,7 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
<div className="flex items-center gap-3">
{refreshing && (
<span className={cn('text-xs flex items-center gap-1', textDesc)}>
<span className="inline-block h-3 w-3 rounded-full border-2 border-slate-300 border-t-indigo-500 animate-spin" />
<span className="inline-block h-3 w-3 rounded-full border-2 border-slate-300 border-t-slate-600 animate-spin dark:border-slate-700 dark:border-t-slate-300" />
后台同步中...
</span>
)}
@ -554,7 +554,7 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
const CardContent = (
<div className="p-5">
<div className="flex items-center gap-4">
<span className={cn('h-10 w-1 rounded-full', card.accentClass)} aria-hidden="true" />
<span className={cn('h-10 w-1 rounded-sm', card.accentClass)} aria-hidden="true" />
<dl className="min-w-0 flex-1">
<dt className={cn('text-sm font-medium truncate', textLabel)}>{card.label}</dt>
<dd className={cn('text-2xl font-semibold mt-1', textValue)}>{card.getValue(data)}</dd>
@ -602,9 +602,9 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
return (
<div key={item.date} className="flex flex-col items-center min-w-[40px]">
<span className={cn('text-xs font-semibold mb-2', textValue)}>{item.count}</span>
<div className="w-7 h-44 rounded-full bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
<div className="w-7 h-44 rounded-sm bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
<div
className="w-full bg-indigo-600 dark:bg-indigo-500 transition-all"
className="w-full bg-slate-600 transition-colors dark:bg-slate-400"
style={{ height: `${barHeight}%` }}
/>
</div>
@ -625,7 +625,7 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
{monthlyUploads.length > 0 && (
<div className={cn('text-sm font-medium', textValue)}>
最近月份上传:
<span className="ml-1 text-purple-600 dark:text-purple-400">
<span className="ml-1 text-slate-900 dark:text-slate-100">
{monthlyUploads[monthlyUploads.length - 1]?.count || 0}
</span>
</div>
@ -639,9 +639,9 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
return (
<div key={item.date} className="flex flex-col items-center min-w-[48px]">
<span className={cn('text-xs font-semibold mb-2', textValue)}>{item.count}</span>
<div className="w-8 h-44 rounded-full bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
<div className="w-8 h-44 rounded-sm bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
<div
className="w-full bg-purple-600 dark:bg-purple-500 transition-all"
className="w-full bg-slate-600 transition-colors dark:bg-slate-400"
style={{ height: `${barHeight}%` }}
/>
</div>
@ -928,7 +928,7 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
<div className="space-y-2 max-h-96 overflow-y-auto">
{loadingMaterialLogs ? (
<div className={cn('text-center py-8', textDesc)}>
<span className="inline-block h-4 w-4 rounded-full border-2 border-slate-300 border-t-indigo-500 animate-spin mr-2" />
<span className="mr-2 inline-block h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-600 animate-spin dark:border-slate-700 dark:border-t-slate-300" />
加载中...
</div>
) : materialLogs.length === 0 ? (
@ -937,14 +937,14 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {
materialLogs.map((log) => {
const cardColorMap: Record<string, string> = {
upload: 'border-green-500/50 bg-green-50/50 dark:bg-green-900/20',
update: 'border-blue-500/50 bg-blue-50/50 dark:bg-blue-900/20',
update: 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50',
delete: 'border-red-500/50 bg-red-50/50 dark:bg-red-900/20',
toggle: 'border-amber-500/50 bg-amber-50/50 dark:bg-amber-900/20',
}
const badgeColorMap: Record<string, string> = {
upload: 'bg-green-500 text-white',
update: 'bg-blue-500 text-white',
update: 'bg-slate-600 text-white dark:bg-slate-500',
delete: 'bg-red-500 text-white',
toggle: 'bg-amber-500 text-white',
}
@ -1022,7 +1022,7 @@ const OVERVIEW_CARDS: Array<{
},
{
label: '总素材数',
accentClass: 'bg-indigo-500 dark:bg-indigo-400',
accentClass: 'bg-slate-500 dark:bg-slate-400',
getValue: (data) => data.overview.totalMaterials,
href: '/',
},

View File

@ -39,6 +39,61 @@ const STATUS_OPTIONS = [
const MATERIAL_TYPE_OPTIONS = ['3DMAX', 'PSD', '音频', '视频', '模特', '其他']
type MigrationPreviewField = {
key: string
label: string
type?: string
source: 'template' | 'standard'
affectedCount?: number
}
type MigrationPreviewResponse = {
success: boolean
previewOnly: true
migratableCount: number
message: string
sourceTemplate: { id: string; name: string }
targetTemplate: { id: string; name: string }
migrationBackup: {
materialsWithDroppedFields: number
droppedTemplateFieldCount: number
droppedStandardFieldCount: number
}
migrationPreview: {
totalMaterialCount: number
totalMaterials: number
previewLimited?: boolean
previewLimit?: number
targetCurrentFields: Array<{
key: string
label: string
type: string
required: boolean
}>
backupFields: {
templateFields: MigrationPreviewField[]
standardFields: MigrationPreviewField[]
}
samples: Array<{
materialId: string
productModel: string | null
droppedTemplateFields: MigrationPreviewField[]
droppedStandardFields: MigrationPreviewField[]
}>
}
}
type MigrationBatchResult = {
success: boolean
migratedCount: number
totalMaterialCount?: number
remainingMaterialCount?: number
batchLimited?: boolean
message: string
}
const MIGRATION_BATCH_LIMIT = 1000
// 默认需要进入分类管理的字段集合,可被后台实时配置覆盖
const DEFAULT_CATEGORY_FIELDS = [
'categoryName',
@ -450,7 +505,13 @@ const requestJson = async <T,>(input: RequestInfo, init?: RequestInit): Promise<
return res.json()
}
export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
export default function TemplateManager({
showToast,
currentIsSuperAdmin = false,
}: {
showToast: ToastFn
currentIsSuperAdmin?: boolean
}) {
const styles = useThemeStyles()
const { confirm } = useDialog()
const { isFree, isFeatureEnabled } = useLicenseStatus()
@ -478,12 +539,23 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const [categoryFieldKeys, setCategoryFieldKeys] = useState<string[]>(Array.from(GLOBAL_CATEGORY_FIELDS))
const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key
const canUseCustomTemplates = !isFree && isFeatureEnabled('customTemplates')
const canManageCustomTemplates = currentIsSuperAdmin && canUseCustomTemplates
const showCustomTemplateBlocked = () => {
if (!currentIsSuperAdmin) {
showToast('只有最高权限账号可以创建、编辑、复制、删除和迁移自定义模板', 'error')
return
}
setShowUpgradeModal(true)
}
// 素材迁移弹窗状态
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false)
const [migrationSourceTemplate, setMigrationSourceTemplate] = useState<{ id: string; name: string; materialCount: number } | null>(null)
const [migrationTargetTemplateId, setMigrationTargetTemplateId] = useState<string>('')
const [migrating, setMigrating] = useState(false)
const [migrationPreview, setMigrationPreview] = useState<MigrationPreviewResponse | null>(null)
const [migrationPreviewLoading, setMigrationPreviewLoading] = useState(false)
const [migrationPreviewError, setMigrationPreviewError] = useState<string | null>(null)
const syncCategoryFieldSet = useCallback((keys: string[]) => {
GLOBAL_CATEGORY_FIELDS.clear()
@ -709,8 +781,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
}, [fetchTemplates])
const openCreate = (presetKey?: string) => {
if (!canUseCustomTemplates) {
setShowUpgradeModal(true)
if (!canManageCustomTemplates) {
showCustomTemplateBlocked()
return
}
const preset =
@ -751,8 +823,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
}
const openEdit = (tpl: UploadTemplateDto) => {
if (!canUseCustomTemplates) {
setShowUpgradeModal(true)
if (!canManageCustomTemplates) {
showCustomTemplateBlocked()
return
}
setForm({
@ -795,8 +867,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
}
const openDuplicate = (tpl: UploadTemplateDto) => {
if (!canUseCustomTemplates) {
setShowUpgradeModal(true)
if (!canManageCustomTemplates) {
showCustomTemplateBlocked()
return
}
setForm({
@ -1112,8 +1184,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
}, [])
const createTemplateFromPreset = async (presetKey: string, status: 'draft' | 'active' = 'draft') => {
if (!canUseCustomTemplates) {
setShowUpgradeModal(true)
if (!canManageCustomTemplates) {
showCustomTemplateBlocked()
return
}
const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey)
@ -1649,6 +1721,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
materialCount: templateDetail.materialCount,
})
setMigrationTargetTemplateId('')
setMigrationPreview(null)
setMigrationPreviewError(null)
setMigrationDialogOpen(true)
return
}
@ -1673,24 +1747,82 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
setDeletingId(null)
}
}
// 执行素材迁移并删除模板
const handleMigrateAndDelete = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId) return
setMigrating(true)
const handleMigrationTargetChange = (targetTemplateId: string) => {
setMigrationTargetTemplateId(targetTemplateId)
setMigrationPreview(null)
setMigrationPreviewError(null)
}
const loadMigrationPreview = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId || migrationPreviewLoading) return
setMigrationPreviewLoading(true)
setMigrationPreviewError(null)
try {
// 1. 迁移素材
const migrateResult = await requestJson<{ success: boolean; migratedCount: number; message: string }>(
const preview = await requestJson<MigrationPreviewResponse>(
`/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetTemplateId: migrationTargetTemplateId }),
body: JSON.stringify({
targetTemplateId: migrationTargetTemplateId,
previewOnly: true,
}),
}
)
showToast(migrateResult.message, 'success')
setMigrationPreview(preview)
} catch (error: any) {
setMigrationPreview(null)
setMigrationPreviewError(error.message || '生成迁移预览失败')
} finally {
setMigrationPreviewLoading(false)
}
}
// 执行素材迁移并删除模板
const handleMigrateAndDelete = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId) return
if (!migrationPreview || migrationPreview.targetTemplate.id !== migrationTargetTemplateId) {
setMigrationPreviewError('请先生成迁移预览,确认字段影响后再执行迁移。')
return
}
setMigrating(true)
try {
let remaining = migrationPreview.migrationPreview.totalMaterialCount || migrationSourceTemplate.materialCount
let migratedTotal = 0
let lastMessage = ''
const maxBatches = Math.ceil(Math.max(remaining, 1) / MIGRATION_BATCH_LIMIT) + 5
for (let batchIndex = 0; remaining > 0 && batchIndex < maxBatches; batchIndex += 1) {
const migrateResult = await requestJson<MigrationBatchResult>(
`/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetTemplateId: migrationTargetTemplateId,
migrationLimit: MIGRATION_BATCH_LIMIT,
forceLargeMigration: true,
}),
}
)
migratedTotal += migrateResult.migratedCount
remaining = migrateResult.remainingMaterialCount ?? Math.max(remaining - migrateResult.migratedCount, 0)
lastMessage = migrateResult.message
if (migrateResult.migratedCount === 0 && remaining > 0) {
throw new Error('迁移没有继续推进,请刷新后重试。')
}
}
if (remaining > 0) {
throw new Error(`仍有 ${remaining} 个素材未迁移,请刷新后重试。`)
}
showToast(lastMessage || `成功迁移 ${migratedTotal} 个素材`, 'success')
// 2. 删除源模板
await requestJson(`/api/admin/upload-templates/${migrationSourceTemplate.id}`, { method: 'DELETE' })
@ -1701,6 +1833,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
setMigrationDialogOpen(false)
setMigrationSourceTemplate(null)
setMigrationTargetTemplateId('')
setMigrationPreview(null)
setMigrationPreviewError(null)
// 通知首页刷新模板列表
notifyTemplatesUpdate()
@ -1713,10 +1847,12 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
// 关闭迁移弹窗
const closeMigrationDialog = () => {
if (migrating) return
if (migrating || migrationPreviewLoading) return
setMigrationDialogOpen(false)
setMigrationSourceTemplate(null)
setMigrationTargetTemplateId('')
setMigrationPreview(null)
setMigrationPreviewError(null)
}
// 获取可选的目标模板列表(排除源模板)
@ -1772,8 +1908,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
presets={BUILTIN_TEMPLATES.filter(p => enabledBuiltinTemplates.has(p.key))}
onPreview={openPresetPreview}
onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')}
canQuickCreate={canUseCustomTemplates}
onRequestUpgrade={() => setShowUpgradeModal(true)}
canQuickCreate={canManageCustomTemplates}
onRequestUpgrade={showCustomTemplateBlocked}
onToggleEnabled={toggleBuiltinTemplateEnabled}
onEnableAll={enableAllBuiltinTemplates}
enabledTemplates={enabledBuiltinTemplates}
@ -1786,8 +1922,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
templates={displayTemplates}
filteredTemplates={filteredTemplates}
deletingId={deletingId}
canManageCustomTemplates={canUseCustomTemplates}
onRequestUpgrade={() => setShowUpgradeModal(true)}
canManageCustomTemplates={canManageCustomTemplates}
onRequestUpgrade={showCustomTemplateBlocked}
onCreate={openCreate}
onPreview={openCustomPreview}
onActivate={handleActivate}
@ -1808,7 +1944,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
closeOnEscape={false}
>
<div
className={`bg-white dark:bg-slate-900 rounded-xl shadow-lg max-h-[92vh] overflow-hidden flex flex-col ${
className={`bg-white dark:bg-slate-900 rounded-lg shadow-sm max-h-[92vh] overflow-hidden flex flex-col ${
isPreviewMode
? 'w-full max-w-4xl'
: 'w-full max-w-6xl'
@ -1829,7 +1965,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</div>
<button
onClick={closeEditor}
className="p-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all duration-200"
className="p-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-md transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -1925,7 +2061,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onClose={() => setShowUpgradeModal(false)}
featureKey="customTemplates"
featureName="自定义模板"
featureDescription="创建、复制、编辑和迁移自定义模板需要升级后才能使用。"
featureDescription="当前许可证未包含创建、复制、编辑和迁移自定义模板的授权。"
/>
{/* 素材迁移弹窗 */}
@ -1933,14 +2069,14 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
<Modal
isOpen={migrationDialogOpen}
onClose={closeMigrationDialog}
size="medium"
size="large"
showCloseButton={true}
closeOnOverlayClick={!migrating}
closeOnEscape={!migrating}
closeOnOverlayClick={!migrating && !migrationPreviewLoading}
closeOnEscape={!migrating && !migrationPreviewLoading}
>
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg p-6 max-w-lg w-full">
<div className="max-h-[84vh] overflow-y-auto">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
@ -1958,7 +2094,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 mb-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
模板 <span className="font-medium text-slate-900 dark:text-white">&ldquo;{migrationSourceTemplate.name}&rdquo;</span> 有{' '}
<span className="font-bold text-blue-600 dark:text-blue-400">{migrationSourceTemplate.materialCount}</span>{' '}
<span className="font-bold text-slate-900 dark:text-white">{migrationSourceTemplate.materialCount}</span>{' '}
个素材正在使用。
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">
@ -1972,8 +2108,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</label>
<select
value={migrationTargetTemplateId}
onChange={(e) => setMigrationTargetTemplateId(e.target.value)}
disabled={migrating}
onChange={(e) => handleMigrationTargetChange(e.target.value)}
disabled={migrating || migrationPreviewLoading}
className={cn(
'w-full px-3 py-2 rounded-lg border transition-colors',
'bg-white dark:bg-slate-800',
@ -2013,11 +2149,129 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</p>
)}
</div>
<div className="mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
迁移预览
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
预览不会修改数据,确认字段影响后再执行迁移。
</p>
</div>
<button
type="button"
onClick={loadMigrationPreview}
disabled={migrating || migrationPreviewLoading || !migrationTargetTemplateId}
className={cn(
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200',
'hover:bg-slate-50 dark:hover:bg-slate-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{migrationPreviewLoading ? '生成中...' : migrationPreview ? '刷新预览' : '生成预览'}
</button>
</div>
{migrationPreviewError && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
{migrationPreviewError}
</p>
)}
{migrationPreview && (
<div className="mt-3 rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">素材总数</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationPreview.totalMaterialCount}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">需备份素材</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.materialsWithDroppedFields}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">备份字段</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.droppedTemplateFieldCount + migrationPreview.migrationBackup.droppedStandardFieldCount}
</p>
</div>
</div>
{migrationPreview.migrationPreview.previewLimited && (
<p className="text-xs text-slate-500 dark:text-slate-400">
当前只预览前 {migrationPreview.migrationPreview.totalMaterials} 个素材;确认后会按每批 {MIGRATION_BATCH_LIMIT} 个自动分批迁移。
</p>
)}
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
迁移后当前字段
</p>
<div className="flex flex-wrap gap-2">
{migrationPreview.migrationPreview.targetCurrentFields.slice(0, 12).map((field) => (
<span
key={field.key}
className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{field.label}{field.required ? ' *' : ''}
</span>
))}
{migrationPreview.migrationPreview.targetCurrentFields.length > 12 && (
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
另有 {migrationPreview.migrationPreview.targetCurrentFields.length - 12} 个
</span>
)}
</div>
</div>
{(migrationPreview.migrationPreview.backupFields.templateFields.length > 0 ||
migrationPreview.migrationPreview.backupFields.standardFields.length > 0) ? (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
会进入备份区的旧字段
</p>
<div className="flex flex-wrap gap-2">
{[
...migrationPreview.migrationPreview.backupFields.templateFields,
...migrationPreview.migrationPreview.backupFields.standardFields,
].slice(0, 12).map((field) => (
<span
key={`${field.source}-${field.key}`}
className="rounded-md bg-amber-50 px-2 py-1 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
{field.label}({field.affectedCount || 0})
</span>
))}
</div>
{migrationPreview.migrationPreview.samples.length > 0 && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
示例素材:{migrationPreview.migrationPreview.samples
.map((sample) => sample.productModel || sample.materialId)
.join('、')}
</p>
)}
</div>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
目标模板已接收当前字段,本次迁移不会产生旧字段备份。
</p>
)}
</div>
)}
</div>
<div className="flex gap-3 justify-end">
<button
onClick={closeMigrationDialog}
disabled={migrating}
disabled={migrating || migrationPreviewLoading}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300',
@ -2029,7 +2283,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</button>
<button
onClick={handleMigrateAndDelete}
disabled={migrating || !migrationTargetTemplateId}
disabled={migrating || migrationPreviewLoading || !migrationTargetTemplateId || !migrationPreview || migrationPreview.targetTemplate.id !== migrationTargetTemplateId}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-red-600 text-white',
@ -2047,7 +2301,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
迁移中...
</>
) : (
<>迁移并删除</>
<>确认迁移并删除</>
)}
</button>
</div>

View File

@ -337,7 +337,7 @@ export default function UsersTab({
.filter(Boolean) as string[]
}, [editingUserDepartment, users])
const renderStatusBadges = (user: User) => (
const renderStatusBadges = useCallback((user: User) => (
<>
<span
className={cn(
@ -360,7 +360,7 @@ export default function UsersTab({
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
), [badgeActive, badgeInactive, badgeNoRole, badgeRole])
const renderActionButtons = useCallback((user: User, options?: { showRoleToggle?: boolean }) => {
const allowed = canManageUser(user)
@ -419,12 +419,33 @@ export default function UsersTab({
)}
</div>
)
}, [canManageUser, currentIsSuperAdmin, currentUserId, handleEditUser, onToggleActive, onToggleDownload, onSetRole])
}, [
canManageUser,
currentIsSuperAdmin,
currentUserId,
handleEditUser,
onToggleActive,
onToggleDownload,
onSetRole,
styles.buttonModifiers.compactXs,
styles.buttonStyles.danger,
styles.buttonStyles.secondary,
styles.buttonStyles.success,
textDesc,
])
const renderUserRow = useCallback(
(user: User, isManager = false) => (
<div key={user.id} className={cn(styles.cardStyles.hover, 'group flex items-start gap-3 cursor-pointer')}>
<div className="flex-1 space-y-1">
<div
key={user.id}
className={cn(
'group flex items-start gap-3 rounded-md border px-3 py-2.5 transition-colors duration-150',
theme === 'dark'
? 'border-slate-800 bg-slate-900/30 hover:bg-slate-800/50'
: 'border-slate-200 bg-white hover:bg-slate-50',
)}
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
@ -453,7 +474,7 @@ export default function UsersTab({
{renderActionButtons(user)}
</div>
),
[renderActionButtons],
[renderActionButtons, renderStatusBadges, textDesc, textNormal, textSuperAdmin, theme],
)
const showSearchResults = Boolean(searchTerm.trim())

View File

@ -36,7 +36,7 @@ export default function BuiltInTemplateGrid({
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">预制模板(推荐)</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">常用场景一键启用,也可作为自定义模板的起点</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
点击卡片仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
点击条目仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
</p>
</div>
{hasDisabledTemplates && onEnableAll && (
@ -54,32 +54,24 @@ export default function BuiltInTemplateGrid({
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
{presets.map((preset) => {
const fieldCount = preset.getFields().length
return (
<div
key={preset.key}
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className="group relative cursor-pointer rounded-lg border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600 dark:hover:bg-slate-800/70"
onClick={() => onPreview(preset.key)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{preset.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug">{preset.description}</p>
</div>
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
预制
</span>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 dark:text-slate-500">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 7h16M4 12h10m-10 5h7" />
</svg>
<span>{fieldCount} 个字段</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
<span className="rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{fieldCount} 个字段
</span>
{onToggleEnabled && (
<button
type="button"
@ -92,7 +84,6 @@ export default function BuiltInTemplateGrid({
? styles.buttonStyles.secondary
: styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'text-xs'
)}
title={enabledTemplates.has(preset.key) ? '禁用此模板' : '启用此模板'}
@ -113,16 +104,15 @@ export default function BuiltInTemplateGrid({
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'inline-flex items-center gap-1.5 shadow-none',
!canQuickCreate && 'opacity-75'
)}
title={canQuickCreate ? '快速创建副本' : '需要升级后才能创建自定义模板'}
title={canQuickCreate ? '快速创建副本' : '当前许可证未包含自定义模板功能'}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3.5a.75.75 0 01.75.75v5h5a.75.75 0 010 1.5h-5v5a.75.75 0 01-1.5 0v-5h-5a.75.75 0 010-1.5h5v-5A.75.75 0 0110 3.5z" />
</svg>
{canQuickCreate ? '快速创建副本' : '升级后创建'}
{canQuickCreate ? '快速创建副本' : '授权后创建'}
</button>
</div>
</div>

View File

@ -1,7 +1,6 @@
'use client'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import ProBadge from '@/components/ui/ProBadge'
import type { DisplayTemplate } from './types'
interface CustomTemplateSectionProps {
@ -49,18 +48,39 @@ export default function CustomTemplateSection({
return (
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between gap-4 mb-3">
<div className="flex flex-col gap-3 mb-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">自定义模板</h3>
{!canManageCustomTemplates && <ProBadge size="small" />}
{!canManageCustomTemplates && (
<span className="inline-flex items-center rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
授权受限
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{canManageCustomTemplates
? '这里列出你自己创建的模板,可继续编辑、复制或删除'
: '当前许可证未开放自定义模板功能,升级后可新建、复制、编辑和迁移模板'}
: '当前许可证未包含自定义模板功能,请联系管理员处理授权'}
</p>
</div>
<button
type="button"
onClick={() => {
if (!canManageCustomTemplates) {
onRequestUpgrade()
return
}
onCreate()
}}
className={cn(
canManageCustomTemplates ? styles.buttonStyles.primary : styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'w-full sm:w-auto'
)}
>
{canManageCustomTemplates ? '新建自定义模板' : '授权后可新建'}
</button>
</div>
{loading ? (
@ -68,59 +88,28 @@ export default function CustomTemplateSection({
) : error ? (
<div className="p-8 text-center text-red-500 dark:text-red-300">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
if (!canManageCustomTemplates) {
onRequestUpgrade()
return
}
onCreate()
}}
className={cn(
styles.cardStyles.flat,
'border-dashed border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center gap-3'
)}
>
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 transition-colors">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{canManageCustomTemplates ? '新建自定义模板' : '升级后新建模板'}
</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-snug">
{canManageCustomTemplates
? '从空白模板起步,按照业务需求配置字段'
: '升级后可新建、复制和迁移自定义模板'}
</p>
</div>
</button>
<div className="space-y-2">
{templates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">暂无自定义模板,点击上方卡片开始创建。</div>
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">暂无自定义模板。</div>
) : filteredTemplates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">未找到匹配的模板名称,请调整搜索关键词。</div>
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">未找到匹配的模板名称,请调整搜索关键词。</div>
) : (
filteredTemplates.map((tpl) => (
<div
key={tpl.id}
onClick={() => onPreview(tpl)}
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className="group relative cursor-pointer rounded-lg border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600 dark:hover:bg-slate-800/70"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug line-clamp-2">{tpl.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-all duration-200 ${
className={`px-2 py-0.5 text-xs font-medium rounded-md transition-colors ${
tpl.status === 'active'
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: tpl.status === 'draft'
@ -130,9 +119,9 @@ export default function CustomTemplateSection({
>
{tpl.statusLabel}
</span>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-200/70 dark:border-slate-700/70 pt-3">
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{tpl.updatedLabel} · 版本 {tpl.version}
</span>
{tpl.status === 'active' ? (
<button
type="button"
@ -143,7 +132,6 @@ export default function CustomTemplateSection({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
!canManageCustomTemplates && 'opacity-70'
)}
>
@ -159,7 +147,6 @@ export default function CustomTemplateSection({
className={cn(
styles.buttonStyles.success,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
!canManageCustomTemplates && 'opacity-70'
)}
>
@ -175,7 +162,6 @@ export default function CustomTemplateSection({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
!canManageCustomTemplates && 'opacity-70'
)}
>
@ -190,7 +176,6 @@ export default function CustomTemplateSection({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
!canManageCustomTemplates && 'opacity-70'
)}
@ -207,7 +192,6 @@ export default function CustomTemplateSection({
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'bg-red-500 hover:bg-red-600 text-white',
!canManageCustomTemplates && 'opacity-70',
deletingId === tpl.id && 'opacity-70 cursor-wait',
@ -215,11 +199,9 @@ export default function CustomTemplateSection({
>
{deletingId === tpl.id ? '删除中...' : '删除'}
</button>
<span className="ml-auto text-[11px] text-slate-400 dark:text-slate-500">
{tpl.updatedLabel} · v{tpl.version}
</span>
</div>
</div>
</div>
))
)}
</div>

View File

@ -19,10 +19,10 @@ export default function TemplateManagerHeader({
return (
<section
className={cn(
'mb-4 rounded-2xl border px-4 py-4',
'mb-4 border-b px-1 pb-4',
theme === 'dark'
? 'border-slate-700 bg-slate-900/85 text-white'
: 'border-slate-200 bg-white text-slate-900'
? 'border-slate-800 text-white'
: 'border-slate-200 text-slate-900'
)}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">

View File

@ -79,10 +79,10 @@ describe('CustomTemplateSection 授权提示', () => {
it('许可证不包含自定义模板时应显示前置授权提示并拦截新建入口', () => {
renderSection(false)
expect(screen.getByText('当前许可证未开放自定义模板功能,升级后可新建、复制、编辑和迁移模板')).toBeInTheDocument()
expect(screen.getByTestId('pro-badge')).toBeInTheDocument()
expect(screen.getByText('当前许可证未包含自定义模板功能,请联系管理员处理授权')).toBeInTheDocument()
expect(screen.getByText('授权受限')).toBeInTheDocument()
fireEvent.click(screen.getByText('升级后新建模板'))
fireEvent.click(screen.getByText('授权后可新建'))
expect(callbacks.onRequestUpgrade).toHaveBeenCalledTimes(1)
expect(callbacks.onCreate).not.toHaveBeenCalled()

View File

@ -103,7 +103,7 @@ export default function FieldConditionsConfig({
const hasConditions = config.conditions.length > 0
const inputClass =
'w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200'
'w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-500 dark:focus:border-slate-500 outline-none transition-colors duration-150'
// 如果没有可选字段,不显示此功能
if (selectableFields.length === 0) {
@ -128,7 +128,7 @@ export default function FieldConditionsConfig({
</svg>
<span>条件显示配置</span>
{hasConditions && (
<span className="px-1.5 py-0.5 text-[10px] bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
<span className="px-1.5 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded">
{config.conditions.length}个条件
</span>
)}
@ -156,15 +156,15 @@ export default function FieldConditionsConfig({
type="button"
onClick={toggleLogic}
className="flex-shrink-0 px-2 py-1.5 text-xs font-medium bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
title="点击切换 AND/OR"
title="切换条件关系"
>
{config.logic}
{config.logic === 'AND' ? '全部' : '任一'}
</button>
)}
{index === 0 && <div className="w-10 flex-shrink-0" />}
{/* 条件配置 */}
<div className="flex-1 grid grid-cols-3 gap-2">
<div className="flex-1 grid grid-cols-1 gap-2 sm:grid-cols-3">
{/* 字段选择 */}
<select
value={condition.fieldKey}
@ -231,7 +231,7 @@ export default function FieldConditionsConfig({
<button
type="button"
onClick={addCondition}
className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -256,10 +256,8 @@ export default function FieldConditionsConfig({
)}
{/* 说明 */}
<div className="text-[10px] text-slate-400 dark:text-slate-500 space-y-1">
<p>• 当设置了条件时,只有满足条件才会显示此字段</p>
<p>• AND: 所有条件都满足时显示 | OR: 任一条件满足时显示</p>
<p>• 点击 AND/OR 按钮可切换逻辑运算符</p>
<div className="text-[10px] text-slate-400 dark:text-slate-500">
<p>满足条件时显示此字段;多个条件可切换“全部”或“任一”。</p>
</div>
</div>
)}

View File

@ -75,7 +75,7 @@ export default function FieldValidationConfig({
}, [rules])
const inputClass =
'w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200'
'w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-500 dark:focus:border-slate-500 outline-none transition-colors duration-150'
const labelClass = 'block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1'
const hintClass = 'text-[10px] text-slate-500 dark:text-slate-400 mt-1'
@ -97,7 +97,7 @@ export default function FieldValidationConfig({
</svg>
<span>验证规则配置</span>
{hasAnyRules && (
<span className="px-1.5 py-0.5 text-[10px] bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
<span className="px-1.5 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded">
已配置
</span>
)}
@ -121,7 +121,7 @@ export default function FieldValidationConfig({
type="checkbox"
checked={rules.required || false}
onChange={(e) => updateRules({ required: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-slate-700 focus:ring-2 focus:ring-slate-300 dark:focus:ring-slate-600"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">必填字段</span>
</label>
@ -142,7 +142,7 @@ export default function FieldValidationConfig({
{/* 长度验证 - text/textarea */}
{supportedValidations.includes('minLength') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={labelClass}>最小长度</label>
<input
@ -179,7 +179,7 @@ export default function FieldValidationConfig({
{/* 自定义长度错误提示 */}
{supportedValidations.includes('minLength') &&
(typeof rules.minLength === 'number' || typeof rules.maxLength === 'number') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{typeof rules.minLength === 'number' && (
<div>
<label className={labelClass}>最小长度提示(可选)</label>
@ -213,7 +213,7 @@ export default function FieldValidationConfig({
{/* 数值范围验证 - number */}
{supportedValidations.includes('min') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={labelClass}>最小值</label>
<input
@ -248,7 +248,7 @@ export default function FieldValidationConfig({
{/* 自定义数值范围错误提示 */}
{supportedValidations.includes('min') &&
(typeof rules.min === 'number' || typeof rules.max === 'number') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{typeof rules.min === 'number' && (
<div>
<label className={labelClass}>最小值提示(可选)</label>

View File

@ -1,6 +1,5 @@
'use client'
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
import type { TemplateForm } from '../types'
interface TemplateAdvancedConfigPanelProps {
@ -26,8 +25,11 @@ export default function TemplateAdvancedConfigPanel({
onFieldGroupsErrorChange,
onDisplayConfigErrorChange,
}: TemplateAdvancedConfigPanelProps) {
const textareaClass =
'w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:focus:border-slate-500 dark:focus:ring-slate-700 font-mono'
return (
<div className={cn(cardStyles.base, 'p-4')}>
<div className="rounded-lg border border-slate-200 bg-slate-50/50 p-4 transition-colors dark:border-slate-700 dark:bg-slate-800/40">
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">高级显示 / 分组配置(选填)</p>
@ -54,7 +56,7 @@ export default function TemplateAdvancedConfigPanel({
onFieldGroupsErrorChange(null)
onFieldGroupsChange(e.target.value)
}}
className={cn(inputStyles.base, inputModifiers.compact, 'font-mono')}
className={textareaClass}
placeholder='例如:{"groups":[{"title":"核心信息","fieldKeys":["materialType","productModel"]}]}'
/>
{fieldGroupsError ? (
@ -73,7 +75,7 @@ export default function TemplateAdvancedConfigPanel({
onDisplayConfigErrorChange(null)
onDisplayConfigChange(e.target.value)
}}
className={cn(inputStyles.base, inputModifiers.compact, 'font-mono')}
className={textareaClass}
placeholder='例如:{"layout":"two-column","showPreview":true}'
/>
{displayConfigError ? (

View File

@ -1,6 +1,6 @@
'use client'
import { buttonStyles, buttonModifiers, inputStyles, cn } from '@/lib/ui-styles'
import { cn } from '@/lib/ui-styles'
import type { TemplateForm, BuiltinTemplateConfig } from '../types'
interface TemplateBasicsFormProps {
@ -24,6 +24,9 @@ export default function TemplateBasicsForm({
onChange,
onSelectPreset,
}: TemplateBasicsFormProps) {
const inputClass =
'w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:focus:border-slate-500 dark:focus:ring-slate-700'
const inferMaterialKind = (materialType: string) => {
if (materialType === '视频') return 'VIDEO'
if (materialType === '音频') return 'AUDIO'
@ -41,7 +44,7 @@ export default function TemplateBasicsForm({
type="text"
value={form.name}
onChange={(e) => onChange({ name: e.target.value })}
className={cn(inputStyles.base)}
className={inputClass}
placeholder="例如:产品素材模板"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">给模板起一个容易识别的名称</p>
@ -53,7 +56,7 @@ export default function TemplateBasicsForm({
rows={2}
value={form.description}
onChange={(e) => onChange({ description: e.target.value })}
className={cn(inputStyles.base, 'resize-none')}
className={cn(inputClass, 'resize-none')}
placeholder="简单描述这个模板的用途"
/>
</div>
@ -63,7 +66,7 @@ export default function TemplateBasicsForm({
<select
value={form.status}
onChange={(e) => onChange({ status: e.target.value })}
className={cn(inputStyles.base)}
className={inputClass}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
@ -82,7 +85,7 @@ export default function TemplateBasicsForm({
<select
value={form.materialType}
onChange={(e) => onChange({ materialType: e.target.value, materialKind: inferMaterialKind(e.target.value) })}
className={cn(inputStyles.base)}
className={inputClass}
>
{materialTypeOptions.map((option) => (
<option key={option} value={option}>
@ -99,10 +102,10 @@ export default function TemplateBasicsForm({
</label>
<div className="flex flex-wrap gap-2">
{[
{ label: '图片/文档', value: 'IMAGE', hint: '普通素材,走图片/文件流程' },
{ label: '音频', value: 'AUDIO', hint: '走音频上传与解析流程' },
{ label: '视频', value: 'VIDEO', hint: '走视频上传与转码流程' },
{ label: '其他', value: 'OTHER', hint: '暂不归类,可后续扩展' },
{ label: '图片/文档', value: 'IMAGE' },
{ label: '音频', value: 'AUDIO' },
{ label: '视频', value: 'VIDEO' },
{ label: '其他', value: 'OTHER' },
].map((option) => {
const active = (form.materialKind || 'IMAGE') === option.value
return (
@ -111,13 +114,13 @@ export default function TemplateBasicsForm({
type="button"
onClick={() => onChange({ materialKind: option.value })}
className={cn(
active ? buttonStyles.primary : buttonStyles.secondary,
buttonModifiers.pill,
'flex flex-col items-start gap-0.5 px-4 py-2 text-left min-w-[120px]',
'min-w-[96px] rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
active
? 'border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800',
)}
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-[11px] text-current/70">{option.hint}</span>
{option.label}
</button>
)
})}
@ -138,14 +141,13 @@ export default function TemplateBasicsForm({
type="button"
onClick={() => onSelectPreset(preset.key)}
className={cn(
active ? buttonStyles.primary : buttonStyles.secondary,
buttonModifiers.compactXs,
buttonModifiers.pill,
'flex items-center gap-1.5 shadow-none',
'rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors',
active
? 'border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800',
!active && 'border-dashed',
)}
>
{active && <span className="inline-block w-1.5 h-1.5 rounded-full bg-white" />}
<span>{preset.name}</span>
</button>
)

View File

@ -35,7 +35,7 @@ export default function TemplateEditorFooter({
{saveMessage && (
<div
className={cn(
'mb-4 px-4 py-3 rounded-xl flex items-center gap-2 border',
'mb-4 px-4 py-3 rounded-lg flex items-center gap-2 border',
saveMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
@ -62,7 +62,7 @@ export default function TemplateEditorFooter({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
'rounded-lg bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
)}
>
关闭
@ -72,7 +72,7 @@ export default function TemplateEditorFooter({
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
'rounded-lg bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-100 dark:hover:bg-white dark:text-slate-900',
)}
>
{previewTemplate ? '编辑此模板' : '从此模板新建'}
@ -86,7 +86,7 @@ export default function TemplateEditorFooter({
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200', // 显式添加背景色和文字色
'rounded-lg bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
saving && 'opacity-60 cursor-not-allowed',
)}
>
@ -99,7 +99,7 @@ export default function TemplateEditorFooter({
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'min-w-[120px]',
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
'rounded-lg bg-slate-900 hover:bg-slate-800 text-white dark:bg-slate-100 dark:hover:bg-white dark:text-slate-900',
)}
>
{saving ? (

View File

@ -1,7 +1,7 @@
'use client'
import { Fragment, DragEvent, useState, useMemo } from 'react'
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
import { cn } from '@/lib/ui-styles'
import type { FieldForm, DragHoverState } from '../../template-manager/types'
import FieldValidationConfig from './FieldValidationConfig'
import FieldConditionsConfig from './FieldConditionsConfig'
@ -42,6 +42,12 @@ interface TemplateFieldsEditorProps {
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
}
const editorInputClass =
'w-full px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-white placeholder:text-slate-400 outline-none transition-colors duration-150 focus:border-slate-500 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700'
const editorCheckboxClass =
'w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-slate-700 focus:ring-2 focus:ring-slate-300 dark:focus:ring-slate-600'
// 字段key验证函数
function validateFieldKey(key: string, currentIndex: number, allFields: FieldForm[]): {
valid: boolean
@ -152,7 +158,7 @@ export default function TemplateFieldsEditor({
}
return (
<div className={cn(cardStyles.base, 'p-4 space-y-5')}>
<div className="rounded-lg border border-slate-200 bg-white p-4 transition-colors dark:border-slate-700 dark:bg-slate-800 space-y-5">
<div>
<h4 className="text-lg font-semibold text-slate-900 dark:text-white">字段配置</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">添加用户上传时需要填写的字段</p>
@ -198,15 +204,15 @@ export default function TemplateFieldsEditor({
{placeholderBefore && renderDropPlaceholder(`editor-before-${index}`)}
<div
id={`editor-field-${index}`}
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
className={`group border rounded-lg p-4 transition-colors duration-150 ${
hasError
? 'border-red-300 dark:border-red-600 bg-red-50/50 dark:bg-red-900/10'
: editingFieldIndex === index
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 shadow-md'
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800 hover:shadow-sm'
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800'
} ${
isDragging
? 'opacity-60 cursor-grabbing border-slate-400 dark:border-slate-500 shadow-sm transition-colors duration-150'
? 'opacity-60 cursor-grabbing border-slate-400 dark:border-slate-500'
: 'cursor-default'
}`}
draggable={true}
@ -253,7 +259,7 @@ export default function TemplateFieldsEditor({
e.stopPropagation()
handleDuplicateField(index)
}}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-all duration-200"
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
title="复制字段"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -266,7 +272,7 @@ export default function TemplateFieldsEditor({
onMoveField(index, 'up')
}}
disabled={index === 0}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="上移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -279,7 +285,7 @@ export default function TemplateFieldsEditor({
onMoveField(index, 'down')
}}
disabled={index === fields.length - 1}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="下移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -291,7 +297,7 @@ export default function TemplateFieldsEditor({
e.stopPropagation()
handleDeleteField(index)
}}
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all duration-200"
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -321,7 +327,7 @@ export default function TemplateFieldsEditor({
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>
@ -369,7 +375,7 @@ export default function TemplateFieldsEditor({
onUpdateField(index, patch)
}}
onClick={(e) => e.stopPropagation()}
className={cn(inputStyles.base, inputModifiers.compact)}
className={editorInputClass}
>
<option value="__custom">自定义字段(手动输入键名)</option>
<optgroup label="标准字段">
@ -390,7 +396,7 @@ export default function TemplateFieldsEditor({
onUpdateField(index, { key: e.target.value })
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
className={editorInputClass}
placeholder="例如:productModel"
autoComplete="off"
/>
@ -413,7 +419,7 @@ export default function TemplateFieldsEditor({
onUpdateField(index, { label: e.target.value, autoLabel: false })
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
className={editorInputClass}
placeholder="例如:商品型号"
autoComplete="off"
/>
@ -427,7 +433,7 @@ export default function TemplateFieldsEditor({
onUpdateField(index, { type: e.target.value })
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
className={editorInputClass}
>
{fieldTypes
.filter((type) => type !== 'image' && type !== 'file')
@ -461,7 +467,7 @@ export default function TemplateFieldsEditor({
value={field.layoutWidth}
onChange={(e) => onUpdateField(index, { layoutWidth: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
className={editorInputClass}
>
{layoutOptions.map((option) => (
<option key={option.value} value={option.value}>
@ -474,18 +480,12 @@ export default function TemplateFieldsEditor({
{/* 选项管理提示 - 仅在select或radio类型时显示 */}
{(isSelectType || field.type === 'radio') && (
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="mt-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 text-slate-400 dark:text-slate-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-xs text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">下拉选项管理</p>
<p className="text-blue-600 dark:text-blue-400">
勾选「添加到分类」后,可在分类管理中统一管理此字段的选项值。
这样可以确保所有模板使用一致的选项,便于维护。
</p>
</div>
<p>勾选“添加到分类”后,选项可在分类管理中统一维护。</p>
</div>
</div>
)}
@ -497,7 +497,7 @@ export default function TemplateFieldsEditor({
checked={field.required ?? false}
onChange={(e) => onUpdateField(index, { required: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
className={editorCheckboxClass}
/>
<span>必填</span>
</label>
@ -507,7 +507,7 @@ export default function TemplateFieldsEditor({
checked={field.isSearchable ?? false}
onChange={(e) => onUpdateField(index, { isSearchable: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
className={editorCheckboxClass}
/>
<span>可搜索</span>
</label>
@ -528,7 +528,7 @@ export default function TemplateFieldsEditor({
onUpdateField(index, { addToCategory: e.target.checked })
}}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
className={editorCheckboxClass}
/>
<span>
添加到分类
@ -537,14 +537,7 @@ export default function TemplateFieldsEditor({
)}
</span>
</label>
) : (
// 调试信息:显示为什么不显示复选框
editingFieldIndex === index && field.type === 'select' && (
<div className="text-[10px] text-red-500 dark:text-red-400 font-mono">
调试: type=&quot;{field.type}&quot;, isSelectType={String(isSelectType)}
</div>
)
)}
) : null}
</div>
{/* 验证规则配置 */}
@ -569,20 +562,17 @@ export default function TemplateFieldsEditor({
<button
onClick={onAddField}
className="w-full border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-all duration-200 hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 group"
className="w-full rounded-lg border border-dashed border-slate-300 px-3 py-3 text-left transition-colors hover:border-slate-400 hover:bg-slate-50 dark:border-slate-600 dark:hover:border-slate-500 dark:hover:bg-slate-800/50 group"
>
<div className="flex items-center justify-center gap-3">
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-600 transition-colors">
<svg className="w-7 h-7 text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex items-center justify-center gap-2">
<div className="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-600 transition-colors">
<svg className="w-4 h-4 text-slate-500 group-hover:text-slate-700 dark:group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">
添加元素
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">点击添加新的字段配置</p>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">
添加字段
</span>
</div>
</button>
</div>
@ -593,7 +583,7 @@ export default function TemplateFieldsEditor({
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 max-w-md w-full mx-4">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/40 flex items-center justify-center">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/40 flex items-center justify-center">
<svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>

View File

@ -16,6 +16,7 @@ import {
} from '@dnd-kit/core'
import { SortableContext, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/ui-styles'
import type { FieldForm } from '../../template-manager/types'
type PreviewSortableItem = {
@ -351,14 +352,15 @@ export default function TemplatePreviewPanel({
<div
className={
isPreviewMode
? 'border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 bg-slate-50/30 dark:bg-slate-800/30'
? ''
: ''
}
>
<div
className={`bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 space-y-5 ${
isPreviewMode ? 'p-5 border border-slate-300 dark:border-slate-600' : 'p-5'
}`}
className={cn(
'rounded-lg bg-white p-5 space-y-5 dark:bg-slate-800',
'border border-slate-200 dark:border-slate-700',
)}
>
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
@ -366,7 +368,7 @@ export default function TemplatePreviewPanel({
</label>
<div className="border border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50/50 dark:hover:bg-slate-700/30">
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-200 dark:border-slate-700">
<div className="w-16 h-16 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-200 dark:border-slate-700">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
@ -512,8 +514,8 @@ function PreviewSortableCard({
const containerPointerEvents = isDragging ? 'none' : 'auto'
const cardStateClass = isEditing
? 'shadow-sm border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-lg'
: 'hover:shadow-sm border border-slate-200/40 dark:border-slate-700/40 bg-white dark:bg-slate-800 rounded-lg'
? 'border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 rounded-lg'
: 'border border-slate-200/60 dark:border-slate-700/60 bg-white dark:bg-slate-800 rounded-lg hover:border-slate-300 dark:hover:border-slate-600'
return (
<div
@ -529,12 +531,12 @@ function PreviewSortableCard({
onMouseLeave={onHoverEnd}
onClick={onClick}
>
<div className={`relative transition-all duration-200 ease-out ${cardStateClass}`}>
<div className={`relative transition-colors duration-150 ${cardStateClass}`}>
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
type="button"
className="w-8 h-8 rounded-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
className="w-8 h-8 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
title="拖拽调整顺序"
aria-label="拖拽调整顺序"
{...attributes}
@ -549,7 +551,7 @@ function PreviewSortableCard({
e.stopPropagation()
onRemove()
}}
className="w-7 h-7 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
className="w-8 h-8 rounded-lg border border-red-200 bg-white text-red-600 flex items-center justify-center hover:bg-red-50 dark:border-red-900/50 dark:bg-slate-900 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
title="删除此字段"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -623,7 +625,7 @@ function PreviewFieldContent({ field }: { field: FieldForm }) {
)}
{field.type === 'checkbox' && (
<label className="flex items-center gap-2 cursor-default select-none">
<input type="checkbox" className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
<input type="checkbox" className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-slate-700 focus:ring-2 focus:ring-slate-300 dark:focus:ring-slate-600" disabled />
<span className="text-xs text-slate-700 dark:text-slate-300">选择{field.label || field.key}</span>
</label>
)}
@ -631,7 +633,7 @@ function PreviewFieldContent({ field }: { field: FieldForm }) {
<div className="space-y-1.5">
{optionList.map((opt, idx) => (
<label key={`${field.key}-radio-${idx}`} className="flex items-center gap-2 cursor-default select-none">
<input type="radio" name={`preview-${field.key}`} value={opt} className="w-3.5 h-3.5 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
<input type="radio" name={`preview-${field.key}`} value={opt} className="w-3.5 h-3.5 text-slate-700 focus:ring-2 focus:ring-slate-300 dark:focus:ring-slate-600" disabled />
<span className="text-xs text-slate-700 dark:text-slate-300">{opt}</span>
</label>
))}

View File

@ -113,6 +113,7 @@ export interface SessionUser {
image?: string
role?: 'ADMIN' | 'USER'
isSuperAdmin?: boolean
mustChangePassword?: boolean
}
// API 响应类型

View File

@ -1,6 +1,6 @@
'use client'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { cn } from '@/lib/ui-styles'
interface LoadingStateProps {
message?: string
@ -9,28 +9,27 @@ interface LoadingStateProps {
}
export default function LoadingState({ message = '加载中...', className, size = 'md' }: LoadingStateProps) {
const styles = useThemeStyles()
const theme = useTheme()
const sizeClasses = {
sm: 'h-8 w-8 border-b-2',
md: 'h-12 w-12 border-b-2',
lg: 'h-16 w-16 border-b-2',
}
// 根据主题选择边框和文字颜色
const borderColor = theme === 'dark' ? 'border-indigo-400' : 'border-indigo-600'
const textColor = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
return (
<div className={cn(styles.cardStyles.base, 'flex items-center justify-center py-12', className)}>
<div
className={cn(
'flex items-center justify-center rounded-lg border border-slate-200 bg-white py-12 transition-colors dark:border-slate-700 dark:bg-slate-800',
className,
)}
>
<div className="text-center">
<div
className={cn(
`animate-spin rounded-full ${borderColor} mx-auto`,
'animate-spin rounded-full border-slate-400 dark:border-slate-500 mx-auto',
sizeClasses[size],
)}
/>
{message && <p className={cn('mt-4', textColor)}>{message}</p>}
{message && <p className="mt-4 text-slate-600 dark:text-slate-400">{message}</p>}
</div>
</div>
)

View File

@ -38,25 +38,15 @@ export default function usePermissions({
[currentUserRecord, sessionUser],
)
const currentRole = (sessionUser?.role ?? currentUserRecord?.role) as
| 'ADMIN'
| 'USER'
| undefined
const currentIsAdminRole = useMemo(
() => currentIsSuperAdmin || currentRole === 'ADMIN' || currentUserRecord?.role === 'ADMIN',
[currentIsSuperAdmin, currentRole, currentUserRecord?.role],
)
const currentManagedDepartment = useMemo(
() => currentUserRecord?.managedDepartment ?? sessionUser?.managedDepartment,
[currentUserRecord?.managedDepartment, sessionUser?.managedDepartment],
)
const currentDepartmentScope = useMemo(() => {
const fallback = (currentUserRecord?.department || '').trim() || undefined
return currentManagedDepartment || fallback
}, [currentManagedDepartment, currentUserRecord?.department])
const trimmed = (currentManagedDepartment || '').trim()
return trimmed || undefined
}, [currentManagedDepartment])
const departmentDescendantsMap = useMemo(
() => buildDepartmentDescendants(departments),
@ -69,11 +59,6 @@ export default function usePermissions({
if (currentIsSuperAdmin) return true
if (target.isSuperAdmin) return false
const targetDept = (target.department || '').trim()
if (!targetDept && currentIsAdminRole) {
return true
}
if (currentDepartmentScope) {
return isDepartmentWithinScope(
currentDepartmentScope,
@ -86,7 +71,6 @@ export default function usePermissions({
},
[
currentDepartmentScope,
currentIsAdminRole,
currentIsSuperAdmin,
currentUserId,
departmentDescendantsMap,

View File

@ -19,7 +19,7 @@ export default function MonitorPage() {
return
}
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN') {
if (status === 'authenticated' && !(session.user as any)?.isSuperAdmin) {
router.push('/')
}
}, [status, session, router])

View File

@ -37,10 +37,10 @@ function getSessionUser(session: { user?: unknown } | null): SessionUser | null
export default function AdminPage() {
const styles = useThemeStyles()
const theme = useTheme()
const tabIndicatorClass = theme === 'dark' ? 'bg-indigo-300' : 'bg-indigo-600'
const tabIndicatorClass = theme === 'dark' ? 'bg-slate-100' : 'bg-slate-900'
const tabIndicatorLineClass = 'absolute bottom-0 left-3 right-3 h-[2px] rounded-full'
const tabFocusRingClass =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
const { data: session, status } = useSession()
const sessionUser = getSessionUser(session)
const router = useRouter()
@ -75,11 +75,16 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}
const isSuperAdmin = sessionUser?.isSuperAdmin
if (status === 'authenticated' && sessionUser?.mustChangePassword) {
router.push('/')
return
}
if (status === 'authenticated' && sessionUser?.role !== 'ADMIN' && !isSuperAdmin) {
router.push('/')
return
}
}, [status, session, router])
}, [status, router, sessionUser?.isSuperAdmin, sessionUser?.mustChangePassword, sessionUser?.role])
const fetchData = useCallback(async () => {
const blockUI = !hasLoadedOnceRef.current
@ -218,7 +223,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'users' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">用户管理</span>
@ -241,7 +247,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'departments' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">架构管理</span>
@ -264,7 +271,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'templates' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">模板管理</span>
@ -287,7 +295,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'categories' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">分类管理</span>
@ -295,8 +304,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
{/* 邀请管理 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
{/* 邀请管理 - 只有最高权限账号才能看到 */}
{sessionUser?.isSuperAdmin && (
<button
type="button"
role="tab"
@ -312,7 +321,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'invitations' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">邀请管理</span>
@ -336,7 +346,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'materials' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">素材管理</span>
@ -349,7 +360,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
{/* 模板管理 */}
{activeTab === 'templates' && (
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
<TemplateManager showToast={showToast} />
<TemplateManager showToast={showToast} currentIsSuperAdmin={currentIsSuperAdmin} />
</TabErrorBoundary>
)}
@ -363,7 +374,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
{/* 分类管理 */}
{activeTab === 'categories' && (
<TabErrorBoundary tabKey="categories" onRetry={fetchData}>
<CategoriesTab showToast={showToast} onDataChange={fetchData} />
<CategoriesTab showToast={showToast} onDataChange={fetchData} isSuperAdmin={currentIsSuperAdmin} />
</TabErrorBoundary>
)}

View File

@ -13,9 +13,10 @@ export default function SystemSettingsPage() {
useEffect(() => {
const isAdmin = (session?.user as any)?.role === 'ADMIN'
const isSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
if (status === 'unauthenticated') {
router.replace('/login')
} else if (status === 'authenticated' && !isAdmin) {
} else if (status === 'authenticated' && (!isAdmin || !isSuperAdmin)) {
router.replace('/')
}
}, [status, session, router])
@ -28,7 +29,7 @@ export default function SystemSettingsPage() {
)
}
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || (session.user as any)?.role !== 'ADMIN' || !(session.user as any)?.isSuperAdmin) {
return null
}

View File

@ -27,16 +27,16 @@ jest.mock('@/lib/s3', () => ({
isS3Url: jest.fn(() => false),
}))
jest.mock('@/lib/material-download-log', () => ({
ensureMaterialDownloadLogsTable: jest.fn(),
}))
jest.mock('@/lib/system-integrity', () => ({
checkSystemIntegrity: jest.fn(),
repairSystemIntegrity: jest.fn(),
ensureBuiltinTemplates: jest.fn(),
}))
jest.mock('@/lib/license', () => ({
canUseSystemSettings: jest.fn(),
}))
jest.mock('fs/promises', () => ({
access: jest.fn(),
unlink: jest.fn(),
@ -44,8 +44,8 @@ jest.mock('fs/promises', () => ({
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { ensureMaterialDownloadLogsTable } = require('@/lib/material-download-log')
const { repairSystemIntegrity } = require('@/lib/system-integrity')
const { canUseSystemSettings } = require('@/lib/license')
import { POST as cleanupDatabase } from '../cleanup-database/route'
import { POST as migrateMaterialFields } from '../migrate-material-fields/route'
@ -56,6 +56,7 @@ import { POST as repairSystemIntegrityRoute } from '../../system/integrity/route
describe('admin destructive actions require super admin', () => {
beforeEach(() => {
jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
})
it('普通管理员不能清理数据库', async () => {
@ -73,7 +74,7 @@ describe('admin destructive actions require super admin', () => {
expect(data.error).toContain('最高权限')
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'admin-1' },
select: { isSuperAdmin: true },
select: { isSuperAdmin: true, isActive: true },
})
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
@ -98,7 +99,10 @@ describe('admin destructive actions require super admin', () => {
])
const response = await cleanupDatabase(
new NextRequest('http://localhost/api/admin/cleanup-database', { method: 'POST' })
new NextRequest('http://localhost/api/admin/cleanup-database', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '清理全部素材' }),
})
)
const data = await response.json()
@ -107,6 +111,48 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$executeRawUnsafe).toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能清理数据库', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await cleanupDatabase(
new NextRequest('http://localhost/api/admin/cleanup-database', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '清理全部素材' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('最高权限账号清理数据库时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
const response = await cleanupDatabase(
new NextRequest('http://localhost/api/admin/cleanup-database', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '删除' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('确认短语')
expect(data.requiredConfirmPhrase).toBe('清理全部素材')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('普通管理员不能重置排行榜数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
@ -120,7 +166,6 @@ describe('admin destructive actions require super admin', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
@ -137,7 +182,6 @@ describe('admin destructive actions require super admin', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
@ -156,16 +200,58 @@ describe('admin destructive actions require super admin', () => {
prisma.$transaction.mockImplementation((callback: any) => callback(tx))
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' })
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(ensureMaterialDownloadLogsTable).toHaveBeenCalled()
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3)
})
it('最高权限账号重置排行榜数据时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('确认短语')
expect(data.requiredConfirmPhrase).toBe('重置排行榜数据')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能重置排行榜数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('普通管理员不能批量移交素材归属', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
@ -246,7 +332,10 @@ describe('admin destructive actions require super admin', () => {
prisma.$queryRawUnsafe.mockResolvedValue(1)
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '执行字段迁移' }),
})
)
const data = await response.json()
@ -256,6 +345,45 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$queryRawUnsafe).toHaveBeenCalled()
})
it('最高权限账号执行素材字段迁移时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '迁移' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('确认短语')
expect(data.requiredConfirmPhrase).toBe('执行字段迁移')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能执行素材字段迁移', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('普通管理员不能执行系统完整性修复', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },

View File

@ -23,12 +23,14 @@ jest.mock('@/lib/prisma', () => ({
category: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
scene: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
@ -55,8 +57,8 @@ const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
const { normalizeFieldOptionValue } = require('@/lib/field-pool')
import { PATCH as updateCategory, DELETE as deleteCategory } from '../../categories/route'
import { PATCH as updateScene, DELETE as deleteScene } from '../../scenes/route'
import { POST as createCategory, PATCH as updateCategory, DELETE as deleteCategory } from '../../categories/route'
import { POST as createScene, PATCH as updateScene, DELETE as deleteScene } from '../../scenes/route'
import { PUT as updatePlatform, DELETE as deletePlatform } from '../../platforms/route'
import { PATCH as toggleCategoryField, PUT as saveCategoryFields } from '../../category-fields/route'
import { PUT as updateTemplateCategoryOptions } from '../../template-categories/[fieldKey]/route'
@ -93,6 +95,34 @@ describe('全局字典和分类字段写操作权限', () => {
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
it('普通管理员不能新增店铺分类', async () => {
const response = await createCategory(
jsonRequest('http://localhost/api/categories', { name: '新店铺' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可新增店铺分类')
expect(prisma.category.create).not.toHaveBeenCalled()
})
it('最高权限账号可以新增店铺分类', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.category.findUnique.mockResolvedValue(null)
prisma.category.create.mockResolvedValue({ id: 'cat-1', name: '新店铺' })
const response = await createCategory(
jsonRequest('http://localhost/api/categories', { name: '新店铺' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.name).toBe('新店铺')
expect(prisma.category.create).toHaveBeenCalledWith({
data: { name: '新店铺' },
})
})
it('最高权限账号可以修改店铺分类并同步素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.category.findFirst.mockResolvedValue(null)
@ -128,6 +158,34 @@ describe('全局字典和分类字段写操作权限', () => {
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
it('普通管理员不能新增场景分类', async () => {
const response = await createScene(
jsonRequest('http://localhost/api/scenes', { name: '新场景' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可新增场景分类')
expect(prisma.scene.create).not.toHaveBeenCalled()
})
it('最高权限账号可以新增场景分类', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.scene.findUnique.mockResolvedValue(null)
prisma.scene.create.mockResolvedValue({ id: 'scene-1', name: '新场景' })
const response = await createScene(
jsonRequest('http://localhost/api/scenes', { name: '新场景' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.name).toBe('新场景')
expect(prisma.scene.create).toHaveBeenCalledWith({
data: { name: '新场景' },
})
})
it('最高权限账号可以修改场景分类并同步素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.scene.findFirst.mockResolvedValue(null)
@ -261,9 +319,9 @@ describe('全局字典和分类字段写操作权限', () => {
where: {
key: 'brandName',
type: 'select',
addToCategory: true,
},
data: {
addToCategory: true,
optionsStatic: JSON.stringify(['A', 'B']),
},
})

View File

@ -27,8 +27,13 @@ jest.mock('@/lib/prisma', () => ({
},
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
import { GET } from '../system-status/route'
@ -49,6 +54,7 @@ describe('admin system status auth', () => {
expect(response.status).toBe(401)
expect(data.error).toContain('未登录')
expect(isCurrentSuperAdmin).not.toHaveBeenCalled()
expect(prisma.user.count).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
@ -57,20 +63,37 @@ describe('admin system status auth', () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('管理员')
expect(data.error).toContain('最高权限')
expect(prisma.user.count).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('管理员可以读取系统监控数据', async () => {
it('普通管理员不能读取系统监控数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(prisma.user.count).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('最高权限账号可以读取系统监控数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.user.aggregate.mockResolvedValue({ _count: { id: 1 } })
prisma.material.aggregate.mockResolvedValue({ _count: { id: 0 } })
@ -98,4 +121,21 @@ describe('admin system status auth', () => {
expect(data.overview.totalUsers).toBe(1)
expect(data.status.database).toBe('healthy')
})
it('最高权限账号读取时数据库查询失败会标记为 unhealthy', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.user.aggregate.mockRejectedValueOnce(new Error('database connection failed'))
prisma.$queryRaw.mockRejectedValueOnce(new Error('trend query failed'))
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.overview.totalUsers).toBe(0)
expect(data.status.database).toBe('unhealthy')
})
})

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
import {
collectMaterialStorageUrls,
deleteMaterialStorageFiles,
@ -21,6 +22,24 @@ export async function POST(req: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== '清理全部素材') {
return NextResponse.json(
{
error: '数据库清理是高风险操作,请输入确认短语后再执行',
requiredConfirmPhrase: '清理全部素材',
},
{ status: 400 }
)
}
const results: Record<string, number> = {}
// 1. 清理过期的邀请码(已使用且过期超过30天)

View File

@ -0,0 +1,74 @@
import { NextRequest } from 'next/server'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
},
}))
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
import { GET } from '../route'
describe('GET /api/admin/material-logs', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('非最高权限账号不能查看素材日志', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await GET(new NextRequest('http://localhost/api/admin/material-logs'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(prisma.$queryRaw).not.toHaveBeenCalled()
})
it('最高权限账号可以查看素材日志且不执行运行期 DDL', async () => {
getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } })
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.$queryRaw.mockResolvedValue([
{
id: 'log-1',
action: 'UPLOAD',
materialId: 'material-1',
userId: 'user-1',
timestamp: new Date('2026-05-28T10:00:00.000Z'),
changes: null,
materialSnapshot: { productModel: '旧素材' },
username: '张三',
email: 'user@example.com',
productModel: '当前素材',
},
])
const response = await GET(new NextRequest('http://localhost/api/admin/material-logs'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.logs).toHaveLength(1)
expect(data.logs[0]).toMatchObject({
action: '上传素材',
materialName: '当前素材',
username: '张三',
})
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1)
})
})

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { ensureMaterialLogsTable } from '@/lib/material-log'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
export const dynamic = 'force-dynamic'
@ -14,12 +14,10 @@ export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json({ error: '仅最高权限账号可查看素材日志' }, { status: 403 })
}
await ensureMaterialLogsTable()
const { searchParams } = new URL(req.url)
const limit = parseInt(searchParams.get('limit') || '100')
const offset = parseInt(searchParams.get('offset') || '0')

View File

@ -10,6 +10,12 @@ jest.mock('next-auth', () => ({
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
count: jest.fn(),
findMany: jest.fn(),
@ -29,6 +35,18 @@ function createRequest(path = 'http://localhost/api/admin/materials/export') {
describe('admin materials export', () => {
beforeEach(() => {
jest.clearAllMocks()
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
role: 'ADMIN',
isSuperAdmin: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
})
it('未登录不能导出素材数据', async () => {
@ -46,6 +64,13 @@ describe('admin materials export', () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
const response = await GET(createRequest())
const data = await response.json()
@ -72,11 +97,20 @@ describe('admin materials export', () => {
sceneName: '场景类别',
platformName: '平台',
materialType: '3DMAX',
materialKind: 'IMAGE',
renderer: 'CR',
renderTime: '10min',
imageResolution: '2000x2000',
colorMode: 'RGB',
authorName: '张三',
metadata: JSON.stringify({
templateId: 'template-from-metadata',
templateFields: {
productModel: 'SPU-001',
customCode: 'C-001',
},
}),
templateId: 'template-1',
zipFileName: 'asset.zip',
zipFileSize: BigInt(123456),
downloadCount: 2,
@ -103,5 +137,46 @@ describe('admin materials export', () => {
expect(data.materials[0].zipFileSize).toBe('123456')
expect(data.materials[0].createdAt).toBe('2026-05-25T01:02:03.000Z')
expect(data.materials[0].updatedAt).toBe('2026-05-25T02:03:04.000Z')
expect(data.materials[0].templateId).toBe('template-1')
expect(data.materials[0].templateFields).toEqual({
productModel: 'SPU-001',
customCode: 'C-001',
})
})
it('普通管理员只能导出自己和管理部门范围内的素材数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'manager-1', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.exportedCount).toBe(0)
expect(prisma.material.count).toHaveBeenCalledWith({
where: {
AND: [
{ deletedAt: null },
{
OR: [
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: ['设计部', '设计部-建模组'] } } },
{ user: { department: { startsWith: '设计部-' } } },
],
},
],
},
})
})
})

View File

@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildMaterialManagementScopeWhere, getMaterialPermissionActor } from '@/lib/material-permissions'
export const dynamic = 'force-dynamic'
@ -24,13 +26,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
const actor = await getMaterialPermissionActor(session)
if (!actor || (actor.role !== 'ADMIN' && !actor.isSuperAdmin)) {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const limit = parseExportLimit(searchParams.get('limit'))
const where = { deletedAt: null }
const scopeWhere = await buildMaterialManagementScopeWhere(actor)
const filters: Prisma.MaterialWhereInput[] = [{ deletedAt: null }]
if (Object.keys(scopeWhere).length > 0) {
filters.push(scopeWhere)
}
const where: Prisma.MaterialWhereInput = filters.length === 1 ? filters[0] : { AND: filters }
const [totalCount, rawMaterials] = await Promise.all([
prisma.material.count({ where }),
@ -47,11 +55,14 @@ export async function GET(req: NextRequest) {
sceneName: true,
platformName: true,
materialType: true,
materialKind: true,
renderer: true,
renderTime: true,
imageResolution: true,
colorMode: true,
authorName: true,
metadata: true,
templateId: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
@ -70,12 +81,34 @@ export async function GET(req: NextRequest) {
}),
])
const materials = rawMaterials.map((material) => ({
...material,
zipFileSize: material.zipFileSize.toString(),
createdAt: material.createdAt.toISOString(),
updatedAt: material.updatedAt.toISOString(),
}))
const materials = rawMaterials.map((material) => {
let parsedMetadata: Record<string, unknown> | null = null
if (material.metadata) {
try {
const parsed = JSON.parse(material.metadata)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedMetadata = parsed as Record<string, unknown>
}
} catch {
parsedMetadata = null
}
}
const templateFields =
parsedMetadata?.templateFields && typeof parsedMetadata.templateFields === 'object' && !Array.isArray(parsedMetadata.templateFields)
? parsedMetadata.templateFields
: {}
return {
...material,
metadata: material.metadata,
templateId: material.templateId ?? (typeof parsedMetadata?.templateId === 'string' ? parsedMetadata.templateId : null),
templateFields,
zipFileSize: material.zipFileSize.toString(),
createdAt: material.createdAt.toISOString(),
updatedAt: material.updatedAt.toISOString(),
}
})
return NextResponse.json({
materials,

View File

@ -3,9 +3,12 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic'
const REQUIRED_CONFIRM_PHRASE = '执行字段迁移'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -17,6 +20,24 @@ export async function POST(req: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
return NextResponse.json(
{
error: '素材字段迁移是高风险操作,请输入确认短语后再执行',
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
},
{ status: 400 }
)
}
// 执行 SQL 迁移 - 需要分开执行每个命令
const commands = [
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,
@ -56,7 +77,7 @@ export async function POST(req: NextRequest) {
SELECT 1 FROM pg_constraint
WHERE conname = 'materials_templateId_fkey'
) THEN
ALTER TABLE "materials" ADD CONSTRAINT "materials_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "upload_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "materials" ADD CONSTRAINT "materials_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "UploadTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
`

View File

@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic'
const REQUIRED_CONFIRM_PHRASE = '重置排行榜数据'
/**
* 重置素材排行榜相关数据:
* - 清空点赞记录表 material_likes
@ -26,8 +28,23 @@ export async function POST(req: NextRequest) {
)
}
// 确保下载日志表存在(避免 TRUNCATE 报错)
await ensureMaterialDownloadLogsTable()
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
return NextResponse.json(
{
error: '重置排行榜是高风险操作,请输入确认短语后再执行',
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
},
{ status: 400 }
)
}
// 使用事务,确保一致性
const result = await prisma.$transaction(async (tx) => {

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
export const dynamic = 'force-dynamic'
@ -26,9 +27,9 @@ export async function GET(req: NextRequest) {
)
}
if ((session.user as any)?.role !== 'ADMIN') {
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ error: '需要最高权限账号' },
{ status: 403 }
)
}
@ -46,6 +47,7 @@ export async function GET(req: NextRequest) {
let activeUsers: number
let departmentStats: Array<{ department: string | null; _count: { id: number } }>
let roleDistribution: Array<{ role: string; isSuperAdmin: boolean | null; _count: { id: number } }>
let databaseHealthy = true
try {
[
@ -121,6 +123,7 @@ export async function GET(req: NextRequest) {
])
} catch (dbError) {
console.error('[SystemStatus] Database query error:', dbError)
databaseHealthy = false
// 如果数据库查询失败,返回默认值
userStats = { _count: { id: 0 } }
materialStats = { _count: { id: 0 } }
@ -225,6 +228,7 @@ export async function GET(req: NextRequest) {
materialTrendDaily = []
materialTrendMonthly = []
userGrowthTrend = []
databaseHealthy = false
}
const statusData = {
@ -291,7 +295,7 @@ export async function GET(req: NextRequest) {
// 系统状态
status: {
database: 'healthy', // 可以扩展为实际数据库连接检查
database: databaseHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
}

View File

@ -0,0 +1,101 @@
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
}))
import { NextRequest } from 'next/server'
import { POST } from '../route'
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
function createTransferRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/admin/transfer-superadmin', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('POST /api/admin/transfer-superadmin', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
bcrypt.compare.mockResolvedValue(true)
prisma.$transaction.mockResolvedValue([])
})
it('禁用的最高权限账号不能转移最高权限', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
id: 'super-1',
isSuperAdmin: true,
isActive: false,
password: 'hashed-password',
})
const response = await POST(createTransferRequest({
targetUserId: 'user-2',
password: 'right-password',
}))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可转移最高权限')
expect(bcrypt.compare).not.toHaveBeenCalled()
expect(prisma.user.update).not.toHaveBeenCalled()
})
it('转移最高权限时会把目标账号修正为可登录管理员', async () => {
prisma.user.findUnique
.mockResolvedValueOnce({
id: 'super-1',
isSuperAdmin: true,
isActive: true,
password: 'hashed-password',
})
.mockResolvedValueOnce({
id: 'user-2',
email: 'user2@example.com',
role: 'USER',
isActive: false,
})
prisma.user.update.mockResolvedValue({})
const response = await POST(createTransferRequest({
targetUserId: 'user-2',
password: 'right-password',
}))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('最高权限已转移')
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: {
isSuperAdmin: true,
role: 'ADMIN',
isActive: true,
},
})
})
})

View File

@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
const requesterId = (session.user as any)?.id
const requester = await prisma.user.findUnique({ where: { id: requesterId } }) as any
if (!requester || !requester.isSuperAdmin) {
if (!requester || !requester.isSuperAdmin || requester.isActive === false) {
return NextResponse.json({ error: '仅最高权限账号可转移最高权限' }, { status: 403 })
}
@ -52,7 +52,14 @@ export async function POST(req: NextRequest) {
// 事务:转移最高权限
await prisma.$transaction([
prisma.user.update({ where: { id: requesterId }, data: { isSuperAdmin: false } as any }),
prisma.user.update({ where: { id: targetUser.id }, data: { isSuperAdmin: true } as any }),
prisma.user.update({
where: { id: targetUser.id },
data: {
isSuperAdmin: true,
role: 'ADMIN',
isActive: true,
} as any,
}),
])
return NextResponse.json({ message: '最高权限已转移' })

View File

@ -25,6 +25,7 @@ jest.mock('@/lib/prisma', () => ({
findUnique: jest.fn(),
},
material: {
count: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
@ -34,6 +35,9 @@ jest.mock('@/lib/prisma', () => ({
jest.mock('@/lib/license', () => ({
canUseCustomTemplates: jest.fn(),
buildTemplateMigrationPreviewWithPaidCore: jest.fn(),
planTemplateMigrationBatchWithPaidCore: jest.fn(),
prepareTemplateMigrationMetadataWithPaidCore: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
@ -43,6 +47,9 @@ jest.mock('@/lib/admin-permissions', () => ({
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { canUseCustomTemplates } = require('@/lib/license')
const { buildTemplateMigrationPreviewWithPaidCore } = require('@/lib/license')
const { planTemplateMigrationBatchWithPaidCore } = require('@/lib/license')
const { prepareTemplateMigrationMetadataWithPaidCore } = require('@/lib/license')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
// 导入 API 处理函数
@ -86,6 +93,391 @@ function createMaterials(count: number, overrides: Record<string, any> = {}) {
}))
}
const STANDARD_MATERIAL_FIELDS = new Set([
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'materialType',
'materialKind',
'renderer',
'renderTime',
'imageResolution',
'colorMode',
'authorName',
'platformName',
])
const MIGRATION_BACKUP_STANDARD_FIELDS = [
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'materialType',
'materialKind',
'renderer',
'renderTime',
'imageResolution',
'colorMode',
'authorName',
'platformName',
'audioDuration',
'audioCategory',
'audioScene',
]
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record<string, string | null> = {
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
platformName: '',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
}
function parseMetadataObject(metadata: string | null | undefined): Record<string, any> {
if (!metadata) return {}
try {
const parsed = JSON.parse(metadata)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
function hasBackupValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}
function hasSubmittedFieldValue(value: unknown, type?: string | null): boolean {
if (type === 'checkbox') {
return value !== undefined && value !== null
}
if (value === undefined || value === null) {
return false
}
if (typeof value === 'string') {
return value.trim().length > 0
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
function mockPrepareMigrationMetadata(params: Record<string, any>) {
const existingMetadata = parseMetadataObject(params.existingMetadata)
const targetFieldKeys = new Set<string>(params.targetFieldKeys || [])
const standardData: Record<string, string> = {}
const targetTemplateFields: Record<string, unknown> = {}
const incomingTemplateFields =
params.templateFields ||
(Array.isArray(params.targetFields)
? Object.fromEntries(
params.targetFields
.filter((field: Record<string, any>) => params.materialFieldValues?.[field.key] !== undefined)
.map((field: Record<string, any>) => [field.key, params.materialFieldValues[field.key]])
)
: {})
for (const [key, value] of Object.entries(incomingTemplateFields)) {
if (STANDARD_MATERIAL_FIELDS.has(key)) {
standardData[key] = value === null || value === undefined ? '' : String(value)
} else {
targetTemplateFields[key] = value
}
}
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
if (!targetFieldKeys.has(key)) {
standardData[key] = emptyValue as any
}
}
const missingRequiredFields = Array.isArray(params.targetFields)
? params.targetFields
.filter((field: Record<string, any>) => field.required)
.filter((field: Record<string, any>) => !hasSubmittedFieldValue(incomingTemplateFields?.[field.key], field.type))
.map((field: Record<string, any>) => field.label || field.key)
: []
if (missingRequiredFields.length > 0) {
return {
valid: false,
error: '目标模板存在无法从旧素材自动补齐的必填字段',
errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED',
details: {
missingRequiredFields,
targetTemplateId: params.targetTemplateId,
},
}
}
const droppedTemplateFields: Record<string, unknown> = {}
const existingTemplateFields =
existingMetadata.templateFields && typeof existingMetadata.templateFields === 'object'
? existingMetadata.templateFields
: {}
for (const [key, value] of Object.entries(existingTemplateFields)) {
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedTemplateFields[key] = value
}
}
const droppedStandardFields: Record<string, unknown> = {}
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
const value = params.materialStandardFields?.[key]
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedStandardFields[key] = value
}
}
const nextMetadata: Record<string, any> = {
...existingMetadata,
templateId: params.targetTemplateId,
}
if (Object.keys(targetTemplateFields).length > 0) {
nextMetadata.templateFields = targetTemplateFields
} else {
delete nextMetadata.templateFields
}
if (Array.isArray(params.previewUrls) && params.previewUrls.length > 0) {
nextMetadata.previewUrls = params.previewUrls
}
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
const backup: Record<string, unknown> = {
sourceTemplateId: params.sourceTemplateId,
sourceTemplateName: params.sourceTemplateName,
targetTemplateId: params.targetTemplateId,
targetTemplateName: params.targetTemplateName,
migratedAt: params.migratedAt,
}
if (droppedTemplateFieldCount > 0) {
backup.droppedTemplateFields = droppedTemplateFields
}
if (droppedStandardFieldCount > 0) {
backup.droppedStandardFields = droppedStandardFields
}
nextMetadata.templateMigrationBackups = [
...(Array.isArray(existingMetadata.templateMigrationBackups) ? existingMetadata.templateMigrationBackups : []),
backup,
]
}
const metadata = JSON.stringify(nextMetadata)
const size = Buffer.byteLength(metadata, 'utf8')
const maxSize = 65535
if (size > maxSize) {
return {
valid: false,
error: '模板迁移后 metadata 数据过大,请先减少自定义字段内容后再迁移。',
errorCode: 'METADATA_TOO_LARGE',
details: { size, maxSize },
}
}
return {
valid: true,
standardData,
metadata,
metadataSize: { ok: true, size, maxSize },
templateFields: incomingTemplateFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
droppedTemplateFields,
droppedStandardFields,
}
}
const STANDARD_FIELD_LABELS: Record<string, string> = {
productModel: '产品型号',
brandName: '品牌',
seriesName: '系列',
skuCode: 'SKU',
materialTexture: '材质纹理',
categoryName: '分类',
sceneName: '场景',
materialType: '素材类型',
materialKind: '素材大类',
renderer: '渲染器',
renderTime: '渲染时间',
imageResolution: '图片分辨率',
colorMode: '色彩模式',
authorName: '作者',
platformName: '平台',
audioDuration: '音频时长',
audioCategory: '音频分类',
audioScene: '音频场景',
}
function mockBuildMigrationPreview(params: Record<string, any>) {
const sourceFieldMap = new Map(
(params.sourceTemplateFields || []).map((field: Record<string, any>) => [field.key, field])
)
const backupImpacts = new Map<string, Record<string, any>>()
const samples: Array<Record<string, any>> = []
let materialsWithDroppedFields = 0
let droppedTemplateFieldCount = 0
let droppedStandardFieldCount = 0
function summarize(key: string, source: 'template' | 'standard') {
const templateField = sourceFieldMap.get(key) as Record<string, any> | undefined
return {
key,
label: templateField?.label || STANDARD_FIELD_LABELS[key] || key,
type: templateField?.type,
source,
}
}
function addImpact(field: Record<string, any>) {
const mapKey = `${field.source}:${field.key}`
const existing = backupImpacts.get(mapKey)
if (existing) {
existing.affectedCount += 1
return
}
backupImpacts.set(mapKey, { ...field, affectedCount: 1 })
}
for (const impact of params.impacts || []) {
const droppedTemplateFieldKeys = impact.droppedTemplateFieldKeys || []
const droppedStandardFieldKeys = impact.droppedStandardFieldKeys || []
if (droppedTemplateFieldKeys.length === 0 && droppedStandardFieldKeys.length === 0) continue
materialsWithDroppedFields += 1
droppedTemplateFieldCount += droppedTemplateFieldKeys.length
droppedStandardFieldCount += droppedStandardFieldKeys.length
const droppedTemplateFields = droppedTemplateFieldKeys.map((key: string) => summarize(key, 'template'))
const droppedStandardFields = droppedStandardFieldKeys.map((key: string) => summarize(key, 'standard'))
droppedTemplateFields.forEach(addImpact)
droppedStandardFields.forEach(addImpact)
if (samples.length < 10) {
samples.push({
materialId: impact.materialId,
productModel: impact.productModel ?? null,
droppedTemplateFields,
droppedStandardFields,
})
}
}
return {
valid: true,
migrationBackup: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationPreview: {
totalMaterialCount: params.totalMaterialCount,
totalMaterials: params.migratableCount,
previewLimited: params.previewOnly && params.totalMaterialCount > params.migratableCount,
previewLimit: params.previewLimit,
targetCurrentFields: (params.targetFields || []).map((field: Record<string, any>) => ({
key: field.key,
label: field.label || field.key,
type: field.type,
required: field.required,
})),
backupFields: {
templateFields: Array.from(backupImpacts.values()).filter((field) => field.source === 'template'),
standardFields: Array.from(backupImpacts.values()).filter((field) => field.source === 'standard'),
},
samples,
},
}
}
function mockPlanMigrationBatch(params: Record<string, any>) {
const safeLimit = 200
const maxBatchLimit = 1000
const request = params.request || {}
const rawLimit = Number.parseInt(String(request.migrationLimit || ''), 10)
const migrationLimit = Math.min(
Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : safeLimit,
maxBatchLimit
)
const totalMaterialCount = Number(params.totalMaterialCount || 0)
const previewOnly = request.previewOnly === true
const forceLargeMigration = request.forceLargeMigration === true
if (totalMaterialCount === 0) {
return {
valid: true,
allowed: false,
statusCode: 200,
response: {
success: true,
migratedCount: 0,
message: '没有需要迁移的素材',
},
}
}
if (!previewOnly && totalMaterialCount > safeLimit && !forceLargeMigration) {
return {
valid: true,
allowed: false,
statusCode: 409,
error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${safeLimit}。请先预览迁移影响,确认备份后再执行。`,
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
details: {
totalMaterialCount,
safeLimit,
migrationLimit,
maxBatchLimit,
sourceTemplateId: params.sourceTemplateId,
targetTemplateId: params.targetTemplateId,
nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。',
},
}
}
const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit)
return {
valid: true,
allowed: true,
sourceTemplateId: params.sourceTemplateId,
targetTemplateId: params.targetTemplateId,
sourceTemplateName: params.sourceTemplateName,
targetTemplateName: params.targetTemplateName,
totalMaterialCount,
previewOnly,
forceLargeMigration,
updateMaterialType: request.updateMaterialType !== false,
migrationLimit,
safeLimit,
maxBatchLimit,
batchLimited,
materialTake: batchLimited ? migrationLimit : null,
migratedAt: '2026-06-03T00:00:00.000Z',
}
}
describe('模板素材迁移 API 测试', () => {
beforeEach(() => {
jest.resetAllMocks()
@ -94,7 +486,17 @@ describe('模板素材迁移 API 测试', () => {
user: { id: 'admin-id', role: 'ADMIN' },
})
canUseCustomTemplates.mockResolvedValue(true)
planTemplateMigrationBatchWithPaidCore.mockImplementation(async (params: Record<string, any>) =>
mockPlanMigrationBatch(params)
)
prepareTemplateMigrationMetadataWithPaidCore.mockImplementation(async (params: Record<string, any>) =>
mockPrepareMigrationMetadata(params)
)
buildTemplateMigrationPreviewWithPaidCore.mockImplementation(async (params: Record<string, any>) =>
mockBuildMigrationPreview(params)
)
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.material.count.mockResolvedValue(undefined)
prisma.material.findMany.mockResolvedValue([])
prisma.material.updateMany.mockResolvedValue({ count: 1 })
prisma.$transaction.mockImplementation(async (callback: any) => {
@ -159,6 +561,67 @@ describe('模板素材迁移 API 测试', () => {
expect(data.targetTemplate.id).toBe('template-target')
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
expect(prisma.material.updateMany).toHaveBeenCalledTimes(5)
expect(prepareTemplateMigrationMetadataWithPaidCore).toHaveBeenCalledTimes(5)
expect(prepareTemplateMigrationMetadataWithPaidCore).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
sourceTemplateId: 'template-source',
targetTemplateId: 'template-target',
targetFieldKeys: [],
})
)
})
test('应该只迁移未删除素材,不处理回收站素材', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
name: '源模板',
_count: { materials: 2 },
})
const targetTemplate = createTemplate({
id: 'template-target',
name: '目标模板',
materialType: '其他',
materialKind: 'OTHER',
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.findMany.mockResolvedValue(createMaterials(1))
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-source/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-target',
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-source' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.migratedCount).toBe(1)
expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
templateId: 'template-source',
deletedAt: null,
},
}))
expect(prisma.material.updateMany).toHaveBeenCalledTimes(1)
expect(prisma.material.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
id: 'mat-1',
templateId: 'template-source',
deletedAt: null,
},
}))
})
test('应该拒绝非管理员访问', async () => {
@ -183,6 +646,7 @@ describe('模板素材迁移 API 测试', () => {
expect(response.status).toBe(403)
expect(data.error).toBe('无权限')
expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled()
})
test('普通管理员不能迁移模板素材', async () => {
@ -209,6 +673,7 @@ describe('模板素材迁移 API 测试', () => {
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled()
})
test('旧会话带最高权限标记但数据库已撤销时不能迁移模板素材', async () => {
@ -236,6 +701,7 @@ describe('模板素材迁移 API 测试', () => {
expect(data.error).toContain('最高权限')
expect(canUseCustomTemplates).not.toHaveBeenCalled()
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled()
})
test('许可证关闭时应拒绝迁移素材', async () => {
@ -260,6 +726,7 @@ describe('模板素材迁移 API 测试', () => {
expect(data.errorCode).toBe('LICENSE_REQUIRED')
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled()
})
test('应该验证目标模板ID必填', async () => {
@ -661,6 +1128,106 @@ describe('模板素材迁移 API 测试', () => {
expect(updateCall.data.platformName).toBe('')
})
test('previewOnly 应返回迁移前字段预览且不写库', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
name: '旧模板',
fields: [
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
{ key: 'oldOnlyField', label: '旧模板独有字段', type: 'text', required: false },
],
_count: { materials: 1 },
})
const targetTemplate = createTemplate({
id: 'template-target',
name: '新模板',
fields: [
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
],
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.findMany.mockResolvedValue(
createMaterials(1, {
metadata: JSON.stringify({
templateId: 'template-source',
templateFields: {
keepCustom: '目标模板接收的值',
oldOnlyField: '旧模板独有的值',
},
}),
})
)
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-source/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-target',
previewOnly: true,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-source' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.previewOnly).toBe(true)
expect(data.migratableCount).toBe(1)
expect(data.migrationBackup).toEqual({
materialsWithDroppedFields: 1,
droppedTemplateFieldCount: 1,
droppedStandardFieldCount: 6,
})
expect(data.migrationPreview.targetCurrentFields).toEqual([
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
])
expect(data.migrationPreview.backupFields.templateFields).toEqual([
{
key: 'oldOnlyField',
label: '旧模板独有字段',
type: 'text',
source: 'template',
affectedCount: 1,
},
])
expect(data.migrationPreview.backupFields.standardFields).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'categoryName', label: '分类', affectedCount: 1 }),
expect.objectContaining({ key: 'sceneName', label: '场景', affectedCount: 1 }),
expect.objectContaining({ key: 'materialType', label: '素材类型', affectedCount: 1 }),
])
)
expect(data.migrationPreview.samples[0]).toEqual(
expect.objectContaining({
materialId: 'mat-1',
productModel: '产品1',
droppedTemplateFields: [
{
key: 'oldOnlyField',
label: '旧模板独有字段',
type: 'text',
source: 'template',
},
],
})
)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('迁移后metadata超限时应拒绝并且不写库', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
@ -765,6 +1332,53 @@ describe('模板素材迁移 API 测试', () => {
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('paid-core无法准备迁移数据时应拒绝并且不写库', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
name: '旧模板',
_count: { materials: 1 },
})
const targetTemplate = createTemplate({
id: 'template-target',
name: '新模板',
fields: [
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
],
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.findMany.mockResolvedValue(createMaterials(1))
prepareTemplateMigrationMetadataWithPaidCore.mockResolvedValueOnce({
valid: false,
error: '授权模块尚未运行',
errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE',
details: { moduleId: 'paid-core', status: 'missing' },
})
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-source/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-target',
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-source' },
})
const data = await response.json()
expect(response.status).toBe(503)
expect(data.errorCode).toBe('PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE')
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('事务内某条素材已不属于源模板时应整体失败', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
@ -809,13 +1423,13 @@ describe('模板素材迁移 API 测试', () => {
expect(prisma.material.updateMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: 'mat-1', templateId: 'template-source' },
where: { id: 'mat-1', templateId: 'template-source', deletedAt: null },
})
)
expect(prisma.material.updateMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: 'mat-2', templateId: 'template-source' },
where: { id: 'mat-2', templateId: 'template-source', deletedAt: null },
})
)
})
@ -844,7 +1458,7 @@ describe('模板素材迁移 API 测试', () => {
expect(data.error).toBe('无权限')
})
test('应该处理大量素材迁移', async () => {
test('默认不应直接执行超过单次安全数量的大批量迁移', async () => {
const materialCount = 300
const sourceTemplate = createTemplate({
id: 'template-1',
@ -861,8 +1475,7 @@ describe('模板素材迁移 API 测试', () => {
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.findMany.mockResolvedValue(createMaterials(materialCount))
prisma.material.count.mockResolvedValue(materialCount)
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-1/migrate',
@ -879,10 +1492,205 @@ describe('模板素材迁移 API 测试', () => {
})
const data = await response.json()
expect(response.status).toBe(409)
expect(data.errorCode).toBe('MIGRATION_CONFIRM_REQUIRED')
expect(data.details.totalMaterialCount).toBe(materialCount)
expect(data.details.safeLimit).toBe(200)
expect(data.details.migrationLimit).toBe(200)
expect(data.details.maxBatchLimit).toBe(1000)
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('提高单次上限但未确认时仍应先要求预览和备份确认', async () => {
const materialCount = 300
const sourceTemplate = createTemplate({
id: 'template-1',
name: '大量素材模板',
_count: { materials: materialCount },
})
const targetTemplate = createTemplate({
id: 'template-2',
name: '目标模板',
materialType: 'PSD',
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.count.mockResolvedValue(materialCount)
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-1/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-2',
migrationLimit: 300,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-1' },
})
const data = await response.json()
expect(response.status).toBe(409)
expect(data.errorCode).toBe('MIGRATION_CONFIRM_REQUIRED')
expect(data.details.safeLimit).toBe(200)
expect(data.details.migrationLimit).toBe(300)
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('大批量迁移必须显式确认并提高单次上限后才执行', async () => {
const materialCount = 300
const sourceTemplate = createTemplate({
id: 'template-1',
name: '大量素材模板',
_count: { materials: materialCount },
})
const targetTemplate = createTemplate({
id: 'template-2',
name: '目标模板',
materialType: 'PSD',
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.count.mockResolvedValue(materialCount)
prisma.material.findMany.mockResolvedValue(createMaterials(materialCount))
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-1/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-2',
migrationLimit: 300,
forceLargeMigration: true,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-1' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.migratedCount).toBe(materialCount)
expect(data.totalMaterialCount).toBe(materialCount)
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
expect(prisma.material.updateMany).toHaveBeenCalledTimes(materialCount)
})
test('超过单批上限时应先迁移一批并返回剩余数量', async () => {
const materialCount = 1200
const batchCount = 1000
const sourceTemplate = createTemplate({
id: 'template-1',
name: '超大素材模板',
_count: { materials: materialCount },
})
const targetTemplate = createTemplate({
id: 'template-2',
name: '目标模板',
materialType: 'PSD',
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.count.mockResolvedValue(materialCount)
prisma.material.findMany.mockResolvedValue(createMaterials(batchCount))
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-1/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-2',
migrationLimit: batchCount,
forceLargeMigration: true,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-1' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.migratedCount).toBe(batchCount)
expect(data.totalMaterialCount).toBe(materialCount)
expect(data.remainingMaterialCount).toBe(200)
expect(data.batchLimited).toBe(true)
expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({
take: batchCount,
}))
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
expect(prisma.material.updateMany).toHaveBeenCalledTimes(batchCount)
})
test('大批量预览只取安全数量且不写库', async () => {
const materialCount = 300
const previewCount = 50
const sourceTemplate = createTemplate({
id: 'template-1',
name: '大量素材模板',
_count: { materials: materialCount },
})
const targetTemplate = createTemplate({
id: 'template-2',
name: '目标模板',
materialType: 'PSD',
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.count.mockResolvedValue(materialCount)
prisma.material.findMany.mockResolvedValue(createMaterials(previewCount))
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-1/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-2',
previewOnly: true,
migrationLimit: previewCount,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-1' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.previewOnly).toBe(true)
expect(data.migratableCount).toBe(previewCount)
expect(data.migrationPreview.totalMaterialCount).toBe(materialCount)
expect(data.migrationPreview.previewLimited).toBe(true)
expect(data.migrationPreview.previewLimit).toBe(previewCount)
expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({
take: previewCount,
}))
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
})
})

View File

@ -8,16 +8,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { canUseCustomTemplates } from '@/lib/license'
import {
buildTemplateMigrationPreviewWithPaidCore,
canUseCustomTemplates,
planTemplateMigrationBatchWithPaidCore,
prepareTemplateMigrationMetadataWithPaidCore,
} from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import {
checkMetadataSize,
compressMetadata,
parseMetadata,
prepareMaterialData,
readAllFieldValues,
} from '@/lib/field-data-storage'
import { hasFieldValue } from '@/lib/template-field-validation'
type TemplateFieldSummary = {
key: string
@ -26,177 +27,6 @@ type TemplateFieldSummary = {
required: boolean
}
type TemplateMigrationBackup = {
sourceTemplateId: string
sourceTemplateName: string
targetTemplateId: string
targetTemplateName: string
migratedAt: string
droppedTemplateFields?: Record<string, unknown>
droppedStandardFields?: Record<string, unknown>
}
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record<string, string | null> = {
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
platformName: '',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
}
const MIGRATION_BACKUP_STANDARD_FIELDS = [
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'materialType',
'materialKind',
'renderer',
'renderTime',
'imageResolution',
'colorMode',
'authorName',
'platformName',
'audioDuration',
'audioCategory',
'audioScene',
] as const
function parseMetadataObject(metadata: string | null | undefined): Record<string, unknown> {
if (!metadata) {
return {}
}
try {
const parsed = JSON.parse(metadata)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// 保持迁移可继续,坏 metadata 不再扩大影响面。
}
return {}
}
function getRecordValue(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function hasBackupValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}
function getExistingMigrationBackups(metadata: Record<string, unknown>): unknown[] {
return Array.isArray(metadata.templateMigrationBackups)
? metadata.templateMigrationBackups
: []
}
function buildMigrationMetadata(params: {
existingMetadata: string | null
targetMetadata: string | null
droppedStandardFields: Record<string, unknown>
targetFieldKeys: Set<string>
sourceTemplateId: string
sourceTemplateName: string
targetTemplateId: string
targetTemplateName: string
migratedAt: string
}): { metadata: string | null; droppedTemplateFieldCount: number; droppedStandardFieldCount: number } {
const existingMetadataObject = parseMetadataObject(params.existingMetadata)
const targetMetadataObject = parseMetadataObject(params.targetMetadata)
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields)
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields)
const droppedTemplateFields: Record<string, unknown> = {}
const droppedStandardFields = Object.fromEntries(
Object.entries(params.droppedStandardFields).filter(([, value]) => hasBackupValue(value))
)
for (const [key, value] of Object.entries(existingTemplateFields)) {
if (!params.targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedTemplateFields[key] = value
}
}
const nextMetadata: Record<string, unknown> = {
...existingMetadataObject,
...targetMetadataObject,
templateId: params.targetTemplateId,
}
if (Object.keys(targetTemplateFields).length > 0) {
nextMetadata.templateFields = targetTemplateFields
} else {
delete nextMetadata.templateFields
}
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
const backup: TemplateMigrationBackup = {
sourceTemplateId: params.sourceTemplateId,
sourceTemplateName: params.sourceTemplateName,
targetTemplateId: params.targetTemplateId,
targetTemplateName: params.targetTemplateName,
migratedAt: params.migratedAt,
}
if (droppedTemplateFieldCount > 0) {
backup.droppedTemplateFields = droppedTemplateFields
}
if (droppedStandardFieldCount > 0) {
backup.droppedStandardFields = droppedStandardFields
}
nextMetadata.templateMigrationBackups = [
...getExistingMigrationBackups(existingMetadataObject),
backup,
]
}
return {
metadata: JSON.stringify(nextMetadata),
droppedTemplateFieldCount,
droppedStandardFieldCount,
}
}
function getDroppedStandardFields(
material: Record<string, unknown>,
targetFieldKeys: Set<string>
): Record<string, unknown> {
const droppedFields: Record<string, unknown> = {}
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
const value = material[key]
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedFields[key] = value
}
}
return droppedFields
}
function clearNonTargetStandardFields(updateData: Record<string, unknown>, targetFieldKeys: Set<string>) {
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
if (!targetFieldKeys.has(key)) {
updateData[key] = emptyValue
}
}
}
function buildMaterialFieldValues(material: Record<string, unknown>) {
const values = readAllFieldValues(material)
@ -210,13 +40,6 @@ function buildMaterialFieldValues(material: Record<string, unknown>) {
return values
}
function normalizeOptionalString(value: unknown): string | null {
if (value === undefined || value === null || value === '') {
return null
}
return String(value)
}
async function ensureAdmin() {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
@ -233,6 +56,9 @@ async function ensureAdmin() {
* Body:
* - targetTemplateId: 目标模板ID
* - updateMaterialType: 是否同时更新素材的 materialType 字段(可选,默认 true)
* - previewOnly: 是否只返回迁移预览、不实际写库(可选,默认 false)
* - forceLargeMigration: 超过安全数量时是否确认执行(可选,默认 false)
* - migrationLimit: 本批处理的最大素材数(可选,默认 200,上限 1000)
*/
export async function POST(
req: NextRequest,
@ -249,14 +75,16 @@ export async function POST(
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
error: '当前许可证未包含自定义模板功能,请联系管理员处理授权',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
const sourceTemplateId = params.id
const body = await req.json()
const { targetTemplateId, updateMaterialType = true } = body
const {
targetTemplateId,
} = body
if (!targetTemplateId) {
return NextResponse.json({
@ -327,27 +155,55 @@ export async function POST(
}, { status: 404 })
}
const materialCount = sourceTemplate._count.materials
const activeMaterialCount = await prisma.material.count({
where: {
templateId: sourceTemplateId,
deletedAt: null,
},
})
const totalMaterialCount = Number.isFinite(activeMaterialCount)
? activeMaterialCount
: sourceTemplate._count.materials
if (materialCount === 0) {
return NextResponse.json({
success: true,
migratedCount: 0,
message: '没有需要迁移的素材',
})
}
console.log('[TemplateMigrate] Starting migration:', {
const batchPlan = await planTemplateMigrationBatchWithPaidCore({
sourceTemplateId,
sourceTemplateName: sourceTemplate.name,
targetTemplateId,
sourceTemplateName: sourceTemplate.name,
targetTemplateName: targetTemplate.name,
materialCount,
updateMaterialType,
totalMaterialCount,
request: {
updateMaterialType: body?.updateMaterialType,
previewOnly: body?.previewOnly,
forceLargeMigration: body?.forceLargeMigration,
migrationLimit: body?.migrationLimit,
},
})
if (!batchPlan.valid) {
return NextResponse.json({
error: batchPlan.error,
errorCode: batchPlan.errorCode,
details: batchPlan.details,
}, { status: batchPlan.statusCode || 503 })
}
if (!batchPlan.allowed) {
if (batchPlan.response) {
return NextResponse.json(batchPlan.response, { status: batchPlan.statusCode })
}
return NextResponse.json({
error: batchPlan.error,
errorCode: batchPlan.errorCode,
details: batchPlan.details,
}, { status: batchPlan.statusCode })
}
const materials = await prisma.material.findMany({
where: { templateId: sourceTemplateId },
where: {
templateId: sourceTemplateId,
deletedAt: null,
},
select: {
id: true,
metadata: true,
@ -373,6 +229,34 @@ export async function POST(
// @ts-ignore
audioScene: true,
},
orderBy: [
{ createdAt: 'asc' },
{ id: 'asc' },
],
take: batchPlan.materialTake ?? undefined,
})
const materialCount = materials.length
if (materialCount === 0) {
return NextResponse.json({
success: true,
migratedCount: 0,
message: '没有需要迁移的素材',
})
}
console.log('[TemplateMigrate] Starting migration:', {
sourceTemplateId,
sourceTemplateName: sourceTemplate.name,
targetTemplateId,
targetTemplateName: targetTemplate.name,
materialCount,
totalMaterialCount,
migrationLimit: batchPlan.migrationLimit,
previewOnly: batchPlan.previewOnly,
forceLargeMigration: batchPlan.forceLargeMigration,
updateMaterialType: batchPlan.updateMaterialType,
})
const incompatibleMaterials: Array<{
@ -389,102 +273,92 @@ export async function POST(
const targetFieldKeys = new Set(
(targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key)
)
const migratedAt = new Date().toISOString()
let materialsWithDroppedFields = 0
let droppedTemplateFieldCount = 0
let droppedStandardFieldCount = 0
const migratedAt = batchPlan.migratedAt
const migrationPreviewImpacts: Array<{
materialId: string
productModel: string | null
droppedTemplateFieldKeys: string[]
droppedStandardFieldKeys: string[]
}> = []
const preparedUpdates = materials.map((material) => {
const preparedUpdates: Array<{
id: string
data: Record<string, unknown>
}> = []
for (const material of materials) {
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce<Record<string, unknown>>(
(acc, field) => {
if (allValues[field.key] !== undefined) {
acc[field.key] = allValues[field.key]
}
return acc
},
{}
)
const missingRequiredFields = (targetTemplate.fields as TemplateFieldSummary[])
.filter((field) => field.required)
.filter((field) => !hasFieldValue(nextTemplateFieldValues[field.key], field.type))
.map((field) => field.label || field.key)
if (missingRequiredFields.length > 0) {
incompatibleMaterials.push({
materialId: material.id,
productModel: material.productModel,
missingRequiredFields,
})
}
const parsedMetadata = parseMetadata(material.metadata)
const { standardData, metadata: rawMetadata } = prepareMaterialData(
nextTemplateFieldValues as Record<string, any>,
targetTemplateId,
parsedMetadata.previewUrls
)
const droppedStandardFields = getDroppedStandardFields(
material as Record<string, unknown>,
targetFieldKeys
)
const migrationMetadata = buildMigrationMetadata({
const migrationMetadata = await prepareTemplateMigrationMetadataWithPaidCore({
existingMetadata: material.metadata,
targetMetadata: rawMetadata,
droppedStandardFields,
targetFieldKeys,
materialFieldValues: allValues,
materialStandardFields: material as Record<string, unknown>,
previewUrls: parsedMetadata.previewUrls,
targetFieldKeys: Array.from(targetFieldKeys),
targetFields: targetTemplate.fields as TemplateFieldSummary[],
sourceTemplateId,
sourceTemplateName: sourceTemplate.name,
targetTemplateId,
targetTemplateName: targetTemplate.name,
migratedAt,
})
const metadata = compressMetadata(migrationMetadata.metadata)
const metadataSize = checkMetadataSize(metadata)
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
materialsWithDroppedFields += 1
droppedTemplateFieldCount += migrationMetadata.droppedTemplateFieldCount
droppedStandardFieldCount += migrationMetadata.droppedStandardFieldCount
if (!migrationMetadata.valid) {
if (migrationMetadata.errorCode === 'TARGET_TEMPLATE_FIELDS_UNRESOLVED') {
const missingRequiredFields = Array.isArray(migrationMetadata.details?.missingRequiredFields)
? migrationMetadata.details.missingRequiredFields.map((field) => String(field))
: []
incompatibleMaterials.push({
materialId: material.id,
productModel: material.productModel,
missingRequiredFields,
})
continue
}
if (migrationMetadata.errorCode === 'METADATA_TOO_LARGE') {
oversizedMetadataMaterials.push({
materialId: material.id,
productModel: material.productModel,
size: Number(migrationMetadata.details?.size || 0),
maxSize: Number(migrationMetadata.details?.maxSize || 0),
})
continue
}
return NextResponse.json({
error: migrationMetadata.error || '付费增强包无法准备模板迁移数据',
errorCode: migrationMetadata.errorCode || 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED',
details: migrationMetadata.details,
}, { status: 503 })
}
if (!metadataSize.ok) {
oversizedMetadataMaterials.push({
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
migrationPreviewImpacts.push({
materialId: material.id,
productModel: material.productModel,
size: metadataSize.size,
maxSize: metadataSize.maxSize,
droppedTemplateFieldKeys: Object.keys(migrationMetadata.droppedTemplateFields),
droppedStandardFieldKeys: Object.keys(migrationMetadata.droppedStandardFields),
})
}
const updateData: any = {
templateId: targetTemplateId,
materialKind: targetTemplate.materialKind,
metadata,
...standardData,
metadata: migrationMetadata.metadata,
...migrationMetadata.standardData,
}
clearNonTargetStandardFields(updateData, targetFieldKeys)
if (updateMaterialType && targetTemplate.materialType) {
if (batchPlan.updateMaterialType && targetTemplate.materialType) {
updateData.materialType = targetTemplate.materialType
}
if ('audioDuration' in nextTemplateFieldValues) {
updateData.audioDuration = normalizeOptionalString(nextTemplateFieldValues.audioDuration)
}
if ('audioCategory' in nextTemplateFieldValues) {
updateData.audioCategory = normalizeOptionalString(nextTemplateFieldValues.audioCategory)
}
if ('audioScene' in nextTemplateFieldValues) {
updateData.audioScene = normalizeOptionalString(nextTemplateFieldValues.audioScene)
}
return {
preparedUpdates.push({
id: material.id,
data: updateData,
}
})
})
}
if (incompatibleMaterials.length > 0) {
return NextResponse.json({
@ -510,18 +384,57 @@ export async function POST(
}, { status: 409 })
}
const migrationPreviewResult = await buildTemplateMigrationPreviewWithPaidCore({
totalMaterialCount,
migratableCount: preparedUpdates.length,
previewOnly: batchPlan.previewOnly,
previewLimit: batchPlan.previewOnly ? batchPlan.migrationLimit : undefined,
targetFields: targetTemplate.fields as TemplateFieldSummary[],
sourceTemplateFields: sourceTemplate.fields as TemplateFieldSummary[],
impacts: migrationPreviewImpacts,
})
if (!migrationPreviewResult.valid) {
return NextResponse.json({
error: migrationPreviewResult.error || '付费增强包无法生成模板迁移预览',
errorCode: migrationPreviewResult.errorCode || 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED',
details: migrationPreviewResult.details,
}, { status: 503 })
}
const { migrationBackup, migrationPreview } = migrationPreviewResult
if (batchPlan.previewOnly) {
return NextResponse.json({
success: true,
previewOnly: true,
migratableCount: preparedUpdates.length,
message: `预览:将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
sourceTemplate: {
id: sourceTemplate.id,
name: sourceTemplate.name,
},
targetTemplate: {
id: targetTemplate.id,
name: targetTemplate.name,
},
migrationBackup,
migrationPreview,
})
}
await prisma.$transaction(async (tx) => {
for (const update of preparedUpdates) {
const result = await tx.material.updateMany({
where: {
id: update.id,
templateId: sourceTemplateId,
deletedAt: null,
},
data: update.data,
})
if (result.count !== 1) {
throw new Error(`素材 ${update.id} 已不属于源模板,迁移已整体回滚`)
throw new Error(`素材 ${update.id} 已不属于源模板或已删除,迁移已整体回滚`)
}
}
})
@ -530,15 +443,19 @@ export async function POST(
sourceTemplateId,
targetTemplateId,
migratedCount: preparedUpdates.length,
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
...migrationBackup,
})
return NextResponse.json({
success: true,
migratedCount: preparedUpdates.length,
message: `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
totalMaterialCount,
remainingMaterialCount: Math.max(totalMaterialCount - preparedUpdates.length, 0),
batchLimited: batchPlan.batchLimited,
message:
totalMaterialCount > preparedUpdates.length
? `已将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}",剩余 ${totalMaterialCount - preparedUpdates.length} 个将继续分批处理`
: `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
sourceTemplate: {
id: sourceTemplate.id,
name: sourceTemplate.name,
@ -547,11 +464,7 @@ export async function POST(
id: targetTemplate.id,
name: targetTemplate.name,
},
migrationBackup: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationBackup,
})
} catch (error: any) {
console.error('[TemplateMigrate] Migration error:', {

View File

@ -3,8 +3,13 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import {
calculateTemplateFieldStatsWithPaidCore,
canUseCustomTemplates,
planUploadTemplateDeleteWithPaidCore,
planUploadTemplateUpdateWithPaidCore,
} from '@/lib/license'
import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions'
const selectTemplate = {
id: true,
@ -43,124 +48,16 @@ const selectTemplate = {
},
} as const
const MAX_SYNC_FIELD_STATS_MATERIALS = 1000
async function ensureAdmin() {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
return null
}
return session
}
type TemplateStructureChange =
| {
type: 'materialTypeChanged'
before: string
after: string
}
| {
type: 'materialKindChanged'
before: string
after: string
}
| {
type: 'fieldDeleted'
fieldId: string
fieldKey: string
fieldLabel: string
}
| {
type: 'fieldKeyChanged'
fieldId: string
before: string
after: string
fieldLabel: string
}
| {
type: 'fieldTypeChanged'
fieldId: string
fieldKey: string
fieldLabel: string
before: string
after: string
}
function detectDangerousTemplateChanges(args: {
existingTemplate: {
materialType: string
materialKind: string
fields: Array<{ id: string; key: string; type: string; label: string }>
}
nextMaterialType?: string
nextMaterialKind?: string
nextFields?: Array<{ id?: string | null; key: string; type: string; label: string }>
}): TemplateStructureChange[] {
const { existingTemplate, nextMaterialType, nextMaterialKind, nextFields } = args
const changes: TemplateStructureChange[] = []
if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) {
changes.push({
type: 'materialTypeChanged',
before: existingTemplate.materialType,
after: nextMaterialType,
})
}
if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) {
changes.push({
type: 'materialKindChanged',
before: existingTemplate.materialKind,
after: nextMaterialKind,
})
}
if (!nextFields) {
return changes
}
const incomingById = new Map(
nextFields
.filter((field) => field.id)
.map((field) => [field.id as string, field])
)
for (const existingField of existingTemplate.fields) {
const incomingField = incomingById.get(existingField.id)
if (!incomingField) {
changes.push({
type: 'fieldDeleted',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
})
continue
}
if (incomingField.key !== existingField.key) {
changes.push({
type: 'fieldKeyChanged',
fieldId: existingField.id,
before: existingField.key,
after: incomingField.key,
fieldLabel: existingField.label,
})
}
if (incomingField.type !== existingField.type) {
changes.push({
type: 'fieldTypeChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
before: existingField.type,
after: incomingField.type,
})
}
}
return changes
}
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await ensureAdmin()
@ -208,8 +105,33 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } }
// 统计字段填写率(如果有素材使用该模板)
let fieldStats = null
if (template._count.materials > 0) {
fieldStats = await calculateFieldStats(id, template.fields)
if (template._count.materials > MAX_SYNC_FIELD_STATS_MATERIALS) {
fieldStats = {
skipped: true,
reason: 'TOO_MANY_MATERIALS',
totalMaterials: template._count.materials,
maxSynchronousMaterials: MAX_SYNC_FIELD_STATS_MATERIALS,
}
} else if (template._count.materials > 0) {
const statsMaterials = await prisma.material.findMany({
where: { templateId: id },
select: {
id: true,
metadata: true,
},
})
const fieldStatsResult = await calculateTemplateFieldStatsWithPaidCore({
fields: template.fields,
materials: statsMaterials,
})
fieldStats = fieldStatsResult.valid
? fieldStatsResult.fieldStats
: {
skipped: true,
reason: 'PAID_CORE_UNAVAILABLE',
error: fieldStatsResult.error,
errorCode: fieldStatsResult.errorCode,
}
}
const result = {
@ -242,73 +164,6 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } }
}
}
/**
* 计算字段填写率统计
*/
async function calculateFieldStats(templateId: string, fields: any[]) {
try {
// 获取所有使用该模板的素材
const materials = await prisma.material.findMany({
where: { templateId },
select: {
id: true,
metadata: true,
},
})
if (materials.length === 0) {
return null
}
const totalMaterials = materials.length
const fieldStats = fields.map(field => {
let filledCount = 0
// 统计该字段在多少素材中被填写
for (const material of materials) {
if (!material.metadata) continue
try {
const metadata = JSON.parse(material.metadata)
const templateFields = metadata.templateFields || {}
const value = templateFields[field.key]
// 判断字段是否被填写(非空、非null、非undefined)
if (value !== null && value !== undefined && value !== '') {
filledCount++
}
} catch (error) {
// 忽略JSON解析错误
}
}
const fillRate = (filledCount / totalMaterials) * 100
return {
fieldKey: field.key,
fieldLabel: field.label,
fieldType: field.type,
filledCount,
totalCount: totalMaterials,
fillRate: Math.round(fillRate * 100) / 100, // 保留2位小数
isLowFillRate: fillRate < 30, // 填写率低于30%标记为低填写率
}
})
// 按填写率排序
fieldStats.sort((a, b) => a.fillRate - b.fillRate)
return {
totalMaterials,
fields: fieldStats,
lowFillRateFields: fieldStats.filter(f => f.isLowFillRate),
}
} catch (error) {
console.error('[TemplateGet] Error calculating field stats:', error)
return null
}
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await ensureAdmin()
@ -319,24 +174,13 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
error: '当前许可证未包含自定义模板功能,请联系管理员处理授权',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
const { id } = params
const body = await req.json()
const {
name,
description,
status,
materialType,
materialKind,
fieldGroups,
displayConfig,
fields,
version,
} = body || {}
const existing = await prisma.uploadTemplate.findUnique({
where: { id },
@ -353,6 +197,13 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
key: true,
type: true,
label: true,
required: true,
addToCategory: true,
isSearchable: true,
isReadOnly: true,
validations: true,
optionsStatic: true,
conditions: true,
},
orderBy: { orderIndex: 'asc' },
},
@ -368,188 +219,37 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 版本冲突检测
if (version !== undefined && version !== existing.version) {
console.warn('[TemplateUpdate] Version conflict detected:', {
const updatePlan = await planUploadTemplateUpdateWithPaidCore({
payload: body || {},
existingTemplate: {
id: existing.id,
version: existing.version,
name: existing.name,
updatedAt: existing.updatedAt,
materialType: existing.materialType,
materialKind: existing.materialKind,
materialCount: existing._count.materials,
fields: existing.fields,
},
})
if (!updatePlan.valid) {
console.warn('[TemplateUpdate] Paid core update planning rejected:', {
templateId: id,
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
updatePlan,
})
return NextResponse.json({
error: '模板已被其他人修改,请刷新后重试',
errorCode: 'VERSION_CONFLICT',
details: {
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
}
}, { status: 409 })
return NextResponse.json({
error: updatePlan.error,
errorCode: updatePlan.errorCode,
field: updatePlan.field,
details: updatePlan.details,
}, { status: updatePlan.statusCode || 400 })
}
console.log('[TemplateUpdate] Version check passed:', {
templateId: id,
templateName: existing.name,
currentVersion: existing.version,
newVersion: (existing.version || 1) + 1,
})
const updateData: any = updatePlan.updateData
const formattedFields = updatePlan.fieldsProvided ? (updatePlan.fields || []) : null
// 验证必填字段
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid name')
return NextResponse.json({
error: '模板名称不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'name',
}, { status: 400 })
}
// 验证materialType
if (materialType !== undefined && (typeof materialType !== 'string' || !materialType.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid materialType')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
if (materialKind !== undefined) {
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateUpdate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
}
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
if (description !== undefined) updateData.description = description
if (status !== undefined) updateData.status = status
if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups
if (displayConfig !== undefined) updateData.displayConfig = displayConfig
if (materialType !== undefined) {
updateData.materialType = materialType.trim()
}
if (materialKind !== undefined) {
const rawKind = materialKind.trim().toUpperCase()
updateData.materialKind = rawKind
}
updateData.version = (existing.version || 1) + 1
let formattedFields = null
if (Array.isArray(fields)) {
// 检查字段key的唯一性
const fieldKeys = new Set<string>()
const duplicateKeys: string[] = []
for (const field of fields) {
const key = String(field.key || '').trim()
if (!key) {
console.warn('[TemplateUpdate] Validation failed: field key is required')
return NextResponse.json({
error: '字段key不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'fields',
}, { status: 400 })
}
if (fieldKeys.has(key)) {
duplicateKeys.push(key)
} else {
fieldKeys.add(key)
}
}
if (duplicateKeys.length > 0) {
console.warn('[TemplateUpdate] Validation failed: duplicate field keys:', duplicateKeys)
return NextResponse.json({
error: `字段key重复:${duplicateKeys.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'fields',
details: { duplicateKeys },
}, { status: 400 })
}
formattedFields = fields.map((field: any, index: number) => {
const key = String(field.key || '').trim()
const label = String(field.label || '').trim() || `字段${index + 1}`
const type = String(field.type || 'text')
// 自动转换optionsStatic数组为JSON字符串
let optionsStatic = field.optionsStatic ?? null
if (optionsStatic !== null && optionsStatic !== undefined) {
if (Array.isArray(optionsStatic)) {
optionsStatic = JSON.stringify(optionsStatic)
console.log(`[TemplateUpdate] Converted optionsStatic array to JSON for field: ${key}`)
} else {
optionsStatic = String(optionsStatic)
}
}
return {
id: field.id ?? null,
key,
label,
type,
required: !!field.required,
orderIndex: field.orderIndex ?? index,
layoutWidth: field.layoutWidth || 'full',
validations: field.validations ?? null,
optionsSource: field.optionsSource ?? null,
optionsStatic,
allowedExtensions: field.allowedExtensions ?? null,
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: !!field.addToCategory,
conditions: field.conditions ?? null,
}
})
}
if (existing._count.materials > 0) {
const dangerousChanges = detectDangerousTemplateChanges({
existingTemplate: {
materialType: existing.materialType,
materialKind: existing.materialKind,
fields: existing.fields,
},
nextMaterialType: updateData.materialType,
nextMaterialKind: updateData.materialKind,
nextFields: formattedFields || undefined,
})
if (dangerousChanges.length > 0) {
console.warn('[TemplateUpdate] Dangerous structure change blocked:', {
templateId: id,
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
})
return NextResponse.json({
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key 或修改字段类型。请新建模板后迁移素材。`,
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
details: {
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
},
}, { status: 409 })
}
}
const result = await prisma.$transaction(async (tx) => {
const template = await prisma.$transaction(async (tx) => {
console.log('[TemplateUpdate] Updating template:', {
templateId: id,
templateName: existing.name,
@ -626,16 +326,14 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
}
}
return updated
})
console.log('[TemplateUpdate] Syncing field pool in template update transaction...')
await syncFieldPoolFromTemplates(tx)
const template = await prisma.uploadTemplate.findUnique({
where: { id: result.id },
select: selectTemplate,
return tx.uploadTemplate.findUnique({
where: { id: updated.id },
select: selectTemplate,
})
})
console.log('[TemplateUpdate] Syncing field pool after template update...')
await syncFieldPoolFromTemplates()
console.log('[TemplateUpdate] Update completed:', {
templateId: template?.id,
@ -693,7 +391,7 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
error: '当前许可证未包含自定义模板功能,请联系管理员处理授权',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
@ -717,21 +415,36 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 检查是否有素材使用该模板
const materialCount = template._count.materials
if (materialCount > 0) {
const deletePlan = await planUploadTemplateDeleteWithPaidCore({
templateName: template.name,
materialCount,
})
if (!deletePlan.valid) {
console.warn('[TemplateDelete] Paid core delete planning failed:', {
templateId: id,
templateName: template.name,
materialCount,
deletePlan,
})
return NextResponse.json({
error: deletePlan.error,
errorCode: deletePlan.errorCode,
details: deletePlan.details,
}, { status: 403 })
}
if (!deletePlan.allowed) {
console.warn('[TemplateDelete] Cannot delete template with materials:', {
templateId: id,
templateName: template.name,
materialCount,
})
return NextResponse.json({
error: `无法删除模板"${template.name}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`,
errorCode: 'TEMPLATE_IN_USE',
details: {
templateName: template.name,
materialCount,
}
return NextResponse.json({
error: deletePlan.error,
errorCode: deletePlan.errorCode,
details: deletePlan.details,
}, { status: 400 })
}
@ -740,10 +453,12 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
templateName: template.name,
})
await prisma.uploadTemplate.delete({ where: { id } })
console.log('[TemplateDelete] Syncing field pool after template deletion...')
await syncFieldPoolFromTemplates()
await prisma.$transaction(async (tx) => {
await tx.uploadTemplate.delete({ where: { id } })
console.log('[TemplateDelete] Syncing field pool in template deletion transaction...')
await syncFieldPoolFromTemplates(tx)
})
console.log('[TemplateDelete] Template deleted successfully:', {
templateId: id,
@ -775,4 +490,3 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseCustomTemplates, normalizeUploadTemplateSaveWithPaidCore } from '@/lib/license'
import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions'
const baseSelect = {
id: true,
@ -47,7 +47,7 @@ const baseSelect = {
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
console.warn('[TemplateList] Unauthorized access attempt')
return NextResponse.json({
error: '无权限',
@ -94,7 +94,7 @@ export async function POST(req: NextRequest) {
let body: any = null
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
if (!(await isCurrentSuperAdmin(session))) {
@ -104,154 +104,48 @@ export async function POST(req: NextRequest) {
// 检查自定义模板功能是否可用
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
error: '当前许可证未包含自定义模板功能,请联系管理员处理授权',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
body = await req.json()
const {
name,
description,
status = 'draft',
materialType,
materialKind,
fieldGroups,
displayConfig,
fields = [],
} = body || {}
// 验证必填字段
if (!name || typeof name !== 'string' || !name.trim()) {
console.warn('[TemplateCreate] Validation failed: name is required')
return NextResponse.json({
error: '模板名称不能为空',
const normalizedResult = await normalizeUploadTemplateSaveWithPaidCore(body || {}, 'create')
if (!normalizedResult.valid) {
console.warn('[TemplateCreate] Paid core validation failed:', normalizedResult)
return NextResponse.json({
error: normalizedResult.error,
errorCode: 'VALIDATION_ERROR',
field: 'name',
field: normalizedResult.field,
details: normalizedResult.details,
}, { status: 400 })
}
// 验证materialType
if (!materialType || typeof materialType !== 'string' || !materialType.trim()) {
console.warn('[TemplateCreate] Validation failed: materialType is required')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateCreate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
const finalMaterialType = materialType.trim()
const finalMaterialKind = rawKind
// 验证字段数组
if (!Array.isArray(fields)) {
console.warn('[TemplateCreate] Validation failed: fields must be an array')
return NextResponse.json({
error: '字段列表必须是数组',
errorCode: 'VALIDATION_ERROR',
field: 'fields',
}, { status: 400 })
}
// 检查字段key的唯一性
const fieldKeys = new Set<string>()
const duplicateKeys: string[] = []
for (const field of fields) {
const key = String(field.key || '').trim()
if (!key) {
console.warn('[TemplateCreate] Validation failed: field key is required')
return NextResponse.json({
error: '字段key不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'fields',
}, { status: 400 })
}
if (fieldKeys.has(key)) {
duplicateKeys.push(key)
} else {
fieldKeys.add(key)
}
}
if (duplicateKeys.length > 0) {
console.warn('[TemplateCreate] Validation failed: duplicate field keys:', duplicateKeys)
return NextResponse.json({
error: `字段key重复:${duplicateKeys.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'fields',
details: { duplicateKeys },
}, { status: 400 })
}
const formattedFields = fields.map((field: any, index: number) => {
const key = String(field.key || '').trim()
const label = String(field.label || '').trim() || `字段${index + 1}`
const type = String(field.type || 'text')
// 自动转换optionsStatic数组为JSON字符串
let optionsStatic = field.optionsStatic ?? null
if (optionsStatic !== null && optionsStatic !== undefined) {
if (Array.isArray(optionsStatic)) {
optionsStatic = JSON.stringify(optionsStatic)
console.log(`[TemplateCreate] Converted optionsStatic array to JSON for field: ${key}`)
} else {
optionsStatic = String(optionsStatic)
}
}
return {
key,
label,
type,
required: !!field.required,
orderIndex: field.orderIndex ?? index,
layoutWidth: field.layoutWidth || 'full',
validations: field.validations ?? null,
optionsSource: field.optionsSource ?? null,
optionsStatic,
allowedExtensions: field.allowedExtensions ?? null,
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: !!field.addToCategory,
conditions: field.conditions ?? null,
}
})
const normalized = normalizedResult.template
const formattedFields = (normalized.fields || []).map(({ id: _id, ...field }) => field)
const createData = {
name,
description: description ?? null,
status,
materialType: finalMaterialType,
materialKind: finalMaterialKind,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
name: normalized.name,
description: normalized.description ?? null,
status: normalized.status,
materialType: normalized.materialType,
materialKind: normalized.materialKind,
fieldGroups: normalized.fieldGroups ?? null,
displayConfig: normalized.displayConfig ?? null,
fields: formattedFields.length
? { createMany: { data: formattedFields } }
: undefined,
} as Prisma.UploadTemplateCreateInput
const template = await prisma.uploadTemplate.create({
data: createData,
select: baseSelect,
})
const template = await prisma.$transaction(async (tx) => {
const created = await tx.uploadTemplate.create({
data: createData,
select: baseSelect,
})
await syncFieldPoolFromTemplates()
await syncFieldPoolFromTemplates(tx)
return created
})
return NextResponse.json(template, { status: 201 })
} catch (error: any) {
console.error('[TemplateCreate] Create error:', {

View File

@ -26,8 +26,13 @@ jest.mock('@/lib/prisma', () => ({
},
}))
jest.mock('@/lib/license', () => ({
planDepartmentScopeWithPaidCore: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { planDepartmentScopeWithPaidCore } = require('@/lib/license')
import { GET as listUsers, DELETE as deleteUser } from '../route'
import { POST as updateUser } from '../update/route'
@ -44,6 +49,11 @@ import {
describe('admin user organization permissions', () => {
beforeEach(() => {
jest.resetAllMocks()
planDepartmentScopeWithPaidCore.mockResolvedValue({
valid: true,
scopedDepartments: ['设计部', '设计部-建模组'],
withinScope: true,
})
})
it('最高权限账号可以查看全量用户列表', async () => {
@ -69,6 +79,7 @@ describe('admin user organization permissions', () => {
expect(response.status).toBe(200)
expect(data.pagination.total).toBe(2)
expect(prisma.department.findMany).not.toHaveBeenCalled()
expect(planDepartmentScopeWithPaidCore).not.toHaveBeenCalled()
expect(prisma.user.count).toHaveBeenCalledWith({ where: {} })
})
@ -99,6 +110,14 @@ describe('admin user organization permissions', () => {
expect(response.status).toBe(200)
expect(data.users).toHaveLength(2)
expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({
scopeDepartment: '设计部',
departments: [
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '财务部' },
],
})
expect(prisma.user.count).toHaveBeenCalledWith({
where: {
AND: [
@ -227,6 +246,11 @@ describe('admin user organization permissions', () => {
const data = await response.json()
expect(response.status).toBe(403)
expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({
scopeDepartment: '设计部',
targetDepartment: '设计部',
departments: [{ name: '设计部' }],
})
expect(data.error).toContain('最高权限')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
@ -253,11 +277,16 @@ describe('admin user organization permissions', () => {
const data = await response.json()
expect(response.status).toBe(403)
expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({
scopeDepartment: '设计部',
targetDepartment: '设计部',
departments: [{ name: '设计部' }],
})
expect(data.error).toContain('最高权限')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('部门管理员可以在部门不变时修改成员姓名', async () => {
it('部门管理员可以在部门不变时修改成员姓名但不会批量改历史素材作者名', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique
.mockResolvedValueOnce({ id: 'manager-1', managedDepartment: '设计部' })
@ -293,7 +322,12 @@ describe('admin user organization permissions', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.materialsUpdated).toBe(true)
expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({
scopeDepartment: '设计部',
targetDepartment: '设计部',
departments: [{ name: '设计部' }],
})
expect(data.materialsUpdated).toBe(false)
expect(tx.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: {
@ -301,6 +335,51 @@ describe('admin user organization permissions', () => {
department: '设计部',
},
})
expect(tx.material.updateMany).not.toHaveBeenCalled()
})
it('最高权限账号显式要求时才会同步历史素材作者名', async () => {
getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } })
prisma.user.findUnique
.mockResolvedValueOnce({ id: 'super-1', managedDepartment: null })
.mockResolvedValueOnce({ id: 'super-1', isSuperAdmin: true })
.mockResolvedValueOnce({ id: 'user-2', username: '旧姓名', department: '设计部' })
.mockResolvedValueOnce({ id: 'user-2', isSuperAdmin: false })
const tx = {
user: {
update: jest.fn().mockResolvedValue({
id: 'user-2',
username: '新姓名',
department: '设计部',
}),
},
material: {
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
},
}
prisma.$transaction.mockImplementation((callback: any) => callback(tx))
const response = await updateUser(
new NextRequest('http://localhost/api/admin/users/update', {
method: 'POST',
body: JSON.stringify({
userId: 'user-2',
username: '新姓名',
syncMaterialAuthorName: true,
}),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.materialsUpdated).toBe(true)
expect(tx.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: {
username: '新姓名',
},
})
expect(tx.material.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-2' },
data: { authorName: '新姓名' },
@ -353,6 +432,33 @@ describe('admin user organization permissions', () => {
})
})
it('最高权限账号把用户降为普通用户时会清空管理部门', async () => {
getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN', email: 'super@example.com' } })
prisma.user.findUnique
.mockResolvedValueOnce({ isSuperAdmin: true })
.mockResolvedValueOnce({ id: 'user-2', email: 'user2@example.com', department: '设计部' })
.mockResolvedValueOnce({ isSuperAdmin: false })
prisma.user.update.mockResolvedValue({
id: 'user-2',
email: 'user2@example.com',
username: 'user2',
role: 'USER',
})
const response = await setRole(
new NextRequest('http://localhost/api/admin/users/set-role', {
method: 'POST',
body: JSON.stringify({ userId: 'user-2', role: 'USER' }),
})
)
expect(response.status).toBe(200)
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: { role: 'USER', managedDepartment: null },
})
})
it('普通管理员不能通过旧接口启用或禁用用户', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
@ -631,7 +737,7 @@ describe('admin user organization permissions', () => {
expect(data.message).toContain('已取消')
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: { managedDepartment: null },
data: { managedDepartment: null, role: 'USER' },
})
})
})

View File

@ -4,7 +4,7 @@ import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { buildDepartmentDescendants } from '@/lib/departments'
import { planDepartmentScopeWithPaidCore } from '@/lib/license'
// Get all users (admin only) - 支持分页
export async function GET(request: NextRequest) {
@ -53,20 +53,24 @@ export async function GET(request: NextRequest) {
let scopeWhere: Prisma.UserWhereInput | undefined
if (!currentUser.isSuperAdmin) {
const scopeDepartment = (currentUser.managedDepartment || currentUser.department || '').trim()
const scopeDepartment = (currentUser.managedDepartment || '').trim()
if (scopeDepartment) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendants = buildDepartmentDescendants(departments).get(scopeDepartment)
const scopedDepartments = Array.from(descendants || new Set([scopeDepartment]))
const scopePlan = await planDepartmentScopeWithPaidCore({
scopeDepartment,
departments,
})
scopeWhere = {
OR: [
{ id: currentUser.id },
{ department: { in: scopedDepartments } },
{ department: { startsWith: `${scopeDepartment}-` } },
],
}
scopeWhere = scopePlan.valid && scopePlan.scopedDepartments.length > 0
? {
OR: [
{ id: currentUser.id },
{ department: { in: scopePlan.scopedDepartments } },
{ department: { startsWith: `${scopeDepartment}-` } },
],
}
: { id: currentUser.id }
} else {
scopeWhere = { id: currentUser.id }
}

View File

@ -116,7 +116,8 @@ export async function DELETE(req: NextRequest) {
const user = await prisma.user.update({
where: { id: userId },
data: {
managedDepartment: null
managedDepartment: null,
role: 'USER',
}
})

View File

@ -76,7 +76,9 @@ export async function POST(req: NextRequest) {
// 更新用户角色
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { role },
data: role === 'USER'
? { role, managedDepartment: null }
: { role },
})
return NextResponse.json({

View File

@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
import { planDepartmentScopeWithPaidCore } from '@/lib/license'
// 统一更新用户信息,并同步更新该用户上传的素材信息
// 统一更新用户信息;历史素材作者名只允许最高权限账号显式同步。
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -16,7 +16,7 @@ export async function POST(req: NextRequest) {
)
}
const { userId, username, department, role, isActive, canDownload } = await req.json()
const { userId, username, department, role, isActive, canDownload, syncMaterialAuthorName } = await req.json()
if (!userId) {
return NextResponse.json(
@ -43,12 +43,6 @@ export async function POST(req: NextRequest) {
where: { id: (session.user as any).id },
}) as any
let departmentDescendantsMap: Map<string, Set<string>> | undefined
if (currentUser.managedDepartment && !currentUserFull?.isSuperAdmin) {
const departments = await prisma.department.findMany({ select: { name: true } })
departmentDescendantsMap = buildDepartmentDescendants(departments)
}
// 获取更新前的用户信息
const oldUser = await prisma.user.findUnique({
where: { id: userId },
@ -75,12 +69,13 @@ export async function POST(req: NextRequest) {
if (!currentUserFull?.isSuperAdmin) {
// 如果是部门管理员,只能编辑自己管理的部门下的用户
if (currentUser.managedDepartment) {
const allowed = isDepartmentWithinScope(
currentUser.managedDepartment,
oldUser.department,
departmentDescendantsMap
)
if (!allowed) {
const departments = await prisma.department.findMany({ select: { name: true } })
const scopePlan = await planDepartmentScopeWithPaidCore({
scopeDepartment: currentUser.managedDepartment,
targetDepartment: oldUser.department,
departments,
})
if (!scopePlan.valid || !scopePlan.withinScope) {
return NextResponse.json(
{ error: '权限不足:只能编辑自己管理的部门树范围内的用户' },
{ status: 403 }
@ -124,7 +119,13 @@ export async function POST(req: NextRequest) {
if (isActive !== undefined) updateData.isActive = isActive
if (canDownload !== undefined) updateData.canDownload = canDownload
// 使用事务更新用户和素材
const shouldSyncMaterialAuthorName =
Boolean(syncMaterialAuthorName) &&
Boolean(currentUserFull?.isSuperAdmin) &&
username !== undefined &&
username !== oldUser.username
// 使用事务更新用户;历史素材作者名同步仅限最高权限账号显式触发。
const result = await prisma.$transaction(async (tx) => {
// 更新用户信息
const updatedUser = await tx.user.update({
@ -132,8 +133,7 @@ export async function POST(req: NextRequest) {
data: updateData,
})
// 如果用户名发生变化,同步更新该用户上传的所有素材的authorName
if (username !== undefined && username !== oldUser.username) {
if (shouldSyncMaterialAuthorName) {
await tx.material.updateMany({
where: { userId: userId },
data: { authorName: username },
@ -147,13 +147,13 @@ export async function POST(req: NextRequest) {
userId: result.id,
username: result.username,
department: result.department,
materialsUpdated: username !== undefined && username !== oldUser.username
materialsUpdated: shouldSyncMaterialAuthorName
})
return NextResponse.json({
success: true,
user: result,
materialsUpdated: username !== undefined && username !== oldUser.username
materialsUpdated: shouldSyncMaterialAuthorName
})
} catch (error) {
console.error('[Update User] Error:', error)

View File

@ -0,0 +1,96 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/video-transcode-queue', () => ({
transcodeQueue: {
getStatus: jest.fn(() => ({
queueLength: 0,
processingCount: 0,
maxConcurrent: 2,
processingIds: [],
queuedIds: [],
})),
},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
findMany: jest.fn(),
},
videoTranscodeJob: {
count: jest.fn(),
},
},
}))
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
import { GET } from '../route'
function createRequest() {
return new NextRequest('http://localhost/api/admin/video-transcode-stats')
}
describe('admin video transcode stats auth', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('未登录不能读取转码统计', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toContain('未登录')
expect(isCurrentSuperAdmin).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('普通管理员不能读取转码统计', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('最高权限账号可以读取转码统计', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.material.count.mockResolvedValue(0)
prisma.videoTranscodeJob.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.statistics.total).toBe(0)
expect(data.jobs.failed).toBe(0)
})
})

View File

@ -9,7 +9,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { isCurrentAdmin } from '@/lib/admin-permissions'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
export const dynamic = 'force-dynamic'
@ -27,9 +27,9 @@ export async function GET(req: NextRequest) {
)
}
if (!(await isCurrentAdmin(session))) {
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ error: '需要最高权限账号' },
{ status: 403 }
)
}

View File

@ -0,0 +1,171 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/config', () => ({
featureFlags: {
aiEditEnabled: false,
},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
},
material: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
import { POST } from '../route'
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
function buildRequest(body: Record<string, unknown> = {}) {
return new NextRequest('http://localhost:3000/api/ai/edit', {
method: 'POST',
body: JSON.stringify({
materialId: 'material-1',
operation: 'remove-background',
...body,
}),
})
}
function createMaterial(overrides: Record<string, unknown> = {}) {
return {
id: 'material-1',
userId: 'owner-1',
ownerType: 'TEAM',
ownerId: null,
deletedAt: null,
user: {
id: 'owner-1',
department: '设计部',
},
...overrides,
}
}
describe('AI 改图接口权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: {
id: 'owner-1',
role: 'USER',
},
})
prisma.systemSettings.findFirst.mockResolvedValue({
enableAiImageEdit: true,
})
prisma.material.findFirst.mockResolvedValue(createMaterial())
prisma.user.findUnique.mockImplementation(async ({ where }: any) => {
if (where.id === 'owner-1') {
return {
id: 'owner-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
}
}
if (where.id === 'other-user') {
return {
id: 'other-user',
role: 'USER',
isSuperAdmin: false,
department: '运营部',
managedDepartment: null,
}
}
return null
})
prisma.department.findMany.mockResolvedValue([])
})
test('未登录不能创建 AI 改图任务', async () => {
getServerSession.mockResolvedValue(null)
const response = await POST(buildRequest())
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('未登录')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('系统未启用 AI 改图时不能创建任务', async () => {
prisma.systemSettings.findFirst.mockResolvedValue({
enableAiImageEdit: false,
})
const response = await POST(buildRequest())
const data = await response.json()
expect(response.status).toBe(501)
expect(data.error).toBe('AI改图功能未启用')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('素材不存在或已删除时不能创建任务', async () => {
prisma.material.findFirst.mockResolvedValue(null)
const response = await POST(buildRequest())
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'material-1',
deletedAt: null,
},
})
)
})
test('无权管理该素材的普通用户不能创建任务', async () => {
getServerSession.mockResolvedValue({
user: {
id: 'other-user',
role: 'USER',
},
})
const response = await POST(buildRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限操作此素材')
})
test('素材本人也不能创建占位任务', async () => {
const response = await POST(buildRequest({ params: { prompt: '调整亮度' } }))
const data = await response.json()
expect(response.status).toBe(501)
expect(data.error).toContain('尚未接入真实任务引擎')
})
})

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { featureFlags } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
async function ensureAiEditEnabled() {
if (featureFlags.aiEditEnabled) return true
@ -31,17 +32,34 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '缺少必要参数(materialId/operation)' }, { status: 400 })
}
// TODO: 校验用户是否有权限访问该素材(上传者或其上级)
// TODO: 接入外部AI服务(基于你提供的API),并以任务形式异步执行
return NextResponse.json({
message: 'AI改图任务创建成功(占位)',
taskId: 'mock_task_id',
materialId,
operation,
params: params || {},
status: 'queued',
const material = await prisma.material.findFirst({
where: {
id: materialId,
deletedAt: null,
},
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
const actor = await getMaterialPermissionActor(session)
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json({ error: '您没有权限操作此素材' }, { status: 403 })
}
return NextResponse.json(
{ error: 'AI改图服务尚未接入真实任务引擎,不能创建占位任务' },
{ status: 501 }
)
} catch (error) {
console.error('AI Edit Error:', error)
return NextResponse.json({ error: '服务异常' }, { status: 500 })

View File

@ -85,4 +85,80 @@ describe('POST /api/auth/register', () => {
expect(data.message).toBe('注册成功')
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
})
test('关闭公开注册时,没有邀请码应拒绝注册', async () => {
prisma.systemSettings.findFirst.mockResolvedValue({ enableRegistration: false })
const response = await POST(createRequest({ password: 'Flnxi-2026-Safe' }))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('注册功能已关闭,请使用邀请链接注册')
expect(prisma.invitation.findUnique).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('缺少系统设置时默认不开放公开注册', async () => {
prisma.systemSettings.findFirst.mockResolvedValue(null)
const response = await POST(createRequest({ password: 'Flnxi-2026-Safe' }))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('注册功能已关闭,请使用邀请链接注册')
expect(prisma.invitation.findUnique).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('关闭公开注册时,有效邀请链接仍可注册', async () => {
prisma.systemSettings.findFirst.mockResolvedValue({
enableRegistration: false,
enableUserActivation: true,
})
prisma.invitation.findUnique.mockResolvedValue({
code: 'ABCD1234',
isActive: true,
currentUses: 0,
maxUses: 1,
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
})
const invitationUpdate = jest.fn()
prisma.$transaction.mockImplementationOnce(async (callback: any) => callback({
user: {
count: jest.fn().mockResolvedValue(1),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({
id: 'user-2',
email: 'user@example.com',
username: 'user',
department: '默认部门',
role: 'USER',
createdAt: new Date('2026-05-25T00:00:00.000Z'),
}),
},
invitation: {
update: invitationUpdate,
},
}))
const response = await POST(createRequest({
password: 'Flnxi-2026-Safe',
inviteCode: 'ABCD1234',
}))
const data = await response.json()
expect(response.status).toBe(201)
expect(data.message).toBe('注册成功')
expect(prisma.invitation.findUnique).toHaveBeenCalledWith({
where: { code: 'ABCD1234' },
})
expect(invitationUpdate).toHaveBeenCalledWith({
where: { code: 'ABCD1234' },
data: {
currentUses: { increment: 1 },
usedBy: 'user-2',
usedAt: expect.any(Date),
},
})
})
})

View File

@ -15,36 +15,17 @@ export async function POST(req: NextRequest) {
)
}
// 检查注册功能是否开启
const settings = await prisma.systemSettings.findFirst()
if (settings && !settings.enableRegistration) {
return NextResponse.json(
{ error: '注册功能已关闭,请联系管理员' },
{ status: 403 }
)
}
const { email, username, department, password, inviteCode } = await req.json()
const normalizedInviteCode = typeof inviteCode === 'string' ? inviteCode.trim() : ''
// Validation
if (!email || !username || !password) {
return NextResponse.json(
{ error: '所有字段都是必填的' },
{ status: 400 }
)
}
// 检查注册功能是否开启。关闭公开注册时,仍允许管理员发出的邀请链接注册。
const settings = await prisma.systemSettings.findFirst()
const registrationEnabled = settings?.enableRegistration === true
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
return NextResponse.json(
{ error: passwordValidation.error || '密码不符合安全要求' },
{ status: 400 }
)
}
let validInvitation: { code: string } | null = null
// 如果注册功能关闭,检查是否有有效的邀请码
if (settings && !settings.enableRegistration) {
if (!inviteCode) {
if (!registrationEnabled || normalizedInviteCode) {
if (!normalizedInviteCode) {
return NextResponse.json(
{ error: '注册功能已关闭,请使用邀请链接注册' },
{ status: 403 }
@ -53,7 +34,7 @@ export async function POST(req: NextRequest) {
// 验证邀请码
const invitation = await prisma.invitation.findUnique({
where: { code: inviteCode },
where: { code: normalizedInviteCode },
})
if (!invitation || !invitation.isActive) {
@ -76,6 +57,24 @@ export async function POST(req: NextRequest) {
{ status: 403 }
)
}
validInvitation = { code: normalizedInviteCode }
}
// Validation
if (!email || !username || !password) {
return NextResponse.json(
{ error: '所有字段都是必填的' },
{ status: 400 }
)
}
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
return NextResponse.json(
{ error: passwordValidation.error || '密码不符合安全要求' },
{ status: 400 }
)
}
// Check if user exists
@ -139,9 +138,9 @@ export async function POST(req: NextRequest) {
})
// 如果使用了邀请码,更新邀请码使用记录
if (inviteCode) {
if (validInvitation) {
await tx.invitation.update({
where: { code: inviteCode },
where: { code: validInvitation.code },
data: {
currentUses: { increment: 1 },
usedBy: newUser.id,

View File

@ -58,6 +58,13 @@ export async function POST(req: NextRequest) {
)
}
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '仅最高权限账号可新增店铺分类' },
{ status: 403 }
)
}
const { name } = await req.json()
if (!name || !name.trim()) {

View File

@ -0,0 +1,137 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/field-pool', () => ({
getFieldPoolRecords: jest.fn(),
syncFieldPoolFromTemplates: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
fieldDefinition: {
findUnique: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
}))
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { getFieldPoolRecords, syncFieldPoolFromTemplates } = require('@/lib/field-pool')
const { prisma } = require('@/lib/prisma')
const { GET, POST, PATCH, PUT } = require('../route')
function jsonRequest(body: unknown, method = 'POST') {
return new NextRequest('http://localhost/api/category-fields', {
method,
body: JSON.stringify(body),
})
}
describe('/api/category-fields', () => {
beforeEach(() => {
jest.clearAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
getFieldPoolRecords.mockResolvedValue([
{
key: 'brandName',
label: '品牌',
filterDisplayName: null,
source: 'custom',
type: 'select',
isFilterEnabled: true,
filterOrder: 2,
},
{
key: 'platformName',
label: '平台',
filterDisplayName: '平台筛选',
source: 'standard',
type: 'select',
isFilterEnabled: false,
filterOrder: 3,
},
])
})
it('读取分类字段配置时不触发字段池同步写入', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(data).toEqual({
managedFields: ['brandName'],
availableFields: [
{
key: 'brandName',
label: '品牌',
source: 'custom',
type: 'select',
filterDisplayName: null,
filterOrder: 2,
},
{
key: 'platformName',
label: '平台筛选',
source: 'standard',
type: 'select',
filterDisplayName: '平台筛选',
filterOrder: 3,
},
],
})
})
it('普通管理员不能手动同步字段池', async () => {
const response = await POST()
expect(response.status).toBe(403)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(getFieldPoolRecords).not.toHaveBeenCalled()
})
it('最高权限账号可以手动同步字段池', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
const response = await POST()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(syncFieldPoolFromTemplates).toHaveBeenCalledTimes(1)
expect(getFieldPoolRecords).toHaveBeenCalledTimes(1)
expect(data.managedFields).toEqual(['brandName'])
})
it('普通管理员不能修改分类字段启用和排序', async () => {
const toggleResponse = await PATCH(
jsonRequest({ fieldKey: 'brandName', enabled: true }, 'PATCH')
)
const saveResponse = await PUT(
jsonRequest({
updates: [{ fieldKey: 'brandName', filterDisplayName: '品牌', filterOrder: 1 }],
}, 'PUT')
)
expect(toggleResponse.status).toBe(403)
expect(saveResponse.status).toBe(403)
expect(prisma.fieldDefinition.update).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
})

View File

@ -28,7 +28,6 @@ export async function GET() {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
const managedFields = fields
@ -49,6 +48,34 @@ export async function GET() {
})
}
export async function POST() {
try {
const session = await getServerSession(authOptions)
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json({ error: '仅最高权限账号可同步分类字段' }, { status: 403 })
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
return NextResponse.json({
success: true,
managedFields: fields.filter((field) => field.isFilterEnabled).map((field) => field.key),
availableFields: fields.map((field) => ({
key: field.key,
label: field.filterDisplayName || field.label,
source: field.source === 'standard' ? 'standard' : 'custom',
type: field.type,
filterDisplayName: field.filterDisplayName,
filterOrder: field.filterOrder ?? 0,
})),
})
} catch (error) {
console.error('Sync category fields failed:', error)
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(authOptions)

View File

@ -1,68 +0,0 @@
/**
* 调试 API:检查素材类型数据
* 用于诊断自定义模板的 materialType 是否正确设置
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// 获取所有自定义模板(包括非活跃的)
const allTemplates = await prisma.uploadTemplate.findMany({
select: {
id: true,
name: true,
materialType: true,
status: true,
},
orderBy: { createdAt: 'desc' },
})
// 获取活跃的自定义模板
const activeTemplates = allTemplates.filter((t) => t.status === 'active')
// 获取所有唯一的 materialType 值
const materialTypes = new Set<string>()
activeTemplates.forEach((template) => {
const value = template.materialType?.trim()
if (value) {
materialTypes.add(value)
}
})
return NextResponse.json({
summary: {
totalTemplates: allTemplates.length,
activeTemplates: activeTemplates.length,
uniqueMaterialTypes: materialTypes.size,
},
allTemplates: allTemplates.map((t) => ({
id: t.id,
name: t.name,
materialType: t.materialType || '(空)',
status: t.status,
hasMaterialType: Boolean(t.materialType?.trim()),
})),
activeTemplates: activeTemplates.map((t) => ({
id: t.id,
name: t.name,
materialType: t.materialType || '(空)',
hasMaterialType: Boolean(t.materialType?.trim()),
})),
uniqueMaterialTypes: Array.from(materialTypes).sort(),
message: activeTemplates.length === 0
? '没有活跃的自定义模板'
: materialTypes.size === 0
? '活跃的自定义模板都没有设置 materialType 字段'
: `找到 ${materialTypes.size} 个唯一的 materialType,应该显示为 tag 按钮`,
})
} catch (error) {
console.error('[debug/material-types] Error:', error)
return NextResponse.json(
{ error: 'Failed to fetch template data', message: error instanceof Error ? error.message : String(error) },
{ status: 500 }
)
}
}

View File

@ -18,7 +18,9 @@ jest.mock('@/lib/prisma', () => ({
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
@ -26,11 +28,12 @@ jest.mock('@/lib/prisma', () => ({
jest.mock('@/lib/license', () => ({
canUseDepartmentIsolation: jest.fn(),
planDepartmentStructureChangeWithPaidCore: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { canUseDepartmentIsolation } = require('@/lib/license')
const { canUseDepartmentIsolation, planDepartmentStructureChangeWithPaidCore } = require('@/lib/license')
import { POST, PATCH, DELETE } from '../route'
@ -41,6 +44,26 @@ describe('departments route license guard', () => {
user: { id: 'admin-id', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.department.findMany.mockResolvedValue([])
planDepartmentStructureChangeWithPaidCore.mockImplementation(async ({ action, target }: any) => {
if (action === 'create') {
return {
valid: true,
allowed: true,
action,
departmentOps: [{ type: 'create', data: { name: String(target.name).trim() } }],
userOps: [],
}
}
return {
valid: false,
allowed: false,
error: '未设置测试计划',
errorCode: 'TEST_PLAN_MISSING',
statusCode: 500,
}
})
canUseDepartmentIsolation.mockResolvedValue(true)
})
@ -62,7 +85,6 @@ describe('departments route license guard', () => {
})
test('POST 最高权限账号可以创建部门', async () => {
prisma.department.findUnique.mockResolvedValue(null)
prisma.department.create.mockResolvedValue({ id: 'dept-1', name: '设计部' })
const request = new NextRequest('http://localhost/api/departments', {
@ -75,6 +97,11 @@ describe('departments route license guard', () => {
expect(response.status).toBe(201)
expect(data.name).toBe('设计部')
expect(planDepartmentStructureChangeWithPaidCore).toHaveBeenCalledWith({
action: 'create',
target: { name: '设计部' },
departments: [],
})
expect(prisma.department.create).toHaveBeenCalledWith({ data: { name: '设计部' } })
})
@ -93,7 +120,7 @@ describe('departments route license guard', () => {
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('部门隔离功能需要专业版许可证')
expect(data.error).toContain('当前许可证未包含部门隔离功能')
expect(prisma.department.findUnique).not.toHaveBeenCalled()
})
@ -128,7 +155,7 @@ describe('departments route license guard', () => {
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('部门隔离功能需要专业版许可证')
expect(data.error).toContain('当前许可证未包含部门隔离功能')
expect(prisma.department.findUnique).not.toHaveBeenCalled()
})

View File

@ -2,9 +2,39 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { canUseDepartmentIsolation } from '@/lib/license'
import { canUseDepartmentIsolation, planDepartmentStructureChangeWithPaidCore } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
function departmentPlanResponse(plan: {
valid: boolean
allowed: boolean
statusCode?: number
error?: string
errorCode?: string
response?: Record<string, unknown>
details?: Record<string, unknown>
}) {
if (plan.response) {
return NextResponse.json(plan.response, { status: plan.statusCode || 200 })
}
return NextResponse.json(
{
error: plan.error || '付费增强包无法规划部门结构变更',
errorCode: plan.errorCode,
details: plan.details,
},
{ status: plan.statusCode || 503 }
)
}
async function loadDepartmentPlanSnapshot() {
return prisma.department.findMany({
select: { id: true, name: true },
orderBy: { createdAt: 'asc' },
})
}
// Get all departments
export async function GET() {
try {
@ -39,34 +69,29 @@ export async function POST(req: NextRequest) {
// 检查部门隔离功能是否可用
if (!(await canUseDepartmentIsolation())) {
return NextResponse.json(
{ error: '部门隔离功能需要专业版许可证,请升级后使用' },
{ error: '当前许可证未包含部门隔离功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const { name } = await req.json()
if (!name || name.trim() === '') {
return NextResponse.json(
{ error: '部门名称不能为空' },
{ status: 400 }
)
}
// Check if department already exists
const existing = await prisma.department.findUnique({
where: { name },
const departments = await loadDepartmentPlanSnapshot()
const plan = await planDepartmentStructureChangeWithPaidCore({
action: 'create',
target: { name },
departments,
})
if (existing) {
return NextResponse.json(
{ error: '部门已存在' },
{ status: 400 }
)
if (!plan.valid || !plan.allowed) {
return departmentPlanResponse(plan)
}
const createOp = plan.departmentOps.find((op) => op.type === 'create') as any
if (!createOp?.data) {
return NextResponse.json({ error: '部门创建计划无效' }, { status: 503 })
}
const department = await prisma.department.create({
data: { name },
data: createOp.data,
})
return NextResponse.json(department, { status: 201 })
@ -93,137 +118,46 @@ export async function PATCH(req: NextRequest) {
if (!(await canUseDepartmentIsolation())) {
return NextResponse.json(
{ error: '部门隔离功能需要专业版许可证,请升级后使用' },
{ error: '当前许可证未包含部门隔离功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const { id, name } = await req.json()
if (!id) {
return NextResponse.json(
{ error: '缺少部门ID' },
{ status: 400 }
)
}
if (!name || name.trim() === '') {
return NextResponse.json(
{ error: '部门名称不能为空' },
{ status: 400 }
)
}
const targetDepartment = await prisma.department.findUnique({
where: { id },
})
if (!targetDepartment) {
return NextResponse.json(
{ error: '未找到对应的部门' },
{ status: 404 }
)
}
const trimmedName = name.trim()
if (targetDepartment.name === trimmedName) {
return NextResponse.json(targetDepartment)
}
// 构建所有需要重命名的部门(自身 + 子级)
const oldName = targetDepartment.name
const oldPrefix = `${oldName}-`
const newPrefix = `${trimmedName}-`
const descendantDepartments = await prisma.department.findMany({
where: {
name: {
startsWith: oldPrefix,
const [departments, users] = await Promise.all([
loadDepartmentPlanSnapshot(),
prisma.user.findMany({
select: {
id: true,
department: true,
managedDepartment: true,
},
},
}),
])
const plan = await planDepartmentStructureChangeWithPaidCore({
action: 'rename',
target: { id, name },
departments,
users,
})
type RenameItem = { id: string; oldName: string; newName: string }
const renameItems: RenameItem[] = [
{ id: targetDepartment.id, oldName, newName: trimmedName },
...descendantDepartments.map((dept) => ({
id: dept.id,
oldName: dept.name,
newName: `${newPrefix}${dept.name.slice(oldPrefix.length)}`,
})),
]
// 检查是否会与其他部门重名
const newNameSet = Array.from(new Set(renameItems.map((item) => item.newName)))
const renameIds = renameItems.map((item) => item.id)
const conflicting = await prisma.department.findMany({
where: {
name: { in: newNameSet },
NOT: {
id: { in: renameIds },
},
},
select: { name: true },
})
if (conflicting.length > 0) {
return NextResponse.json(
{ error: `存在同名分支:${conflicting.map((item) => item.name).join('、')}` },
{ status: 400 }
)
}
const renameValue = (value?: string | null) => {
if (!value) return value
if (value === oldName) return trimmedName
if (value.startsWith(oldPrefix)) {
return `${newPrefix}${value.slice(oldPrefix.length)}`
}
return value
if (!plan.valid || !plan.allowed) {
return departmentPlanResponse(plan)
}
await prisma.$transaction(async (tx) => {
for (const item of renameItems) {
for (const item of plan.departmentOps.filter((op) => op.type === 'rename') as any[]) {
await tx.department.update({
where: { id: item.id },
data: { name: item.newName },
})
}
const affectedUsers = await tx.user.findMany({
where: {
OR: [
{ department: oldName },
{ department: { startsWith: oldPrefix } },
{ managedDepartment: oldName },
{ managedDepartment: { startsWith: oldPrefix } },
],
},
select: {
id: true,
department: true,
managedDepartment: true,
},
})
for (const user of affectedUsers) {
const nextDepartment = renameValue(user.department) ?? ''
const nextManaged = renameValue(user.managedDepartment)
const data: { department?: string; managedDepartment?: string | null } = {}
if (nextDepartment !== user.department) {
data.department = nextDepartment
}
if (nextManaged !== user.managedDepartment) {
data.managedDepartment = nextManaged ?? null
}
if (Object.keys(data).length > 0) {
for (const userOp of plan.userOps.filter((op) => op.type === 'update') as any[]) {
if (userOp.id && userOp.data && Object.keys(userOp.data).length > 0) {
await tx.user.update({
where: { id: user.id },
data,
where: { id: userOp.id },
data: userOp.data,
})
}
}
@ -257,7 +191,7 @@ export async function DELETE(req: NextRequest) {
if (!(await canUseDepartmentIsolation())) {
return NextResponse.json(
{ error: '部门隔离功能需要专业版许可证,请升级后使用' },
{ error: '当前许可证未包含部门隔离功能,请联系管理员处理授权' },
{ status: 403 }
)
}
@ -265,50 +199,36 @@ export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少部门ID' },
{ status: 400 }
)
}
const department = await prisma.department.findUnique({
where: { id },
select: { id: true, name: true },
const departments = await loadDepartmentPlanSnapshot()
const plan = await planDepartmentStructureChangeWithPaidCore({
action: 'delete',
target: { id },
departments,
})
if (!department) {
return NextResponse.json(
{ error: '未找到对应的部门' },
{ status: 404 }
)
if (!plan.valid || !plan.allowed) {
return departmentPlanResponse(plan)
}
const prefix = `${department.name}-`
const relatedDepartments = await prisma.department.findMany({
where: {
OR: [
{ name: department.name },
{ name: { startsWith: prefix } },
],
},
select: { id: true, name: true },
})
const targetIds = relatedDepartments.map((dept) => dept.id)
const targetNames = relatedDepartments.map((dept) => dept.name)
const deleteOp = plan.departmentOps.find((op) => op.type === 'deleteMany') as any
const targetIds = Array.isArray(deleteOp?.ids) ? deleteOp.ids : []
const targetNames = Array.isArray(deleteOp?.names) ? deleteOp.names : []
await prisma.$transaction(async (tx) => {
if (targetNames.length > 0) {
await tx.user.updateMany({
where: { department: { in: targetNames } },
data: { department: '' },
})
for (const userOp of plan.userOps as any[]) {
if (userOp.type === 'clearDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) {
await tx.user.updateMany({
where: { department: { in: userOp.names } },
data: { department: '' },
})
}
await tx.user.updateMany({
where: { managedDepartment: { in: targetNames } },
data: { managedDepartment: null },
})
if (userOp.type === 'clearManagedDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) {
await tx.user.updateMany({
where: { managedDepartment: { in: userOp.names } },
data: { managedDepartment: null },
})
}
}
if (targetIds.length > 0) {

View File

@ -0,0 +1,87 @@
import { GET, HEAD } from '../route'
import fs from 'fs'
import path from 'path'
jest.mock('@/lib/startup-check', () => ({
runStartupCheck: jest.fn(),
getStartupCheckStatus: jest.fn(),
}))
const { runStartupCheck, getStartupCheckStatus } = require('@/lib/startup-check')
describe('/api/health', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('首次检查会执行启动检查,成功时返回 200', async () => {
getStartupCheckStatus
.mockReturnValueOnce({ executed: false, result: null })
.mockReturnValueOnce({
executed: true,
result: {
success: true,
templatesCreated: ['3DMAX素材模板'],
errors: [],
},
})
runStartupCheck.mockResolvedValue({
success: true,
templatesCreated: ['3DMAX素材模板'],
errors: [],
})
const response = await GET()
const data = await response.json()
expect(runStartupCheck).toHaveBeenCalledTimes(1)
expect(response.status).toBe(200)
expect(data.status).toBe('ok')
expect(data.version).toBe(fs.readFileSync(path.join(process.cwd(), 'VERSION.txt'), 'utf8').trim())
expect(data.systemCheck.success).toBe(true)
expect(data.systemCheck.errorCount).toBe(0)
})
it('启动检查失败时返回 503,避免 Docker 健康检查误判可用', async () => {
getStartupCheckStatus
.mockReturnValueOnce({ executed: false, result: null })
.mockReturnValueOnce({
executed: true,
result: {
success: false,
templatesCreated: [],
errors: ['内置模板初始化失败'],
},
})
runStartupCheck.mockResolvedValue({
success: false,
templatesCreated: [],
errors: ['内置模板初始化失败'],
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(503)
expect(data.status).toBe('unhealthy')
expect(data.systemCheck.success).toBe(false)
expect(data.systemCheck.errorCount).toBe(1)
expect(data.systemCheck.errors).toBeUndefined()
})
it('HEAD 同样遵守启动检查状态', async () => {
getStartupCheckStatus.mockReturnValue({
executed: true,
result: {
success: false,
templatesCreated: [],
errors: ['内置模板初始化失败'],
},
})
const response = await HEAD()
expect(runStartupCheck).not.toHaveBeenCalled()
expect(response.status).toBe(503)
})
})

View File

@ -1,30 +1,64 @@
import { NextResponse } from 'next/server'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { runStartupCheck, getStartupCheckStatus } from '@/lib/startup-check'
// Health check endpoint - 用于网络检测和系统完整性检查
// 首次调用时会自动执行系统初始化(创建内置模板等)
export async function GET() {
let cachedAppVersion: string | null = null
async function getAppVersion() {
if (cachedAppVersion) {
return cachedAppVersion
}
try {
const content = await readFile(path.join(process.cwd(), 'VERSION.txt'), 'utf8')
cachedAppVersion = content.trim() || 'unknown'
} catch {
cachedAppVersion = 'unknown'
}
return cachedAppVersion
}
async function resolveHealthStatus() {
const status = getStartupCheckStatus()
// 如果还没执行过启动检查,执行它
if (!status.executed) {
await runStartupCheck()
}
const finalStatus = getStartupCheckStatus()
const success = finalStatus.result?.success === true
return {
finalStatus,
httpStatus: success ? 200 : 503,
statusLabel: success ? 'ok' : 'unhealthy',
}
}
// Health check endpoint - 用于网络检测和系统完整性检查
// 首次调用时会自动执行系统初始化(创建内置模板等)
export async function GET() {
const { finalStatus, httpStatus, statusLabel } = await resolveHealthStatus()
const version = await getAppVersion()
return NextResponse.json({
status: 'ok',
status: statusLabel,
version,
timestamp: new Date().toISOString(),
systemCheck: {
executed: finalStatus.executed,
success: finalStatus.result?.success ?? false,
templatesCreated: finalStatus.result?.templatesCreated ?? [],
errorCount: finalStatus.result?.errors.length ?? 0,
},
})
}, { status: httpStatus })
}
export async function HEAD() {
return new NextResponse(null, { status: 200 })
const { httpStatus } = await resolveHealthStatus()
return new NextResponse(null, { status: httpStatus })
}

View File

@ -21,14 +21,29 @@ jest.mock('@/lib/field-pool', () => ({
formatFieldOptionEntries: jest.fn((options) => options),
}))
jest.mock('@/lib/license', () => ({
planHomeFilterFieldCountsWithPaidCore: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } = require('@/lib/field-pool')
const { planHomeFilterFieldCountsWithPaidCore } = require('@/lib/license')
function getSqlText(query: unknown) {
const sqlQuery = query as { sql?: string; text?: string }
return sqlQuery.sql || sqlQuery.text || String(query)
}
describe('GET /api/home-filter-fields 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
formatFieldOptionEntries.mockImplementation((options: Array<{ label: string; value: string }>) => options)
planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({
valid: true,
fieldPlans: [],
moduleAvailable: true,
})
})
it('未登录时拒绝读取首页筛选字段', async () => {
@ -40,6 +55,7 @@ describe('GET /api/home-filter-fields 权限边界', () => {
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(planHomeFilterFieldCountsWithPaidCore).not.toHaveBeenCalled()
expect(getFieldPoolRecords).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
@ -57,7 +73,12 @@ describe('GET /api/home-filter-fields 权限边界', () => {
options: [{ label: '店铺A', value: '店铺A' }],
},
])
prisma.$queryRawUnsafe
planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({
valid: true,
fieldPlans: [{ key: 'categoryName', source: 'column' }],
moduleAvailable: true,
})
prisma.$queryRaw
.mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }])
.mockResolvedValueOnce([{ count: BigInt(3) }])
@ -65,6 +86,12 @@ describe('GET /api/home-filter-fields 权限边界', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(planHomeFilterFieldCountsWithPaidCore).toHaveBeenCalledWith({
fields: [{ key: 'categoryName' }],
})
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
expect(data).toEqual({
fields: [
{
@ -90,6 +117,11 @@ describe('GET /api/home-filter-fields 权限边界', () => {
options: [{ label: 'CODE-001', value: 'CODE-001' }],
},
])
planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({
valid: true,
fieldPlans: [{ key: 'customCode', source: 'metadata' }],
moduleAvailable: true,
})
prisma.$queryRaw
.mockResolvedValueOnce([{ value: 'CODE-001', count: BigInt(2) }])
.mockResolvedValueOnce([{ count: BigInt(2) }])
@ -98,8 +130,18 @@ describe('GET /api/home-filter-fields 权限边界', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(planHomeFilterFieldCountsWithPaidCore).toHaveBeenCalledWith({
fields: [{ key: 'customCode' }],
})
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
const optionCountSql = getSqlText(prisma.$queryRaw.mock.calls[0][0])
const totalCountSql = getSqlText(prisma.$queryRaw.mock.calls[1][0])
expect(optionCountSql).toContain('FROM (')
expect(optionCountSql).toContain('GROUP BY value')
expect(totalCountSql).toContain('FROM (')
expect(totalCountSql).not.toContain('GROUP BY')
expect(data).toEqual({
fields: [
{

View File

@ -3,24 +3,30 @@ import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } from '@/lib/field-pool'
import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool'
import { planHomeFilterFieldCountsWithPaidCore } from '@/lib/license'
// 字段池key到数据库列名的映射
// 注意:只有在 materials 表中存在对应列的字段才能统计
const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
categoryName: 'categoryName',
sceneName: 'sceneName',
platformName: 'platformName',
materialType: 'materialType',
renderer: 'renderer',
brandName: 'brandName',
colorMode: 'colorMode',
renderTime: 'renderTime',
imageResolution: 'imageResolution',
seriesName: 'seriesName',
skuCode: 'skuCode',
materialTexture: 'materialTexture',
authorName: 'authorName',
const MATERIAL_FIELD_COLUMN_MAP: Record<string, Prisma.Sql> = {
categoryName: Prisma.sql`"categoryName"`,
sceneName: Prisma.sql`"sceneName"`,
platformName: Prisma.sql`"platformName"`,
materialType: Prisma.sql`"materialType"`,
renderer: Prisma.sql`"renderer"`,
brandName: Prisma.sql`"brandName"`,
colorMode: Prisma.sql`"colorMode"`,
renderTime: Prisma.sql`"renderTime"`,
imageResolution: Prisma.sql`"imageResolution"`,
seriesName: Prisma.sql`"seriesName"`,
skuCode: Prisma.sql`"skuCode"`,
materialTexture: Prisma.sql`"materialTexture"`,
authorName: Prisma.sql`"authorName"`,
}
type HomeFilterCountPlan = {
key: string
source: 'column' | 'metadata'
}
function metadataTemplateFieldsJson() {
@ -40,21 +46,25 @@ function metadataTemplateFieldsJson() {
* 构建每个字段的素材统计
* @returns counts: 每个选项的素材数量, totals: 每个字段有非空值的素材总数
*/
async function buildMaterialCounts(fieldKeys: string[]) {
async function buildMaterialCounts(fieldPlans: HomeFilterCountPlan[]) {
const counts: Record<string, Map<string, number>> = {}
const totals: Record<string, number> = {}
await Promise.all(
fieldKeys.map(async (fieldKey) => {
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
if (!column) {
fieldPlans.map(async ({ key: fieldKey, source }) => {
const columnSql = source === 'column' ? MATERIAL_FIELD_COLUMN_MAP[fieldKey] : null
if (source === 'metadata' || !columnSql) {
const fieldValueSql = Prisma.sql`${metadataTemplateFieldsJson()} ->> ${fieldKey}`
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
SELECT (${metadataTemplateFieldsJson()} ->> ${fieldKey}) AS value, COUNT(*)::bigint AS count
FROM "materials"
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
AND "deletedAt" IS NULL
GROUP BY (${metadataTemplateFieldsJson()} ->> ${fieldKey})
SELECT value, COUNT(*)::bigint AS count
FROM (
SELECT (${fieldValueSql}) AS value
FROM "materials"
WHERE "deletedAt" IS NULL
) AS material_filter_values
WHERE value IS NOT NULL
AND value != ''
GROUP BY value
`)
const map = new Map<string, number>()
@ -66,19 +76,27 @@ async function buildMaterialCounts(fieldKeys: string[]) {
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
SELECT COUNT(*)::bigint AS count
FROM "materials"
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
AND "deletedAt" IS NULL
FROM (
SELECT (${fieldValueSql}) AS value
FROM "materials"
WHERE "deletedAt" IS NULL
) AS material_filter_values
WHERE value IS NOT NULL
AND value != ''
`)
totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
return
}
// 获取每个选项的素材数量(排除已删除的素材)
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>(
`SELECT "${column}" as value, COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL GROUP BY "${column}"`
)
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
SELECT ${columnSql} AS value, COUNT(*)::bigint AS count
FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
GROUP BY ${columnSql}
`)
const map = new Map<string, number>()
rows.forEach((row) => {
if (!row.value) return
@ -88,9 +106,13 @@ async function buildMaterialCounts(fieldKeys: string[]) {
// 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材)
// 这是"全部"按钮应该显示的数量
const totalResult = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL`
)
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
SELECT COUNT(*)::bigint AS count
FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
`)
totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
})
)
@ -107,7 +129,6 @@ export async function GET() {
)
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as Array<{
key: string
label: string
@ -115,11 +136,20 @@ export async function GET() {
isFilterEnabled?: boolean
options: Array<{ value: string; label?: string | null }>
}>
const fieldKeys = fields.filter((field) => field.isFilterEnabled).map((field) => field.key)
const { counts: countsMap, totals: totalsMap } = await buildMaterialCounts(fieldKeys)
const enabledFields = fields.filter((field) => field.isFilterEnabled)
const countPlan = await planHomeFilterFieldCountsWithPaidCore({
fields: enabledFields.map((field) => ({ key: field.key })),
})
const fieldPlans = countPlan.valid
? countPlan.fieldPlans.filter((field): field is HomeFilterCountPlan => (
field.source === 'metadata' || Boolean(MATERIAL_FIELD_COLUMN_MAP[field.key])
))
: []
const plannableFieldKeys = new Set(fieldPlans.map((field) => field.key))
const { counts: countsMap, totals: totalsMap } = await buildMaterialCounts(fieldPlans)
const payload = fields
.filter((field) => field.isFilterEnabled)
.filter((field) => field.isFilterEnabled && plannableFieldKeys.has(field.key))
.map((field) => ({
key: field.key,
label: field.filterDisplayName || field.label,

View File

@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
// PATCH - 更新邀请链接(仅管理员)
// PATCH - 更新邀请链接(仅最高权限账号)
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
@ -11,8 +12,7 @@ export async function PATCH(
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -44,7 +44,7 @@ export async function PATCH(
}
}
// DELETE - 删除邀请链接(仅管理员)
// DELETE - 删除邀请链接(仅最高权限账号)
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
@ -52,8 +52,7 @@ export async function DELETE(
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -0,0 +1,171 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
invitation: {
count: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
}))
jest.mock('@/lib/config', () => ({
resolveBaseUrl: jest.fn(() => 'http://localhost:3000'),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { resolveBaseUrl } = require('@/lib/config')
import { GET, POST } from '../route'
import { PATCH, DELETE } from '../[id]/route'
function jsonRequest(url: string, body: unknown, method = 'POST') {
return new NextRequest(url, {
method,
body: JSON.stringify(body),
})
}
describe('邀请链接管理权限', () => {
beforeEach(() => {
jest.resetAllMocks()
resolveBaseUrl.mockReturnValue('http://localhost:3000')
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
})
it('普通管理员不能查看或创建邀请链接', async () => {
const listResponse = await GET(new NextRequest('http://localhost/api/invitations'))
const createResponse = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 1, expiresInDays: null })
)
expect(listResponse.status).toBe(403)
expect(createResponse.status).toBe(403)
expect(prisma.invitation.findMany).not.toHaveBeenCalled()
expect(prisma.invitation.create).not.toHaveBeenCalled()
})
it('旧会话带最高权限标记但数据库已撤销时不能创建邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
const response = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 1, expiresInDays: null })
)
expect(response.status).toBe(403)
expect(prisma.invitation.create).not.toHaveBeenCalled()
})
it('最高权限账号可以创建并查看邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.invitation.create.mockResolvedValue({
id: 'invite-1',
code: 'ABCD1234',
createdBy: 'super-1',
maxUses: 3,
currentUses: 0,
expiresAt: null,
isActive: true,
createdAt: new Date('2026-05-28T00:00:00.000Z'),
})
prisma.invitation.count.mockResolvedValue(1)
prisma.invitation.findMany.mockResolvedValue([
{
id: 'invite-1',
code: 'ABCD1234',
createdBy: 'super-1',
maxUses: 3,
currentUses: 0,
expiresAt: null,
isActive: true,
createdAt: new Date('2026-05-28T00:00:00.000Z'),
},
])
const createResponse = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 3, expiresInDays: null })
)
const listResponse = await GET(new NextRequest('http://localhost/api/invitations'))
const listData = await listResponse.json()
expect(createResponse.status).toBe(200)
expect(listResponse.status).toBe(200)
expect(prisma.invitation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
createdBy: 'super-1',
maxUses: 3,
}),
})
)
expect(listData.invitations[0].inviteUrl).toBe('http://localhost:3000/register?invite=ABCD1234')
})
it('普通管理员不能启停或删除邀请链接', async () => {
const patchResponse = await PATCH(
jsonRequest('http://localhost/api/invitations/invite-1', { isActive: false }, 'PATCH'),
{ params: { id: 'invite-1' } }
)
const deleteResponse = await DELETE(
new NextRequest('http://localhost/api/invitations/invite-1', { method: 'DELETE' }),
{ params: { id: 'invite-1' } }
)
expect(patchResponse.status).toBe(403)
expect(deleteResponse.status).toBe(403)
expect(prisma.invitation.update).not.toHaveBeenCalled()
expect(prisma.invitation.delete).not.toHaveBeenCalled()
})
it('最高权限账号可以启停和删除邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.invitation.update.mockResolvedValue({ id: 'invite-1', isActive: false })
prisma.invitation.delete.mockResolvedValue({ id: 'invite-1' })
const patchResponse = await PATCH(
jsonRequest('http://localhost/api/invitations/invite-1', { isActive: false }, 'PATCH'),
{ params: { id: 'invite-1' } }
)
const deleteResponse = await DELETE(
new NextRequest('http://localhost/api/invitations/invite-1', { method: 'DELETE' }),
{ params: { id: 'invite-1' } }
)
expect(patchResponse.status).toBe(200)
expect(deleteResponse.status).toBe(200)
expect(prisma.invitation.update).toHaveBeenCalledWith({
where: { id: 'invite-1' },
data: { isActive: false },
})
expect(prisma.invitation.delete).toHaveBeenCalledWith({
where: { id: 'invite-1' },
})
})
})

View File

@ -4,14 +4,14 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { randomBytes } from 'crypto'
import { resolveBaseUrl } from '@/lib/config'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
// GET - 获取邀请链接列表(仅管理员)
// GET - 获取邀请链接列表(仅最高权限账号)
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -70,13 +70,12 @@ export async function GET(req: NextRequest) {
}
}
// POST - 创建邀请链接(仅管理员)
// POST - 创建邀请链接(仅最高权限账号)
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -2,6 +2,8 @@ import { NextRequest } from 'next/server';
import { POST } from '../route';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
@ -14,6 +16,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/register-free'),
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
}));
jest.mock('@/lib/license/crypto-service', () => ({
@ -26,13 +29,27 @@ jest.mock('@/lib/license/license-service', () => ({
startHeartbeat: jest.fn(),
}));
jest.mock('@/lib/license/premium-module-service', () => ({
syncPremiumModules: jest.fn(),
}));
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}));
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>;
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>;
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>;
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>;
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>;
const signedFreeLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
@ -87,7 +104,11 @@ describe('POST /api/license/activate-free', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai';
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
mockVerifyLicenseSignature.mockReturnValue(true);
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true });
mockSyncPremiumModules.mockResolvedValue({ synced: true });
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
@ -112,7 +133,53 @@ describe('POST /api/license/activate-free', () => {
signedFreeLicense.data,
signedFreeLicense.signature
);
expect(mockSyncPremiumModules).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
expect(fetchBody.timestamp).toEqual(expect.any(Number));
expect(fetchBody.nonce).toMatch(/^[0-9a-f]{32}$/);
expect(fetchBody.signature).toMatch(/^[0-9a-f]{64}$/);
expect(fetchBody.customerName).toBe('测试公司');
expect(fetchBody.contactInfo).toBe('13800000000');
});
it('免费版授权模块同步失败不应阻断首次激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRegisterFreeSuccess();
mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' });
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSaveLicenseLocally).toHaveBeenCalled();
expect(mockInitializeSystem).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down');
consoleWarnSpy.mockRestore();
});
it('已有最高权限账号后,未授权请求不能改写免费许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录');
expect(global.fetch).not.toHaveBeenCalled();
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => {
@ -125,6 +192,7 @@ describe('POST /api/license/activate-free', () => {
expect(data.success).toBe(false);
expect(data.error).toContain('许可证服务返回数据异常');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
@ -140,6 +208,29 @@ describe('POST /api/license/activate-free', () => {
expect(data.success).toBe(false);
expect(data.error).toBe('许可证签名验证失败');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证绑定信息不匹配时拒绝激活', async () => {
mockRegisterFreeSuccess({
license: {
...signedFreeLicense,
data: {
...signedFreeLicense.data,
licenseKey: 'FLNXI-FREE-OTHER-1234-5678-ABCD',
},
},
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('激活码不匹配');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});

View File

@ -3,6 +3,10 @@ import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
function maskSecret(value: unknown): string {
if (typeof value !== 'string' || !value) return '****';
@ -33,6 +37,14 @@ function sanitizeLicenseServerLogPayload(payload: unknown) {
export async function POST(request: NextRequest) {
try {
const guard = await requireSuperAdminAfterInitialSetup();
if (!guard.allowed) {
return NextResponse.json(
{ success: false, error: guard.error },
{ status: guard.status }
);
}
const body = await request.json().catch(() => ({}));
const customerName = body.customerName?.trim();
const contactInfo = body.contactInfo?.trim();
@ -71,15 +83,16 @@ export async function POST(request: NextRequest) {
}
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('register-free', {
fingerprint,
customerName,
contactInfo,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('register-free'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint,
customerName,
contactInfo,
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
if (!response.ok) {
@ -127,10 +140,25 @@ export async function POST(request: NextRequest) {
);
}
const bindingResult = validateSignedLicenseBinding(signedLicense, {
licenseKey: activationCode,
instanceId: fingerprint.instanceId,
});
if (!bindingResult.valid) {
return NextResponse.json(
{ success: false, error: bindingResult.error },
{ status: 400 }
);
}
console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)');
// ✅ 在后端保存许可证
await saveLicenseLocally(activationCode, signedLicense);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
// 初始化系统(创建默认管理员、部门、店铺等)
const initResult = await initializeSystem();

View File

@ -2,6 +2,8 @@ import { NextRequest } from 'next/server'
import { POST } from '../route'
import { verifyLicenseSignature } from '@/lib/license/crypto-service'
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'
import { syncPremiumModules } from '@/lib/license/premium-module-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
@ -14,6 +16,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'),
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
}))
jest.mock('@/lib/license/crypto-service', () => ({
@ -26,13 +29,27 @@ jest.mock('@/lib/license/license-service', () => ({
startHeartbeat: jest.fn(),
}))
jest.mock('@/lib/license/premium-module-service', () => ({
syncPremiumModules: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
const signedLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'ABCD-1234-EFGH-5678',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'PRO',
limits: { maxUsers: 100, maxMaterials: 10000 },
features: {
@ -82,7 +99,11 @@ describe('POST /api/license/activate', () => {
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn()
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai'
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32)
mockVerifyLicenseSignature.mockReturnValue(true)
mockSyncPremiumModules.mockResolvedValue({ synced: true })
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
@ -111,10 +132,36 @@ describe('POST /api/license/activate', () => {
expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(signedLicense.data, signedLicense.signature)
expect(mockSyncPremiumModules).toHaveBeenCalled()
expect(mockStartHeartbeat).toHaveBeenCalled()
expect(setCookieHeader).toContain('license_activated=true')
expect(setCookieHeader).toContain('HttpOnly')
expect(setCookieHeader).toContain('Max-Age=31536000')
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)
expect(fetchBody.timestamp).toEqual(expect.any(Number))
expect(fetchBody.nonce).toMatch(/^[0-9a-f]{32}$/)
expect(fetchBody.signature).toMatch(/^[0-9a-f]{64}$/)
expect(fetchBody.licenseKey).toBe('ABCD-1234-EFGH-5678')
})
test('已有最高权限账号后,未授权请求不能改写本地许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录')
expect(global.fetch).not.toHaveBeenCalled()
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockInitializeSystem).not.toHaveBeenCalled()
expect(mockStartHeartbeat).not.toHaveBeenCalled()
})
test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {
@ -177,6 +224,7 @@ describe('POST /api/license/activate', () => {
expect(rejectedResponse.status).toBe(400)
expect(rejectedData.error).toBe('许可证无效')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
mockActivateSuccess()
mockVerifyLicenseSignature.mockReturnValueOnce(false)
@ -187,6 +235,43 @@ describe('POST /api/license/activate', () => {
expect(invalidSignatureResponse.status).toBe(400)
expect(invalidSignatureData.error).toBe('许可证签名验证失败')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('许可证绑定信息不匹配时不保存本地许可证', async () => {
mockActivateSuccess({
license: {
...signedLicense,
data: {
...signedLicense.data,
instanceId: 'other-instance-id',
},
},
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('不属于当前设备')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('授权模块清单同步失败不应阻断许可证激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockActivateSuccess()
mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' })
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down')
consoleWarnSpy.mockRestore()
})
test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => {

View File

@ -3,10 +3,22 @@ import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
import type { ActivateResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
try {
const guard = await requireSuperAdminAfterInitialSetup();
if (!guard.allowed) {
return NextResponse.json(
{ success: false, error: guard.error },
{ status: guard.status }
);
}
const body = await request.json();
// 支持两种参数名:activationCode(新)和 licenseKey(旧)
const licenseKey = body.activationCode || body.licenseKey;
@ -22,14 +34,15 @@ export async function POST(request: NextRequest) {
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('activate', {
licenseKey,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('activate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey,
fingerprint,
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
const data: ActivateResponse = await response.json();
@ -49,8 +62,23 @@ export async function POST(request: NextRequest) {
);
}
const bindingResult = validateSignedLicenseBinding(data.license, {
licenseKey,
instanceId: fingerprint.instanceId,
});
if (!bindingResult.valid) {
return NextResponse.json(
{ success: false, error: bindingResult.error },
{ status: 400 }
);
}
// ✅ 在后端保存许可证
await saveLicenseLocally(licenseKey, data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
// 初始化系统(创建默认管理员、部门、店铺等)
const initResult = await initializeSystem();

View File

@ -2,15 +2,17 @@ import { NextRequest } from 'next/server'
jest.mock('@/lib/license/license-service', () => ({
getLocalLicense: jest.fn(),
isLicenseConfigExpired: jest.fn(),
}))
const { getLocalLicense } = require('@/lib/license/license-service')
const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service')
import { GET } from '../route'
describe('GET /api/license/check', () => {
beforeEach(() => {
jest.clearAllMocks()
isLicenseConfigExpired.mockReturnValue(false)
})
test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => {
@ -30,6 +32,7 @@ describe('GET /api/license/check', () => {
intranetPenetration: false,
},
expiresAt: null,
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
},
})
@ -39,6 +42,7 @@ describe('GET /api/license/check', () => {
expect(response.status).toBe(200)
expect(data.activated).toBe(true)
expect(response.cookies.get('license_activated')?.value).toBe('true')
expect(isLicenseConfigExpired).toHaveBeenCalled()
})
test('本地许可证为空或验签失败时应返回未激活', async () => {
@ -51,4 +55,34 @@ describe('GET /api/license/check', () => {
expect(data.activated).toBe(false)
expect(response.cookies.get('license_activated')).toBeUndefined()
})
test('本地许可证在线租约过期时不应恢复激活 cookie', async () => {
isLicenseConfigExpired.mockReturnValue(true)
getLocalLicense.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',
lastVerified: new Date().toISOString(),
cachedConfig: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
leaseExpiresAt: new Date(Date.now() - 60_000).toISOString(),
},
})
const response = await GET(new NextRequest('http://localhost/api/license/check'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.activated).toBe(false)
expect(response.cookies.get('license_activated')).toBeUndefined()
})
})

View File

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLocalLicense } from '@/lib/license/license-service';
import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service';
export const dynamic = 'force-dynamic';
/**
* 检查数据库中是否有已激活的许可证
@ -8,7 +10,7 @@ import { getLocalLicense } from '@/lib/license/license-service';
export async function GET(request: NextRequest) {
try {
const license = await getLocalLicense();
if (!license) {
if (!license || isLicenseConfigExpired(license.cachedConfig)) {
return NextResponse.json({ activated: false });
}

View File

@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
import { runLicenseHeartbeat } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/license-service', () => ({
runLicenseHeartbeat: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockRunLicenseHeartbeat = runLicenseHeartbeat as jest.MockedFunction<typeof runLicenseHeartbeat>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
function createRequest() {
return new NextRequest('http://localhost:3000/api/license/heartbeat', {
method: 'POST',
})
}
describe('POST /api/license/heartbeat', () => {
beforeEach(() => {
jest.clearAllMocks()
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockRunLicenseHeartbeat.mockResolvedValue({ acknowledged: true })
})
it('已有最高权限账号后,未授权请求不能触发许可证心跳写入', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({
acknowledged: false,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
expect(mockRunLicenseHeartbeat).not.toHaveBeenCalled()
})
it('最高权限账号可触发服务端许可证心跳', async () => {
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ acknowledged: true })
expect(mockRunLicenseHeartbeat).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,65 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { getLocalLicense, updateLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { prisma } from '@/lib/prisma';
import type { HeartbeatResponse } from '@/lib/license/types';
import { NextRequest, NextResponse } from 'next/server'
import { runLicenseHeartbeat } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
export async function POST(request: NextRequest) {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ acknowledged: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// 获取使用统计
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
timestamp: Date.now(),
}),
});
// 检查是否被吊销
if (response.status === 403 || response.status === 404) {
console.log('[License] 心跳检测到许可证已被吊销');
await clearLocalLicense();
return NextResponse.json({
acknowledged: false,
revoked: true,
error: '许可证已被吊销'
});
}
const data: HeartbeatResponse = await response.json();
if (data.acknowledged && data.license) {
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
}
return NextResponse.json({
acknowledged: data.acknowledged || false
});
} catch (error) {
console.error('心跳发送失败:', error);
return NextResponse.json({
acknowledged: false,
error: '网络错误'
});
export async function POST(_request: NextRequest) {
const guard = await requireSuperAdminAfterInitialSetup()
if (!guard.allowed) {
return NextResponse.json(
{ acknowledged: false, error: guard.error },
{ status: guard.status }
)
}
}
const result = await runLicenseHeartbeat()
return NextResponse.json(result)
}

View File

@ -24,8 +24,9 @@ jest.mock('@/lib/prisma', () => ({
},
}))
jest.mock('@/lib/license', () => ({
getLicense: jest.fn(),
jest.mock('@/lib/license/license-service', () => ({
getLocalLicense: jest.fn(),
isLicenseConfigExpired: jest.fn(),
}))
import { GET } from '../route'
@ -33,11 +34,12 @@ import { GET } from '../route'
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
const { getLicense } = require('@/lib/license')
const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service')
const activeProLicense = {
type: 'PRO',
expiresAt: null,
leaseExpiresAt: '2026-07-25T00:00:00.000Z',
limits: {
maxUsers: 100,
maxMaterials: 1000,
@ -61,13 +63,13 @@ describe('GET /api/license/info', () => {
isCurrentSuperAdmin.mockResolvedValue(false)
prisma.user.count.mockResolvedValue(12)
prisma.material.count.mockResolvedValue(345)
prisma.systemConfig.findUnique.mockResolvedValue({
value: JSON.stringify({
licenseKey: 'FLNXI-PRO-SECRET-0001',
lastVerified: '2026-05-24T00:00:00.000Z',
}),
prisma.systemConfig.findUnique.mockResolvedValue(null)
getLocalLicense.mockResolvedValue({
licenseKey: 'FLNXI-PRO-SECRET-0001',
lastVerified: '2026-05-24T00:00:00.000Z',
cachedConfig: activeProLicense,
})
getLicense.mockReturnValue(activeProLicense)
isLicenseConfigExpired.mockReturnValue(false)
})
it('未登录不能读取许可证详情和使用规模', async () => {
@ -103,6 +105,7 @@ describe('GET /api/license/info', () => {
expect(data.licenseKey).toBe('FLNX****0001')
expect(data.usage.users.current).toBe(12)
expect(data.usage.materials.current).toBe(345)
expect(data.leaseExpiresAt).toBe('2026-07-25T00:00:00.000Z')
})
it('最高权限账号可以看到完整激活码', async () => {
@ -116,7 +119,7 @@ describe('GET /api/license/info', () => {
})
it('管理员在未激活时可读取未激活状态', async () => {
getLicense.mockReturnValue(null)
getLocalLicense.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
@ -126,4 +129,15 @@ describe('GET /api/license/info', () => {
expect(data.licenseKey).toBeNull()
expect(data.usage.users.current).toBe(12)
})
it('本地许可证时间状态异常时按未激活展示', async () => {
getLocalLicense.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.activated).toBe(false)
expect(data.type).toBe('FREE')
})
})

View File

@ -3,8 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isCurrentSuperAdmin } from '@/lib/admin-permissions';
import { getLicense } from '@/lib/license';
import type { LocalLicenseData } from '@/lib/license/types';
import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service';
function maskLicenseKey(licenseKey: string | null): string | null {
if (!licenseKey) return null;
@ -31,7 +30,10 @@ export async function GET() {
prisma.material.count(),
]);
const license = getLicense();
const localLicense = await getLocalLicense();
const license = localLicense && !isLicenseConfigExpired(localLicense.cachedConfig)
? localLicense.cachedConfig
: null;
// 如果没有许可证,返回未激活状态(而不是401错误)
if (!license) {
@ -39,6 +41,7 @@ export async function GET() {
type: 'FREE' as const,
status: 'active' as const,
expiresAt: null,
leaseExpiresAt: null,
licenseKey: null,
usage: {
users: {
@ -62,26 +65,19 @@ export async function GET() {
});
}
// 获取本地许可证数据
const localLicense = await prisma.systemConfig.findUnique({
where: { key: 'license' },
});
let lastVerified = new Date().toISOString();
let licenseKey: string | null = null;
if (localLicense) {
try {
const data = JSON.parse(localLicense.value) as LocalLicenseData;
lastVerified = data.lastVerified || lastVerified;
licenseKey = data.licenseKey || null;
} catch {
// ignore
}
lastVerified = localLicense.lastVerified || lastVerified;
licenseKey = localLicense.licenseKey || null;
}
// 判断状态
let status: 'active' | 'expired' | 'grace' = 'active';
if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
if (
(license.expiresAt && new Date(license.expiresAt) < new Date()) ||
(license.leaseExpiresAt && new Date(license.leaseExpiresAt) < new Date())
) {
status = 'expired';
}
@ -89,6 +85,7 @@ export async function GET() {
type: license.type,
status,
expiresAt: license.expiresAt,
leaseExpiresAt: license.leaseExpiresAt,
usage: {
users: {
current: userCount,

View File

@ -0,0 +1,97 @@
import { NextRequest } from 'next/server';
import { POST } from '../route';
import { updatePremiumModuleRuntimeStatus } from '@/lib/license/premium-module-service';
jest.mock('@/lib/license/premium-module-service', () => ({
PREMIUM_MODULE_IDS: {
PAID_CORE: 'paid-core',
},
updatePremiumModuleRuntimeStatus: jest.fn(),
}));
const mockUpdatePremiumModuleRuntimeStatus =
updatePremiumModuleRuntimeStatus as jest.MockedFunction<typeof updatePremiumModuleRuntimeStatus>;
function createRequest(body: Record<string, unknown>, token = 'a'.repeat(32)) {
return new NextRequest('http://localhost/api/license/premium-modules/runtime-status', {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
}
describe('POST /api/license/premium-modules/runtime-status', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.MODULE_RUNTIME_STATUS_TOKEN = 'a'.repeat(32);
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
afterEach(() => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
it('需要回写 token,避免公开接口被任意写入模块运行态', async () => {
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}, 'wrong-token'));
expect(response.status).toBe(401);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('token 未配置时默认关闭回写入口', async () => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(503);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('接受 Agent 上报的合法模块运行状态并持久化', async () => {
const lastSyncAt = new Date().toISOString();
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
}));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ success: true, updated: 1 });
expect(mockUpdatePremiumModuleRuntimeStatus).toHaveBeenCalledWith({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
lastError: undefined,
});
});
it('拒绝未知模块或非法状态', async () => {
const response = await POST(createRequest({
moduleId: 'unknown-module',
image: 'registry.test/flnxi/premium/unknown:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(400);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import {
PREMIUM_MODULE_IDS,
updatePremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatusValue,
} from '@/lib/license/premium-module-service';
const validModuleIds = new Set<string>(Object.values(PREMIUM_MODULE_IDS));
const validStatuses = new Set<PremiumModuleRuntimeStatusValue>([
'missing',
'pulling',
'running',
'unhealthy',
'expired',
'revoked',
]);
function getRuntimeStatusToken(): string {
return (
process.env.MODULE_RUNTIME_STATUS_TOKEN ||
process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN ||
''
).trim();
}
function readBearerToken(request: NextRequest): string {
const authorization = request.headers.get('authorization') || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice('bearer '.length).trim();
}
return (request.headers.get('x-module-status-token') || '').trim();
}
function normalizeRuntimeStatus(input: unknown): PremiumModuleRuntimeStatus | null {
if (!input || typeof input !== 'object') return null;
const raw = input as Record<string, unknown>;
const moduleId = typeof raw.moduleId === 'string' ? raw.moduleId.trim() : '';
const image = typeof raw.image === 'string' ? raw.image.trim() : '';
const digest = typeof raw.digest === 'string' && raw.digest.trim() ? raw.digest.trim() : null;
const status = typeof raw.status === 'string' ? raw.status.trim() : '';
const lastSyncAt = typeof raw.lastSyncAt === 'string' ? raw.lastSyncAt.trim() : '';
const lastError = typeof raw.lastError === 'string' && raw.lastError.trim()
? raw.lastError.trim()
: undefined;
if (!validModuleIds.has(moduleId)) return null;
if (!image) return null;
if (!validStatuses.has(status as PremiumModuleRuntimeStatusValue)) return null;
const lastSyncAtTime = Date.parse(lastSyncAt);
if (!lastSyncAt || Number.isNaN(lastSyncAtTime)) return null;
return {
moduleId,
image,
digest,
status: status as PremiumModuleRuntimeStatusValue,
lastSyncAt: new Date(lastSyncAtTime).toISOString(),
lastError,
};
}
export async function POST(request: NextRequest) {
const configuredToken = getRuntimeStatusToken();
if (!configuredToken || configuredToken.length < 32) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写 token 未配置' },
{ status: 503 }
);
}
if (readBearerToken(request) !== configuredToken) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写未授权' },
{ status: 401 }
);
}
const body = await request.json().catch(() => null);
const rawStatuses: unknown[] = Array.isArray(body?.statuses) ? body.statuses : [body];
const statuses = rawStatuses.map(normalizeRuntimeStatus);
if (statuses.length === 0 || statuses.some((status: PremiumModuleRuntimeStatus | null) => !status)) {
return NextResponse.json(
{ success: false, error: '模块运行状态参数无效' },
{ status: 400 }
);
}
for (const status of statuses as PremiumModuleRuntimeStatus[]) {
await updatePremiumModuleRuntimeStatus(status);
}
return NextResponse.json({
success: true,
updated: statuses.length,
});
}

View File

@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
import { runLicenseVerification } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/license-service', () => ({
runLicenseVerification: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockRunLicenseVerification = runLicenseVerification as jest.MockedFunction<typeof runLicenseVerification>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
function createRequest() {
return new NextRequest('http://localhost:3000/api/license/verify', {
method: 'POST',
})
}
describe('POST /api/license/verify', () => {
beforeEach(() => {
jest.clearAllMocks()
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockRunLicenseVerification.mockResolvedValue({ valid: true })
})
it('已有最高权限账号后,未授权请求不能触发许可证校验写入', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({
valid: false,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
expect(mockRunLicenseVerification).not.toHaveBeenCalled()
})
it('最高权限账号可触发服务端许可证校验', async () => {
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ valid: true })
expect(mockRunLicenseVerification).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,87 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { updateLocalLicense, getLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import type { VerifyResponse } from '@/lib/license/types';
import { NextRequest, NextResponse } from 'next/server'
import { runLicenseVerification } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
// 宽限期(7天)
const GRACE_PERIOD_DAYS = 7;
const GRACE_PERIOD_MS = GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000;
function isWithinGracePeriod(lastVerified: Date | string | null): boolean {
if (!lastVerified) return false;
const lastVerifiedDate = typeof lastVerified === 'string' ? new Date(lastVerified) : lastVerified;
const now = new Date();
return (now.getTime() - lastVerifiedDate.getTime()) < GRACE_PERIOD_MS;
}
export async function POST(request: NextRequest) {
try {
// 先检查本地缓存
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ valid: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
timestamp: Date.now(),
}),
});
const data: VerifyResponse = await response.json();
if (!data.valid || !data.license) {
// 检查是否是被吊销(禁用/删除/过期)
const isRevoked = response.status === 403 || response.status === 404;
if (isRevoked) {
// 许可证被吊销,清除本地缓存
console.log('[License] 许可证已被吊销:', data.error);
await clearLocalLicense();
return NextResponse.json({
valid: false,
error: data.error || '许可证已被吊销',
revoked: true
});
}
// 其他错误(如网络问题),检查宽限期
if (isWithinGracePeriod(localLicense.lastVerified)) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: data.error || '验证失败' });
}
// ✅ 在后端验证签名
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return NextResponse.json({ valid: false, error: '许可证签名验证失败' });
}
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
return NextResponse.json({ valid: true });
} catch (error) {
console.error('验证失败:', error);
// 网络错误时检查宽限期
const localLicense = await getLocalLicense();
if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: '网络错误' });
export async function POST(_request: NextRequest) {
const guard = await requireSuperAdminAfterInitialSetup()
if (!guard.allowed) {
return NextResponse.json(
{ valid: false, error: guard.error },
{ status: guard.status }
)
}
}
const result = await runLicenseVerification()
return NextResponse.json(result)
}

View File

@ -0,0 +1,48 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
material: {
findMany: jest.fn(),
},
},
}))
export {}
const { prisma } = require('@/lib/prisma')
const { GET } = require('../route')
describe('GET /api/material-rankings', () => {
beforeEach(() => {
jest.clearAllMocks()
prisma.$queryRaw
.mockResolvedValueOnce([{ id: 'download-1' }])
.mockResolvedValueOnce([{ id: 'like-1' }])
prisma.material.findMany.mockResolvedValue([{ id: 'premium-1' }])
})
it('读取排行榜时只查询正式表,不执行运行期建表维护', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
downloads: ['download-1'],
likes: ['like-1'],
premium: ['premium-1'],
})
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.material.findMany).toHaveBeenCalledWith({
where: {
isPremium: true,
},
select: {
id: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 10,
})
})
})

View File

@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
export const dynamic = 'force-dynamic'
@ -10,9 +9,6 @@ export const dynamic = 'force-dynamic'
*/
export async function GET() {
try {
// 确保下载日志表存在(第一次使用时自动创建)
await ensureMaterialDownloadLogsTable()
// 下载榜:直接从下载日志表统计,按下载次数降序,相同次数按最近下载时间降序
const downloadRankings = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT

View File

@ -0,0 +1,102 @@
import { GET } from '../route'
export {}
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
uploadTemplate: {
findMany: jest.fn(),
},
},
}))
jest.mock('@/lib/license', () => ({
canUseCustomTemplates: jest.fn(),
projectPublicTemplateOptionsWithPaidCore: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { canUseCustomTemplates } = require('@/lib/license')
const { projectPublicTemplateOptionsWithPaidCore } = require('@/lib/license')
describe('GET /api/material-types 权限与模板过滤', () => {
beforeEach(() => {
jest.resetAllMocks()
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: null }])
prisma.uploadTemplate.findMany.mockResolvedValue([])
canUseCustomTemplates.mockResolvedValue(true)
projectPublicTemplateOptionsWithPaidCore.mockImplementation(async ({ builtinMaterialTypes, customTemplates }: any) => ({
valid: true,
templates: [],
uploadTemplates: [],
materialTypes: [
...builtinMaterialTypes,
...customTemplates
.filter((template: any) => template.status === 'active' && !template.id.startsWith('builtin-'))
.map((template: any) => {
const rawName = String(template.name || '').trim()
const name = rawName.replace(/\s*素材模板\s*$/g, '').trim()
return name ? `template:${template.id}:${name}` : null
})
.filter(Boolean),
].sort(),
}))
})
it('未登录时拒绝读取素材类型', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
})
it('只返回 active 自定义模板,不回退暴露草稿或归档模板', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.uploadTemplate.findMany.mockResolvedValue([
{ id: 'custom-active', name: '客户模板素材模板', materialType: '客户模板', status: 'active' },
])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toContain('template:custom-active:客户模板')
expect(prisma.uploadTemplate.findMany).toHaveBeenCalledTimes(1)
expect(prisma.uploadTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: { id: true, name: true, materialType: true, status: true },
}))
expect(projectPublicTemplateOptionsWithPaidCore).toHaveBeenCalledWith(expect.objectContaining({
customTemplates: [
{ id: 'custom-active', name: '客户模板素材模板', materialType: '客户模板', status: 'active' },
],
}))
})
it('许可证不包含自定义模板时,不再把历史自定义模板作为素材类型返回', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
canUseCustomTemplates.mockResolvedValue(false)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toContain('3DMAX')
expect(data.every((item: string) => !item.startsWith('template:'))).toBe(true)
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
expect(projectPublicTemplateOptionsWithPaidCore).not.toHaveBeenCalled()
})
})

View File

@ -5,7 +5,10 @@
*/
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { canUseCustomTemplates, projectPublicTemplateOptionsWithPaidCore } from '@/lib/license'
import {
BUILTIN_TEMPLATE_KEYS,
BUILTIN_TEMPLATE_TYPES,
@ -14,11 +17,30 @@ import {
// 强制此 API 每次请求都在运行时执行,而不是在构建阶段预渲染成静态结果
export const dynamic = 'force-dynamic'
function noCacheJson(payload: unknown, status = 200) {
return NextResponse.json(payload, {
status,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
}
export async function GET() {
// 使用 console.error 确保日志输出到 stderr,在 Docker 中更容易被捕获
console.error('[material-types] ========== API CALLED ==========')
console.error('[material-types] Timestamp:', new Date().toISOString())
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
let enabledBuiltinTemplates: string[] = []
try {
const settings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
@ -34,99 +56,57 @@ export async function GET() {
console.error('[material-types] Failed to parse enabledBuiltinTemplates:', error)
}
const typeSet = new Set<string>()
BUILTIN_TEMPLATE_KEYS
const builtinMaterialTypes = BUILTIN_TEMPLATE_KEYS
.filter((key) => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
.forEach((key) => {
.map((key) => {
const mapped = BUILTIN_TEMPLATE_TYPES[key]
if (mapped) {
typeSet.add(mapped)
}
return typeof mapped === 'string' ? mapped.trim() : ''
})
.filter(Boolean)
// 查询所有自定义模板(排除内置模板,避免重复显示)
const customTemplatesAllowed = await canUseCustomTemplates()
let customTemplates: Array<{ id: string; name: string | null; materialType: string | null; status: string }> = []
try {
console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======')
console.error('[material-types] Querying active custom templates (excluding builtin)...')
customTemplates = await prisma.uploadTemplate.findMany({
where: {
status: 'active',
// 排除内置模板,内置模板的ID以 'builtin-' 开头
NOT: {
id: {
startsWith: 'builtin-'
}
}
},
select: { id: true, name: true, materialType: true, status: true },
})
console.error(`[material-types] ✓ Found ${customTemplates.length} active custom templates`)
if (customTemplates.length > 0) {
console.error('[material-types] Active templates:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
}
// 如果没有找到活跃的自定义模板,查询所有非内置模板用于调试
if (customTemplates.length === 0) {
console.error('[material-types] WARNING: No active custom templates found, querying all non-builtin templates for debugging...')
const allTemplates = await prisma.uploadTemplate.findMany({
where: {
NOT: {
id: {
startsWith: 'builtin-'
}
}
},
if (customTemplatesAllowed) {
try {
console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======')
console.error('[material-types] Querying custom template snapshot for paid-core projection...')
customTemplates = await prisma.uploadTemplate.findMany({
select: { id: true, name: true, materialType: true, status: true },
})
console.error(`[material-types] Total custom templates in DB: ${allTemplates.length}`)
if (allTemplates.length > 0) {
console.error('[material-types] All custom template statuses:', JSON.stringify(allTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
// 如果数据库中有模板但status不是'active',尝试查询所有非隐藏的自定义模板
const nonHiddenTemplates = allTemplates.filter(
(t) => t.status !== 'hidden' && t.status !== null
)
console.error(`[material-types] Non-hidden custom templates: ${nonHiddenTemplates.length}`)
if (nonHiddenTemplates.length > 0) {
console.error('[material-types] Using non-hidden custom templates instead of active templates')
customTemplates = nonHiddenTemplates
}
console.error(`[material-types] ✓ Found ${customTemplates.length} custom template rows`)
if (customTemplates.length > 0) {
console.error('[material-types] Template rows:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
}
} catch (error) {
console.error('[material-types] Error querying custom templates:', error)
console.error('[material-types] Error details:', JSON.stringify(error, null, 2))
// 继续执行,即使查询失败也返回内置类型
}
} catch (error) {
console.error('[material-types] Error querying custom templates:', error)
console.error('[material-types] Error details:', JSON.stringify(error, null, 2))
// 继续执行,即使查询失败也返回内置类型
} else {
console.error('[material-types] Custom templates disabled by current license')
}
// 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag
// 使用特殊前缀 "template:" 来标识这是自定义模板,避免与内置类型冲突
// 注意:不再使用 materialType,直接使用模板名称作为tag
// 去掉模板名称中的"素材模板"后缀,让tag按钮显示更简洁
let addedCount = 0
customTemplates.forEach((template) => {
let templateName = template.name?.trim()
if (!templateName) {
console.warn(`[material-types] Skipping template with empty name: ${template.id}`)
return
}
// 去掉"素材模板"后缀(包括可能的空格)
templateName = templateName.replace(/\s*素材模板\s*$/g, '').trim()
if (!templateName) {
// 如果去掉后缀后为空,使用原始名称
templateName = template.name?.trim() || `模板-${template.id.substring(0, 8)}`
}
// 使用 "template:模板ID:模板名称" 格式,前端可以解析出模板ID和名称
// 模板名称可能包含冒号,所以使用 split(':').slice(2).join(':') 来提取
const templateIdentifier = `template:${template.id}:${templateName}`
typeSet.add(templateIdentifier)
addedCount++
console.log(`[material-types] ✓ Added custom template: "${templateName}" (ID: ${template.id}, status: ${template.status})`)
if (!customTemplatesAllowed) {
const builtinOnly = Array.from(new Set(builtinMaterialTypes)).sort()
console.log(`[material-types] Final material types list (${builtinOnly.length} items):`, JSON.stringify(builtinOnly, null, 2))
return noCacheJson(builtinOnly)
}
const projected = await projectPublicTemplateOptionsWithPaidCore({
builtinMaterialTypes,
customTemplates,
})
console.log(`[material-types] ✓ Added ${addedCount} custom templates`)
const result = Array.from(typeSet).sort()
if (!projected.valid) {
return noCacheJson({
error: projected.error || '付费增强包无法生成素材类型列表',
errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED',
details: projected.details,
}, 503)
}
const result = projected.materialTypes
console.log(`[material-types] Final material types list (${result.length} items):`, JSON.stringify(result, null, 2))
console.log(`[material-types] Built-in types: ${result.filter(t => !t.startsWith('template:')).join(', ')}`)
const customTemplateNames = result.filter(t => t.startsWith('template:')).map(t => {
@ -137,14 +117,7 @@ export async function GET() {
console.log(`[material-types] Custom template identifiers: ${result.filter(t => t.startsWith('template:')).join(', ') || '(none)'}`)
console.log('[material-types] ====== END QUERYING CUSTOM TEMPLATES ======')
// 强制不缓存,确保每次请求都返回最新数据
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
return noCacheJson(result)
} catch (error) {
console.error('[material-types] ========== ERROR IN API ==========')
console.error('[material-types] Error fetching material types:', error)
@ -152,13 +125,7 @@ export async function GET() {
// 返回内置类型作为fallback(与首页 FALLBACK_MATERIAL_TYPES 保持一致,但去掉"素材模板"后缀)
const fallbackTypes = Array.from(new Set(Object.values(BUILTIN_TEMPLATE_TYPES)))
console.error('[material-types] Returning fallback types:', fallbackTypes)
return NextResponse.json(fallbackTypes, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
return noCacheJson(fallbackTypes)
}
}

View File

@ -10,7 +10,10 @@ import {
prepareMaterialData,
readAllFieldValues,
} from '@/lib/field-data-storage'
import { prepareMaterialMetadataWithPaidCore } from '@/lib/license'
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
import { isBuiltinTemplate } from '@/lib/template-validator'
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
@ -26,8 +29,19 @@ export async function GET(
)
}
const material = await prisma.material.findUnique({
where: { id: params.id },
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '账号不可用,请重新登录或联系管理员' },
{ status: 403 }
)
}
const material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
select: {
id: true,
userId: true,
@ -196,8 +210,19 @@ export async function PUT(
}
// 获取素材信息
const material = await prisma.material.findUnique({
where: { id: params.id }
const material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
@ -207,9 +232,9 @@ export async function PUT(
)
}
// 权限检查:只有素材上传者或管理员可以编辑
const userFromSession = session.user as any
if (material.userId !== userFromSession.id && userFromSession.role !== 'ADMIN') {
// 权限检查:本人、最高权限账号,或管理部门范围内的管理员可以编辑
const actor = await getMaterialPermissionActor(session)
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 }
@ -283,22 +308,42 @@ export async function PUT(
)
}
const { metadata: rawMetadata } = prepareMaterialData(
templateFieldsValidation.templateFields as Record<string, any>,
effectiveTemplateId,
undefined,
material.metadata
)
const metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
let metadata: string | null = null
if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId: effectiveTemplateId,
templateFields: templateFieldsValidation.templateFields as Record<string, unknown>,
existingMetadata: material.metadata,
})
if (!metadataResult.valid) {
return NextResponse.json(
{
error: metadataResult.error,
errorCode: metadataResult.errorCode,
details: metadataResult.details,
},
{ status: 400 }
)
}
metadata = metadataResult.metadata
} else {
const { metadata: rawMetadata } = prepareMaterialData(
templateFieldsValidation.templateFields as Record<string, any>,
effectiveTemplateId,
undefined,
material.metadata
)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
)
}
}
updateData.templateId = effectiveTemplateId
@ -339,7 +384,7 @@ export async function PUT(
})
if (changes.length > 0) {
console.log(`[MaterialUpdate] Material ${params.id} updated by ${userFromSession.id}:`, changes.join('; '))
console.log(`[MaterialUpdate] Material ${params.id} updated by ${actor!.id}:`, changes.join('; '))
// 记录详细修改日志到数据库
const changesObj: Record<string, { oldValue: any; newValue: any }> = {}
@ -358,7 +403,7 @@ export async function PUT(
if (Object.keys(changesObj).length > 0) {
await createMaterialLog({
materialId: params.id,
userId: userFromSession.id,
userId: actor!.id,
action: 'UPDATE',
changes: changesObj,
})

View File

@ -15,6 +15,12 @@ jest.mock('@/lib/prisma', () => ({
count: jest.fn(),
findMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
uploadField: {
findMany: jest.fn(),
},
@ -39,6 +45,11 @@ jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/license', () => ({
planDepartmentScopeWithPaidCore: jest.fn(),
planMaterialListQueryWithPaidCore: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
isS3Url: jest.fn(() => false),
@ -46,10 +57,39 @@ jest.mock('@/lib/s3', () => ({
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { planDepartmentScopeWithPaidCore, planMaterialListQueryWithPaidCore } = require('@/lib/license')
describe('GET /api/materials 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
planMaterialListQueryWithPaidCore.mockResolvedValue({
valid: true,
search: '',
standardSearchFields: [],
customSearchFieldKeys: [],
standardHomeFilters: [],
customHomeFilters: [],
moduleAvailable: true,
})
planDepartmentScopeWithPaidCore.mockResolvedValue({
valid: true,
scopedDepartments: ['设计部', '设计部-建模组'],
withinScope: true,
})
})
it('未登录时拒绝读取素材列表', async () => {
@ -83,6 +123,115 @@ describe('GET /api/materials 权限边界', () => {
expect(prisma.material.findMany).toHaveBeenCalled()
})
it('禁用用户即使带着旧会话也不能读取素材列表', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.count).not.toHaveBeenCalled()
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
it('普通用户不能直接读取回收站素材列表', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
const response = await GET(new NextRequest('http://localhost/api/materials?showDeleted=true&pageSize=1'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权限查看回收站')
expect(prisma.material.count).not.toHaveBeenCalled()
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
it('普通管理员读取素材列表时只返回自己和管理部门范围内的素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'manager-1', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValueOnce({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
prisma.uploadField.findMany.mockResolvedValue([])
prisma.fieldDefinition.findMany.mockResolvedValue([])
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
expect(response.status).toBe(200)
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
expect.arrayContaining([
{ deletedAt: null },
expect.objectContaining({
OR: expect.arrayContaining([
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: ['设计部', '设计部-建模组'] } } },
{ user: { department: { startsWith: '设计部-' } } },
]),
}),
])
)
})
it('普通管理员读取回收站时同样只返回管理范围内的素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'manager-1', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValueOnce({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
prisma.uploadField.findMany.mockResolvedValue([])
prisma.fieldDefinition.findMany.mockResolvedValue([])
const response = await GET(new NextRequest('http://localhost/api/materials?showDeleted=true&pageSize=1'))
expect(response.status).toBe(200)
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
expect.arrayContaining([
{ deletedAt: { not: null } },
expect.objectContaining({
OR: expect.arrayContaining([
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: ['设计部', '设计部-建模组'] } } },
{ user: { department: { startsWith: '设计部-' } } },
]),
}),
])
)
})
it('搜索时会把可搜索自定义字段纳入查询', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
@ -91,6 +240,15 @@ describe('GET /api/materials 权限边界', () => {
{ key: 'customCode' },
{ key: 'productModel' },
])
planMaterialListQueryWithPaidCore.mockResolvedValue({
valid: true,
search: 'CODE-001',
standardSearchFields: ['productModel', 'brandName'],
customSearchFieldKeys: ['customCode'],
standardHomeFilters: [],
customHomeFilters: [],
moduleAvailable: true,
})
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
prisma.fieldDefinition.findMany.mockResolvedValue([])
prisma.material.count.mockResolvedValue(1)
@ -103,6 +261,15 @@ describe('GET /api/materials 权限边界', () => {
where: { isSearchable: true },
select: { key: true },
})
expect(planMaterialListQueryWithPaidCore).toHaveBeenCalledWith({
search: 'CODE-001',
searchableFields: [
{ key: 'customCode' },
{ key: 'productModel' },
],
homeFieldFilters: [],
enabledHomeFilterFields: [],
})
expect(prisma.$queryRaw).toHaveBeenCalled()
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
expect.arrayContaining([
@ -118,6 +285,15 @@ describe('GET /api/materials 权限边界', () => {
user: { id: 'user-1', role: 'USER' },
})
prisma.fieldDefinition.findMany.mockResolvedValue([{ key: 'customCode' }])
planMaterialListQueryWithPaidCore.mockResolvedValueOnce({
valid: true,
search: '',
standardSearchFields: [],
customSearchFieldKeys: [],
standardHomeFilters: [],
customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }],
moduleAvailable: true,
})
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
prisma.material.count.mockResolvedValue(1)
prisma.material.findMany.mockResolvedValue([])

View File

@ -14,8 +14,12 @@ jest.mock('@/lib/prisma', () => ({
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
@ -41,6 +45,7 @@ jest.mock('@/lib/image-processor', () => ({
jest.mock('@/lib/audio-processor', () => ({
parseAudioMetadata: jest.fn(),
parseAudioMetadataFromFile: jest.fn(),
isAudioFile: jest.fn(() => false),
}))
@ -51,15 +56,17 @@ jest.mock('@/lib/license', () => ({
jest.mock('@/lib/template-validator', () => ({
validateTemplateId: jest.fn(),
getTemplateWithFields: jest.fn(),
isBuiltinTemplate: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { createMaterialLog } = require('@/lib/material-log')
const { uploadToS3, uploadStreamToS3 } = require('@/lib/s3')
const { parseAudioMetadata, isAudioFile } = require('@/lib/audio-processor')
const { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } = require('@/lib/audio-processor')
const { canUploadMaterial } = require('@/lib/license')
const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator')
const { validateTemplateId, getTemplateWithFields, isBuiltinTemplate } = require('@/lib/template-validator')
const { processImage, getProcessedFileName, sliceImage } = require('@/lib/image-processor')
import { POST as uploadMaterial } from '../upload/route'
import { PUT as updateMaterial } from '../update/route'
@ -120,6 +127,46 @@ function createMockRequest(
} as any
}
async function createMultipartUploadRequest(
fields: Record<string, string>,
files: Record<string, File | File[]>
) {
const boundary = `----flnxi-test-${Date.now()}`
const chunks: Buffer[] = []
const append = (value: string | Buffer) => {
chunks.push(typeof value === 'string' ? Buffer.from(value) : value)
}
Object.entries(fields).forEach(([key, value]) => {
append(`--${boundary}\r\n`)
append(`Content-Disposition: form-data; name="${key}"\r\n\r\n`)
append(`${value}\r\n`)
})
for (const [key, fileOrFiles] of Object.entries(files)) {
const fileList = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]
for (const file of fileList) {
append(`--${boundary}\r\n`)
append(`Content-Disposition: form-data; name="${key}"; filename="${file.name}"\r\n`)
append(`Content-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`)
append(Buffer.from(await file.arrayBuffer()))
append('\r\n')
}
}
append(`--${boundary}--\r\n`)
const body = Buffer.concat(chunks)
return new NextRequest('http://localhost/api/materials/upload', {
method: 'POST',
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(body.length),
},
body,
})
}
function buildUploadRequest(overrides: Partial<Record<string, MockFormDataValue>> = {}) {
return createMockRequest({
productModel: '稳定材质',
@ -158,11 +205,28 @@ describe('素材模板字段链路测试', () => {
allowed: true,
})
prisma.user.findUnique.mockResolvedValue({
username: '上传者A',
prisma.user.findUnique.mockImplementation(async ({ where }: any = {}) => {
const id = where?.id || 'admin-1'
const isAdmin = id === 'admin-1'
return {
id,
role: isAdmin ? 'ADMIN' : 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: isAdmin ? '设计部' : '运营部',
managedDepartment: isAdmin ? '设计部' : null,
username: '上传者A',
}
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
prisma.material.findUnique.mockResolvedValue(null)
prisma.material.findFirst.mockResolvedValue(null)
prisma.material.create.mockImplementation(async ({ data }: any) => ({
id: data.id,
@ -184,10 +248,13 @@ describe('素材模板字段链路测试', () => {
createMaterialLog.mockResolvedValue(undefined)
getProcessedFileName.mockImplementation((name: string) => name)
uploadToS3.mockResolvedValue('s3://bucket/buffer-upload')
uploadStreamToS3.mockResolvedValue('s3://bucket/stream-upload')
parseAudioMetadata.mockResolvedValue(null)
parseAudioMetadataFromFile.mockResolvedValue(null)
isAudioFile.mockImplementation((mimeType: string) => mimeType.startsWith('audio/'))
isBuiltinTemplate.mockReturnValue(true)
validateTemplateId.mockResolvedValue({
valid: true,
@ -221,7 +288,15 @@ describe('素材模板字段链路测试', () => {
expect(response.status).toBe(400)
expect(data.errorCode).toBe('TEMPLATE_REQUIRED_FIELDS_MISSING')
expect(prisma.material.create).not.toHaveBeenCalled()
expect(validateTemplateId).not.toHaveBeenCalled()
expect(validateTemplateId).toHaveBeenCalledWith(
'template-1',
expect.objectContaining({
checkStatus: true,
logPerformance: true,
})
)
expect(uploadToS3).not.toHaveBeenCalled()
expect(uploadStreamToS3).not.toHaveBeenCalled()
})
test('上传成功时应写入 templateId 和自定义字段 metadata', async () => {
@ -256,6 +331,117 @@ describe('素材模板字段链路测试', () => {
expect(data.material.zipFileSize).toBe('0')
})
test('模板不可用时应在上传文件前拒绝', async () => {
const zipFile = createMockFile('inactive-template.zip', 'application/zip', 'zip-data')
validateTemplateId.mockResolvedValueOnce({
valid: false,
error: '模板已停用',
errorCode: 'TEMPLATE_INACTIVE',
templateStatus: 'inactive',
})
const response = await uploadMaterial(
buildUploadRequest({
zipFile,
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-INACTIVE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('模板已停用')
expect(data.errorCode).toBe('TEMPLATE_INACTIVE')
expect(getTemplateWithFields).not.toHaveBeenCalled()
expect(uploadToS3).not.toHaveBeenCalled()
expect(uploadStreamToS3).not.toHaveBeenCalled()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('预览图处理失败时不向前端暴露底层错误细节', async () => {
processImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/source.png'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: '3DMAX',
categoryName: '空间',
sceneName: '客厅',
previewImage: createMockFile('preview.png', 'image/png', 'preview-data'),
zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-PREVIEW',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('预览图处理失败,请检查图片格式后重试')
expect(data.error).not.toContain('sharp')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('PSD展示图处理失败时不向前端暴露底层错误细节', async () => {
sliceImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/display.png'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: 'PSD',
categoryName: '空间',
sceneName: '客厅',
displayImage: createMockFile('display.png', 'image/png', 'display-data'),
zipFile: createMockFile('source.psd', 'application/octet-stream', 'psd-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-PSD',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('PSD展示图片处理失败,请检查图片格式后重试')
expect(data.error).not.toContain('sharp')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('原始文件保存失败时不向前端暴露底层错误细节', async () => {
processImage.mockResolvedValueOnce(Buffer.from('processed-preview'))
uploadStreamToS3.mockRejectedValueOnce(new Error('s3 secret path /private/bucket/source.zip'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: '3DMAX',
categoryName: '空间',
sceneName: '客厅',
previewImage: createMockFile('preview.png', 'image/png', 'preview-data'),
zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-FILE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('文件保存失败,请检查存储配置后重试')
expect(data.error).not.toContain('private')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('非音频原始素材上传应走流式上传,避免二次整文件 Buffer', async () => {
const zipFile = createMockFile('model.zip', 'application/zip', 'zip-data')
@ -285,40 +471,48 @@ describe('素材模板字段链路测试', () => {
expect(data.material.zipFileSize).toBe(String(zipFile.size))
})
test('音频原始素材暂时保留 Buffer 上传以解析时长', async () => {
test('音频原始素材应走临时文件流式上传并从文件解析时长', async () => {
const audioFile = createMockFile('demo.mp3', 'audio/mpeg', 'audio-data')
parseAudioMetadata.mockResolvedValueOnce({
parseAudioMetadataFromFile.mockResolvedValueOnce({
duration: 65,
durationFormatted: '01:05',
format: 'mp3',
})
const response = await uploadMaterial(
buildUploadRequest({
materialType: '音频',
materialKind: 'AUDIO',
categoryName: '音效',
sceneName: '提示音',
zipFile: audioFile,
templateFields: JSON.stringify({
await createMultipartUploadRequest(
{
productModel: '稳定材质',
customCode: 'CODE-AUDIO',
}),
})
materialType: '音频',
materialKind: 'AUDIO',
categoryName: '音效',
sceneName: '提示音',
templateId: 'template-1',
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-AUDIO',
}),
},
{
zipFile: audioFile,
}
)
)
expect(response.status).toBe(201)
expect(uploadStreamToS3).not.toHaveBeenCalled()
expect(uploadToS3).toHaveBeenCalledWith(
expect.any(Buffer),
expect(uploadStreamToS3).toHaveBeenCalledWith(
expect.any(Object),
expect.stringMatching(/^material-.+\.mp3$/),
'audio/mpeg'
'audio/mpeg',
audioFile.size
)
expect(parseAudioMetadata).toHaveBeenCalledWith(expect.any(Buffer), 'audio/mpeg')
expect(uploadToS3).not.toHaveBeenCalled()
expect(parseAudioMetadataFromFile).toHaveBeenCalledWith(expect.stringContaining('flnxi-upload-'))
expect(parseAudioMetadata).not.toHaveBeenCalled()
const createArgs = prisma.material.create.mock.calls[0][0]
expect(createArgs.data.audioDuration).toBe('01:05')
expect(createArgs.data.audioUrl).toBe('s3://bucket/buffer-upload')
expect(createArgs.data.audioUrl).toBe('s3://bucket/stream-upload')
expect(createArgs.data.zipFileSize).toBe(BigInt(audioFile.size))
})
@ -393,9 +587,41 @@ describe('素材模板字段链路测试', () => {
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('软删除素材不能通过旧表单更新入口继续编辑', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await updateMaterial(
buildUpdateRequest({
id: 'deleted-material',
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'NOPE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -452,7 +678,7 @@ describe('素材模板字段链路测试', () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'owner-1', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
@ -503,10 +729,14 @@ describe('素材模板字段链路测试', () => {
)
})
test('管理员可以通过表单编辑别人上传的素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -559,7 +789,7 @@ describe('素材模板字段链路测试', () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
@ -600,8 +830,13 @@ describe('素材模板字段链路测试', () => {
})
test('编辑时缺失模板必填字段应拒绝更新', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -644,8 +879,13 @@ describe('素材模板字段链路测试', () => {
test('编辑时条件不满足的必填字段缺失应允许通过且保留历史隐藏字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -688,8 +928,13 @@ describe('素材模板字段链路测试', () => {
})
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -741,11 +986,11 @@ describe('素材模板字段链路测试', () => {
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('读取素材详情时应回传 metadata 中的模板字段', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'admin-1',
productModel: '已保存产品',
@ -806,6 +1051,55 @@ describe('素材模板字段链路测试', () => {
legacyKey: 'legacy-value',
})
expect(data.zipFileSize).toBe('16')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'material-1',
deletedAt: null,
},
})
)
})
test('禁用用户即使带着旧会话也不能读取素材详情', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
id: 'disabled-user',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'material-1' },
})
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('软删除素材详情不应通过旧 ID 入口返回', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'deleted-material' },
})
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
})
})
@ -861,7 +1155,7 @@ describe('素材模板字段链路测试', () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'owner-1', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
})
@ -889,10 +1183,14 @@ describe('素材模板字段链路测试', () => {
)
})
test('旧 JSON 更新入口允许管理员编辑别人上传的素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
test('旧 JSON 更新入口允许管理员编辑管理部门范围内的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
})
const response = await updateMaterialById(
@ -918,11 +1216,76 @@ describe('素材模板字段链路测试', () => {
)
})
test('旧 JSON 更新入口拒绝没有管理部门的普通 ADMIN 编辑别人素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'admin-no-scope', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValueOnce({
id: 'admin-no-scope',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-NO-SCOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口拒绝普通管理员编辑管理范围外素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '运营部',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-NOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(prisma.material.update).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口拒绝非上传者普通用户编辑别人的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
})
@ -946,7 +1309,7 @@ describe('素材模板字段链路测试', () => {
test('旧 JSON 更新入口也应执行条件必填校验,不能绕过模板规则', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findUnique.mockResolvedValueOnce(existingMaterialForJsonUpdate)
prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate)
const response = await updateMaterialById(
buildJsonUpdateRequest({
@ -965,7 +1328,7 @@ describe('素材模板字段链路测试', () => {
test('旧 JSON 更新入口应过滤条件不满足的隐藏字段并保留历史字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findUnique.mockResolvedValueOnce(existingMaterialForJsonUpdate)
prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate)
const response = await updateMaterialById(
buildJsonUpdateRequest({
@ -999,6 +1362,34 @@ describe('素材模板字段链路测试', () => {
expect(response.status).toBe(400)
expect(data.errorCode).toBe('INVALID_TEMPLATE_FIELDS')
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(prisma.material.findFirst).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口不应修改回收站里的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'DELETED-NOPE',
},
}),
{ params: { id: 'deleted-material' } }
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
expect(prisma.material.update).not.toHaveBeenCalled()
})
})

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