gongxiangp2/CLAUDE.md

929 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 提供项目代码工作的指导说明。
## 项目概述
**由壹点3D素材** - 一个具有 Apple 风格 UI 设计的现代化 3D 素材共享平台。
**技术栈:**
- Next.js 14 (App Router) + React 18 + TypeScript
- NextAuth.js 身份认证(凭证登录 + 钉钉 OAuth)
- PostgreSQL + Prisma ORM
- MinIO S3(预览图)+ 本地存储(压缩包)
- Redis(上传会话管理)
- TailwindCSS 样式
## 开发命令
```bash
# 开发环境
npm run dev # 在 :3000 端口启动开发服务器
# 构建与部署
npm run build # 生成 Prisma 客户端 + Next.js 构建
npm start # 启动生产服务器
# 数据库
npm run prisma:generate # 生成 Prisma 客户端
npm run prisma:push # 推送数据库结构
npm run prisma:studio # 打开 Prisma Studio
# 测试
npm test # 运行 Jest 测试
npm run test:watch # 监视模式
npm run test:coverage # 覆盖率报告
# 代码检查
npm run lint # ESLint
```
## 网络架构
### 生产基础设施
**物理服务器:**
- 生产服务器:`192.168.58.20`
- 构建服务器:`192.168.40.206`(部署脚本内的固定值;旧文档中的 `192.168.42.134` 已不是部署入口)
- 公网入口服务器:`218.93.9.38`(FRP 入口 + Nginx 反向代理;2026-10-06 核实,原香港服务器 `103.118.245.6` 已下线)
- 上游报表库:`218.93.9.21`(SQL Server 2019,人事与组织架构数据源)
**公网访问架构:**
- 域名:`sc.jiansun.vip`(素材平台)、`s3.jiansun.vip`(MinIO)
- 公网入口:218.93.9.38(生产机 frpc 配置自 2026-08-02 起指向此地址)
- FRP 服务:`frps-sucai`(端口 7003,只放行远端端口 2321-2326)。同机 7002 是 furniture 项目的 frps,别混用
- 生产服务器通过 FRPC 连接到入口服务器:配置 `/etc/frp/frpc-*-entry.toml`,日志 `/root/frp/frpc-*-entry.log`
- Nginx 反向代理:sc.jiansun.vip → 127.0.0.1:2321,s3.jiansun.vip → 127.0.0.1:2323
**Docker 网络:**
- 网络名称:`youyidian-3d_app-network`
- 驱动类型:bridge
- 连接的容器:
- `youyidian-3d-material`(生产应用)
- `gongxiangpan-redis`(生产 Redis)
- `youyidian-3d-shadow`(影子环境应用)
### ⚠️ 生产实际部署形态(2026-08-14 现场核实,与旧文档不一致,以此为准)
CLAUDE.md 旧版记的容器名/端口映射与线上对不上,实际是**蓝绿部署 + 独立 nginx 反代**:
- **应用容器采用蓝绿双实例**:`youyidian-3d-material-blue` / `youyidian-3d-material-green`,同一时刻一个接流量、另一个待命回退。容器**不直接映射宿主机端口**(`3000/tcp` 不外露)。
- **流量入口是一个独立的 local-proxy nginx**(不是 TrueNAS 系统自带的那个),监听宿主机 `4320`:
- 配置路径:`/mnt/PoolOne/apps/youyidian-3d/local-proxy/youyidian-3d-material/nginx.conf`
- 通过 `upstream` 里的容器内网 IP(如 `172.19.0.3:3000`)指向当前生效实例
- **切换蓝绿 = 改这个 `upstream` IP + `nginx -s reload`**,`current-container` 文件记录当前指向
- 应用挂载:`/mnt/PoolOne/apps/youyidian-3d/uploads:/app/uploads`(uploads 实际路径,注意**不是**部署脚本默认的 `/mnt/PoolOne/youyidian-uploads`)
- 应用连的数据库是 `192.168.58.20:5432/lobechat`(宿主机 5432 由 `gongxiangpan-postgres-1` 映射)
### ⚠️ 大文件分片上传 500 的一个基础设施坑(2026-08-14 已修)
**现象**:视频等大文件分片上传,chunk 0 就返回 500,前端报"上传块 0 失败,已重试多次"或"分片合并文件必须同时提供 uploadId",但**应用容器日志里查不到该 chunk 请求**——因为请求在 nginx 层就被拦下了。
**根因**:local-proxy nginx(以 `nobody` 运行)和 TrueNAS 系统 nginx(以 `www-data` 运行)**共用同一个临时缓冲目录 `/var/lib/nginx/body`**。当系统 nginx 重建该目录并锁成 `www-data drwx------` 后,`nobody` 无法写入,10MB 的分片缓冲失败,nginx 直接回 500。`nginx -s reload` 或系统 nginx 重启都可能触发。
**修复**:给 local-proxy 配独立临时目录,不再和系统 nginx 抢。在 `nginx.conf` 的 `http {}` 块内加:
```
client_body_temp_path <proxy_dir>/tmp/client_body;
proxy_temp_path <proxy_dir>/tmp/proxy;
fastcgi_temp_path <proxy_dir>/tmp/fastcgi;
uwsgi_temp_path <proxy_dir>/tmp/uwsgi;
scgi_temp_path <proxy_dir>/tmp/scgi;
```
并 `mkdir -p` 这些目录、`chown -R nobody:nogroup`、`chmod 700`,再 reload。诊断时先看 `local-proxy/.../error.log` 有没有 `open() "..." failed (13: Permission denied)`。
### ⚠️ 公网 502 排查顺序(2026-10-06 事故)
**现象**:`sc.jiansun.vip` 整站返回 nginx 502。
**本次是两层原因叠加**:
1. 应用 / Redis / Postgres 三个容器从 9-30 起处于停止状态(退出码 0,不是崩溃,停止原因未查明),10-03 服务器重启后也没有被拉起
2. 10-03 下午起公司防火墙拦截了生产机出网,FRP 隧道连不上入口机。第 1 层修好后公网仍是 502,防火墙放开后才恢复
**排查顺序**(登录生产机执行):
1. `docker ps -a` 看应用容器(以 `current-container` 文件为准)、`gongxiangpan-redis`、`gongxiangpan-postgres-1` 是否在跑。停了就按 Postgres → Redis → 应用的顺序 `docker start`
2. `curl http://127.0.0.1:4320/api/health`:返回 200 说明应用和本地反代都正常,问题在公网链路
3. 看 `/root/frp/frpc-sc-entry.log`:如果满屏 `dial tcp 218.93.9.38:7003: i/o timeout`,再从生产机访问任意外网地址,全部超时就是出网被拦。这时找网管查公司防火墙,不用动应用
### 上游报表库(人事与组织架构数据源)
⚠️ **重要认知**:花名册**不是**从钉钉通讯录来的。钉钉在本项目中只负责登录鉴权与消息推送,从未调用过通讯录接口。人事权威源是公司报表库。
**连接信息(只读账号,配置在 `.env`,勿写入仓库):**
- Server:`218.93.9.21`
- Database:`JSReportReplica`
- Schema:`rpt`(只读)
- 环境变量:`REPORT_DB_SERVER` / `REPORT_DB_DATABASE` / `REPORT_DB_USER` / `REPORT_DB_PASSWORD`
⚠️ 密码含 `#` 等特殊字符时,在 `.env` 中**必须加引号**,否则 `#` 之后会被当作注释截断,表现为登录失败。
**关键表:**
| 表 | 行数量级 | 用途 |
|---|---|---|
| `rpt.hr_groups` | 1075 | 组织架构树。仅 `Id` / `parent_id` / `name` 三列可用,无状态位、无排序、无编码。单一根节点(Id=1),零孤儿节点 |
| `rpt.hr_employee` | 1296 | 钉钉员工表,含 `manager_userid`、`dept_id_list`、`active` |
| `rpt.hr_employee_register` | 1296 | 入职登记,含 `sys01-employeeStatus` 状态码、`sys00-flowerName` 花名 |
| `rpt.hr_flowerName_book` | — | 完整员工档案(含社保、银行卡等敏感信息,谨慎查询) |
| `rpt.hr_employee_dimission` | 4711 | **离职表**,含 `last_work_day`、`handover_userid` 交接人 |
**员工状态码:`2` 试用、`3` 正式、`5` 已离职。**注意上游还存在中文值(正式/离职/试用)与空值,口径混杂,不可用关键词匹配判断在职。
---
## 人员架构与组织同步(2026-08-13 重构)
### 核心原则
**钉钉唯一值是全项目唯一的人员身份判定依据。** 姓名、花名、邮箱只能用于人工排错,不得自动建立绑定。
### 数据模型
| 模型 | 作用 |
|---|---|
| `UserEmployeeBinding` | 素材库账号 ↔ 权威人员档案的正式绑定。活跃绑定双向唯一(部分唯一索引,仅约束 `status='ACTIVE'`) |
| `OrganizationUnit` | 共享组织架构,直接镜像 `hr_groups`。上游删除的部门置 `isActive=false` 而非物理删除 |
| `PerformanceSurveyEmployeeProfile` | 事实上的平台级人员主档(表名仍带历史前缀)。新增 `employmentState`、`resignedAt`、`handoverDingtalkUserId` |
`users.dingtalkUserId` 已加唯一约束,数据库层面杜绝一人多号。
### 关键文件
| 文件 | 用途 |
|---|---|
| `lib/employee-directory.ts` | **通用人员目录服务**。上层统一通过它访问人员数据,不直接读带 `PerformanceSurvey` 前缀的模型 |
| `lib/organization-sync.ts` | 组织架构同步、在职状态校正、离职联动、每日定时任务 |
| `lib/material-transfer.ts` | 素材正式转移与部门托管 |
### 在职状态判定
**不要用关键词匹配 `employmentStatus`**(上游原样值,口径混杂)。使用规范化的 `employmentState`:
- `ACTIVE` / `RESIGNED` / `UNKNOWN`
- `UNKNOWN` 视为在职——上游查不到明确状态的人宁可多留,误判导致"人凭空消失"比"多出几个人"难发现
- 判定条件统一走 `getActivePerformanceSurveyEmployeeProfileWhere()` 或 `getActiveEmployeeWhere()`
### 离职联动
上游标记离职后自动执行:归档素材库账号 + 把素材托管给所在部门。
- 人事已确认花名册标记离职即为事实离职:**离职即托管+归档,无天数缓冲**(`RESIGNATION_MIN_DAYS` 已废弃)
- 离职日期缺失者同样处理(日期仅作展示,不作门槛)
- 超级管理员账号不由自动流程处理
### 素材归属与署名分离
**这是刻意的设计,不是数据不一致:**
- `Material.userId`(归属)+ `authorName`(署名):素材是谁做的,永远记在谁名下
- `Material.custodyDepartmentId`(托管部门):人离职后由部门接手维护,**不改 userId 与 authorName**
- 首页卡片显示的作者读的是 `user.realName || username`,托管不影响显示
### 权限组绑定花名册身份与团队职能(2026-10-08)
- 权限组的部门关联已从"路径字符串"改为绑定 `OrganizationUnit.id` 外键(迁移 `20261008_permission_group_department_identity`,生产 17 条已回填)。`departmentPath` 降级为失效关联的展示快照;**鉴权一律用绑定部门的当前有效路径**;一个部门只属一个组(DB 唯一索引),一人多部门取各任职权限组的并集;删除"按名称自动挂默认组"的旧逻辑。
- 品牌中心"团队职能"已数据库化:`TeamFunctionConfig`(迁移 `20261008_team_function_configs`)每组织单元一条,负责人 = 花名册员工 profile ID,branches JSON,revision + 审计表。API `app/api/organization/team-functions`(GET 品牌中心可读、PUT 仅超管),UI `TeamFunctionsDashboard.tsx`,初始内容由 `scripts/seed-team-function-config.ts` 从 `data/team-function-initial-content.json` 导入。
- 权限组与用户权限管理界面已合并简化(`PermissionGroupsDashboard.tsx`、`UserPermissionsDashboard.tsx`)。
### 定时同步
`ensureDailyOrganizationSyncStarted()` 在考核调研上下文中懒启动,每 24 小时执行:组织架构 → 在职状态 → 离职联动。
⚠️ **生产容器必须配置 `REPORT_DB_*` 环境变量**,否则同步连不上上游,人员档案会退化成用素材库信息拼的兜底假档案(无工号、无职级、`isLeadership=false`)。
### 运维脚本
```bash
# 人员架构盘点(纯只读,可重复执行)
npx tsx scripts/diagnose-people-architecture.ts
# 组织架构与在职状态同步(默认预演,--apply 才写入)
npx tsx --env-file=.env scripts/sync-organization.ts
npx tsx --env-file=.env scripts/sync-organization.ts --apply
# 离职人员素材部门托管
npx tsx scripts/custody-archived-user-materials.ts
```
---
### 数据库配置
**PostgreSQL (192.168.58.20:5432):**
- 数据库用户:`gongxiangpan`
- **生产数据库**:`lobechat`(5918 条素材记录)✅
- 影子环境数据库:`lobechat_stage`(4051 条素材记录)
- 默认数据库:`gongxiangpan`(旧系统,仅 4 个表,未使用)
⚠️ **重要提示**:生产数据库名称是 `lobechat`,不是 `gongxiangpan`(那只是 PostgreSQL 用户名)
### Redis 配置
⚠️ **【重要】Redis 连接依赖 Docker 网络配置**
应用容器**必须**连接到 `youyidian-3d_app-network` 网络才能访问 Redis。如果 Redis 连接失败,会导致:
- 上传分片无法保存,上传到 100% 后循环重传
- 会话无法持久化,用户频繁掉线
**正确的部署命令必须包含 `--network` 参数:**
```bash
docker run -d --name youyidian-3d-material \
--network youyidian-3d_app-network \ # ⚠️ 必须指定网络!
-p 4320:3000 \
...
```
**诊断 Redis 连接问题:**
```bash
# 1. 检查容器是否在正确网络
docker network inspect youyidian-3d_app-network | grep youyidian-3d-material
# 2. 检查 Redis 连接
docker exec youyidian-3d-material sh -c "ping -c 2 gongxiangpan-redis"
# 3. 检查容器日志中的 Redis 状态
docker logs youyidian-3d-material 2>&1 | grep -i redis | tail -5
# 4. 手动连接到网络(如果遗漏)
docker network connect youyidian-3d_app-network youyidian-3d-material
docker restart youyidian-3d-material
```
**生产环境 Redis:**
- 容器名称:`gongxiangpan-redis`
- 连接地址:`redis://gongxiangpan-redis:6379`(Docker 网络内)
- 环境变量:`USE_REDIS=true`、`REDIS_URL=redis://gongxiangpan-redis:6379`
- 会话 TTL:4 小时(14400 秒)
- 内存限制:128MB(maxmemory-policy: allkeys-lru)
- 无需密码
✅ **已验证**:生产环境正在使用 Redis 存储上传会话(不是内存 Map 兜底方案)
- 验证方式:在 Redis 中找到 3 个活跃的上传会话
- 数据格式:`{"fileName": "...", "fileSize": N, "totalChunks": N, "chunks": [...], "createdAt": N, "userId": "..."}`
**开发环境 Redis:**
- 连接地址:`redis://localhost:6379`
- 兜底方案:内存 Map(Redis 不可用时)
- 启用方式:通过 `USE_REDIS=true` 环境变量
### MinIO/S3 存储配置
**MinIO 服务器 (192.168.58.20:9000):**
- 访问密钥:由服务器环境变量 `S3_ACCESS_KEY_ID` 提供
- 秘密密钥:由服务器环境变量 `S3_SECRET_ACCESS_KEY` 提供
- 存储桶:`gongxiangpan`
- 文件路径:`由壹点AI/`
**公网访问域名:**
- URL:`https://s3.jiansun.vip/gongxiangpan`
- 展示缓存前缀:`/gongxiangpan/public-display/`
- 预览链接过期时间:7200 秒(2 小时)
- 路径样式:已启用
### 上传配置
**当前设置(截至 2026-01-27):**
- 分片大小:50MB(内网/外网统一)
- 并发数:2-3 个并发上传
- 会话 TTL:4 小时(Redis)
- 超时时间:每个分片 5-10 分钟
### 容器端口映射
| 容器 | 内部端口 | 外部端口 | 用途 |
|-----------|--------------|---------------|---------|
| youyidian-3d-material | 3000 | 4320 | 生产应用 |
| youyidian-3d-shadow | 3000 | 5320 | 影子环境应用 |
| gongxiangpan-redis | 6379 | - | 生产 Redis |
### 密码和密钥管理
⚠️ **安全原则**:敏感信息存储在服务器上,不提交到 Git。
#### SSH 密钥配置
**本地密钥位置**:`C:\Users\54972\.ssh`
| 服务器 | IP地址 | SSH别名 | 密钥文件 | 用途 |
|--------|--------|---------|---------|------|
| 生产服务器 | 192.168.58.20 | 直接用 IP(`prod` 别名已删除) | `youyidian_prod_ed25519` | 应用容器、数据库、Redis |
| 构建服务器 | 192.168.42.134 | `build` | `id_rsa_build_192.168.42.134` | Docker镜像构建 |
| 公网入口服务器 | 218.93.9.38 | - | root 密码登录(密码不入库) | FRP 入口、Nginx 反向代理 |
| License服务器 | 122.51.149.232 | `license-server` | `id_ed25519_license_122.51.149.232` | License服务 |
| 腾讯云服务器 | 45.249.208.118 | `tencent` | `tencent_server.pem` | 腾讯云服务器 |
**已废弃服务器:**
- 旧镇江服务器 180.97.221.140(已封禁,不再使用)
- 原香港服务器 103.118.245.6(已下线;`~/.ssh/config` 里的 `frp` 别名仍指向它,勿用)
**未识别的密钥**:
- `id_rsa` / `id_rsa.pub` - 默认密钥(保留不动)
- `flnxilicenseserver.pem` - 可能是 license 服务器的备用密钥(PEM格式)
**连接方式**:
```bash
ssh 192.168.58.20 # 生产服务器(~/.ssh/config 已配好密钥)
ssh build # 构建服务器
ssh tencent # 腾讯云服务器
```
**公网入口服务器 218.93.9.38**:root 密码登录,密码向维护人员索取。Windows 下用 `plink -batch -hostkey <指纹> -pw <密码> root@218.93.9.38`;2026-10-06 首次连接记录的指纹是 `SHA256:UwFngDHnyUFNAszlwbtVzBc4NdZMwCCfOz+8yoJvgKs`(未经第二渠道核对)。
**配置文件**:`C:\Users\54972\.ssh\config`(已配置好 IdentityFile)
#### 查看密码的方式
**方式1:通过 SSH 查看环境变量**
```bash
# 查看生产环境所有环境变量
ssh root@192.168.58.20 "docker exec youyidian-3d-material printenv | grep -E 'DATABASE|S3|REDIS|NEXTAUTH'"
# 查看特定密码
ssh root@192.168.58.20 "docker exec youyidian-3d-material printenv | grep DATABASE_URL"
ssh root@192.168.58.20 "docker exec youyidian-3d-material printenv | grep S3_"
```
**方式2:查看 docker-compose.yml(服务器端)**
```bash
# 生产服务器上的配置文件路径
ssh root@192.168.58.20 "cat /root/youyidian-3d/docker-compose.yml | grep -A 20 'environment:'"
```
**方式3:查看 .env 文件(如果存在)**
```bash
ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env file found'"
```
#### 需要记录的敏感信息位置
| 信息类型 | 存储位置 | 查看命令 |
|---------|---------|----------|
| 数据库密码 | 服务器环境变量 | `docker exec youyidian-3d-material printenv \| grep DATABASE_URL` |
| MinIO/S3 密钥 | 服务器环境变量 | `docker exec youyidian-3d-material printenv \| grep S3_` |
| NextAuth 密钥 | 服务器环境变量 | `docker exec youyidian-3d-material printenv \| grep NEXTAUTH` |
| 钉钉 App 密钥 | 代码中(已加密) | `cat app/api/dingtalk/login/route.ts \| grep DINGTALK` |
| 管理员 API Key | 服务器环境变量 | `docker exec youyidian-3d-material printenv \| grep ADMIN_API_KEY` |
#### 密码变更记录
当密码变更时,在此处记录:
- **2026-01-27**:初始记录,建立密码管理机制
- **2026-05-05**:公网接入迁移到香港服务器 103.118.245.6
- **2026-10-06**:核实公网入口已换为 218.93.9.38,原香港服务器 103.118.245.6 已下线
---
## 部署
当前默认使用 **Git 增量部署**,统一通过 `scripts/git-incremental-deploy.ps1` 执行:
```powershell
# 预检
.\scripts\git-incremental-deploy.ps1 -DryRun
# 部署到生产环境
.\scripts\git-incremental-deploy.ps1
# 部署到影子环境
.\scripts\git-incremental-deploy.ps1 -Target shadow
```
**当前部署流程:**
1. 源码先提交并推送到 Git 仓库
2. 构建服务器 (192.168.40.206) 增量拉取指定提交
3. 构建服务器构建 Docker 镜像并推送到本地镜像仓库 (192.168.40.206:5000)
4. 生产服务器 (192.168.58.20) 拉取指定提交镜像
5. 替换正式容器或影子容器
6. 检查 `/api/health`,失败时默认尝试回滚旧镜像
**⚠️ 部署前置条件(缺一不可):**
脚本由维护人员执行,需要以下环境变量,且**必须在内网环境**(构建机 192.168.40.206 走公网穿透连不上,表现为 TCP 端口"可达"但 SSH 握手失败——穿透代理对任何连接都返回成功,端口连通性测试是假阳性):
```powershell
$env:BUILD_SERVER_PASSWORD = '构建机密码' # 脚本用 plink -pw,不走 SSH 密钥
$env:BUILD_SERVER_HOST_KEY = 'SHA256:...' # 已人工核对的构建机指纹
$env:PROD_SERVER_HOST_KEY = 'SHA256:...' # 已人工核对的生产机指纹
```
已记录的主机指纹(来自 `~/.ssh/known_hosts`,生产机已现场验证一致):
- 生产机 192.168.58.20:`SHA256:b0qukGVPuI8j+lykfCIplkcKWh7d4Pq9APzvr4FDnss`
- 构建机 192.168.40.206:`SHA256:MXW5w48SFSTUaqS+dS1G9hY228hI6SLepjhCmItHL04`
正式部署必须显式指定 40 位提交 SHA,脚本不接受"分支最新提交":
```powershell
.\scripts\git-incremental-deploy.ps1 -Commit '<40位小写提交SHA>'
```
`deploy-remote-build.ps1` 属于本地历史脚本名,仅作为识别应急资料使用,不是仓库交付入口,也不再作为默认部署入口。旧的“本机打包源码 -> 上传到构建机”链路不再作为当前标准口径。
详细部署规范见:
- `docs/Git增量部署方案.md`
- `docs/部署规范.md`
- `docs/部署脚本安全说明.md`
## 架构
### 目录结构
```
app/
├── api/ # API 路由
│ ├── auth/[...nextauth]/ # NextAuth 配置
│ ├── materials/ # 素材增删改查、上传、下载
│ │ ├── upload/ # 上传端点(分片、完成、特定类型)
│ │ └── [id]/ # 单个素材操作
│ ├── admin/ # 管理员端点(用户、权限、统计)
│ ├── external/ # 外部 API 集成
│ └── user/ # 用户资料操作
├── [id]/share # 公开分享页面
├── admin/ # 管理员后台页面(素材库管理,旧壳待统一)
├── edit/[id]/ # 素材编辑页面
├── login/ # 登录页面
├── materials/ # 创意资产库(原素材库首页,2026-08-15 迁入)
├── organization/ # 组织管理(人员架构顶层页,2026-08-15 上移)
└── page.tsx # 品牌中心看板首页(登录落点,Zen×Organic 白皮书风)
components/
├── upload/ # 上传组件
│ ├── UploadModal.tsx # 主上传弹窗(通用上传)
│ ├── ProductPackageUploader.tsx # 产品全案
│ ├── TaskPageUploader.tsx # 任务-页面
│ ├── VideoScreenshotUploader.tsx # 任务-视频截图
│ ├── ThreeDOriginalUploader.tsx # 3D原图
│ ├── AIOriginalUploader.tsx # AI原图
│ ├── RealPhotoUploader.tsx # 实拍图
│ └── SpecialBatchUploadUI.tsx # 批量上传界面(音乐、视频、3D、品牌VI等)
├── admin/ # 管理后台组件
├── board/ # 看板壳组件(BoardShell、MaterialsHeader——新视觉语言)
├── home/ # 创意资产库(原首页)组件
└── detail/ # 详情页组件
lib/
├── chunk-upload.ts # 分片上传(IndexedDB 状态 + 重试)
├── chunk-upload-config.ts # 分片上传配置(50MB统一分片)
├── upload-sessions.ts # 内存上传会话 Map(开发环境)
├── upload-sessions-redis.ts # Redis 上传会话(生产环境)
├── task-material-processor.ts # 任务类型素材处理逻辑
├── material-validation.ts # 素材类型验证辅助函数
├── material-type-mapper.ts # 素材类型常量和映射器
├── pdf-processor.ts # PDF 处理(预览图提取)
├── image-processor.ts # 图片压缩和切片
├── video-processor.ts # 视频转码
├── audio-processor.ts # 音频元数据提取
├── psd-processor.ts # PSD 文件处理
├── s3.ts # MinIO S3 客户端
├── auth.ts # NextAuth 配置
├── prisma.ts # Prisma 客户端
└── network.ts # 网络检测和带宽监控
```
### 素材类型
平台支持多种素材类型,使用不同的上传流程:
| 类型 | 中文名称 | 上传流程 | 特殊要求 |
|------|---------|-------------|---------------------|
| 3D场景 | 3D Scene | 标准流程 | 渲染器 (CR/V5/FB) |
| 详情模板 | Detail Template | 展示图 + zip | PSD 切片(200KB 分块) |
| 模特 | Model | 仅模特图片 | 无需 zip |
| 标准模型 | Standard Model | 主图 + SKU | SKU 管理 + 白底图元数据 |
| 品牌VI灵感 | Brand VI | 仅 PDF | 自动从 PDF 第1页提取预览 |
| 证书-检测报告 | Certificate Report | 仅 PDF | 支持中文文件名 |
| 任务-详情 | Task-Detail | ProductPackageUploader | 主图/SKU/详情图/PSD |
| 任务-页面 | Task-Page | TaskPageUploader | 预览图 → zip |
| 任务-视频截图 | Task-Video | VideoScreenshotUploader | 截图 → zip |
| 淘系-主图视频 | Video (Main) | 批量上传 | 原名:主图视频 |
| 淘系-逛逛视频 | Video (Browse) | 批量上传 | 原名:种草视频 |
| 抖音-千川素材 | Douyin Qianchuan | 批量上传 | 新增类型 |
| 抖音-账号维护 | Douyin Account | 批量上传 | 新增类型 |
| 音乐/买家秀/小红书 | 特殊类型 | 批量上传 | 特定类型限制 |
| 直播切片 | Live Clip | 批量上传(仅视频,复用视频转码链路) | 标题必填;直播日期(默认当天)/主播(可关联用户)/平台(抖音、快手、视频号、淘宝直播、B站);**豁免 15 天视频下载保护期**(站内与外部接口同时生效,`lib/video-download-policy.ts`) |
**证书-检测报告元数据(2026-09-23 起):** 报告标题(兼作素材名)、报告类型(成品 / 原材料)、检测日期、到期日期(**默认 = 检测日期 + 1 年**,`lib/certificate-report.ts`)、证书编号、使用说明与内置使用规则。迁移 `20260923_add_certificate_report_metadata`、`20260924_add_certificate_tested_on`。
**视频类型改名说明(2026-04-30):**
- `主图视频` → `淘系-主图视频`
- `种草视频` → `淘系-逛逛视频`
- 权限系统支持新旧名称兼容(`lib/video-material-types.ts`、`lib/department-permission.ts`)
- 数据库迁移:`prisma/migrations/20260430_200000_rename_video_material_types/migration.sql`
### 身份认证与授权
**用户角色:**
- `USER` - 普通用户
- `ADMIN` - 管理员(旧版,保留兼容性)
- `DEPT_ADMIN` - 部门管理员
- `SYSTEM_ADMIN` - 系统管理员
- `isSuperAdmin` - 超级管理员标记(最多4人)
**钉钉集成:**
- OAuth 流程:`/api/dingtalk/login`
- 回调地址:`/dingtalk-callback`
- 通过 `dingtalkUserId` 关联用户
- 首次登录时创建/更新用户资料
**会话管理:**
- JWT 策略,30天过期
- Token 包含:`id`、`role`、`department`、`isSuperAdmin`、`avatar`
**部门权限:**
- `DepartmentMaterialPermission` 表控制部门访问权限
- 字段:`canView`、`canDownload`、`canShare`、`canDownloadSource`
- 通过 `linkedToDepartment` 实现权限继承
- 视频类型权限支持新旧名称兼容(`lib/department-permission.ts`)
**标准模型 SKU 权限(2026-05-05):**
- SKU 操作权限改为登录即可(不再限制作者/管理员)
- 白底图上传记录:出图方式(实拍/渲染/AI)、上传人、上传时间
- 白底图元数据字段:`whiteBackgroundSourceType`、`whiteBackgroundUploadedBy`、`whiteBackgroundUploadedAt`
- 历史数据回填:已有白底图默认标记为"渲染"
### 上传系统架构
**分片上传流程(lib/chunk-upload.ts):**
1. **初始化** - 生成 `uploadId`,在 Redis/Map 中创建会话
2. **分片上传** - 顺序上传分片,支持重试
- 统一分片大小:50MB(内网/外网统一)
- IndexedDB 持久化状态,支持断点续传
3. **完成** - 服务端合并分片并处理
**上传会话管理:**
- 开发环境:`lib/upload-sessions.ts` - 内存 Map
- 生产环境:`lib/upload-sessions-redis.ts` - Redis,4小时 TTL
- 已完成会话:1小时 TTL
- 关键函数:`saveUploadSession`、`getUploadSession`、`updateChunk`、`setCompletedSessionTTL`
**特殊上传流程:**
1. **标准上传** (`/api/materials/upload`)
- 预览图 + 可选 zip
- 直接上传或分片上传(如果超过阈值)
2. **任务-详情** (`ProductPackageUploader`)
- `/api/materials/upload/product-package/init`
- 上传内容:主图、SKU图、详情图、PSD 文件
- `lib/task-material-processor.ts` 处理(6个步骤)
3. **任务-页面** (`TaskPageUploader`)
- `/api/materials/upload/task-page/init`
- 预览图 → zip 生成
4. **品牌VI**(PDF 上传)
- `lib/pdf-processor.ts` 提取第1页作为预览
- `lib/pdf-upload-handler.ts` 处理上传
5. **证书-检测报告**(PDF 上传)
- `/api/materials/upload/certificate-report/init`
- `/api/materials/upload/certificate-report/complete`
- 支持中文文件名(使用 execFile 调用 pdftoppm)
- 核心提交成功后,收尾步骤失败不影响整体成功
## AI 模型接入与详情页生成器(2026-10 新增,灰度中)
### AI 模型管理(仅超管)
- 管理界面:`/admin` 超级管理员 → AI 模型接入(`components/admin/AiModelManagementPanel.tsx`);API 在 `app/api/admin/ai-models/`;领域逻辑 `lib/ai-model-protocol.ts` 等。
- 管四类对象:上游服务商(API Key AES-256-GCM 加密存储)、模型目录(含能力元数据)、按用途的真实测试记录、三阶段配置(资料分析 / 卖点文案 / 生图)+ 操作审计。
- **核心纪律:只有"真实协议测试"通过的用途才允许开放**——资料分析要真读测试图并返回指定位置颜色 JSON,文案要返回合规大纲 JSON,生图要可解码、尺寸有效、非空白;连接 / 密钥 / 能力 / 路由 / 适配版本任一变化即要求重新测试。
- 测试与详情页生成共享配额:每人 1 并发、全局 2。上游已下架(present=false)的模型允许直接"停用"保存,不再被"需验证"卡住。
- 表:`AiModelProvider / AiModelCatalog / AiModelVerification / AiModelConfiguration / AiModelAudit`(迁移 `20261012_ai_model_registry`、`20261013_ai_model_capabilities`、`20261013_ai_model_verifications`)。文档:`docs/ai-model-management.md`。
### 详情页生成器(正式验收前仅超管可见)
- 页面 `/assets/detail-generator`;API `app/api/detail-generator/*`;业务逻辑 `lib/detail-generator/`;表 `DetailGeneratorProject / BrandProfile / Asset / Job`(迁移 `20261012_detail_generator`,job 带幂等键、租约、checkpoint)。
- 两级 worker:进程内队列(instrumentation 每 2 秒 tick,`DETAIL_GENERATOR_ENABLED==='true'` 才启动,全局并发 2,与模型测试共享);渲染走**独立子进程** `workers/detail-renderer/cli.mjs`——自带依赖锁(Fabric 7 / Canvas 3 / Sharp 0.34,与主应用 Canvas 2 隔离)、768MB 独立堆、非守护服务不监听端口,生产由 `DETAIL_RENDERER_NODE` / `DETAIL_RENDERER_ENTRY` / `DETAIL_FONT_DIR` 指定。
- 存储必须用独立私有卷 `DETAIL_GENERATOR_STORAGE_DIR`,不得复用 uploads/公开展示目录;生产 Docker 有独立 stage 与中文渲染 smoke 测试。
- 显式未验收项:PSD 在 Photoshop 应用内改字保存、品牌风格一致性、多屏完整生成。带 `unknown` 状态的模型调用需人工到上游核对计费。
## 回收站永久删除(2026-10 重做为 outbox 任务)
- 入口 `POST /api/admin/materials/batch-delete`(`permanent:true`,单次上限 100):请求路径只落 `MaterialPurgeJob`(文件清单快照 JSON)+ `MaterialPurgeAudit` 并删素材记录,**不做物理删除**;物理删除由 instrumentation 启动的 worker 执行(租约 10 分钟、最多 3 次尝试、每批 8 个文件、per-file outcome 写回快照,重启可续跑,可经 `POST /api/admin/materials/purge-jobs` 重试)。核心逻辑 `lib/material-purge.ts`,进度 UI `components/admin/MaterialPurgeStatus.tsx`。
- 引用检查依赖 9 张表上的 GIN 表达式索引(规范化路径 md5,迁移 `20261009_material_purge_reference_indexes`;`lib/material-purge-reference-query.ts` 用 MATERIALIZED / OFFSET 0 钉住计划);S3 派生文件用 `derived:` 键参与判断,共享派生文件不会被误删。
- ⚠️ `material_logs` / `material_download_logs` 的 materialId 外键已为 purge 移除,**不要加回去**。备份恢复演练脚本:`scripts/verify-purge-backup-restore.mjs`。
### 文件处理流程
**图片处理**(`lib/image-processor.ts`):
- 预览压缩:最大 200KB,质量 85
- 详情切片:200KB 分块,带重叠
- 支持水印
- 使用 Sharp 库处理
**视频处理**(`lib/video-processor.ts`):
- canonical 源视频(生产机 `UPLOAD_DIR` / uploads/NAS)→ 默认 720P H.265(HEVC)主预览
- 生成 10 秒 H.265 预览片段
- 提取封面
- 基于 FFmpeg 转码;同一应用实例最多 1 条任务、FFmpeg 默认 1 线程
- 任务由 PostgreSQL 租约持久化,启动时恢复并每 60 秒重扫;Redis 只用于上传会话等业务,不承担视频队列
- 上传建档后异步经历 `pending → processing → done/failed`
**音频处理**(`lib/audio-processor.ts`):
- 元数据提取(艺术家、专辑、时长)
- 支持格式:MP3、FLAC、M4A、AAC、OGG、WAV
**PSD 处理**(`lib/psd-processor.ts`):
- 图层提取
- 使用 ag-psd 生成预览
### API 集成
**外部 API**(`lib/external-api.ts`):
- `/api/external/download` - 通过 Token 验证下载
- `/api/external/product-assets` - 任务系统批量资产下载
- `/api/external/dingtalk-material-match` - 钉钉素材匹配接口
- `/api/external/dingtalk-material-options` - 钉钉下拉选项接口
- `/api/external/standard-model-sku-white-background` - 标准模型 SKU 白底图元数据查询
**钉钉接口集成(2026-05-05):**
- 支持两种鉴权方式:URL 参数 `apiKey` 或请求头 `X-API-Key`
- 钉钉低代码流程推荐使用 URL 参数方式
- 程序系统对接推荐使用请求头方式
- 字段映射:需求类型→素材类型、品牌→店铺、场景类别、产品类别、SPU
- 文档:`docs/钉钉素材库接口对接文档.md`
**API Key 系统:**
- `ApiKey` 表用于外部系统身份认证
- `ApiCallLog` 跟踪所有 API 调用
- 通过 `x-api-key` 请求头或 `ADMIN_API_KEY` 环境变量验证
- 钉钉接口额外支持 URL 参数 `apiKey`(`lib/external-api.ts` 中 `allowQuery: true`)
### 性能优化
**分片配置**(`lib/chunk-upload-config.ts`):
- 统一使用 50MB 分片(内网/外网一致)
- 动态超时时间:根据分片大小自动计算(300KB/s 最低速度)
- 并发控制:2-3 个并发(内网3、外网2)
- 请求数减少:1GB 文件从 100 个请求减少到 20 个(80% 减少)
**Redis 优化:**
- 会话 TTL:4小时(活跃中)、1小时(已完成)
- 连接池,带重试策略
- 离线队列,增强连接弹性
**内存监控**(`lib/memory-monitor.ts`):
- 跟踪上传期间的堆使用
- 流式合并,避免 Buffer.concat() 溢出
### 重要文件位置
| 文件 | 用途 |
|------|---------|
| `prisma/schema.prisma` | 数据库结构(所有模型) |
| `lib/material-validation.ts` | 类型特定验证规则 |
| `lib/material-type-mapper.ts` | 素材类型常量和映射器 |
| `lib/video-material-types.ts` | 视频类型定义和新旧兼容 |
| `lib/department-permission.ts` | 部门权限判断(支持视频类型兼容) |
| `lib/task-material-processor.ts` | 任务类型处理流程(6个步骤) |
| `lib/chunk-upload.ts` | 核心分片上传逻辑 |
| `lib/pdf-processor.ts` | PDF 处理(支持中文文件名) |
| `lib/external-api.ts` | 外部 API 鉴权(支持 URL 参数) |
| `lib/standard-model-white-background-metadata.ts` | 标准模型白底图元数据 |
| `middleware.ts` | 请求拦截和认证检查 |
| `app/api/materials/upload/route.ts` | 旧版上传端点 |
| `app/api/materials/upload/chunk/route.ts` | 分片上传处理 |
| `app/api/materials/upload/certificate-report/complete/route.ts` | 证书报告上传完成 |
| `components/HeaderTaobaoSearch.tsx` | 标题栏淘宝 ID 搜索框 |
| `components/SkuEditModal.tsx` | SKU 编辑弹窗(白底图上传) |
| `components/SkuList.tsx` | SKU 列表(白底图元数据显示) |
| `docs/钉钉素材库接口对接文档.md` | 钉钉接口对接文档 |
### 环境变量
`.env` 中必需的配置:
```
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=...
NEXTAUTH_URL=http://localhost:3000
# S3/MinIO
S3_ENDPOINT=...
S3_BUCKET=...
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
# Redis(生产环境)
REDIS_URL=redis://localhost:6379
# 管理员
ADMIN_API_KEY=...
# 钉钉(可选)
DINGTALK_APP_KEY=...
DINGTALK_APP_SECRET=...
# AI 详情页生成器(默认关;开启需独立私有持久卷,不得用 uploads)
DETAIL_GENERATOR_ENABLED=false
DETAIL_GENERATOR_BASE_URL=...
DETAIL_GENERATOR_API_KEY=...
DETAIL_GENERATOR_STORAGE_DIR=...
# 详情渲染子进程(生产镜像已固定 /opt/detail-renderer/...,一般无需覆盖)
DETAIL_RENDERER_NODE=
DETAIL_RENDERER_ENTRY=
DETAIL_FONT_DIR=
# AI 上游密钥加密(≥32 字符;与 API Key 加密密钥独立生成、稳定保存)
API_KEY_ENCRYPTION_KEY=...
```
### 常用模式
**API 路由认证:**
```typescript
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
```
**Prisma 事务:**
```typescript
await prisma.$transaction(async (tx) => {
const material = await tx.material.create({ ... })
await tx.materialAsset.createMany({ ... })
return material
})
```
**错误处理:**
```typescript
import { parseApiError } from '@/lib/error-handler'
try { ... }
catch (error) {
const { message } = parseApiError(error)
return NextResponse.json({ error: message }, { status: 500 })
}
```
### 已知问题和注意事项
1. **内存溢出** - 在 `upload-sessions.ts` 中使用流式合并,不要用 `Buffer.concat()`
2. **竞态条件** - 处理过程中避免重复更新 `previewUrl`
3. **软删除** - 素材有 `deletedAt` 字段 - 始终过滤掉这些记录
4. **任务号格式** - 格式:`TASK` + 12-18位数字 + 可选的 `-XX` 后缀
5. **预览生成** - 上传先完成建档并进入 `pending`,预览由后台异步生成;三类产物完整且租约仍有效才是 `done`,失败写入 `failed`
6. **Docker 网络** - 容器必须在 `youyidian-3d_app-network` 上才能访问 Redis
7. **视频类型权限** - 视频类型改名后需同步更新部门权限表,或依赖代码兼容逻辑
8. **PDF 中文文件名** - 必须使用 `execFile` 而非 `exec` 调用 pdftoppm,避免路径注入
9. **部署版本管理** - 不同对话中的改动需合并到同一工作区再部署,避免"回退"现象
10. **构建缓存** - 默认保留构建缓存加快构建,只在必要时使用 `-AggressiveBuildCacheCleanup`
11. **Alpine 镜像源** - 使用清华源,避免官方源在国内网络环境下的连接问题
12. **音频/文件访问路由** - `.dockerignore` 和部署脚本中排除 `uploads` 时要精确,避免误伤 `app/api/uploads` 路由
13. **人员身份判定** - 只认钉钉唯一值。钉钉登录遇到同名账号会**拒绝并转人工**,不再自动合并——这是有意的摩擦,防止重名变成越权
14. **在职状态** - 不要用关键词匹配 `employmentStatus`,用 `employmentState`。上游有数字码、中文、空值三种口径并存
15. **素材署名与归属分离** - 署名和归属对不上通常是**正确的**(素材转过手,署名保留原作者),不要"修正"成一致
16. **`Material.user` 是级联删除** - 一旦有代码真的删除 User,会连带硬删其全部素材。目前全项目无硬删除调用,靠"没人碰"撑着而非数据库拦着;引入离职下线流程前必须先改掉
17. **上游连接密码含特殊字符** - `.env` 中含 `#` 的值必须加引号,否则被当注释截断
### 最近修复的问题(2026-04-30 至 2026-05-05)
1. **证书-检测报告中文 PDF 上传失败** - 修复 `lib/pdf-processor.ts` 路径校验,支持中文文件名
2. **DNZ032912 视频素材"看不见"** - 视频类型改名后权限表未同步,已补齐权限并增加代码兼容
3. **标准模型 SKU 白底图缺少时间显示** - 新增元数据字段并回填历史数据(2730 条)
4. **钉钉接口需要手动输入请求头** - 增加 URL 参数 `apiKey` 支持,适配钉钉低代码流程
5. **音频素材播放 404** - 修复 `.dockerignore` 和部署脚本,避免误排除 `app/api/uploads` 路由
6. **部署后小改动重装基础包** - 优化构建缓存策略,默认保留缓存
7. **Alpine 镜像源连接失败** - 从官方源改为清华源
### 测试状态
- Jest 已配置,测试覆盖关键业务逻辑
- 重要测试文件:
- `lib/__tests__/video-material-types.test.ts` - 视频类型兼容测试
- `lib/__tests__/external-api-key.test.ts` - 外部 API 鉴权测试
- `lib/__tests__/dingtalk-material-match-route.test.ts` - 钉钉匹配接口测试
- `lib/__tests__/dingtalk-material-options-route.test.ts` - 钉钉选项接口测试
- 推荐通过影子环境部署进行手动测试
- 使用 `/api/admin/system-status` 进行健康检查
### 数据库维护记录
**2026-04-30:视频类型改名**
- 执行迁移:`prisma/migrations/20260430_200000_rename_video_material_types/migration.sql`
- 手动补齐部门权限:复制 6 条"主图视频"权限到"淘系-主图视频",复制 6 条"种草视频"权限到"淘系-逛逛视频"
**2026-05-05:标准模型白底图元数据**
- 执行迁移:`prisma/migrations/20260430_add_standard_model_white_background_metadata/migration.sql`
- 历史数据回填:2730 条 SKU 的 `white_background_uploaded_at` 字段(使用资产最后创建时间)
- 历史数据默认值:`white_background_source_type` 统一回填为"渲染"
**2026-08-13:人员架构统一到钉钉唯一值**
执行了三个迁移(均已在生产库 `lobechat` 应用):
- `20260813_add_user_employee_binding`:绑定表 + `users.dingtalkUserId` 唯一约束
- `20260813_add_material_custody_department`:素材托管部门
- `20260813_add_organization_units_and_employment_state`:组织架构表 + 规范化在职状态
数据操作结果:
- 建立 503 条账号↔人员绑定,覆盖活跃账号的 96.3%(余下 18 个无钉钉 ID,需人工确认)
- 同步 1075 个部门,单根树 5 层,零孤儿节点
- 校正在职状态:1285 在职 / 219 离职 / 214 未知(此前 216 条已离职档案中有 215 条被误判为在职)
- 离职联动:归档 139 个账号(离职满 30 天者),39 人暂缓
- 素材部门托管:3775 条,孤儿素材清零
- 回填 1807 条被误存为邮箱的作者名(全部来自任务-详情入口的历史缺陷)
- 账号治理:处置 1 个"组织上查无此人"的 ADMIN 账号(素材移交后撤权归档)
---
## 👤 开发者偏好
### ⚠️ 沟通偏好
**语言:**
- **全中文环境**:回复、注释、文档、变量命名、思考过程、Git message
- **表达方式**:多用自然语言,少用代码堆砌
- **用户背景**:非程序员,侧重日常语言解释
**回复风格:**
- 少代码多文字
- 必要时用少量代码示例
- 优先自然语言说明
### 🧠 思维方式
**方案分析:**
- 对比不同方案的优劣
- 用数字和星级量化评价
- 结构化输出,用 emoji 标记章节
**问题解决:**
- 根因分析:不满足表面修复,深入排查底层原因
- 验证导向:修改后必须验证效果
**架构思维:**
- 优先考虑稳定性和可扩展性
- 模块化设计,构建稳定基础
- 统一 UI 组件和页面风格
- 抽取通用工具函数和样式
- 减少重复代码,降低维护成本
### 💼 工作风格
**代码原则:**
- 不写不重要的文档
- 保持代码简洁,避免过度工程化
- 优先使用现有组件和方案
**垃圾文件清理(开发时即时清理,不留尾巴):**
- 更新脚本/文档时删除旧版本
- 测试完成后删除临时文件
- 一次性脚本用完即删
- 用 Git 管理版本,不创建备份文件
- 清理无用的会话数据、缓存、临时文件
**执行偏好:**
- 能自动执行的不让用户手动操作
- 能批量处理的不让用户逐个处理
### ⚡ 性能意识
**内存:**
- 避免内存泄漏(事件监听器、定时器、闭包)
- 优化缓存策略(TTL、上限控制)
- 流式处理大文件,避免全量加载
### 🔒 数据安全
- 绝不删除重要数据目录
- 修改前备份数据
### 📋 工作习惯
**Git:**
- 只有明确要求时才创建 commit
- Commit message 使用中文,简洁明了
**文件操作:**
- 修改前先读取文件内容
- 创建新文件前检查是否已存在
### 🚨 重要提醒
**工作区状态:**
- 工作区长期存在大量未提交改动,这是正常状态
- 不同对话中的改动需要合并到同一工作区再部署
- 部署脚本只打包当前磁盘工作区,不会自动合并其它对话的改动
- 高敏感文件(如 `components/HeaderTaobaoSearch.tsx`)曾多次被"回退",根因是未合并工作区
**部署前检查清单:**
1. 确认所有相关改动都在当前工作区
2. 检查 `.dockerignore` 和部署脚本的排除规则
3. 验证 Alpine 镜像源配置(清华源)
4. 确认构建缓存策略(默认保留)
5. 检查 Docker 网络配置(`youyidian-3d_app-network`)
**数据库操作原则:**
- 生产数据库名称是 `lobechat`,不是 `gongxiangpan`
- 涉及权限、素材类型、用户数据的变更需谨慎
- 大批量数据回填前先在影子环境验证
- 保持迁移文件和手动操作的记录
**外部接口注意事项:**
- 钉钉接口使用 URL 参数 `apiKey`,不要做成用户填写字段
- API Key 不要在日志中明文输出
- 外部接口调用需记录到 `ApiCallLog` 表