Compare commits
327 Commits
codex/mate
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 70369dbdb2 | |||
| 971e17fcd8 | |||
| c51f567539 | |||
| 4f123ea272 | |||
| 88e0a058e2 | |||
| 441878adb9 | |||
| 2c29b9bd78 | |||
| b4c2d30898 | |||
|
|
292ea7e853 | ||
| 5a5f0a52dc | |||
| 493dd16d83 | |||
| 46e8e02779 | |||
| d477231b66 | |||
| eb4836ca81 | |||
| cfa8f0ff9b | |||
| 88ce9e3798 | |||
| 21b6eb767c | |||
| efb8e1af3d | |||
| 4da4ceaf40 | |||
| b924735235 | |||
| b93a7647d4 | |||
| c43823863f | |||
| 924e54dbb2 | |||
| 90b2e6e83c | |||
| 94ea82e7cb | |||
| 9270bbb457 | |||
| e5a20753b6 | |||
| 56c9dc1f2d | |||
| a5c132718e | |||
| c53fa0e292 | |||
| 5b07f5ca54 | |||
| a1ffe3b106 | |||
| c27d920527 | |||
| 477ff64b07 | |||
| de2d7595ce | |||
| df0e765f3e | |||
| aa81054b8e | |||
| cf8ddb029d | |||
| 858e9a690e | |||
| c9f5110074 | |||
| 73c1e98309 | |||
| 24fe0de19d | |||
|
|
4653a56392 | ||
| 846b8c601b | |||
| d2337b960e | |||
| cb510075fc | |||
| 41ecfb6d36 | |||
| 2e7235c704 | |||
| 756f9c9575 | |||
| 0baa951fd9 | |||
| 59e21e1b99 | |||
| 9686efe11e | |||
| 8dc5ffb71b | |||
| 231db310bb | |||
| 4e0701d0d0 | |||
| 45c4e6321e | |||
| bea82d67b0 | |||
| be43f1a6a5 | |||
| d8d843e6ad | |||
| 5891627dec | |||
|
|
ad9c07414a | ||
|
|
3bc36d141e | ||
|
|
b931e9b08e | ||
|
|
1318f94953 | ||
|
|
b53a382715 | ||
|
|
e5da7f0e25 | ||
|
|
3be02ff92e | ||
|
|
88f6c10799 | ||
|
|
d0cd7ab979 | ||
|
|
35a301a45e | ||
|
|
6dbafbd5ed | ||
|
|
305ce3a52b | ||
|
|
83673baf70 | ||
|
|
ab37a99601 | ||
|
|
71e30662e5 | ||
|
|
ea14061331 | ||
|
|
2be20f84c3 | ||
|
|
2db3cf962f | ||
|
|
34f355b538 | ||
|
|
6c263e4999 | ||
|
|
6d05ec2de6 | ||
|
|
f5cb86e875 | ||
|
|
61dade05ed | ||
|
|
9692357954 | ||
|
|
23ae91da4f | ||
|
|
51d80148c3 | ||
|
|
7563cf573e | ||
|
|
754c5e05ed | ||
|
|
f4c2fbf023 | ||
|
|
12f2baf3bc | ||
| bdc7458038 | |||
|
|
99fbce0bcb | ||
|
|
9b3278d559 | ||
|
|
b3fa6b23d8 | ||
|
|
c71185afc5 | ||
| 56210b988e | |||
|
|
00ad0783a3 | ||
|
|
c15444060b | ||
|
|
576b62507f | ||
|
|
8df5b31f8c | ||
|
|
1481f56eb2 | ||
|
|
74ec2f0c58 | ||
|
|
a2e7d197cb | ||
|
|
cda8ba4f78 | ||
|
|
52c3a36b0f | ||
|
|
b33cef8c44 | ||
|
|
fe8c17e316 | ||
|
|
84a11d087e | ||
|
|
78e97e0756 | ||
|
|
e3cb07f15d | ||
|
|
a8c5bc54c1 | ||
|
|
57e95ae770 | ||
|
|
40edf22126 | ||
|
|
c96e790c9b | ||
|
|
db47ab5197 | ||
|
|
6a53dd6922 | ||
|
|
f538270b53 | ||
| bbebe24685 | |||
|
|
1d4231d868 | ||
|
|
f9cd2ca7b9 | ||
| edd21e8290 | |||
| 7eeefea38e | |||
|
|
8a3122b1fd | ||
|
|
cae0e68a04 | ||
|
|
8bea6d8d9a | ||
|
|
5a35321b3a | ||
|
|
b7592fa687 | ||
|
|
87919a3943 | ||
|
|
d2ca6a1bd8 | ||
|
|
69750d32b4 | ||
|
|
ba0b198eb8 | ||
|
|
b14968a9f8 | ||
|
|
a137b20e8c | ||
|
|
2343b06f40 | ||
|
|
9c7508f52f | ||
|
|
723c09cb9a | ||
|
|
15b4501ffa | ||
|
|
b917c09402 | ||
|
|
9f1006c3f8 | ||
|
|
f50b8db719 | ||
|
|
0cf7984ce3 | ||
|
|
6ac36e73af | ||
|
|
92a2326699 | ||
|
|
a645326593 | ||
|
|
d27e86a7b1 | ||
|
|
71b8f30d5b | ||
|
|
378ae08ec4 | ||
|
|
60fa35e87e | ||
|
|
4e5ccece1b | ||
|
|
4e6e2ba0d1 | ||
|
|
93656492ef | ||
|
|
fd2dec176a | ||
|
|
4e58fde4d6 | ||
|
|
5ef8e77eeb | ||
|
|
97bcfcf5c9 | ||
|
|
d9377b550e | ||
|
|
e9902f9d9d | ||
|
|
4c08965cc7 | ||
|
|
a1ada79974 | ||
|
|
beea679525 | ||
|
|
9e8714af73 | ||
|
|
f201661dcf | ||
|
|
6d5d5c871e | ||
|
|
c3cb493a1b | ||
|
|
bcac7e0eab | ||
|
|
d6a41d9283 | ||
|
|
d374a0bba2 | ||
|
|
53b19ccd5a | ||
|
|
09328d776d | ||
|
|
7f2fb05bc9 | ||
|
|
a0211e2890 | ||
|
|
d25cff82ad | ||
|
|
0d27b6384f | ||
|
|
3bd7a4c9d8 | ||
|
|
2b51d220a5 | ||
|
|
ee1500ab64 | ||
|
|
f3095f2a43 | ||
|
|
d8d49a425b | ||
|
|
20d6b06201 | ||
|
|
484781f23c | ||
|
|
8490b3b371 | ||
|
|
8bb0fc5c47 | ||
|
|
ec782a914e | ||
|
|
586d4cd147 | ||
|
|
dcaa11ce35 | ||
|
|
762b3ae69a | ||
|
|
c664ac8723 | ||
|
|
620a0fe8b2 | ||
|
|
c13f5a6e1c | ||
|
|
07bb208048 | ||
|
|
d0d7d64434 | ||
|
|
89506fc2e8 | ||
|
|
2503ad02e0 | ||
|
|
2b615ffc75 | ||
|
|
2957adfacb | ||
|
|
99d7274ad0 | ||
|
|
cefe8b003a | ||
|
|
daa79fdecd | ||
|
|
5559f2074a | ||
|
|
2ef03cc81e | ||
|
|
d9d6110a9c | ||
|
|
cb165771d1 | ||
|
|
bdec270c84 | ||
|
|
0f319f7b68 | ||
|
|
524024f7d6 | ||
|
|
eb10f8246b | ||
|
|
d196033b13 | ||
|
|
d4e9a9bc87 | ||
|
|
887d911b76 | ||
|
|
0fa583ee8f | ||
|
|
c6c4453c02 | ||
|
|
d7604a6674 | ||
|
|
683f86c7af | ||
|
|
da2588051b | ||
|
|
1769c65055 | ||
|
|
315dab3a52 | ||
|
|
466ab61278 | ||
|
|
2a2c713d83 | ||
|
|
83cb88c991 | ||
|
|
433b8087fa | ||
|
|
a98d86aab5 | ||
|
|
c764610e5d | ||
|
|
ce41af74ca | ||
|
|
92ed481314 | ||
|
|
23083c2ef5 | ||
|
|
4efdccdd3c | ||
|
|
ae75bca301 | ||
|
|
e1669b90b6 | ||
|
|
ed7c338537 | ||
|
|
2b5220fdd7 | ||
|
|
d04d4ec34f | ||
|
|
d907e5b4ba | ||
|
|
5ef70bce4e | ||
|
|
39954b1424 | ||
|
|
e864fc3249 | ||
|
|
2bb2434c7e | ||
|
|
91b617bc2e | ||
|
|
32610b5527 | ||
|
|
5f2eb55478 | ||
|
|
8009c62868 | ||
|
|
656d4a2794 | ||
|
|
02df0b7ef4 | ||
|
|
17e23c26ae | ||
|
|
904535d7d9 | ||
|
|
b470d356c6 | ||
|
|
47f3b7e067 | ||
|
|
2c7df1765b | ||
|
|
440b1a7f60 | ||
|
|
5e99c74e79 | ||
|
|
cc6f6578be | ||
|
|
6c6c282203 | ||
|
|
f8db4232ce | ||
|
|
9d23656669 | ||
|
|
b2784ac279 | ||
|
|
f63e04ba02 | ||
|
|
1a2b9b0c52 | ||
|
|
ab2d24b95c | ||
|
|
ec300cf199 | ||
|
|
448bf62ff7 | ||
|
|
ce44459685 | ||
|
|
7b0ee5efb4 | ||
|
|
33f4d0b9b8 | ||
|
|
53b314a056 | ||
|
|
fa14bbc207 | ||
|
|
930b22cd1e | ||
|
|
0fe49db973 | ||
|
|
98068a4209 | ||
|
|
7968ee07c2 | ||
|
|
95825b54ea | ||
|
|
2f4ee50a97 | ||
|
|
0255368562 | ||
|
|
440eee42e1 | ||
|
|
be3b5f9cee | ||
|
|
c4eff7b2bb | ||
|
|
b2aac04c13 | ||
|
|
343b88cb98 | ||
|
|
a39208802e | ||
|
|
fb6187a0b1 | ||
|
|
4a6fa92589 | ||
|
|
020006ceb7 | ||
|
|
7c5f42ca40 | ||
|
|
d4fb3971e9 | ||
|
|
7326e273e1 | ||
|
|
d2afa2bfe7 | ||
|
|
17d45352d3 | ||
|
|
d69eb6e252 | ||
|
|
a72bbf5fed | ||
|
|
ebe4325db2 | ||
|
|
caeabb3b4f | ||
|
|
634760393a | ||
|
|
52ad6aeb04 | ||
|
|
2163ddb4f7 | ||
|
|
98c9b4900b | ||
|
|
e7eb8d152e | ||
|
|
f627344616 | ||
|
|
fc20baf9d6 | ||
|
|
57a9a946ab | ||
|
|
7c609a5ca8 | ||
|
|
37581ef30f | ||
|
|
a93667db0f | ||
|
|
62295a0230 | ||
|
|
3495530f3d | ||
|
|
873156ed0f | ||
|
|
f9b59dcc1c | ||
|
|
6b909fa234 | ||
|
|
776c6bafce | ||
|
|
a6843d684b | ||
|
|
1f68cba81c | ||
|
|
878906c52f | ||
|
|
50ea94ce3c | ||
|
|
f5ea657784 | ||
|
|
0897c81471 | ||
|
|
04fd25b60e | ||
|
|
94641795cf | ||
|
|
3ceab7ac0b | ||
|
|
40fc7a4a37 | ||
|
|
e75fc67b6a | ||
|
|
74f7c0d509 | ||
|
|
8f798dbd51 | ||
|
|
16fe042678 | ||
|
|
de146338fa | ||
|
|
c63ec96dab | ||
|
|
b0f9c026bd | ||
|
|
e2f4911268 | ||
|
|
88c98cc081 | ||
|
|
69c2c62be3 | ||
|
|
b48872d83b |
@ -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
6
.gitattributes
vendored
Normal 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
65
.gitignore
vendored
@ -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
614
.impeccable/design.json
Normal 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/*` 直接进入工作台。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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 秒恢复
|
||||
- 滚动位置保存
|
||||
- 搜索防抖优化
|
||||
|
||||
|
||||
@ -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
1
.mailmap
Normal file
@ -0,0 +1 @@
|
||||
WuYongJie <549727955@qq.com> chenpei <chenpei@1080p.hk>
|
||||
106
AGENTS.md
106
AGENTS.md
@ -15,93 +15,15 @@
|
||||
- 非必要不要使用兜底逻辑;优先把原生主链路和标准流程做好,只有在明确需要兼容、降级或应急,并说明原因和影响范围时,才增加兜底处理。
|
||||
- 修改后必须验证,先跑最相关的验证,再视情况扩大范围。
|
||||
- 不触碰其他应用配置,不把项目规则写入全局 `Codex` 配置。
|
||||
- 数据库写入、删除或恢复,权限与下载资格,上传、下载、转码、外部接口、批量操作,后台统计、日志、导出与审计,以及影响线上数据或用户可见结果的改动,按高风险任务核对影响范围并加强验证。
|
||||
|
||||
## 3. 项目内 Multi-agents 规则
|
||||
## 3. 界面基线
|
||||
|
||||
当任务满足以下任一条件时,优先启用内置多代理:
|
||||
- 品牌中心实际入口是 `/workspaces/dashboard/core-metrics`;`app/page.tsx` 只负责登录和权限跳转。界面改动以 `DESIGN.md`、`components/department-dashboard/DepartmentShell.tsx` 和对应页面的现状为准。
|
||||
- 品牌中心采用深色导航轨与浅色内容区;素材资产库按其独立工作壳处理。
|
||||
- 项目不挂载 `.dark` 主题类,新代码不要添加无效的 `dark:` 变体。
|
||||
|
||||
- 任务同时涉及前后端、接口与页面联动。
|
||||
- 任务涉及上线前审查、发布门禁、回归检查。
|
||||
- 任务需要同时做“功能正确性 + UI 统一性 + 发布风险”判断。
|
||||
- 任务跨越多个目录,单代理容易遗漏影响范围。
|
||||
|
||||
当用户明确写出以下任一表达时,默认必须真正进入多代理流程,而不是只解释角色定义:
|
||||
|
||||
- “按项目的审查模式处理”
|
||||
- “按项目的修复模式处理”
|
||||
- “按项目的发布模式处理”
|
||||
- “用 Multi-agents 处理”
|
||||
- “启用项目团队”
|
||||
|
||||
默认团队结构:
|
||||
|
||||
- `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. 前端体验与 Impeccable 规则
|
||||
## 4. 前端体验与 Impeccable 规则
|
||||
|
||||
- 凡涉及页面、组件、样式、布局、交互、响应式、可访问性或其它前端体验的工作,默认必须优先调用并严格遵循 `$impeccable` Skill;除非用户明确要求跳过。
|
||||
- 开始修改前,先读取项目现有代码、视觉规范、组件库和产品上下文,并判断当前页面属于 `Operate`、`Persuade`、`Read` 还是 `Experience` 模式。
|
||||
@ -109,18 +31,12 @@ UI 基线规则:
|
||||
- 实现后必须检查桌面端与移动端实际页面,并处理层级、间距、字体、颜色、溢出、空状态、错误状态、交互反馈、响应式和可访问性问题。
|
||||
- 交付前执行与任务匹配的 `$impeccable critique`、`$impeccable audit` 或 `$impeccable polish` 流程,并保留必要的验证证据。
|
||||
|
||||
## 6. 输出要求
|
||||
## 5. 开发进度同步
|
||||
|
||||
多代理任务的输出默认按下面结构收敛:
|
||||
- 可说明的工作提交后,运行 `npm run progress:sync -- ... --apply`,按真实证据使用 `code_complete`、`testing` 或 `done`。
|
||||
- 仅同步 Git 元数据;目录匹配不唯一就停止,同步失败不得阻断 Git 与生产发布。
|
||||
|
||||
- `发现的问题`
|
||||
- `严重级别 P0 / P1 / P2`
|
||||
- `影响范围`
|
||||
- `修复建议`
|
||||
- `验证清单`
|
||||
- `最终结论`
|
||||
|
||||
## 7. 建议先读的文档
|
||||
## 6. 建议先读的文档
|
||||
|
||||
- `README.md`
|
||||
- `docs/CODEX_MULTI_AGENTS.md`
|
||||
- `.private\gongxiangp2-完整敏感接管清单-2026-08-13.md`
|
||||
|
||||
279
CLAUDE.md
279
CLAUDE.md
@ -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
385
DESIGN.md
Normal 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/*` 直接进入工作台。
|
||||
256
Dockerfile
256
Dockerfile
@ -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
68
PRODUCT.md
Normal 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
115
README.md
@ -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`:当前对外接口说明
|
||||
|
||||
## 说明
|
||||
|
||||
@ -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
7
app/admin/org/page.tsx
Normal 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
72
app/admin/page.test.tsx
Normal 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()
|
||||
})
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ 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(),
|
||||
}))
|
||||
@ -21,7 +23,7 @@ jest.mock('@/components/Header', () => ({
|
||||
jest.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
toast: null,
|
||||
showToast: jest.fn(),
|
||||
showToast: showToastMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -44,8 +46,10 @@ describe('系统设置页凭据脱敏', () => {
|
||||
json: async () => ({
|
||||
headerBgColor: '#1f2937',
|
||||
siteName: '由壹点3D素材',
|
||||
headerLogoUrl: '',
|
||||
loginLogoUrl: '',
|
||||
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',
|
||||
@ -94,5 +98,67 @@ describe('系统设置页凭据脱敏', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,10 +3,15 @@
|
||||
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
|
||||
@ -83,10 +88,10 @@ 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 || '')
|
||||
setNewS3AccessKey('')
|
||||
setNewS3SecretKey('')
|
||||
@ -174,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
|
||||
}
|
||||
|
||||
@ -202,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' })
|
||||
}
|
||||
@ -220,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
|
||||
}
|
||||
|
||||
@ -248,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' })
|
||||
}
|
||||
@ -266,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">
|
||||
@ -340,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" />
|
||||
@ -352,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
|
||||
@ -381,7 +396,7 @@ export default function SystemSettings() {
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={BRAND_LOGO_ACCEPT}
|
||||
onChange={handleHeaderLogoUpload}
|
||||
disabled={uploadingHeader}
|
||||
className="hidden"
|
||||
@ -415,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" />
|
||||
@ -427,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
|
||||
@ -456,7 +479,7 @@ export default function SystemSettings() {
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={BRAND_LOGO_ACCEPT}
|
||||
onChange={handleLoginLogoUpload}
|
||||
disabled={uploadingLogin}
|
||||
className="hidden"
|
||||
@ -647,7 +670,7 @@ 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>
|
||||
|
||||
5
app/api/admin/ai-models/configuration/route.ts
Normal file
5
app/api/admin/ai-models/configuration/route.ts
Normal 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))) }
|
||||
4
app/api/admin/ai-models/import-environment/route.ts
Normal file
4
app/api/admin/ai-models/import-environment/route.ts
Normal 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)) }
|
||||
@ -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)))
|
||||
}
|
||||
@ -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)))
|
||||
}
|
||||
5
app/api/admin/ai-models/providers/[id]/route.ts
Normal file
5
app/api/admin/ai-models/providers/[id]/route.ts
Normal 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))) }
|
||||
5
app/api/admin/ai-models/providers/[id]/sync/route.ts
Normal file
5
app/api/admin/ai-models/providers/[id]/sync/route.ts
Normal 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))) }
|
||||
@ -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)))
|
||||
}
|
||||
@ -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) : {}))
|
||||
}
|
||||
@ -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))
|
||||
})
|
||||
}
|
||||
5
app/api/admin/ai-models/providers/route.ts
Normal file
5
app/api/admin/ai-models/providers/route.ts
Normal 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))) }
|
||||
5
app/api/admin/ai-models/route.ts
Normal file
5
app/api/admin/ai-models/route.ts
Normal 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()) }
|
||||
70
app/api/admin/api-keys/[id]/reveal/route.ts
Normal file
70
app/api/admin/api-keys/[id]/reveal/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
159
app/api/admin/api-keys/route.test.ts
Normal file
159
app/api/admin/api-keys/route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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: '服务器错误' },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
46
app/api/admin/materials/purge-jobs/route.ts
Normal file
46
app/api/admin/materials/purge-jobs/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
61
app/api/admin/notification-center/_shared.ts
Normal file
61
app/api/admin/notification-center/_shared.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
22
app/api/admin/notification-center/audits/route.ts
Normal file
22
app/api/admin/notification-center/audits/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
app/api/admin/notification-center/deliveries/route.ts
Normal file
22
app/api/admin/notification-center/deliveries/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
93
app/api/admin/notification-center/projects/[code]/route.ts
Normal file
93
app/api/admin/notification-center/projects/[code]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
38
app/api/admin/notification-center/projects/route.ts
Normal file
38
app/api/admin/notification-center/projects/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
app/api/admin/notification-center/recipients/route.ts
Normal file
22
app/api/admin/notification-center/recipients/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
23
app/api/admin/notification-center/test/route.ts
Normal file
23
app/api/admin/notification-center/test/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
204
app/api/department-dashboard/assets/value-metrics/route.ts
Normal file
204
app/api/department-dashboard/assets/value-metrics/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
158
app/api/department-dashboard/brand-knowledge/[id]/route.ts
Normal file
158
app/api/department-dashboard/brand-knowledge/[id]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
375
app/api/department-dashboard/brand-knowledge/route.test.ts
Normal file
375
app/api/department-dashboard/brand-knowledge/route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
511
app/api/department-dashboard/brand-knowledge/route.ts
Normal file
511
app/api/department-dashboard/brand-knowledge/route.ts
Normal 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?.()
|
||||
}
|
||||
}
|
||||
36
app/api/department-dashboard/business-trends/route.test.ts
Normal file
36
app/api/department-dashboard/business-trends/route.test.ts
Normal 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: '无权查看品牌中心流程指标' })
|
||||
})
|
||||
})
|
||||
30
app/api/department-dashboard/business-trends/route.ts
Normal file
30
app/api/department-dashboard/business-trends/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 不属于该流程' })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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: '品牌参数无效' })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
46
app/api/department-dashboard/team-capacity/route.test.ts
Normal file
46
app/api/department-dashboard/team-capacity/route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
30
app/api/department-dashboard/team-capacity/route.ts
Normal file
30
app/api/department-dashboard/team-capacity/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
61
app/api/desktop/[...path]/route.ts
Normal file
61
app/api/desktop/[...path]/route.ts
Normal 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}
|
||||
18
app/api/detail-generator/assets/[assetId]/route.ts
Normal file
18
app/api/detail-generator/assets/[assetId]/route.ts
Normal 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) }
|
||||
}
|
||||
12
app/api/detail-generator/brand-profiles/route.ts
Normal file
12
app/api/detail-generator/brand-profiles/route.ts
Normal 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) }
|
||||
})
|
||||
}
|
||||
9
app/api/detail-generator/capabilities/route.ts
Normal file
9
app/api/detail-generator/capabilities/route.ts
Normal 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' } })
|
||||
}
|
||||
11
app/api/detail-generator/jobs/[id]/recover/route.ts
Normal file
11
app/api/detail-generator/jobs/[id]/recover/route.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
6
app/api/detail-generator/jobs/[id]/route.ts
Normal file
6
app/api/detail-generator/jobs/[id]/route.ts
Normal 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)) })) }
|
||||
@ -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) }
|
||||
}
|
||||
5
app/api/detail-generator/materials/route.ts
Normal file
5
app/api/detail-generator/materials/route.ts
Normal 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')) } }) }
|
||||
5
app/api/detail-generator/projects/[id]/analyze/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/analyze/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/approve/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/approve/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/assets/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/assets/route.ts
Normal 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)) }
|
||||
5
app/api/detail-generator/projects/[id]/exports/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/exports/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/generate/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/generate/route.ts
Normal 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)) })) }
|
||||
@ -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))) }
|
||||
10
app/api/detail-generator/projects/[id]/route.ts
Normal file
10
app/api/detail-generator/projects/[id]/route.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user