Compare commits

..

352 Commits

Author SHA1 Message Date
70369dbdb2 fix(deploy): verify desktop migration and bound private image sync 2026-10-20 13:53:22 +08:00
971e17fcd8 feat(desktop): authorize material accounts and sync private workspaces 2026-10-20 13:39:33 +08:00
c51f567539 feat(api): add read-only certificate report lookup and original downloads 2026-10-16 17:39:26 +08:00
4f123ea272 fix(ai): bind image parameters and expose actual request settings 2026-10-16 14:25:35 +08:00
88e0a058e2 fix(ai): preserve project bindings across unrelated model settings 2026-10-16 13:08:45 +08:00
441878adb9 fix(ai): keep product prompts factual and exclude internal metadata 2026-10-16 12:34:19 +08:00
2c29b9bd78 fix(ai): separate completed executions from unknown model results 2026-10-16 11:43:21 +08:00
b4c2d30898 fix(ai): recover original analysis and copy results without resubmission 2026-10-16 11:23:32 +08:00
江林铖
292ea7e853 feat: support categorized work review image submissions 2026-10-16 10:36:54 +08:00
5a5f0a52dc fix(deploy): validate public storage URLs and document image API 2026-10-15 17:43:35 +08:00
493dd16d83 feat(api): query original image resources by SKU or barcode 2026-10-15 16:50:04 +08:00
46e8e02779 fix(ai): validate every reference image and enforce tested limits 2026-10-15 15:37:39 +08:00
d477231b66 fix(ai): verify native reference image adapters and clarify model states 2026-10-15 14:05:14 +08:00
eb4836ca81 fix(ai): pin declared image working quality 2026-10-15 11:51:33 +08:00
cfa8f0ff9b fix(ai): support private reference images and explain model failures 2026-10-15 09:45:53 +08:00
88ce9e3798 fix(ai): support model tests on HTTP browsers 2026-10-14 19:01:37 +08:00
21b6eb767c feat(ai): verify analysis and copy in one model request 2026-10-14 18:10:54 +08:00
efb8e1af3d fix(ai): complete verification activation and recovery workflows 2026-10-14 15:37:01 +08:00
4da4ceaf40 feat: attach complete inventory check to progress sync 2026-10-13 14:47:22 +08:00
b924735235 docs: document ai model registry, detail generator, purge outbox and roster identity changes 2026-10-13 13:49:20 +08:00
b93a7647d4 docs: update public entry topology and 502 playbook 2026-10-13 13:46:43 +08:00
c43823863f fix: enforce unique task detail zip entry names and gate submissions 2026-10-13 13:34:09 +08:00
924e54dbb2 fix(ai): allow disabling withdrawn models without blocking configuration saves 2026-10-13 13:06:13 +08:00
90b2e6e83c fix(deploy): restore pinned image when replacement creation fails 2026-10-13 12:35:21 +08:00
94ea82e7cb feat(ai): verify model capabilities through real protocol tests 2026-10-13 12:28:41 +08:00
9270bbb457 perf: slim database migrator and application image layers 2026-10-13 10:51:37 +08:00
e5a20753b6 feat: add capability-aware AI model administration 2026-10-13 09:59:04 +08:00
56c9dc1f2d fix: align app runtime group for private storage 2026-10-12 20:43:23 +08:00
a5c132718e fix: verify isolated restore of purge reference indexes 2026-10-12 20:05:41 +08:00
c53fa0e292 feat: add super-admin product detail generator 2026-10-12 19:29:03 +08:00
5b07f5ca54 fix: keep bulk purge reference lookups on indexes 2026-10-09 16:40:37 +08:00
a1ffe3b106 fix: preserve shared derived file references in purge lookup 2026-10-09 16:11:27 +08:00
c27d920527 fix: refresh active purge progress without idle delay 2026-10-09 15:48:01 +08:00
477ff64b07 fix: index file references for recycle-bin cleanup 2026-10-09 15:33:56 +08:00
de2d7595ce perf: speed up durable material file cleanup 2026-10-09 14:44:30 +08:00
df0e765f3e fix: restore durable recycle-bin permanent deletion 2026-10-09 12:49:44 +08:00
aa81054b8e build: preserve applied migration checksums across Windows archives 2026-10-08 17:43:49 +08:00
cf8ddb029d build: include team function import inputs in migrator context 2026-10-08 17:21:48 +08:00
858e9a690e fix: bind permissions to roster identities and persist team functions 2026-10-08 17:14:11 +08:00
c9f5110074 feat: streamline permission group and user management interfaces 2026-10-07 17:28:02 +08:00
73c1e98309 fix: compact material library search and filter toolbar 2026-10-07 15:53:11 +08:00
24fe0de19d fix: unify material filters and dropdown controls 2026-10-07 13:36:37 +08:00
江林铖
4653a56392 feat: show intent rate window summaries 2026-09-29 11:35:41 +08:00
846b8c601b fix: use report title as certificate material name 2026-09-28 20:28:48 +08:00
d2337b960e fix: default certificate expiry to one year after testing 2026-09-27 16:06:27 +08:00
cb510075fc feat: simplify report instructions and restore certificate expiry date 2026-09-27 15:36:29 +08:00
41ecfb6d36 fix: restore certificate report PDF preview and download 2026-09-27 14:53:03 +08:00
2e7235c704 fix: restore material type and shop badges on cards 2026-09-24 15:30:58 +08:00
756f9c9575 feat: unify and compact material upload controls 2026-09-24 15:00:20 +08:00
0baa951fd9 fix: use certificate test date for inspection reports 2026-09-24 11:14:25 +08:00
59e21e1b99 fix: keep certificate calendar visible in narrow upload dialog 2026-09-23 21:46:26 +08:00
9686efe11e fix: theme certificate expiry calendar and simplify report upload 2026-09-23 21:25:28 +08:00
8dc5ffb71b feat: add certificate report metadata and report kinds 2026-09-23 20:27:17 +08:00
231db310bb docs: remove obsolete Codex multi-agent rules 2026-09-23 18:43:30 +08:00
4e0701d0d0 chore: remove obsolete agent model pin 2026-09-23 18:20:01 +08:00
45c4e6321e fix: exempt live clips from video wait period 2026-09-23 18:19:56 +08:00
bea82d67b0 feat: add live clip batch upload and date platform filters
Allow multi-file live clip uploads with shared date/host/platform and
per-clip titles, filter materials by live platform and date range, and
fix the migration to alter the mapped materials table.
2026-09-22 18:59:05 +08:00
be43f1a6a5 feat: surface live clip metadata in search cards and share
Include host and platform in keyword search, show date/host/platform on
material cards and share detail, and reset live fields when switching
material type.
2026-09-22 18:59:05 +08:00
d8d843e6ad feat: add live clip material type with date host and platform
Register 直播切片 as a video-only material type. Upload and edit flows
collect required title, live date (defaults to today), optional host with
user link, and live platform presets. Persist the fields on Material and
skip SPU formatting for free-text titles.
2026-09-22 18:59:05 +08:00
5891627dec chore: ignore local secrets, temp deploy files and private docs
Keep migration credentials, preview routes, design tool artifacts,
personnel PII, and local handoff docs out of the repository.
2026-09-22 18:59:05 +08:00
江林铖
ad9c07414a feat: show submitter first-pass rates 2026-09-21 17:49:40 +08:00
江林铖
3bc36d141e fix: search brand knowledge by type 2026-09-21 14:24:51 +08:00
江林铖
b931e9b08e fix: fill brand knowledge preview cards 2026-09-21 13:36:57 +08:00
江林铖
1318f94953 fix: prevent brand knowledge edit crash 2026-09-21 11:53:08 +08:00
江林铖
b53a382715 feat: add brand knowledge template types 2026-09-21 11:24:03 +08:00
江林铖
e5da7f0e25 chore: remove brand building workspace 2026-09-21 10:44:47 +08:00
江林铖
3be02ff92e feat: sync performance conversion metrics daily 2026-09-18 11:31:40 +08:00
江林铖
88f6c10799 feat: show platform for conversion product ids 2026-09-18 09:27:49 +08:00
江林铖
d0cd7ab979 feat: reconcile workflow product ids daily 2026-09-17 16:14:14 +08:00
江林铖
35a301a45e feat: open performance conversion details separately 2026-09-17 15:29:01 +08:00
江林铖
6dbafbd5ed feat: filter performance conversion by completion date 2026-09-17 14:34:09 +08:00
江林铖
305ce3a52b feat: add performance conversion monitoring 2026-09-17 13:12:38 +08:00
江林铖
83673baf70 fix: support large brand knowledge uploads 2026-09-16 15:28:42 +08:00
江林铖
ab37a99601 ui: align brand knowledge card grid 2026-09-16 11:54:00 +08:00
江林铖
71e30662e5 fix: refine brand knowledge image previews 2026-09-16 11:27:06 +08:00
江林铖
ea14061331 feat: add brand knowledge sharing and management 2026-09-16 09:14:20 +08:00
江林铖
2be20f84c3 fix: preprocess oversized knowledge previews in browser 2026-09-15 13:05:30 +08:00
江林铖
2db3cf962f ui: simplify brand knowledge header 2026-09-15 11:27:31 +08:00
江林铖
34f355b538 fix: slice long brand knowledge previews 2026-09-14 20:29:32 +08:00
江林铖
6c263e4999 feat: add brand knowledge detail view 2026-09-14 16:45:42 +08:00
江林铖
6d05ec2de6 feat: add brand knowledge engagement actions 2026-09-14 13:56:08 +08:00
江林铖
f5cb86e875 feat: align knowledge metadata with material cards 2026-09-14 11:57:44 +08:00
江林铖
61dade05ed feat: align brand knowledge cards with material library 2026-09-12 14:53:59 +08:00
江林铖
9692357954 fix: support brand knowledge uploads on node 18 2026-09-12 11:50:47 +08:00
江林铖
23ae91da4f feat: expand brand knowledge upload limits 2026-09-11 13:24:30 +08:00
江林铖
51d80148c3 feat: update brand knowledge types 2026-09-10 09:58:17 +08:00
江林铖
7563cf573e feat: add build-only mac release option 2026-09-09 18:22:02 +08:00
江林铖
754c5e05ed fix: include brand knowledge upload index in audit 2026-09-09 18:17:42 +08:00
江林铖
f4c2fbf023 chore: gate brand knowledge schema migration 2026-09-09 13:37:11 +08:00
江林铖
12f2baf3bc feat: enhance brand knowledge uploads 2026-09-09 13:29:33 +08:00
bdc7458038 fix: replace only selected detail image slice 2026-09-08 23:00:55 +08:00
江林铖
99fbce0bcb fix: use web-compatible knowledge file response body 2026-09-08 18:34:12 +08:00
江林铖
9b3278d559 fix: allow brand knowledge document uploads 2026-09-08 18:33:04 +08:00
江林铖
b3fa6b23d8 feat: enable brand knowledge uploads 2026-09-08 17:46:49 +08:00
江林铖
c71185afc5 feat: add brand knowledge workspace surface 2026-09-08 10:58:42 +08:00
56210b988e fix: recognize plus-joined product codes as SKU 2026-09-07 17:07:21 +08:00
江林铖
00ad0783a3 feat: show SPU and asset IDs in value metrics 2026-09-07 16:36:47 +08:00
江林铖
c15444060b feat: connect material reuse value metrics 2026-09-07 12:20:03 +08:00
江林铖
576b62507f fix: continue survey redirect when webview storage is blocked 2026-09-04 16:17:37 +08:00
江林铖
8df5b31f8c fix: preserve dingtalk survey session across paths 2026-09-04 16:07:20 +08:00
江林铖
1481f56eb2 feat: move people structure tree to secondary sidebar 2026-09-04 13:22:34 +08:00
江林铖
74ec2f0c58 fix: scope dingtalk performance survey access 2026-09-04 11:50:50 +08:00
江林铖
a2e7d197cb chore: register work review brand migration 2026-09-03 17:49:05 +08:00
江林铖
cda8ba4f78 fix: expose work review brand types 2026-09-03 17:41:53 +08:00
江林铖
52c3a36b0f fix: sync completed dingtalk workflows and add work review brand 2026-09-03 17:41:17 +08:00
江林铖
b33cef8c44 feat: add team functions department navigation 2026-09-03 10:00:59 +08:00
江林铖
fe8c17e316 feat: add team functions dashboard 2026-09-03 09:09:59 +08:00
江林铖
84a11d087e feat: use current roster and split team capacity trends 2026-09-02 17:14:57 +08:00
江林铖
78e97e0756 feat: refine team capacity workflow filtering and roster history 2026-09-02 16:06:13 +08:00
江林铖
e3cb07f15d feat: align team capacity department order 2026-09-02 13:19:19 +08:00
江林铖
a8c5bc54c1 feat: add team capacity dashboard 2026-09-02 11:44:55 +08:00
江林铖
57e95ae770 fix: exclude terminated workflows from completed trends 2026-09-01 18:52:24 +08:00
江林铖
40edf22126 feat: add brand business trends dashboard 2026-09-01 18:39:19 +08:00
江林铖
c96e790c9b fix: exclude product planning from team workload 2026-09-01 17:32:06 +08:00
江林铖
db47ab5197 chore: remove brand health dashboard entry 2026-09-01 17:03:41 +08:00
江林铖
6a53dd6922 chore: remove people workload dashboard entry 2026-09-01 16:44:24 +08:00
江林铖
f538270b53 chore: remove unused business dashboard entries 2026-09-01 15:32:54 +08:00
bbebe24685 fix: harden direct manager resolution 2026-09-01 15:13:27 +08:00
江林铖
1d4231d868 chore: remove management focus dashboard entry 2026-09-01 15:04:01 +08:00
江林铖
f9cd2ca7b9 feat: add on-time delivery date filters 2026-09-01 13:35:55 +08:00
edd21e8290 feat: add incremental development progress sync 2026-09-01 13:14:18 +08:00
7eeefea38e chore: canonicalize repository author identity 2026-09-01 13:01:28 +08:00
江林铖
8a3122b1fd feat: add monthly launch date filters 2026-09-01 12:09:08 +08:00
chenpei
cae0e68a04 fix: recognize ZH bundles as SKU codes 2026-09-01 11:57:10 +08:00
chenpei
8bea6d8d9a ui: compact task detail upload layout 2026-08-31 18:19:26 +08:00
chenpei
5a35321b3a fix: finish permission saves without blocking on reload 2026-08-31 17:54:03 +08:00
chenpei
b7592fa687 fix: preserve insertion seams through approved image deletions 2026-08-31 17:03:00 +08:00
江林铖
87919a3943 feat: add team workload core metric 2026-08-31 16:40:07 +08:00
chenpei
d2ca6a1bd8 feat: protect private task details and unify supervisor resolution 2026-08-31 15:25:03 +08:00
江林铖
69750d32b4 fix: use all work reviews for first pass metric 2026-08-31 15:12:38 +08:00
江林铖
ba0b198eb8 feat: hourly sync work review dashboard data 2026-08-31 14:38:23 +08:00
江林铖
b14968a9f8 fix: complete first-pass detail columns 2026-08-31 13:17:20 +08:00
江林铖
a137b20e8c feat: show all first-pass work details 2026-08-31 13:15:14 +08:00
江林铖
2343b06f40 fix: use approved works for first-pass rate 2026-08-31 09:37:34 +08:00
江林铖
9c7508f52f feat: show real first-pass work details 2026-08-31 09:33:14 +08:00
chenpei
723c09cb9a fix: make notification credentials copy reliable 2026-08-29 22:09:05 +08:00
chenpei
15b4501ffa feat: add notification project deletion 2026-08-29 21:39:17 +08:00
chenpei
b917c09402 feat: simplify notification center onboarding 2026-08-29 20:40:55 +08:00
chenpei
9f1006c3f8 feat: manage existing notification project secrets 2026-08-29 11:57:01 +08:00
chenpei
f50b8db719 fix: accept api key migration audit in deploy gate 2026-08-29 11:09:07 +08:00
chenpei
0cf7984ce3 fix: include api key migration in runner image 2026-08-29 10:34:35 +08:00
chenpei
6ac36e73af feat: integrate notification admin and recoverable API keys 2026-08-29 10:25:32 +08:00
chenpei
92a2326699 keep task detail insert controls discoverable 2026-08-28 21:32:07 +08:00
chenpei
a645326593 refine task detail image insert control 2026-08-28 20:52:18 +08:00
chenpei
d27e86a7b1 fix: make migrator inputs readable by runtime user 2026-08-28 19:44:54 +08:00
chenpei
71b8f30d5b feat: add task detail image approval and insertion flow 2026-08-28 19:20:10 +08:00
江林铖
378ae08ec4 fix: align workflow milestones with confirmed review nodes 2026-08-28 18:52:41 +08:00
chenpei
60fa35e87e 修复部门权限组编辑图片授权读取并防止保存回退 2026-08-28 17:08:54 +08:00
江林铖
4e5ccece1b fix: hide pending content supervisor review time 2026-08-28 17:00:38 +08:00
chenpei
4e6e2ba0d1 修复权限组编辑图片保存被写死与读取缺列,勾选状态可正常保存回显 2026-08-28 16:44:52 +08:00
chenpei
93656492ef 修正Dockerfile迁移门禁行换行符 2026-08-28 16:11:12 +08:00
chenpei
fd2dec176a 权限组矩阵新增任务-详情编辑图片能力,默认关闭,勾选组成员可单图编辑 2026-08-28 16:06:20 +08:00
江林铖
4e58fde4d6 fix: align content queue ordering with external dashboard 2026-08-28 15:56:56 +08:00
chenpei
5ef8e77eeb 离职联动去掉30天缓冲与日期暂缓,花名册标记离职即托管归档 2026-08-28 15:54:00 +08:00
chenpei
97bcfcf5c9 应用变更等待态加GSAP动效:清单逐项扫描与按钮呼吸 2026-08-28 15:07:03 +08:00
chenpei
d9377b550e 重命名对话框改用源条目口径显示,提交不带扩展名由服务端保留原格式 2026-08-28 14:52:21 +08:00
chenpei
e9902f9d9d 单图编辑兼容历史客户端 -preview 尾巴命名,剥尾取来源锚 2026-08-28 14:31:01 +08:00
江林铖
4c08965cc7 fix: apply content delivery queue deferrals 2026-08-28 14:12:02 +08:00
江林铖
a1ada79974 fix: align on-time delivery with review completion month 2026-08-28 13:28:05 +08:00
chenpei
beea679525 任务详情单图编辑支持批量攒批提交,一次重建下载包与事务落库 2026-08-28 13:15:27 +08:00
chenpei
9e8714af73 任务详情单图编辑兼容旧代预览命名,按序号与条目顺序对齐 2026-08-28 11:25:15 +08:00
chenpei
f201661dcf 上传后台处理进度改用用户话术,内部阶段字段不再直接显示 2026-08-28 10:54:40 +08:00
江林铖
6d5d5c871e fix: 修正内容流程节点与时效时间 2026-08-28 10:52:44 +08:00
江林铖
c3cb493a1b 修正按时交付统计并增加流程筛选 2026-08-28 09:34:58 +08:00
chenpei
bcac7e0eab 迁移门禁纳入权限组视频提前访问,SOP口径更新为十三条 2026-08-27 21:28:39 +08:00
chenpei
d6a41d9283 视频提前访问改为权限组级能力,人进组即获得,看板直接开关 2026-08-27 21:06:27 +08:00
江林铖
d374a0bba2 feat: 接入本月按时交付率 2026-08-27 18:00:55 +08:00
chenpei
53b19ccd5a 视频15天保护期支持按用户豁免,仅站内生效且超管可开关 2026-08-27 14:48:05 +08:00
江林铖
09328d776d feat: unify workflow detail fields 2026-08-27 13:19:27 +08:00
江林铖
7f2fb05bc9 fix: recognize workflow detail migration in deploy gate 2026-08-27 11:09:13 +08:00
江林铖
a0211e2890 fix: support content review branches 2026-08-27 10:16:01 +08:00
江林铖
d25cff82ad fix: package workflow detail migration 2026-08-27 09:50:23 +08:00
江林铖
0d27b6384f feat: enrich workflow project details 2026-08-27 09:42:33 +08:00
江林铖
3bd7a4c9d8 feat: sync Ding workflow tables incrementally 2026-08-27 06:37:46 +08:00
江林铖
2b51d220a5 fix: keep deployment SSH sessions alive 2026-08-27 06:15:18 +08:00
江林铖
ee1500ab64 feat: map art workflow task node names 2026-08-26 17:49:36 +08:00
江林铖
f3095f2a43 fix: ship demand type migration safely 2026-08-26 15:39:59 +08:00
江林铖
d8d49a425b fix: filter monthly launches by demand type 2026-08-26 14:45:11 +08:00
江林铖
20d6b06201 fix: align monthly launch completion metric 2026-08-26 13:58:15 +08:00
江林铖
484781f23c 修复本月新品负责人审核口径 2026-08-26 12:23:05 +08:00
江林铖
8490b3b371 接入本月新品流程指标 2026-08-26 11:21:23 +08:00
chenpei
8bb0fc5c47 SKU判别支持MKH等业务前缀无连字符拼接品类码 2026-08-26 09:23:14 +08:00
chenpei
ec782a914e 任务详情上传放开图片上限并按命名规则自动预填SKU编号与条码 2026-08-25 19:29:19 +08:00
江林铖
586d4cd147 按发起人展示进行中项目维度 2026-08-25 18:45:45 +08:00
江林铖
dcaa11ce35 限定品牌中心进行中流程并补充 Mac 部署入口 2026-08-25 17:42:16 +08:00
江林铖
762b3ae69a 校验 Prisma 客户端结构一致性 2026-08-25 13:48:57 +08:00
江林铖
c664ac8723 限定进行中流程起始时间 2026-08-25 13:09:37 +08:00
chenpei
620a0fe8b2 锁定 next 精确版本 14.2.33,防止依赖漂移改变 middleware 行为 2026-08-25 12:59:43 +08:00
Codex
c13f5a6e1c fix: 修复公网同源上传被跨域拦截 2026-08-25 12:10:25 +08:00
江林铖
07bb208048 规范需求部门流程字段 2026-08-25 10:29:18 +08:00
江林铖
d0d7d64434 修复生产迁移镜像引擎权限 2026-08-25 09:29:50 +08:00
江林铖
89506fc2e8 补齐流程洞察生产迁移门禁 2026-08-24 18:25:44 +08:00
江林铖
2503ad02e0 进行中项目支持多维下钻分析 2026-08-24 18:10:00 +08:00
chenpei
2b615ffc75 任务详情支持单图替换改名删除并同步重建下载包 2026-08-24 13:09:17 +08:00
江林铖
2957adfacb fix: restrict Ding workflow sync types 2026-08-24 11:34:17 +08:00
chenpei
99d7274ad0 合并视频等待提示蒙版修复 2026-08-21 14:12:28 +08:00
chenpei
cefe8b003a 保留视频等待提示并移除蒙版 2026-08-21 14:12:02 +08:00
chenpei
daa79fdecd 合并视频等待提示调整 2026-08-21 13:42:26 +08:00
chenpei
5559f2074a 调整视频等待提示位置 2026-08-21 13:41:49 +08:00
chenpei
2ef03cc81e 合并素材卡片预览修复 2026-08-21 13:20:46 +08:00
chenpei
d9d6110a9c 恢复素材卡片预览比例 2026-08-21 13:19:16 +08:00
江林铖
cb165771d1 fix: include material shell migration in production gate 2026-08-21 13:15:42 +08:00
chenpei
bdec270c84 修复素材筛选加载遮罩卡住 2026-08-21 12:53:28 +08:00
chenpei
0f319f7b68 优化素材库视频筛选加载 2026-08-21 12:36:02 +08:00
chenpei
524024f7d6 fix: 修复权限组继承与花名表人员同步 2026-08-21 11:10:35 +08:00
江林铖
eb10f8246b feat: sync DingTalk workflows for ongoing metric 2026-08-21 10:31:01 +08:00
chenpei
d196033b13 fix: 权限组去掉分享开关
分享不参与判定,界面不再配置这一项。
2026-08-20 17:09:46 +08:00
江林铖
d4e9a9bc87 fix: normalize roster resignations without ding ids 2026-08-20 15:51:38 +08:00
chenpei
887d911b76 fix: 权限组部门树改走花名册组织口径
跟数据口径同一套在职花名册和一人多部门,不再只按有素材库账号的人裁树。
2026-08-20 15:43:28 +08:00
江林铖
0fa583ee8f fix: preserve production deploy payload syntax 2026-08-20 14:30:14 +08:00
chenpei
c6c4453c02 fix: 一个部门只能进一组,树上标明占用状态
分配部门时拦截冲突。已在别组的不能再加,并写清已在哪组、随上级还是未进组。
2026-08-20 14:17:17 +08:00
江林铖
d7604a6674 feat: show full HR roster in organization 2026-08-20 13:34:01 +08:00
chenpei
683f86c7af feat: 权限组可挂部门和多人
一组就是一份权限。部门进组当底子,人进组当加餐,能看的取并集。
2026-08-20 12:41:12 +08:00
江林铖
da2588051b feat: restrict external users to material library 2026-08-20 11:55:18 +08:00
chenpei
1769c65055 feat: 用户可继承一套权限加餐
人仍用花名册部门权限。个别用户可再继承运营或视觉套餐,两套叠加。九千七不用再改部门。
2026-08-20 10:43:57 +08:00
江林铖
315dab3a52 feat: 聚焦品牌中心人员架构 2026-08-20 10:33:09 +08:00
chenpei
466ab61278 feat: 组织架构 M 级人名用金色 2026-08-19 22:26:26 +08:00
chenpei
2a2c713d83 fix: 组织架构人数统计避开 Set 直接遍历 2026-08-19 22:02:56 +08:00
chenpei
83cb88c991 feat: 组织架构按在职显示,并修正人数与兼任
默认只显示花名册在职人员。树上人数与点进去名单一致,去掉重复累加。一人多部门在每个任职部门都显示。
2026-08-19 21:54:30 +08:00
chenpei
433b8087fa feat: 素材权限改走花名册组织树 2026-08-19 20:23:14 +08:00
江林铖
a98d86aab5 feat: 数据口径接入公司组织架构 2026-08-19 18:18:01 +08:00
江林铖
c764610e5d feat: 默认进入核心指标页面 2026-08-19 16:51:10 +08:00
江林铖
ce41af74ca fix: 修复自动化工作流并恢复人员架构 2026-08-19 14:17:34 +08:00
江林铖
92ed481314 feat: 上线品牌中心部门看板 2026-08-19 12:08:38 +08:00
chenpei
23083c2ef5 fix: 上传面板关闭动效避开 contextSafe 可能为空 2026-08-19 11:22:45 +08:00
chenpei
4efdccdd3c feat: 全部素材详情页对齐看板,上传面板补上 GSAP 2026-08-19 10:57:00 +08:00
chenpei
ae75bca301 feat: 上传面板对齐看板视觉,通用类型可填钉钉审批编号 2026-08-18 22:24:39 +08:00
chenpei
e1669b90b6 fix: 素材库桌面一行四卡,数字与文字分开 2026-08-17 21:59:36 +08:00
chenpei
ed7c338537 feat: 创意资产库桌面改版,全站换上思源与 Space Grotesk 2026-08-17 20:55:15 +08:00
chenpei
2b5220fdd7 fix: 任务-详情源文件不再误套 1GB 图片上限
PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
2026-08-17 18:48:00 +08:00
chenpei
d04d4ec34f fix: 同名替换成功后清掉残留的转码排空标记 2026-08-17 16:22:12 +08:00
chenpei
d907e5b4ba fix: 转码状态接口避开素材查询联合类型误报 2026-08-17 15:29:49 +08:00
chenpei
5ef70bce4e fix: 转码按正式目录找源文件,失败只给用户可重试提示
把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。
页面和接口不再回传路径或堆栈,只提示稍后重试。
2026-08-17 15:24:13 +08:00
chenpei
39954b1424 fix: 系统性能内存读取避开低版本 TS 的 BigInt 字面量 2026-08-17 13:41:19 +08:00
chenpei
e864fc3249 fix: 系统性能面板改回浅色,并纠正内存和队列口径
内存改读应用容器上限,不再把整机 90% 当成素材服务快满了。
转码队列改看素材状态和租约,上传停住的会话单独标出。
2026-08-17 13:35:09 +08:00
chenpei
2bb2434c7e fix: 排行榜初始位置避开服务端读取 window 2026-08-17 12:10:22 +08:00
chenpei
91b617bc2e fix: 素材骨架改为 CSS 动画,避免构建预渲染访问 window 2026-08-17 12:04:35 +08:00
chenpei
32610b5527 fix: 素材网格骨架动效避开服务端预渲染访问 window 2026-08-17 11:59:57 +08:00
chenpei
5f2eb55478 fix: 列表封面去重改为兼容当前 TS 目标 2026-08-17 11:56:58 +08:00
chenpei
8009c62868 perf: 创意资产库第一页加速,空壳改为权威标记 2026-08-17 11:39:40 +08:00
chenpei
656d4a2794 chore: .dockerignore 补漏(outputs/tmp_*/xlsx/csv/ison.md);移除残留设计稿 2026-08-17 09:18:41 +08:00
chenpei
02df0b7ef4 perf: facets 服务端缓存 + loadMonths 查询优化 + 首页去重复卡片
- facets API 增加内存缓存(TTL 5 分钟,键=角色+部门+筛选参数,容量 200 条)
- loadMonths 从 findMany 全表扫描改为 groupBy 按月聚合(数据库侧完成)
- 首页移除下方白卡区块(与编号索引重复)+ 恢复 05-07 规划中板块行
2026-08-17 09:09:51 +08:00
chenpei
17e23c26ae feat: 统一更新记录与品牌元数据 2026-08-16 11:33:42 +08:00
chenpei
904535d7d9 fix: restore direct cached-image deployment 2026-08-16 11:21:17 +08:00
chenpei
b470d356c6 fix: 统一迁移校验换行符 2026-08-15 18:47:42 +08:00
chenpei
47f3b7e067 fix: 兼容旧容器上传挂载别名 2026-08-15 18:31:21 +08:00
chenpei
2c7df1765b fix: 将迁移审计从常规发布中剥离 2026-08-15 18:14:24 +08:00
chenpei
440b1a7f60 fix: 将人员架构迁移纳入发布审计 2026-08-15 17:10:18 +08:00
chenpei
5e99c74e79 feat: 统一品牌中心入口与治理页体验 2026-08-15 16:56:43 +08:00
chenpei
cc6f6578be docs: 第一阶段交接文档(2026-08-15) 2026-08-15 14:24:00 +08:00
chenpei
6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题):
- 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮
- 作品过稿:深灰头→白皮书,返回链接→返回创意资产库
- 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接
- 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等)
- admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮
- 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark
- 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md

个人档案(原个人中心):
- API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态)
- 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏

顶栏平台化:
- MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑)
- 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出
- 菜单头身份行:花名+职位·部门(权威档案)
- 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中
- 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚)
- 排行榜换白皮书(白底发丝线+琥珀前三名)

构建修复:
- tsconfig 排除 output/(本地审计临时文件不再卡类型检查)
- 存储边界门禁豁免 output/(与 .dockerignore 对齐)
2026-08-15 14:16:20 +08:00
chenpei
f8db4232ce feat: 第一阶段框架——登录落点切换为品牌中心看板首页
- 看板首页 / :Zen Minimal × Future Organic 融合(用户裁定),编号索引+柔光光晕+GSAP克制动效,白皮书气质
- 素材库首页整体迁移至 /materials(含colocated测试),全站返回素材库链接同步清扫
- 组织架构页上移为顶层 /organization(保留云采石场契约注释),旧路径 /admin/org 服务端跳转;API 迁移 /api/organization(逻辑零改动)
- 后台删除组织架构跳转按钮(白屏问题源头消失);middleware 增 /materials 登录门、/organization 管理员门
- 品牌统一:浅色顶栏用 loginLogoUrl(深色图案版),标题「由壹点·品牌中心」;字体自托管 @fontsource
- 可访问性收尾(评审fix项):全文对比度过WCAG、焦点环、加载骨架防闪灰、移动端状态可见、::selection
- PRODUCT.md/DESIGN.md 记录方向契约;测试基线 1815 通过/6 既有失败(无劣化)
2026-08-15 11:01:27 +08:00
chenpei
9d23656669 fix: 修复刘伟/江澄同名串档——删除外部交流刘伟账号,江澄账号绑定品类六部M1档案并升DEPT_ADMIN 2026-08-14 18:48:15 +08:00
chenpei
b2784ac279 chore: 新增花名与等级一致性审计脚本(排查同名串档) 2026-08-14 18:38:54 +08:00
chenpei
f63e04ba02 feat: 用户管理改为组织架构展示;花名册自动激活/角色判定;修复花名册同名串档(有花名=正式员工) 2026-08-14 18:36:14 +08:00
chenpei
1a2b9b0c52 fix: 组织架构树分支层点击可选中查看成员(评审发现的行为回归) 2026-08-14 17:28:40 +08:00
chenpei
ab2d24b95c refactor: 组织架构页移除冗余 visible 字段(评审清理) 2026-08-14 17:23:54 +08:00
chenpei
ec300cf199 feat: 新增组织架构页面(云采石场视觉方向),展示真实组织树与人员 M/P 等级/归属/主管/在职状态 2026-08-14 17:16:31 +08:00
chenpei
448bf62ff7 fix: 组织架构每日同步改为应用启动即调度(每日02:00 Asia/Shanghai),并排除 output/资料/截图等本地目录出构建上下文 2026-08-14 16:23:29 +08:00
chenpei
ce44459685 docs: 更新 CLAUDE.md,补充人员架构与组织同步说明
- 修正构建服务器 IP(192.168.42.134 → 192.168.40.206,以部署脚本为准)
- 新增上游报表库说明:花名册来自公司报表库而非钉钉通讯录,含关键表与状态码含义
- 新增人员架构与组织同步章节:数据模型、在职判定口径、离职联动、归属与署名分离
- 补充部署前置条件:所需环境变量、主机指纹、必须在内网执行
- 记录 2026-08-13 的三个迁移与数据操作结果
- 已知问题补充 5 条,含级联删除风险与 .env 特殊字符陷阱

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:13:31 +08:00
chenpei
7b0ee5efb4 feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步
素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时
逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随
上游 HR 系统自动更新。

人员身份
- 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一
- users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号
- 移除钉钉登录的同名自动合并,同名冲突一律转人工核对
- 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权

组织架构
- 新增 OrganizationUnit,直接镜像上游 HR 部门树
- 不再从员工部门路径倒推,同名部门不会再被合并
- 上游已删除的部门下线而非物理删除

人员状态
- 新增规范化的 employmentState,取代对混杂原始值做关键词匹配
- 接入上游离职表,离职判定不再依赖中文关键词
- 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者

素材归属
- 归属变更纳入正式转移,记录最初上传者、转移流水与理由
- 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名
- 编辑素材的权限判定统一到 canManageMaterial
- 修复任务-详情上传把邮箱误存为作者名的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:53:48 +08:00
chenpei
33f4d0b9b8 fix: include upload id when finalizing chunked uploads 2026-08-13 14:21:30 +08:00
chenpei
53b314a056 fix: validate video SPU without rewriting input 2026-08-13 13:15:46 +08:00
江林铖
fa14bbc207 feat: hourly work review reminders 2026-08-13 12:23:23 +08:00
chenpei
930b22cd1e fix: preserve complete video SPU values 2026-08-13 12:18:21 +08:00
chenpei
0fe49db973 fix: validate video SPU names 2026-08-13 12:09:14 +08:00
chenpei
98068a4209 fix: 修复 Alpine 视频转码空间检查 2026-08-13 10:55:22 +08:00
chenpei
7968ee07c2 fix: 优化视频保护期分享页体验 2026-08-13 10:12:52 +08:00
chenpei
95825b54ea fix: reuse migration audit for code-only deploys 2026-08-13 09:27:46 +08:00
chenpei
2f4ee50a97 fix: allow protected video share links 2026-08-13 09:21:02 +08:00
chenpei
0255368562 fix: harden production deployment runtime setup 2026-08-13 00:07:03 +08:00
chenpei
440eee42e1 deploy: compare portable image config digests 2026-08-12 22:04:53 +08:00
chenpei
be3b5f9cee build: use bundled node headers for canvas 2026-08-12 21:29:49 +08:00
chenpei
c4eff7b2bb build: compile canvas without external binary download 2026-08-12 21:20:58 +08:00
chenpei
b2aac04c13 test: make release gates portable on Windows 2026-08-12 20:43:09 +08:00
chenpei
343b88cb98 fix: parse immutable registry digest 2026-08-12 20:00:13 +08:00
chenpei
a39208802e fix: resolve OCI image manifest digest 2026-08-12 19:52:15 +08:00
chenpei
fb6187a0b1 fix: streamline verified release builds 2026-08-12 19:44:23 +08:00
chenpei
4a6fa92589 fix: verify production host key on build server 2026-08-12 19:16:30 +08:00
chenpei
020006ceb7 fix: bound HTTP Gitea release fetches 2026-08-12 18:55:38 +08:00
chenpei
7c5f42ca40 fix: support pinned HTTP Gitea releases 2026-08-12 18:25:08 +08:00
chenpei
d4fb3971e9 Merge remote-tracking branch 'origin/main' into codex/video-logo-resilience-20260811 2026-08-12 18:16:07 +08:00
chenpei
7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00
江林铖
d2afa2bfe7 feat: show work description in review workspace 2026-08-11 13:34:11 +08:00
江林铖
17d45352d3 fix: stabilize mobile annotation box selection 2026-08-11 10:34:15 +08:00
chenpei
d69eb6e252 feat: 支持视频手动预览图 2026-08-07 23:28:55 +08:00
chenpei
a72bbf5fed chore: 统一权威仓库为 WuYongJie 2026-08-07 17:37:54 +08:00
chenpei
ebe4325db2 fix: prevent local proxy drift during deploys 2026-08-07 11:05:55 +08:00
江林铖
caeabb3b4f fix: keep annotation comments within mobile viewport 2026-08-07 10:02:13 +08:00
江林铖
634760393a fix: wrap long work review annotations 2026-08-07 09:32:11 +08:00
江林铖
52ad6aeb04 fix: harden performance survey xlsx downloads 2026-08-06 14:28:26 +08:00
江林铖
2163ddb4f7 fix: include all active DingTalk evaluators 2026-08-06 13:22:24 +08:00
江林铖
98c9b4900b feat: export performance survey dashboard to xlsx 2026-08-06 10:02:57 +08:00
江林铖
e7eb8d152e feat: restrict work review deletion to wuxie 2026-08-05 16:49:54 +08:00
chenpei
f627344616 fix: honor range for legacy material downloads 2026-08-03 16:55:08 +08:00
chenpei
fc20baf9d6 fix: preserve range downloads through s3 proxy 2026-08-03 16:28:59 +08:00
chenpei
57a9a946ab fix: restore task page previews and bound download memory 2026-08-03 15:42:02 +08:00
江林铖
7c609a5ca8 feat: add boxed work review annotations 2026-08-03 13:40:13 +08:00
chenpei
37581ef30f docs: 补充生产内存复核与清理结果 2026-08-03 10:29:52 +08:00
chenpei
a93667db0f docs: 记录任务详情隔离验收结果 2026-08-03 10:26:50 +08:00
chenpei
62295a0230 fix: 降低任务详情选图内存峰值 2026-08-03 09:53:20 +08:00
chenpei
3495530f3d fix: 加固任务详情上传稳定性 2026-08-02 01:04:11 +08:00
chenpei
873156ed0f docs: 标记 uploads 权限修复暂缓 2026-08-02 00:18:15 +08:00
chenpei
f9b59dcc1c docs: 记录影子服务生产收口结果 2026-08-02 00:10:18 +08:00
chenpei
6b909fa234 fix: 收紧网络状态与媒体代理安全边界 2026-08-01 23:57:48 +08:00
江林铖
776c6bafce fix: unify work review detail workspace 2026-08-01 20:04:38 +08:00
江林铖
a6843d684b fix: align work review card summaries 2026-08-01 19:43:54 +08:00
江林铖
1f68cba81c fix: hide revision action after approval 2026-08-01 19:09:26 +08:00
江林铖
878906c52f fix: add temporary single-review test flow 2026-08-01 18:34:51 +08:00
江林铖
50ea94ce3c feat: add work review revision submission flow 2026-08-01 17:22:00 +08:00
chenpei
f5ea657784 fix: 稳定移动端评审图片切换 2026-08-01 16:17:42 +08:00
chenpei
0897c81471 fix: 稳定任务详情上传与素材查询 2026-08-01 16:17:42 +08:00
江林铖
04fd25b60e feat: reposition draft review annotations 2026-08-01 16:16:20 +08:00
江林铖
94641795cf fix: restore mobile review comparison navigation 2026-08-01 15:31:56 +08:00
江林铖
3ceab7ac0b refactor: simplify review annotation controls 2026-08-01 14:46:16 +08:00
江林铖
40fc7a4a37 feat: add inline work review annotations 2026-08-01 13:43:58 +08:00
江林铖
e75fc67b6a feat: streamline reviewer comparison workspace 2026-08-01 11:57:04 +08:00
江林铖
74f7c0d509 feat: add reference images to work review submissions 2026-08-01 11:04:03 +08:00
江林铖
8f798dbd51 fix: 按花名繁简体排除本人评审 2026-08-01 10:25:48 +08:00
江林铖
16fe042678 fix: 排除作品评审人的重复身份 2026-08-01 10:09:01 +08:00
江林铖
de146338fa feat: 作品过稿支持手动选择评审人 2026-08-01 09:46:59 +08:00
江林铖
c63ec96dab fix: 识别无邪生产账号删除权限 2026-08-01 09:11:54 +08:00
江林铖
b0f9c026bd feat: 无邪可删除公共看板单项调研 2026-07-31 15:11:45 +08:00
chenpei
e2f4911268 fix: 限制图片视频处理内存峰值 2026-07-31 10:40:00 +08:00
chenpei
88c98cc081 fix(ui): 移除网络诊断与缓存刷新入口 2026-07-31 10:26:18 +08:00
chenpei
69c2c62be3 chore(security): 收紧容器运行文件权限 2026-07-31 10:26:18 +08:00
chenpei
b48872d83b fix(security): 收紧上传与素材文件操作 2026-07-31 10:26:18 +08:00
chenpei
7c913b6de9 fix(security): 脱敏系统设置凭据 2026-07-31 09:41:40 +08:00
江林铖
19174d5050 fix: 展示岗位人员并修复公共看板权限 2026-07-31 09:40:55 +08:00
江林铖
5590c5a454 feat: 公共看板支持全部岗位 2026-07-30 19:13:24 +08:00
chenpei
90be76bb94 fix: 规范化部署脚本换行 2026-07-30 16:18:58 +08:00
chenpei
bde5ba47d9 feat: 素材详情页增加快捷操作 2026-07-30 16:16:07 +08:00
江林铖
b47b075855 fix: 修复视觉调研草稿上传权限 2026-07-30 14:54:23 +08:00
chenpei
fb50b59451 fix: 分离视频详情与元数据编辑门禁 2026-07-30 14:51:52 +08:00
chenpei
5202d1240c feat: 完善素材搜索与编辑体验 2026-07-30 14:39:30 +08:00
江林铖
1299357f8e feat: 支持继续编辑未发布调研草稿 2026-07-30 13:50:11 +08:00
江林铖
d41ba9b751 fix: 显示调研草稿发布反馈 2026-07-30 12:09:44 +08:00
江林铖
6600d14b02 fix: 调整新增模板入口位置 2026-07-30 09:57:45 +08:00
江林铖
18835e4a6b refactor: 考核模板改为单配置模式 2026-07-29 19:35:55 +08:00
江林铖
30947f013f fix: 同步发起页模板名称 2026-07-29 18:52:58 +08:00
江林铖
1089842e27 fix: 发起调研自动关联人员岗位 2026-07-29 18:36:22 +08:00
江林铖
ddb49e0157 docs: 固化 Impeccable 前端规范 2026-07-29 18:15:47 +08:00
江林铖
7aa356db9b feat: 支持复制考核模板 2026-07-29 18:05:30 +08:00
江林铖
c4b3ab8e38 feat: 已发布岗位增加修改模式 2026-07-29 17:29:14 +08:00
江林铖
7bf3a04a3f fix: 取消岗位模板生效月份限制 2026-07-29 16:59:01 +08:00
chenpei
51f2fb30a1 fix: 合并旧分支中的上传下载稳定性修复 2026-07-29 13:46:37 +08:00
江林铖
ea77f8a73f fix: 精简配置页面月份入口 2026-07-29 13:44:03 +08:00
江林铖
c7ac95fc8f fix: 同步岗位配置中的模板名称 2026-07-29 13:24:01 +08:00
chenpei
362a468d67 feat: 为任务素材增加钉钉审批编号 2026-07-29 11:57:35 +08:00
江林铖
b19a5fff58 fix: 修正评分项默认分值显示 2026-07-29 09:31:52 +08:00
江林铖
66335f0ab5 fix: M级人员获得考核调研管理权限 2026-07-28 19:52:29 +08:00
江林铖
417a3b0243 feat: 新增作品过稿看板及本地测试环境 2026-07-28 17:29:47 +08:00
975 changed files with 145628 additions and 18212 deletions

View File

@ -54,6 +54,8 @@ prisma/engine-cache
prisma/engine-cache/**
prisma/migrations
prisma/migrations/**
!prisma/migrations/
!prisma/migrations/**
# 备份文件
backups
@ -100,6 +102,23 @@ CHANGELOG*
LICENSE
docs
# 本地临时产物目录(不进 git、也不该进构建上下文)。
# output/ 里含历史调查脚本,其中有无法通过 tsconfig 的旧 TS 文件,
# 若进入构建上下文会导致 next build 扫描失败。
output
outputs
tmp_epplus_probe
tmp_excel_cell_image_probe
migration-configs
资料
# 本地截图/临时克隆目录,不该进构建上下文
截图
# 根目录业务数据文件(历史导入/统计表,不进构建上下文)
*.xlsx
*.csv
*.ison.md
# 测试和覆盖率
coverage
.nyc_output
@ -121,6 +140,12 @@ scripts
scripts/**
!scripts/
!scripts/public-traffic-rollup-from-nginx.py
!scripts/collapse-performance-survey-template-versions.mjs
!scripts/check-legacy-storage-boundaries.mjs
!scripts/safe-production-migrate.mjs
!scripts/create-verified-production-backup.mjs
!scripts/verify-prisma-client-schema.mjs
!scripts/seed-team-function-config.ts
__tests__
*.test.ts
*.test.js
@ -185,51 +210,26 @@ missing-*.json
missing-*.txt
temp_*.sql
# SQL 文件和数据文件
*.sql
*.csv
*.json
!package.json
!package-lock.json
!tsconfig.json
!next.config.js
!tailwind.config.ts
!postcss.config.js
!jest.config.js
!jest.setup.js
*.txt
!README.md
# Docker ignore 使用“最后匹配规则生效”。上面的全局 *.sql 规则不能再次
# 排除 Prisma 迁移,否则 builder/migrator 会在发布时缺少 migration.sql。
!prisma/migrations/
!prisma/migrations/**
!data/team-function-initial-content.json
# 旧的 JS 文件(不在 app 或 components 中)
shadow_*.js
*_page*.js
delete-text-materials.js
find-text-materials.js
# Private generated data never belongs to a build context.
/detail-generator-private
/detail-generator-private/**
/.private
/.private/**
/workers/detail-renderer/node_modules
/workers/detail-renderer/node_modules/**
!workers/detail-renderer/package.json
!workers/detail-renderer/package-lock.json
!public/detail-generator/fonts/OFL.txt
# 部署和配置文件(不需要在容器中)
docker-compose*.yml
deploy-*.ps1
check-*.ps1
sync-*.ps1
verify-*.ps1
install-*.ps1
quick-*.ps1
cleanup-*.ps1
restore-*.sh
run-*.ps1
test-*.ps1
# 报告和分析文件
*-report.csv
*-report.json
*-stats*.csv
*-stats*.txt
*-inventory*.csv
*-inventory*.json
material-inventory-*.csv
material-inventory-*.json
old-materials-*.csv
missing-*.csv
missing-*.json
missing-*.txt
temp_*.sql
# Isolated migration tooling, locked to the application's dependency versions.
/workers/database-migrator/node_modules
/workers/database-migrator/node_modules/**
!workers/database-migrator/package.json
!workers/database-migrator/package-lock.json
!scripts/database-migrator-lock.mjs

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
# Keep historical migration bytes identical across Windows Git archives and Linux builds.
prisma/migrations/**/migration.sql text eol=lf
# These two migrations were already applied from the Windows CRLF archive on
# 2026-10-08. Preserve their recorded bytes instead of rewriting DB checksums.
prisma/migrations/20261008_permission_group_department_identity/migration.sql text eol=crlf
prisma/migrations/20261008_team_function_configs/migration.sql text eol=crlf

65
.gitignore vendored
View File

@ -96,6 +96,10 @@ missing-video-files*.json
/.claude/worktrees/
/.playwright-mcp/
/.performance-survey-audits/
/outputs/department-dashboard-qa/
/.impeccable/screenshots/
/pnpm-lock.yaml
/pnpm-workspace.yaml
# Temporary investigation/probe artifacts
/tmp/
@ -121,3 +125,64 @@ test-*.ps1
test-*.bin
backups/
截图/
# DSH 视觉工具产物目录(本地生成,不进 git)
.dsh-vision-toolkit/
# 本地设计/代理工具状态(截图、问题状态、拼图素材)
.impeccable/
.zcode/
# 本地预览路由(验收用,注释已标明不进 git)
/app/board-preview/
/app/profile-preview/
# 服务器迁移配置(含 frp token、nginx 私钥/密码,严禁入库)
/migration-configs/
# 外部私密接管清单与无关项目续接摘要(含凭据或非本仓内容)
Relay私密接管清单*
*私密接管清单*
对话续接摘要*
*对话续接摘要*.md
*对话续接摘要*.ison
*对话续接说明*
*.ison.md
# 根目录会话交接文档(本地参考,不入 git)
/*功能交接-*.md
# 根目录运维临时脚本
/tmp-*.sh
/tmp-simulate.mjs
/scripts/tmp-*
# 人员花名册映射(含钉钉身份 PII,不入库)
/scripts/人员对应花名册*
# 本地设计素材包与检测报告附件(体积大,含 node_modules)
/资料/
# 教材/训练说明等非本仓核心文档
/furniture-image-dataset-ai-sorting-guide.md
# 本地设计/交接/审查文档(不入 git)
/docs/class-diagram.mermaid
/docs/sequence-diagram.mermaid
/docs/system_design.md
/docs/wuxie开发者接入与协作说明-*.md
/docs/人员架构界面切换-交接文档-*.md
/docs/品牌中心后续界面统一-交接文档-*.md
/docs/素材库店铺与多层权限改造实施计划-*.md
/docs/绩效考核需求文档.xlsx
/docs/项目全量深度审查报告-*.md
# 本地诊断/恢复脚本(不入 git)
/scripts/diagnose-data-stats-consistency.ts
/scripts/migrate-standard-model-white-bg-webp.js
/scripts/recover-selected-video-preview*.ts
/scripts/verify-p1-1-fix.ts
# Private detail-generator projects and disposable acceptance output
/detail-generator-private/
/output/detail-generator/

614
.impeccable/design.json Normal file
View File

@ -0,0 +1,614 @@
{
"schemaVersion": 2,
"generatedAt": "2026-10-12T10:15:30Z",
"title": "Design System: 品牌中心部门看板",
"extensions": {
"colorMeta": {
"nav-rail": {
"role": "neutral",
"displayName": "深海军蓝导航轨",
"canonical": "#06192d",
"tonalRamp": [
"#f1f5f9",
"#dbe4ed",
"#aebdcd",
"#7f93a8",
"#526b83",
"#2b435b",
"#142b42",
"#06192d"
]
},
"action-blue": {
"role": "primary",
"displayName": "当前项高识别蓝",
"canonical": "#1677ff",
"tonalRamp": [
"#eff6ff",
"#dbeafe",
"#bfdbfe",
"#93c5fd",
"#60a5fa",
"#3b82f6",
"#1677ff",
"#0958d9"
]
},
"canvas": {
"role": "neutral",
"displayName": "冷灰工作画布",
"canonical": "#f2f4f7",
"tonalRamp": [
"#ffffff",
"#fafbfc",
"#f8fafc",
"#f2f4f7",
"#e9edf2",
"#dfe4ea",
"#cfd6df",
"#98a2b3"
]
},
"surface": {
"role": "neutral",
"displayName": "白色数据面板",
"canonical": "#ffffff",
"tonalRamp": [
"#ffffff",
"#fcfcfd",
"#f9fafb",
"#f2f4f7",
"#e4e7ec",
"#d0d5dd",
"#98a2b3",
"#667085"
]
},
"surface-soft": {
"role": "neutral",
"displayName": "次级交互面",
"canonical": "#f8fafc",
"tonalRamp": [
"#ffffff",
"#f8fafc",
"#f1f5f9",
"#e2e8f0",
"#cbd5e1",
"#94a3b8",
"#64748b",
"#334155"
]
},
"border": {
"role": "neutral",
"displayName": "数据面板边界",
"canonical": "#e2e8f0",
"tonalRamp": [
"#ffffff",
"#f8fafc",
"#f1f5f9",
"#e2e8f0",
"#cbd5e1",
"#94a3b8",
"#64748b",
"#334155"
]
},
"text-primary": {
"role": "neutral",
"displayName": "主要文字",
"canonical": "#101828",
"tonalRamp": [
"#f2f4f7",
"#d0d5dd",
"#98a2b3",
"#667085",
"#475467",
"#344054",
"#1d2939",
"#101828"
]
},
"text-secondary": {
"role": "neutral",
"displayName": "辅助文字",
"canonical": "#66758b",
"tonalRamp": [
"#f8fafc",
"#e9eef5",
"#d7e0ea",
"#b6c4d4",
"#8fa2b8",
"#66758b",
"#4f6077",
"#344054"
]
},
"nav-secondary": {
"role": "neutral",
"displayName": "导航辅助文字",
"canonical": "#aebdcd",
"tonalRamp": [
"#f5f8fb",
"#e4ebf2",
"#cfdae5",
"#aebdcd",
"#8aa0b6",
"#647b92",
"#425a72",
"#243c54"
]
},
"connected": {
"role": "success",
"displayName": "真实已接入状态",
"canonical": "#31d0aa",
"tonalRamp": [
"#ecfdf8",
"#d1fae5",
"#a7f3d0",
"#6ee7b7",
"#31d0aa",
"#0e9f78",
"#087052",
"#064e3b"
]
},
"danger": {
"role": "danger",
"displayName": "错误与风险",
"canonical": "#b42318",
"tonalRamp": [
"#fff1f0",
"#fee4e2",
"#fecdca",
"#fda29b",
"#f97066",
"#d92d20",
"#b42318",
"#7a271a"
]
},
"delivery-yellow": {
"role": "warning",
"displayName": "交付率黄色区间",
"canonical": "#a88a00",
"tonalRamp": [
"#fffceb",
"#fff7cc",
"#ffec99",
"#ffdd66",
"#e9c436",
"#c4a516",
"#a88a00",
"#725400"
]
},
"business-orange": {
"role": "secondary",
"displayName": "业务经营识别色",
"canonical": "#9a4d08",
"tonalRamp": [
"#fff4e7",
"#ffe2bd",
"#ffc982",
"#f3a94f",
"#d9821c",
"#b6630b",
"#9a4d08",
"#663004"
]
},
"assets-green": {
"role": "secondary",
"displayName": "资产与自动化识别色",
"canonical": "#087052",
"tonalRamp": [
"#e9f8f2",
"#ccefe2",
"#9fe0ca",
"#63c4a4",
"#2ea481",
"#0f8563",
"#087052",
"#064b39"
]
},
"organization-gold": {
"role": "secondary",
"displayName": "组织管理识别色",
"canonical": "#725400",
"tonalRamp": [
"#fff8d9",
"#ffed9a",
"#f8dc58",
"#d9bb22",
"#b99408",
"#8f7000",
"#725400",
"#4f3a00"
]
},
"governance-blue": {
"role": "secondary",
"displayName": "治理与设置识别色",
"canonical": "#214ba7",
"tonalRamp": [
"#edf3ff",
"#d6e4ff",
"#adc8ff",
"#85a5ff",
"#597ef7",
"#3569df",
"#214ba7",
"#15347a"
]
},
"workflow-active": {
"role": "tertiary",
"displayName": "工作流选中底",
"canonical": "#e6f4ff",
"tonalRamp": [
"#f7fcff",
"#eef9ff",
"#e6f4ff",
"#bae0ff",
"#91caff",
"#69b1ff",
"#4096ff",
"#1677ff"
]
},
"workflow-active-title": {
"role": "primary",
"displayName": "工作流选中标题",
"canonical": "#0050cb",
"tonalRamp": [
"#e6f4ff",
"#bae0ff",
"#69b1ff",
"#1677ff",
"#0958d9",
"#0050cb",
"#003eb3",
"#002c8c"
]
},
"workflow-active-description": {
"role": "tertiary",
"displayName": "工作流选中说明",
"canonical": "#315f91",
"tonalRamp": [
"#edf6ff",
"#d6e9fb",
"#b5d4ee",
"#8fb8dc",
"#699ac5",
"#4c7daf",
"#315f91",
"#20436c"
]
},
"workflow-inactive-title": {
"role": "neutral",
"displayName": "工作流未选标题",
"canonical": "#4f6077",
"tonalRamp": [
"#f8fafc",
"#e9eef5",
"#d7e0ea",
"#b6c4d4",
"#8fa2b8",
"#66758b",
"#4f6077",
"#344054"
]
},
"workflow-inactive-description": {
"role": "neutral",
"displayName": "工作流未选说明",
"canonical": "#667085",
"tonalRamp": [
"#fcfcfd",
"#f2f4f7",
"#e4e7ec",
"#d0d5dd",
"#98a2b3",
"#667085",
"#475467",
"#344054"
]
},
"workflow-hover": {
"role": "neutral",
"displayName": "工作流未选悬停",
"canonical": "#eef2f6",
"tonalRamp": [
"#ffffff",
"#f8fafc",
"#f4f6f8",
"#eef2f6",
"#e4e9ef",
"#d5dde6",
"#b8c4d2",
"#7f93a8"
]
},
"workflow-focus": {
"role": "tertiary",
"displayName": "工作流焦点环",
"canonical": "#5a9ce6",
"tonalRamp": [
"#eff7ff",
"#dbeeff",
"#b8dcff",
"#8fc4f5",
"#5a9ce6",
"#2f7fcf",
"#176fc1",
"#0d4d8c"
]
},
"detail-field-border": {
"role": "neutral",
"displayName": "详情页字段与次要按钮边框",
"canonical": "#cfd8e3"
},
"detail-control-ink": {
"role": "neutral",
"displayName": "详情页次要操作文字",
"canonical": "#344054"
},
"detail-primary-hover": {
"role": "primary",
"displayName": "详情页主要操作悬停蓝",
"canonical": "#0964de"
}
},
"typographyMeta": {
"display": {
"displayName": "页面主标题",
"use": "核心指标与关键业务页标题"
},
"headline": {
"displayName": "数据页标题",
"use": "指标与明细页标题"
},
"title": {
"displayName": "面板标题",
"use": "职责域与数据分区"
},
"body": {
"displayName": "正文",
"use": "页面说明和数据解释"
},
"label": {
"displayName": "操作标签",
"use": "导航、按钮、表头和状态"
}
},
"shadows": [
{
"name": "panel-low",
"value": "0 6px 18px rgba(15, 23, 42, 0.05)",
"purpose": "需要与冷灰画布分离的主要白色面板"
},
{
"name": "menu",
"value": "0 14px 38px rgba(15, 23, 42, 0.16)",
"purpose": "账户菜单、移动抽屉和可拖动榜单"
}
],
"motion": [
{
"name": "state",
"value": "200ms ease",
"purpose": "导航折叠、选中和轻量悬停反馈"
}
],
"breakpoints": [
{
"name": "sm",
"value": "640px"
},
{
"name": "lg",
"value": "1024px"
},
{
"name": "2xl",
"value": "1536px"
}
]
},
"components": [
{
"name": "Department Navigation Active",
"type": "navigation",
"description": "二级页面在深色导航轨中的唯一当前位置;16px 语义图标与文字一起变为白色。",
"projection": "<a class=\"ds-nav-active\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M3 17l5-5 4 4 7-8\"/><path d=\"M15 8h4v4\"/></svg><span>业务趋势</span></a><style>.ds-nav-active{display:flex;min-height:44px;align-items:center;gap:10px;border-radius:8px;background:#1677ff;color:#fff;padding:0 12px;font:600 14px/1.4 ui-sans-serif,system-ui;text-decoration:none}.ds-nav-active svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-nav-active:focus-visible{outline:2px solid #69a9ff;outline-offset:2px}</style>"
},
{
"name": "Data Panel",
"type": "card",
"description": "边框优先的白色矩形数据工作面。",
"projection": "<section class=\"ds-panel\"><h2>核心指标</h2><p>真实、示例与待接入状态均清晰标注。</p></section><style>.ds-panel{border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:24px;color:#101828;box-shadow:0 6px 18px rgba(15,23,42,.05)}.ds-panel h2{margin:0;font:700 20px/1.4 ui-sans-serif,system-ui}.ds-panel p{margin:8px 0 0;color:#66758b;font:400 16px/1.75 ui-sans-serif,system-ui}</style>"
},
{
"name": "Primary Button",
"type": "button",
"description": "高识别蓝色主要行动按钮。",
"projection": "<button class=\"ds-button-primary\" type=\"button\">查看明细</button><style>.ds-button-primary{min-height:44px;border:0;border-radius:8px;background:#1677ff;color:#fff;padding:0 16px;font:600 14px/1 ui-sans-serif,system-ui;cursor:pointer}.ds-button-primary:hover{background:#0958d9}.ds-button-primary:focus-visible{outline:2px solid #1677ff;outline-offset:3px}</style>"
},
{
"name": "Search Field",
"type": "input",
"description": "白底细边框的数据筛选字段。",
"projection": "<label class=\"ds-field-label\">搜索<input class=\"ds-field\" type=\"search\" placeholder=\"输入关键词\"></label><style>.ds-field-label{display:grid;gap:6px;color:#344054;font:600 14px/1.4 ui-sans-serif,system-ui}.ds-field{min-height:44px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;padding:0 14px;color:#101828;font:400 16px/1.4 ui-sans-serif,system-ui}.ds-field:focus{outline:2px solid #1677ff;outline-offset:1px}</style>"
},
{
"name": "Connected Status",
"type": "chip",
"description": "只有真实接入业务才使用绿色状态。",
"projection": "<span class=\"ds-status-connected\"><i aria-hidden=\"true\"></i>实时读取</span><style>.ds-status-connected{display:inline-flex;align-items:center;gap:7px;border-radius:9999px;background:#ecfdf8;padding:5px 9px;color:#087052;font:600 13px/1.3 ui-sans-serif,system-ui}.ds-status-connected i{width:7px;height:7px;border-radius:50%;background:#31d0aa}</style>"
},
{
"name": "Sidebar Account Menu",
"type": "navigation",
"description": "账户入口固定在侧栏底部,菜单向上展开。",
"projection": "<div class=\"ds-account\"><button aria-haspopup=\"menu\"><b>本</b><span>本地超级管理员<small>账户与退出</small></span></button></div><style>.ds-account{border-top:1px solid rgba(255,255,255,.1);background:#06192d;padding:12px 16px}.ds-account button{display:flex;min-height:48px;width:100%;align-items:center;gap:12px;border:0;border-radius:8px;background:transparent;color:#dbe4ed;text-align:left}.ds-account b{display:grid;width:36px;height:36px;place-items:center;border-radius:8px;background:#1677ff;color:#fff}.ds-account span{font:600 14px/1.4 ui-sans-serif,system-ui}.ds-account small{display:block;color:#7f93a8;font-weight:400}.ds-account button:focus-visible{outline:2px solid #69a9ff}</style>"
},
{
"name": "Mobile Navigation Drawer",
"type": "navigation",
"description": "1024px 以下使用的完整模态导航抽屉。",
"projection": "<div class=\"ds-drawer\" role=\"dialog\" aria-modal=\"true\" aria-label=\"品牌中心移动导航\"><strong>品牌中心</strong><button type=\"button\" aria-label=\"关闭部门导航\">×</button><nav><a href=\"#\">中心仪表盘</a><a href=\"#\">业务经营</a></nav></div><style>.ds-drawer{width:min(86vw,304px);min-height:360px;background:#06192d;padding:20px;color:#fff;font-family:ui-sans-serif,system-ui}.ds-drawer>button{float:right;border:0;background:transparent;color:#fff;font-size:24px}.ds-drawer nav{display:grid;gap:8px;margin-top:24px}.ds-drawer a{min-height:44px;border-radius:8px;padding:12px;color:#aebdcd;text-decoration:none}.ds-drawer a:focus-visible{outline:2px solid #69a9ff}</style>"
},
{
"name": "Automation Workflow Split Workbench",
"type": "navigation",
"description": "Department Shell 内的 232px 二级业务栏与同源真实操作面;移动端菜单改为顶部两列,不渲染标题或接入数量区。",
"projection": "<section class=\"ds-workflow\"><nav class=\"ds-workflow-nav\" aria-label=\"自动化工作流二级导航\"><button class=\"ds-workflow-item ds-workflow-active\" type=\"button\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M9 11l2 2 4-4\"/><path d=\"M5 3h14v18H5z\"/></svg><span><b>绩效考核调研</b><small>考核问卷、填报与结果管理</small></span></button><button class=\"ds-workflow-item ds-workflow-inactive\" type=\"button\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M6 3h9l3 3v15H6z\"/><path d=\"M9 12h6M9 16h6\"/></svg><span><b>作品过稿看板</b><small>作品提交、评审与退修流转</small></span></button></nav><section class=\"ds-workflow-live\" aria-label=\"绩效考核调研操作区\"><strong>绩效考核调研</strong><p>同源真实业务操作面</p></section></section><style>.ds-workflow{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-workflow-nav{display:grid;align-content:start;gap:4px;border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-workflow-item{display:flex;min-width:0;min-height:58px;align-items:center;gap:12px;border:0;border-radius:8px;padding:10px 12px;text-align:left;transition:background .2s,color .2s}.ds-workflow-item svg{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-workflow-item span{min-width:0}.ds-workflow-item b,.ds-workflow-item small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ds-workflow-item b{font-size:14px}.ds-workflow-item small{margin-top:2px;font-size:12px}.ds-workflow-active{background:#e6f4ff;color:#0050cb}.ds-workflow-active svg{color:#1677ff}.ds-workflow-active small{color:#315f91}.ds-workflow-active:hover{background:#e6f4ff}.ds-workflow-inactive{background:transparent;color:#4f6077}.ds-workflow-inactive svg{color:#087052}.ds-workflow-inactive small{color:#667085}.ds-workflow-inactive:hover{background:#eef2f6;color:#101828}.ds-workflow-item:focus-visible{outline:2px solid #5a9ce6;outline-offset:0}.ds-workflow-live{min-width:0;background:#f2f4f7;padding:24px;color:#101828}.ds-workflow-live strong{font-size:20px}.ds-workflow-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-workflow{grid-template-columns:1fr}.ds-workflow-nav{grid-template-columns:repeat(2,minmax(0,1fr));border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-workflow-item small{display:none}}</style>"
},
{
"name": "Data Definitions Split Workbench",
"type": "navigation",
"description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。",
"projection": "<section class=\"ds-data-source\"><nav class=\"ds-data-source-nav\" aria-label=\"数据口径二级导航\"><a class=\"ds-data-source-item\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"2\"/><circle cx=\"5\" cy=\"19\" r=\"2\"/><circle cx=\"19\" cy=\"19\" r=\"2\"/><path d=\"M12 7v5M5 17v-2h14v2\"/></svg><span><b>公司组织架构</b><small>组织树与人员明细</small></span></a></nav><section class=\"ds-data-source-live\" aria-label=\"公司组织架构操作区\"><strong>组织架构</strong><p>沿用真实人员层级、归属明细与管理员权限。</p></section></section><style>.ds-data-source{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-data-source-nav{border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-data-source-item{display:flex;min-height:58px;align-items:center;gap:12px;border-radius:8px;background:#e6f4ff;padding:10px 12px;color:#0050cb;text-decoration:none}.ds-data-source-item svg{width:16px;height:16px;flex:none;fill:none;stroke:#1677ff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-data-source-item b,.ds-data-source-item small{display:block}.ds-data-source-item b{font-size:14px}.ds-data-source-item small{margin-top:2px;color:#315f91;font-size:12px}.ds-data-source-item:focus-visible{outline:2px solid #5a9ce6}.ds-data-source-live{min-width:0;background:#fafafa;padding:24px;color:#101828}.ds-data-source-live strong{font-size:20px}.ds-data-source-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-data-source{grid-template-columns:1fr}.ds-data-source-nav{border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-data-source-item small{display:none}}</style>"
},
{
"name": "Permission Groups Primary Button",
"type": "button",
"description": "仅用于 PermissionGroupsDashboard 创建权限组与明确保存素材权限的深蓝主按钮;全局 Action Blue 不变。",
"projection": "<button class=\"ds-permission-groups-primary\" type=\"button\">保存这组权限</button><style>.ds-permission-groups-primary{display:inline-flex;min-height:44px;align-items:center;justify-content:center;gap:8px;border:0;border-radius:8px;background:#0050cb;padding:0 16px;color:#fff;font:600 14px/1.4 ui-sans-serif,system-ui;cursor:pointer}.ds-permission-groups-primary:hover{background:#003da3}.ds-permission-groups-primary:focus-visible{outline:2px solid #1677ff;outline-offset:2px}.ds-permission-groups-primary:disabled{cursor:not-allowed;background:#dce5ef;color:#66758b}</style>"
},
{
"name": "Standalone Material Library Shell",
"type": "navigation",
"description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。",
"projection": "<header class=\"ds-material-standalone\" aria-label=\"素材资产库顶栏\"><strong><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M4 5h6l2 2h8v12H4z\"/></svg>素材资产库</strong><button type=\"button\">退出登录</button></header><style>.ds-material-standalone{display:flex;min-height:64px;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e2e8f0;background:#fff;padding:0 24px;color:#101828;font-family:ui-sans-serif,system-ui}.ds-material-standalone strong{display:flex;min-width:0;align-items:center;gap:12px;font-size:16px}.ds-material-standalone svg{width:20px;height:20px;fill:none;stroke:#176fc1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-material-standalone button{min-height:40px;border:0;border-radius:8px;background:transparent;padding:0 12px;color:#4f6077;font:600 14px/1 ui-sans-serif,system-ui}.ds-material-standalone button:hover{background:#eef2f6;color:#101828}.ds-material-standalone button:focus-visible{outline:2px solid #5a9ce6;outline-offset:2px}</style>"
},
{
"name": "Detail Generator Field",
"kind": "input",
"refersTo": "detail-field",
"description": "仅记录详情页资料、大纲与文案编辑复用的 detailFieldClass;44px 最小高度、8px 圆角与明确的蓝色聚焦环。",
"html": "<label class=\"ds-detail-field-label\">产品名称<input class=\"ds-detail-field\" placeholder=\"输入已确认的名称\"></label>",
"css": ".ds-detail-field-label{display:grid;gap:6px;color:#101828;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif}.ds-detail-field{min-height:44px;width:100%;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:8px 12px;color:#101828;font-family:inherit;font-size:14px;line-height:20px;font-weight:inherit}.ds-detail-field::placeholder{color:#66758b}.ds-detail-field:focus-visible{outline:none;box-shadow:0 0 0 2px #1677ff}.ds-detail-field:disabled{background:#f8fafc;color:#66758b}"
},
{
"name": "Detail Generator Secondary Button",
"kind": "button",
"refersTo": "detail-button-secondary",
"description": "详情页复用的 detailButtonClass,承载保存、上传、返回和图片替换等次要操作;不替换其他页面按钮。",
"html": "<button class=\"ds-detail-button-secondary\" type=\"button\">保存修改</button>",
"css": ".ds-detail-button-secondary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:0 12px;color:#344054;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-secondary:hover{background:#f8fafc}.ds-detail-button-secondary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-secondary:disabled{cursor:not-allowed;opacity:.5}"
},
{
"name": "Detail Generator Primary Button",
"kind": "button",
"refersTo": "detail-button-primary",
"description": "详情页复用的 detailPrimaryClass,用于分析、确认生成、继续生成与导出;保持既有 Action Blue,悬停变体仅限该控件。",
"html": "<button class=\"ds-detail-button-primary\" type=\"button\">确认大纲并生成</button>",
"css": ".ds-detail-button-primary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:0;border-radius:8px;background:#1677ff;padding:0 16px;color:#fff;font:600 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-primary:hover{background:#0964de}.ds-detail-button-primary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-primary:disabled{cursor:not-allowed;opacity:.5}"
},
{
"name": "AI Model Management Controls",
"kind": "custom",
"description": "仅记录 AiModelManagementPanel 的三个阶段默认模型、目录搜索/能力/开放状态筛选及管理员人工兼容确认控件;可用用途由服务端能力决定。提取现有 fieldClass、buttonClass、primaryClass、checkboxClass;目录每页 20 行,手机搜索整行、两个筛选器各半行;不改变全局令牌。",
"html": "<section class=\"ds-ai-model-controls\" aria-label=\"AI 模型接入控件\"><div class=\"ds-ai-model-actions\"><button type=\"button\" class=\"ds-ai-model-button\">撤销</button><button type=\"button\" class=\"ds-ai-model-button ds-ai-model-primary\">保存配置</button></div><fieldset class=\"ds-ai-model-defaults\"><legend class=\"ds-ai-model-sr\">阶段默认模型</legend><label class=\"ds-ai-model-label\">资料分析默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label><label class=\"ds-ai-model-label\">卖点文案默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label><label class=\"ds-ai-model-label\">图片生成默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label></fieldset><div class=\"ds-ai-model-filters\"><label class=\"ds-ai-model-search\"><span class=\"ds-ai-model-sr\">搜索模型或上游</span><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.3-4.3\"/></svg><input class=\"ds-ai-model-field\" placeholder=\"搜索模型或上游\"></label><select class=\"ds-ai-model-field\" aria-label=\"按能力筛选模型\"><option>全部能力</option><option>对话</option><option>视觉理解</option><option>生图</option><option>其他</option><option>待核验</option></select><select class=\"ds-ai-model-field\" aria-label=\"按状态筛选模型\"><option>全部状态</option><option>已开放</option><option>未开放</option><option>兼容待确认</option><option>已离开目录</option></select></div><p class=\"ds-ai-model-hint\">用户可自行选择已开放的模型;不支持的用途不可勾选。</p><label class=\"ds-ai-model-confirm\"><input type=\"checkbox\" class=\"ds-ai-model-checkbox\">管理员已确认</label></section>",
"css": ".ds-ai-model-controls{min-width:0;background:#fff;color:#101828;font-family:'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif}.ds-ai-model-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-bottom:16px}.ds-ai-model-button{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:0 12px;color:#344054;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;transition:color .15s,background-color .15s,border-color .15s}.ds-ai-model-button:hover{background:#f8fafc}.ds-ai-model-primary{border-color:#0050cb;background:#0050cb;color:#fff}.ds-ai-model-primary:hover{background:#003da3}.ds-ai-model-button:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-ai-model-button:disabled{cursor:not-allowed;opacity:.5}.ds-ai-model-defaults{display:grid;min-width:0;gap:12px;margin:0 0 16px;border:0;padding:0}.ds-ai-model-label{min-width:0;color:#101828;font-size:14px;line-height:20px;font-weight:500}.ds-ai-model-label .ds-ai-model-field{margin-top:6px}.ds-ai-model-field{box-sizing:border-box;min-height:44px;width:100%;min-width:0;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:8px 12px;color:#101828;font:inherit;font-size:14px;line-height:20px}.ds-ai-model-field::placeholder{color:#66758b}.ds-ai-model-field:focus-visible{outline:none;box-shadow:0 0 0 2px #1677ff}.ds-ai-model-field:disabled{background:#f8fafc;color:#66758b}.ds-ai-model-filters{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.ds-ai-model-search{position:relative;grid-column:span 2;min-width:0}.ds-ai-model-search .ds-ai-model-field{padding-left:36px}.ds-ai-model-search svg{position:absolute;left:12px;top:14px;width:16px;height:16px;fill:none;stroke:#66758b;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}.ds-ai-model-hint{margin:16px 0;color:#66758b;font-size:12px;line-height:16px}.ds-ai-model-confirm{display:inline-flex;min-height:44px;align-items:center;gap:8px;font-size:12px;line-height:16px}.ds-ai-model-checkbox{width:16px;height:16px;flex-shrink:0;accent-color:#0050cb}.ds-ai-model-checkbox:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-ai-model-checkbox:disabled{cursor:not-allowed}.ds-ai-model-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}@media(min-width:640px){.ds-ai-model-defaults{grid-template-columns:repeat(3,minmax(0,1fr))}.ds-ai-model-filters{display:flex;flex-wrap:wrap}.ds-ai-model-search{flex:1 1 192px}.ds-ai-model-filters>select{width:auto;flex:none}}"
}
],
"narrative": {
"northStar": "深色导航轨数据工作台",
"overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。",
"keyCharacteristics": [
"272px 固定深色导航轨,桌面可折叠为 80px。",
"72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。",
"`#1677ff` 只用于当前项、主要行动和明确焦点。",
"白色面板以 8–12px 圆角、细边框和克制阴影呈现。",
"移动端导航是完整的模态抽屉;内容状态始终诚实标注。",
"自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。",
"数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。",
"人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。",
"素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。"
],
"rules": [
{
"name": "The Current Location Rule",
"body": "根路径直接进入核心指标,页面只保留一个最强的 `#1677ff` 导航选中态;不设置独立“部门总览”入口,职责域色不得与当前项竞争。",
"section": "colors"
},
{
"name": "The Truth Color Rule",
"body": "绿色只表示真实已接入,示例值和待接入状态不得伪装成成功。",
"section": "colors"
},
{
"name": "The Embedded Business Truth Rule",
"body": "二级栏只切换既有真实业务操作面;选中态、URL 与 iframe 内容必须同步,不能用入口卡片或示例数据替代。",
"section": "colors"
},
{
"name": "The Dense Tool Rule",
"body": "顶栏和导航保持 12–14px 紧凑标签;页面标题才使用 30–36px,不把每个面板都做成英雄区。",
"section": "typography"
},
{
"name": "The Two-Level Workflow Rule",
"body": "自动化工作流的二级栏只包含两个业务菜单,不添加标题、数量、说明头或入口卡;右侧始终是当前菜单对应的真实操作面。",
"section": "layout"
},
{
"name": "The Direct Data Source Rule",
"body": "数据口径二级栏直接显示“公司组织架构”,不增加“数据来源”折叠层;右侧必须复用真实组织架构内容和原权限边界。",
"section": "layout"
},
{
"name": "The Brand Center Scope Rule",
"body": "人员架构只呈现公司组织架构中的“品牌中心”节点及其下级人员;公司级 `/organization` 保持全量视图,节点缺失时显示同步提示而不是回退全量。",
"section": "layout"
},
{
"name": "The Top-Level Return Rule",
"body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。",
"section": "layout"
},
{
"name": "The Organization Gate Rule",
"body": "品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。",
"section": "layout"
},
{
"name": "The Border-First Rule",
"body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。",
"section": "elevation"
}
],
"dos": [
"**Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。",
"**Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。",
"**Do** 明确区分真实数据、示例值、公式待接入与空数据。",
"**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。",
"**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。",
"**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。",
"**Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。",
"**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。",
"**Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。"
],
"donts": [
"**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。",
"**Don't** 恢复独立部门总览页或在核心指标页提供“返回部门看板”按钮。",
"**Don't** 让职责域标题本身成为导航;只有下方小项链接进入二级页面。",
"**Don't** 复制参考图中的京东品牌、店铺指标、水印或虚构业务数据。",
"**Don't** 让移动抽屉键盘焦点穿透到底层页面。",
"**Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。",
"**Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。",
"**Don't** 让 `href=\"/\"` 在 iframe 内打开并嵌套品牌中心首页。",
"**Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。",
"**Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。",
"**Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。"
]
}
}

View File

@ -1,5 +1,8 @@
# 正式系统功能分析与对比报告
> **历史快照(2025-12-20),不得作为当前生产配置或存储写入依据。**
> 当前主站与资源入口以 `docs/NETWORK_TOPOLOGY.md` 和 `docs/生产存储桶接入信息.md` 为准;文内旧域名只能用于理解历史状态。
## 📋 文档说明
本文档通过对比正式系统(192.168.58.20:4320)和本地源码,系统性分析所有功能的实现状态。
@ -483,14 +486,13 @@
#### 6.1 视频转码
- **自动转码** ✅
- 工具: `lib/video-processor.ts`
- H.265 (HEVC) 编码
- 高清晰度
- 默认 720P H.265 (HEVC) 主预览,另生成 10 秒片段和封面
- 状态:已实现
- **转码队列** ✅
- 工具: `lib/video-transcode-queue.ts`
- Redis 队列管理
- 自动恢复
- **事故时旧实现**:Redis 队列管理;当前视频任务由 PostgreSQL 租约持久化
- 当前由启动扫描和每 60 秒恢复扫描接回 `pending/processing` 任务
- 状态:已实现
- **转码重试** ✅
@ -765,10 +767,9 @@
- 提升上传速度
### 7. 视频转码
- H.265 (HEVC) 编码
- 高清晰度
- Redis 队列管理
- 自动恢复
- 默认 720P H.265 (HEVC) 主预览 + 10 秒片段 + 封面
- **事故时旧实现**使用 Redis 队列;当前由 PostgreSQL 租约持久化,不依赖 Redis 视频队列
- 启动时恢复,之后每 60 秒重扫;状态为 `pending → processing → done/failed`
---
@ -952,8 +953,8 @@ DATABASE_URL=postgresql://user:password@host:port/database
NEXTAUTH_URL=http://your-domain.com
NEXTAUTH_SECRET=your-secret-key
# S3 存储
S3_ENDPOINT=https://s3.yydd.ai
# S3 存储(本段为 2025-12-20 历史示例,当前 endpoint/public domain 以生产存储文档为准)
S3_ENDPOINT=https://s3.yydd.ai # 历史值,不作为当前生产入口
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET=gongxiangpan
@ -986,10 +987,10 @@ OPENAI_BASE_URL=https://api.openai.com/v1
- 地址:`192.168.58.20:5432`
### 存储配置
- S3 Endpoint: `https://s3.yydd.ai`
- S3 Endpoint(历史快照值): `https://s3.yydd.ai`;当前以 `docs/生产存储桶接入信息.md` 为准
- Bucket: `gongxiangpan`
- 文件前缀: `由壹点AI/`
- 本地存储: `./uploads`
- 视频预览 / 片段 / 封面:S3 对象;canonical 源视频:生产机 uploads/NAS(容器内 `/app/uploads`)
---
@ -1053,7 +1054,7 @@ OPENAI_BASE_URL=https://api.openai.com/v1
- 分块上传 + 断点续传
- 前端图片压缩
- 视频自动转码
- Redis 队列管理
- **事故时旧实现** Redis 视频队列;当前使用 PostgreSQL 租约 + 启动/60 秒恢复
- 滚动位置保存
- 搜索防抖优化

View File

@ -28,9 +28,9 @@ Next.js 14 + React 18 + TypeScript + TailwindCSS + Prisma + PostgreSQL + MinIO S
## 生产环境
- 数据库: `lobechat` @ `192.168.58.20:5432`(影子: `lobechat_stage`)
- S3: `https://s3.yydd.ai`,Bucket: `gongxiangpan`,前缀: `由壹点AI/`
- S3: `https://s3.jiansun.vip`,Bucket: `gongxiangpan`,前缀: `由壹点AI/`
- 正式: 端口4320 | 影子: 端口5320
- 本地存储: `/mnt/PoolOne/apps/youyidian-3d/uploads`
- 本地存储: `/mnt/PoolOne/youyidian-uploads`(当前正式独立 ZFS 数据集);历史路径 `/mnt/PoolOne/apps/youyidian-3d/uploads` 仅在人工显式选择兼容模式时使用,禁止据旧容器或旧文档自动推断。
---
@ -70,10 +70,12 @@ ssh root@192.168.58.20 "docker exec -i gongxiangpan-postgres-1 psql -U gongxiang
| 项目 | 老素材 | 新素材 |
|------|--------|--------|
| S3 | `s3.1080p.hk/jsxiangche` | `s3.yydd.ai/gongxiangpan/由壹点AI/` |
| S3 | `s3.1080p.hk/jsxiangche`(仅历史读取) | `s3.jiansun.vip/gongxiangpan/由壹点AI/` |
| 识别 | `previewUrl`不含`由壹点AI/` | `previewUrl`含`由壹点AI/` |
**原则**: 不改老素材 | 视频/音频存本地(MinIO机械盘慢)| 下载兼容新老路径
**原则**: 老素材地址只做读取兼容,不得用于默认值、新上传、重置或新数据库写入 | 视频/音频源文件存本地 | 下载兼容新老路径
站点 Header Logo、登录 Logo、favicon 属于当前系统配置,不属于“老素材不改”的范围;它们必须使用仓库内置品牌资源或当前 `s3.jiansun.vip/gongxiangpan/` 对象。
---

1
.mailmap Normal file
View File

@ -0,0 +1 @@
WuYongJie <549727955@qq.com> chenpei <chenpei@1080p.hk>

106
AGENTS.md
View File

@ -15,104 +15,28 @@
- 非必要不要使用兜底逻辑;优先把原生主链路和标准流程做好,只有在明确需要兼容、降级或应急,并说明原因和影响范围时,才增加兜底处理。
- 修改后必须验证,先跑最相关的验证,再视情况扩大范围。
- 不触碰其他应用配置,不把项目规则写入全局 `Codex` 配置。
- 数据库写入、删除或恢复,权限与下载资格,上传、下载、转码、外部接口、批量操作,后台统计、日志、导出与审计,以及影响线上数据或用户可见结果的改动,按高风险任务核对影响范围并加强验证。
## 3. 项目内 Multi-agents 规则
## 3. 界面基线
当任务满足以下任一条件时,优先启用内置多代理:
- 品牌中心实际入口是 `/workspaces/dashboard/core-metrics`;`app/page.tsx` 只负责登录和权限跳转。界面改动以 `DESIGN.md`、`components/department-dashboard/DepartmentShell.tsx` 和对应页面的现状为准。
- 品牌中心采用深色导航轨与浅色内容区;素材资产库按其独立工作壳处理。
- 项目不挂载 `.dark` 主题类,新代码不要添加无效的 `dark:` 变体。
- 任务同时涉及前后端、接口与页面联动。
- 任务涉及上线前审查、发布门禁、回归检查。
- 任务需要同时做“功能正确性 + UI 统一性 + 发布风险”判断。
- 任务跨越多个目录,单代理容易遗漏影响范围。
## 4. 前端体验与 Impeccable 规则
当用户明确写出以下任一表达时,默认必须真正进入多代理流程,而不是只解释角色定义:
- 凡涉及页面、组件、样式、布局、交互、响应式、可访问性或其它前端体验的工作,默认必须优先调用并严格遵循 `$impeccable` Skill;除非用户明确要求跳过。
- 开始修改前,先读取项目现有代码、视觉规范、组件库和产品上下文,并判断当前页面属于 `Operate`、`Persuade`、`Read` 还是 `Experience` 模式。
- 保留当前产品的视觉语言和业务语义,不套用千篇一律的 AI 前端模板;不得滥用渐变、套娃卡片、超大标题、装饰性面板和无意义动画。
- 实现后必须检查桌面端与移动端实际页面,并处理层级、间距、字体、颜色、溢出、空状态、错误状态、交互反馈、响应式和可访问性问题。
- 交付前执行与任务匹配的 `$impeccable critique`、`$impeccable audit` 或 `$impeccable polish` 流程,并保留必要的验证证据。
- “按项目的审查模式处理”
- “按项目的修复模式处理”
- “按项目的发布模式处理”
- “用 Multi-agents 处理”
- “启用项目团队”
## 5. 开发进度同步
默认团队结构:
- `orchestrator`:主控代理,负责拆解任务、分配角色、汇总结论。
- `backend`:后端代理,负责 `app/api/**`、`lib/**`、`prisma/**` 风险审查与修复建议。
- `frontend`:前端代理,负责 `app/**` 页面层、`components/**`、`hooks/**` 交互回归。
- `ui_auditor`:UI 统一规范审查员,负责视觉系统和组件一致性审查。
- `release_gate`:发布守门代理,负责按证据给出可上线/有条件上线/不可上线结论。
默认子代理模型规则:
- 只要启用 `subagent` / 子代理,默认使用 `gpt-5.4`。
- 默认推理强度使用 `xhigh`。
- 只有在用户明确要求更换模型或推理强度时,才允许偏离这条默认配置。
对外沟通规则:
- 只有 `orchestrator` 直接和用户对话。
- 其它子代理只负责内部执行、审查、汇总,不直接面向用户输出。
- 用户默认把需求交给 `orchestrator`;由 `orchestrator` 自行决定是否派发、派给谁、何时汇总。
- 除非用户明确要求查看子代理原始结果,否则对外只输出主控汇总后的结论。
按需附加角色:
- `explorer`:跨文件侦察员,用于快速搜集上下文、文件归属和影响范围。
- `worker`:定点执行代理,用于在职责明确后修改小范围文件。
## 4. 角色分工边界
- `backend` 默认优先处理:上传、下载、转码、权限、批量操作、后台统计、外部接口。
- `frontend` 默认优先处理:管理后台页面、上传组件、分享页、首页、交互状态。
- `ui_auditor` 默认不先改代码,先审查视觉问题;只有明确要求修 UI 时才参与改动建议。
- `release_gate` 只做汇总和判定,不直接承担业务实现。
- `worker` 只能接收已经明确边界的子任务,避免和主代理重复改动。
自动派单规则:
- 用户只需要把任务交给 `orchestrator`,不需要手动指定团队成员。
- `orchestrator` 根据任务类型自动决定是否启用团队,以及启用哪些角色。
- 默认分配逻辑:
- 单一页面或纯 UI 小改动:`frontend` + `ui_auditor`
- 纯接口、下载、上传、转码、权限、批量操作:`backend`
- 前后端联动:`backend` + `frontend`
- 前后端联动且涉及视觉统一:`backend` + `frontend` + `ui_auditor`
- 上线前审查、高风险任务、改动范围大:`backend` + `frontend` + `ui_auditor` + `release_gate`
- 影响范围不清:先派 `explorer` 摸底,再决定后续角色
- 用户不负责选人,角色编组由 `orchestrator` 内部完成。
高风险边界规则:
- 下面这些任务默认视为高风险:
- 涉及数据库写入、删除、恢复、批量更新、批量删除
- 涉及权限判断、角色判断、下载资格、管理员能力
- 涉及上传、下载、转码、外部接口、批量操作
- 涉及后台统计、日志、导出、审计记录
- 涉及可能影响线上已有数据、用户可见结果或管理后台操作结果的改动
- 高风险任务默认至少派出:`backend`
- 如果高风险任务同时影响页面或交互,再追加:`frontend`
- 如果高风险任务还影响视觉一致性,再追加:`ui_auditor`
- 如果任务准备上线、范围较大、或存在不确定性,再追加:`release_gate`
- 只要任务落入高风险边界,`orchestrator` 默认采用更保守的判断和验证策略。
UI 基线规则:
- 当前项目的 UI 统一标准默认以首页为基线。
- 首页对应文件:`app/page.tsx`
- `ui_auditor` 在审查其它页面时,优先对齐首页的视觉语言、主按钮风格、信息层级、间距节奏和状态反馈。
- 如果某个后台页面或特殊业务页面需要偏离首页风格,必须说明原因和影响范围。
## 5. 输出要求
多代理任务的输出默认按下面结构收敛:
- `发现的问题`
- `严重级别 P0 / P1 / P2`
- `影响范围`
- `修复建议`
- `验证清单`
- `最终结论`
- 可说明的工作提交后,运行 `npm run progress:sync -- ... --apply`,按真实证据使用 `code_complete`、`testing` 或 `done`。
- 仅同步 Git 元数据;目录匹配不唯一就停止,同步失败不得阻断 Git 与生产发布。
## 6. 建议先读的文档
- `README.md`
- `docs/CODEX_MULTI_AGENTS.md`
- `.private\gongxiangp2-完整敏感接管清单-2026-08-13.md`

279
CLAUDE.md
View File

@ -44,14 +44,15 @@ npm run lint # ESLint
**物理服务器:**
- 生产服务器:`192.168.58.20`
- 构建服务器:`192.168.42.134`
- 香港公网服务器:`103.118.245.6`(FRP 入口 + Nginx 反向代理)
- 构建服务器:`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)
- 公网入口:香港服务器 103.118.245.6
- FRP 服务:`frps-jiansun-entry`(端口 7002)
- 生产服务器通过 FRPC 连接到香港服务器
- 公网入口: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 网络:**
@ -62,6 +63,150 @@ npm run lint # ESLint
- `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):**
@ -161,14 +306,15 @@ docker restart youyidian-3d-material
| 服务器 | IP地址 | SSH别名 | 密钥文件 | 用途 |
|--------|--------|---------|---------|------|
| 生产服务器 | 192.168.58.20 | `prod` | `youyidian_prod_ed25519` | 应用容器、数据库、Redis |
| 生产服务器 | 192.168.58.20 | 直接用 IP(`prod` 别名已删除) | `youyidian_prod_ed25519` | 应用容器、数据库、Redis |
| 构建服务器 | 192.168.42.134 | `build` | `id_rsa_build_192.168.42.134` | Docker镜像构建 |
| 香港公网服务器 | 103.118.245.6 | - | - | FRP 入口、Nginx 反向代理 |
| 公网入口服务器 | 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` - 默认密钥(保留不动)
@ -176,16 +322,13 @@ docker restart youyidian-3d-material
**连接方式**:
```bash
# 使用别名(推荐)
ssh prod # 生产服务器
ssh build # 构建服务器
ssh frp # FRP服务器
ssh tencent # 腾讯云服务器
# 使用IP
ssh 192.168.58.20
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)
#### 查看密码的方式
@ -226,6 +369,7 @@ ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env
当密码变更时,在此处记录:
- **2026-01-27**:初始记录,建立密码管理机制
- **2026-05-05**:公网接入迁移到香港服务器 103.118.245.6
- **2026-10-06**:核实公网入口已换为 218.93.9.38,原香港服务器 103.118.245.6 已下线
---
@ -246,12 +390,32 @@ ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env
**当前部署流程:**
1. 源码先提交并推送到 Git 仓库
2. 构建服务器 (192.168.42.134) 增量拉取指定提交
3. 构建服务器构建 Docker 镜像并推送到本地镜像仓库 (192.168.42.134:5000)
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` 属于本地历史脚本名,仅作为识别应急资料使用,不是仓库交付入口,也不再作为默认部署入口。旧的“本机打包源码 -> 上传到构建机”链路不再作为当前标准口径。
详细部署规范见:
@ -275,10 +439,12 @@ app/
│ ├── external/ # 外部 API 集成
│ └── user/ # 用户资料操作
├── [id]/share # 公开分享页面
├── admin/ # 管理员后台页面
├── admin/ # 管理员后台页面(素材库管理,旧壳待统一)
├── edit/[id]/ # 素材编辑页面
├── login/ # 登录页面
└── page.tsx # 首页(素材网格)
├── materials/ # 创意资产库(原素材库首页,2026-08-15 迁入)
├── organization/ # 组织管理(人员架构顶层页,2026-08-15 上移)
└── page.tsx # 品牌中心看板首页(登录落点,Zen×Organic 白皮书风)
components/
├── upload/ # 上传组件
@ -291,7 +457,8 @@ components/
│ ├── RealPhotoUploader.tsx # 实拍图
│ └── SpecialBatchUploadUI.tsx # 批量上传界面(音乐、视频、3D、品牌VI等)
├── admin/ # 管理后台组件
├── home/ # 首页组件
├── board/ # 看板壳组件(BoardShell、MaterialsHeader——新视觉语言)
├── home/ # 创意资产库(原首页)组件
└── detail/ # 详情页组件
lib/
@ -333,6 +500,9 @@ lib/
| 抖音-千川素材 | 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):**
- `主图视频` → `淘系-主图视频`
@ -411,6 +581,29 @@ lib/
- 支持中文文件名(使用 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`):
@ -420,11 +613,12 @@ lib/
- 使用 Sharp 库处理
**视频处理**(`lib/video-processor.ts`):
- 原视频 → 540p 预览
- 生成片段(前30秒)
- canonical 源视频(生产机 `UPLOAD_DIR` / uploads/NAS)→ 默认 720P H.265(HEVC)主预览
- 生成 10 秒 H.265 预览片段
- 提取封面
- 基于 FFMPEG 转码
- 队列式处理
- 基于 FFmpeg 转码;同一应用实例最多 1 条任务、FFmpeg 默认 1 线程
- 任务由 PostgreSQL 租约持久化,启动时恢复并每 60 秒重扫;Redis 只用于上传会话等业务,不承担视频队列
- 上传建档后异步经历 `pending → processing → done/failed`
**音频处理**(`lib/audio-processor.ts`):
- 元数据提取(艺术家、专辑、时长)
@ -519,6 +713,20 @@ 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=...
```
### 常用模式
@ -557,7 +765,7 @@ catch (error) {
2. **竞态条件** - 处理过程中避免重复更新 `previewUrl`
3. **软删除** - 素材有 `deletedAt` 字段 - 始终过滤掉这些记录
4. **任务号格式** - 格式:`TASK` + 12-18位数字 + 可选的 `-XX` 后缀
5. **预览生成** - 标记上传完成前必须完成预览生成
5. **预览生成** - 上传先完成建档并进入 `pending`,预览由后台异步生成;三类产物完整且租约仍有效才是 `done`,失败写入 `failed`
6. **Docker 网络** - 容器必须在 `youyidian-3d_app-network` 上才能访问 Redis
7. **视频类型权限** - 视频类型改名后需同步更新部门权限表,或依赖代码兼容逻辑
8. **PDF 中文文件名** - 必须使用 `execFile` 而非 `exec` 调用 pdftoppm,避免路径注入
@ -565,6 +773,11 @@ catch (error) {
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)
@ -598,6 +811,22 @@ catch (error) {
- 历史数据回填: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 账号(素材移交后撤权归档)
---
## 👤 开发者偏好

385
DESIGN.md Normal file
View File

@ -0,0 +1,385 @@
---
name: "品牌中心部门看板"
description: "以深色部门导航轨组织业务、资产、组织与治理工作的内部数据工作台。"
colors:
nav-rail: "#06192d"
action-blue: "#1677ff"
canvas: "#f2f4f7"
surface: "#ffffff"
surface-soft: "#f8fafc"
border: "#e2e8f0"
text-primary: "#101828"
text-secondary: "#66758b"
nav-secondary: "#aebdcd"
connected: "#31d0aa"
danger: "#b42318"
delivery-yellow: "#a88a00"
business-orange: "#9a4d08"
assets-green: "#087052"
organization-gold: "#725400"
governance-blue: "#214ba7"
workflow-active: "#e6f4ff"
workflow-active-title: "#0050cb"
workflow-active-description: "#315f91"
workflow-inactive-title: "#4f6077"
workflow-inactive-description: "#667085"
workflow-hover: "#eef2f6"
workflow-focus: "#5a9ce6"
detail-field-border: "#cfd8e3"
detail-control-ink: "#344054"
detail-primary-hover: "#0964de"
typography:
display:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "36px"
fontWeight: 700
lineHeight: 1.2
letterSpacing: "-0.03em"
headline:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "30px"
fontWeight: 700
lineHeight: 1.25
letterSpacing: "-0.02em"
title:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "20px"
fontWeight: 700
lineHeight: 1.4
body:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "16px"
fontWeight: 400
lineHeight: 1.75
label:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "14px"
fontWeight: 600
lineHeight: 1.4
rounded:
sm: "4px"
md: "8px"
lg: "12px"
full: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "12px"
lg: "16px"
xl: "24px"
xxl: "32px"
components:
navigation-active:
backgroundColor: "{colors.action-blue}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "12px"
panel:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.lg}"
padding: "24px"
button-primary:
backgroundColor: "{colors.action-blue}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "12px 16px"
field:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.md}"
padding: "12px 16px"
status-connected:
backgroundColor: "{colors.connected}"
textColor: "{colors.nav-rail}"
rounded: "{rounded.full}"
size: "6px"
workflow-secondary-nav:
backgroundColor: "{colors.surface-soft}"
textColor: "{colors.workflow-inactive-title}"
rounded: "{rounded.md}"
padding: "16px"
width: "232px"
workflow-secondary-item-active:
backgroundColor: "{colors.workflow-active}"
textColor: "{colors.workflow-active-title}"
rounded: "{rounded.md}"
padding: "10px 12px"
height: "58px"
data-source-secondary-nav:
backgroundColor: "{colors.surface-soft}"
textColor: "{colors.workflow-active-title}"
rounded: "{rounded.md}"
padding: "10px 12px"
width: "232px"
detail-field:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.md}"
padding: "8px 12px"
detail-button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.detail-control-ink}"
rounded: "{rounded.md}"
padding: "0 12px"
detail-button-primary:
backgroundColor: "{colors.action-blue}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "0 16px"
detail-button-primary-hover:
backgroundColor: "{colors.detail-primary-hover}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "0 16px"
---
# Design System: 品牌中心部门看板
## Overview
**Creative North Star: "深色导航轨数据工作台"**
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
它保留品牌中心自己的五个职责域、14 个小项入口,以及素材库、自动化工作流、组织架构、权限管理与价值指标等已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
**Key Characteristics:**
- 272px 固定深色导航轨,桌面可折叠为 80px。
- 72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。
- `#1677ff` 只用于当前项、主要行动和明确焦点。
- 白色面板以 8–12px 圆角、细边框和克制阴影呈现。
- 移动端导航是完整的模态抽屉;内容状态始终诚实标注。
- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。
- 组织架构复用真实组织树与人员明细,提供“品牌中心 / 全公司”范围切换,沿用原管理员权限。
- 权限管理提供“权限组 / 人员权限”两个视图,侧栏只有一个入口。
- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。
- 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。
- 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。
- 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。
- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧先标注当前钉钉组织架构在职人数,再参照业务趋势界面按“美术设计流程”和“内容视频流程”分成两个近 8 周收到/完结趋势区,并可展开每周明细。流程按当前节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点;不展示历史人员快照口径。
- 团队职能页面把花名册团队及数据库职责配置组织成可扫描的工作面:团队名和负责人作为入口,服务/定位、能力/岗位、职责作为稳定层级;四组克制色彩循环用于区分团队,节点在宽屏多列排布、窄屏纵向堆叠,不复制长图画布或产生页面级横向滚动。
- 团队职能直接进入部门选择与职责内容,主工作面距壳顶栏保留 24px 间距;不展示重复页面大标题、装饰图标、概述及说明图例,不增加返回部门看板按钮。页面与职责地图标题保留为读屏标题,跨页导航使用现有侧栏。
- 团队列表以同步数据库中品牌中心直接下级部门为准,不限定四个固定名称;阅读态只显示职责内容,超级管理员点击“编辑”后显示负责人选择、说明与分组条目表单。保存后给出简短反馈;冲突保留草稿,取消时重新加载,未保存时离开或切换部门须确认。
- 团队职能维护的保存按钮与编辑文字使用 `#0050cb` 深蓝色,保存按钮 hover 使用 `#003da3`,保证小字号操作内容的对比度;不改变既有导航色彩。
- 业绩转化页面使用 176px 六品牌二级栏;右侧先展示“流程 × 商品ID”列表,再在下方展开所选商品的身份信息、三组指标摘要、单指标每日趋势与每日明细。不得增加第三个常驻侧栏,也不得把八项指标拆成八张装饰卡;缺失日期用趋势断点和 `—` 表示,与真实零值严格区分。
## Colors
深色导航和高识别蓝色建立方向感,冷灰中性色保持高密度数据的可读性;职责域色只做局部识别,不铺满主画布。
### Primary
- **Action Blue** (`#1677ff`): 当前导航项、主要按钮、焦点环和关键链接。
- **Deep Navy Rail** (`#06192d`): 桌面固定导航和移动抽屉。
### Secondary
- **Business Orange** (`#9a4d08`), **Assets Green** (`#087052`), **Organization Gold** (`#725400`), **Governance Blue** (`#214ba7`): 五个职责域的局部图标、摘要和软色块。
- **Connected Mint** (`#31d0aa`): 已接入业务入口的小圆点状态。
### Neutral
- **Canvas Gray** (`#f2f4f7`): 主工作区背景。
- **Panel White** (`#ffffff`): 顶栏、面板、表格和字段。
- **Soft Surface** (`#f8fafc`): 面板内部的次级行或骨架。
- **Divider** (`#e2e8f0`): 面板、行、表格与字段边界。
- **Primary Ink** (`#101828`) / **Secondary Ink** (`#66758b`): 标题、正文和辅助信息。
- **Workflow Active** (`#e6f4ff`, `#0050cb`, `#315f91`): 自动化工作流二级栏的选中底、标题与说明。
- **Workflow Inactive** (`#4f6077`, `#667085`, `#eef2f6`): 未选标题、说明与 hover;焦点环固定为 `#5a9ce6`。
### Named Rules
**The Current Location Rule.** 根路径直接进入核心指标,页面只保留一个最强的 `#1677ff` 导航选中态;不设置独立“部门总览”入口,职责域色不得与当前项竞争。
**The Truth Color Rule.** 绿色只表示真实已接入,示例值和待接入状态不得伪装成成功。
**The Embedded Business Truth Rule.** 二级栏只切换既有真实业务操作面;选中态、URL 与 iframe 内容必须同步,不能用入口卡片或示例数据替代。
## Typography
**Display Font:** System UI sans-serif
**Body Font:** System UI sans-serif
**Character:** 紧凑、直接、适合中文运营后台;依靠字重和间距建立层级,不使用装饰性字体。
### Hierarchy
- **Display** (700, 36px, 1.2): 核心指标与关键业务页标题;移动端降至约 30px。
- **Headline** (700, 30px, 1.25): 指标与明细页标题。
- **Title** (700, 20px, 1.4): 工作板块、数据分区和面板标题。
- **Body** (400, 16px, 1.75): 页面说明,建议控制在约 70ch 内。
- **Label** (600, 14px, 1.4): 导航、表头、按钮和状态标签。
### Named Rules
**The Dense Tool Rule.** 顶栏和导航保持 12–14px 紧凑标签;页面标题才使用 30–36px,不把每个面板都做成英雄区。
## Layout
桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。访问根路径时直接进入核心指标;其余职责域及小项全部通过固定侧栏访问,不额外渲染部门总览工作地图。
品牌中心工作台访问范围以活跃账号绑定的权威 HR 组织路径为准。系统管理员保留维护通行能力;其余人员只有任一在职组织路径包含精确“品牌中心”节点时才能进入 `/` 与 `/workspaces/*`。范围外人员访问这些地址时服务端跳转至 `/assets/material-library`,素材库仅渲染中性独立顶栏、素材内容和退出登录,不显示品牌中心标识、侧栏、移动抽屉或任何返回入口。
1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。
自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。
右侧通过同源 iframe 直接嵌入 `/performance-survey` 与 `/work-review`,保留原业务的真实数据、权限和交互。嵌入业务中所有 `href="/"` 的跨应用返回必须使用 `target="_top"`,确保返回品牌中心根页面而不是在 iframe 内再次嵌套首页。
组织架构进入 /workspaces/organization/organization-structure,顶部以紧凑范围导航切换“品牌中心 / 全公司”,默认品牌中心。直接复用组织树与人员明细,不增加单项二级菜单或说明面板。
权限管理进入 /workspaces/governance/permissions,顶部切换“权限组 / 人员权限”。两种工作面保持挂载,切换时保留权限草稿、当前组与人员搜索。刷新移到权限组列表标题行,移除嵌入工作面的重复标题和跨页按钮。
**The Two-Level Workflow Rule.** 自动化工作流的二级栏只包含两个业务菜单,不添加标题、数量、说明头或入口卡;右侧始终是当前菜单对应的真实操作面。
**The Direct Data Source Rule.** 组织架构直接复用真实组织工作面与原权限边界,两个范围使用同一花名册来源。
**The Brand Center Scope Rule.** 品牌中心范围只呈现该节点及其下级人员,节点缺失显示同步提示,禁止自动回退全公司;全公司范围由用户明确切换。
**The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。
**The Organization Gate Rule.** 品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。
## Elevation & Depth
系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。
### Shadow Vocabulary
- **Panel Low** (`0 6px 18px rgba(15, 23, 42, 0.05)`): 仅用于需要与画布分离的主要白色面板。
- **Menu** (`0 14px 38px rgba(15, 23, 42, 0.16)`): 账户菜单、移动浮层和可拖动榜单。
### Named Rules
**The Border-First Rule.** 默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。
## Shapes
主表面使用 8–12px 圆角:8px 用于字段、导航项和小面板,12px 用于职责域和大数据面板。固定侧栏和顶栏已提供位置上下文,子页面不重复渲染面包屑。圆形只用于状态点、头像和明确图标容器。不得恢复 22–28px 的门户式主表面。
## Components
### Buttons
- **Shape:** 8–12px 圆角,最小触控高度 40–44px。
- **Primary:** `#1677ff` 背景和白字;hover 变深,focus-visible 使用清晰蓝色环。
- **Secondary:** 白底、`#e2e8f0` 边框和深色文字。
### Chips
- **Style:** 8px 或全圆角;状态文案必须同时说明“实时读取”“示例值”或“待接入”,不能只靠颜色。
### Cards / Containers
- **Corner Style:** 主要面板 12px,内部分区 8px。
- **Background:** 白色;次级行可用 `#f8fafc`。
- **Shadow Strategy:** 边框优先,阴影克制。
- **Internal Padding:** 20–32px,随屏宽收紧。
### Inputs / Fields
- **Style:** 白底、8px 圆角、`#e2e8f0` 边框。
- **Focus:** `#1677ff` 边框或 2px focus ring。
- **Error / Disabled:** 红色错误与降低不透明度并用,保留文字解释。
### Navigation
- 深色轨默认文字为 `#aebdcd`,hover 使用低透明白色,当前项使用 `#1677ff` + 白字。
- 侧栏从五个职责域直接开始,不显示独立“部门总览”入口;根路径与“核心指标”入口指向同一默认工作面。
- 账户入口固定在侧栏最下方并向上展开菜单;顶栏只保留页面定位与导航折叠控制。
- 子页面标题直接承接首屏内容,不在标题上方增加重复的面包屑面板。
- 14 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。
- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。
- “组织架构”集中品牌中心与全公司范围,默认品牌中心,旧人员架构和数据口径路径分别跳转至对应范围。
- “权限管理”集中权限组与人员权限视图,旧路径跳转至对应视图,不再保留独立侧栏入口。
- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。
- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。
### Automation Workflow Split Workbench
- **Desktop Structure:** 深色主导航右侧是 232px 白 / 雾灰二级栏,再右侧是同源真实操作面;整体填满 `calc(100dvh - 72px)`,不生成欢迎页或入口卡片。
- **Menus:** 只显示“绩效考核调研”和“作品过稿看板”。选中项使用 `#e6f4ff` 底、`#0050cb` 标题、`#1677ff` 图标和 `#315f91` 说明;未选项使用 `#4f6077` 标题、`#667085` 说明,hover 为 `#eef2f6`,active hover 不得覆盖选中底。
- **Focus:** 两个菜单最小高度 58px,8px 圆角,焦点环为 `#5a9ce6`;当前项同时设置 `aria-current="page"`。
- **Mobile:** 同一菜单移动到操作面顶部并变为两列;桌面说明在窄屏隐藏,顺序和 URL 语义不变。
- **Embedding:** `/performance-survey` 与 `/work-review` 直接保留各自真实数据、权限、工具栏和交互;`?workflow=` 记录选择。所有跨应用根路径返回使用 `target="_top"`。
- **Permanent Omission:** 二级栏顶部永久不显示“自动化工作流”、接入数量、统计区或解释区。
### Data Definitions Split Workbench
- **Desktop Structure:** 主导航右侧是 232px 雾灰二级栏,再右侧是可独立滚动的真实公司组织架构工作面。
- **Direct Menu:** 二级栏只显示“公司组织架构”,选中态复用 `#e6f4ff`、`#0050cb`、`#1677ff` 与 `#315f91`;不得增加“数据来源”标题或折叠控件。
- **Permissions:** 仅 `ADMIN`、`SYSTEM_ADMIN` 与 `DEPT_ADMIN` 复用真实组织树;其他角色保留二级栏并显示明确权限提示,不重定向或嵌套首页。
- **Mobile:** 单项菜单移动到工作面顶部并保持 58px 最小高度;组织树与人员表由右侧工作面独立滚动。
### Data Status Placement
所有子页面从页面标题直接进入核心内容,不渲染独立的“数据接入说明”“数据来源说明”或“计算说明”面板。真实读取、示例值、待接入、读取失败和公式状态由对应卡片或数据模块自身承担;实际业务操作帮助不属于此类,仍应保留在需要的位置。
### Standalone Material Library Shell
- **Audience:** 品牌中心权威组织范围外的普通用户。
- **Structure:** 64px 白色中性顶栏 + 完整素材资产工作面;不渲染 Department Shell、侧栏、品牌中心副标题或返回图标。
- **Actions:** 顶栏只提供素材资产库定位和退出登录;素材上传、筛选、分享与下载能力仍由原业务权限控制。
- **Security:** `/` 与 `/workspaces/*` 同时经过会话声明、Middleware 和服务端 Layout 门禁,禁止仅靠 CSS 或条件隐藏导航。
### Permission Groups Workbench
- **Scope:** `PermissionGroupsDashboard` 延续 Department Shell 的深色导航轨、浅色画布与白色工作面,采用任务优先的 Operate 模式。
- **Structure:** 桌面左侧为可搜索的 224px 权限组列表,右侧固定“素材权限、部门、人员、组设置”四个页签。主区域以 flex 填满顶栏下方视口,页签内容独立滚动,底部保存栏作为正常 flex 项占位。
- **Mobile:** 1024px 以下以原生选择框切换权限组;640px 以下权限矩阵转为两列勾选列表,四个页签保留文字,保存栏在页面底部 sticky。
- **Save States:** 素材权限明确点击“保存”,有草稿时底栏显示“X 项未保存”并提供“撤销”;草稿跨页签与同组部门、人员管理动作保留。组名通过“保存组名”确认;部门、人员归属及视频提前访问切换立即生效,界面明确标注。
- **Minimal Copy:** 控件文字承担说明;移除重复标题、数量和大段帮助,仅保留未保存、立即生效、错误与确认信息。部门/人员/组设置无素材草稿时不显示保存栏;组织完整路径保留在悬停提示与辅助语义中。
- **Scoped Primary Action:** 本工作台的创建与权限保存按钮使用深蓝底(`#0050cb`)和白字,对比度约 6.98:1;hover 为 `#003da3`,focus-visible 保留 `#1677ff` 轮廓。此变体仅限权限组工作台,其他页面沿用全局 Action Blue。
- **Interaction:** 页签提供 tab/tabpanel 关联与方向键、Home/End 导航。组或页签切换以限定在工作面内的 GSAP `useGSAP` 呈现 180ms `expo.out` 轻移与显现;reduced-motion 时不运行 tween,更新与卸载时 revert 清理。
### User Permissions List
- **Scope:** `UserPermissionsDashboard` 属于 Operate 模式,沿用 Department Shell 的视觉语言。
- **Density:** 搜索结果使用一个白色列表,桌面共享“人员、部门组、个人组、加组”表头,常规人员行约 68px;姓名、账号/部门、继承来源和个人组均在同行,不再为每个人建立大卡片。
- **Responsive:** 1280px 以下转为两列;640px 以下加组与人物身份同一行,部门组和个人组在下方换行。长名称与多组允许行高自然增加。
- **Interaction:** 个人组提供具备人员/组名的移除按钮,独立加组选择器保留原提交行为;部门继承来源仅展示。完整部门路径保留在悬停提示及辅助语义中,错误与成功状态使用 alert/status。
### Detail Generator Controls
- **Scope:** `components/detail-generator/DetailProductForm.tsx` 导出的 `detailFieldClass`、`detailButtonClass` 和 `detailPrimaryClass`,在资料、大纲与单屏编辑器中复用;`detail-*` token 只记录这组已实现控件,不替换其他页面的字段或按钮。
- **Fields:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框,14px 文字与 `8px 12px` 内边距;placeholder 使用 `#66758b`。聚焦显示 2px `#1677ff` 环;禁用时为 `#f8fafc` 底与 `#66758b` 文字。长文案使用可纵向调整的 textarea。
- **Secondary Button:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框、`#344054` 文字,左右 12px 内边距;hover 为 `#f8fafc`。
- **Primary Button:** 最小高度 44px、8px 圆角、`#1677ff` 底与白字,左右 16px 内边距;hover 为 `#0964de`。两种按钮的 focus-visible 均为 2px 蓝环与 2px 白色间隔,disabled 降为 50% 不透明度并显示不可用指针。
- **Surface Contract:** 详情页的 Operate 模式、分阶段构图、750px 画布、编辑范围、响应式与验收边界记录在 `.impeccable/surfaces/app-assets-detail-generator-page-tsx.md`,不提升为全项目规则。
### AI Model Management Workbench
`components/admin/AiModelManagementPanel.tsx` 是 `/admin` 超级管理员工作台的第四个操作分区,沿用现有 232px 二级导航和白色工作面。上游连接行之后是资料分析、卖点文案、图片生成三个默认模型与目录;目录按模型或上游搜索、能力类型及开放状态筛选,每页 20 行,在 1280px 起对齐为“模型 / 上游、开放给用户、用户可选用途、接口兼容”四列。模型能力由服务端提供,类型用短文字标识;不适配用途禁勾,未知标“待核验”,已识别但尚无适配用途标“待适配”,人工兼容确认不能越过能力限制。用户可在后台开放的阶段模型中自主选择;取消最后用途同步关闭开放,并清空受影响默认值。手机默认项纵向排列,搜索独占整行,两个筛选器各占半行;按钮、字段和勾选标签最小高度为 44px,字段与按钮使用 8px 圆角,保存主按钮沿用深蓝变体(`#0050cb`,hover `#003da3`)。API Key 只显示保存状态,密码字段只用于新增或替换;当前服务端连接必须显式迁入,目录同步只读,接口兼容由管理员确认,不表述为测试通过。错误与保存反馈靠近操作,切换分区和离开页面保护未保存修改;本表面的任务构图与本地验收边界记录在 `.impeccable/surfaces/components-admin-aimodelmanagementpanel-tsx.md`。
## Do's and Don'ts
### Do:
- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
- **Do** 保持 5 个职责域、14 个小项与 7 个框架入口,合并入口以真实职责为准。
- **Do** 让价值指标直接复用素材资产库数据,按资产类型显示复用率,并按下载次数降序展示明细。
- **Do** 让品牌知识库沿用素材资产库的搜索、分类、排序与内容网格节奏,并在数据未接入时显示诚实空状态。
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
- **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。
- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。
- **Do** 将花名册组织数据集中到组织架构,沿用主线管理员权限。
- **Do** 保持品牌中心范围的组织与人员边界,权限视图切换保留未保存草稿。
- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。
### Don't:
- **Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。
- **Don't** 恢复独立部门总览页或在核心指标页提供“返回部门看板”按钮。
- **Don't** 让职责域标题本身成为导航;只有下方小项链接进入二级页面。
- **Don't** 复制参考图中的京东品牌、店铺指标、水印或虚构业务数据。
- **Don't** 让移动抽屉键盘焦点穿透到底层页面。
- **Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。
- **Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。
- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。
- **Don't** 为同一职责增加重复侧栏入口、单项二级菜单或占位说明卡。
- **Don't** 让品牌中心范围自动回退全公司,或复制脱离主线数据源的人员列表。
- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。

View File

@ -1,8 +1,27 @@
# 基础镜像(使用 Alpine,手动处理 OpenSSL 兼容性)
ARG BASE_IMAGE=node:18-alpine
# 2026-08-12 通过 `docker buildx imagetools inspect node:18-alpine` 核验的
# Docker Hub 官方多架构清单 digest。生产默认值必须是不可变引用;本地测试
# 如需改镜像源,仍可显式传入 BASE_IMAGE,但发布脚本不得覆盖此默认值。
ARG BASE_IMAGE=node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e
ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine
# Local/CI builds remain possible without Git metadata. Production deployment
# supplies the exact SHA and rejects this sentinel before any container starts.
ARG VCS_REF=0000000000000000000000000000000000000000
ARG DETAIL_RENDERER_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
FROM ${BASE_IMAGE} AS base
# Fabric's Node renderer has a separate dependency tree and Node runtime.
# The platform's existing Canvas 2 installation remains independent.
FROM ${DETAIL_RENDERER_IMAGE} AS detail-renderer-deps
ARG ALPINE_MIRROR
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev
WORKDIR /opt/detail-renderer
COPY workers/detail-renderer/package.json workers/detail-renderer/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm CXXFLAGS="-include cstdint" npm_config_build_from_source=canvas npm ci --no-audit --no-fund
COPY workers/detail-renderer/ ./
RUN node -e "require('canvas').createCanvas(1,1); console.log('isolated Canvas smoke passed')"
# 安装依赖阶段
FROM base AS deps
ARG ALPINE_MIRROR
@ -10,20 +29,35 @@ RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk
apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev
WORKDIR /app
# 复制 package.json(利用 Docker 层缓存)
COPY package.json package-lock.json* ./
# canvas 在项目里是可选依赖,且服务端代码已做降级处理。
# 构建机当前卡在 canvas 的 node-pre-gyp 安装阶段,因此这里只在镜像构建时跳过它。
RUN node -e "const fs=require('fs'); const pkg=require('./package.json'); if (pkg.optionalDependencies && pkg.optionalDependencies.canvas) { delete pkg.optionalDependencies.canvas; fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n'); }"
# package-lock.json 是生产构建的必需输入,缺失时 COPY 直接失败。
COPY package.json package-lock.json ./
# 安装依赖(使用淘宝镜像 + BuildKit缓存)
RUN --mount=type=cache,target=/root/.npm \
manifest_sha="$(sha256sum package.json | awk '{print $1}')" && \
lock_sha="$(sha256sum package-lock.json | awk '{print $1}')" && \
echo "Installing dependencies (using cache if available)..." && \
npm config set registry https://registry.npmmirror.com && \
npm install --prefer-offline --no-audit --no-fund --legacy-peer-deps && \
npm_config_build_from_source=canvas npm_config_nodedir=/usr/local npm ci --prefer-offline --no-audit --no-fund --legacy-peer-deps && \
test "$manifest_sha" = "$(sha256sum package.json | awk '{print $1}')" && \
test "$lock_sha" = "$(sha256sum package-lock.json | awk '{print $1}')" && \
node -e "const sharp=require('sharp'); sharp(Buffer.alloc(4),{raw:{width:1,height:1,channels:4}}).png().toBuffer().then(()=>console.log('sharp dependency smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \
echo "Dependencies installed ($(du -sh /app/node_modules | cut -f1))"
# 本地开发阶段:复用依赖层,并补齐项目运行时会调用的媒体处理工具。
# 源码由 Compose 绑定挂载,避免每次修改代码都重新构建镜像。
FROM deps AS dev
ARG ALPINE_MIRROR
RUN apk add --no-cache ffmpeg openssl poppler-utils
ENV NODE_ENV=development
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
# 构建阶段
FROM base AS builder
ARG ALPINE_MIRROR
@ -33,6 +67,61 @@ RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VCS_REF
# The source revision is immutable image metadata, not a runtime environment
# assertion. Production builds must provide the exact 40-character Git SHA.
RUN if ! printf '%s\n' "$VCS_REF" | grep -Eq '^[0-9a-f]{40}$'; then \
echo "VCS_REF must be the exact 40-character lowercase Git SHA" >&2; \
exit 1; \
fi && \
printf '%s\n' "$VCS_REF" > /app/DEPLOYMENT_COMMIT && \
chmod 0444 /app/DEPLOYMENT_COMMIT
# 品牌资源是页面和 favicon 的离线兜底;缺失或空文件时禁止继续构建。
RUN test -s "/app/public/brand/header-logo.png" && \
test -s "/app/public/brand/login-logo.png" && \
test -s "/app/public/brand/favicon.png" && \
node -e "const sharp=require('sharp'); Promise.all(['header-logo.png','login-logo.png','favicon.png'].map((name)=>sharp('/app/public/brand/'+name,{failOn:'error',limitInputPixels:25000000,animated:false}).rotate().toBuffer())).then(()=>console.log('builder brand image decode smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \
test -f "/app/prisma/migrations/20251118000000_add_upload_id_to_materials/migration.sql" && \
test -f "/app/prisma/migrations/20260811_add_video_transcode_lease/migration.sql" && \
test -f "/app/prisma/migrations/20260811_update_brand_logo_defaults/migration.sql" && \
test -f "/app/prisma/migrations/20260812_add_product_package_upload_job_lease/migration.sql" && \
test -f "/app/prisma/migrations/20260812_align_material_upload_idempotency_index/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_material_custody_department/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql" && \
test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql" && \
test -f "/app/prisma/migrations/20260819_add_user_permission_inherit/migration.sql" && \
test -f "/app/prisma/migrations/20260820_add_permission_group_members/migration.sql" && \
test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql" && \
test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql" && \
test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \
test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \
test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \
test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \
test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \
test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" && \
test -f "/app/prisma/migrations/20260829_add_recoverable_api_key_secret/migration.sql" && \
test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" && \
test -f "/app/prisma/migrations/20260902_add_employee_roster_history/migration.sql" && \
test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql" && \
test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" && \
test -f "/app/prisma/migrations/20260909_expand_brand_knowledge_upload/migration.sql" && \
test -f "/app/prisma/migrations/20260914_add_brand_knowledge_engagement/migration.sql" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql" && \
test -f "/app/prisma/migrations/20260915_add_brand_knowledge_soft_delete/migration.sql" && \
test -f "/app/prisma/migrations/20260918_add_performance_conversion_sync/migration.sql" && \
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \
test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql"
RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \
test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql" && \
test -f "/app/prisma/migrations/20261012_ai_model_registry/migration.sql" && \
test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql" && \
test -f "/app/prisma/migrations/20261013_ai_model_verifications/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -68,6 +157,56 @@ RUN echo "清理 standalone 目录..." && \
rm -rf .next/standalone/scripts && \
echo "清理完成"
# Install only the migration/backup/seed dependency closure. Its lock is derived
# from the application lock and checked before installation to prevent drift.
FROM base AS migrator-deps
ARG ALPINE_MIRROR
WORKDIR /app
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache openssl
COPY package-lock.json ./package-lock.json
COPY workers/database-migrator/package.json workers/database-migrator/package-lock.json ./workers/database-migrator/
COPY scripts/database-migrator-lock.mjs ./scripts/database-migrator-lock.mjs
RUN node scripts/database-migrator-lock.mjs --check
# The matching generated client is copied from builder; do not generate a second
# client during installation. The CLI engines still run their normal postinstall.
RUN --mount=type=cache,target=/root/.npm \
cd workers/database-migrator && PRISMA_SKIP_POSTINSTALL_GENERATE=1 npm ci --no-audit --no-fund
# Explicit, one-shot database migrator. It is never used by the application
# container or the normal deploy entry. The operator must supply production DB
# identity, exact source SHA and a hashed backup receipt before --apply works.
FROM base AS migrator
ARG ALPINE_MIRROR
WORKDIR /app
ENV NODE_ENV=production
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache openssl postgresql-client && \
command -v openssl >/dev/null && \
command -v pg_dump >/dev/null && \
command -v pg_restore >/dev/null && \
command -v psql >/dev/null
ARG VCS_REF
LABEL org.opencontainers.image.revision=$VCS_REF
COPY --from=migrator-deps --chown=root:root /app/workers/database-migrator/node_modules ./node_modules
COPY --from=builder --chown=root:root /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=root:root /app/prisma ./prisma
COPY --from=builder --chown=root:root /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs
COPY --from=builder --chown=root:root /app/scripts/create-verified-production-backup.mjs ./scripts/create-verified-production-backup.mjs
COPY --from=builder --chown=root:root /app/scripts/seed-team-function-config.ts ./scripts/seed-team-function-config.ts
# Source closure for the team-function seed; no application routes or services.
COPY --from=builder --chown=root:root /app/lib/auto-personnel-rules.ts /app/lib/organization-material-permissions.ts /app/lib/prisma.ts /app/lib/team-function-contract.ts /app/lib/department-permission.ts /app/lib/employee-directory.ts /app/lib/env-validation.ts /app/lib/material-type-mapper.ts /app/lib/material-type-options.ts /app/lib/permission-groups.ts /app/lib/team-functions.ts /app/lib/user-permission-inherit.ts /app/lib/video-material-types.ts ./lib/
COPY --from=builder --chown=root:root /app/tsconfig.json ./tsconfig.json
COPY --from=builder --chown=root:root /app/data/team-function-initial-content.json ./data/team-function-initial-content.json
COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT
RUN test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
test -x /app/node_modules/prisma/node_modules/@prisma/engines/schema-engine-linux-musl-openssl-3.0.x && \
test "$(stat -c '%a' /app/DEPLOYMENT_COMMIT)" = 444
USER node
# Prove the CLI can use the bundled native engine without downloading or writing.
RUN --network=none ./node_modules/.bin/prisma -v
ENTRYPOINT ["node", "scripts/safe-production-migrate.mjs"]
# 生产运行阶段
FROM base AS runner
WORKDIR /app
@ -77,41 +216,92 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG ALPINE_MIRROR
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache ffmpeg python3 cairo jpeg pango giflib openssl poppler-utils
apk add --no-cache ffmpeg python3 cairo jpeg pango giflib openssl poppler-utils flock && \
command -v flock >/dev/null && \
flock --exclusive --timeout 1 --conflict-exit-code 75 --no-fork \
/tmp/material-upload-flock-probe.lock sh -c 'true' && \
rm -f /tmp/material-upload-flock-probe.lock
ARG VCS_REF
LABEL org.opencontainers.image.revision=$VCS_REF
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
adduser --system --uid 1001 --ingroup nodejs nextjs
RUN mkdir -p /app/scripts
# 复制必要文件
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/app ./app
COPY --from=builder --chown=root:root /app/.next/standalone ./
COPY --from=builder --chown=root:root /app/.next/static ./.next/static
COPY --from=builder --chown=root:root /app/app ./app
COPY --from=builder --chown=root:root /app/public ./public
COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT
COPY --from=detail-renderer-deps --chown=root:root /opt/detail-renderer /opt/detail-renderer
COPY --from=detail-renderer-deps --chown=root:root /usr/local/bin/node /opt/detail-renderer/bin/node
ENV DETAIL_RENDERER_NODE=/opt/detail-renderer/bin/node
ENV DETAIL_RENDERER_ENTRY=/opt/detail-renderer/cli.mjs
# 只复制运行时必需的 node_modules
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ioredis ./node_modules/ioredis
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@ioredis ./node_modules/@ioredis
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@smithy ./node_modules/@smithy
COPY --from=builder --chown=root:root /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=root:root /app/node_modules/ag-psd ./node_modules/ag-psd
COPY --from=builder --chown=root:root /app/node_modules/ioredis ./node_modules/ioredis
COPY --from=builder --chown=root:root /app/node_modules/@ioredis ./node_modules/@ioredis
COPY --from=builder --chown=root:root /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
COPY --from=builder --chown=root:root /app/node_modules/@smithy ./node_modules/@smithy
# 只复制 schema.prisma,不复制 migrations 和 engine-cache(减少 35MB)
COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
# 复制运行时 schema,以及切流前只读校验所需的精确 migration;仍不复制 engine-cache。
COPY --from=builder --chown=root:root /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder --chown=root:root /app/prisma/migrations/20251118000000_add_upload_id_to_materials ./prisma/migrations/20251118000000_add_upload_id_to_materials
COPY --from=builder --chown=root:root /app/prisma/migrations/20260811_add_video_transcode_lease ./prisma/migrations/20260811_add_video_transcode_lease
COPY --from=builder --chown=root:root /app/prisma/migrations/20260811_update_brand_logo_defaults ./prisma/migrations/20260811_update_brand_logo_defaults
COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_add_product_package_upload_job_lease ./prisma/migrations/20260812_add_product_package_upload_job_lease
COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_align_material_upload_idempotency_index ./prisma/migrations/20260812_align_material_upload_idempotency_index
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_material_custody_department ./prisma/migrations/20260813_add_material_custody_department
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_organization_units_and_employment_state ./prisma/migrations/20260813_add_organization_units_and_employment_state
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_user_employee_binding ./prisma/migrations/20260813_add_user_employee_binding
COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_material_is_incomplete_shell ./prisma/migrations/20260817_add_material_is_incomplete_shell
COPY --from=builder --chown=root:root /app/prisma/migrations/20260819_add_user_permission_inherit ./prisma/migrations/20260819_add_user_permission_inherit
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_permission_group_members ./prisma/migrations/20260820_add_permission_group_members
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_unique_permission_group_department ./prisma/migrations/20260820_unique_permission_group_department
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_workflow_snapshots ./prisma/migrations/20260820_add_ding_workflow_snapshots
COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_ding_workflow_insights ./prisma/migrations/20260824_expand_ding_workflow_insights
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type
COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals
COPY --from=builder --chown=root:root /app/prisma/migrations/20260829_add_recoverable_api_key_secret ./prisma/migrations/20260829_add_recoverable_api_key_secret
COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_private_task_detail ./prisma/migrations/20260831_add_private_task_detail
COPY --from=builder --chown=root:root /app/prisma/migrations/20260902_add_employee_roster_history ./prisma/migrations/20260902_add_employee_roster_history
COPY --from=builder --chown=root:root /app/prisma/migrations/20260903_add_work_review_brand ./prisma/migrations/20260903_add_work_review_brand
COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_knowledge ./prisma/migrations/20260908_add_brand_knowledge
COPY --from=builder --chown=root:root /app/prisma/migrations/20260909_expand_brand_knowledge_upload ./prisma/migrations/20260909_expand_brand_knowledge_upload
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_add_brand_knowledge_engagement ./prisma/migrations/20260914_add_brand_knowledge_engagement
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_download_count ./prisma/migrations/20260914_expand_brand_knowledge_download_count
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices ./prisma/migrations/20260914_expand_brand_knowledge_preview_slices
COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_knowledge_soft_delete ./prisma/migrations/20260915_add_brand_knowledge_soft_delete
COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync
COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata
COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications
COPY --from=builder --chown=root:root /app/prisma/migrations/20261020090000_desktop_client_workspace ./prisma/migrations/20261020090000_desktop_client_workspace
COPY --from=builder --chown=root:root /app/lib ./lib
COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
# 创建必要目录并设置权限
RUN mkdir -p /app/public /app/uploads && \
mkdir -p /app/scripts && \
mkdir -p /app/uploads/temp && \
mkdir -p /app/uploads/assembled && \
mkdir -p /app/uploads/materials && \
mkdir -p /app/uploads/cleanup && \
chown -R nextjs:nodejs /app/public /app/uploads && \
chmod -R 777 /app/uploads
# 代码在 COPY 时直接归 root,避免再次改动整棵目录产生重复镜像层。
# 仅运行时缓存和存储目录允许 nextjs 写入,代码权限保持只读。
RUN install -d -o nextjs -g nodejs -m 0755 /app/.next/cache && \
install -d -o nextjs -g nodejs -m 0750 /app/uploads /app/uploads/temp /app/uploads/assembled /app/uploads/materials /app/uploads/cleanup && \
install -d -o nextjs -g nodejs -m 0700 /app/detail-generator-private && \
test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
test "$(stat -c '%a' /app/DEPLOYMENT_COMMIT)" = 444 && \
test -s /app/public/brand/header-logo.png && \
test -s /app/public/brand/login-logo.png && \
test -s /app/public/brand/favicon.png && \
node -e "const sharp=require('sharp'); Promise.all(['header-logo.png','login-logo.png','favicon.png'].map((name)=>sharp('/app/public/brand/'+name,{failOn:'error',limitInputPixels:25000000,animated:false}).rotate().toBuffer())).then(()=>console.log('runner brand image decode smoke passed')).catch((error)=>{console.error(error);process.exit(1)})"
USER nextjs

68
PRODUCT.md Normal file
View File

@ -0,0 +1,68 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
品牌中心内部成员、部门负责人和管理者需要从统一入口定位业务、资产、组织与治理相关工作;品牌中心架构以外的人员只使用素材资产库,并按自身权限浏览、上传、分享或下载素材,品牌中心工作台、组织、管理、考核与过稿工作面统一重定向回素材库。
## Product Purpose
“品牌中心部门看板”是品牌中心的内部工作台,默认直接进入“核心指标”,并用五个业务域组织其余部门工作入口:中心仪表盘、业务经营、资产与AI自动化、组织管理、治理与设置。底层业务数据按确认后的口径逐步接入。
## Operating Context
产品运行在内部 Web 环境中,既有“由壹点3D素材”平台已经在线运行。本地开发阶段必须与线上数据和部署链路隔离;代码只有在用户明确指示后才允许提交或部署。
## Capabilities and Constraints
- 根路径 `/` 直接跳转至 `/workspaces/dashboard/core-metrics`,不再展示独立部门总览页。
- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
- “组织架构”统一品牌中心与全公司范围,默认品牌中心,复用真实组织树、人员明细和原管理员权限。
- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
- “权限管理”统一权限组与人员权限视图;组织数据归组织管理,不再保留数据口径的独立入口。旧链接跳转至对应的新视图。
- “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。
- “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。
- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。
- 美术设计申请流程的 `ding_WorkflowTask.activityId` 按已确认流程定义映射为精确中文节点名并写入本地快照;未知节点继续使用同步字段推断或明确回退,不改写上游只读数据。
- 钉钉流程快照每小时从只读报表库的 `ding_Workflow`、`ding_WorkflowTask`、`ding_WorkflowScheme` 联合增量同步,只接收已批准的美术设计申请和内容视频流程;三表复合游标写入现有同步状态,并每 24 小时全量校准流程、品牌和商品 ID,修正上游未推进游标的历史表单改动。
- “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。
- “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。
- “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。
- “价值指标”直接读取素材资产库的可见素材,按资产类型展示下载过的资产数、该类型总数与复用率,并按下载次数从高到低列出该类型资产明细。
- “业绩转化”按亿家达、卓禾、欧意朗、顾家、索菲亚、朴美六个品牌展示 2026 年 4 月 1 日以后发起且已批准完结的美术设计流程;商品经营关联键优先取流程表单“商品ID”,兼容分支字段“现商品ID”,空值和非数字占位内容不参与关联。每条“流程 × 商品ID”独立保留审批编号、流程实例 ID、完结时间和六个月监控窗口,并按需读取该商品流程完结后的每日点击率、支付转化率、收藏、加购、展现、访客、推广 ROI 与支付金额;缺失日保持缺失,不补成零。
- “品牌知识库”参照素材资产库的浏览工作面建立搜索、知识类型切换、排序与内容网格结构;真实知识数据和上传能力未接入前必须显示明确空状态,不虚构知识条目或数量。
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。
- “团队职能”实时读取花名册中品牌中心的直接下级部门;部门与负责人显示当前数据库名称,负责人由超级管理员按在职人员 ID 明确指定,不按花名、职级猜测。服务品牌、能力、岗位、职责及团队说明由独立配置表保存,通过页内编辑维护;配置绑定部门 ID,HR 同步改名不覆盖职责,负责人离职或调出后显示关联失效。旧职责图内容保留为显式 ID 导入模板,不参与运行时兜底。
- 除素材资产库、自动化工作流、组织架构、团队职能、权限管理、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
## Brand Commitments
- 产品主标题使用“品牌中心部门看板”。
- 信息架构以用户提供的五分支部门工作地图为依据,使用克制、清晰的业务语言。
- 保留“由壹点3D素材”现有名称和功能语义。
## Evidence on Hand
- 用户提供的五板块思维导图截图。
- 当前仓库内已运行的素材库、人员架构页面、登录、权限和后台功能。
- 暂无用于一级门户的真实业务指标,界面不得编造数值或经营结论。
## Product Principles
- 先让入口与职责边界清楚,再接入数据。
- 核心指标作为默认工作面,固定侧栏负责其它能力导航,具体页面负责完成任务。
- 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。
- 组织归属门禁必须服务端收紧,隐藏导航不能替代路由权限校验。
- 对不可用能力诚实标注状态,不用假数据制造完成感。
## Accessibility & Inclusion
门户的核心导航必须支持键盘操作、清晰焦点、语义化标签,并在桌面端与移动端保持可读和可用。

115
README.md
View File

@ -9,7 +9,7 @@
- 基于 Next.js 14 App Router 的素材协作与分发系统
- 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径
- 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、内外网状态识别、健康检查等完整链路
- 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布
## 当前核心能力
@ -41,23 +41,33 @@
- 标准模特白底图
- SKU 维度文件管理、排序、批量下载
- 详情图 / 素材资产管理
- 直播切片(仅视频):直播日期 / 主播 / 平台元数据,批量多选上传,关键词搜主播与平台,按平台与日期区间筛选,豁免 15 天视频下载保护期
- 证书-检测报告元数据:报告标题、报告类型(成品 / 原材料)、检测与到期日期(到期默认检测日期 + 1 年)
- 商品套包未完成上传的中止与回滚
- 视频转码、封面 / 预览片段生成、转码队列与重试
- 视频转码、封面 / 预览片段生成、PostgreSQL 租约任务恢复与重试
- 默认生成 720P H.265 主预览和 10 秒片段;素材建档后异步经历 `pending → processing → done/failed`
### 4. 后台与权限
- 素材管理、用户管理、分类管理
- 部门管理与部门素材权限控制
- 数据统计、统计导出、素材操作日志
- 回收站永久删除:数据库 outbox 任务 + 应用内 worker(租约、重试、断点续跑、审计),引用检查走 GIN 表达式索引
- 权限组绑定花名册组织身份;品牌中心"团队职能"数据库化配置
- API Key、API 日志、系统设置、回收站、超级管理员相关能力
### 5. 运行与诊断
- 网络诊断页面与接口
- 内外网状态识别、健康检查与受控运维监控
- 健康检查接口与内存检查接口
- 快速性能检查接口
- 内外网 URL 重写与下载链路适配
### 6. AI 能力(灰度中,默认关闭)
- AI 模型接入管理(仅超管):上游服务商与模型注册表,按用途做"真实协议测试"(真读图、真出图)后才允许开放;能力变更即要求重新验证
- AI 商品详情页生成器(正式验收前仅超管):资料分析 → 卖点大纲 → 逐屏生成 → 导出 PNG / 长图 / 分层 PSD;渲染走独立子进程 `workers/detail-renderer`(自带依赖锁,与主应用隔离)
## 技术栈
- 前端:Next.js 14、React 18、TypeScript、Tailwind CSS
@ -74,17 +84,20 @@
```text
gongxiangp2/
├── app/ # 页面、App Router、API 路由
│ ├── admin/ # 管理后台页面
│ ├── admin/ # 管理后台页面(素材库管理,旧壳待统一)
│ ├── api/ # 业务 API
│ ├── login/ # 登录页
│ ├── materials/ # 创意资产库(原素材库首页)
│ ├── organization/ # 组织管理(人员架构顶层页)
│ ├── register/ # 注册页
│ ├── share/ # 分享页
│ └── page.tsx # 首页
├── components/ # 通用组件、后台组件、上传组件
│ └── page.tsx # 登录与权限跳转至实际工作面
├── components/ # 通用组件、后台组件、上传组件、看板壳(board/)
├── lib/ # 业务工具、上传/下载/转码/权限逻辑
├── prisma/ # Prisma schema
├── scripts/ # 运维/修复/清理脚本
├── docs/ # 网络、团队协作等项目文档
├── workers/ # 独立子进程:detail-renderer(详情页渲染)、database-migrator(精简迁移镜像)
├── docs/ # 网络、接口与部署等项目文档
├── uploads/ # 本地上传与处理中间文件
└── README.md
```
@ -105,6 +118,10 @@ gongxiangp2/
- `ApiCallLog`
- `VideoTranscodeProgress`
- `SystemSettings`
- AI 模型注册表:`AiModelProvider` / `AiModelCatalog` / `AiModelVerification` / `AiModelConfiguration` / `AiModelAudit`
- 详情页生成器:`DetailGeneratorProject` / `DetailGeneratorBrandProfile` / `DetailGeneratorAsset` / `DetailGeneratorJob`
- 回收站永久删除:`MaterialPurgeJob` / `MaterialPurgeAudit`
- 组织与团队职能:`TeamFunctionConfig` / `TeamFunctionConfigAudit`
角色枚举当前包含:
@ -132,6 +149,13 @@ DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=replace-this-with-a-strong-secret
```
启用素材平台 API Key 创建和后续查看时,还必须配置独立且稳定的加密密钥:
```bash
# 至少 32 个字符;生产环境必须独立生成并稳定保存
API_KEY_ENCRYPTION_KEY=replace-this-with-a-separate-strong-secret
```
常用配置:
```bash
@ -139,7 +163,7 @@ NEXTAUTH_SECRET=replace-this-with-a-strong-secret
NEXTAUTH_URL=http://localhost:3000
APP_PUBLIC_URL=http://localhost:3000
# 本地文件目录
# 仅限直接运行 `npm run dev` 的非容器本地开发目录
UPLOAD_DIR=./uploads
# S3 / MinIO
@ -164,6 +188,11 @@ DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL=robot_oto
DINGTALK_ROBOT_CODE=your-dingtalk-robot-code
# 灰度阶段填写允许走机器人单聊的钉钉 userId,多个用逗号分隔;留空表示全量启用
DINGTALK_ROBOT_OTO_TEST_USER_IDS=user-id-1,user-id-2
# 通知中心管理后台代理(仅服务端使用,绝不能下发到浏览器)
YYD_NOTIFICATION_CENTER_ADMIN_URL=https://notify-admin.example.com/notification-center/admin
YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET=replace-with-server-side-admin-secret
# 可选,请求超时毫秒;默认 8000
YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS=8000
# 下载与外部接口
DOWNLOAD_TOKEN_SECRET=replace-this
@ -179,7 +208,8 @@ SERVER_IP=127.0.0.1
MINIO_API_PORT=9000
# 视频转码(按需)
VIDEO_TRANSCODE_MAX_CONCURRENT=2
VIDEO_TRANSCODE_MAX_CONCURRENT=1
VIDEO_TRANSCODE_THREADS=1
VIDEO_TRANSCODE_TIMEOUT=3600000
VIDEO_PREVIEW_RESOLUTION=720
VIDEO_CLIP_DURATION=10
@ -216,12 +246,74 @@ WHERE email = 'your@email.com';
npm run dev
```
直接运行 `npm run dev` 时,`UPLOAD_DIR=./uploads` 只保存本机开发数据。容器化的本地集成测试请使用 `docker-compose.local-test.yml` 中的独立命名卷,不要把相对目录配置复制到默认 Compose 或生产环境。
### 6. 默认 Compose 的 uploads 挂载门禁
根目录 `docker-compose.yml` 是生产形态的单机参考配置(正式发布仍以 `scripts/git-incremental-deploy.ps1` 为准)。它不会再自动创建或挂载仓库内的 `./uploads`,启动前必须显式提供已经核验的宿主机 uploads 挂载:
```bash
export GONGXIANGP2_UPLOADS_MOUNT=/mnt/PoolOne/youyidian-uploads
docker compose config
docker compose up -d
```
- 当前生产宿主机的真实独立 ZFS 数据集路径是 `/mnt/PoolOne/youyidian-uploads`;先在宿主机用 `findmnt` 核验它确实是预期挂载,不能只确认目录存在。
- 容器内统一使用 `UPLOAD_DIR=/app/uploads`,不要把宿主机路径写进容器环境变量。
- `create_host_path: false` 会在路径缺失时直接失败,避免 Docker 静默新建空目录后让素材写入错误磁盘。
- 相对 `./uploads` 只允许用于明确的非容器开发;影子环境使用其独立的 `uploads-shadow`,本地集成测试使用命名卷,二者都不能当作生产数据目录。
默认访问地址:
```text
http://localhost:3000
```
登录后的一级入口为“品牌中心部门看板”,当前页面路由如下:
```text
品牌中心部门看板:http://localhost:3000/
小项二级框架示例:http://localhost:3000/workspaces/dashboard/core-metrics
由壹点3D素材:http://localhost:3000/assets/material-library
```
### 无本机 Node 环境时使用 Docker 开发
仓库提供一套隔离的 Docker 开发环境,包含 Node.js 22、PostgreSQL、Redis、MinIO 和支持源码热更新的 Next.js。它使用独立数据卷,不连接生产数据库或生产对象存储。
```bash
docker compose \
--env-file .env.docker-test.local \
-f docker-compose.local-test.yml \
-f docker-compose.dev.yml \
up -d --build
```
首次启动会自动执行 `prisma generate` 和 `prisma db push`。启动完成后访问:
```text
应用:http://localhost:4321
MinIO 控制台:http://localhost:59001
```
查看应用日志或停止环境:
```bash
docker compose \
--env-file .env.docker-test.local \
-f docker-compose.local-test.yml \
-f docker-compose.dev.yml \
logs -f app
docker compose \
--env-file .env.docker-test.local \
-f docker-compose.local-test.yml \
-f docker-compose.dev.yml \
down
```
`down` 会保留本地数据库和上传文件;只有显式增加 `--volumes` 才会清除本地开发数据。
## 常用脚本
```bash
@ -245,7 +337,7 @@ npm run prisma:studio
```powershell
.\scripts\git-incremental-deploy.ps1 -DryRun
.\scripts\git-incremental-deploy.ps1
.\scripts\git-incremental-deploy.ps1 -Commit '<40位小写提交SHA>'
```
部署链路是:源码推送到 Git -> 构建机增量拉取并构建镜像 -> 生产机拉取镜像并替换应用容器。旧的 `deploy-remote-build.ps1` 属于本地历史脚本名,只作为识别应急资料使用,不是仓库交付入口,也不再作为默认推荐方式。
@ -279,7 +371,7 @@ npm run start
- 登录是否正常
- 首页筛选、上传、下载、分享是否正常
- 管理后台核心页签是否可用
- 视频转码队列是否异常堆积
- 视频转码数据库任务 / 租约是否异常堆积(Redis 仅检查上传会话,不作为视频队列)
- `/api/health` 是否正常
- 如果改了网络或下载链路,再验证一次内外网访问
@ -289,7 +381,6 @@ npm run start
- `docs/部署规范.md`:当前部署规范
- `docs/Git增量部署方案.md`:Git 增量部署方案
- `docs/生产存储桶接入信息.md`:当前 MinIO / uploads 存储事实
- `docs/CODEX_MULTI_AGENTS.md`:项目内 Codex 团队协作规则
- `docs/外部接口文档.md`:当前对外接口说明
## 说明

View File

@ -36,9 +36,8 @@ export default function ApiKeysPage() {
if (status === 'unauthenticated') {
router.push('/login');
} else if (status === 'authenticated' && session?.user) {
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN' && userRole !== 'SYSTEM_ADMIN') {
router.push('/');
if (!(session.user as any).isSuperAdmin) {
router.push('/admin');
}
}
}, [status, session, router]);
@ -207,7 +206,7 @@ export default function ApiKeysPage() {
<div className="bg-gray-100 p-3 rounded font-mono text-sm break-all mb-4">
{createdKey}
</div>
<p className="text-sm text-red-600 mb-4">注意:此密钥只显示一次,关闭后将无法再次查看</p>
<p className="text-sm text-gray-600 mb-4">关闭后仍可在超级管理员工作台的“权限与凭证”中按需查看。</p>
<button
onClick={() => {
setCreatedKey(null);

7
app/admin/org/page.tsx Normal file
View File

@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
// 组织架构页已于 2026-08-14 上移为全平台顶层入口 /organization(品牌中心看板·组织管理板块)。
// 旧路径保留服务端跳转,保护已有收藏与历史链接。
export default function AdminOrgRedirectPage() {
redirect('/organization')
}

72
app/admin/page.test.tsx Normal file
View File

@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useSession } from 'next-auth/react'
import AdminPage from './page'
const push = jest.fn()
const router = { push }
const confirmAction = jest.fn()
jest.mock('next-auth/react', () => ({ useSession: jest.fn() }))
jest.mock('next/navigation', () => ({ useRouter: () => router }))
jest.mock('@/components/ConfirmActionDialog', () => ({ useConfirmActionDialog: () => ({ confirmAction, confirmActionDialog: null }) }))
jest.mock('@/components/board/MaterialsHeader', () => () => null)
jest.mock('@/components/DepartmentPermissionsTab', () => () => null)
jest.mock('@/components/admin/MaterialManagement', () => () => null)
jest.mock('@/components/admin/CategoryManagement', () => () => null)
jest.mock('@/components/admin/PublicTrafficTab', () => () => null)
jest.mock('@/components/admin/DataStatisticsTab', () => function TestDataStatisticsTab() { return <div>统计内容</div> })
jest.mock('@/components/SuperAdminsTab', () => function TestSuperAdminsTab({ onStateChange }: { onStateChange: (state: { dirty: boolean; busy: boolean }) => void }) { return <div><span>配置内容</span><button onClick={() => onStateChange({ dirty: true, busy: false })}>模拟配置草稿</button><button onClick={() => onStateChange({ dirty: true, busy: true })}>模拟保存中</button></div> })
beforeEach(() => { jest.clearAllMocks(); confirmAction.mockResolvedValue({ confirmed: false }) })
function session(role: string, isSuperAdmin: boolean) { (useSession as jest.Mock).mockReturnValue({ status: 'authenticated', data: { user: { id: 'user', role, isSuperAdmin } } }) }
test('super-admin flag grants the management entry independently of the ordinary role', async () => {
session('USER', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
await screen.findByText('配置内容')
expect(push).not.toHaveBeenCalled()
})
test('ordinary administrators do not see the super-admin configuration entry', async () => {
session('ADMIN', false); render(<AdminPage />)
await screen.findByText('统计内容')
expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument()
})
test('ordinary users remain outside the admin page', async () => {
session('USER', false); render(<AdminPage />)
await waitFor(() => expect(push).toHaveBeenCalledWith('/'))
expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument()
})
test('changing top-level tabs cannot discard a configuration draft without the user choosing to leave', async () => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' }))
fireEvent.click(screen.getByRole('button', { name: '数据统计' }))
await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1))
expect(screen.getByText('配置内容')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '模拟保存中' }))
expect(screen.getByRole('button', { name: '数据统计' })).toBeDisabled()
})
test.each([false, true])('appearance navigation respects the unsaved configuration decision: confirmed=%s', async (confirmed) => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' }))
confirmAction.mockResolvedValueOnce({ confirmed })
fireEvent.click(screen.getByRole('button', { name: '外观设置' }))
await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1))
if (confirmed) {
await waitFor(() => expect(push).toHaveBeenCalledWith('/admin/settings'))
} else {
expect(push).not.toHaveBeenCalled()
expect(screen.getByText('配置内容')).toBeInTheDocument()
}
})
test('appearance navigation is blocked while a configuration save is running', async () => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟保存中' }))
const appearance = screen.getByRole('button', { name: '外观设置' })
expect(appearance).toBeDisabled()
fireEvent.click(appearance)
expect(push).not.toHaveBeenCalled()
expect(confirmAction).not.toHaveBeenCalled()
})

View File

@ -3,32 +3,17 @@
import { useCallback, useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import MaterialsHeader from '@/components/board/MaterialsHeader'
import DepartmentPermissionsTab from '@/components/DepartmentPermissionsTab'
import SuperAdminsTab from '@/components/SuperAdminsTab'
import MaterialManagement from '@/components/admin/MaterialManagement'
import UserManagement from '@/components/admin/UserManagement'
import CategoryManagement from '@/components/admin/CategoryManagement'
import DepartmentManagement from '@/components/admin/DepartmentManagement'
import DataStatisticsTab from '@/components/admin/DataStatisticsTab'
import PublicTrafficTab from '@/components/admin/PublicTrafficTab'
import ToastBubble from '@/components/ToastBubble'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
// 类型定义
interface User {
id: string
email: string
username: string
department: string
role: string
isActive: boolean
canDownload: boolean
managedDepartment?: string
createdAt: string
_count: {
materials: number
}
}
interface Material {
id: string
previewUrl: string
@ -107,18 +92,33 @@ export default function AdminPage() {
email?: string | null
} | undefined
const currentUserRole = sessionUser?.role || ''
const canAccessAdminPage = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN'
const canManageSystemTabs = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN'
const canAccessAdminPage = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN'
const canManageSystemTabs = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN'
const departmentScopeName = String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() || null
const canSelectDepartmentInStats = canManageSystemTabs
// Tab状态
const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'dataStats' | 'permissions' | 'superAdmins' | 'publicTraffic'>('dataStats')
const [activeTab, setActiveTab] = useState<'materials' | 'categories' | 'dataStats' | 'permissions' | 'superAdmins' | 'publicTraffic'>('dataStats')
const [superAdminEditing, setSuperAdminEditing] = useState({ dirty: false, busy: false })
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const confirmLeaveConfiguration = async () => {
if (activeTab !== 'superAdmins') return true
if (superAdminEditing.busy) return false
if (!superAdminEditing.dirty) return true
const result = await confirmAction({ title: '离开当前配置?', description: '当前修改尚未保存。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
return result.confirmed
}
const changeTab = async (next: typeof activeTab) => {
if (next === activeTab || !(await confirmLeaveConfiguration())) return
setActiveTab(next)
}
const openAppearanceSettings = async () => {
if (await confirmLeaveConfiguration()) router.push('/admin/settings')
}
const [loading, setLoading] = useState(true)
// 数据状态
const [materials, setMaterials] = useState<Material[]>([])
const [users, setUsers] = useState<User[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [scenes, setScenes] = useState<Scene[]>([])
const [platforms, setPlatforms] = useState<Platform[]>([])
@ -138,11 +138,6 @@ export default function AdminPage() {
const [isLoadingMoreMaterials, setIsLoadingMoreMaterials] = useState(false)
const [totalMaterialsCount, setTotalMaterialsCount] = useState(0) // 素材总数(用于显示)
const materialsPageSize = 50 // 增大分页大小,减少请求次数
// 用户管理相关状态
const [userSearchTerm, setUserSearchTerm] = useState('')
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
const [collapsedAdmins, setCollapsedAdmins] = useState<Set<string>>(new Set())
// Toast自动隐藏
useEffect(() => {
@ -164,8 +159,6 @@ export default function AdminPage() {
}
const data = await response.json()
setDepartments(data)
// 默认所有部门收起
setCollapsedDepartments(new Set(data.map((d: Department) => d.id)))
}, [])
const fetchData = useCallback(async () => {
@ -193,12 +186,6 @@ export default function AdminPage() {
if (data.totalCount !== undefined) {
setTotalMaterialsCount(data.totalCount)
}
} else if (activeTab === 'users') {
const response = await fetch('/api/admin/users')
if (!response.ok) throw new Error('用户数据加载失败')
const data = await response.json()
setUsers(data)
await fetchDepartments()
} else if (activeTab === 'categories') {
const [categoriesRes, scenesRes, platformsRes, productCategoriesRes] = await Promise.all([
fetch('/api/categories'),
@ -213,7 +200,7 @@ export default function AdminPage() {
setScenes(await scenesRes.json())
setPlatforms(await platformsRes.json())
setProductCategories(await productCategoriesRes.json())
} else if (activeTab === 'departments' || activeTab === 'permissions') {
} else if (activeTab === 'permissions') {
await fetchDepartments()
}
} catch (error) {
@ -296,37 +283,11 @@ export default function AdminPage() {
}
}, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialSearchTerm])
// 切换部门折叠状态
const toggleDepartmentCollapse = useCallback((departmentName: string) => {
setCollapsedDepartments(prev => {
const newSet = new Set(prev)
if (newSet.has(departmentName)) {
newSet.delete(departmentName)
} else {
newSet.add(departmentName)
}
return newSet
})
}, [])
// 切换管理员折叠状态
const toggleAdminCollapse = useCallback((adminId: string) => {
setCollapsedAdmins(prev => {
const newSet = new Set(prev)
if (newSet.has(adminId)) {
newSet.delete(adminId)
} else {
newSet.add(adminId)
}
return newSet
})
}, [])
// 加载状态
if (status === 'loading' || loading) {
if (status === 'loading' || (loading && activeTab !== 'materials')) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
<div className="text-xl text-[#86898e]">加载中...</div>
</div>
)
}
@ -336,28 +297,27 @@ export default function AdminPage() {
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<div className="min-h-screen bg-[#fafafa]">
{confirmActionDialog}
<MaterialsHeader session={session} sectionLabel="治理与设置" />
{/* Toast通知 */}
{toast && (
<div className={`fixed top-20 right-4 z-50 px-6 py-3 rounded-lg shadow-lg ${
toast.type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
} animate-fade-in`}>
{toast.message}
</div>
<ToastBubble message={toast.message} variant={toast.type} topOffsetClass="top-20" />
)}
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">系统管理</h1>
<main className="container mx-auto max-w-7xl px-4 py-10 sm:py-14">
<div className="mb-10 flex items-end justify-between gap-4 border-b border-[#e7e8ea] pb-5">
<div>
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">Governance</p>
<h1 className="mt-2 text-[clamp(28px,4vw,42px)] font-semibold tracking-[-0.03em] text-[#1a1c1d]">治理与设置</h1>
</div>
{canManageSystemTabs && (
<button
type="button"
onClick={() => router.push('/admin/settings')}
className="btn-primary flex items-center gap-2"
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void openAppearanceSettings()}
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-semibold text-white transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] disabled:pointer-events-none disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<title>外观设置</title>
@ -369,14 +329,15 @@ export default function AdminPage() {
</div>
{/* Tabs */}
<div className="flex gap-4 overflow-x-auto border-b border-gray-200 dark:border-gray-700 mb-8 [&>button]:shrink-0">
<div className="mb-8 flex gap-4 overflow-x-auto border-b border-[#e7e8ea] [&>button]:shrink-0">
<button
type="button"
onClick={() => setActiveTab('dataStats')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('dataStats')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'dataStats'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
数据统计
@ -386,11 +347,12 @@ export default function AdminPage() {
<>
<button
type="button"
onClick={() => setActiveTab('materials')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('materials')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'materials'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
素材管理
@ -398,49 +360,27 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('users')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'users'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
用户管理
</button>
<button
type="button"
onClick={() => setActiveTab('categories')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('categories')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'categories'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
分类管理
</button>
<button
type="button"
onClick={() => setActiveTab('departments')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'departments'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
部门管理
</button>
{sessionUser?.isSuperAdmin && (
<>
<button
type="button"
onClick={() => setActiveTab('permissions')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('permissions')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'permissions'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
部门权限配置
@ -448,11 +388,12 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('superAdmins')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('superAdmins')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'superAdmins'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
超级管理员
@ -460,11 +401,12 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('publicTraffic')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('publicTraffic')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'publicTraffic'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
: 'text-[#6b7075] hover:text-[#1a1c1d]'
}`}
>
公网流量
@ -488,6 +430,7 @@ export default function AdminPage() {
{/* 素材管理 Tab */}
{canManageSystemTabs && activeTab === 'materials' && (
<MaterialManagement
isLoadingMaterials={loading}
materials={materials}
setMaterials={setMaterials}
selectedMaterials={selectedMaterials}
@ -509,24 +452,6 @@ export default function AdminPage() {
/>
)}
{/* 用户管理 Tab */}
{canManageSystemTabs && activeTab === 'users' && (
<UserManagement
users={users}
setUsers={setUsers}
departments={departments}
userSearchTerm={userSearchTerm}
setUserSearchTerm={setUserSearchTerm}
collapsedDepartments={collapsedDepartments}
collapsedAdmins={collapsedAdmins}
toggleDepartmentCollapse={toggleDepartmentCollapse}
toggleAdminCollapse={toggleAdminCollapse}
showToast={showToast}
fetchData={fetchData}
currentUserEmail={session?.user?.email || undefined}
/>
)}
{/* 分类管理 Tab */}
{canManageSystemTabs && activeTab === 'categories' && (
<CategoryManagement
@ -542,16 +467,6 @@ export default function AdminPage() {
/>
)}
{/* 部门管理 Tab */}
{canManageSystemTabs && activeTab === 'departments' && (
<DepartmentManagement
departments={departments}
setDepartments={setDepartments}
showToast={showToast}
fetchDepartments={fetchDepartments}
/>
)}
{/* 部门权限配置 Tab */}
{canManageSystemTabs && activeTab === 'permissions' && sessionUser?.isSuperAdmin && (
<DepartmentPermissionsTab
@ -564,6 +479,7 @@ export default function AdminPage() {
{canManageSystemTabs && activeTab === 'superAdmins' && sessionUser?.isSuperAdmin && (
<SuperAdminsTab
showToast={showToast}
onStateChange={setSuperAdminEditing}
/>
)}

View File

@ -0,0 +1,164 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useSession } from 'next-auth/react'
import SystemSettings from '@/app/admin/settings/page'
const showToastMock = jest.fn()
jest.mock('next-auth/react', () => ({
useSession: jest.fn(),
}))
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
refresh: jest.fn(),
}),
}))
jest.mock('@/components/Header', () => ({
__esModule: true,
default: () => <div data-testid="header" />,
}))
jest.mock('@/hooks/useToast', () => ({
useToast: () => ({
toast: null,
showToast: showToastMock,
}),
}))
jest.mock('@/components/ToastBubble', () => ({
__esModule: true,
default: () => null,
}))
describe('系统设置页凭据脱敏', () => {
beforeEach(() => {
jest.clearAllMocks()
;(useSession as jest.Mock).mockReturnValue({
data: {
user: { id: 'admin-1', role: 'ADMIN' },
},
status: 'authenticated',
})
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
headerBgColor: '#1f2937',
siteName: '由壹点3D素材',
headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/header.png',
headerLogoPreviewUrl: '/api/proxy-media?url=header-preview',
loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/login.png',
loginLogoPreviewUrl: '/api/proxy-media?url=login-preview',
s3Endpoint: 'https://storage.example.com',
s3Bucket: 'materials',
s3Region: 'us-east-1',
s3PublicUrl: 'https://cdn.example.com/materials',
s3CredentialsConfigured: true,
// 即使旧服务误返回这些字段,页面也不得回填或展示。
s3AccessKey: 'should-never-render-access-key',
s3SecretKey: 'should-never-render-secret-key',
maintenanceEnabled: false,
maintenanceMessage: null,
maintenanceStartTime: null,
maintenanceEndTime: null,
}),
}) as jest.Mock
})
test('只显示 configured 状态,凭据输入框始终为空', async () => {
render(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('已配置')).toBeInTheDocument()
})
expect(screen.getByLabelText('新 Access Key')).toHaveValue('')
expect(screen.getByLabelText('新 Secret Key')).toHaveValue('')
expect(screen.queryByDisplayValue('should-never-render-access-key')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('should-never-render-secret-key')).not.toBeInTheDocument()
expect(screen.queryByText('should-never-render-access-key')).not.toBeInTheDocument()
expect(screen.queryByText('should-never-render-secret-key')).not.toBeInTheDocument()
})
test('未填写新凭据时,保存请求不会覆盖现有凭据', async () => {
render(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('已配置')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2)
})
const [, requestInit] = (global.fetch as jest.Mock).mock.calls[1]
const payload = JSON.parse(requestInit.body)
expect(payload).not.toHaveProperty('s3AccessKey')
expect(payload).not.toHaveProperty('s3SecretKey')
expect(payload.headerLogoUrl).toBe('https://s3.jiansun.vip/gongxiangpan/brand/header.png')
expect(payload.loginLogoUrl).toBe('https://s3.jiansun.vip/gongxiangpan/brand/login.png')
expect(JSON.stringify(payload)).not.toContain('/api/proxy-media')
})
test('恢复默认使用镜像内置品牌图,不再写入旧存储域名', async () => {
render(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('已配置')).toBeInTheDocument()
})
const resetButtons = screen.getAllByRole('button', { name: '恢复默认' })
fireEvent.click(resetButtons[0])
fireEvent.click(resetButtons[1])
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2)
})
const [, requestInit] = (global.fetch as jest.Mock).mock.calls[1]
const payload = JSON.parse(requestInit.body)
expect(payload.headerLogoUrl).toBe('/brand/header-logo.png')
expect(payload.loginLogoUrl).toBe('/brand/login-logo.png')
expect(JSON.stringify(payload)).not.toContain('s3.1080p.hk')
})
test('文件选择器和前端校验只接受服务端支持的三种 Logo 格式', async () => {
const { container } = render(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('已配置')).toBeInTheDocument()
})
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="file"]'))
expect(inputs).toHaveLength(2)
expect(inputs.every((input) => input.accept === 'image/png,image/jpeg,image/webp')).toBe(true)
fireEvent.change(inputs[0], {
target: { files: [new File(['<svg />'], 'logo.svg', { type: 'image/svg+xml' })] },
})
expect(showToastMock).toHaveBeenCalledWith(
'LOGO仅支持 PNG、JPEG 或 WebP 格式',
{ variant: 'error' },
)
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test.each([
{ user: { id: 'system-admin-1', role: 'SYSTEM_ADMIN' }, label: 'SYSTEM_ADMIN' },
{ user: { id: 'super-admin-1', role: 'USER', isSuperAdmin: true }, label: '超级管理员' },
])('$label 权限账号可以正常看到设置页面', async ({ user }) => {
;(useSession as jest.Mock).mockReturnValue({
data: { user },
status: 'authenticated',
})
render(<SystemSettings />)
expect(await screen.findByRole('heading', { name: '系统设置' })).toBeInTheDocument()
})
})

View File

@ -3,10 +3,28 @@
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import MaterialsHeader from '@/components/board/MaterialsHeader'
import BrandLogo from '@/components/BrandLogo'
import { useToast } from '@/hooks/useToast'
import ToastBubble from '@/components/ToastBubble'
import { parseFetchError, handleError } from '@/lib/error-handler'
import { DEFAULT_HEADER_LOGO_URL, DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
const BRAND_LOGO_ACCEPT = 'image/png,image/jpeg,image/webp'
const SUPPORTED_BRAND_LOGO_MIME_TYPES = new Set(BRAND_LOGO_ACCEPT.split(','))
type SettingsSessionUser = {
role?: string | null
isSuperAdmin?: boolean | null
}
function canManageSystemSettings(user: SettingsSessionUser | null | undefined) {
return (
user?.role === 'ADMIN' ||
user?.role === 'SYSTEM_ADMIN' ||
user?.isSuperAdmin === true
)
}
export default function SystemSettings() {
const { data: session, status } = useSession()
@ -28,8 +46,9 @@ export default function SystemSettings() {
// S3配置状态
const [s3Endpoint, setS3Endpoint] = useState('')
const [s3AccessKey, setS3AccessKey] = useState('')
const [s3SecretKey, setS3SecretKey] = useState('')
const [newS3AccessKey, setNewS3AccessKey] = useState('')
const [newS3SecretKey, setNewS3SecretKey] = useState('')
const [s3CredentialsConfigured, setS3CredentialsConfigured] = useState(false)
const [s3Bucket, setS3Bucket] = useState('')
const [s3Region, setS3Region] = useState('us-east-1')
const [s3PublicUrl, setS3PublicUrl] = useState('')
@ -44,14 +63,20 @@ export default function SystemSettings() {
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
} else if (status === 'authenticated' && (session?.user as any)?.role !== 'ADMIN') {
} else if (
status === 'authenticated' &&
!canManageSystemSettings(session?.user as SettingsSessionUser)
) {
router.push('/')
}
}, [status, session, router])
// 加载设置
useEffect(() => {
if (status === 'authenticated' && (session?.user as any)?.role === 'ADMIN') {
if (
status === 'authenticated' &&
canManageSystemSettings(session?.user as SettingsSessionUser)
) {
fetchSettings()
}
}, [status, session])
@ -63,13 +88,14 @@ export default function SystemSettings() {
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || '由壹点3D素材')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
setLoginLogoPreview(data.loginLogoUrl || '')
setHeaderLogoUrl(data.headerLogoUrl || DEFAULT_HEADER_LOGO_URL)
setHeaderLogoPreview(data.headerLogoPreviewUrl || data.headerLogoUrl || DEFAULT_HEADER_LOGO_URL)
setLoginLogoUrl(data.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL)
setLoginLogoPreview(data.loginLogoPreviewUrl || data.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL)
setS3Endpoint(data.s3Endpoint || '')
setS3AccessKey(data.s3AccessKey || '')
setS3SecretKey(data.s3SecretKey || '')
setNewS3AccessKey('')
setNewS3SecretKey('')
setS3CredentialsConfigured(data.s3CredentialsConfigured === true)
setS3Bucket(data.s3Bucket || '')
setS3Region(data.s3Region || 'us-east-1')
setS3PublicUrl(data.s3PublicUrl || '')
@ -89,39 +115,53 @@ export default function SystemSettings() {
const handleSave = async () => {
const nextAccessKey = newS3AccessKey.trim()
const nextSecretKey = newS3SecretKey.trim()
if (Boolean(nextAccessKey) !== Boolean(nextSecretKey)) {
showToast('如需更新存储凭据,请同时填写 Access Key 和 Secret Key', { variant: 'error' })
return
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
headerBgColor,
siteName,
headerLogoUrl,
loginLogoUrl,
s3Endpoint,
s3Bucket,
s3Region,
s3PublicUrl,
maintenanceEnabled,
maintenanceMessage: maintenanceMessage || null,
maintenanceStartTime: maintenanceStartTime || null,
maintenanceEndTime: maintenanceEndTime || null,
}
if (nextAccessKey && nextSecretKey) {
payload.s3AccessKey = nextAccessKey
payload.s3SecretKey = nextSecretKey
}
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
headerBgColor,
siteName,
headerLogoUrl,
loginLogoUrl,
s3Endpoint,
s3AccessKey,
s3SecretKey,
s3Bucket,
s3Region,
s3PublicUrl,
maintenanceEnabled,
maintenanceMessage: maintenanceMessage || null,
maintenanceStartTime: maintenanceStartTime || null,
maintenanceEndTime: maintenanceEndTime || null,
}),
body: JSON.stringify(payload),
})
if (response.ok) {
const data = await response.json()
setS3CredentialsConfigured(data.s3CredentialsConfigured === true)
setNewS3AccessKey('')
setNewS3SecretKey('')
showToast('保存成功!页面将自动刷新以应用新设置', { variant: 'success', duration: 3000 })
// 刷新页面以应用新设置
setTimeout(() => {
window.location.reload()
window.location.reload()
}, 1000)
} else {
const error = await response.json()
const parsedError = await parseFetchError(response)
showToast(parsedError.message, { variant: 'error' })
}
@ -139,8 +179,8 @@ export default function SystemSettings() {
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', { variant: 'error' })
if (!SUPPORTED_BRAND_LOGO_MIME_TYPES.has(file.type.toLowerCase())) {
showToast('LOGO仅支持 PNG、JPEG 或 WebP 格式', { variant: 'error' })
return
}
@ -167,7 +207,6 @@ export default function SystemSettings() {
setHeaderLogoPreview(data.logoUrl)
showToast('导航栏LOGO上传成功!', { variant: 'success' })
} else {
const error = await response.json()
const parsedError = await parseFetchError(response)
showToast(parsedError.message, { variant: 'error' })
}
@ -185,8 +224,8 @@ export default function SystemSettings() {
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', { variant: 'error' })
if (!SUPPORTED_BRAND_LOGO_MIME_TYPES.has(file.type.toLowerCase())) {
showToast('LOGO仅支持 PNG、JPEG 或 WebP 格式', { variant: 'error' })
return
}
@ -213,7 +252,6 @@ export default function SystemSettings() {
setLoginLogoPreview(data.logoUrl)
showToast('登录页LOGO上传成功!', { variant: 'success' })
} else {
const error = await response.json()
const parsedError = await parseFetchError(response)
showToast(parsedError.message, { variant: 'error' })
}
@ -231,38 +269,42 @@ export default function SystemSettings() {
}
const handleHeaderLogoReset = () => {
setHeaderLogoPreview('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setHeaderLogoUrl('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setHeaderLogoPreview(DEFAULT_HEADER_LOGO_URL)
setHeaderLogoUrl(DEFAULT_HEADER_LOGO_URL)
}
const handleLoginLogoReset = () => {
setLoginLogoPreview('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setLoginLogoUrl('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setLoginLogoPreview(DEFAULT_LOGIN_LOGO_URL)
setLoginLogoUrl(DEFAULT_LOGIN_LOGO_URL)
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
<div className="flex min-h-screen items-center justify-center bg-[#fafafa]">
<div className="text-[13px] text-[#86898e]">加载中...</div>
</div>
)
}
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (
!session ||
!canManageSystemSettings(session.user as SettingsSessionUser)
) {
return null
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<MaterialsHeader session={session} sectionLabel="系统设置" />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">系统设置</h1>
<p className="text-gray-600 dark:text-gray-400">自定义网站外观和品牌信息</p>
<main className="container mx-auto max-w-4xl px-4 py-10 sm:py-14">
<div className="mb-10 border-b border-[#e7e8ea] pb-5">
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">System settings</p>
<h1 className="mt-2 text-[clamp(28px,4vw,42px)] font-semibold tracking-[-0.03em] text-[#1a1c1d]">系统设置</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">管理品牌外观、存储连接与维护通知。</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8">
<div className="space-y-8 border border-[#e7e8ea] bg-white p-6 sm:p-8">
{/* 网站名称设置 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
@ -305,7 +347,15 @@ export default function SystemSettings() {
<div className="flex items-center gap-4">
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden" style={{ backgroundColor: headerBgColor }}>
{headerLogoPreview ? (
<img src={headerLogoPreview} alt="导航栏LOGO" className="max-w-full max-h-full object-contain" />
<BrandLogo
src={headerLogoPreview}
fallbackSrc={DEFAULT_HEADER_LOGO_URL}
alt="导航栏LOGO"
width={160}
height={64}
unoptimized
className="max-h-full max-w-full object-contain"
/>
) : (
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
@ -317,7 +367,7 @@ export default function SystemSettings() {
用于深色导航栏,建议使用浅色LOGO
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
建议尺寸:200x200px,支持PNG、JPG、SVG格式
建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
文件大小不超过5MB
@ -346,7 +396,7 @@ export default function SystemSettings() {
)}
<input
type="file"
accept="image/*"
accept={BRAND_LOGO_ACCEPT}
onChange={handleHeaderLogoUpload}
disabled={uploadingHeader}
className="hidden"
@ -380,7 +430,15 @@ export default function SystemSettings() {
<div className="flex items-center gap-4">
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden bg-white">
{loginLogoPreview ? (
<img src={loginLogoPreview} alt="登录页LOGO" className="max-w-full max-h-full object-contain" />
<BrandLogo
src={loginLogoPreview}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
alt="登录页LOGO"
width={160}
height={96}
unoptimized
className="max-h-full max-w-full object-contain"
/>
) : (
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
@ -392,7 +450,7 @@ export default function SystemSettings() {
用于浅色登录页面,建议使用深色LOGO
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
建议尺寸:200x200px,支持PNG、JPG、SVG格式
建议尺寸:200x200px,支持 PNG、JPEG、WebP 格式
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
文件大小不超过5MB
@ -421,7 +479,7 @@ export default function SystemSettings() {
)}
<input
type="file"
accept="image/*"
accept={BRAND_LOGO_ACCEPT}
onChange={handleLoginLogoUpload}
disabled={uploadingLogin}
className="hidden"
@ -612,38 +670,76 @@ export default function SystemSettings() {
type="text"
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="例如: s3.1080p.hk"
placeholder="例如: https://storage.example.com"
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
</div>
{/* Access Key & Secret Key */}
<div className="grid grid-cols-2 gap-4">
{/* Access Key & Secret Key:只允许覆盖写入,不回填原文 */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Access Key (访问密钥)
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
访问凭据
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
已保存的凭据不会返回浏览器;仅在轮换时填写新值。
</p>
</div>
<span
className={`inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-medium ${
s3CredentialsConfigured
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
aria-label={`S3 凭据状态:${s3CredentialsConfigured ? '已配置' : '未配置'}`}
>
{s3CredentialsConfigured ? '已配置' : '未配置'}
</span>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
htmlFor="newS3AccessKey"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
新 Access Key
</label>
<input
type="text"
value={s3AccessKey}
onChange={(e) => setS3AccessKey(e.target.value)}
placeholder="输入Access Key"
id="newS3AccessKey"
name="newS3AccessKey"
type="password"
autoComplete="new-password"
spellCheck={false}
value={newS3AccessKey}
onChange={(e) => setNewS3AccessKey(e.target.value)}
placeholder={s3CredentialsConfigured ? '留空则保持当前配置' : '输入新的 Access Key'}
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Secret Key (密钥)
<label
htmlFor="newS3SecretKey"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
新 Secret Key
</label>
<input
id="newS3SecretKey"
name="newS3SecretKey"
type="password"
value={s3SecretKey}
onChange={(e) => setS3SecretKey(e.target.value)}
placeholder="输入Secret Key"
autoComplete="new-password"
spellCheck={false}
value={newS3SecretKey}
onChange={(e) => setNewS3SecretKey(e.target.value)}
placeholder={s3CredentialsConfigured ? '留空则保持当前配置' : '输入新的 Secret Key'}
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
两项必须同时填写。保存后输入框会清空,页面只显示配置状态。
</p>
{/* Bucket & Region */}
<div className="grid grid-cols-2 gap-4">
@ -700,7 +796,7 @@ export default function SystemSettings() {
<p className="font-semibold mb-2">配置说明:</p>
<ul className="space-y-1 list-disc list-inside">
<li>S3端点通常是您的对象存储服务器地址</li>
<li>Access Key和Secret Key用于身份验证</li>
<li>Access Key 和 Secret Key 只可覆盖写入,不会显示已保存的原文</li>
<li>存储桶需要预先创建并设置公开读取权限</li>
<li>修改配置后需要重启应用才能生效</li>
</ul>

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { configureAiModels } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function PUT(request: Request) { return aiModelAdminRoute(async userId => configureAiModels(userId, await parseDetailJson(request))) }

View File

@ -0,0 +1,4 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { importAiModelEnvironment } from '@/lib/ai-model-service'
export const runtime = 'nodejs'
export async function POST() { return aiModelAdminRoute(userId => importAiModelEnvironment(userId)) }

View File

@ -0,0 +1,7 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { checkAiModelSubmission } from '@/lib/ai-model-recovery'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, context: { params: { id: string; modelKey: string } }) {
return aiModelAdminRoute(async userId => checkAiModelSubmission(userId, context.params.id, context.params.modelKey, await parseDetailJson(request)))
}

View File

@ -0,0 +1,8 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { verifyAiModel } from '@/lib/ai-model-verification'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const maxDuration = 300
export async function POST(request: Request, context: { params: { id: string; modelKey: string } }) {
return aiModelAdminRoute(async userId => verifyAiModel(userId, context.params.id, context.params.modelKey, await parseDetailJson(request)))
}

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { editAiModelProvider } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function PATCH(request: Request, context: { params: { id: string } }) { return aiModelAdminRoute(async userId => editAiModelProvider(userId, context.params.id, await parseDetailJson(request))) }

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { syncAiModelProvider } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, context: { params: { id: string } }) { return aiModelAdminRoute(async userId => syncAiModelProvider(userId, context.params.id, await parseDetailJson(request))) }

View File

@ -0,0 +1,7 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { reconcileAiModelVerification } from '@/lib/ai-model-recovery'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, context: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => reconcileAiModelVerification(userId, context.params.id, context.params.verificationId, await parseDetailJson(request)))
}

View File

@ -0,0 +1,8 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { refreshAiModelVerification } from '@/lib/ai-model-verification'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const maxDuration = 300
export async function POST(request: Request, context: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => refreshAiModelVerification(userId, context.params.id, context.params.verificationId, request.body ? await parseDetailJson(request) : {}))
}

View File

@ -0,0 +1,11 @@
import { aiModelAdminRoute, AiModelError } from '@/lib/ai-model-access'
import { releaseAiModelVerificationExecution } from '@/lib/ai-model-execution-release'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(request: Request, { params }: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => {
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw new AiModelError('须使用 JSON 请求', 415)
return releaseAiModelVerificationExecution(userId, params.id, params.verificationId, await parseDetailJson(request))
})
}

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { createAiModelProvider } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request) { return aiModelAdminRoute(async userId => createAiModelProvider(userId, await parseDetailJson(request))) }

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { getAiModelAdminState } from '@/lib/ai-model-service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() { return aiModelAdminRoute(() => getAiModelAdminState()) }

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin } from '@/lib/department-permission';
import {
ApiKeyEncryptionConfigError,
ApiKeyNotRecoverableError,
revealApiKey,
} from '@/lib/external-api';
const NO_STORE_HEADERS = {
'Cache-Control': 'no-store, max-age=0',
Pragma: 'no-cache',
};
export async function POST(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as { id?: string } | undefined)?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401, headers: NO_STORE_HEADERS },
);
}
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '仅超级管理员可查看' },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
const key = await revealApiKey(params.id, userId);
if (!key) {
return NextResponse.json(
{ success: false, error: 'API Key 不存在' },
{ status: 404, headers: NO_STORE_HEADERS },
);
}
return NextResponse.json(
{ success: true, data: { key } },
{ headers: NO_STORE_HEADERS },
);
} catch (error) {
if (error instanceof ApiKeyNotRecoverableError) {
return NextResponse.json(
{ success: false, error: error.message, code: 'API_KEY_NOT_RECOVERABLE' },
{ status: 409, headers: NO_STORE_HEADERS },
);
}
if (error instanceof ApiKeyEncryptionConfigError) {
return NextResponse.json(
{ success: false, error: 'API Key 加密配置缺失,请联系运维配置后重试' },
{ status: 503, headers: NO_STORE_HEADERS },
);
}
console.error('[admin/api-keys/[id]/reveal] POST error:', error);
return NextResponse.json(
{ success: false, error: '读取 API Key 失败' },
{ status: 500, headers: NO_STORE_HEADERS },
);
}
}

View File

@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission';
import { isSuperAdmin } from '@/lib/department-permission';
import { enableApiKey, disableApiKey } from '@/lib/external-api';
import { prisma } from '@/lib/prisma';
@ -29,13 +29,9 @@ export async function PUT(
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}
@ -102,13 +98,9 @@ export async function DELETE(
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}

View File

@ -0,0 +1,159 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
ApiKeyNotRecoverableError,
createApiKey,
getApiKeys,
getApiKeyPreview,
revealApiKey,
} from '@/lib/external-api'
import { prisma } from '@/lib/prisma'
import { GET as getApiKeysRoute, POST as createApiKeyRoute } from '@/app/api/admin/api-keys/route'
import { PUT as updateApiKeyRoute, DELETE as deleteApiKeyRoute } from '@/app/api/admin/api-keys/[id]/route'
import { POST as revealApiKeyRoute } from '@/app/api/admin/api-keys/[id]/reveal/route'
import { enableApiKey, disableApiKey } from '@/lib/external-api'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/external-api', () => ({
ApiKeyEncryptionConfigError: class ApiKeyEncryptionConfigError extends Error {},
ApiKeyNotRecoverableError: class ApiKeyNotRecoverableError extends Error {},
getApiKeys: jest.fn(),
createApiKey: jest.fn(),
getApiKeyPreview: jest.fn((key: string) => `${String(key).slice(0, 8)}...`),
enableApiKey: jest.fn(),
disableApiKey: jest.fn(),
revealApiKey: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
apiKey: {
findUnique: jest.fn(),
delete: jest.fn(),
},
},
}))
describe('admin api-keys routes', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
role: 'SYSTEM_ADMIN',
isSuperAdmin: false,
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
})
test('GET 会继续返回 masked key 预览', async () => {
;(getApiKeys as jest.Mock).mockResolvedValue([
{ id: 'key-1', key: 'abcdefgh12345678', name: '任务系统', isActive: true, canReveal: true },
])
const response = await getApiKeysRoute(new Request('http://localhost/api/admin/api-keys') as any)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data[0].key).toBe('abcdefgh...')
expect(body.data[0].canReveal).toBe(true)
expect(getApiKeyPreview).toHaveBeenCalledWith('abcdefgh12345678')
})
test('非真正超管时 GET/POST/PUT/DELETE/reveal 都返回 403', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const getResponse = await getApiKeysRoute(new Request('http://localhost/api/admin/api-keys') as any)
const postResponse = await createApiKeyRoute({ json: jest.fn().mockResolvedValue({ name: '任务系统' }) } as any)
const putResponse = await updateApiKeyRoute(
{ json: jest.fn().mockResolvedValue({ isActive: true }) } as any,
{ params: { id: 'key-1' } } as any,
)
const deleteResponse = await deleteApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1') as any,
{ params: { id: 'key-1' } } as any,
)
const revealResponse = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-1' } } as any,
)
expect(getResponse.status).toBe(403)
expect(postResponse.status).toBe(403)
expect(putResponse.status).toBe(403)
expect(deleteResponse.status).toBe(403)
expect(revealResponse.status).toBe(403)
expect(createApiKey).not.toHaveBeenCalled()
expect(enableApiKey).not.toHaveBeenCalled()
expect(disableApiKey).not.toHaveBeenCalled()
expect(prisma.apiKey.delete).not.toHaveBeenCalled()
expect(revealApiKey).not.toHaveBeenCalled()
})
test('超管可创建并管理 API Key', async () => {
;(createApiKey as jest.Mock).mockResolvedValue({ id: 'key-1', key: 'plain-key' })
;(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' })
;(prisma.apiKey.delete as jest.Mock).mockResolvedValue({ id: 'key-1' })
const postResponse = await createApiKeyRoute({ json: jest.fn().mockResolvedValue({ name: '任务系统' }) } as any)
const putResponse = await updateApiKeyRoute(
{ json: jest.fn().mockResolvedValue({ isActive: false }) } as any,
{ params: { id: 'key-1' } } as any,
)
const deleteResponse = await deleteApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1') as any,
{ params: { id: 'key-1' } } as any,
)
expect(postResponse.status).toBe(200)
expect((await postResponse.json()).data.key).toBe('plain-key')
expect(postResponse.headers.get('cache-control')).toContain('no-store')
expect(putResponse.status).toBe(200)
expect(disableApiKey).toHaveBeenCalledWith('key-1')
expect(deleteResponse.status).toBe(200)
expect(prisma.apiKey.delete).toHaveBeenCalledWith({ where: { id: 'key-1' } })
})
test('超管可按需查看完整 Key,响应禁止缓存', async () => {
;(revealApiKey as jest.Mock).mockResolvedValue('plain-key')
const response = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-1' } } as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.key).toBe('plain-key')
expect(response.headers.get('cache-control')).toContain('no-store')
expect(revealApiKey).toHaveBeenCalledWith('key-1', 'admin-1')
})
test('历史纯哈希 Key 返回可操作的 409 提示', async () => {
;(revealApiKey as jest.Mock).mockRejectedValue(new ApiKeyNotRecoverableError())
const response = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-old/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-old' } } as any,
)
const body = await response.json()
expect(response.status).toBe(409)
expect(body.code).toBe('API_KEY_NOT_RECOVERABLE')
})
})

View File

@ -7,8 +7,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission';
import { getApiKeys, createApiKey, getApiKeyPreview } from '@/lib/external-api';
import { isSuperAdmin } from '@/lib/department-permission';
import {
ApiKeyEncryptionConfigError,
createApiKey,
getApiKeyPreview,
getApiKeys,
} from '@/lib/external-api';
/**
* GET - 获取 API Key 列表
@ -23,15 +28,11 @@ export async function GET(request: NextRequest) {
);
}
// 检查权限(仅超级管理员和系统管理员可访问)
// 检查权限(仅真正超级管理员可访问)
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限访问' },
{ success: false, error: '仅超级管理员可访问' },
{ status: 403 }
);
}
@ -72,13 +73,9 @@ export async function POST(request: NextRequest) {
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}
@ -95,12 +92,21 @@ export async function POST(request: NextRequest) {
const apiKey = await createApiKey(name.trim(), userId);
return NextResponse.json({
success: true,
data: apiKey,
message: 'API Key 创建成功,请妥善保管密钥,此密钥仅显示一次',
});
return NextResponse.json(
{
success: true,
data: apiKey,
message: 'API Key 创建成功,之后仍可由超级管理员按需查看',
},
{ headers: { 'Cache-Control': 'no-store, max-age=0', Pragma: 'no-cache' } },
);
} catch (error) {
if (error instanceof ApiKeyEncryptionConfigError) {
return NextResponse.json(
{ success: false, error: 'API Key 加密配置缺失,请联系运维配置后重试' },
{ status: 503 },
);
}
console.error('[admin/api-keys] POST error:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import { retryExternalNotificationDelivery } from '@/lib/external-auto-listing'
async function assertAdminPermission() {
@ -11,12 +11,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可操作' }, { status: 403 })
}
return null

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import { listExternalNotificationDeliveries } from '@/lib/external-auto-listing'
async function assertAdminPermission() {
@ -11,12 +11,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可访问' }, { status: 403 })
}
return null

View File

@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { updateExternalNotificationTarget } from '@/lib/external-auto-listing'
import { isSuperAdmin } from '@/lib/department-permission'
import {
buildExternalNotificationTargetAdminView,
getExternalNotificationTargetById,
updateExternalNotificationTarget,
type ExternalNotificationTarget,
} from '@/lib/external-auto-listing'
async function assertAdminPermission() {
const session = await getServerSession(authOptions)
@ -11,17 +16,42 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可操作' }, { status: 403 })
}
return null
}
function normalizeAuthConfigInput(
value: unknown,
existing: ExternalNotificationTarget,
nextAuthType: string,
): Record<string, string> | undefined {
const record =
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
if (!record) return undefined
if (nextAuthType === 'bearer') {
const token = String(record.token || '').trim()
return { token: token || String(existing.authConfig.token || '').trim() }
}
if (nextAuthType === 'header') {
const headerName = String(record.headerName || existing.authConfig.headerName || '').trim()
const headerValue = String(record.headerValue || '').trim()
|| String(existing.authConfig.headerValue || '').trim()
const nextConfig: Record<string, string> = {}
if (headerName) nextConfig.headerName = headerName
if (headerValue) nextConfig.headerValue = headerValue
return nextConfig
}
return {}
}
function toErrorResponse(error: unknown) {
const message = error instanceof Error ? error.message : '服务器错误'
const status =
@ -53,21 +83,24 @@ export async function PUT(
}
const body = await request.json()
const existing = await getExternalNotificationTargetById(targetId)
if (!existing) {
return NextResponse.json({ success: false, error: '通知目标不存在' }, { status: 404 })
}
const nextAuthType =
typeof body?.authType === 'string' ? body.authType : existing.authType
const target = await updateExternalNotificationTarget(targetId, {
name: typeof body?.name === 'string' ? body.name : undefined,
callbackUrl: typeof body?.callbackUrl === 'string' ? body.callbackUrl : undefined,
isActive: typeof body?.isActive === 'boolean' ? body.isActive : undefined,
authType: typeof body?.authType === 'string' ? body.authType : undefined,
authConfig:
body?.authConfig && typeof body.authConfig === 'object' && !Array.isArray(body.authConfig)
? body.authConfig
: undefined,
authConfig: normalizeAuthConfigInput(body?.authConfig, existing, nextAuthType),
eventTypes: Array.isArray(body?.eventTypes) ? body.eventTypes : undefined,
})
return NextResponse.json({
success: true,
data: target,
data: buildExternalNotificationTargetAdminView(target),
message: '通知目标更新成功',
})
} catch (error) {

View File

@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import {
buildExternalNotificationTargetAdminView,
createExternalNotificationTarget,
listExternalNotificationTargets,
} from '@/lib/external-auto-listing'
@ -14,12 +15,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可访问' }, { status: 403 })
}
return null
@ -48,7 +45,7 @@ export async function GET(request: NextRequest) {
const targets = await listExternalNotificationTargets()
return NextResponse.json({
success: true,
data: targets,
data: targets.map(buildExternalNotificationTargetAdminView),
})
} catch (error) {
console.error('[admin/external-notification-targets] GET error:', error)
@ -78,7 +75,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
data: target,
data: buildExternalNotificationTargetAdminView(target),
message: '通知目标创建成功',
})
} catch (error) {

View File

@ -3,10 +3,8 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { deleteFiles } from '@/lib/file-cleanup'
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'
import { createMaterialLog } from '@/lib/material-log'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
import { enqueueMaterialPurge, ensureMaterialPurgeWorkerStarted, MaterialPurgeError } from '@/lib/material-purge'
// UUID 格式校验正则
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@ -30,6 +28,9 @@ export async function POST(request: NextRequest) {
}
const { ids, permanent } = await request.json()
if (permanent !== undefined && typeof permanent !== 'boolean') {
return NextResponse.json({ error: '删除方式无效' }, { status: 400 })
}
const operatorId = (session.user as any).id
if (!operatorId) {
@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
}
// 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0) {
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 })
}
@ -49,117 +50,10 @@ export async function POST(request: NextRequest) {
}, { status: 400 })
}
if (permanent) {
// ========== 永久删除:物理删除文件 + 删除数据库记录 ==========
console.log(`[BatchDelete] 开始永久删除 ${ids.length} 条素材...`)
// 确保下载日志表已应用“删除素材后保留日志”策略
await ensureMaterialDownloadLogsTable()
// 1. 先查询所有素材(获取文件路径和素材类型)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
// 2. 收集所有文件并删除
let totalFilesDeleted = 0
let totalFilesFailed = 0
const allErrors: Array<{ materialId: string; fileUrl: string; error: string }> = []
for (const material of materials) {
const materialType = (material as any).materialType || ''
// 记录永久删除日志(先记录,避免数据库删除后日志级联删除前丢失上下文)
await createMaterialLog({
materialId: material.id,
userId: operatorId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: materialType || null,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: 'PERMANENTLY_DELETED',
},
deleteMode: {
oldValue: material.deletedAt ? 'soft' : 'active',
newValue: 'permanent',
},
materialType: {
oldValue: materialType || null,
newValue: materialType || null,
},
},
})
// 收集该素材的所有文件
const fileInfo = await collectMaterialFiles(material.id, material)
const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]
console.log(`[BatchDelete] Material ${material.id} (${materialType}):`, {
fileUrls: fileInfo.fileUrls.length,
directories: fileInfo.directories.length,
relatedRecords: fileInfo.relatedRecords,
})
// 删除文件
if (filesToDelete.length > 0) {
const result = await deleteFiles(filesToDelete)
totalFilesDeleted += result.deletedCount
totalFilesFailed += result.failedCount
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => ({
materialId: material.id,
fileUrl: e.fileUrl,
error: e.error,
})))
}
}
// 删除关联记录(MaterialAsset, MaterialSku)
if (materialType) {
await deleteMaterialRelatedRecords(material.id, materialType)
}
}
// 3. 删除数据库记录
const deleteResult = await prisma.material.deleteMany({
where: { id: { in: ids } }
})
console.log(`[BatchDelete] 永久删除完成:`, {
requestedCount: ids.length,
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
errorCount: allErrors.length,
})
// 如果有文件删除失败,记录警告但不影响整体结果
if (allErrors.length > 0) {
console.warn(`[BatchDelete] 部分文件删除失败:`, allErrors.slice(0, 5))
}
return NextResponse.json({
success: true,
message: `批量永久删除成功`,
details: {
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
}
})
if (permanent === true) {
const jobs = await enqueueMaterialPurge(ids, operatorId)
ensureMaterialPurgeWorkerStarted()
return NextResponse.json({ success: true, jobs }, { status: 202 })
} else {
// ========== 软删除:仅标记 deletedAt ==========
@ -233,6 +127,7 @@ export async function POST(request: NextRequest) {
})
}
} catch (error) {
if (error instanceof MaterialPurgeError) return NextResponse.json({ error: error.message }, { status: error.status })
console.error('[BatchDelete] Error:', error)
return NextResponse.json({ error: '批量删除失败' }, { status: 500 })
}

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isSystemAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
import { ensureMaterialPurgeWorkerStarted, MaterialPurgeError, retryMaterialPurge, toPurgeJobView } from '@/lib/material-purge'
export const dynamic = 'force-dynamic'
async function operator() {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) return null
return isSystemAdmin((session.user as any).role) ? (session.user as any).id as string : null
}
export async function GET(request: NextRequest) {
if (!await operator()) return NextResponse.json({ error: '无权限' }, { status: 403 })
const ids = request.nextUrl.searchParams.getAll('id')
if (ids.length > 100 || ids.some(id => !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))) {
return NextResponse.json({ error: '清理任务无效' }, { status: 400 })
}
try {
const [pending, tracked] = await Promise.all([
prisma.materialPurgeJob.findMany({ where: { status: { not: 'completed' } }, orderBy: { createdAt: 'asc' }, take: 100 }),
ids.length ? prisma.materialPurgeJob.findMany({ where: { id: { in: ids } } }) : Promise.resolve([]),
])
const jobs = [...tracked, ...pending.filter(job => !tracked.some(item => item.id === job.id))]
return NextResponse.json({ jobs: jobs.map(toPurgeJobView) }, { headers: { 'Cache-Control': 'no-store' } })
} catch {
return NextResponse.json({ error: '暂时无法读取清理进度,请重试' }, { status: 503 })
}
}
export async function POST(request: NextRequest) {
const operatorId = await operator()
if (!operatorId) return NextResponse.json({ error: '无权限' }, { status: 403 })
try {
const { jobId } = await request.json()
const job = await retryMaterialPurge(jobId, operatorId)
ensureMaterialPurgeWorkerStarted()
return NextResponse.json({ job }, { status: 202 })
} catch (error) {
if (error instanceof MaterialPurgeError) return NextResponse.json({ error: error.message }, { status: error.status })
return NextResponse.json({ error: '暂时无法重试,请稍后再试' }, { status: 503 })
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
type NotificationCenterAdminActor,
NotificationCenterAdminError,
} from '@/lib/notification-center-admin'
export async function requireNotificationCenterSuperAdmin(): Promise<
{ actor: NotificationCenterAdminActor; session: unknown } | { response: NextResponse }
> {
const session = await getServerSession(authOptions)
const user = (session as {
user?: { id?: string; username?: string; realName?: string | null; email?: string | null }
} | null)?.user
if (!user?.id) {
return {
response: NextResponse.json({ success: false, error: '未登录' }, { status: 401 }),
}
}
if (!(await isSuperAdmin(user.id))) {
return {
response: NextResponse.json({ success: false, error: '仅超级管理员可访问通知中心管理接口' }, { status: 403 }),
}
}
return {
actor: {
userId: user.id,
username: user.username || null,
realName: user.realName || null,
email: user.email || null,
},
session,
}
}
export function toNotificationCenterAdminErrorResponse(error: unknown) {
if (error instanceof NotificationCenterAdminError) {
return NextResponse.json(
{
success: false,
error: error.message,
...(error.details ? { details: error.details } : {}),
code: error.code,
},
{ status: error.statusCode },
)
}
const message = error instanceof Error ? error.message : '通知中心请求失败'
console.error('[notification-center-admin] route error:', message)
return NextResponse.json(
{ success: false, error: '通知中心请求失败,请稍后重试', code: 'NOTIFICATION_CENTER_ADMIN_ROUTE_ERROR' },
{ status: 500 },
)
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterAudits } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterAudits(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterDeliveries } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterDeliveries(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterProjectRecipients } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function GET(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const result = await listNotificationCenterProjectRecipients(
auth.actor,
code,
new URL(request.url).searchParams,
)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { revealNotificationCenterProjectSecret } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const NO_STORE_HEADERS = {
'Cache-Control': 'no-store, max-age=0',
Pragma: 'no-cache',
}
function withNoStore(response: NextResponse) {
for (const [name, value] of Object.entries(NO_STORE_HEADERS)) {
response.headers.set(name, value)
}
return response
}
export async function POST(
_request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return withNoStore(auth.response)
const code = String(params?.code || '').trim()
if (!code) {
return NextResponse.json(
{ success: false, error: '通知中心项目不存在' },
{ status: 404, headers: NO_STORE_HEADERS },
)
}
const result = await revealNotificationCenterProjectSecret(auth.actor, code)
return NextResponse.json(
{ success: true, data: result.data },
{ status: result.status, headers: NO_STORE_HEADERS },
)
} catch (error) {
return withNoStore(toNotificationCenterAdminErrorResponse(error))
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { rotateNotificationCenterProjectSecret } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = String(params?.code || '').trim()
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await rotateNotificationCenterProjectSecret(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server'
import {
deleteNotificationCenterProject,
getNotificationCenterProject,
updateNotificationCenterProject,
} from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function GET(
_request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const result = await getNotificationCenterProject(auth.actor, code)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
async function handlePatch(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await updateNotificationCenterProject(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
export async function PATCH(
request: NextRequest,
context: { params: { code: string } },
) {
return handlePatch(request, context)
}
export async function PUT(
request: NextRequest,
context: { params: { code: string } },
) {
return handlePatch(request, context)
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const result = await deleteNotificationCenterProject(auth.actor, code)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { sendNotificationCenterProjectTest } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function POST(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await sendNotificationCenterProjectTest(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import {
createNotificationCenterProject,
listNotificationCenterProjects,
} from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterProjects(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const body = await request.json().catch(() => ({}))
const result = await createNotificationCenterProject(auth.actor, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterRecipients } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterRecipients(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { sendNotificationCenterTest } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const body = await request.json().catch(() => ({}))
const result = await sendNotificationCenterTest(auth.actor, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -1,88 +1,312 @@
import { createHash, timingSafeEqual } from 'crypto'
import { lstat, realpath } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { processPdfUpload } from '@/lib/pdf-upload-handler'
import { readFile } from 'fs/promises'
import { prisma } from '@/lib/prisma'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const MAX_REQUEST_BODY_BYTES = 2 * 1024
const MAX_API_KEY_BYTES = 1024
const MAX_PDF_SOURCE_BYTES = 128 * 1024 * 1024
const MATERIAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
class RegeneratePdfPreviewInputError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message)
this.name = 'RegeneratePdfPreviewInputError'
}
}
type FileSnapshot = {
size: number | bigint
dev: number | bigint
ino: number | bigint
mtimeMs: number | bigint
}
function apiKeyMatchesConfiguredSecret(providedApiKey: string | null): boolean {
const configuredApiKey = process.env.ADMIN_API_KEY
if (!configuredApiKey || configuredApiKey.trim().length === 0 || !providedApiKey) {
return false
}
if (Buffer.byteLength(providedApiKey, 'utf8') > MAX_API_KEY_BYTES) {
return false
}
// 固定长度摘要后再恒时比较,避免直接字符串比较泄露已配置密钥的长度或前缀。
const configuredDigest = createHash('sha256').update(configuredApiKey, 'utf8').digest()
const providedDigest = createHash('sha256').update(providedApiKey, 'utf8').digest()
return timingSafeEqual(configuredDigest, providedDigest)
}
async function authorizeRequest(request: NextRequest): Promise<NextResponse | null> {
if (apiKeyMatchesConfiguredSecret(request.headers.get('x-api-key'))) {
return null
}
const session = await getServerSession(authOptions)
const userId = String((session?.user as { id?: unknown } | undefined)?.id || '').trim()
if (!userId) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const role = (session?.user as { role?: string | null } | undefined)?.role
if (!hasSystemAdminPermission(role, await isSuperAdmin(userId))) {
return NextResponse.json({ error: '无权限操作' }, { status: 403 })
}
return null
}
async function readBoundedJsonBody(request: NextRequest): Promise<Record<string, unknown>> {
const contentType = String(request.headers.get('content-type') || '').toLowerCase()
if (!/^application\/json(?:\s*;|$)/.test(contentType)) {
throw new RegeneratePdfPreviewInputError('请求必须使用 JSON 格式', 415)
}
const contentEncoding = String(request.headers.get('content-encoding') || 'identity').toLowerCase()
if (contentEncoding !== 'identity') {
throw new RegeneratePdfPreviewInputError('不支持压缩请求体', 415)
}
const contentLengthHeader = request.headers.get('content-length')
if (contentLengthHeader !== null) {
if (!/^\d+$/.test(contentLengthHeader)) {
throw new RegeneratePdfPreviewInputError('请求体长度无效')
}
const contentLength = Number(contentLengthHeader)
if (!Number.isSafeInteger(contentLength) || contentLength > MAX_REQUEST_BODY_BYTES) {
throw new RegeneratePdfPreviewInputError('请求体过大', 413)
}
}
if (!request.body) {
throw new RegeneratePdfPreviewInputError('请求体不能为空')
}
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || value.byteLength === 0) continue
totalBytes += value.byteLength
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
await reader.cancel().catch(() => undefined)
throw new RegeneratePdfPreviewInputError('请求体过大', 413)
}
chunks.push(value)
}
} finally {
reader.releaseLock()
}
if (totalBytes === 0) {
throw new RegeneratePdfPreviewInputError('请求体不能为空')
}
let parsed: unknown
try {
parsed = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes).toString('utf8'))
} catch {
throw new RegeneratePdfPreviewInputError('JSON 格式无效')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new RegeneratePdfPreviewInputError('请求体格式无效')
}
return parsed as Record<string, unknown>
}
function isStrictChildPath(rootPath: string, candidatePath: string): boolean {
const relativePath = path.relative(rootPath, candidatePath)
return relativePath.length > 0
&& relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath)
}
function toFileSnapshot(stats: Awaited<ReturnType<typeof lstat>>): FileSnapshot {
return {
size: stats.size,
dev: stats.dev,
ino: stats.ino,
mtimeMs: stats.mtimeMs,
}
}
function matchesFileSnapshot(
stats: Awaited<ReturnType<typeof lstat>>,
snapshot: FileSnapshot,
): boolean {
return stats.size === snapshot.size
&& stats.dev === snapshot.dev
&& stats.ino === snapshot.ino
&& stats.mtimeMs === snapshot.mtimeMs
}
function assertRegularPdfStats(stats: Awaited<ReturnType<typeof lstat>>): void {
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new RegeneratePdfPreviewInputError('PDF 源文件不是普通文件')
}
if (stats.size <= 0) {
throw new RegeneratePdfPreviewInputError('PDF 源文件为空')
}
if (stats.size > MAX_PDF_SOURCE_BYTES) {
throw new RegeneratePdfPreviewInputError('PDF 源文件过大', 413)
}
}
async function inspectTrustedPdfSource(storedPath: string): Promise<{
realPath: string
snapshot: FileSnapshot
}> {
const uploadRootPath = path.resolve(getUploadRootDir())
const sourcePath = path.resolve(resolveStoredUploadPath(storedPath))
if (path.extname(sourcePath).toLowerCase() !== '.pdf') {
throw new RegeneratePdfPreviewInputError('源文件必须是 PDF')
}
if (!isStrictChildPath(uploadRootPath, sourcePath)) {
throw new RegeneratePdfPreviewInputError('PDF 源文件路径越界')
}
const uploadRootStats = await lstat(uploadRootPath)
if (uploadRootStats.isSymbolicLink() || !uploadRootStats.isDirectory()) {
throw new Error('UPLOAD_DIR 不是可信目录')
}
let sourceStats: Awaited<ReturnType<typeof lstat>>
try {
sourceStats = await lstat(sourcePath)
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
throw new RegeneratePdfPreviewInputError('PDF 源文件不存在', 404)
}
throw error
}
assertRegularPdfStats(sourceStats)
let uploadRootRealPath: string
let sourceRealPath: string
try {
;[uploadRootRealPath, sourceRealPath] = await Promise.all([
realpath(uploadRootPath),
realpath(sourcePath),
])
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
throw new RegeneratePdfPreviewInputError('PDF 源文件不存在', 404)
}
throw error
}
if (!isStrictChildPath(uploadRootRealPath, sourceRealPath)) {
throw new RegeneratePdfPreviewInputError('PDF 源文件路径越界')
}
if (path.extname(sourceRealPath).toLowerCase() !== '.pdf') {
throw new RegeneratePdfPreviewInputError('源文件必须是 PDF')
}
const realSourceStats = await lstat(sourceRealPath)
assertRegularPdfStats(realSourceStats)
if (!matchesFileSnapshot(realSourceStats, toFileSnapshot(sourceStats))) {
throw new RegeneratePdfPreviewInputError('PDF 源文件在检查期间发生变化', 409)
}
return {
realPath: sourceRealPath,
snapshot: toFileSnapshot(realSourceStats),
}
}
async function processTrustedPdfSource(
realPath: string,
snapshot: FileSnapshot,
) {
return withImageProcessingSlot(async () => {
// processPdfUpload 会读取 PDF 文件头和生成内容;这些读取必须全程处在全局锁内。
const currentRealPath = await realpath(realPath)
if (path.resolve(currentRealPath) !== path.resolve(realPath)) {
throw new RegeneratePdfPreviewInputError('PDF 源文件在处理前发生变化', 409)
}
const currentStats = await lstat(currentRealPath)
assertRegularPdfStats(currentStats)
if (!matchesFileSnapshot(currentStats, snapshot)) {
throw new RegeneratePdfPreviewInputError('PDF 源文件在处理前发生变化', 409)
}
return processPdfUpload(currentRealPath, '')
})
}
export async function POST(request: NextRequest) {
try {
// 验证管理员权限(支持API密钥或会话)
const apiKey = request.headers.get('x-api-key')
const session = await getServerSession(authOptions)
const authorizationError = await authorizeRequest(request)
if (authorizationError) return authorizationError
// API密钥或会话任一有效即可
const isValidApiKey = apiKey === process.env.ADMIN_API_KEY
const isValidSession = session && (session.user as any).id
if (!isValidApiKey && !isValidSession) {
return NextResponse.json({ error: '未授权' }, { status: 401 })
const body = await readBoundedJsonBody(request)
if (Object.keys(body).some((key) => key !== 'materialId')) {
throw new RegeneratePdfPreviewInputError('请求体包含不支持的字段')
}
const body = await request.json()
const { materialId } = body
if (!materialId) {
return NextResponse.json({ error: '缺少 materialId' }, { status: 400 })
const materialId = typeof body.materialId === 'string' ? body.materialId.trim() : ''
if (!MATERIAL_ID_PATTERN.test(materialId)) {
throw new RegeneratePdfPreviewInputError('素材编号无效')
}
console.log('[Regenerate PDF Preview] Material ID:', materialId)
// 获取素材详情
const material = await prisma.material.findUnique({
where: { id: materialId },
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '品牌VI灵感') {
return NextResponse.json({ error: '此API仅适用于品牌VI灵感类型素材' }, { status: 400 })
}
// 检查本地PDF文件是否存在
if (!material.zipFilePath) {
return NextResponse.json({ error: '本地PDF文件路径不存在' }, { status: 400 })
}
try {
// 检查文件是否存在
await readFile(material.zipFilePath)
} catch {
return NextResponse.json({ error: `PDF文件不存在: ${material.zipFilePath}` }, { status: 404 })
}
console.log('[Regenerate PDF Preview] PDF file path:', material.zipFilePath)
// 提取PDF预览图
const pdfResult = await processPdfUpload(material.zipFilePath, '')
console.log('[Regenerate PDF Preview] PDF预览图提取结果:', {
success: pdfResult.success,
hasPreviewUrl: !!pdfResult.previewUrl,
previewUrl: pdfResult.previewUrl,
error: pdfResult.error
})
if (!pdfResult.success || !pdfResult.previewUrl) {
const errorMsg = pdfResult.error || 'PDF预览图提取失败'
console.error('[Regenerate PDF Preview] ❌ PDF预览图提取失败:', errorMsg)
return NextResponse.json({ error: errorMsg }, { status: 500 })
}
// 更新素材的 previewUrl
await prisma.material.update({
where: { id: materialId },
data: {
previewUrl: pdfResult.previewUrl,
select: {
id: true,
materialType: true,
zipFilePath: true,
},
})
console.log('[Regenerate PDF Preview] ✅ Updated material.previewUrl:', pdfResult.previewUrl)
if (!material) {
throw new RegeneratePdfPreviewInputError('素材不存在', 404)
}
if (material.materialType !== '品牌VI灵感') {
throw new RegeneratePdfPreviewInputError('此接口仅适用于品牌VI灵感类型素材')
}
const storedPath = String(material.zipFilePath || '').trim()
if (!storedPath) {
throw new RegeneratePdfPreviewInputError('PDF 源文件路径不存在')
}
const source = await inspectTrustedPdfSource(storedPath)
const pdfResult = await processTrustedPdfSource(source.realPath, source.snapshot)
if (!pdfResult.success || !pdfResult.previewUrl) {
console.error('[Regenerate PDF Preview] PDF 预览生成失败:', pdfResult.error)
throw new Error('PDF 预览生成失败')
}
await prisma.material.update({
where: { id: materialId },
data: { previewUrl: pdfResult.previewUrl },
})
return NextResponse.json({
success: true,
@ -90,9 +314,14 @@ export async function POST(request: NextRequest) {
})
} catch (error) {
console.error('[Regenerate PDF Preview] Error:', error)
const status = error instanceof RegeneratePdfPreviewInputError ? error.status : 500
return NextResponse.json(
{ error: error instanceof Error ? error.message : '服务器错误' },
{ status: 500 }
{
error: error instanceof RegeneratePdfPreviewInputError
? error.message
: 'PDF 预览重建失败,请稍后重试',
},
{ status },
)
}
}

View File

@ -1,175 +1,444 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { readFile, mkdir, rm, readdir } from 'fs/promises'
import { join } from 'path'
import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import {
buildTaskDetailPublicDisplayPath,
uploadPublicDisplayToS3,
} from '@/lib/public-display'
import { randomUUID } from 'crypto'
import { mkdtemp, rm } from 'fs/promises'
import path from 'path'
import AdmZip from 'adm-zip'
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import sharp from 'sharp'
import { authOptions } from '@/lib/auth'
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { deleteLocalFile } from '@/lib/file-cleanup'
import { assertProcessableMetadata, sliceImage } from '@/lib/image-processor'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
import {
ensureSafeDirectoryWithinRoot,
isPathFreeOfSymbolicLinks,
isPathInAllowedDirs,
} from '@/lib/path-security'
import { prisma } from '@/lib/prisma'
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage'
import { deleteFromS3, verifyUploadedS3Object } from '@/lib/s3'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
const MATERIAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
const MAX_REQUEST_BODY_BYTES = 2 * 1024
const MAX_SOURCE_ZIP_BYTES = 64 * 1024 * 1024
const MAX_ZIP_ENTRIES = 2_000
const MAX_ZIP_ENTRY_BYTES = 48 * 1024 * 1024
const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 256 * 1024 * 1024
const MAX_ZIP_COMPRESSION_RATIO = 200
const MAX_IMAGE_PIXELS = 60_000_000
const MAX_IMAGE_DIMENSION = 20_000
const ALLOWED_IMAGE_ENTRY_PATTERN = /\.(?:jpe?g|png|webp)$/i
class RegeneratePreviewInputError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message)
this.name = 'RegeneratePreviewInputError'
}
}
async function readBoundedRequestBody(request: NextRequest): Promise<Record<string, unknown>> {
const contentType = String(request.headers.get('content-type') || '').toLowerCase()
if (!/^application\/json(?:\s*;|$)/.test(contentType)) {
throw new RegeneratePreviewInputError('请求必须使用 JSON 格式', 415)
}
const contentEncoding = String(request.headers.get('content-encoding') || 'identity').toLowerCase()
if (contentEncoding !== 'identity') {
throw new RegeneratePreviewInputError('不支持压缩请求体', 415)
}
const contentLengthHeader = request.headers.get('content-length')
if (contentLengthHeader === null) {
throw new RegeneratePreviewInputError('请求体长度缺失', 411)
}
if (!/^\d+$/.test(contentLengthHeader)) {
throw new RegeneratePreviewInputError('请求体长度无效')
}
const declaredBytes = Number(contentLengthHeader)
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
throw new RegeneratePreviewInputError('请求体长度无效')
}
if (declaredBytes > MAX_REQUEST_BODY_BYTES) {
throw new RegeneratePreviewInputError('请求体过大', 413)
}
if (!request.body) throw new RegeneratePreviewInputError('请求体不能为空')
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value || value.byteLength === 0) continue
totalBytes += value.byteLength
if (totalBytes > MAX_REQUEST_BODY_BYTES || totalBytes > declaredBytes) {
await reader.cancel().catch(() => undefined)
throw new RegeneratePreviewInputError('请求体实际大小与声明不符', 413)
}
chunks.push(value)
}
} finally {
reader.releaseLock()
}
if (totalBytes === 0 || totalBytes !== declaredBytes) {
throw new RegeneratePreviewInputError('请求体实际大小与声明不符')
}
let parsed: unknown
try {
parsed = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes).toString('utf8'))
} catch {
throw new RegeneratePreviewInputError('JSON 格式无效')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new RegeneratePreviewInputError('请求体格式无效')
}
const keys = Object.keys(parsed)
if (keys.length !== 1 || keys[0] !== 'materialId') {
throw new RegeneratePreviewInputError('请求体只能包含素材编号')
}
return parsed as Record<string, unknown>
}
class RegeneratePreviewTemporaryError extends Error {
readonly status = 503
constructor(message: string) {
super(message)
this.name = 'RegeneratePreviewTemporaryError'
}
}
function validateZipEntryName(entryName: string): string {
const normalized = String(entryName || '').normalize('NFC').replace(/\\/g, '/')
const segments = normalized.split('/').filter(Boolean)
if (
!normalized
|| normalized.startsWith('/')
|| path.posix.isAbsolute(normalized)
|| path.win32.isAbsolute(normalized)
|| /^[A-Za-z]:/.test(normalized)
|| /[\x00-\x1f\x7f]/.test(normalized)
|| segments.length === 0
|| segments.some((segment) => segment === '.' || segment === '..')
) {
throw new RegeneratePreviewInputError('压缩包包含不安全的文件路径')
}
return normalized
}
function selectBoundedImageEntry(zip: AdmZip): AdmZip.IZipEntry {
const entries = zip.getEntries()
if (entries.length === 0 || entries.length > MAX_ZIP_ENTRIES) {
throw new RegeneratePreviewInputError('压缩包文件数量超出安全范围')
}
let totalUncompressedBytes = 0
let selected: AdmZip.IZipEntry | null = null
for (const entry of entries) {
// AdmZip 会把部分 ../ 片段静默净化后才暴露 entryName;必须同时检查 ZIP
// 中原始文件名,否则不能把库的展示层净化误当成档案本身安全。
validateZipEntryName(entry.rawEntryName.toString('utf8'))
const entryName = validateZipEntryName(entry.entryName)
if (entry.isDirectory) continue
if (entry.header.encripted) {
throw new RegeneratePreviewInputError('不支持加密压缩包')
}
const size = Number(entry.header.size)
const compressedSize = Number(entry.header.compressedSize)
if (
!Number.isSafeInteger(size)
|| size <= 0
|| !Number.isSafeInteger(compressedSize)
|| compressedSize <= 0
) {
throw new RegeneratePreviewInputError('压缩包文件大小信息无效')
}
if (size > MAX_ZIP_ENTRY_BYTES) {
throw new RegeneratePreviewInputError('压缩包内单个文件超过安全上限')
}
if (size / compressedSize > MAX_ZIP_COMPRESSION_RATIO) {
throw new RegeneratePreviewInputError('压缩包压缩比异常,拒绝处理')
}
totalUncompressedBytes += size
if (!Number.isSafeInteger(totalUncompressedBytes)
|| totalUncompressedBytes > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
throw new RegeneratePreviewInputError('压缩包解压后总大小超过安全上限')
}
if (!selected && ALLOWED_IMAGE_ENTRY_PATTERN.test(entryName)) selected = entry
}
if (!selected) throw new RegeneratePreviewInputError('压缩包内未找到支持的图片文件')
return selected
}
async function readZipEntryBounded(entry: AdmZip.IZipEntry): Promise<Buffer> {
const expectedSize = Number(entry.header.size)
const buffer = await new Promise<Buffer>((resolve, reject) => {
entry.getDataAsync((data, error) => {
if (error) {
reject(new RegeneratePreviewInputError('压缩包内图片读取失败'))
return
}
resolve(data)
})
})
if (buffer.length !== expectedSize || buffer.length > MAX_ZIP_ENTRY_BYTES) {
throw new RegeneratePreviewInputError('压缩包内图片实际大小与记录不一致')
}
return buffer
}
async function inspectPreviewCommit(materialId: string, newUrls: string[]) {
try {
const [material, assets] = await Promise.all([
prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
}),
prisma.materialAsset.findMany({
where: { materialId, assetType: 'detail-preview' },
select: { fileUrl: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
}),
])
const actualUrls = assets.map((asset) => asset.fileUrl)
if (
material?.previewUrl === newUrls[0]
&& actualUrls.length === newUrls.length
&& actualUrls.every((url, index) => url === newUrls[index])
) {
return 'committed' as const
}
if (
material?.previewUrl !== newUrls[0]
&& actualUrls.every((url) => !newUrls.includes(url))
) {
return 'not-committed' as const
}
return 'ambiguous' as const
} catch {
return 'ambiguous' as const
}
}
async function cleanupDerivedObjects(fileUrls: string[]): Promise<void> {
await Promise.allSettled(
Array.from(new Set(fileUrls.filter(Boolean))).map((fileUrl) =>
(/^https?:\/\//i.test(fileUrl)
? deleteFromS3(fileUrl)
: deleteLocalFile(fileUrl)
).catch(() => undefined),
),
)
}
async function regeneratePreview(materialId: string, userId: string) {
let releaseMaterialLock: Awaited<ReturnType<typeof acquireTaskMaterialCompletionLock>> | null = null
let tempDir: string | null = null
const uploadedUrls: string[] = []
let allowDerivedCleanup = true
try {
releaseMaterialLock = await acquireTaskMaterialCompletionLock(materialId)
releaseMaterialLock.assertHeld()
const material = await prisma.material.findUnique({ where: { id: materialId } })
if (!material) throw new RegeneratePreviewInputError('素材不存在')
if (!(await canAccessMaterialPrivacy(material, userId))) {
throw new RegeneratePreviewInputError('无权限操作', 403)
}
const isPrivate = Boolean(material.taskDetailPrivate)
const detailAssets = await prisma.materialAsset.findMany({
where: { materialId, assetType: 'detail' },
orderBy: { sortOrder: 'asc' },
})
if (detailAssets.length === 0) {
throw new RegeneratePreviewInputError('没有找到详情图源文件')
}
const detailAsset = detailAssets[0]
if (!detailAsset.fileUrl.startsWith('uploads/')) {
throw new RegeneratePreviewInputError('该详情图源文件不支持安全重建')
}
const uploadRootDir = getUploadRootDir()
const localPath = resolveStoredUploadPath(detailAsset.fileUrl)
if (
!isPathInAllowedDirs(localPath, [uploadRootDir])
|| !(await isPathFreeOfSymbolicLinks(uploadRootDir, localPath))
|| path.extname(localPath).toLowerCase() !== '.zip'
) {
throw new RegeneratePreviewInputError('详情图源文件路径无效')
}
const tempRoot = await ensureSafeDirectoryWithinRoot(uploadRootDir, 'temp')
if (!tempRoot) throw new Error('无法创建安全的预览重建工作目录')
tempDir = await mkdtemp(path.join(tempRoot, `regenerate-${materialId}-`))
const sliceBuffers = await withImageProcessingSlot(async () => {
let zipBuffer: Buffer
try {
zipBuffer = await readBoundedRegularFile(localPath, {
maxBytes: MAX_SOURCE_ZIP_BYTES,
label: '详情图压缩包',
})
} catch (error) {
throw new RegeneratePreviewInputError(
error instanceof Error ? error.message : '详情图压缩包读取失败',
)
}
// AdmZip 只打开已经过磁盘大小上限检查的 ZIP;不再 extractAllTo,避免
// Zip Slip/Zip Bomb。ZIP 读取、解码、像素检查和切片全程共用同一把全局锁。
let zip: AdmZip
try {
zip = new AdmZip(zipBuffer)
} catch {
throw new RegeneratePreviewInputError('详情图压缩包格式无效')
}
const imageEntry = selectBoundedImageEntry(zip)
const buffer = await readZipEntryBounded(imageEntry)
const metadata = await sharp(buffer, {
limitInputPixels: MAX_IMAGE_PIXELS,
sequentialRead: true,
failOn: 'error',
}).metadata()
assertProcessableMetadata(metadata, {
maxInputWidth: MAX_IMAGE_DIMENSION,
maxInputHeight: MAX_IMAGE_DIMENSION,
maxInputPixels: MAX_IMAGE_PIXELS,
})
releaseMaterialLock!.assertHeld()
// sliceImage 内部也使用 withImageProcessingSlot;AsyncLocalStorage 会安全
// 复用当前锁,避免释放大 Buffer 后重新排队或发生同锁自锁。
return sliceImage(buffer, 1500, 200)
})
if (sliceBuffers.length === 0) throw new Error('未生成任何预览切片')
releaseMaterialLock.assertHeld()
const operationId = randomUUID()
for (let index = 0; index < sliceBuffers.length; index += 1) {
const slice = sliceBuffers[index]
const fileName = `detail-preview-${operationId}-${index}.webp`
const url = await saveTaskDetailPreview(
slice,
materialId,
'detail',
fileName,
isPrivate,
)
uploadedUrls.push(url)
if (!isPrivate) {
await verifyUploadedS3Object(url, slice.length, 'image/webp')
}
releaseMaterialLock.assertHeld()
}
const assetRecords = uploadedUrls.map((url, index) => ({
materialId,
assetType: 'detail-preview',
fileUrl: url,
fileName: `detail-preview-${index}.webp`,
fileSize: BigInt(sliceBuffers[index].length),
sortOrder: index,
}))
try {
releaseMaterialLock.assertHeld()
await prisma.$transaction(async (tx) => {
releaseMaterialLock!.assertHeld()
await tx.materialAsset.deleteMany({
where: { materialId, assetType: 'detail-preview' },
})
releaseMaterialLock!.assertHeld()
await tx.materialAsset.createMany({ data: assetRecords })
releaseMaterialLock!.assertHeld()
await tx.material.update({
where: { id: materialId },
data: { previewUrl: uploadedUrls[0] },
})
})
releaseMaterialLock.assertHeld()
allowDerivedCleanup = false
} catch (transactionError) {
const commitState = await inspectPreviewCommit(materialId, uploadedUrls)
if (commitState === 'committed') {
allowDerivedCleanup = false
} else if (commitState === 'ambiguous') {
// 数据库提交结果不明时绝不删除可能已被引用的新对象。
allowDerivedCleanup = false
throw new RegeneratePreviewTemporaryError(
'预览记录提交结果不明确,已保留全部文件,请管理员核查',
)
} else {
throw transactionError
}
}
return { previewCount: uploadedUrls.length, previewUrls: uploadedUrls }
} catch (error) {
if (allowDerivedCleanup && uploadedUrls.length > 0) {
await cleanupDerivedObjects(uploadedUrls)
}
throw error
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
if (releaseMaterialLock) await releaseMaterialLock().catch(() => undefined)
}
}
export async function POST(request: NextRequest) {
try {
// 验证管理员权限
const session = await getServerSession(authOptions)
if (!session || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const userId = (session.user as any).id
const userId = String((session.user as any).id)
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
if (!hasSystemAdminPermission(userRole, isSuperAdminUser)) {
if (!hasSystemAdminPermission(userRole, await isSuperAdmin(userId))) {
return NextResponse.json({ error: '无权限操作' }, { status: 403 })
}
const body = await request.json()
const { materialId } = body
if (!materialId) {
return NextResponse.json({ error: '缺少 materialId' }, { status: 400 })
const materialId = String((await readBoundedRequestBody(request)).materialId || '').trim()
if (!MATERIAL_ID_PATTERN.test(materialId)) {
return NextResponse.json({ error: '素材编号无效' }, { status: 400 })
}
console.log('[Regenerate Preview] Material ID:', materialId)
// 获取素材详情
const material = await prisma.material.findUnique({
where: { id: materialId },
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
// 查找详情图资产
const detailAssets = await prisma.materialAsset.findMany({
where: {
materialId,
assetType: 'detail',
},
orderBy: { sortOrder: 'asc' },
})
if (detailAssets.length === 0) {
return NextResponse.json({ error: '没有找到详情图' }, { status: 404 })
}
// 获取第一个详情图
const detailAsset = detailAssets[0]
console.log('[Regenerate Preview] Detail asset:', detailAsset.fileUrl)
// 如果是本地文件,解压并处理
if (detailAsset.fileUrl.startsWith('uploads/')) {
const uploadRootDir = getUploadRootDir()
const localPath = resolveStoredUploadPath(detailAsset.fileUrl)
if (!isPathInAllowedDirs(localPath, [uploadRootDir])) {
return NextResponse.json({ error: '详情图文件路径无效' }, { status: 400 })
}
// 创建临时目录
const tempDir = join(uploadRootDir, 'temp', `regenerate-${materialId}`)
await mkdir(tempDir, { recursive: true })
// ⚠️ 安全修复:使用 AdmZip 替代 unzip 命令,防止命令注入(R40)
try {
const zip = new AdmZip(localPath)
zip.extractAllTo(tempDir, true)
console.log('[Regenerate Preview] Unzip complete')
} catch (unzipError) {
console.error('[Regenerate Preview] Unzip failed:', unzipError)
return NextResponse.json({ error: '解压文件失败' }, { status: 400 })
}
// ⚠️ 安全修复:使用 fs.readdir 替代 ls 命令,防止命令注入(R40)
const files = await readdir(tempDir)
const imageFile = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
if (!imageFile) {
return NextResponse.json({ error: '未找到图片文件' }, { status: 400 })
}
const imagePath = join(tempDir, imageFile)
console.log('[Regenerate Preview] Image path:', imagePath)
// 读取图片
const buffer = await readFile(imagePath)
const metadata = await sharp(buffer).metadata()
console.log('[Regenerate Preview] Image size:', metadata.width, 'x', metadata.height)
// 切片处理
const sliceBuffers = await sliceImage(buffer, 1500, 200)
console.log('[Regenerate Preview] Slices:', sliceBuffers.length)
// 上传新预览图
const timestamp = Date.now()
const newPreviewUrls: string[] = []
for (let i = 0; i < sliceBuffers.length; i++) {
const fileName = `detail-preview-${timestamp}-${i}-slice${i}.webp`
const url = await uploadPublicDisplayToS3(
sliceBuffers[i],
buildTaskDetailPublicDisplayPath(materialId, 'detail', fileName),
'image/webp',
)
newPreviewUrls.push(url)
console.log('[Regenerate Preview] Uploaded slice', i + 1, '/', sliceBuffers.length, ':', url)
}
// 删除旧的预览图记录
await prisma.materialAsset.deleteMany({
where: {
materialId,
assetType: 'detail-preview',
},
})
// 创建新的预览图记录
const assetRecords = newPreviewUrls.map((url, index) => ({
materialId,
assetType: 'detail-preview',
fileUrl: url,
fileName: `detail-preview-${index}.webp`,
sortOrder: index,
}))
await prisma.materialAsset.createMany({
data: assetRecords,
})
// 更新素材的 previewUrl(用于首页卡片显示)
await prisma.material.update({
where: { id: materialId },
data: {
previewUrl: newPreviewUrls[0], // 使用第一张切片作为卡片预览图
},
})
console.log('[Regenerate Preview] Updated material.previewUrl:', newPreviewUrls[0])
// ⚠️ 安全修复:使用 fs.rm 替代 rm 命令,防止命令注入(R40)
await rm(tempDir, { recursive: true, force: true })
console.log('[Regenerate Preview] Complete! Generated', newPreviewUrls.length, 'previews')
return NextResponse.json({
success: true,
previewCount: newPreviewUrls.length,
previewUrls: newPreviewUrls,
})
}
return NextResponse.json({ error: '不支持的文件类型' }, { status: 400 })
const result = await regeneratePreview(materialId, userId)
return NextResponse.json({ success: true, ...result })
} catch (error) {
console.error('[Regenerate Preview] Error:', error)
const status = error instanceof RegeneratePreviewInputError
? (error.message === '素材不存在' || error.message === '没有找到详情图源文件'
? 404
: error.status)
: Number((error as { status?: number; statusCode?: number } | undefined)?.status
|| (error as { statusCode?: number } | undefined)?.statusCode
|| 500)
const exposeMessage = error instanceof RegeneratePreviewInputError
|| error instanceof RegeneratePreviewTemporaryError
|| status === 503
return NextResponse.json(
{ error: error instanceof Error ? error.message : '服务器错误' },
{ status: 500 }
{
error: exposeMessage && error instanceof Error
? error.message
: '预览重建失败,请稍后重试',
},
{ status: Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500 },
)
}
}

View File

@ -49,8 +49,63 @@ export async function GET() {
createdAt: 'desc',
},
});
// 批量查询活跃绑定,附加权威部门路径(避免 N+1)
// 权威人员数据:UserEmployeeBinding -> PerformanceSurveyEmployeeProfile.departmentPath
const activeBindings = await prisma.userEmployeeBinding.findMany({
where: { status: 'ACTIVE' },
select: {
userId: true,
employeeProfile: {
select: {
departmentPath: true,
departmentName: true,
displayName: true,
positionLevel: true,
levelType: true,
isLeadership: true,
employmentState: true,
},
},
},
});
const bindingMap = new Map(
activeBindings
.filter((b) => b.employeeProfile)
.map((b) => [
b.userId,
{
departmentPath: b.employeeProfile!.departmentPath,
departmentName: b.employeeProfile!.departmentName,
displayName: b.employeeProfile!.displayName,
positionLevel: b.employeeProfile!.positionLevel,
levelType: b.employeeProfile!.levelType,
isLeadership: b.employeeProfile!.isLeadership,
employmentState: b.employeeProfile!.employmentState,
},
]),
);
// 附加权威部门字段到每个用户
const enriched = users.map((u) => {
const binding = bindingMap.get(u.id);
return {
...u,
// 权威部门路径(新组织树),如「江苏简尚品牌管理有限公司/运营中心G/运营一部(CZ)」
employeeDeptPath: binding?.departmentPath ?? null,
// 权威部门名(最后一段)
employeeDeptName: binding?.departmentPath ? binding.departmentPath.split('/').pop() ?? null : null,
employeeDisplayName: binding?.displayName ?? null,
employeePositionLevel: binding?.positionLevel ?? null,
employeeLevelType: binding?.levelType ?? null,
employeeIsLeadership: binding?.isLeadership ?? false,
employeeEmploymentState: binding?.employmentState ?? null,
// 是否有权威绑定(无绑定 = 待人工确认)
hasEmployeeBinding: binding != null,
};
});
return NextResponse.json(users);
return NextResponse.json(enriched);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 });

View File

@ -12,6 +12,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types'
import { USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'
export async function GET(req: NextRequest) {
try {
@ -89,7 +90,7 @@ export async function GET(req: NextRequest) {
failedTasks.push({
id: material.id,
materialType: material.materialType,
error: material.transcodeLastError,
error: USER_TRANSCODE_FAILURE_MESSAGE,
attempts: material.transcodeAttempts || 0,
createdAt: material.createdAt,
})

View File

@ -0,0 +1,204 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
buildMaterialWhereFromSearchParams,
createMaterialQueryContext,
normalizeMaterialTypeForUi,
} from '@/lib/material-query'
import { MATERIAL_TYPE_OPTIONS } from '@/lib/material-type-options'
import { calculateReuseRate, type ValueMetricsMaterial } from '@/lib/value-metrics'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
const MAX_DETAIL_LIMIT = 100
type MaterialTypeCountRow = {
materialType: string
_count: { _all: number }
}
function typeOrder(type: string) {
const index = MATERIAL_TYPE_OPTIONS.indexOf(type as typeof MATERIAL_TYPE_OPTIONS[number])
return index === -1 ? MATERIAL_TYPE_OPTIONS.length : index
}
function buildTypeSummaries(
totalRows: MaterialTypeCountRow[],
reusedRows: MaterialTypeCountRow[],
) {
const grouped = new Map<string, { rawTypes: Set<string>; totalCount: number; reusedCount: number }>()
for (const row of totalRows) {
const label = normalizeMaterialTypeForUi(row.materialType)
if (!label) continue
const current = grouped.get(label) || { rawTypes: new Set<string>(), totalCount: 0, reusedCount: 0 }
current.rawTypes.add(row.materialType)
current.totalCount += Number(row._count?._all || 0)
grouped.set(label, current)
}
for (const row of reusedRows) {
const label = normalizeMaterialTypeForUi(row.materialType)
if (!label) continue
const current = grouped.get(label) || { rawTypes: new Set<string>(), totalCount: 0, reusedCount: 0 }
current.rawTypes.add(row.materialType)
current.reusedCount += Number(row._count?._all || 0)
grouped.set(label, current)
}
return Array.from(grouped.entries())
.map(([label, value]) => ({
key: label,
label,
totalCount: value.totalCount,
reusedCount: value.reusedCount,
reuseRate: calculateReuseRate(value.reusedCount, value.totalCount),
rawTypes: Array.from(value.rawTypes),
}))
.sort((a, b) => typeOrder(a.label) - typeOrder(b.label) || a.label.localeCompare(b.label, 'zh-CN'))
}
function toMaterialDetail(material: any): ValueMetricsMaterial {
const authorName = String(material.user?.realName || material.user?.username || material.authorName || '').trim() || '未标注'
const title = String(material.productModel || material.reportTitle || material.taskNo || '').trim() || `素材 ${String(material.id).slice(0, 8)}`
return {
id: String(material.id),
title,
materialType: normalizeMaterialTypeForUi(material.materialType) || String(material.materialType || '未分类'),
authorName,
createdAt: new Date(material.createdAt).toISOString(),
downloadCount: Number(material.downloadCount || 0),
categoryName: String(material.categoryName || '').trim(),
sceneName: String(material.sceneName || '').trim(),
platformName: String(material.platformName || '').trim(),
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ success: false, error: '未登录', code: 'UNAUTHORIZED' }, { status: 401, headers: PRIVATE_HEADERS })
}
// 复用素材资产库的筛选、删除状态、素材类型权限和私密任务详情准入规则,
// 保证价值指标不会比用户在资产库里能看到的范围更宽。
const user = session.user as any
const baseParams = new URLSearchParams()
const context = await createMaterialQueryContext(baseParams, user)
const { where, empty } = await buildMaterialWhereFromSearchParams(baseParams, user, { context })
if (empty) {
return NextResponse.json({
success: true,
data: {
types: [],
selectedType: null,
selectedTypeSummary: null,
materials: [],
materialsTotal: 0,
hasMore: false,
summary: { totalAssets: 0, reusedAssets: 0, reuseRate: null },
},
}, { headers: PRIVATE_HEADERS })
}
const [totalRows, reusedRows] = await Promise.all([
prisma.material.groupBy({
by: ['materialType'],
where,
_count: { _all: true },
}) as unknown as Promise<MaterialTypeCountRow[]>,
prisma.material.groupBy({
by: ['materialType'],
where: { AND: [where, { downloadCount: { gt: 0 } }] },
_count: { _all: true },
}) as unknown as Promise<MaterialTypeCountRow[]>,
])
const typeRows = buildTypeSummaries(totalRows, reusedRows)
const requestedType = normalizeMaterialTypeForUi(request.nextUrl.searchParams.get('materialType'))
const selectedSummary = typeRows.find((type) => type.key === requestedType) || typeRows[0] || null
const selectedType = selectedSummary?.key || null
const detailLimit = Math.min(Math.max(Number.parseInt(request.nextUrl.searchParams.get('limit') || '100', 10) || 100, 1), MAX_DETAIL_LIMIT)
const detailOffset = Math.max(Number.parseInt(request.nextUrl.searchParams.get('offset') || '0', 10) || 0, 0)
let materials: ValueMetricsMaterial[] = []
if (selectedSummary) {
const detailRows = await prisma.material.findMany({
where: {
AND: [where, { materialType: { in: (selectedSummary as any).rawTypes } }],
},
select: {
id: true,
reportTitle: true,
productModel: true,
taskNo: true,
materialType: true,
authorName: true,
createdAt: true,
downloadCount: true,
categoryName: true,
sceneName: true,
platformName: true,
user: { select: { realName: true, username: true } },
},
orderBy: [
{ downloadCount: 'desc' },
{ createdAt: 'desc' },
{ id: 'asc' },
],
skip: detailOffset,
take: detailLimit + 1,
})
const hasMore = detailRows.length > detailLimit
materials = detailRows.slice(0, detailLimit).map(toMaterialDetail)
const totalAssets = typeRows.reduce((sum, type) => sum + type.totalCount, 0)
const reusedAssets = typeRows.reduce((sum, type) => sum + type.reusedCount, 0)
return NextResponse.json({
success: true,
data: {
types: typeRows.map(({ rawTypes: _rawTypes, ...type }) => type),
selectedType,
selectedTypeSummary: selectedSummary
? (({ rawTypes: _rawTypes, ...summary }) => summary)(selectedSummary)
: null,
materials,
materialsTotal: selectedSummary.totalCount,
hasMore,
summary: {
totalAssets,
reusedAssets,
reuseRate: calculateReuseRate(reusedAssets, totalAssets),
},
},
}, { headers: PRIVATE_HEADERS })
}
return NextResponse.json({
success: true,
data: {
types: typeRows,
selectedType: null,
selectedTypeSummary: null,
materials: [],
materialsTotal: 0,
hasMore: false,
summary: { totalAssets: 0, reusedAssets: 0, reuseRate: null },
},
}, { headers: PRIVATE_HEADERS })
} catch (error) {
console.error('[department-dashboard] 价值指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '价值指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,49 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
const body = await request.json().catch(() => null)
if (typeof body?.active !== 'boolean') {
return NextResponse.json({ success: false, error: '收藏状态不合法' }, { status: 400, headers: PRIVATE_HEADERS })
}
const knowledge = await prisma.brandKnowledge.findFirst({ where: { id: params.id, deletedAt: null }, select: { id: true } })
if (!knowledge) {
return NextResponse.json({ success: false, error: '知识内容不存在' }, { status: 404, headers: PRIVATE_HEADERS })
}
if (body.active) {
await prisma.brandKnowledgeFavorite.upsert({
where: { knowledgeId_userId: { knowledgeId: knowledge.id, userId } },
create: { knowledgeId: knowledge.id, userId },
update: {},
})
} else {
await prisma.brandKnowledgeFavorite.deleteMany({ where: { knowledgeId: knowledge.id, userId } })
}
return NextResponse.json(
{ success: true, isFavorited: body.active },
{ headers: PRIVATE_HEADERS },
)
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识收藏失败:', error)
return NextResponse.json({ success: false, error: '收藏操作失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -0,0 +1,132 @@
import { getServerSession } from 'next-auth'
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import { Readable } from 'stream'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({
prisma: { brandKnowledge: { findFirst: jest.fn(), updateMany: jest.fn() } },
}))
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: () => '/uploads',
resolveStoredUploadPath: () => '/uploads/brand-knowledge/guide.pdf',
}))
jest.mock('@/lib/path-security', () => ({
isPathFreeOfSymbolicLinks: jest.fn(async () => true),
isPathInAllowedDirs: jest.fn(() => true),
}))
jest.mock('fs/promises', () => ({
stat: jest.fn(),
}))
jest.mock('fs', () => ({ createReadStream: jest.fn() }))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
ReadableStream: edgePrimitives.ReadableStream,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let GET: typeof import('./route').GET
class CancellableReadable extends Readable {
private emitted = false
override _read() {
if (!this.emitted) {
this.emitted = true
this.push(Buffer.from('part'))
}
}
}
class FailingReadable extends Readable {
override _read() {
this.destroy(new Error('stream failed'))
}
}
describe('brand knowledge file route', () => {
beforeAll(async () => { ({ GET } = await import('./route')) })
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue({
id: 'knowledge-1',
fileName: 'guide.pdf',
filePath: 'uploads/brand-knowledge/guide.pdf',
mimeType: 'application/pdf',
fileSize: BigInt(4),
})
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 4 })
;(createReadStream as jest.Mock).mockReturnValue(Readable.from([Buffer.from('test')]))
;(prisma.brandKnowledge.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
})
test('increments the download count after the validated file stream completes', async () => {
const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } })
expect(response.status).toBe(200)
expect(createReadStream).toHaveBeenCalledWith('/uploads/brand-knowledge/guide.pdf')
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
expect(await response.text()).toBe('test')
expect(prisma.brandKnowledge.updateMany).toHaveBeenCalledWith({
where: { id: 'knowledge-1', deletedAt: null },
data: { downloadCount: { increment: 1 } },
})
expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''guide.pdf")
})
test('does not count a cancelled download', async () => {
;(createReadStream as jest.Mock).mockReturnValue(new CancellableReadable())
const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } })
const reader = response.body!.getReader()
await reader.read()
await reader.cancel()
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
})
test('does not count a stream that fails before EOF', async () => {
;(createReadStream as jest.Mock).mockReturnValue(new FailingReadable())
const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } })
await expect(response.text()).rejects.toThrow('stream failed')
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
})
test('does not fail the completed download when count persistence fails', async () => {
const countError = new Error('count unavailable')
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(prisma.brandKnowledge.updateMany as jest.Mock).mockRejectedValue(countError)
const response = await GET(new Request('http://localhost/file'), { params: { id: 'knowledge-1' } })
await expect(response.text()).resolves.toBe('test')
await Promise.resolve()
expect(errorSpy).toHaveBeenCalledWith('[department-dashboard] 品牌知识下载计数更新失败:', countError)
})
test('does not increment when the knowledge file does not exist', async () => {
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(null)
const response = await GET(new Request('http://localhost/file'), { params: { id: 'missing' } })
expect(response.status).toBe(404)
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,61 @@
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security'
import { nodeReadableToWebStream } from '@/lib/node-readable-stream'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(_request: Request, context: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const item = await prisma.brandKnowledge.findFirst({ where: { id: context.params.id, deletedAt: null } })
if (!item) return NextResponse.json({ success: false, error: '知识文件不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const uploadRoot = path.resolve(getUploadRootDir())
const filePath = path.resolve(resolveStoredUploadPath(item.filePath))
if (!isPathInAllowedDirs(filePath, [uploadRoot]) || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))) {
return NextResponse.json({ success: false, error: '知识文件路径不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const fileStats = await stat(filePath)
if (!fileStats.isFile() || fileStats.size !== Number(item.fileSize)) {
return NextResponse.json({ success: false, error: '知识文件不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const body = nodeReadableToWebStream(createReadStream(filePath), {
onComplete: () => {
void prisma.brandKnowledge.updateMany({
where: { id: item.id, deletedAt: null },
data: { downloadCount: { increment: 1 } },
}).catch((error) => {
console.error('[department-dashboard] 品牌知识下载计数更新失败:', error)
})
},
})
const encodedName = encodeURIComponent(item.fileName)
return new NextResponse(body, {
status: 200,
headers: {
...PRIVATE_HEADERS,
'Content-Type': item.mimeType || 'application/octet-stream',
'Content-Length': String(fileStats.size),
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
},
})
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识文件读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识文件读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -0,0 +1,50 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
const body = await request.json().catch(() => null)
if (typeof body?.active !== 'boolean') {
return NextResponse.json({ success: false, error: '点赞状态不合法' }, { status: 400, headers: PRIVATE_HEADERS })
}
const knowledge = await prisma.brandKnowledge.findFirst({ where: { id: params.id, deletedAt: null }, select: { id: true } })
if (!knowledge) {
return NextResponse.json({ success: false, error: '知识内容不存在' }, { status: 404, headers: PRIVATE_HEADERS })
}
if (body.active) {
await prisma.brandKnowledgeLike.upsert({
where: { knowledgeId_userId: { knowledgeId: knowledge.id, userId } },
create: { knowledgeId: knowledge.id, userId },
update: {},
})
} else {
await prisma.brandKnowledgeLike.deleteMany({ where: { knowledgeId: knowledge.id, userId } })
}
const likeCount = await prisma.brandKnowledgeLike.count({ where: { knowledgeId: knowledge.id } })
return NextResponse.json(
{ success: true, liked: body.active, likeCount },
{ headers: PRIVATE_HEADERS },
)
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识点赞失败:', error)
return NextResponse.json({ success: false, error: '点赞操作失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -0,0 +1,109 @@
import { getServerSession } from 'next-auth'
import { readFile, stat } from 'fs/promises'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({
prisma: { brandKnowledge: { findFirst: jest.fn() } },
}))
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: () => '/uploads',
resolveStoredUploadPath: (storedPath: string) => `/${storedPath}`,
}))
jest.mock('@/lib/path-security', () => ({
isPathFreeOfSymbolicLinks: jest.fn(async () => true),
isPathInAllowedDirs: jest.fn(() => true),
}))
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
stat: jest.fn(),
}))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let GET: typeof import('./route').GET
describe('brand knowledge preview route', () => {
beforeAll(async () => { ({ GET } = await import('./route')) })
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue({
previewImagePath: 'uploads/brand-knowledge/part-001.webp',
previewImageSlices: [
'uploads/brand-knowledge/part-001.webp',
'uploads/brand-knowledge/part-002.webp',
],
previewImageMimeType: 'image/webp',
previewImageSize: BigInt(4),
})
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 })
;(readFile as jest.Mock).mockResolvedValue(Buffer.from('slice-02'))
})
test('serves the requested ordered WebP slice without applying the legacy size check', async () => {
const response = await GET(
new Request('http://localhost/preview?part=1'),
{ params: { id: 'knowledge-1' } },
)
expect(response.status).toBe(200)
expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/part-002.webp')
expect(response.headers.get('content-type')).toBe('image/webp')
await expect(response.text()).resolves.toBe('slice-02')
})
test('verifies the stored size for the first generated slice', async () => {
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 })
const response = await GET(
new Request('http://localhost/preview?part=0'),
{ params: { id: 'knowledge-1' } },
)
expect(response.status).toBe(404)
expect(readFile).not.toHaveBeenCalled()
})
test('rejects invalid and out-of-range slice indexes', async () => {
const invalid = await GET(new Request('http://localhost/preview?part=1.5'), { params: { id: 'knowledge-1' } })
const missing = await GET(new Request('http://localhost/preview?part=2'), { params: { id: 'knowledge-1' } })
expect(invalid.status).toBe(400)
expect(missing.status).toBe(404)
expect(readFile).not.toHaveBeenCalled()
})
test('falls back to the legacy single preview and still verifies its stored size', async () => {
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue({
previewImagePath: 'uploads/brand-knowledge/legacy.png',
previewImageSlices: [],
previewImageMimeType: 'image/png',
previewImageSize: BigInt(4),
})
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 4 })
;(readFile as jest.Mock).mockResolvedValue(Buffer.from('test'))
const response = await GET(new Request('http://localhost/preview'), { params: { id: 'knowledge-1' } })
expect(response.status).toBe(200)
expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/legacy.png')
expect(response.headers.get('content-type')).toBe('image/png')
})
})

View File

@ -0,0 +1,65 @@
import { readFile, stat } from 'fs/promises'
import path from 'path'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(request: Request, context: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const item = await prisma.brandKnowledge.findFirst({
where: { id: context.params.id, deletedAt: null },
select: { previewImagePath: true, previewImageSlices: true, previewImageMimeType: true, previewImageSize: true },
})
if (!item?.previewImagePath) return NextResponse.json({ success: false, error: '预览图不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const rawPart = new URL(request.url).searchParams.get('part')
const part = rawPart === null || rawPart === '' ? 0 : Number(rawPart)
if (!Number.isSafeInteger(part) || part < 0) {
return NextResponse.json({ success: false, error: '预览图片段编号无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
const slicePaths = item.previewImageSlices.length > 0 ? item.previewImageSlices : [item.previewImagePath]
const selectedPath = slicePaths[part]
if (!selectedPath) return NextResponse.json({ success: false, error: '预览图片段不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const uploadRoot = path.resolve(getUploadRootDir())
const filePath = path.resolve(resolveStoredUploadPath(selectedPath))
if (!isPathInAllowedDirs(filePath, [uploadRoot]) || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))) {
return NextResponse.json({ success: false, error: '预览图路径不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const fileStats = await stat(filePath)
const isLegacySinglePreview = item.previewImageSlices.length === 0
const expectedSize = isLegacySinglePreview || part === 0
? item.previewImageSize
: null
if (!fileStats.isFile() || (expectedSize != null && fileStats.size !== Number(expectedSize))) {
return NextResponse.json({ success: false, error: '预览图不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const body = await readFile(filePath)
return new NextResponse(new Uint8Array(body), {
status: 200,
headers: {
...PRIVATE_HEADERS,
'Content-Type': isLegacySinglePreview ? item.previewImageMimeType || 'image/webp' : 'image/webp',
'Content-Length': String(fileStats.size),
'Content-Disposition': 'inline',
},
})
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识预览图读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识预览图读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -0,0 +1,158 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
const KNOWLEDGE_TYPES: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_TYPE_OPTIONS)
function managementError(message: string, status: number) {
return NextResponse.json({ success: false, error: message }, { status, headers: PRIVATE_HEADERS })
}
async function requireSuperAdmin() {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return { response: managementError('请先登录', 401), userId: null }
if (!(await isSuperAdmin(userId))) return { response: managementError('仅超级管理员可管理品牌知识', 403), userId }
return { response: null, userId }
}
function parseMetadata(body: unknown):
| { ok: false; error: string }
| { ok: true; data: { title: string; knowledgeType: string; brand: string; productCategoryName: string } } {
if (!body || typeof body !== 'object') return { ok: false, error: '提交内容不合法' }
const payload = body as Record<string, unknown>
if (typeof payload.title !== 'string' || typeof payload.knowledgeType !== 'string'
|| typeof payload.brand !== 'string' || typeof payload.productCategoryName !== 'string') {
return { ok: false, error: '品牌知识字段不完整' }
}
const title = payload.title.trim()
const knowledgeType = payload.knowledgeType.trim()
const brand = payload.brand.trim()
const productCategoryName = payload.productCategoryName.trim()
if (!title || title.length > 200) return { ok: false, error: '知识标题需为 1 至 200 个字符' }
if (!KNOWLEDGE_TYPES.has(knowledgeType)) return { ok: false, error: '知识类型不合法' }
if (brand.length > 40) return { ok: false, error: '品牌名称不能超过 40 个字符' }
if (productCategoryName.length > 80) return { ok: false, error: '品类名称不能超过 80 个字符' }
return { ok: true, data: { title, knowledgeType, brand, productCategoryName } }
}
export async function GET(_request: Request, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) {
return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
}
const item = await prisma.brandKnowledge.findFirst({
where: { id: params.id, deletedAt: null },
select: {
id: true,
title: true,
knowledgeType: true,
brand: true,
productCategoryName: true,
fileName: true,
mimeType: true,
fileSize: true,
downloadCount: true,
previewImageName: true,
previewImagePath: true,
previewImageSlices: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
likes: { where: { userId }, select: { id: true } },
favorites: { where: { userId }, select: { id: true } },
_count: { select: { likes: true } },
},
})
if (!item) {
return NextResponse.json({ success: false, error: '知识内容不存在' }, { status: 404, headers: PRIVATE_HEADERS })
}
return NextResponse.json({
success: true,
data: {
id: item.id,
title: item.title,
knowledgeType: item.knowledgeType,
brand: item.brand,
productCategoryName: item.productCategoryName,
fileName: item.fileName,
mimeType: item.mimeType,
fileSize: Number(item.fileSize),
downloadCount: item.downloadCount,
createdAt: item.createdAt.toISOString(),
uploadedBy: item.uploader.realName || item.uploader.username,
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null,
previewUrls: item.previewImageSlices.length > 0
? item.previewImageSlices.map((_, index) => `/api/department-dashboard/brand-knowledge/${item.id}/preview?part=${index}`)
: item.previewImagePath
? [`/api/department-dashboard/brand-knowledge/${item.id}/preview`]
: [],
previewImageName: item.previewImageName,
likeCount: item._count.likes,
isLiked: item.likes.length > 0,
isFavorited: item.favorites.length > 0,
},
}, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识详情读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识详情读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
try {
const authorization = await requireSuperAdmin()
if (authorization.response) return authorization.response
const parsed = parseMetadata(await request.json().catch(() => null))
if (!parsed.ok) return managementError(parsed.error, 400)
const result = await prisma.brandKnowledge.updateMany({
where: { id: params.id, deletedAt: null },
data: parsed.data,
})
if (result.count !== 1) return managementError('知识内容不存在', 404)
return NextResponse.json({ success: true, data: { id: params.id, ...parsed.data } }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return managementError(error.message, error.status)
}
console.error('[department-dashboard] 品牌知识编辑失败:', error)
return managementError('品牌知识编辑失败,请稍后重试', 500)
}
}
export async function DELETE(_request: Request, { params }: { params: { id: string } }) {
try {
const authorization = await requireSuperAdmin()
if (authorization.response) return authorization.response
const result = await prisma.brandKnowledge.updateMany({
where: { id: params.id, deletedAt: null },
data: { deletedAt: new Date() },
})
if (result.count !== 1) return managementError('知识内容不存在', 404)
return NextResponse.json({ success: true }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return managementError(error.message, error.status)
}
console.error('[department-dashboard] 品牌知识删除失败:', error)
return managementError('品牌知识删除失败,请稍后重试', 500)
}
}

View File

@ -0,0 +1,244 @@
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { assertBrandCenterDashboardAccess } from '@/lib/brand-center-dashboard-access'
import { isSuperAdmin } from '@/lib/department-permission'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(async () => false) }))
jest.mock('@/lib/prisma', () => ({
prisma: {
brandKnowledge: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn() },
brandKnowledgeLike: { upsert: jest.fn(), deleteMany: jest.fn(), count: jest.fn() },
brandKnowledgeFavorite: { upsert: jest.fn(), deleteMany: jest.fn() },
},
}))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let GET: typeof import('./route').GET
let getDetail: typeof import('./[id]/route').GET
let patchDetail: typeof import('./[id]/route').PATCH
let deleteDetail: typeof import('./[id]/route').DELETE
let putLike: typeof import('./[id]/like/route').PUT
let putFavorite: typeof import('./[id]/favorite/route').PUT
const context = { params: { id: 'knowledge-1' } }
function actionRequest(path: string, active: unknown) {
return new Request(`http://localhost${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
}) as unknown as import('next/server').NextRequest
}
describe('brand knowledge engagement routes', () => {
beforeAll(async () => {
;({ GET } = await import('./route'))
;({ GET: getDetail } = await import('./[id]/route'))
;({ PATCH: patchDetail, DELETE: deleteDetail } = await import('./[id]/route'))
;({ PUT: putLike } = await import('./[id]/like/route'))
;({ PUT: putFavorite } = await import('./[id]/favorite/route'))
})
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(assertBrandCenterDashboardAccess as jest.Mock).mockResolvedValue(undefined)
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue({ id: 'knowledge-1' })
;(prisma.brandKnowledge.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.brandKnowledgeLike.count as jest.Mock).mockResolvedValue(1)
})
test('list returns current user engagement state and total likes', async () => {
;(prisma.brandKnowledge.findMany as jest.Mock).mockResolvedValue([{
id: 'knowledge-1',
title: '影像规范',
knowledgeType: '影像规范',
brand: '索菲亚',
productCategoryName: '衣柜',
fileName: 'guide.pdf',
mimeType: 'application/pdf',
fileSize: BigInt(10),
downloadCount: 2,
previewImageName: null,
previewImagePath: null,
previewImageMimeType: null,
previewImageSize: null,
createdAt: new Date('2026-09-14T00:00:00.000Z'),
uploader: { realName: '无邪', username: 'wuxie' },
likes: [{ id: 'like-1' }],
favorites: [{ id: 'favorite-1' }],
_count: { likes: 3 },
}])
const response = await GET(new Request('http://localhost/api/department-dashboard/brand-knowledge'))
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
expect(body.data[0]).toEqual(expect.objectContaining({ downloadCount: 2, likeCount: 3, isLiked: true, isFavorited: true }))
expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.objectContaining({
likes: expect.objectContaining({ where: { userId: 'user-1' } }),
favorites: expect.objectContaining({ where: { userId: 'user-1' } }),
_count: { select: { likes: true } },
}),
}))
})
test('detail returns one knowledge item with file metrics and engagement state', async () => {
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue({
id: 'knowledge-1',
title: '影像规范',
knowledgeType: '影像规范',
brand: '索菲亚',
productCategoryName: '衣柜',
fileName: 'guide.pdf',
mimeType: 'application/pdf',
fileSize: BigInt(10),
downloadCount: 4,
previewImageName: 'cover.png',
previewImagePath: 'uploads/brand-knowledge/cover.png',
previewImageSlices: [
'uploads/brand-knowledge/cover-001.webp',
'uploads/brand-knowledge/cover-002.webp',
],
createdAt: new Date('2026-09-14T00:00:00.000Z'),
uploader: { realName: '无邪', username: 'wuxie' },
likes: [{ id: 'like-1' }],
favorites: [{ id: 'favorite-1' }],
_count: { likes: 3 },
})
const response = await getDetail(new Request('http://localhost/detail'), context)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual(expect.objectContaining({
id: 'knowledge-1',
downloadCount: 4,
likeCount: 3,
isLiked: true,
isFavorited: true,
previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview',
previewUrls: [
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=0',
'/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=1',
],
}))
})
test('like uses an idempotent active state and returns the new count', async () => {
const response = await putLike(actionRequest('/like', true), context)
expect(response.status).toBe(200)
expect(prisma.brandKnowledgeLike.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { knowledgeId_userId: { knowledgeId: 'knowledge-1', userId: 'user-1' } },
}))
await expect(response.json()).resolves.toEqual({ success: true, liked: true, likeCount: 1 })
})
test('unlike and unfavorite are idempotent deletes', async () => {
const likeResponse = await putLike(actionRequest('/like', false), context)
const favoriteResponse = await putFavorite(actionRequest('/favorite', false), context)
expect(prisma.brandKnowledgeLike.deleteMany).toHaveBeenCalledWith({ where: { knowledgeId: 'knowledge-1', userId: 'user-1' } })
expect(prisma.brandKnowledgeFavorite.deleteMany).toHaveBeenCalledWith({ where: { knowledgeId: 'knowledge-1', userId: 'user-1' } })
await expect(likeResponse.json()).resolves.toEqual({ success: true, liked: false, likeCount: 1 })
await expect(favoriteResponse.json()).resolves.toEqual({ success: true, isFavorited: false })
})
test('favorite upserts one user state', async () => {
const response = await putFavorite(actionRequest('/favorite', true), context)
expect(prisma.brandKnowledgeFavorite.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { knowledgeId_userId: { knowledgeId: 'knowledge-1', userId: 'user-1' } },
}))
await expect(response.json()).resolves.toEqual({ success: true, isFavorited: true })
})
test('rejects invalid state and missing knowledge without writing', async () => {
const invalid = await putLike(actionRequest('/like', 'yes'), context)
expect(invalid.status).toBe(400)
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(null)
const missing = await putFavorite(actionRequest('/favorite', true), context)
expect(missing.status).toBe(404)
expect(prisma.brandKnowledgeFavorite.upsert).not.toHaveBeenCalled()
})
test('checks brand center access before reading or writing', async () => {
const AccessError = jest.requireMock('@/lib/brand-center-dashboard-access').BrandCenterDashboardAccessError
;(assertBrandCenterDashboardAccess as jest.Mock).mockRejectedValue(new AccessError('无权访问', 403))
const response = await putLike(actionRequest('/like', true), context)
expect(response.status).toBe(403)
expect(prisma.brandKnowledge.findFirst).not.toHaveBeenCalled()
expect(prisma.brandKnowledgeLike.upsert).not.toHaveBeenCalled()
})
test('management requires a database-backed super administrator', async () => {
const body = { title: '新标题', knowledgeType: '影像规范', brand: '索菲亚', productCategoryName: '衣柜' }
;(isSuperAdmin as jest.Mock).mockResolvedValueOnce(false)
const denied = await patchDetail(new Request('http://localhost/detail', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}), context)
expect(denied.status).toBe(403)
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
})
test('super administrators can update metadata and soft delete active knowledge', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
const body = { title: ' 新标题 ', knowledgeType: '影像规范', brand: '索菲亚', productCategoryName: '衣柜' }
const updated = await patchDetail(new Request('http://localhost/detail', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}), context)
expect(updated.status).toBe(200)
expect(prisma.brandKnowledge.updateMany).toHaveBeenCalledWith({
where: { id: 'knowledge-1', deletedAt: null },
data: { title: '新标题', knowledgeType: '影像规范', brand: '索菲亚', productCategoryName: '衣柜' },
})
;(prisma.brandKnowledge.updateMany as jest.Mock).mockClear()
const deleted = await deleteDetail(new Request('http://localhost/detail', { method: 'DELETE' }), context)
expect(deleted.status).toBe(200)
expect(prisma.brandKnowledge.updateMany).toHaveBeenCalledWith({
where: { id: 'knowledge-1', deletedAt: null },
data: { deletedAt: expect.any(Date) },
})
})
test('rejects invalid metadata and treats repeated deletion as missing', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
const invalid = await patchDetail(new Request('http://localhost/detail', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '', knowledgeType: '未知类型', brand: '', productCategoryName: '' }),
}), context)
expect(invalid.status).toBe(400)
expect(prisma.brandKnowledge.updateMany).not.toHaveBeenCalled()
;(prisma.brandKnowledge.updateMany as jest.Mock).mockResolvedValue({ count: 0 })
const deleted = await deleteDetail(new Request('http://localhost/detail', { method: 'DELETE' }), context)
expect(deleted.status).toBe(404)
})
})

View File

@ -0,0 +1,375 @@
import { getServerSession } from 'next-auth'
import { link, rename, unlink, writeFile } from 'fs/promises'
import { prisma } from '@/lib/prisma'
import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview'
import { validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview'
import { isSuperAdmin } from '@/lib/department-permission'
import { resolveOwnedMergedUpload } from '@/lib/upload-sessions'
import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
import { readUploadSessionSeal } from '@/lib/material-upload-session-seal'
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
const releaseRequestSlot = jest.fn()
const releaseUploadSessionLock = Object.assign(jest.fn(), { assertHeld: jest.fn() })
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
constructor(message: string, readonly status = 403) { super(message) }
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { count: jest.fn(), create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() } } }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(async () => false) }))
jest.mock('@/lib/brand-knowledge-preview', () => ({
prepareBrandKnowledgePreview: jest.fn(),
validateBrandKnowledgeUploadedPreviewSlices: jest.fn(),
}))
jest.mock('@/lib/upload-paths', () => ({ getUploadRootDir: () => '/uploads' }))
jest.mock('@/lib/path-security', () => ({
ensureSafeDirectoryWithinRoot: jest.fn(async () => '/uploads/brand-knowledge'),
isPathFreeOfSymbolicLinks: jest.fn(async () => true),
normalizeSafeFileName: (value: string) => value,
resolveStrictChildPath: (_root: string, name: string) => `/uploads/brand-knowledge/${name}`,
}))
jest.mock('@/lib/multipart-request-guard', () => ({
assertBoundedMultipartHeaders: jest.fn(),
assertMultipartFiles: jest.fn(),
}))
jest.mock('@/lib/multipart-route-limits', () => ({
boundedMultipartRequestLimiter: { acquire: jest.fn(async () => releaseRequestSlot) },
getBoundedMultipartFailure: jest.fn(() => null),
}))
jest.mock('@/lib/upload-sessions', () => ({
isSafeUploadId: (value: string) => /^upload-[A-Za-z0-9-]+$/.test(value),
resolveOwnedMergedUpload: jest.fn(),
}))
jest.mock('@/lib/material-upload-cross-process-lock', () => ({
acquireTaskMaterialUploadSessionLocks: jest.fn(),
}))
jest.mock('@/lib/material-upload-session-seal', () => ({ readUploadSessionSeal: jest.fn() }))
jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn() }))
jest.mock('fs/promises', () => ({
link: jest.fn(async () => undefined),
lstat: jest.fn(),
rename: jest.fn(async () => undefined),
unlink: jest.fn(async () => undefined),
writeFile: jest.fn(async () => undefined),
}))
const { TextDecoder, TextEncoder } = require('util') as typeof import('util')
const { clearImmediate, setImmediate } = require('timers') as typeof import('timers')
Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate })
const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis
Object.assign(globalThis, {
Headers: edgePrimitives.Headers,
Request: edgePrimitives.Request,
Response: edgePrimitives.Response,
})
let GET: typeof import('./route').GET
let POST: typeof import('./route').POST
const previewFile = {
name: 'long.png',
size: 8,
type: 'image/png',
arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]).buffer,
}
function uploadRequest() {
const values = new Map<string, unknown>([
['title', '长图规范'],
['knowledgeType', '主图模板'],
['brand', '索菲亚'],
['productCategoryName', '衣柜'],
['uploadId', 'upload-1'],
['existingFilePath', '/uploads/temp/upload-1/merged-knowledge.pdf'],
['originalFileName', 'knowledge.pdf'],
['fileSize', '4'],
['previewImage', previewFile],
])
return {
headers: new Headers(),
formData: async () => ({
get: (name: string) => values.get(name) ?? null,
getAll: (name: string) => {
const value = values.get(name)
return value === undefined ? [] : Array.isArray(value) ? value : [value]
},
}),
} as unknown as Request
}
function slicedUploadRequest() {
const firstSlice = {
name: 'long-001.webp', size: 11, type: 'image/webp',
arrayBuffer: async () => Uint8Array.from(Buffer.from('first-slice')).buffer,
}
const secondSlice = {
name: 'long-002.webp', size: 12, type: 'image/webp',
arrayBuffer: async () => Uint8Array.from(Buffer.from('second-slice')).buffer,
}
const values = new Map<string, unknown>([
['title', '长图规范'],
['knowledgeType', '影像规范'],
['brand', '索菲亚'],
['productCategoryName', '衣柜'],
['uploadId', 'upload-1'],
['existingFilePath', '/uploads/temp/upload-1/merged-knowledge.pdf'],
['originalFileName', 'knowledge.pdf'],
['fileSize', '4'],
['previewImageName', 'long.png'],
['previewImageSliceCount', '2'],
['previewImageSlices', [firstSlice, secondSlice]],
])
return {
headers: new Headers(),
formData: async () => ({
get: (name: string) => {
const value = values.get(name)
return Array.isArray(value) ? value[0] : value ?? null
},
getAll: (name: string) => {
const value = values.get(name)
return value === undefined ? [] : Array.isArray(value) ? value : [value]
},
}),
} as unknown as Request
}
function createdRecord(data: Record<string, unknown>) {
return {
id: 'knowledge-1',
title: data.title,
knowledgeType: data.knowledgeType,
brand: data.brand,
productCategoryName: data.productCategoryName,
fileName: data.fileName,
filePath: data.filePath,
mimeType: data.mimeType,
fileSize: data.fileSize,
downloadCount: 0,
previewImageName: data.previewImageName,
previewImagePath: data.previewImagePath,
previewImageMimeType: data.previewImageMimeType,
previewImageSize: data.previewImageSize,
createdAt: new Date('2026-09-14T00:00:00.000Z'),
uploadedBy: data.uploadedBy,
uploader: { realName: '无邪', username: 'wuxie' },
likes: [],
favorites: [],
_count: { likes: 0 },
}
}
describe('brand knowledge upload route', () => {
beforeAll(async () => { ({ GET, POST } = await import('./route')) })
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(prisma.brandKnowledge.findMany as jest.Mock).mockResolvedValue([])
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(0)
;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLock)
;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue({
uploadId: 'upload-1',
filePath: '/uploads/temp/upload-1/merged-knowledge.pdf',
session: { fileName: 'knowledge.pdf', fileSize: 4, userId: 'user-1', uploadPurpose: 'brand-knowledge' },
})
;(readUploadSessionSeal as jest.Mock).mockResolvedValue({ state: 'completed' })
;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true, status: 'deleted' })
;(prepareBrandKnowledgePreview as jest.Mock).mockResolvedValue({
source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 },
slices: [Buffer.from('first-slice'), Buffer.from('second-slice')],
})
;(validateBrandKnowledgeUploadedPreviewSlices as jest.Mock).mockResolvedValue({
slices: [
{ buffer: Buffer.from('first-slice'), width: 1600, height: 1500 },
{ buffer: Buffer.from('second-slice'), width: 1600, height: 800 },
],
totalBytes: 23,
})
;(prisma.brandKnowledge.create as jest.Mock).mockImplementation(async ({ data }: { data: Record<string, unknown> }) => createdRecord(data))
})
test('list excludes soft-deleted knowledge and returns database-backed management permission', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(23_081)
const response = await GET(new Request('http://localhost/api/department-dashboard/brand-knowledge'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.total).toBe(23_081)
expect(body.permissions).toEqual({ canManage: true })
expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ deletedAt: null }),
}))
expect(prisma.brandKnowledge.count).toHaveBeenCalledWith({
where: expect.objectContaining({ deletedAt: null }),
})
expect(isSuperAdmin).toHaveBeenCalledWith('user-1')
})
test('searches knowledge type as well as title and file metadata', async () => {
await GET(new Request('http://localhost/api/department-dashboard/brand-knowledge?q=%E4%B8%BB%E5%9B%BE%E6%A8%A1%E6%9D%BF'))
expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([
{ knowledgeType: { contains: '主图模板', mode: 'insensitive' } },
]),
}),
}))
expect(prisma.brandKnowledge.count).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([
{ knowledgeType: { contains: '主图模板', mode: 'insensitive' } },
]),
}),
}))
})
test('stores ordered WebP slices and keeps the first slice as the card preview', async () => {
const response = await POST(uploadRequest())
const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0]
expect(response.status).toBe(201)
expect(prepareBrandKnowledgePreview).toHaveBeenCalledWith(expect.any(Buffer), 'image/png')
expect(call.data.previewImageSlices).toHaveLength(2)
expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/)
expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/)
expect(call.data.previewImagePath).toBe(call.data.previewImageSlices[0])
expect(call.data.previewImageMimeType).toBe('image/webp')
expect(call.data.previewImageSize).toBe(BigInt('first-slice'.length))
expect(link).toHaveBeenCalledWith(
'/uploads/temp/upload-1/merged-knowledge.pdf',
'/uploads/brand-knowledge/source-upload-1.pdf',
)
expect(call.data.filePath).toBe('uploads/brand-knowledge/source-upload-1.pdf')
expect(writeFile).toHaveBeenCalledTimes(2)
expect(rename).toHaveBeenCalledTimes(2)
expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
expect(releaseUploadSessionLock).toHaveBeenCalledTimes(1)
expect(releaseRequestSlot).toHaveBeenCalledTimes(1)
})
test('removes the published hard link and preview slices when database persistence fails', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(prisma.brandKnowledge.create as jest.Mock).mockRejectedValue(new Error('database unavailable'))
const response = await POST(uploadRequest())
expect(response.status).toBe(500)
expect(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/source-upload-1.pdf')
expect(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/upload-1-preview-part-001.webp')
expect(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/upload-1-preview-part-002.webp')
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalled()
expect(releaseRequestSlot).toHaveBeenCalledTimes(1)
})
test('accepts ordered browser slices without decoding the original long image on the server', async () => {
const response = await POST(slicedUploadRequest())
const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0]
expect(response.status).toBe(201)
expect(prepareBrandKnowledgePreview).not.toHaveBeenCalled()
expect(validateBrandKnowledgeUploadedPreviewSlices).toHaveBeenCalledWith([
expect.objectContaining({ declaredContentType: 'image/webp' }),
expect.objectContaining({ declaredContentType: 'image/webp' }),
])
expect(call.data.previewImageName).toBe('long.png')
expect(call.data.previewImageSlices).toHaveLength(2)
expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/)
expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/)
})
test('requires a completed upload seal before publishing the main file', async () => {
;(readUploadSessionSeal as jest.Mock).mockResolvedValue({ state: 'sealing' })
const response = await POST(uploadRequest())
const body = await response.json()
expect(response.status).toBe(409)
expect(body.error).toContain('尚未完成分片合并')
expect(link).not.toHaveBeenCalled()
expect(prisma.brandKnowledge.create).not.toHaveBeenCalled()
})
test('rejects a merged upload that does not belong to the current user', async () => {
;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue(null)
const response = await POST(uploadRequest())
expect(response.status).toBe(403)
expect(link).not.toHaveBeenCalled()
expect(prisma.brandKnowledge.create).not.toHaveBeenCalled()
})
test('rejects a merged upload created for a different workflow', async () => {
;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue({
uploadId: 'upload-1',
filePath: '/uploads/temp/upload-1/merged-knowledge.pdf',
session: { fileName: 'knowledge.pdf', fileSize: 4, userId: 'user-1' },
})
const response = await POST(uploadRequest())
const body = await response.json()
expect(response.status).toBe(409)
expect(body.error).toContain('上传用途')
expect(link).not.toHaveBeenCalled()
expect(prisma.brandKnowledge.create).not.toHaveBeenCalled()
})
test('replays the existing knowledge record without consuming the merged file twice', async () => {
const replayed = createdRecord({
title: '长图规范',
knowledgeType: '主图模板',
brand: '索菲亚',
productCategoryName: '衣柜',
fileName: 'knowledge.pdf',
filePath: 'uploads/brand-knowledge/source-upload-1.pdf',
mimeType: 'application/pdf',
fileSize: BigInt(4),
previewImageName: null,
previewImagePath: null,
previewImageMimeType: null,
previewImageSize: null,
uploadedBy: 'user-1',
})
;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(replayed)
const response = await POST(uploadRequest())
const body = await response.json()
expect(response.status).toBe(201)
expect(body.replayed).toBe(true)
expect(resolveOwnedMergedUpload).not.toHaveBeenCalled()
expect(link).not.toHaveBeenCalled()
expect(prisma.brandKnowledge.create).not.toHaveBeenCalled()
expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
})
test('preserves published files when database commit state cannot be inspected', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(prisma.brandKnowledge.create as jest.Mock).mockRejectedValue(new Error('connection reset'))
;(prisma.brandKnowledge.findFirst as jest.Mock)
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('database unavailable'))
const response = await POST(uploadRequest())
const body = await response.json()
expect(response.status).toBe(503)
expect(body.code).toBe('BRAND_KNOWLEDGE_PERSISTENCE_UNKNOWN')
expect(unlink).not.toHaveBeenCalledWith('/uploads/brand-knowledge/source-upload-1.pdf')
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,511 @@
import { randomUUID } from 'crypto'
import { link, lstat, rename, unlink, writeFile } from 'fs/promises'
import path from 'path'
import { lookup as lookupMimeType } from 'mime-types'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { getUploadRootDir } from '@/lib/upload-paths'
import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'
import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits'
import { prepareBrandKnowledgePreview, validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview'
import { isWebFileLike } from '@/lib/web-file'
import { isSafeUploadId, resolveOwnedMergedUpload } from '@/lib/upload-sessions'
import {
acquireTaskMaterialUploadSessionLocks,
type MaterialUploadStorageLockRelease,
} from '@/lib/material-upload-cross-process-lock'
import { readUploadSessionSeal } from '@/lib/material-upload-session-seal'
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
import {
BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS,
BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
BRAND_KNOWLEDGE_MAX_REQUEST_BYTES,
BRAND_KNOWLEDGE_TYPE_OPTIONS,
} from '@/lib/brand-knowledge-file-types'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES
const MAX_REQUEST_SIZE = BRAND_KNOWLEDGE_MAX_REQUEST_BYTES
const MAX_PREVIEW_IMAGE_SIZE = BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES
const KNOWLEDGE_TYPES: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_TYPE_OPTIONS)
// The source file stays independent from Material/MaterialAsset rows.
const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS)
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
class BrandKnowledgePersistenceUnknownError extends Error {
constructor() {
super('品牌知识保存状态暂时无法确认,服务器已保留上传文件,请勿重新上传')
this.name = 'BrandKnowledgePersistenceUnknownError'
}
}
function accessErrorResponse(error: unknown) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
return null
}
function normalizeText(value: FormDataEntryValue | null, maxLength: number) {
return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''
}
function getKnowledgeResponseSelect(userId: string) {
return {
id: true,
title: true,
knowledgeType: true,
brand: true,
productCategoryName: true,
fileName: true,
filePath: true,
mimeType: true,
fileSize: true,
downloadCount: true,
previewImageName: true,
previewImagePath: true,
previewImageMimeType: true,
previewImageSize: true,
createdAt: true,
uploadedBy: true,
uploader: { select: { realName: true, username: true } },
likes: { where: { userId }, select: { id: true } },
favorites: { where: { userId }, select: { id: true } },
_count: { select: { likes: true } },
} as const
}
async function publishMergedUploadByHardLink(input: {
sourcePath: string
destination: string
expectedBytes: number
}) {
try {
await link(input.sourcePath, input.destination)
return
} catch (error: any) {
if (error?.code !== 'EEXIST') throw error
}
const [sourceStats, destinationStats] = await Promise.all([
lstat(input.sourcePath),
lstat(input.destination),
])
if (
sourceStats.isSymbolicLink()
|| destinationStats.isSymbolicLink()
|| !sourceStats.isFile()
|| !destinationStats.isFile()
|| sourceStats.size !== input.expectedBytes
|| destinationStats.size !== input.expectedBytes
|| sourceStats.dev !== destinationStats.dev
|| sourceStats.ino !== destinationStats.ino
) {
throw new Error('品牌知识主文件目标路径已存在且与上传会话不一致')
}
}
function serializeKnowledge(item: {
id: string
title: string
knowledgeType: string
brand: string
productCategoryName: string
fileName: string
mimeType: string
fileSize: bigint
downloadCount: number
previewImageName: string | null
previewImagePath: string | null
previewImageMimeType: string | null
previewImageSize: bigint | null
createdAt: Date
uploader: { realName: string | null; username: string }
likes: { id: string }[]
favorites: { id: string }[]
_count: { likes: number }
}) {
return {
id: item.id,
title: item.title,
knowledgeType: item.knowledgeType,
brand: item.brand,
productCategoryName: item.productCategoryName,
fileName: item.fileName,
mimeType: item.mimeType,
fileSize: Number(item.fileSize),
downloadCount: item.downloadCount,
createdAt: item.createdAt.toISOString(),
uploadedBy: item.uploader.realName || item.uploader.username,
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null,
previewImageName: item.previewImageName,
likeCount: item._count.likes,
isLiked: item.likes.length > 0,
isFavorited: item.favorites.length > 0,
}
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
const params = new URL(request.url).searchParams
const type = params.get('type')?.trim() || ''
const query = params.get('q')?.trim() || ''
const [items, total, canManage] = await Promise.all([
prisma.brandKnowledge.findMany({
where: {
deletedAt: null,
...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}),
...(query ? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ knowledgeType: { contains: query, mode: 'insensitive' } },
{ fileName: { contains: query, mode: 'insensitive' } },
{ brand: { contains: query, mode: 'insensitive' } },
{ productCategoryName: { contains: query, mode: 'insensitive' } },
],
} : {}),
},
orderBy: { createdAt: 'desc' },
take: 200,
select: {
id: true,
title: true,
knowledgeType: true,
brand: true,
productCategoryName: true,
fileName: true,
mimeType: true,
fileSize: true,
downloadCount: true,
previewImageName: true,
previewImagePath: true,
previewImageMimeType: true,
previewImageSize: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
likes: { where: { userId }, select: { id: true } },
favorites: { where: { userId }, select: { id: true } },
_count: { select: { likes: true } },
},
}),
prisma.brandKnowledge.count({
where: {
deletedAt: null,
...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}),
...(query ? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ knowledgeType: { contains: query, mode: 'insensitive' } },
{ fileName: { contains: query, mode: 'insensitive' } },
{ brand: { contains: query, mode: 'insensitive' } },
{ productCategoryName: { contains: query, mode: 'insensitive' } },
],
} : {}),
},
}),
isSuperAdmin(userId),
])
return NextResponse.json(
{ success: true, data: items.map(serializeKnowledge), total, permissions: { canManage } },
{ headers: PRIVATE_HEADERS },
)
} catch (error) {
const response = accessErrorResponse(error)
if (response) return response
console.error('[department-dashboard] 品牌知识列表读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识列表读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}
export async function POST(request: Request) {
let uploadId = ''
let publishedMainPath: string | null = null
const storedPreviewPaths: string[] = []
const temporaryPreviewPaths: string[] = []
let releaseRequestSlot: (() => void) | null = null
let releaseUploadSessionLock: MaterialUploadStorageLockRelease | null = null
const persistence: {
outcome: 'not_attempted' | 'not_committed' | 'committed' | 'unknown'
} = { outcome: 'not_attempted' }
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const userId = session?.user?.id
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
assertBoundedMultipartHeaders(request.headers, MAX_REQUEST_SIZE)
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
assertMultipartFiles(formData, {
fields: {
previewImage: {
maxCount: 1,
maxFileBytes: MAX_PREVIEW_IMAGE_SIZE,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
previewImageSlices: {
maxCount: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
maxFileBytes: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
maxTotalBytes: MAX_PREVIEW_IMAGE_SIZE,
allowedMimeTypes: ['image/webp'],
},
},
maxFiles: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
maxTotalBytes: MAX_PREVIEW_IMAGE_SIZE,
})
const previewImage = formData.get('previewImage')
const uploadedPreviewSlices = formData.getAll('previewImageSlices')
if (previewImage !== null && !isWebFileLike(previewImage)) {
return NextResponse.json({ success: false, error: '预览图文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (isWebFileLike(previewImage) && uploadedPreviewSlices.length > 0) {
return NextResponse.json({ success: false, error: '预览图不能同时提交原图和切片' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (uploadedPreviewSlices.some((slice) => !isWebFileLike(slice))) {
return NextResponse.json({ success: false, error: '预览图切片文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
const title = normalizeText(formData.get('title'), 200)
const knowledgeType = normalizeText(formData.get('knowledgeType'), 30)
const brand = normalizeText(formData.get('brand'), 40)
const productCategoryName = normalizeText(formData.get('productCategoryName'), 80)
uploadId = normalizeText(formData.get('uploadId'), 128)
const existingFilePath = normalizeText(formData.get('existingFilePath'), 4096)
const safeOriginalName = normalizeSafeFileName(normalizeText(formData.get('originalFileName'), 255))
const declaredFileSize = Number(normalizeText(formData.get('fileSize'), 20))
if (!title) return NextResponse.json({ success: false, error: '请填写知识标题' }, { status: 400, headers: PRIVATE_HEADERS })
if (!KNOWLEDGE_TYPES.has(knowledgeType)) return NextResponse.json({ success: false, error: '知识类型不合法' }, { status: 400, headers: PRIVATE_HEADERS })
if (!isSafeUploadId(uploadId) || !existingFilePath) {
return NextResponse.json({ success: false, error: '请先完成知识主文件的分片上传' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS })
if (!Number.isSafeInteger(declaredFileSize) || declaredFileSize <= 0 || declaredFileSize > MAX_FILE_SIZE) {
return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
}
const extension = path.extname(safeOriginalName).toLowerCase()
if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS })
releaseUploadSessionLock = await acquireTaskMaterialUploadSessionLocks([uploadId])
releaseUploadSessionLock.assertHeld()
const uploadRoot = getUploadRootDir()
const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge')
if (!knowledgeDirectory) throw new Error('品牌知识存储目录不可用')
const storageName = `source-${uploadId}${extension}`
const destination = resolveStrictChildPath(knowledgeDirectory, storageName)
if (!destination) throw new Error('品牌知识文件路径不可用')
if (!(await isPathFreeOfSymbolicLinks(uploadRoot, destination, { allowMissingLeaf: true }))) throw new Error('品牌知识存储目录不安全')
const storedPath = `uploads/brand-knowledge/${storageName}`
const responseSelect = getKnowledgeResponseSelect(userId)
const replayed = await prisma.brandKnowledge.findFirst({
where: { filePath: storedPath },
select: responseSelect,
})
if (replayed) {
if (
replayed.uploadedBy !== userId
|| replayed.fileName !== safeOriginalName
|| replayed.fileSize !== BigInt(declaredFileSize)
) {
return NextResponse.json(
{ success: false, error: '该上传会话已被其它品牌知识记录使用' },
{ status: 409, headers: PRIVATE_HEADERS },
)
}
await cleanupConsumedUploadSession(uploadId).catch((error) => {
console.warn('[department-dashboard] 品牌知识重放请求清理上传会话失败:', { uploadId, error })
})
return NextResponse.json(
{ success: true, data: serializeKnowledge(replayed), replayed: true },
{ status: 201, headers: PRIVATE_HEADERS },
)
}
const ownedMergedUpload = await resolveOwnedMergedUpload({
existingFilePath,
uploadId,
userId,
})
if (!ownedMergedUpload) {
return NextResponse.json(
{ success: false, error: '上传会话不存在、已失效或不属于当前用户' },
{ status: 403, headers: PRIVATE_HEADERS },
)
}
const seal = await readUploadSessionSeal(uploadId)
if (seal?.state !== 'completed') {
return NextResponse.json(
{ success: false, error: '知识主文件尚未完成分片合并' },
{ status: 409, headers: PRIVATE_HEADERS },
)
}
if (
normalizeSafeFileName(ownedMergedUpload.session.fileName) !== safeOriginalName
|| ownedMergedUpload.session.fileSize !== declaredFileSize
|| ownedMergedUpload.session.uploadPurpose !== 'brand-knowledge'
) {
return NextResponse.json(
{ success: false, error: '知识主文件信息或上传用途与会话不一致' },
{ status: 409, headers: PRIVATE_HEADERS },
)
}
let preparedPreview: Awaited<ReturnType<typeof prepareBrandKnowledgePreview>> | null = null
let preparedUploadedSlices: Awaited<ReturnType<typeof validateBrandKnowledgeUploadedPreviewSlices>> | null = null
let previewImageName: string | null = null
if (isWebFileLike(previewImage)) {
if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) {
return NextResponse.json({ success: false, error: '预览图需为 1B 至 50MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS })
}
try {
preparedPreview = await prepareBrandKnowledgePreview(Buffer.from(await previewImage.arrayBuffer()), previewImage.type)
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' },
{ status: 400, headers: PRIVATE_HEADERS },
)
}
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${preparedPreview.source.extension}`
} else if (uploadedPreviewSlices.length > 0) {
const declaredSliceCount = Number(normalizeText(formData.get('previewImageSliceCount'), 4))
if (!Number.isSafeInteger(declaredSliceCount) || declaredSliceCount !== uploadedPreviewSlices.length) {
return NextResponse.json({ success: false, error: '预览图切片数量不一致,请重新选择图片' }, { status: 400, headers: PRIVATE_HEADERS })
}
try {
preparedUploadedSlices = await validateBrandKnowledgeUploadedPreviewSlices(
await Promise.all(uploadedPreviewSlices.map(async (slice) => {
const fileSlice = slice as File
return { buffer: Buffer.from(await fileSlice.arrayBuffer()), declaredContentType: fileSlice.type }
})),
)
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '预览图切片无效' },
{ status: 400, headers: PRIVATE_HEADERS },
)
}
previewImageName = normalizeSafeFileName(normalizeText(formData.get('previewImageName'), 255)) || 'preview.webp'
}
await publishMergedUploadByHardLink({
sourcePath: ownedMergedUpload.filePath,
destination,
expectedBytes: declaredFileSize,
})
publishedMainPath = destination
const previewSlices = preparedUploadedSlices
? preparedUploadedSlices.slices.map((slice) => slice.buffer)
: preparedPreview?.slices || []
if (previewSlices.length > 0) {
for (let index = 0; index < previewSlices.length; index += 1) {
const previewStorageName = `${uploadId}-preview-part-${String(index + 1).padStart(3, '0')}.webp`
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
throw new Error('品牌知识预览图路径不可用')
}
const temporaryPreviewPath = `${previewDestination}.uploading-${randomUUID()}`
temporaryPreviewPaths.push(temporaryPreviewPath)
await writeFile(temporaryPreviewPath, previewSlices[index], { flag: 'wx' })
await rename(temporaryPreviewPath, previewDestination)
temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1)
storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`)
}
}
let created
persistence.outcome = 'not_committed'
try {
created = await prisma.brandKnowledge.create({
data: {
title,
knowledgeType,
brand,
productCategoryName,
fileName: safeOriginalName,
filePath: storedPath,
mimeType: String(lookupMimeType(safeOriginalName) || 'application/octet-stream'),
fileSize: BigInt(declaredFileSize),
previewImageName,
previewImagePath: storedPreviewPaths[0] || null,
previewImageSlices: [...storedPreviewPaths],
previewImageMimeType: previewSlices.length > 0 ? 'image/webp' : null,
previewImageSize: previewSlices.length > 0 ? BigInt(previewSlices[0].length) : null,
uploadedBy: userId,
},
select: responseSelect,
})
persistence.outcome = 'committed'
} catch (persistenceError) {
let confirmed
try {
confirmed = await prisma.brandKnowledge.findFirst({
where: { filePath: storedPath, uploadedBy: userId },
select: responseSelect,
})
} catch (inspectionError) {
persistence.outcome = 'unknown'
console.error('[department-dashboard] 品牌知识保存异常后无法确认数据库状态:', {
uploadId,
persistenceError,
inspectionError,
})
throw new BrandKnowledgePersistenceUnknownError()
}
if (!confirmed) throw persistenceError
created = confirmed
persistence.outcome = 'committed'
}
publishedMainPath = null
storedPreviewPaths.length = 0
await cleanupConsumedUploadSession(uploadId).catch((error) => {
console.warn('[department-dashboard] 品牌知识已保存但上传会话清理失败:', { uploadId, error })
})
return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS })
} catch (error) {
if (persistence.outcome !== 'committed' && persistence.outcome !== 'unknown') {
await Promise.all(temporaryPreviewPaths.map((previewPath) => unlink(previewPath).catch(() => undefined)))
if (publishedMainPath) await unlink(publishedMainPath).catch(() => undefined)
await Promise.all(storedPreviewPaths.map(async (previewPath) => {
const absolute = path.join(getUploadRootDir(), previewPath.replace(/^uploads\//, ''))
await unlink(absolute).catch(() => undefined)
}))
}
const response = accessErrorResponse(error)
if (response) return response
if (error instanceof BrandKnowledgePersistenceUnknownError) {
return NextResponse.json(
{ success: false, error: error.message, code: 'BRAND_KNOWLEDGE_PERSISTENCE_UNKNOWN' },
{ status: 503, headers: PRIVATE_HEADERS },
)
}
const boundedFailure = getBoundedMultipartFailure(error)
if (boundedFailure) {
return NextResponse.json(
{ success: false, error: boundedFailure.message, code: boundedFailure.code },
{ status: boundedFailure.status, headers: { ...PRIVATE_HEADERS, ...(boundedFailure.retryAfterSeconds ? { 'Retry-After': String(boundedFailure.retryAfterSeconds) } : {}) } },
)
}
console.error('[department-dashboard] 品牌知识上传失败:', error)
return NextResponse.json({ success: false, error: '品牌知识上传失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
} finally {
await releaseUploadSessionLock?.()
releaseRequestSlot?.()
}
}

View File

@ -0,0 +1,36 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { getBusinessTrendsInsights, DingWorkflowMetricApiError } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return { ...actual, getBusinessTrendsInsights: jest.fn() }
})
const { GET } = require('@/app/api/department-dashboard/business-trends/route') as typeof import('@/app/api/department-dashboard/business-trends/route')
describe('GET /api/department-dashboard/business-trends', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getBusinessTrendsInsights as jest.Mock).mockResolvedValue({ brands: [], summary: { syncStatus: 'ready' } })
})
it('returns aggregated insights', async () => {
const response = await GET()
expect(response.status).toBe(200)
expect(getBusinessTrendsInsights).toHaveBeenCalledWith({ user: { id: 'manager-1' } })
await expect(response.json()).resolves.toEqual({ success: true, data: { brands: [], summary: { syncStatus: 'ready' } } })
expect(response.headers.get('cache-control')).toContain('no-store')
})
it('maps metric errors to their status', async () => {
;(getBusinessTrendsInsights as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403))
const response = await GET()
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ success: false, error: '无权查看品牌中心流程指标' })
})
})

View File

@ -0,0 +1,30 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { DingWorkflowMetricApiError, getBusinessTrendsInsights } from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getBusinessTrendsInsights(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 业务趋势读取失败:', error)
return NextResponse.json(
{ success: false, error: '业务趋势读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,128 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { getFirstPassReviewProjects, WorkReviewApiError } from '@/lib/work-review-service'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
status = 403
},
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/work-review-service', () => {
const actual = jest.requireActual('@/lib/work-review-service')
return {
...actual,
getFirstPassReviewProjects: jest.fn(),
}
})
const { GET } = require('@/app/api/department-dashboard/core-metrics/first-pass/projects/route') as typeof import('@/app/api/department-dashboard/core-metrics/first-pass/projects/route')
describe('GET /api/department-dashboard/core-metrics/first-pass/projects', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
})
test('按分页和搜索参数读取 V1 首轮作品明细', async () => {
const data = {
summary: {
totalUploads: 2,
totalApproved: 2,
firstPassApproved: 1,
rate: 50,
statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 0, unknown: 0 },
readAt: '2026-08-31T02:00:00.000Z',
},
items: [{
id: 'project-1',
title: '真实作品',
currentStatus: 'approved',
latestVersion: 1,
submitterName: '提交人',
managerName: '主管',
finalReviewerName: '元泽',
workDate: '2026-08-30T00:00:00.000Z',
updatedAt: '2026-08-31T01:00:00.000Z',
firstVersionStatus: 'approved',
firstVersionSubmittedAt: '2026-08-30T01:00:00.000Z',
firstVersionCompletedAt: '2026-08-30T02:00:00.000Z',
firstVersionReviews: [{
reviewerRole: 'manager',
reviewerName: '主管',
decision: 'approved',
reviewedAt: '2026-08-30T01:30:00.000Z',
}],
}],
pagination: { page: 2, pageSize: 30, total: 31, totalPages: 2 },
}
;(getFirstPassReviewProjects as jest.Mock).mockResolvedValue(data)
const response = await GET(new Request(
'https://sc.jiansun.vip/api/department-dashboard/core-metrics/first-pass/projects?page=2&pageSize=30&q=%E7%9C%9F%E5%AE%9E',
))
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
expect(body).toEqual({ success: true, data })
expect(getFirstPassReviewProjects).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ page: 2, pageSize: 30, query: '真实' },
)
})
test('限制异常分页参数并兼容 query 参数名', async () => {
;(getFirstPassReviewProjects as jest.Mock).mockResolvedValue({
summary: {}, items: [], pagination: { page: 1, pageSize: 50, total: 0, totalPages: 1 },
})
const response = await GET(new Request(
'https://sc.jiansun.vip/api/department-dashboard/core-metrics/first-pass/projects?page=0&pageSize=999&query=%20负责人%20',
))
expect(response.status).toBe(200)
expect(getFirstPassReviewProjects).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ page: 1, pageSize: 50, query: '负责人' },
)
})
test('将权限和登录错误按后端状态返回', async () => {
;(getFirstPassReviewProjects as jest.Mock).mockRejectedValue(new WorkReviewApiError('请先登录', 401))
const response = await GET(new Request(
'https://sc.jiansun.vip/api/department-dashboard/core-metrics/first-pass/projects',
))
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({ success: false, error: '请先登录' })
})
test('隐藏未知异常细节', async () => {
;(getFirstPassReviewProjects as jest.Mock).mockRejectedValue(new Error('database password leaked'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const response = await GET(new Request(
'https://sc.jiansun.vip/api/department-dashboard/core-metrics/first-pass/projects',
))
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({
success: false,
error: '首轮通过明细读取失败,请稍后重试',
})
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})

View File

@ -0,0 +1,57 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
assertBrandCenterDashboardAccess,
BrandCenterDashboardAccessError,
} from '@/lib/brand-center-dashboard-access'
import { PerformanceSurveyApiError } from '@/lib/performance-survey-service'
import {
getFirstPassReviewProjects,
WorkReviewApiError,
} from '@/lib/work-review-service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
function parsePositiveInteger(value: string | null, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
/**
* 首轮通过明细只返回作品元数据、V1 审核状态和轻量评审摘要;图片组和批注仍由作品过稿
* 详情接口按单个项目权限读取,避免看板列表把大 JSON 直接暴露给浏览器。
*/
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const url = new URL(request.url)
const page = parsePositiveInteger(url.searchParams.get('page'), 1)
const pageSize = Math.min(parsePositiveInteger(url.searchParams.get('pageSize'), 20), 50)
const query = (url.searchParams.get('q') || url.searchParams.get('query') || '')
.trim()
.slice(0, 100)
const data = await getFirstPassReviewProjects(session, { page, pageSize, query })
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError || error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 首轮通过明细读取失败:', error)
return NextResponse.json(
{ success: false, error: '首轮通过明细读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,42 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
assertBrandCenterDashboardAccess,
BrandCenterDashboardAccessError,
} from '@/lib/brand-center-dashboard-access'
import { PerformanceSurveyApiError } from '@/lib/performance-survey-service'
import {
WorkReviewApiError,
getFirstPassReviewSummary,
} from '@/lib/work-review-service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const data = await getFirstPassReviewSummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError || error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 首轮通过指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '首轮通过指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,54 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { getMonthlyLaunchSummary, DingWorkflowMetricApiError } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return {
...actual,
getMonthlyLaunchSummary: jest.fn(),
}
})
const { GET } = require('@/app/api/department-dashboard/core-metrics/monthly-launches/route') as typeof import('@/app/api/department-dashboard/core-metrics/monthly-launches/route')
describe('GET /api/department-dashboard/core-metrics/monthly-launches', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getMonthlyLaunchSummary as jest.Mock).mockResolvedValue({ completed: 2, ongoing: 1, total: 3 })
})
it('passes month filter to the service', async () => {
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/monthly-launches?month=2026-08'))
expect(response.status).toBe(200)
expect(getMonthlyLaunchSummary).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ month: '2026-08', startDate: null, endDate: null },
)
})
it('accepts inclusive custom date range aliases', async () => {
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/monthly-launches?start=2026-08-01&end=2026-08-31'))
expect(response.status).toBe(200)
expect(getMonthlyLaunchSummary).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ month: null, startDate: '2026-08-01', endDate: '2026-08-31' },
)
})
it('returns service errors with their status', async () => {
;(getMonthlyLaunchSummary as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('month格式应为YYYY-MM', 400))
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/monthly-launches?month=2026/08'))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({ success: false, error: 'month格式应为YYYY-MM' })
})
})

View File

@ -0,0 +1,40 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getMonthlyLaunchSummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
const params = new URL(request.url).searchParams
const month = params.get('month')
const startDate = params.get('startDate') || params.get('start')
const endDate = params.get('endDate') || params.get('end')
const data = await getMonthlyLaunchSummary(session, { month, startDate, endDate })
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 本月上新指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '本月上新指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,53 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { DingWorkflowMetricApiError, getOnTimeDeliverySummary } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return {
...actual,
getOnTimeDeliverySummary: jest.fn(),
}
})
const { GET } = require('@/app/api/department-dashboard/core-metrics/on-time-delivery/route') as typeof import('@/app/api/department-dashboard/core-metrics/on-time-delivery/route')
describe('GET /api/department-dashboard/core-metrics/on-time-delivery', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getOnTimeDeliverySummary as jest.Mock).mockResolvedValue({ onTime: 2, overtime: 1, pending: 0, total: 3 })
})
it('passes month filter to the service', async () => {
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/on-time-delivery?month=2026-08'))
expect(response.status).toBe(200)
expect(getOnTimeDeliverySummary).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ month: '2026-08', startDate: null, endDate: null },
)
})
it('accepts inclusive custom date range aliases', async () => {
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/on-time-delivery?start=2026-08-01&end=2026-08-31'))
expect(response.status).toBe(200)
expect(getOnTimeDeliverySummary).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ month: null, startDate: '2026-08-01', endDate: '2026-08-31' },
)
})
it('returns service errors with their status', async () => {
;(getOnTimeDeliverySummary as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('month格式应为YYYY-MM', 400))
const response = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/on-time-delivery?month=2026/08'))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({ success: false, error: 'month格式应为YYYY-MM' })
})
})

View File

@ -0,0 +1,40 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getOnTimeDeliverySummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions)
const params = new URL(request.url).searchParams
const month = params.get('month')
const startDate = params.get('startDate') || params.get('start')
const endDate = params.get('endDate') || params.get('end')
const data = await getOnTimeDeliverySummary(session, { month, startDate, endDate })
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 按时交付指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '按时交付指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getOngoingDingWorkflowSummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getOngoingDingWorkflowSummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 进行中流程指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '进行中流程指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getTeamWorkloadInsights,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getTeamWorkloadInsights(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 团队负荷明细读取失败:', error)
return NextResponse.json(
{ success: false, error: '团队负荷明细读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,49 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { DingWorkflowMetricApiError, getTeamWorkloadSummary } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('mssql', () => ({ __esModule: true, default: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return { ...actual, getTeamWorkloadSummary: jest.fn() }
})
const { GET } = require('@/app/api/department-dashboard/core-metrics/team-workload/route') as typeof import('@/app/api/department-dashboard/core-metrics/team-workload/route')
describe('GET /api/department-dashboard/core-metrics/team-workload', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
})
it('返回团队负荷摘要并禁止缓存', async () => {
const data = { averageLoadPercent: 30.7, personnelCount: 44, busyPersonnel: 17 }
;(getTeamWorkloadSummary as jest.Mock).mockResolvedValue(data)
const response = await GET()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
await expect(response.json()).resolves.toEqual({ success: true, data })
})
it('按业务权限错误返回对应状态', async () => {
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403))
const response = await GET()
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ success: false, error: '无权查看品牌中心流程指标' })
})
it('隐藏未知异常细节', async () => {
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new Error('database password leaked'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const response = await GET()
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ success: false, error: '团队负荷指标读取失败,请稍后重试' })
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getTeamWorkloadSummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getTeamWorkloadSummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 团队负荷指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '团队负荷指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,50 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { getPerformanceConversionTimeseries, PerformanceConversionError } from '@/lib/performance-conversion'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/performance-conversion', () => {
class MockPerformanceConversionError extends Error {
status: number
constructor(message: string, status = 400) { super(message); this.status = status }
}
return {
getPerformanceConversionTimeseries: jest.fn(),
PerformanceConversionError: MockPerformanceConversionError,
}
})
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
status: number
constructor(message: string, status = 403) { super(message); this.status = status }
},
}))
const { GET } = require('./route') as typeof import('./route')
describe('GET /api/department-dashboard/performance-conversion/timeseries', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getPerformanceConversionTimeseries as jest.Mock).mockResolvedValue({ daily: [] })
})
it('passes the workflow and product id pair to the service', async () => {
const response = await GET(new NextRequest('http://localhost/api/department-dashboard/performance-conversion/timeseries?workflowId=flow-1&productId=1077434823611'))
expect(response.status).toBe(200)
expect(getPerformanceConversionTimeseries).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{ workflowId: 'flow-1', productId: '1077434823611' },
)
})
it('preserves authorization-style service status codes', async () => {
;(getPerformanceConversionTimeseries as jest.Mock).mockRejectedValue(new PerformanceConversionError('商品 ID 不属于该流程', 403))
const response = await GET(new NextRequest('http://localhost/api/department-dashboard/performance-conversion/timeseries?workflowId=flow-1&productId=999999'))
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ success: false, error: '商品 ID 不属于该流程' })
})
})

View File

@ -0,0 +1,38 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import {
getPerformanceConversionTimeseries,
PerformanceConversionError,
} from '@/lib/performance-conversion'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const params = request.nextUrl.searchParams
const data = await getPerformanceConversionTimeseries(session, {
workflowId: params.get('workflowId'),
productId: params.get('productId'),
})
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof PerformanceConversionError || error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[performance-conversion] 日指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '商品经营数据读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,58 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { getPerformanceConversionWorkflows, PerformanceConversionError } from '@/lib/performance-conversion'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/performance-conversion', () => {
class MockPerformanceConversionError extends Error {
status: number
constructor(message: string, status = 400) { super(message); this.status = status }
}
return {
getPerformanceConversionWorkflows: jest.fn(),
PerformanceConversionError: MockPerformanceConversionError,
}
})
jest.mock('@/lib/brand-center-dashboard-access', () => ({
BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error {
status: number
constructor(message: string, status = 403) { super(message); this.status = status }
},
}))
const { GET } = require('./route') as typeof import('./route')
describe('GET /api/department-dashboard/performance-conversion/workflows', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getPerformanceConversionWorkflows as jest.Mock).mockResolvedValue({ items: [], total: 0 })
})
it('passes bounded list inputs and returns private data', async () => {
const response = await GET(new NextRequest('http://localhost/api/department-dashboard/performance-conversion/workflows?brand=%E7%B4%A2%E8%8F%B2%E4%BA%9A&page=2&pageSize=20&q=1077&completedFrom=2026-07-01&completedTo=2026-07-31'))
expect(response.status).toBe(200)
expect(getPerformanceConversionWorkflows).toHaveBeenCalledWith(
{ user: { id: 'manager-1' } },
{
brand: '索菲亚',
page: 2,
pageSize: 20,
query: '1077',
completedFrom: '2026-07-01',
completedTo: '2026-07-31',
},
)
expect(response.headers.get('cache-control')).toContain('no-store')
})
it('maps service errors without leaking internals', async () => {
;(getPerformanceConversionWorkflows as jest.Mock).mockRejectedValue(new PerformanceConversionError('品牌参数无效', 400))
const response = await GET(new NextRequest('http://localhost/api/department-dashboard/performance-conversion/workflows?brand=bad'))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({ success: false, error: '品牌参数无效' })
})
})

View File

@ -0,0 +1,42 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import {
getPerformanceConversionWorkflows,
PerformanceConversionError,
} from '@/lib/performance-conversion'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const params = request.nextUrl.searchParams
const data = await getPerformanceConversionWorkflows(session, {
brand: params.get('brand'),
page: Number(params.get('page') || 1),
pageSize: Number(params.get('pageSize') || 20),
query: params.get('q'),
completedFrom: params.get('completedFrom'),
completedTo: params.get('completedTo'),
})
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof PerformanceConversionError || error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[performance-conversion] 流程列表读取失败:', error)
return NextResponse.json(
{ success: false, error: '业绩转化流程读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,46 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { DingWorkflowMetricApiError, getTeamCapacityInsights } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return { ...actual, getTeamCapacityInsights: jest.fn() }
})
const { GET } = require('@/app/api/department-dashboard/team-capacity/route') as typeof import('@/app/api/department-dashboard/team-capacity/route')
describe('GET /api/department-dashboard/team-capacity', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
;(getTeamCapacityInsights as jest.Mock).mockResolvedValue({ departments: [], summary: { weekStart: '2026-07-13T00:00:00.000Z', weekEnd: '2026-09-07T00:00:00.000Z', departmentCount: 0, syncStatus: 'ready', lastSyncedAt: null } })
})
it('returns capacity insights and disables caching', async () => {
const response = await GET()
expect(response.status).toBe(200)
expect(getTeamCapacityInsights).toHaveBeenCalledWith({ user: { id: 'manager-1' } })
await expect(response.json()).resolves.toEqual({ success: true, data: { departments: [], summary: { weekStart: '2026-07-13T00:00:00.000Z', weekEnd: '2026-09-07T00:00:00.000Z', departmentCount: 0, syncStatus: 'ready', lastSyncedAt: null } } })
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
})
it('maps metric errors to their status', async () => {
;(getTeamCapacityInsights as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403))
const response = await GET()
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ success: false, error: '无权查看品牌中心流程指标' })
})
it('hides unexpected error details', async () => {
;(getTeamCapacityInsights as jest.Mock).mockRejectedValue(new Error('database password leaked'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const response = await GET()
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ success: false, error: '团队产能读取失败,请稍后重试' })
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})

View File

@ -0,0 +1,30 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { DingWorkflowMetricApiError, getTeamCapacityInsights } from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getTeamCapacityInsights(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 团队产能读取失败:', error)
return NextResponse.json(
{ success: false, error: '团队产能读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { desktopAssert, desktopPortal, DesktopClientError, DESKTOP_CLIENT_ID } from '@/lib/desktop-client/contract.mjs'
import {isAllowedCorsOrigin} from '@/lib/cors-origin'
import { approveDesktop, desktopUser, exchangeDesktop, getDesktopAsset, getDesktopProject, listDesktopDevices, listDesktopProjects, putDesktopAsset, putDesktopProject, requireDesktop, revokeDesktop } from '@/lib/desktop-client/service'
export const runtime='nodejs'
export const dynamic='force-dynamic'
const headers={'Cache-Control':'private, no-store','X-Content-Type-Options':'nosniff'}
async function body(request:Request,max=1600000){
desktopAssert(Number(request.headers.get('content-length')||0)<=max,'请求内容过大',413)
const reader=request.body?.getReader();const chunks:Uint8Array[]=[];let size=0
if(reader)while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>max){await reader.cancel();throw new DesktopClientError('请求内容过大',413)}chunks.push(part.value)}
return Buffer.concat(chunks)
}
async function browserOwner(request:Request){
const origin=request.headers.get('origin')
desktopAssert(origin&&isAllowedCorsOrigin(request,origin),'请从素材库授权页面操作',403,'invalid_origin')
desktopPortal(origin)
const session=await getServerSession(authOptions);return desktopUser((session?.user as {id?:string})?.id||'')
}
async function route(request:Request,{params}:{params:{path:string[]}}){
try{
const parts=params.path,url=new URL(request.url),method=request.method
let value:unknown
if(parts.length===1&&parts[0]==='config'&&method==='GET')return NextResponse.json({clientId:DESKTOP_CLIENT_ID,enabled:process.env.DETAIL_GENERATOR_ENABLED==='true',pkce:'S256'},{headers})
if(parts.length===1&&parts[0]==='authorize'&&method==='POST'){const user=await browserOwner(request);value=await approveDesktop(user.id,JSON.parse((await body(request,5000)).toString()))}
else if(parts.length===1&&parts[0]==='token'&&method==='POST')value=await exchangeDesktop(JSON.parse((await body(request,5000)).toString()))
else if(parts[0]==='devices'){
const session=await getServerSession(authOptions);const user=await desktopUser((session?.user as {id?:string})?.id||'')
if(method==='GET'&&parts.length===1)value={devices:await listDesktopDevices(user.id)}
else if(method==='DELETE'&&parts.length===2){await browserOwner(request);await revokeDesktop(parts[1],user.id);value={revoked:true}}
else throw new DesktopClientError('接口不存在',404)
}else{
const auth=await requireDesktop(request),ownerId=auth.account.id
if(parts[0]==='session'&&parts.length===1){if(method==='GET')value={account:auth.account,expiresAt:auth.expiresAt};else if(method==='DELETE'){await revokeDesktop(auth.id,ownerId);value={revoked:true}}else throw new DesktopClientError('接口不存在',404)}
else if(parts[0]==='models'&&method==='GET'&&parts.length===1){const {getAiModelRuntimeState}=await import('@/lib/ai-model-service');value=await getAiModelRuntimeState()}
else if(parts[0]==='agent-models'&&parts.length===1&&method==='GET'){
const {getAiModelRuntimeState,resolveAiModel}=await import('@/lib/ai-model-service');const config=await getAiModelRuntimeState();const models=[]
for(const option of config.modelOptions.copy){const resolved=await resolveAiModel('copy',option.value);const base=new URL(resolved.binding.baseUrl);if(base.hostname==='api.vip98k.com'&&base.protocol==='https:'&&['openai-chat','anthropic-messages'].includes(resolved.protocol.id))models.push({key:option.value,id:resolved.binding.modelId,label:option.label,protocol:resolved.protocol.id,path:resolved.protocol.path})}
value={models}
}
else if(parts[0]==='projects'){
if(method==='GET'&&parts.length===1)value=await listDesktopProjects(ownerId,url.searchParams.get('cursor'))
else if(parts.length===3&&method==='GET')value=await getDesktopProject(ownerId,parts[1],parts[2])
else if(parts.length===3&&parts[1]==='desktop'&&method==='PUT')value=await putDesktopProject(ownerId,parts[2],JSON.parse((await body(request)).toString()))
else if(parts.length===5&&parts[3]==='assets'){
if(method==='PUT'&&parts[1]==='desktop')value=await putDesktopAsset(ownerId,parts[2],parts[4],await body(request,20*1024*1024),request.headers.get('content-type')||'')
else if(method==='GET'){const asset=await getDesktopAsset(ownerId,parts[1],parts[2],parts[4]);return new NextResponse(Uint8Array.from(asset.bytes).buffer,{headers:{...headers,'Content-Type':asset.mime,'Content-Disposition':'attachment'}})}
else throw new DesktopClientError('接口不存在',404)
}else throw new DesktopClientError('接口不存在',404)
}else throw new DesktopClientError('接口不存在',404)
}
return NextResponse.json(value,{headers})
}catch(error){
const known=error instanceof DesktopClientError||Boolean(error&&typeof error==='object'&&'status'in error)
const item=error as {status?:number;code?:string;message?:string}
return NextResponse.json({error:known?item.message:'客户端服务暂不可用,请稍后重试',code:known?item.code||'invalid_request':'service_unavailable'},{status:known?item.status||400:503,headers})
}
}
export {route as GET,route as POST,route as PUT,route as DELETE}

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { findDetailAsset } from '@/lib/detail-generator/repository'
import { readDetailFile } from '@/lib/detail-generator/storage'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { assetId: string } }) {
try {
const ownerId = await requireDetailGeneratorUser()
const asset = await findDetailAsset(assertDetailId(params.assetId), ownerId)
const buffer = await readDetailFile(asset.storageKey)
return new NextResponse(new Uint8Array(buffer), { headers: {
'Content-Type': asset.mimeType, 'Content-Length': String(buffer.length), 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `${asset.mimeType.startsWith('image/') && asset.kind !== 'export' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.name)}`,
} })
} catch (error) { return detailGeneratorErrorResponse(error) }
}

View File

@ -0,0 +1,12 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { listDetailBrandProfiles, saveDetailBrandProfile } from '@/lib/detail-generator/brand-profiles'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export function GET() { return detailGeneratorRoute(async () => ({ profiles: await listDetailBrandProfiles() })) }
export function PUT(request: Request) {
return detailGeneratorRoute(async () => {
const input = await parseDetailJson(request)
return { profile: await saveDetailBrandProfile(input.profile, input.revision) }
})
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from 'next/server'
import { requireDetailGeneratorUser } from '@/lib/detail-generator/access'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() {
let canUse = false
try { await requireDetailGeneratorUser(); canUse = true } catch { canUse = false }
return NextResponse.json({ canUse }, { headers: { 'Cache-Control': 'private, no-store' } })
}

View File

@ -0,0 +1,11 @@
import { DetailGeneratorError, detailGeneratorRoute } from '@/lib/detail-generator/access'
import { recoverDetailTextJob } from '@/lib/detail-generator/recovery'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(request: Request, { params }: { params: { id: string } }) {
return detailGeneratorRoute(async userId => {
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw new DetailGeneratorError('恢复操作须使用 JSON 请求', 415, 'content_type_required')
return recoverDetailTextJob(userId, assertDetailId(params.id), await parseDetailJson(request))
})
}

View File

@ -0,0 +1,6 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { detailPublicJob, findDetailJob } from '@/lib/detail-generator/repository'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: detailPublicJob(await findDetailJob(assertDetailId(params.id), userId)) })) }

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import sharp from 'sharp'
import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { readDetailMaterialImage } from '@/lib/detail-generator/materials'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { materialId: string; source: string; sourceId: string } }) {
try {
const ownerId = await requireDetailGeneratorUser()
const image = await readDetailMaterialImage(ownerId, assertDetailId(params.materialId), params.source, assertDetailId(params.sourceId))
const buffer = await sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' } })
} catch (error) { return detailGeneratorErrorResponse(error) }
}

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { findDetailMaterialMatches } from '@/lib/detail-generator/materials'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: Request) { return detailGeneratorRoute(async userId => { const query = new URL(request.url).searchParams; return { matches: await findDetailMaterialMatches(userId, query.get('identifierType'), query.get('identifier')) } }) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'analyze', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { approveDetailAnalysis } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ project: await approveDetailAnalysis(userId, assertDetailId(params.id), await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { uploadDetailAsset } from '@/lib/detail-generator/service'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(userId => uploadDetailAsset(userId, assertDetailId(params.id), request)) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'export', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'generate', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { importDetailMaterial } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => importDetailMaterial(userId, assertDetailId(params.id), await parseDetailJson(request))) }

View File

@ -0,0 +1,10 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { editDetailProject, getDetailProject } from '@/lib/detail-generator/service'
import { deleteDetailProject } from '@/lib/detail-generator/repository'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
type Context = { params: { id: string } }
export async function GET(_request: Request, { params }: Context) { return detailGeneratorRoute(userId => getDetailProject(userId, assertDetailId(params.id))) }
export async function PATCH(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ project: await editDetailProject(userId, assertDetailId(params.id), await parseDetailJson(request)) })) }
export async function DELETE(_request: Request, { params }: Context) { return detailGeneratorRoute(async userId => { await deleteDetailProject(assertDetailId(params.id), userId); return { deleted: true } }) }

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