交接审计 · 2026-08-11

VW Corporate
项目接手报告

本报告梳理两套代码库的真实结构、本机还原结果、交付缺口、AI 与人工编写比例,以及接手所需的工作量与难度评级。全部结论来自实测,每条论断附文件与行号。

已跑通 交付树零改动 代码 55,543 缺陷 145 必修 275.5 小时

TL;DR

四句话结论

代码能跑

两个站点已在本机 Docker 完整启动,登录、MFA、RBAC、审计全链路验证通过,未修改任何一行源码。

安全基线不合格

去重后 145 项缺陷,其中 17 项阻断级。内容与栏目管理接口完全没有权限校验⁠,CSRF 全局关闭,DAM 上传可写任意路径。

交付物不完整

数据库、DAM 媒体、CI4 运行时目录、生产 web server 配置、25 个工程规则文件,全部不在交付包里。

约九成代码由 AI 生成

主力工具是 Cursor(15 条 rules + 10 个 skills)。风格一致性检验显示 213 个 PHP 文件零漂移,且无格式化工具配置。

最关键的一句

这套系统的难,八成不在代码里,在交接物里。代码本身是写法朴素、可读的 CodeIgniter 4 + Vue 3 应用;真正卡住接手的是数据、部署配置、代码基线、域名控制权这四类非代码资产的缺失。

01 · 项目全貌

它不是「前端 + 后端」,是两套独立系统

两个目录并非前后端分离的一套工程,而是两个各自完整、通过共享同一个 MySQL 库和一条符号链接耦合的系统。

CMS w2r.site

自研内容管理系统。CodeIgniter 4 后端加原生 Vue 3 管理后台,是内容生产的全部工具。

backend/CI4,27,252 行 PHP
admin-frontend/Vue 3 + Element Plus,13,656
scripts/Python,Sitecore 迁移
docs/31 份文档,含正式规格书

官网 demo.w2r.site

面向公众的官网前台,按 Figma 设计稿重写,只读同一个 vgc 库。

frontend/Vue 3 + Vite + SCSS
backend/CI4 薄壳,实际生效的是 public/api.php
shared/符号链接指向 w2r 的 DAM 存储
AGENTS.mdFigma 保真度开发指令

系统规模

35
数据库表
51
数据库迁移
111
路由声明
29
控制器
25
Service
25
Model
14
CLI 命令
7
角色 / 22 权限

必须尽早决策的架构隐患

demo.w2r.site/backend/public/api.php21 KB 的独立文件,文件头自述「绕过 CI4 完整引导」,用裸 mysqli 手写 SQL;而 app/Controllers/News.php 等 CI4 控制器实现了同一批接口的另一份逻辑⁠。哪一份生效由不在仓库里的 web server 配置决定。两份实现会持续漂移,接手后必须二选一。

02 · 本机还原

已完整跑通,交付树零改动

本机原本没有 PHP、Composer、MySQL。方案选择 Docker,把项目挂到容器内的 /www/wwwroot⁠,使源码里的绝对路径与符号链接原样解析。运行发生在家目录副本上,原始交付目录不参与运行。

访问入口

地址内容状态
localhost:1977/AdM/CMS 管理后台200
localhost:1977/api/*CMS API200
localhost:1978/官网前台200
localhost:1978/api/*官网只读 API200

端到端验证

环节结果
PHP 8.2.33 + 全部必需扩展通过
数据库迁移 51/51通过
RBAC 播种 7 角色 / 22 权限通过
验证码 + 登录限流 + JWT通过
TOTP MFA 注册与验证通过
建栏目 → 官网导航显示通过
内容提交审核500

完整性已验证

verify-tree.sh check31,997 个文件逐一比对大小与修改时间,结果为零差异⁠。

启动方式

cd /Volumes/ProjectsAPFS/VWCORP/localdev
docker compose up -d
open http://localhost:1977/AdM/

03 · 环境地图

四个站点,三台服务器

域名解析归属角色
w2r.site47.242.46.253阿里云CMS 后台
demo.w2r.site47.242.46.253阿里云 · 同一台官网前台,交付码来源
vgc.digirepub.com139.224.64.38另一台文档标注的「生产环境」,无任何代码
volkswagengroupchina.com.cn211.151.111.91现有真实官网,Sitecore 驱动,被替代对象

w2r.site 域名于 2026-02-08 经阿里云万网注册,2027-02-08 到期⁠,DNS 在 hichina。这是交付方自建的开发演示环境,不是 VW 基础设施。

悬而未决的问题

规格书写明生产域名是 vgc.digirepub.com⁠,但所有开发痕迹(changelog、baseURL、符号链接、.env)都指向 w2r.site 那台。这个项目到底上线了没有、生产跑的是哪份代码⁠,从交付物无法判断,必须直接问交付方。

04 · 缺失清单

交付物缺了什么

阻断 数据库导出

全盘搜索两个压缩包,.sql / .dump 数量为 0⁠。丢失:350–400 篇双语新闻、首页 Figma 内容、栏目树、SEO 配置、FAQ 题库、全部账号与审计历史。

changelog 中大量记录如「entry #6667 首页」「entry_i18n #7996 zh」,这些主键本地全部不存在。

阻断 DAM 媒体与运行时目录

backend/writable/ 整个目录不在包里。它同时是 CI4 运行时目录和 DAM 存储根,缺了框架无法启动。

unzip -l | grep -c 'backend/writable' 结果为 0;241 MB 包内媒体文件仅 13 个,而 migrate_video.log 显示 197 个视频迁移成功。

阻断 生产 web server 配置

证据显示生产跑的是 nginxpublic/404.html 是 nginx 默认错误页,index.html 是宝塔面板页),但改写规则全写在 Apache 语法的 .htaccess 里。nginx 不读 .htaccess⁠,真实路由配置完全缺失。

这也意味着「哪套 API 实现生效」这个问题无法从代码回答。

阻断 工程知识体系

规格书第四章列出 15 条 .cursor/rules + 10 个 .cursor/skills⁠,实际交付 2 个 rules、0 个 skills。它们在 /www/wwwroot/.cursor/⁠,比站点根高一层,打包时漏了。

好消息:15 条规则里 14 条在 docs/cms/(26 份、313 KB)有对应设计文档,知识大部分可恢复。

代码仓库与基线

原仓库在 VW 内网 GitLab。随包的 .git 只有 backend 13 个、admin-frontend 7 个提交,全是整树倾倒,集中在六天内。无法做任何行级溯源。更严重的是有核心功能代码从未提交,远端不存在这批实现。

提交者三人:Arwen Fu、Viola Liu、Yuhan Wu。

官网前台落后三个月

changelog 记载的 /api/home(Figma 首页正文)与 /preview/{token}(外部预览页)在交付代码里都不存在⁠。api.php 最后修改停在 2026-03-23。

CMS 侧的 PreviewService / PreviewController 完整实现,但前台承接页面缺失——功能只做了一半。

05 · 缺陷总账

145 项缺陷,必修 275.5 小时

八个模块代理独立排查共报出 154 条,跨模块去重后 145 项。被多个代理同时报出的会标注——那通常意味着问题更值得优先处理。

17
阻断
46
56
26
必修(阻断+高)275.5 小时全部 496 小时 ≈ 62 人天

阻断级(17 项,上线前必须清零)

阻断生产部署产物完全缺失:无 nginx 配置,.htaccess 在 nginx 下不生效

public/.htaccess 用 <IfModule mod_rewrite.c> 写 Apache 重写;但 public/404.html:6 是 nginx 默认 404 页、public/index.html 是宝塔面板默认页,说明生产跑的是 nginx,.htaccess 全部规则被忽略。仓库内 find 不到任何 .conf / Dockerfile / docker-compose。接手方拿不到「/AdM 静态资源直出、其余打到 index.php、writable 与 .env 拒绝外部访问」这套真正生效的规则,换机部署要从零复原。

backend/public/.htaccess:4 · 修复约 8 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

阻断JWT 会话撤销表只写不读:登出、并发登录踢人、禁用账号都不会让已签发 token 失效

AuthFilter 只做 verify(签名+exp),从不查 user_sessions.revoked_at;UserSessionService 里根本没有 isRevoked() 方法。AuthController::logout 第 298 行调 revokeSession 写了 revoked_at,UserSessionService.php:76-82 在关闭并发登录时也批量 revoke,但两者对后续请求毫无作用——旧 token 在 exp 之前照常通行。同时 UserController::update/updateStatus/delete 从不调 revokeAllForUser,禁用或删除用户后其 token 仍可用。安全审计里「登出/踢人/禁用」三条控制全部失效。

w2r.site/backend/app/Filters/AuthFilter.php:20 · 修复约 6 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断CSRF 过滤器被注释掉,而 AuthFilter 保留 Session 兜底认证 → 全部写接口可被跨站伪造

globals.before 里 'csrf'、'invalidchars' 全被注释(第 82-83 行),after 里 'secureheaders' 也注释(第 87 行)。若只用 Bearer token 风险有限,但 AuthFilter.php:31-35 与 ApiController.php:92 都保留了 session()->get('user_id') 兜底,Session Cookie 会随跨站请求自动带上(Cookie.php:90 samesite=Lax,POST 跨站虽被 Lax 拦一部分,但同站子域/表单场景仍可打)。加上 Cookie.php:57 secure=false、App.php:160 forceGlobalSecureRequests=false,明文 HTTP 下 Cookie 可被嗅探。建议要么彻底删掉 Session 兜底只认 JWT,要么开启 csrf。

w2r.site/backend/app/Config/Filters.php:82 · 修复约 6 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断ContentController 五个写接口完全没有权限码校验,任意已登录用户可创建/改/删内容

路由只挂 auth 过滤器(Routes.php:75-81),控制器内没有任何 requirePermission/requireRoles。只要登录并通过 MFA,任何角色(含只读的 audit_readonly)都能调 POST /api/content 建内容、调 PUT/DELETE 改删自己创建的内容(:493 与 :719 只比对 created_by 或 sys_admin)。RBAC 里 content:create / content:edit / content:delete 三个权限码在整个内容主流程上形同虚设,等保口径下属于越权。

backend/app/Controllers/Api/ContentController.php:40,130,304,474,705 · 修复约 4 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

阻断audit:accept-ext 会 TRUNCATE 生产表 audit_rate_buckets

clearRateBuckets() 直接 truncate('audit_rate_buckets'),在 run() 中被调用两次(:156、:185)。这是一条命名像「验收」的命令,实际会清空生产环境的访问频次/异常检测桶,导致 access_denied_threshold、query_rate_anomaly 两类等保告警在此后一段时间内失效且历史计数不可恢复。命令由 spark 自动发现,任何有 shell 的人 php spark audit:accept-ext 即触发。

backend/app/Commands/AuditAcceptanceExtended.php:151 · 修复约 4 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

阻断MFA 可被无条件重置:已登录未过 MFA 的会话能调 mfa/enroll 覆盖密钥,绕过第二因子

路由 Routes.php:32 给 auth/mfa/enroll 挂的是 auth_no_mfa(只验登录,不验 MFA)。mfaEnroll 不校验当前是否已 enrolled,直接调 AuthService::generateMfaSecret,后者在 AuthService.php:116-121 对已有记录执行 update 覆盖 secret 并把 is_enrolled 置 0。攻击者只要拿到用户名+口令(撞库/钓鱼),登录拿到 mfa_verified=false 的 token,即可 enroll 自己的 TOTP 再 verify 通过,整个 MFA 形同虚设。

w2r.site/backend/app/Controllers/Api/AuthController.php:422 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断审计验收命令用 JwtService 凭空伪造 sys_admin token,绕过密码与 MFA

$jwt->generate(1, 'sys_admin', true) 直接签发 user_id=1、role=sys_admin、mfa_verified=true 的合法 JWT(JwtService.php:48 签名为 generate(int $userId, string $role, bool $mfaVerified))。AuditAcceptanceExtended.php:48-49 同样。等于在代码库里留了一条「无需口令即可拿到最高权限令牌」的路径;只要能跑 spark 或能复制这三行逻辑,整套 RBAC/MFA 形同虚设。

backend/app/Commands/AuditAcceptance.php:50 · 修复约 3 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

阻断publish:revert-pages-to-draft 无权限校验、无确认,一条命令可全站下线

默认用户名硬编码为 'test'(:29-32),不做任何 PermissionService 校验(对比 PublishPagesAsUser.php:46 是有 content:publish 校验的),也没有二次确认。php spark publish:revert-pages-to-draft 不带任何参数即把所有 type=page 且 status!=draft 的条目改成 draft 并清空 published_at(:78-82),审计里记成 test 用户所为。绕过 PublishService/工作流直写 EntryModel。

backend/app/Commands/RevertPagesToDraft.php:29 · 修复约 3 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

阻断运行期目录 writable/ 整体缺失,且被 .gitignore 排除

WRITEPATH 指向 backend/writable,实际目录不存在(.gitignore:2 排除)。依赖它的有:Session.php:64 savePath、Cache.php:84 storePath(LoginThrottleService.php:24 与 CaptchaService.php:25 用它做登录限流与验证码)、Logger 的 logs/、Dam.php:49-50 的 uploads/dam 与 dam_chunks、Figma.php:58 的 uploads/page-css。缺目录会让登录限流、验证码、会话、日志、上传、页面 CSS 全部在运行时报错,而不是启动时报错,排查成本高。

backend/app/Config/Paths.php:56 · 修复约 3 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

阻断DAM 分片上传的 upload_id 未做任何格式校验,直接当目录名与文件名拼接 → 任意路径写文件(可 getshell)

DamController::chunk 第 50 行把请求里的 upload_id 原样传下来,saveChunk 第 106 行 $chunkDir = chunksDir . '/' . $uploadId 并用 mkdir(recursive) 创建,第 111 行写 {$chunkIndex}.part⁠;merge 第 202 行又拼 $targetDir . '/' . $uploadId . '_' . $safeName⁠。upload_id 传 ../../../public/x 即可把内容写到 web 根下。配合下条 resolveAssetType 的 OR 判定(mime 传 image/png 就放行 .php 扩展名),任何拿到 dam:upload 权限的账号可写入并执行 PHP,等于远程代码执行。

w2r.site/backend/app/Services/DamUploadService.php:106 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断栏目管理全部写接口没有任何权限校验,任意登录角色可增删改栏目

create(111)、update(164)、delete(237)、reorder(43)、index(31)、show(97) 六个方法体内没有一处 requirePermission/requireRoles,只有 rolePermissions(278) 和 updateRolePermissions(307) 校验了 rbac:manage。路由 Routes.php:65-70 只挂了 auth 过滤器。结果:audit_readonly 这种纯只读角色也能 DELETE /api/channels/:id 删掉栏目、能 PUT reorder 打乱整站导航。ChannelModel::hasContent 只挡有内容的栏目,空栏目直接删。

w2r.site/backend/app/Controllers/Api/ChannelController.php:111 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断ChannelController 栏目增删改查无权限校验,任意登录用户可改站点导航

index/reorder/show/create/update/delete 六个方法零校验,只有 rolePermissions(:278)与 updateRolePermissions(:307)检查 rbac:manage。任意登录用户可以新建栏目、改栏目 slug、拖动整棵导航树(reorder 批量改 parent_id/sort_order),删除无内容栏目。栏目 slug 是前台 URL 的组成部分,改动直接影响线上站点结构与 SEO。

backend/app/Controllers/Api/ChannelController.php:31,43,97,111,164,237 · 修复约 2 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

阻断WorkflowService 向 EntryModel 的 ?datetime cast 字段写字符串,工作流提交/审核 500 3 个代理独立报出

WorkflowService::now()(:280-288)返回 'Y-m-d H:i:s' 字符串,而 EntryModel 把 submitted_at / reviewed_l1_at / reviewed_l2_at / last_rejected_at / published_at 全部声明为 ?datetime cast(EntryModel.php:50-57)。CI4 4.7 的 DatetimeCast::set() 只接受 Time 实例,收到字符串抛异常。受影响的写点还有 :99、:112、:128、:131、:157、:159、:174、:177,以及 recordAction 的 :276(WorkflowActionModel.php:31 同样 cast created_at)。正确写法见 PublishService.php:27/118 与 AssetRefService.php:145-148(返回 Time 对象)。

w2r.site/backend/app/Services/WorkflowService.php:63 · 修复约 2 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s、CMS 后端 · 服务层(w2r.site/、缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断资产类型判定用 OR 而非 AND,扩展名与 MIME 只要中一个就放行

if ($extOk || $mimeOk) return $type; —— 上传 shell.php 但把 mime_type 报成 image/png,即被判定为 image 放行;反之扩展名合法但内容任意也放行。且 mime 完全取自请求参数(DamController.php:55),从不做服务端嗅探。是上一条能升级成 RCE 的关键一环。

w2r.site/backend/app/Services/DamUploadService.php:469 · 修复约 2 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断迁移 100031 硬编码 created_by=1,干净库上 51 个迁移跑不完 2 个代理独立报出

第 35 行和第 59 行两处 insert 写死 'created_by' => 1,而 2026-01-27-100024_CreateAssetCategoriesTable.php:61 给该列建了 addForeignKey('created_by','users','id','RESTRICT','CASCADE')⁠。空库上先跑迁移再建管理员时 users 表没有 id=1,外键 RESTRICT 直接让迁移中断,且此迁移前半段的 ALTER(15-24 行)已经生效、无法重跑(见下一条)。灾备重建、新环境搭建会卡在这里。

w2r.site/backend/app/Database/Migrations/2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:35 · 修复约 1.5 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s、缺陷穷举与技术债(横向,覆盖 w2r.sit

阻断update_sitecore_channels_and_assets.py --update-assets 会把无 channel 匹配的资产 category_id 静默置为 NULL(无 dry-run、无确认)

category_id 初始化为 None,若 channel_ids 为空或无命中(全部 1650 条 Document、全部新闻封面、全部无 channel 的资产)仍会执行 UPDATE assets SET category_id = NULL WHERE sitecore_id = ...。这条脚本没有 --dry-run、没有 --confirm,README 与 docs/import_sitecore_files.md:156 又把它列为常规命令。在当前生产库上跑一次,媒体库的分类树会被大面积清空,且无法从脚本侧回滚(backend/writable 与数据库导出都已缺失)。

w2r.site/scripts/update_sitecore_channels_and_assets.py:273-289 · 修复约 1.5 小时 · 来源:w2r.site — Python 迁移脚本

阻断核心功能代码从未提交,GitLab 远端不存在这批实现

admin-frontend 的 git HEAD 是 2026-05-25 的 b033e30,工作区有 8 个文件被修改(channel.js、content.js、figma.js、RichTextEditor.vue、Channel.vue、Content.vue、Media.vue、News.vue,共 675 行新增),另有 6 个一级功能文件完全未入库:api/preview.js、api/publish.js、composables/useContentPublish.js、composables/useContentEditDraft.js、composables/useContentDeleteDraft.js、utils/contentPreview.js(合计 231 行)。发布、预览 token、修订稿三套核心流程全在这批未提交的代码里。远端 gitlab.onedevops.vw.com.cn/c-sdxx/corporate-website/frontend 上没有它们,交接的这份工作区是唯一副本,任何误删都不可恢复。接手第一件事应是提交入库。

js/composables/useContentPublish.js:1 · 修复约 1 小时 · 来源:CMS 管理后台前端(w2r.site/ad
高危级(46 项)

测试只覆盖 5 个 Figma 服务类,认证/RBAC/工作流/发布/DAM 零覆盖

tests/ 下 8 个测试文件共 33 个测试方法、79 条真实断言,其中 6 个文件全部属于 Figma 导入链路,另两个(ExampleDatabaseTest、ExampleSessionTest)是 CI4 骨架自带示例,HealthTest 只校验 APPPATH 常量与 baseURL 是合法 URL。对应 app/ 下 24 个 API 控制器、32 个 Service、25 个 Model、3 个 Filter、51 个迁移无任何测试。已知的 EntryModel ?datetime cast 崩溃、创建内容非事务性这两个缺陷,正是因为没有工作流/发布冒烟测试才漏到线上。

backend/phpunit.xml.dist:26 · 修复约 40 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

工作流审批 UI 完全缺失,三个权限点与两个角色无法行使

后端 Routes.php:117-120 提供 workflow/submit、workflow/review-l1、workflow/review-l2、workflow/actions 四个接口,但前端 workflowApi 只封装了 getConfig 与 updateConfig,全仓库 grep 不到任何提交审核或审批的调用。Content.vue:38-39 与 News.vue:31-32 的状态筛选提供了「待初审」「待终审」选项,却没有任何界面能把内容推进到这两个状态。内容的唯一流转路径是 useContentPublish 的草稿直接发布,等于绕过了整套两级审核。RolePermissionSeeder 里的 reviewer_l1、reviewer_l2 两个角色登录后除仪表盘外无事可做,content:submit、workflow:review_l1、workflow:review_l2 三个权限点是空转的。已知缺陷清单中「WorkflowService 提交 500」之所以一直没被发现,正是因为没有 UI 路径能触发它。

js/api/workflow.js:3 · 修复约 24 小时 · 来源:CMS 管理后台前端(w2r.site/ad

按钮级权限管控缺失,危险操作对无权角色一律可见

权限判断只出现在 4 处:菜单过滤(menu-permissions.js:29)、路由守卫(router/index.js:134)、发布按钮(useContentPublish.js:15)、栏目授权按钮(Channel.vue:241)。其余所有操作按钮都无条件渲染。以 editor 角色为例:它只有 content:create、content:submit、preview:create、dam:upload、figma:import,却能看到 Content.vue:119 的删除、Channel.vue:54 的删除栏目、Channel.vue:10 的创建栏目、Media.vue:126 的删除资产。用户走完确认弹窗、输入完密码、等生成完 confirm_token,最后才拿到后端 403。22 个权限点中 content:unpublish、workflow:review_l1、workflow:review_l2、dam:force_delete、mfa:reset_other、audit:export、figma:import 共 7 个在前端从未被引用。

js/views/Content.vue:119 · 修复约 16 小时 · 来源:CMS 管理后台前端(w2r.site/ad

两套并行后端实现,生产由不在仓库里的 web server 配置决定谁生效

.htaccess 只把 /api/ 与 /api/hello 交给 api.php,其余落到 index.php 走 CI4 控制器;frontend/README.md:24 又说 nginx 把整个 location ^~ /api/ 转给 api.php。backend/writable/logs/log-2026-03-23.log:1-3 证明线上确有 /api/* 请求进入 CI4 Router。也就是说仓库内无法判定 /api/menu、/api/news 归谁处理,改一份可能完全不生效。判定方法:curl 生产 /api/ 看 data.framework 是 'CodeIgniter 4'(控制器)还是 'CodeIgniter 4 (轻量入口)'(api.php),见 api.php:49 对 Api.php:29。

backend/public/api.php:1-62 与 backend/app/Controllers/{Api,News,AssetDeliver}.php;分发规则见 backend/public/.htaccess:15,29 与 frontend/README.md:24 · 修复约 8 小时 · 来源:demo.w2r.site 官网前台(fro

demo 存在两套并行的接口实现,靠仓库外的 web 服务器配置决定哪套生效

public/api.php(683 行,绕过框架、手写 mysqli)与 CI4 侧的 app/Controllers/News.php(450 行)、Api.php(72 行)、AssetDeliver.php(25 行)、Models/ChannelModel.php(130 行) 处理的是同一批端点(/api/menu、/api/news、/api/news/{id}、/api/asset/{id}),逻辑各自实现、已经漂移。但 .htaccess 第 29 行的 RewriteRule ^(hello|)$ api.php [L,QSA] 只把 /api/ 和 /api/hello 交给 api.php,其余按第 34-36 行落到 index.php 走 CI4——与 api.php 里自己写的路由分发(第 12/24/31/37 行)矛盾。api.php 的 mtime 是 3-23 而 .htaccess 是 3-01,说明 api.php 后来加了 menu/news/asset 分发但 rewrite 规则没同步。生产实际用的是 nginx(仓库里没有配置文件),换台机器部署很可能跑到另一套实现上、行为静默改变。必须先确认线上哪套生效,然后删掉另一套约 680 行死代码。

demo.w2r.site/backend/public/.htaccess:29 · 修复约 8 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

MFA 密钥与备用码仅 base64 编码,注释谎称「加密存储」

第 110 行注释写「加密存储(Base64编码)」,实际 base64_encode 是编码不是加密。任何拿到 mfa_secrets 表读权限的人(DBA、备份文件、SQL 注入、误导出)都能一步还原 Base32 TOTP 种子并持续生成有效验证码,同时拿到全部 8 个明文备用码,MFA 这层防护等于零。修复需要引入 CI4 Encryption 服务并对存量行做一次性重加密迁移。

w2r.site/backend/app/Services/AuthService.php:110-112(写入)、:217-221(读取) · 修复约 6 小时 · 来源:CMS 后端 · 服务层(w2r.site/

DAM 资产接口无归属校验,可按 id 枚举拉取任意资产

demo_asset_fetch_row 只按 assets.id 取行,只在 asset_categories.access_level='auth' 时拒绝(:186-188)。没有「该资产是否挂在已发布 entry 上」的判断。任何人 for i in 1..N 遍历 /api/asset/{i} 即可下载未发布稿件的配图、内部文档等全部非 auth 分类资产。

backend/public/demo_asset_delivery.inc.php:166-219 · 修复约 6 小时 · 来源:demo.w2r.site 官网前台(fro

前端没有兜底路由,顶栏所有栏目链接点开是空白页

router 只注册了 /、/en/、/news/:uuid、/en/news/:uuid,没有 catch-all 也没有 NotFound 组件。菜单 href 是 /api/menu 返回的栏目 slug(如 /about),服务器 SPA 回退到 index.html 后 vue-router 无匹配,router-view 渲染空——整页只剩空白 div,无报错、无 404。同一问题命中新闻详情页面包屑(NewsArticleContent.vue:11,38)。另外这些都是 <a href> 而非 router-link,每次点击整页刷新。

frontend/src/router/index.js:10-26;链接产生处 frontend/src/components/home/HeaderNav.vue:24-31,98,143 · 修复约 6 小时 · 来源:demo.w2r.site 官网前台(fro

前台正文 HTML 只在浏览器端用自研消毒函数过滤,iframe srcdoc 等可穿透

NewsArticleContent.vue:32 用 v-html 渲染 bodyHtmlSafe,消毒全靠这个 27 行的自研函数:只删 script/style/link[rel=stylesheet]、只去 on* 属性、只查 href 的 javascript:。未处理 <iframe srcdoc="...">⁠、<object>⁠、<embed>⁠、src="javascript:"⁠、formaction⁠、SVG 的 xlink:href。而 CMS 写入侧(ContentController.php:379-382 / 544-546)对 content_json 不做任何过滤,只是 json_encode。任何 editor 角色可在正文注入持久化 XSS,打到官网所有访客。DOMParser 不可用时(SSR/老浏览器)第 11-12 行只做一个正则删 script,形同虚设。应改用 DOMPurify 并在服务端入库时也过滤一次。

demo.w2r.site/frontend/src/lib/news/sanitizeArticleHtml.js:14 · 修复约 6 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

创建内容是四张表的非事务性写入,任一步失败留下孤儿数据

create() 依次写 entries(348)、entry_i18n zh(374)、entry_i18n en(405)、asset_entry_i18n(429/437),再调 assetRefService->refreshEntryRefs(446),整段没有 transStart/transComplete。zh 的 i18n 插入失败(例如 slug 唯一键冲突、title 超长)时 entries 主记录已落库且不回滚,产生无 i18n 的孤儿 entry;这种 entry 在 WorkflowService::checkI18nCompleteness 会被判 block,在前台查询会被 INNER JOIN 掉,管理端列表却能看到,用户无法修复只能删。对照 EntryDraftService::createDraftFromPublished(第 75-94 行)是正确用了事务的,说明作者知道怎么做,只是 ContentController 没做。update()(474-699) 同样无事务,涉及 entries + 两条 entry_i18n + asset_entry_i18n 删插。

w2r.site/backend/app/Controllers/Api/ContentController.php:348 · 修复约 5 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

asset_refs 重建为非事务的 delete+insert,且 asset_id 有外键,正文里出现不存在的 /asset/{id} 会让保存直接 500

refreshEntryRefs 第 34-37 行先无条件删掉该 entry 的全部引用,第 83-89 行再逐条插入,中间没有事务。而迁移 2026-01-24-100018_CreateAssetRefsTable.php:51 给 asset_id 建了指向 assets 的外键。scanHtmlForAssetIds(第 135 行)用正则 #/asset/(\d+)# 从富文本里抓 id,编辑只要在正文里粘一个已被删除或不存在的 /asset/99999,insert 就违反外键 → 抛 DatabaseException → 整个保存 500,且此时旧引用已被删光,资产的「被引用保护」静默失效,随后可被 dam:delete 误删。scanForAssetIds 第 104 行的 str_contains($k, 'asset') 匹配过宽,任何含 asset 的键名的数值都会被当成资产 id。

w2r.site/backend/app/Services/AssetRefService.php:34 · 修复约 5 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

MFA 校验与预览登录都没有次数限制,6 位 TOTP 与 6 位备用码可暴力破解

mfaVerify 全程没有调用 LoginThrottleService,允许 ±1 个时间窗(AuthService.php:240)等于每 90 秒有 3 个有效码;备用码是 8 个 6 位纯数字(AuthService.php:107),空间与 TOTP 相同且校验成功即消耗。/api/auth/login 有图形验证码 + IP 锁定,但 PreviewController::access(第 167 行,Routes.php:40 公开路由)是另一个完整的用户名+口令校验入口,只有 IP 节流没有验证码,且 recordFailure 只在 code===3205 时调用(第 205-207 行),token 不存在/已撤销/已过期都不计次,可无限枚举预览 token。

w2r.site/backend/app/Controllers/Api/AuthController.php:453 · 修复约 5 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

登出与并发登录吊销写了 user_sessions.revoked_at,但没有任何地方读它

AuthController.php:295-300 登出时调 UserSessionService::revokeSession 打上 revoked_at;UserSessionService.php:76-82 在禁止并发登录时也会批量吊销。但全仓只有 AuthController 和 Commands/AuditAcceptanceExtended 引用该服务,三个 Filter 与 ApiController 都只做 JWT 验签(AuthFilter.php:22-28)。因此登出后 token 依旧有效、并发登录「踢下线」不生效,泄露的 token 在 2 小时内无法作废。

backend/app/Filters/AuthFilter.php:12-62 · 修复约 4 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

entries.channel_id 改 JSON 后既无外键也无索引 2 个代理独立报出

第 18-28 行显式 DROP 掉 channel_id 上的外键和索引(JSON 列确实不能直接建 KEY),此后再没有补生成列+索引。ContentController.php:70 的筛选是 JSON_CONTAINS(channel_id, CAST('[17]' AS JSON), '$')⁠,demo 的 api.php:630 也用同样写法且是每次打开新闻详情都全量取一遍同栏目 id 列表(api_news_resolve_neighbors 第 628-639 行把所有已发布新闻 id 读进 PHP 数组再 array_search 找上下篇)。entries 上万条后列表页与详情页都会明显变慢。同时失去 channel_id 的引用完整性,删栏目不再被数据库挡住。

w2r.site/backend/app/Database/Migrations/2026-02-10-100030_ChangeEntriesChannelIdToJson.php:19 · 修复约 4 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s、缺陷穷举与技术债(横向,覆盖 w2r.sit

user_sessions 只写不读:登出与并发会话撤销对 JWT 无实际约束力

全仓 grep 'user_sessions' 只有 UserSessionService 自身命中,鉴权链任何一处都不查 revoked_at。后果:(1) 用户点登出、AuthController.php:298 把 jti 标记 revoked,但同一个 JWT 在 exp(Config/Jwt.php:35,默认 7200 秒)内继续畅通;(2) 把 auth.allow_concurrent_sessions 设为 0 时,UserSessionService.php:77-82 撤销了旧会话行,旧终端却完全不受影响,安全承诺落空;(3) 管理员禁用/删除用户后已签发 token 依然可用(PermissionService 会拦权限但 AuthFilter 先放行)。

w2r.site/backend/app/Services/UserSessionService.php:100-117;未消费方 w2r.site/backend/app/Filters/AuthFilter.php:12-62、w2r.site/backend/app/Controllers/Api/ApiController.php:77-137 · 修复约 4 小时 · 来源:CMS 后端 · 服务层(w2r.site/

manualPublish/runSchedule 绕过审核状态机,草稿与在审内容可直接发布

manualPublish 只判断 status === 'published' 就拒绝,其余任何状态(draft / review_l1 / review_l2 / expired)一律置为 published;PublishController.php:35 只校验 content:publish,既不查 workflow 状态也不做栏目级 checkChannelPermission。runSchedule 的 SQL(:35-37)只筛 publish_at <= now 且 published_at IS NULL,一条挂着旧 publish_at 的 review_l1 内容会被 cron 直接推上线。两条路径都不写 workflow_actions,workflow_actions 里的审核链因此存在无法解释的断点,合规审计不可信。

w2r.site/backend/app/Services/PublishService.php:112-127(manualPublish)、:33-52(runSchedule);权限入口 w2r.site/backend/app/Controllers/Api/PublishController.php:35 · 修复约 4 小时 · 来源:CMS 后端 · 服务层(w2r.site/

富文本净化只删 script 标签,事件属性与危险协议全部放行

sanitizeHtml 的实现是 html.replace(/<script[\s\S]*?<\/script>/gi, ''),仅此一行。onerror、onload、onclick 等事件属性,javascript: 协议的 a 标签,以及 iframe、object、svg/onload 都不受影响。该函数是 onInput(第 244 行)与 getHtml(第 417 行)的唯一出口,产物写入 content_json 后由官网前台渲染。此外 handleFigmaImport 从 /figma/import 拿回的 HTML 经 insertFigmaHtml(第 393 行)直接 innerHTML 注入,连这一层正则都不过。任一具备 content:create 的账号即可在公司官网页面植入持久化脚本。

js/cms/components/RichTextEditor.vue:240 · 修复约 4 小时 · 来源:CMS 管理后台前端(w2r.site/ad

分类 ID 硬编码为自增值(category 2–8 与 60),换库即错位

NEWS_COVER_CATEGORY_ID=60 与 migrate_sitecore_files_to_dam.py:54-62 的 DEFAULT_CHANNEL_TO_CATEGORY(GUID→2..8)都是写死的整数。但 asset_categories 的行由迁移 2026-03-04-100031:47-52 遍历 channels 表逐条 insert 生成,id 是自增、随环境而变。在干净库或任何重建过的库上重跑迁移,封面会挂到错误分类甚至不存在的分类下(该列无外键,不会报错,只会静默错配)。config.sitecore.channel_to_category 只覆盖了 2–8,60 连配置兜底都没有。

w2r.site/scripts/migrate_news_covers_to_assets.py:46 · 修复约 4 小时 · 来源:w2r.site — Python 迁移脚本

docs/import_sitecore_news.md 与 scripts/README.md 描述的是已被删除的旧版脚本,与现有代码完全不符

文档写的是 --step=1..4、--clear、--dump-list-page、list_zh.json/list_en.json 缓存、LIST_SELECTORS/DETAIL_SELECTORS 选择器、直接写入 entries+entry_i18n+asset_entry_i18n。实际 scripts/import_sitecore_news.py 只接受 --languages/--language,只写临时表 sitecore_news_import,没有任何 step 概念也不碰 entries。scripts/README.md:12-70 复述同一套错误说明。writable/import_sitecore_news/newslist.json(636KB,zh 1576 条 / en 327 条,字段为 item_id/edit_url/cover_img)是旧版脚本留下的产物,证明该代版本确实存在过但源码已丢。接手者按文档操作会全部报错。

w2r.site/docs/import_sitecore_news.md:8-58 · 修复约 4 小时 · 来源:w2r.site — Python 迁移脚本

内容创建/更新/删除接口缺少 content:create / content:edit / content:delete 权限码校验

create(304) 完全没有权限检查;update(474) 与 delete(705) 只在 493/719 行做「created_by == 自己 或 sys_admin」的归属判断,没有权限码校验,也没有像 WorkflowController::submit(第 46-49 行)那样调 checkChannelPermission 做栏目级授权。对比 FaqQuestionController.php:114/222/318 明确校验了 content:create/edit/delete,说明这是遗漏而非设计。任何角色(含 audit_readonly)都能创建内容并删除自己创建的内容,栏目级 deny 策略对内容 CRUD 完全不生效。

w2r.site/backend/app/Controllers/Api/ContentController.php:304 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

requireRoles 只信 JWT 里的 role claim,不查库、不校验 is_active

requireRoles 走 getCurrentUserRole()(ApiController.php:100-116),角色取自 JWT 的 data.role;而 requirePermission 走 PermissionService::hasPermission 会查库并校验 is_active。结果是:把某人从 sys_admin 降级、或直接禁用账号后,他手里的 token 在有效期内(Config/Jwt.php expiration=7,200 秒)仍能访问所有按角色门禁的接口——审计查询/导出、系统会话配置、审计策略、SEO 配置、工作流配置、搜索热词与置顶管理。

backend/app/Controllers/Api/ApiController.php:215-227 · 修复约 3 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

创建内容全程无事务,i18n 插入失败会留下孤儿 entry

create() 依次写 entries(:348)、entry_i18n zh(:374)、entry_i18n en(:405)、asset_entry_i18n(:429/:437)、asset_refs(:446),没有任何 transStart。同文件 :327-329 的校验只检查 channel,不校验 type/title_zh/slug_zh,而 entry_i18n.title/slug 是 NOT NULL 且模型无 validationRules,MySQL 8 默认 STRICT_TRANS_TABLES 会让 i18n 插入直接失败——此时 entries 主记录已落库,产生没有任何语言内容的孤儿条目,列表页与前台都会渲染成空白行。EntryDraftService 同类流程是带事务的(:76、:129、:187),说明作者知道该怎么写,只是内容创建路径漏了。

w2r.site/backend/app/Controllers/Api/ContentController.php:348 · 修复约 3 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

MFA 密钥经 URL 发送到第三方 api.qrserver.com

把包含 TOTP 共享密钥的 otpauth:// URI urlencode 后拼进 https://api.qrserver.com/v1/create-qr-code/?data=... 并打印给运维点开。只要有人点这个链接,超管的 MFA 种子就以明文查询串形式出站到境外第三方,并留在对方访问日志里。ResetUserMfa.php:86 同一处问题。与 App.php:201 开启 CSP、ContentSecurityPolicy.php:139 connectSrc='self'「禁止外联」的安全基线自相矛盾。

backend/app/Commands/SetupSuperAdminMfa.php:111 · 修复约 3 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

api.php 所有失败路径伪装成功,故障静默为「没有数据」

数据库未配置、连接失败、查询失败三种情况都返回 {success:true,data:[]}(菜单)或 {success:true,data:{list:[],total:0}}(新闻列表),HTTP 状态恒 200。线上表现为顶栏菜单整个消失、首页 Latest Release 三张卡片空白,而监控、CDN、前端 error 分支全都看不到异常。api.php:21/62 的兜底也是 HTTP 200 + success:false。对照组 app/Controllers/Api.php:60-64 与 News.php:59-63 是正确的 500。

backend/public/api.php:142,157,166,212-216 · 修复约 3 小时 · 来源:demo.w2r.site 官网前台(fro

新闻迁移非事务:entries 先 commit,entry_i18n 失败留下孤儿 entry

entries 插入后立即 conn.commit()(421 行),随后循环插入 zh/en 的 entry_i18n(450-470 行)并各自 commit。任一语言插入失败走到 except 时 conn.rollback()(484 行)只能回滚最后一个未提交事务,已提交的 entries 行留在库里。这类孤儿 entry 没有任何 i18n 行,后台列表按 JOIN 查会直接看不见,但 slug 判重(370-376 行)也匹配不到,重跑时会再插一条新的 entries,越积越多。

w2r.site/scripts/migrate_sitecore_news_to_entries.py:421 · 修复约 3 小时 · 来源:w2r.site — Python 迁移脚本

import_sitecore_files.py 每次运行先 TRUNCATE 临时表,中途失败即丢失全部下游依赖数据

run_import 在登录 Sitecore 之前就 truncate_table(conn) 清空 sitecore_files_import。之后任何一步失败(Sitecore 登录失效、token 拿不到、网络中断、抓到一半异常)都只是 break 或 sys.exit,表已经空了。而 migrate_sitecore_files_to_dam / backfill_video_posters / sync_video_assets_status / replace_sitecore_media 四个脚本全部以这张表为唯一数据源,表空之后它们会静默打印「无数据」直接返回,看不出是灾难还是正常。该表既无导出也不在版本控制内。

w2r.site/scripts/import_sitecore_files.py:493 · 修复约 3 小时 · 来源:w2r.site — Python 迁移脚本

reset_sitecore_dam_assets.py 删除 assets 时不清理引用它的 poster_asset_id / cover_asset_id(这两列无外键)

assets 表只有 created_by 建了外键(迁移 2026-01-24-100017:80),poster_asset_id 与 entries.cover_asset_id 都是裸 BIGINT。带 --type=image 过滤执行时,会把视频封面图删掉而留下视频行里指向已不存在 id 的 poster_asset_id;不带过滤执行时,entries.cover_asset_id 同样悬空。asset_entry_i18n 因为是 CASCADE,会被静默连带删除,新闻的缩略图关联无声丢失。脚本删完只打印计数,不做任何引用检查或提示。

w2r.site/scripts/reset_sitecore_dam_assets.py:255-262 · 修复约 3 小时 · 来源:w2r.site — Python 迁移脚本

demo 的 api.php 所有失败路径都返回 success:true + 空数据,故障对前端与监控完全不可见

api_menu_response 三处:.env 读不到库名/用户名(142)、mysqli 连接失败(157)、SQL 执行失败(166) 一律 return ['success'=>true,'data'=>[]]⁠;api_news_list_response 第 211-217 行数据库不可用同样返回 success:true、list 为空、total 0。生产上数据库挂掉或口令改了,官网表现是「导航栏空白、新闻列表空」而 HTTP 200 + success:true,任何基于状态码或 success 字段的告警都不会响。排查时看不到任何线索,因为连接错误也没有 error_log。

demo.w2r.site/backend/public/api.php:142 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

MFA 重置后再次验证会因 in_array(null) 抛 TypeError,接口 500

resetMfa(第 304-308 行)把 backup_codes 置成空字符串 ''。之后 verifyMfaCode 第 221 行 json_decode(base64_decode(''), true) 得到 null,第 222 行 in_array($code, $backupCodes, true) 在 PHP 8 下抛 TypeError(haystack 必须是 array)→ /api/auth/mfa/verify 返回 500 而不是「验证码错误」。用户自助重置 MFA 后走注册流程会撞上。同一处第 217 行注释写「解密密钥」但实际只是 base64_encode(generateMfaSecret 第 111 行),MFA 种子在库里等同明文。

w2r.site/backend/app/Services/AuthService.php:221 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

AuthFilter / AuthNoMfaFilter 不校验账号是否被禁用

两个过滤器只判断能否解析出 user_id,不查 users.is_active。被禁用的账号在 token 到期前(≤2 小时)仍可访问所有「无权限码」的接口:内容全套、栏目全套、仪表盘、DAM 列表/详情/引用、搜索、FAQ 列表、预览 token 列表、SEO 与系统配置的读接口。只有走 requirePermission 的接口因 PermissionService.php:33 顺带校验了 is_active 才被挡住。

backend/app/Filters/AuthFilter.php:31-47 · 修复约 2 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

迁移 100042 硬编码 asset_categories.id 2–8 的映射,干净库上全部错位 2 个代理独立报出

DEFAULT_MAPPING 常量把 7 个 Sitecore channel GUID 映射到写死的 category_id 2,3,4,5,6,7,8(第 19-27 行)。这些 id 是原生产库当时的自增值,在任何新库上 100031 生成的「网站素材」根与栏目镜像节点会拿到完全不同的 id。迁移本身不会报错(只是往 config 表写 JSON),但后续 scripts/update_sitecore_channels_and_assets.py 与 migrate_sitecore_files_to_dam.py 读这个配置去归类素材,会把资产挂到错误分类下。是「迁移全过 ≠ 数据正确」的典型坑。

w2r.site/backend/app/Database/Migrations/2026-03-12-100042_SeedSitecoreChannelToCategoryConfig.php:17 · 修复约 2 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s、缺陷穷举与技术债(横向,覆盖 w2r.sit

/api/auth/mfa/verify 无任何节流,6 位 TOTP 可被无限爆破

LoginThrottleService 在 AuthController 中只出现在 login()(:58, :83, :104, :125, :147, :182),mfaVerify() 完全没接。verifyMfaCode 还在 :240-247 接受前后各一个时间步,单次请求命中概率 3/1,000,000;配合已通过密码但未过 MFA 的 JWT(AuthController.php:205 已经签发),攻击者可无限并发提交,数小时内可爆破成功。备用码路径(:222)同样无限尝试且只有 8 个 6 位码。

w2r.site/backend/app/Services/AuthService.php:209-250;调用方 w2r.site/backend/app/Controllers/Api/AuthController.php:453-495 · 修复约 2 小时 · 来源:CMS 后端 · 服务层(w2r.site/

audit:accept 把用户 id=2 提权为 sys_admin,再硬编码「还原」为 publisher

:124 PUT /api/users/2 {role: sys_admin},:126 再 PUT {role: publisher}。还原值是写死的 publisher,不是读回来的原值——若用户 2 原本是 editor/reviewer,跑一次命令就被永久改成 publisher;若第二次 HTTP 调用失败(超时/服务未起),用户 2 就永久停在 sys_admin。:143-150 同理对 id=3 做锁定/解锁。

backend/app/Commands/AuditAcceptance.php:124 · 修复约 2 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

dam:delete-video-mp4 批量删除资产与物理文件,无二次确认、无审计

:31 无条件捞出全部 mime='video/mp4' 的资产,:58/:71 unlink 物理文件,:85 delete 记录(并级联 asset_variants、asset_refs)。虽然支持 --dry-run(:26),但没有 CLI::prompt 确认,也没有像 audit:cleanup 那样写 job_start/job_finish 审计。参数拼错成 --dryrun 就是一次不可逆的全量删除,事后连谁执行的都查不到。

backend/app/Commands/DeleteVideoMp4Assets.php:31 · 修复约 2 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

定时发布不看内容状态,草稿/被驳回内容只要设了 publish_at 就会被自动发布,绕过两级审核

runSchedule 第 33-39 行的查询条件只有 publish_at IS NOT NULL AND publish_at <= now AND published_at IS NULL⁠,没有任何 status 过滤,第 44-48 行直接把 status 改成 published。也就是说一条 status=draft、刚被终审驳回的内容,只要 publish_at 是过去时间,下一次 cron(Commands/RunPublishSchedule.php)就会把它推上线。两级审核(workflow:review_l1 / review_l2)被完全旁路。

w2r.site/backend/app/Services/PublishService.php:33 · 修复约 2 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

迁移 100031 全用裸 ALTER 且不带存在性判断,失败后无法重放

第 15-24 行连续 6 条 $this->db->query('ALTER TABLE asset_categories ADD COLUMN/KEY/CONSTRAINT ...')⁠,没有 fieldExists/indexExists 判断(对比同项目 2026-02-09-100029_AddUuidToEntries.php:15 是有 fieldExists 保护的)。上一条的外键失败发生在第 28 行 insert,此时前 6 条 DDL 已提交但迁移未记入 migrations 表,重跑必然报 Duplicate column name 'parent_id',只能手工回滚 DDL。

w2r.site/backend/app/Database/Migrations/2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:15 · 修复约 2 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

JWT 接受从 URL 查询参数传入,token 会落进访问日志、Referer 和浏览器历史

getTokenFromRequest 第 196-199 行在 Authorization 头和 Cookie 之后还支持 ?token=⁠。任何带 token 的链接被分享、被 nginx access_log 记录、或页面里有外链导致 Referer 外泄,都会泄露一个完整有效的管理会话;配合上面「撤销不生效」,泄露后无法回收。同时 verify()(第 86-89 行) 只验签名和 exp,没有校验 iss / aud(config 里定义了但没用上),同密钥签发的其它用途 token 会被接受。

w2r.site/backend/app/Services/JwtService.php:196 · 修复约 2 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

Python 迁移脚本内置默认口令 ImportNews1! 并自动创建 editor 账号

pw = os.environ.get("IMPORT_PASSWORD") or "ImportNews1!" 同样出现在 backfill_video_posters_from_sitecore.py:109、migrate_sitecore_files_to_dam.py:123、migrate_news_covers_to_assets.py:110,共 4 处。脚本随后 INSERT 一个 role=editor、is_active=1 的账号(第 104-107 行)。只要有人不设环境变量跑过任一脚本,生产库里就有一个口令写在 Git 仓库里的可登录 editor 账号。交接第一件事应是查 users 表里 email 形如 *@import.local 的账号并禁用。

w2r.site/scripts/migrate_sitecore_news_to_entries.py:99 · 修复约 2 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

资产交付接口的 JWT 分支必定 500(把 stdClass 当数组下标)

JwtService::verify 只对顶层做 (array) 转换(JwtService.php:92),payload['data'] 仍是 stdClass。这里写 $payload['data']['user_id'],PHP 抛 Error「Cannot use object of type stdClass as array」,而 :61 与 :145 的 catch 只捕 \Exception,捕不到 Error → 直接 500。后果:access_level=auth 的分类资产,凡是用 Authorization 头(而非 session cookie)携带身份的客户端一律拿到 500,getAssetInfo(:143)同样。正确写法参考 ApiController 里的 JwtService::claimsData()。

backend/app/Controllers/AssetDeliveryController.php:59 · 修复约 1 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

audit:accept-ext 用 command('audit:cleanup 6') 真删审计日志作为「测试」

为了验证 job_start/job_finish 审计埋点,直接调用真正的清理命令,删掉 operation_logs 中 6 个月以前的全部记录。等保要求的 180 天留存会被一次「验收」抹掉一批,且不可回滚(无软删)。

backend/app/Commands/AuditAcceptanceExtended.php:237 · 修复约 1 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

audit:accept-ext 把全局审计策略硬编码还原成默认值,覆盖运维配置

:196 先把 audit.query_rate_threshold 改成 '3',:220 无论原值是多少一律写回 '50'。:189-194 把 auth.work_hours 改成「只有周末算工作日」,:204-219 的还原逻辑在原值为空时写入硬编码的周一至周五 09:00-18:00。运维在后台调过的阈值和工作时间会被静默覆盖,且改动期间(命令执行的几十秒)全站的非工作时间告警判定是错的。

backend/app/Commands/AuditAcceptanceExtended.php:220 · 修复约 1 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

未启用 secureheaders 过滤器,CSP 也未设 frame-ancestors,无点击劫持防护

Filters.php:35 定义了 secureheaders 别名,但 $globals['after'] 里它被注释掉(:87),$filters(:115)也为空,即该过滤器全局从未生效——X-Frame-Options、X-Content-Type-Options、Referrer-Policy 等响应头一个都没有。同时 ContentSecurityPolicy.php:164 的 $frameAncestors 为 null,CI4 不会输出 frame-ancestors 指令。结果是 /AdM 管理后台可被任意站点 iframe 嵌套,配合诱导点击可执行提权、删除等操作。

backend/app/Config/Filters.php:87 · 修复约 1 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

新闻详情页的返回箭头与下载图标指向 Figma MCP 外链,生产必然裂图

https://www.figma.com/api/mcp/asset/... 是 Figma MCP 会话态资源地址,未登录/过期即 403。每篇新闻详情页顶部面包屑箭头、底部返回箭头、下载按钮图标三处必然显示破图;同一批常量还被 data/mockNewsArticle.js 引用(9 张正文图)。需导出为本地 SVG 放进 src/assets/images/。

frontend/src/assets/figma/mcp-assets.js:59-62(被 frontend/src/components/news/NewsArticleContent.vue:13,43,56 引用) · 修复约 1 小时 · 来源:demo.w2r.site 官网前台(fro

public/test-putenv.php 公网可达,且文件中段的 namespace 声明是 PHP 致命错误

该文件在第 44 行写了 namespace TestNamespace;⁠,而 PHP 要求 namespace 必须是文件首条语句,整个文件是编译期 Fatal error。它位于文档根目录、被 git 跟踪,.htaccess 的 RewriteCond %{REQUEST_FILENAME} !-f 会让 /test-putenv.php 直接命中。设计意图是向匿名访问者输出 PHP 版本、SAPI、disable_functions 清单(:28-40、:72-74)——即便当前因语法错误输出不全,也应立即删除。

backend/public/test-putenv.php:44 · 修复约 0.5 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

交接件里的 .env 是 CI_ENVIRONMENT = development 2 个代理独立报出

development 环境下 app/Config/Boot/development.php:14 打开 display_errors、:34 令 CI_DEBUG=true,任何未捕获异常都会渲染带完整堆栈与配置的调试页(含 Database 连接信息);Security.php:85 的 CSRF 失败重定向也随之关闭;Events.php:60 会额外注册 __hot-reload 路由。仓库里没有任何生产用 .env 或填好的模板(env 文件 69 行全是注释),接手方无从判断线上真实取值,误把这份 .env 带上生产即等于开着调试模式对外服务。

backend/.env:5 · 修复约 0.5 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r、demo.w2r.site 官网前台(fro

审计日志查询的筛选条件与分页参数被全部丢弃

query(params) 实际调用 api.get('/audit/query', { params }),多包了一层。api/index.js:124 用 URLSearchParams 序列化后请求变成 ?params=%5Bobject+Object%5D。后端 AuditController.php:32-46 逐个读取 start_time/end_time/module/operation/actor_id 与 page/limit,全部读到 null,于是筛选条件静默失效、页码恒为 1、每页恒为 50。用户点第 2 页、改每页条数、按时间或操作人过滤,界面都毫无反应且不报错。更危险的是导出走 auditApi.export → api.getBlob(url, params),参数传递是正确的,因此导出的 CSV 会按筛选条件过滤,与屏幕上未过滤的表格内容对不上。合规审计场景下这是取证结论错误的来源。

js/api/audit.js:5 · 修复约 0.5 小时 · 来源:CMS 管理后台前端(w2r.site/ad
中低级(56 + 26 项)

全站零响应式,顶栏用绝对定位固定像素

1440px 以下视口顶栏导航、语言按钮、搜索按钮会与 logo 重叠或被裁切;品牌网格写死 379px×3(BrandLogoGridSection.vue:40)、新闻正文写死 790px(styles/news-detail.scss:6)。AGENTS.md:32-35 把响应式列为「desktop 之后再做」,实际没做。手机访问基本不可用。

frontend/src/components/home/HeaderNav.vue:342,374,399(left:600px / 1089px / 1200px);全站仅 BrandLogoCard.vue:98 一条 @media 且是 prefers-reduced-motion · 修复约 40 小时 · 来源:demo.w2r.site 官网前台(fro

没有任何项目自有测试

api.php 与控制器两套实现要保持行为一致,却没有一条断言来锁住契约(字段名、日期格式 Y.m.d 对 Y-m-d、href 前缀规则)。任何一侧改动都只能靠人肉 curl 比对。

backend/tests/unit/HealthTest.php、tests/database/ExampleDatabaseTest.php、tests/session/ExampleSessionTest.php(均为 CI4 starter 原样样例);frontend 无测试目录 · 修复约 16 小时 · 来源:demo.w2r.site 官网前台(fro

8 张表没有 Model,读写靠散落各处的 $db->table() 裸调用

roles / permissions / role_permissions / channel_role_permissions / user_sessions / audit_rate_buckets / asset_entry_i18n / sitecore_*_import 都无模型。仅 asset_entry_i18n 就在 EntryDraftService.php:277/283/399/403/409 与 ContentController.php:429/437/657/659/669/671 六处各写一遍插入逻辑,字段名与默认值靠复制粘贴保持一致。改这些表的结构必须全仓 grep,漏一处就是运行时 SQL 错误。

w2r.site/backend/app/Services/UserSessionService.php:45 · 修复约 8 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

role_permissions.scope 字段被存储和编辑,但权限判定完全不读它

hasPermission()(:40-49)与 getPermissionCodes()(:101-108)只 join roles/permissions 判断有无,从不读 scope。而 RoleController.php:199-206 允许管理员为每条授权设置 own/all,ConfigPromotionService.php:120 还会把它导出到配置包。实际的「只能改自己的内容」是散落在业务代码里硬编码的(如 EntryDraftService.php:59、:176 的 created_by != userId && role !== 'sys_admin')。管理员在界面上把 scope 从 own 改成 all 不会有任何效果,属于会误导运维的假开关。

w2r.site/backend/app/Services/PermissionService.php:40 · 修复约 6 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

25 个 Model 的 $validationRules 全为空,约束只靠数据库

全部模型都是 $validationRules = [] / skipValidation = false 的空壳(EntryI18nModel.php:43、ChannelModel.php:44 等同)。ENUM 取值、NOT NULL、长度、外键存在性全部下沉到 MySQL,而 app/Config/Database.php:42 设了 strictOn=false(不主动加 STRICT_ALL_TABLES),行为完全取决于服务器 sql_mode——换一台非严格模式的 MySQL,超长标题会被截断、非法 ENUM 会变成空串且不报错。

w2r.site/backend/app/Models/EntryModel.php:67 · 修复约 6 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

接口已返回但前端完全丢弃的字段:prev / next / cover_asset_id / seo

详情页没有上一篇/下一篇、没有封面大图、没有 SEO title/description 注入(index.html:6 标题恒为 Volkswagen Group China)。后端为此付出的全量邻居查询是纯浪费。属于「后端做完了、前端没接」的半成品。

backend/public/api.php:474-481 产出;frontend/src/lib/news/adaptNewsDetailResponse.js:35-51 只取其中一部分,coverAssetId 取了却没有任何模板消费 · 修复约 6 小时 · 来源:demo.w2r.site 官网前台(fro

大量占位死链与无功能控件

页脚 FAQ/Help/Sitemap/Privacy Policy/Legal Statement 全部无效;三个 Read More 无效;View All 无效;语言切换按钮没有任何点击逻辑,/en/ 只能手输 URL 到达;搜索按钮同理。对外发布前这些都要么接上要么隐藏。

frontend/src/components/home/SiteFooter.vue:43-49(5 条 href:'#')、FeatureCtaButton.vue:2(3 处 Read More)、LatestReleaseSection.vue:7(View All)、HeaderNav.vue:34-45(语言、搜索按钮无 handler) · 修复约 6 小时 · 来源:demo.w2r.site 官网前台(fro

DAM 合并写文件与写库非事务,失败留孤儿文件或孤儿会话

merge() 先 fopen/fwrite 出最终文件(:204-245),再 assetModel->insert(:299),再 sessionModel->update 标记 merged(:308)。全程无事务:insert 失败时 :301-303 直接 return,已落盘的合并文件(可达 5GB,Config/Dam.php:11)留在 writable/uploads/dam 无人回收;update 失败则 assets 已建但会话仍是 uploading,前端重试会再合一份。注释 :314 也承认分片目录「留给 cron 清理」,但仓库里没有对应的清理 Command(app/Commands 下只有 CleanAuditLogs / CleanApplicationLogs)。

w2r.site/backend/app/Services/DamUploadService.php:204-312 · 修复约 4 小时 · 来源:CMS 后端 · 服务层(w2r.site/

内容下线功能有 API 封装但无任何界面入口

publishApi.unpublish 已按后端 Routes.php:126 的 publish/unpublish 封装完毕,但全仓库没有任何调用方。Content.vue 与 News.vue 的操作列只有预览、发布、删除修订、编辑、删除五个按钮,没有「下线」。结果是 content:unpublish 权限无法行使,已发布内容一旦要撤下只能走删除(不可逆)或直接改数据库。状态映射表里的 unpublished(Content.vue:971)只有筛选和展示的意义,界面无法产生该状态。同类未被调用的封装还有 damApi.getAssetRefs、authApi.mfaReset、previewApi.listTokens、channelApi.getDetail。

js/api/publish.js:11 · 修复约 4 小时 · 来源:CMS 管理后台前端(w2r.site/ad

资产交付把整个文件读进 PHP 内存,无 Range / ETag / 流式输出

file_get_contents 一次性载入后 echo。DAM 里若有几十 MB 的 PDF 或视频,单请求即撞 memory_limit 返回 500;并发几个就打满 PHP-FPM 内存。同时不支持断点续传与条件请求,Cache-Control 写死 86400(:83),资产替换后一天内不更新。

backend/public/demo_asset_delivery.inc.php:75-86 · 修复约 4 小时 · 来源:demo.w2r.site 官网前台(fro

资产交付把整个文件读进内存再输出,5GB 视频会打爆 PHP 内存

第 103 行 setBody(file_get_contents($filePath))⁠,demo 侧 demo_asset_delivery.inc.php:75 同样是 file_get_contents。而 Config/Dam.php:12 允许单文件到 5GB、type 里明确包含 video/mp4。几个并发的视频请求即可耗尽 memory_limit 触发 500,或把 PHP-FPM 打满。也不支持 Range 请求,视频无法拖动进度条。应改 readfile/流式输出并支持 206。另外第 94 行 WRITEPATH . ltrim($storageKey,'/') 没有 realpath 越界校验(demo 侧第 68-73 行反而做了),storage_key 被污染即可读任意文件。

w2r.site/backend/app/Controllers/AssetDeliveryController.php:103 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

多处 catch 后完全静默,认证失败与配置错误查不到任何日志

AssetDeliveryController.php:61-62 与 145-147 两处 catch (\Exception $e) {} 空体吞掉 JWT 验证异常;ApiController.php:38-41 与 AuthController.php:42-45 把 JwtService 构造失败吞成 $jwtService=null(随后 AuthController::login 第 205 行、mfaVerify 第 510 行直接 $this->jwtService->generate()⁠,null 会致命错误,报出来的是「Call to a member function on null」而不是真实的配置问题);ApiController.php:240-242 JSON 解析失败静默返回 [](请求体格式错会表现为「所有字段都没传」);SearchService.php:205-207 全文索引探测失败静默降级成 LIKE。全部应至少 log_message('error', ...)。

w2r.site/backend/app/Controllers/AssetDeliveryController.php:61 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

多个只读接口未做权限校验,任意登录角色可读全站内容、资产、系统与 SEO 配置

DamController::assets(219)、assetShow(292)、assetRefs(319) 只判断是否登录,不校验 dam 相关权限;ContentController::index(40)、show(130) 同样;DashboardController::stats(16)、SeoConfigController::show(25)/schemaPreview(77)、SystemConfigController::sessionConfig(28)/auditPolicy(118)、EntryRelationController::list(25)、FaqPageController::questions(25) 都无校验。与同文件里写接口清楚标注了权限码形成对比。最小权限原则未落实,越权读取全站草稿、未发布内容与素材元数据。

w2r.site/backend/app/Controllers/Api/DamController.php:219 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

14 个 spark 命令中 9 个在代码库与文档里零引用,含一次性运维脚本和默认用户名

CleanAuditLogs、RunPublishSchedule、VerifyRolePermission、CleanApplicationLogs、DeleteVideoMp4Assets、AuditAcceptanceExtended、SetupSuperAdminMfa、ResetUserPassword、QueryUserActivity 在 app/ 和 docs/ 里搜不到任何引用(RunPublishSchedule 是定时发布的唯一入口却没有任何 crontab 文档,说明定时发布可能根本没在跑)。PublishPagesAsUser 第 31 行把用户名默认成 'test',RevertPagesToDraft 第 74-80 行绕过工作流批量把已发布内容改回 draft 并清空 published_at。这些一次性数据修复脚本混在正式代码里,接手者无法分辨哪些还能跑、哪些跑了会毁数据。

w2r.site/backend/app/Commands/PublishPagesAsUser.php:31 · 修复约 4 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

响应封装不覆盖框架层错误,前端拿到的不是 JSON envelope

fail() 恒定返回 HTTP 400 且业务码放在 body.code;但过滤器返 401/1001 与 403/1002(AuthFilter.php:39-58),两套语义并存。更麻烦的是路由未命中(Config/Routing.php:87 override404=null)与未捕获异常走的是 CI4 的 HTML 错误页,而 admin-frontend/js/api/index.js:88 只按 data.code 判定 → 前端会以 JSON 解析失败的形式炸掉,看不到真实原因。

backend/app/Controllers/Api/ApiController.php:60-70 · 修复约 3 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

响应 trace_id 与审计 trace_id 各自随机生成,无法关联排障

success()/fail() 每次用 bin2hex(random_bytes(8)) 现造一个 trace_id(:53、:68),AuditLogger.php:47 又独立造一个 16 字节的写进 operation_logs.trace_id。用户报障时提供的 trace_id 在审计表里永远查不到,等于这个字段白记。

backend/app/Controllers/Api/ApiController.php:53 · 修复约 3 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

entry_i18n 缺 (lang, slug) 唯一约束,slug 可重复

slug 只有普通索引,唯一键只有 (entry_id, lang)。应用层也不查重(ContentController.php:384、:415 直接写入,:643 甚至用 zh slug 拼 '-en' 兜底)。两条内容取同一个 slug 时,前台按 slug 解析路由会随机命中一条,且没有任何告警。

w2r.site/backend/app/Database/Migrations/2025-01-19-100006_CreateEntryI18nTable.php:77 · 修复约 3 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

RolePermissionSeeder 先 DELETE 全表再重插,线上重跑会重编 id 并清空栏目级授权

:14-25 关闭外键检查后 DELETE channel_role_permissions / role_permissions / permissions / roles 并把 AUTO_INCREMENT 归 1。已有库上重跑一次,栏目级 allow/deny 配置全部丢失,roles/permissions 的 id 重新分配。虽然 PermissionService 按 code 关联、影响可控,但任何外部按 id 引用的配置包(ConfigPromotionService)都会错位。它同时又是新库必须手动执行的一步,没有幂等版本。

w2r.site/backend/app/Database/Seeds/RolePermissionSeeder.php:14 · 修复约 3 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

登录节流只按 IP 且计数非原子 2 个代理独立报出

AuthController::login 第 58 行 checkLock($ip)、147 行 recordFailure($ip) 全部以 IP 为键。后果双向:一是公司出口 NAT 后一人输错密码会把整个办公室锁在门外;二是分布式撞库换 IP 即可绕过,针对单一管理员账号的爆破无任何限制。应同时按 username 计数并对锁定做审计告警。

w2r.site/backend/app/Services/LoginThrottleService.php:96-101 · 修复约 3 小时 · 来源:CMS 后端 · 服务层(w2r.site/、缺陷穷举与技术债(横向,覆盖 w2r.sit

新闻管理缺少内容管理已有的预览与删除修订按钮

News.vue 与 Content.vue 是同一套内容模型的两个视图(News 固定 type=news,Content 用 exclude_type=news 排除新闻)。Content.vue:87-121 的操作列有预览、发布、删除修订、编辑、删除五个按钮,News.vue:74-91 只有发布、编辑、删除三个。News.vue 既没 import useContentDeleteDraft 也没 import previewApi。后果是:新闻走 resolveEditEntryId 生成修订稿后(News.vue:881),若想放弃该修订稿,界面上没有任何入口删除,只能带着这个未发布修订稿一直挂着;新闻也无法生成预览链接。两个文件有大量重复逻辑(normalizeContentJson、getChannelNames、getEntryId、formatDateTime 等逐字重复),修一处必须记得同步另一处。

js/views/News.vue:73 · 修复约 3 小时 · 来源:CMS 管理后台前端(w2r.site/ad

缺少自助修改密码功能,后端接口无前端调用

后端 Routes.php:37 提供 POST auth/change-password,authApi 里没有对应封装,界面上也没有入口。普通用户想改自己的密码,只能找系统管理员用 Users.vue 的「重置密码」代改——而该流程要求管理员输入自己的密码和 MFA 码,并且新密码由管理员设定后口头转告,明文经手第三人。对一个带 MFA 和审计的合规系统而言这是个显眼的安全流程缺口。

js/api/auth.js:3 · 修复约 3 小时 · 来源:CMS 管理后台前端(w2r.site/ad

新闻上下篇查询把整个栏目的已发布文章全量拉回 PHP 再遍历

SQL 无 LIMIT,用 JSON_CONTAINS(e.channel_id,...) 无法走索引,随后在 PHP 里 array_search 定位当前文章。栏目文章上千篇后,每次打开详情页都要全表扫描 + 全量传输。而且拿到的 prev/next 前端根本没用(见下一条)。

backend/public/api.php:628-641;同源实现 backend/app/Controllers/News.php:264-271 · 修复约 3 小时 · 来源:demo.w2r.site 官网前台(fro

内容创建的必填校验与错误文案不符,type / 标题 / slug 实际从不校验

第 327-329 行只判断 channelIdArr 是否为空,错误信息却写「类型、栏目、中文标题和slug不能为空」。$type(312)、$titleZh(315)、$slugZh(317) 三个变量取到后从未校验就直接入库。entries.type 是 VARCHAR(50) NOT NULL 无枚举约束(2025-01-19-100005_CreateEntriesTable.php:18-22),传任意字符串都会落库,产生列表页、前台路由都识别不了的「幽灵类型」内容;type 传 null 则触发 NOT NULL 报错,用户看到的是笼统的「创建失败」(3004)。slug 无格式校验、无同栏目唯一性校验,前台按 slug 取文章会取到多条中的第一条。

w2r.site/backend/app/Controllers/Api/ContentController.php:327 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

栏目级授权 checkChannelPermission 为 fail-open:查不到配置即放行

第 214-216 行「无任何 channel_role_permissions 记录 → return true」,第 228 行「有记录但既非 deny 也非 allow → return true」,第 199 行还对 sys_admin 直接短路放行。而 PermissionService.php:38 的注释明确写「不做 sys_admin 特例」——两层授权模型策略相反,接手者很容易按其中一层的心智模型改另一层。channel_role_permissions 表被误清空时,栏目级限制会静默全部失效且无任何告警。

w2r.site/backend/app/Services/WorkflowService.php:214 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

admin-frontend 构建产物直接写进后端 public 目录且 emptyOutDir=true

outDir 指向 ../backend/public/AdM⁠,并开了 emptyOutDir,构建会先清空后端文档根下的这个目录;package.json 的 build 脚本还要额外 rm -f ../backend/public/AdM/index.php ../backend/public/AdM/.htaccess 来善后。前后端仓库物理耦合,前端构建失败或路径配错就会直接影响线上后台可用性,也无法把前端产物纳入独立的发布/回滚流程。

w2r.site/admin-frontend/vite.config.js:12 · 修复约 3 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

FaqPage 与 EntryRelation 的写接口无权限码,仅靠 created_by 判定

updateQuestions 只检查登录、entry 类型为 faq_page、status=draft、created_by==self||sys_admin,没有 content:edit。EntryRelationController.php:61 replace 同样。读接口 FaqPageController.php:25 与 EntryRelationController.php:25 连 owner 都不判,任意登录用户可枚举任意 entry 的内链关系。

backend/app/Controllers/Api/FaqPageController.php:62 · 修复约 2 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

JWT 可从 ?token= 查询串与 jwt_token Cookie 取值,且 CSRF 全局关闭

getTokenFromRequest 依次尝试 Authorization 头、jwt_token Cookie、?token= 查询参数。查询串携带令牌会进 Nginx/Apache access log 与 Referer;Cookie 路径配合 Config/Filters.php:80-84 里被注释掉的 csrf/invalidchars 全局过滤器,构成可被 CSRF 利用的写接口面(当前前端用 localStorage + Bearer 头,属于「暂时没踩到」而非「不存在」)。

backend/app/Services/JwtService.php:196-199 · 修复约 2 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

两张 Sitecore 导入表在迁移与 Python 脚本里各定义一次,collation 不一致

scripts/import_sitecore_files.py:321 与 scripts/import_sitecore_news.py:306 用 CREATE TABLE IF NOT EXISTS 各自建了一份同名表,且显式指定 utf8mb4_unicode_ci,而迁移建表走 Database.php:38 的 utf8mb4_general_ci。先跑脚本再跑迁移,表按脚本版本存在、迁移不报错也不修正,两条路径产出的排序规则不同,与 assets 表 JOIN 时可能触发 illegal mix of collations。

w2r.site/backend/app/Database/Migrations/2026-03-11-100034_CreateSitecoreFilesImportTable.php:193 · 修复约 2 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

搜索热词/置顶控制器把字符串与 null 写入 datetime cast 字段(与工作流同类缺陷)

SearchHotwordModel / SearchPinModel 把 start_at、end_at、created_at、updated_at 全部声明为不可空的 'datetime' cast(Models/SearchHotwordModel.php:30-38、Models/SearchPinModel.php:30-38,且 useTimestamps=false)。控制器把 $this->now() 的字符串和 request 里的原始 start_at/end_at 直接塞进 model->insert():给了值就撞 DatetimeCast::set 的 Time 类型断言,不给值就撞 DataCaster.php:152-156 的「Field 不可空却传了 null」。结论:热词与置顶的新建/编辑接口在任何入参下都必炸。虽在 Services 之外,但根因与工作流缺陷完全一致,建议同批修。

w2r.site/backend/app/Controllers/Api/SearchHotwordController.php:63-68 与 :114;w2r.site/backend/app/Controllers/Api/SearchPinController.php:72-76 与 :122 · 修复约 2 小时 · 来源:CMS 后端 · 服务层(w2r.site/

Jwt.php 的 .env 手工回退永不命中,且开发环境每请求随机生成密钥

:62 用 strpos($line, 'JWT_SECRET=') === 0 匹配,而实际 .env:37 写作 JWT_SECRET = ...(等号前有空格),回退分支恒不命中,是死代码;同时 :63 的 substr($line, 11) 不去引号、不剥行尾注释,一旦有人按它的假设改 .env 反而会把引号并进密钥。:71-73 在 development 下用 bin2hex(random_bytes(16)) 现编密钥——每个 PHP 请求一个新密钥,上一个请求签发的 token 下一个请求就验不过,本地会表现为「登录后随机掉线」,极难定位。正确路径只有 :54 的 $_ENV 一条。

backend/app/Config/Jwt.php:62 · 修复约 2 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

用户管理的「注册MFA」按钮不发任何请求,且引导链接指向管理员自己

handleEnrollMfa 只做了 currentMfaUser.value = row、mfaEnrollStep.value = 0、mfaEnrollDialogVisible.value = true 三件事,弹出的是一个纯图文步骤说明,全程零 API 调用。弹窗底部第 244 行的「打开MFA注册页面」调用 openMfaEnrollPage → window.open('/AdM/mfa-enroll'),而该页面读取的是当前登录会话,也就是管理员自己的 MFA 状态。管理员为用户 X 点这个按钮,打开的是自己的 MFA 注册流程,若继续操作会改动自己的密钥。这是本次 @click 审计中唯一一个既不发请求、又会产生错误后果的可达按钮。

js/views/Users.vue:588 · 修复约 2 小时 · 来源:CMS 管理后台前端(w2r.site/ad

可视化页面搭建器整套子系统为死代码,约 1890 行

VisualEditor.vue(464) 及其依赖 ComponentProperties.vue(214)、ComponentNode.vue(126)、ComponentPreview.vue(356)、registry.ts(171)、components.ts(559) 合计 1,890 行,占前端代码约 14%。全仓库检索 VisualEditor 只匹配到它自己的 class 名,没有任何视图或路由引用,构建产物 backend/public/AdM/assets 下也确实没有对应 chunk(已被 tree-shaking 剔除)。main.js:30 仍 import './cms/components/components' 做副作用注册,把 components.ts 与 registry.ts 打进了主包却无人消费。VisualEditor.vue:270 的 handleSave 只 emit('save') 并弹出「已保存」提示,没有任何监听方,是典型的假成功按钮。接手者读代码时极易误以为系统具备可视化搭建能力。

js/cms/components/VisualEditor.vue:1 · 修复约 2 小时 · 来源:CMS 管理后台前端(w2r.site/ad

首屏视频 20MB 且无 webm,dist 里还残留 12MB 旧版本

frontend/docs/changelog.md:3-4 记录 2026-03-23 把视频从 public/video 改为 src/assets 走 Vite,顺带换成了 20MB 的 mp4 并丢掉了 webm。结果 dist 总体积 33MB,其中 20MB 是实际播放的 mp4,另外 12MB 是没人引用的旧 mp4+webm。移动网络首屏体验很差。

frontend/src/assets/video/home.mp4(20M,由 HeroBanner.vue:51,30 引用);frontend/dist/video/home.mp4 8.5M + home.webm 3.6M · 修复约 2 小时 · 来源:demo.w2r.site 官网前台(fro

scripts/README.md 与 docs 引用 5 个不存在的脚本,另有 2 份 workspace 级文档缺失

README 详细描述了 import_vgc_news.py(181-248 行)、import_mediacenter_news.py(252-283)、md_to_docx.py(293-311)、gen_interface_docs.py(315-343),docs/import_sitecore_files.md:86 引用 stats_sitecore_files_import.py,五个文件在 scripts/ 下都不存在(__pycache__ 里只残留 import_vgc_news.cpython-312.pyc)。scripts/requirements-import_vgc_news.txt 是为其中一个已消失脚本准备的依赖清单。docs/cms/figma-import.md:5 与 video-delivery-and-transcoding.md:4 引用 ../../../docs/figma-designer-checklist.md 与 demo-video-media-delivery.md,checklist-implementation.md:3 引用 docs/checklist.xlsx 与 checklist-mapping.csv,均不在交接包内。

w2r.site/scripts/README.md:181 · 修复约 2 小时 · 来源:w2r.site — Python 迁移脚本

migrate_sitecore_files_to_dam.py 先落盘再写库,INSERT 失败留下无记录的孤儿文件

第 738-739 行把下载内容写入 backend/writable/uploads/dam/YYYY/MM/ 之后,才在 745-752 行 INSERT assets。若 INSERT 抛异常(如 sitecore_id UNIQUE 冲突、字段超长),异常被 _worker_migrate_one:824-828 或 run_migrate:930-934 捕获后仅写失败日志,磁盘上的文件不会被删除,也没有任何数据库记录指向它。视频动辄数百 MB,多轮重试会持续占用磁盘且无法通过 reset_sitecore_dam_assets.py 清理(该脚本只按数据库记录的 storage_key 删文件)。

w2r.site/scripts/migrate_sitecore_files_to_dam.py:738 · 修复约 2 小时 · 来源:w2r.site — Python 迁移脚本

配置类读接口无门禁:SEO 配置、会话配置、审计策略只要登录就能读

SeoConfigController::show(:25)与 schemaPreview(:77)无任何校验,update(:42)才要求 sys_admin;SystemConfigController::sessionConfig(:28)与 auditPolicy(:118)同样只需登录,其 update 才限 sys_admin。读写门禁不对称,低权限账号可摸清审计策略开关(哪些行为不记库)与会话超时配置,为后续绕过做侦察。

backend/app/Controllers/Api/SeoConfigController.php:25 · 修复约 1 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

entries.uuid 的唯一性只依赖 addColumn 的 unique 属性,未见独立唯一索引

uuid 是对外主标识(EntryModel::resolveId 按 uuid 查、前台路由用它),但唯一性只写在 forge->addColumn 的 'unique' => true 里,CI4 的 ALTER TABLE ADD COLUMN 路径不保证把该属性翻译成索引,51 个迁移里也没有任何 CREATE UNIQUE INDEX。若索引实际缺失,导入脚本传入重复 uuid 时 resolveId 会随机命中一条。接手第一件事应在库上 SHOW INDEX FROM entries 核实。

w2r.site/backend/app/Database/Migrations/2026-02-09-100029_AddUuidToEntries.php:21 · 修复约 1 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

AssetCategorySyncService 运行时同样硬编码 created_by=1 2 个代理独立报出

asset_categories.created_by 在 Database/Migrations/2026-01-27-100024_CreateAssetCategoriesTable.php:64 上带 FOREIGN KEY ... ON DELETE RESTRICT。新建栏目时本服务用写死的 1 作为创建人,一旦 id=1 的账号不存在(干净库、或按新规范建的超管不是 1)就违反外键,「网站素材」下的栏目镜像分类静默创建失败;即使 id=1 存在,归属人也被记成了错的人。这与已知缺陷(迁移 2026-03-04-100031 硬编码 created_by=1)是同一根因在运行时的复现。

w2r.site/backend/app/Services/AssetCategorySyncService.php:52 · 修复约 1 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s、CMS 后端 · 服务层(w2r.site/

JWT 密钥缺失时每请求生成随机密钥,token 立即失效;且引用了不存在的类

未设置 JWT_SECRET 时,Config/Jwt.php:75 在 development 环境用 'dev-secret-key-change-in-production-' . bin2hex(random_bytes(16)) 生成密钥,JwtService.php:35 在其它非 production 环境再生成一次;两处都是「每次实例化都不同」,于是签发的 token 下一个请求就验不过,表现为登录后立刻掉线,而错误被 JwtService::verify 的 catch-all(:93-96)吞掉,无任何日志。另外 :5 的 use App\Config\Jwt 指向不存在的类(真实命名空间是 Config\Jwt,见 Config/Jwt.php:3),只因未被实际使用才没报错,是留给接手者的陷阱。

w2r.site/backend/app/Services/JwtService.php:30-36 与 :5;配合 w2r.site/backend/app/Config/Jwt.php:73-76 · 修复约 1 小时 · 来源:CMS 后端 · 服务层(w2r.site/

backend/scripts 两个脚本误用 Boot::bootSpark 做引导,会先把 entry_id 当 spark 命令执行

system/Boot.php 的 bootSpark() 末尾是 initializeConsole() + runCommand($console),即引导完成后立刻拿 $argv 去派发 CLI 命令。脚本把 entry_id 放在 $argv[1],于是每次运行都会先打印一次「命令未找到」的错误再往下走。reimport_entry_figma_content.php:26 同样。两个脚本还都绕过 Model 和审计直写 entry_i18n.content_json(:83-88),user_id 默认 1(:30),属于「一次性脚本长期留在仓库里」的典型隐患。

backend/scripts/refresh_entry_figma_css.php:27 · 修复约 1 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

user:reset-password 把明文新密码放在命令行参数

新密码作为 $params[1] 传入,会出现在 shell history、ps 输出、以及堡垒机/审计终端的会话录像里。该命令同时没有写任何 AuditLogger 记录(对比 CleanAuditLogs.php:34 是有 job_start 埋点的),改完谁都不知道。admin:create-super(CreateSuperAdmin.php:44)更直接把自动生成的密码 echo 到 stdout。

backend/app/Commands/ResetUserPassword.php:26 · 修复约 1 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

栏目删除是唯一没有二次确认与 confirm_token 的删除操作

内容删除(Content.vue:1284)、新闻删除(News.vue:1001)、资产删除(Media.vue:653)、FAQ 题目与主题删除(FaqQuestions.vue:402、499)、用户删除(Users.vue:535)都走「确认弹窗 → 输入密码 → generateConfirmToken → 携带 token 调用接口」的四步流程。handleDelete 对栏目只做了一次 ElMessageBox.confirm 就直接 channelApi.delete(data.id),既不要密码也不带 confirm_token。栏目是内容的组织骨架,误删会导致其下内容全部失去归属并影响官网导航。同一页面的「栏目授权」反而要求密码加 MFA,安全强度分布倒挂。

js/views/Channel.vue:398 · 修复约 1 小时 · 来源:CMS 管理后台前端(w2r.site/ad

shared/dam-writable 是指向生产 CMS 可写目录的符号链接,本机悬空

本机 /www 不存在,符号链接悬空,所有 /api/asset/{id} 走到 demo_asset_delivery.inc.php:61-66 返回 404 'File missing',本地无法验证任何新闻封面。反向风险更大:该链接直通生产 CMS 的媒体目录,任何在 demo 目录下跟随符号链接的 rsync/rm/权限批处理都会直接改动 w2r.site 的 DAM 原始文件。

demo.w2r.site/shared/dam-writable -> /www/wwwroot/w2r.site/backend/writable;配置见 backend/.env:24 · 修复约 1 小时 · 来源:demo.w2r.site 官网前台(fro

随包 .venv 是 Linux 虚拟环境且缺 playwright,三个抓取脚本开箱即不可运行

site-packages 内含 x86_64-linux-gnu 的 .so,是从生产服务器整体拷来的,在 macOS 上无法使用。且只装了 pymysql/bcrypt/requests/certifi/urllib3,没有 playwright,而 import_sitecore_files.py:49、import_sitecore_news.py:45、update_sitecore_channels_and_assets.py:47 三处都在 import 阶段硬依赖 playwright.sync_api 并 sys.exit(1)。gen_diagram_images.py 依赖 Pillow、docs/cms/gen_quickcheck_xlsx.py 依赖 openpyxl,两者也都没装。

w2r.site/.venv/lib/python3.12/site-packages · 修复约 1 小时 · 来源:w2r.site — Python 迁移脚本

--skip-existing 的预取集合被穿透但从未使用,already_migrated() 是死函数

run_migrate:894 用 fetch_existing_sitecore_ids 一次性拉全部已迁移 sitecore_id,逐层传进 migrate_one(641 行形参),但函数体内从未读取它——668 行仍然对每条记录单独执行 SELECT ... FROM assets WHERE sitecore_id=%s。为此写的 already_migrated()(589 行)在整个文件里没有任何调用点。结果是几千条记录多出几千次往返查询,且多线程模式下每条记录还各开一条新连接(_worker_migrate_one:814)。这也是 migrate_video.log 里 197 条视频跑了 663 分钟、速率显示 0.0/s 的部分原因。

w2r.site/scripts/migrate_sitecore_files_to_dam.py:641 · 修复约 1 小时 · 来源:w2r.site — Python 迁移脚本

migrate_news_covers_to_assets.py 的 --workers=N 是假参数,实际串行执行

442 行做了 workers = max(1, min(workers, 8)) 的钳制并在 443 行打印出来,444 行还写进会话日志,但 450-479 行是一个普通 for 循环,没有任何线程池。docs/migrate_news_covers_to_assets.md:46 注明了「预留参数,当前为顺序执行」,而 scripts/README.md:91 把它与 --limit/--dry-run 并列为正常参数,两份文档自相矛盾。接手者按 README 传 --workers=8 会以为在并发,实际耗时是预期的 8 倍。

w2r.site/scripts/migrate_news_covers_to_assets.py:442 · 修复约 1 小时 · 来源:w2r.site — Python 迁移脚本

发布修订稿时删除草稿 entry 却不清理其 asset_refs,留下孤儿引用

publishDraftOverOriginal 第 152 行 $this->entryModel->delete($draftEntryId) 之后没有清理 asset_refs。而同类逻辑 deleteEditDraft 第 190 行明确写了 $db->table('asset_refs')->where('object_type','entry')->where('object_id',$draftId)->delete()⁠,ContentController::delete 第 740 行也清了(注释还说明 object_id 不设外键需手动清)。修订稿走「发布」路径就漏了,asset_refs 里堆积指向已删 entry 的行,导致资产被误判为仍被引用而无法删除。

w2r.site/backend/app/Services/EntryDraftService.php:152 · 修复约 1 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

demo 全部 API 响应无条件带 Access-Control-Allow-Origin: *

第 19 行对所有 /api/* JSON 响应硬编码通配 CORS。当前返回的是公开内容问题不大,但一旦这个入口以后加了需要鉴权的端点(它已经和 w2r 后台共用同一个数据库),通配 CORS 会直接把数据暴露给任意站点。同时路由匹配用的是前缀正则(第 24 行 #^/api/menu#⁠、第 52 行 #^/api/hello#⁠),/api/menuXXX 也会命中。

demo.w2r.site/backend/public/api.php:19 · 修复约 1 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

public/test-putenv.php 调试脚本残留在 web 根目录,未鉴权

任何人访问 https://<域名>/test-putenv.php 即可拿到 PHP 版本、SAPI、完整 disable_functions 列表,以及 system/Config/DotEnv.php 第 98 行的源码内容。属于典型的信息泄露与攻击面探测入口,上线前应删除。

backend/public/test-putenv.php:1 · 修复约 0.5 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

resetMfa 后调用 verifyMfaCode 触发 PHP 8 TypeError(in_array 传 null)

resetMfa 把 secret 与 backup_codes 写成空字符串但保留行。随后 verifyMfaCode 的 findByUserAndProvider 仍返回该行,:221 的 json_decode(base64_decode('')) 得到 null,:222 的 in_array($code, null, true) 在 PHP 8 下抛 TypeError(haystack 必须是 array),接口 500 而非返回「验证码错误」。触发路径:用户在 /api/auth/mfa/reset 之后没走 setup 直接提交旧验证码。

w2r.site/backend/app/Services/AuthService.php:221-222(配合 :304-308 的 resetMfa) · 修复约 0.5 小时 · 来源:CMS 后端 · 服务层(w2r.site/

EntryDraftService 生成修订稿失败时 transRollback 后未 transComplete,事务深度泄漏

第 76 行 transStart() 之后,若 cloneEntryRow 返回 <= 0,第 80 行调 transRollback() 然后直接 return,跳过了 transComplete()。CI4 的 transStart 是按深度计数的,只回滚不收尾会让该请求后续所有同连接查询处在异常事务状态(后面的 AuditLogger 写审计可能连带丢失)。正确写法是 transComplete() 后用 transStatus() 判断,与同文件 :91-94、:156-159 的写法保持一致。

w2r.site/backend/app/Services/EntryDraftService.php:76-83 · 修复约 0.5 小时 · 来源:CMS 后端 · 服务层(w2r.site/

.env 中 sitecore.username / sitecore.password 是无人读取的死凭据

sitecore.LOGGING_URL、sitecore.username、sitecore.password、sitecore.backendnewslisturl(.env:16-19)在整个仓库(backend PHP、scripts Python、admin-frontend、demo 站)grep 不到任何读取点,属于历史迁移期遗留。凭据长期躺在磁盘上却没人负责轮换,且 .gitlab-ci.yml:19 配了 secret_detection 也扫不到(.env 被 .gitignore 排除)。交接时必须确认这套 Sitecore 账号是否仍然有效并吊销。

backend/.env:17 · 修复约 0.5 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

重置密码与重置 MFA 每次多生成一个 confirm_token,污染审计流水

handleResetMfa 在第 649 行调用 authApi.generateConfirmToken('user_reset_mfa', password, mfaCode) 并把结果存进 confirmToken,随后第 653 行调用 usersApi.resetMfa(row.id, password, mfaCode),而 api/users.js:56 内部又生成了一次 token。第 650 行的 confirmToken 变量从未被使用。handlePasswordSubmit 在第 740 行和第 744 行有完全相同的重复。AuthController.php:683 每次生成都会写一条 confirm_token_generated 审计记录,因此每做一次密码重置或 MFA 重置,审计表里就多出一条无对应操作的孤立记录,排查时会误导审计人员;同时多消耗一次密码校验,可能触发限流。

js/views/Users.vue:649 · 修复约 0.5 小时 · 来源:CMS 管理后台前端(w2r.site/ad

dist 构建产物比源码旧,仓库里的产物不代表当前源码

网站根目录指向 frontend/dist(.cursor/rules/frontend-build.mdc:18),产物是提交进仓库的。两个源文件的 mtime 晚于最后一次构建 5 分钟,无法确认线上包含最后一次 mega menu 改动。接手第一件事应是本地重新构建并 diff 产物。

frontend/dist/assets/index-CE112lbo.js(Jun 12 15:55:42)对 frontend/src/components/home/HeaderNav.vue、frontend/src/lib/nav/megaMenuState.js(Jun 12 16:00:54) · 修复约 0.5 小时 · 来源:demo.w2r.site 官网前台(fro

security-scan.sh 与其 README 把项目根写死为 /www/wwwroot/vgc,与实际部署路径不符

PROJECT_ROOT="/www/wwwroot/vgc",SECURITY-SCAN-README.md:36 也要求 cd /www/wwwroot/vgc。但 migrate_video.log:220 显示实际部署路径是 /www/wwwroot/w2r.site。脚本第 6 行有 set -e,在错误路径下会中途硬失败或对空目录生成一份看似正常但内容为空的报告。security-scan-results/ 里的唯一一份结果是 2026-01-25 生成的,项目名仍是 vgc、依赖树是当时的版本,与 docs/software-bom.md:5 记录的 2026-05-18 依赖版本已经对不上,等于安全扫描半年没有真正跑过。

w2r.site/security-scan.sh:9 · 修复约 0.5 小时 · 来源:w2r.site — Python 迁移脚本

api.php 在消费结果集之前就关闭了数据库连接 2 个代理独立报出

第 162 行 $res = $mysqli->query($sql);⁠、第 163 行立刻 $mysqli->close();⁠,第 170-172 行才 fetch_assoc 遍历。当前依赖 mysqli 默认的 MYSQLI_STORE_RESULT 缓冲行为侥幸能跑,一旦有人改成非缓冲查询、或 PHP 版本行为变化,菜单接口会静默返回空(并被上面的 success:true 掩盖)。

backend/public/api.php:163 与 :170 · 修复约 0.25 小时 · 来源:demo.w2r.site 官网前台(fro、缺陷穷举与技术债(横向,覆盖 w2r.sit

users.role 是字符串关联 roles.code,无外键、单角色

users.role VARCHAR(50) DEFAULT 'editor',与 roles.code 靠字符串在 PermissionService.php:43 join,数据库层无任何约束。改了 roles.code 就会让对应用户瞬间失去全部权限且无报错;一个用户只能有一个角色,业务上要「编辑兼初审」只能新造角色。

w2r.site/backend/app/Database/Migrations/2025-01-19-100000_CreateUsersTable.php:33 · 修复约 8 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

多条迁移用裸 ALTER 且固定索引/约束名,不可重入

idx_category_id / fk_assets_category(:24-25)、fk_asset_categories_parent 与 uk_channel_id(100031:16-24)、idx_sitecore_id(100036:30)都是无 IF NOT EXISTS 的裸 DDL。迁移一旦在中途失败(如 100031 的外键错误),已执行的 ALTER 不会回滚,重跑必然撞「Duplicate key name」,只能手工清理。全仓只有 100041、100043、100040、100029、100031(部分) 写了存在性守卫。

w2r.site/backend/app/Database/Migrations/2026-01-27-100025_AddCategoryIdToAssets.php:24 · 修复约 3 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

Figma CSS 编译器硬编码设计稿图层名,改名即静默失效

compileCss 尾部针对 data-figma-name="card_awards"、"Badge"、"Badge Text" 输出带 !important 的补丁规则,靠设计师在 Figma 里的图层命名生效。设计侧任何一次重命名或复制图层,样式补丁静默失灵(页面高度被裁、Badge 年份换行),排查时没人会想到去看后端 PHP。同类隐式约定还有 :319(str_contains name 'header'/'footer'/'nav' 决定语义标签)。

w2r.site/backend/app/Services/Figma/FigmaToHtmlCssService.php:826-831 · 修复约 3 小时 · 来源:CMS 后端 · 服务层(w2r.site/

多条迁移的 down() 不可逆或语义不对称

100030 的 down 用 $[0] 把 JSON 数组压回单值,多栏目关联静默丢失;100032 的 down 是空实现(数据合并不可逆);100031 的 down 直接 DELETE 所有 channel_id 非空的分类。任何一次 spark migrate:rollback 都是有损操作,接手者必须知道这一层没有安全回退,只能靠备份。

w2r.site/backend/app/Database/Migrations/2026-02-10-100030_ChangeEntriesChannelIdToJson.php:69 · 修复约 2 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

栏目级授权默认放行 + 硬编码 sys_admin 特例,与 PermissionService 的设计声明冲突

PermissionService.php:38 与 :99 两处注释明写「严格按 role_permissions 校验,不做 sys_admin 特例」,而 checkChannelPermission 第 199-201 行直接 return true 放过 sys_admin,且第 214-216 行在无任何配置行时默认放行。于是栏目级 ACL 只能做减法(deny)不能做加法,「给某角色单独开某栏目」的运营预期无法实现,而运维以为配置生效了。两套授权语义并存是最容易在交接后写出越权 bug 的地方。

w2r.site/backend/app/Services/WorkflowService.php:197-229;对照 w2r.site/backend/app/Services/PermissionService.php:38-49 · 修复约 2 小时 · 来源:CMS 后端 · 服务层(w2r.site/

图形验证码:错误时不消费、TTF 字体从未随仓库交付导致强度退化

verify() 只在校验通过时删除缓存(:85-87),输错不消费,同一个 token 可在 5 分钟内被无限次试(虽有登录节流兜底,但节流本身按 IP 可绕)。另外 :162 指向 WRITEPATH.'fonts/arial.ttf',而 backend/writable/ 整个目录在交接件中缺失(已知事实),文件必然不存在,代码永远走 :188-206 的 imagestring 分支——固定位图字体、单色、仅 ±4px 抖动,OCR 破解成本极低,等于没有验证码。

w2r.site/backend/app/Services/CaptchaService.php:85-87 与 :162-163 · 修复约 2 小时 · 来源:CMS 后端 · 服务层(w2r.site/

FigmaClient 下载远程图片跟随重定向且无大小上限,存在 SSRF 与内存打爆面

downloadBinary 设了 CURLOPT_FOLLOWLOCATION => true(:166)但没有限制协议、没有拦内网地址、没有 CURLOPT_MAXREDIRS、也没有响应体大小上限,整个 body 用 CURLOPT_RETURNTRANSFER 一次性读进内存(:172)。虽然当前 URL 只来自 Figma API 应答,但 Figma 的 CDN 一旦被投毒或 baseUrl 被 env 覆盖(Config/Figma.php:74-77 允许 figma.baseUrl 从环境变量注入),就变成一个可被驱动的服务端请求器。

w2r.site/backend/app/Services/Figma/FigmaClient.php:163-191 · 修复约 2 小时 · 来源:CMS 后端 · 服务层(w2r.site/

composer.json 与 README.md 仍是 CodeIgniter 官方发行版原文,零项目信息

composer.json 的 name 是 "codeigniter4/framework"、description "The CodeIgniter framework v4"、homepage 指向 codeigniter.com;README.md 60 行全是 CI4 官方发行说明,连「本项目是什么、怎么跑、依赖哪些外部系统」都没有。README.md:45 还写着「PHP 8.1 或更高」,与 composer.json:13 的 ^8.2、public/index.php:12 与 spark:41 的 $minPhpVersion='8.2' 矛盾。新人从仓库根目录开始读,第一份文档就是错的。

backend/composer.json:2 · 修复约 2 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

FULLTEXT 索引创建被 try/catch 静默吞掉

ft_entry_i18n_search 建失败时不报错也不记录,迁移照样标记成功。SearchService 有运行时探测并回退 LIKE(SearchService.php:73、:103),所以不会 500,但全站搜索会静默退化成三个 orLike 全表扫描,且没人会发现索引其实不存在。

w2r.site/backend/app/Database/Migrations/2026-01-24-100021_AddContentTextToEntryI18n.php:26 · 修复约 1 小时 · 来源:CMS 后端 · 数据模型与迁移(w2r.s

死代码与空占位组件仍挂在渲染树上

HomeIntroBandSection 渲染一个空 <section>,真正的 intro 文案在 HeroBanner.vue:42-45 里;接手者会花时间找「为什么这个组件没效果」。mockNewsArticle.js 182 行连带把 9 个 Figma 外链常量拖住不敢删。

frontend/src/components/home/HomeIntroBandSection.vue:1-13(空 section,挂在 HomePage.vue:5);frontend/src/data/mockNewsArticle.js:1-182(自述 deprecated);frontend/src/lib/news/normalizeContentJson.js、lib/nav/normalizeMegaMenuNav.js:95,142、lib/news/articleUrl.js:20 均无引用 · 修复约 1 小时 · 来源:demo.w2r.site 官网前台(fro

英文页仍渲染中文兜底文案

breadcrumb 缺失时写死 '媒体中心' 与 href '#';NewsArticleContent.vue:4,34,36 的「加载中…」「暂无正文」「返回栏目与新闻下载」也是硬编码中文。/en/news/{uuid} 页面会中英混排。

frontend/src/lib/news/adaptNewsDetailResponse.js:21-22 · 修复约 1 小时 · 来源:demo.w2r.site 官网前台(fro

writable 目录约定不一致:抓取脚本写项目根 writable/,迁移脚本写 backend/writable/

import_sitecore_files.py:40 的 DEBUG_DIR = ROOT/"writable"/"import_sitecore_files",而 migrate_sitecore_files_to_dam.py:50、migrate_news_covers_to_assets.py:47、backfill_video_posters_from_sitecore.py:46 的失败日志与 851 行的 writable_root 都指向 ROOT/"backend"/"writable"。交接包里 writable/ 存在(含 3 个缓存 JSON),backend/writable/ 整个目录缺失。排查问题时容易在错误目录下找日志,两处备份策略也会不一致。

w2r.site/scripts/import_sitecore_files.py:40 · 修复约 1 小时 · 来源:w2r.site — Python 迁移脚本

密码哈希算法两套并行实现,参数不一致

UserController::create 第 206 行用 password_hash($password, PASSWORD_DEFAULT)⁠,而 AuthService::hashPassword(AuthService.php:33)用 PASSWORD_BCRYPT, cost 10⁠,Python 脚本 migrate_sitecore_news_to_entries.py:100 用 bcrypt.gensalt(rounds=10)⁠。PHP 8.2 的 PASSWORD_DEFAULT 仍是 bcrypt cost 10,目前巧合一致,但 PHP 升级后 PASSWORD_DEFAULT 会变(官方保留变更权),届时不同入口创建的用户哈希算法不同。校验侧 password_verify 能兼容,属于隐患而非当前故障,但应统一到一个方法。

w2r.site/backend/app/Controllers/Api/UserController.php:206 · 修复约 1 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

PreviewService 用用户可控字符串构造 Time,格式非法时抛异常

createToken 第 43-45 行把请求传来的 expires_at 直接交给 parseDateTime(第 121-125 行),先 Time::createFromFormat('Y-m-d H:i:s', ...)⁠,失败再 Time::parse($value)⁠。Time::parse 对无法解析的字符串会抛异常而不是返回 false,PreviewController::create 没有 try/catch,传 expires_at=abc 即 500 而非 400。

w2r.site/backend/app/Services/PreviewService.php:121 · 修复约 1 小时 · 来源:缺陷穷举与技术债(横向,覆盖 w2r.sit

DashboardController::stats 无任何权限校验

只挂 auth 过滤器,控制器内零校验。任意登录用户(含 audit_readonly)可读全站新闻数、页面数、图片/视频/文件资产总量。信息量有限,但属于同一类系统性缺口。

backend/app/Controllers/Api/DashboardController.php:16 · 修复约 0.5 小时 · 来源:CMS 后端 · HTTP 接口层(w2r.

public/ 下存在多个不应对外可达的文件

groupui-showcase.html(28KB 设计规范演示页,全仓库无任何引用)、index.html(宝塔面板默认页,泄露主机管理面板信息)、404.html(nginx 默认页)、AdM/.vite/manifest.json(8KB Vite 构建清单,暴露前端全部源码模块图与 chunk 结构)都在文档根下且被 git 跟踪。另 /assets/fonts 下 4 个 MXiangHeHeiSCStd 商用中文字体以 .ttf 明文对外直出,存在字体授权风险。

backend/public/groupui-showcase.html:1 · 修复约 0.5 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

角色管理的模块名映射缺 system,配置类权限显示为英文原文

MODULE_NAMES 只覆盖 content、workflow、dam、preview、rbac、mfa、audit、other 八项。RolePermissionSeeder.php:86-88 把 config:export 与 config:import 归在 module='system',Roles.vue:217 的 getModuleName 找不到映射就回退返回原值,权限分组标题直接显示英文 system。另外 needScopeSelect(第 222 行)硬编码了 5 个需要 scope 的权限码,与后端实际支持 scope 的权限集合没有任何同步机制,后端新增权限时这里会静默漏配。

js/views/Roles.vue:206 · 修复约 0.5 小时 · 来源:CMS 管理后台前端(w2r.site/ad

内容管理中的新闻类型分支为不可达代码

Content.vue 的类型下拉(第 156-160 行)只提供页面、博客、FAQ页,没有新闻选项;列表查询固定带 exclude_type: 'news'(第 1033 行)。但文件里保留了大量 form.type === 'news' 的分支:第 163-174 行的栏目多选、第 894-896 行的 newsChannelOptions、第 899-910 行的类型切换同步 watch、第 1315-1316 行的 channel_ids 提交逻辑。这些代码在 UI 上永远走不到(新闻由 News.vue 独立维护),却与 News.vue 的同名逻辑各自演化,是后续改栏目逻辑时的主要误改点。

js/views/Content.vue:163 · 修复约 0.5 小时 · 来源:CMS 管理后台前端(w2r.site/ad

App.php 里 baseURL 硬编码生产域名,且带 /api/ 后缀

public string $baseURL = 'https://demo.w2r.site/api/',.env 里没有 app.baseURL 覆盖。本地或测试环境跑 CI4 入口时 URI 解析、重定向、site_url() 全部错位。Routes.php:11 的注释正是为了迁就这个后缀才额外注册了一遍裸路由(Routes.php:20-23)。

backend/app/Config/App.php:19 · 修复约 0.5 小时 · 来源:demo.w2r.site 官网前台(fro

replace_sitecore_media_in_content_json.py 的 --verbose 已声明形参但 main() 从不解析

docstring 第 14 行把 --verbose 列为可用参数,run() 第 261 行也定义了 verbose 形参,但 main()(353-367 行)只解析 --limit=/--dry-run/--type=,且函数体内没有一处使用 verbose。传了这个参数不会报错也不会有任何效果,排查替换失败时拿不到期望的明细输出。

w2r.site/scripts/replace_sitecore_media_in_content_json.py:261 · 修复约 0.5 小时 · 来源:w2r.site — Python 迁移脚本

sync_video_assets_status_from_sitecore.py 用 len(changes) <= 3 作为「无变化」哨兵

changes 字典初始化时固定放 asset_id/sitecore_id/title_zh 三个键(224 行),233 行靠「长度是否仍为 3」判断有没有待更新字段。任何人日后往初始化字典里加一个诊断字段(比如 mime 或 category_id),这个判断就永远为假,脚本会对所有视频执行空 UPDATE;反之少加一个键则会漏更新。没有测试覆盖这一分支。

w2r.site/scripts/sync_video_assets_status_from_sitecore.py:233 · 修复约 0.5 小时 · 来源:w2r.site — Python 迁移脚本

Config/Hostnames.php 与 Config/WorkerMode.php 是死配置

Hostnames.php 定义了 40 行 TWO_PART_TLDS 常量,全仓库无任何引用(App.php:32 的 $allowedHostnames 是另一回事且为空数组)。WorkerMode.php(62 行)是 CI4 4.7 为 FrankenPHP worker 模式准备的配置,项目里 grep 不到任何 frankenphp 相关代码,属骨架残留。接手者容易误以为这两处在生效而做无用改动。

backend/app/Config/Hostnames.php:5 · 修复约 0.25 小时 · 来源:CMS 后端 · 配置、CLI、测试(w2r

系统设置的路由守卫权限与菜单权限不一致

router/index.js:90 中 /settings 的 requiresPermission 是 ['workflow:config','rbac:manage'],而 config/menu-permissions.js:16 给同一路径配的是 ['workflow:config','rbac:manage','config:export','config:import'],多出两项。若某角色只有 config:export 或 config:import,左侧菜单会显示「系统设置」,点进去被守卫(第 134 行 canAccessRoute)打回 /dashboard,形成点不开的菜单项。按当前 RolePermissionSeeder,config:export/import 只授予了同时拥有 rbac:manage 的 sys_admin,所以问题暂时不显现,属于潜伏缺陷——一旦运维用角色管理界面新建自定义角色就会触发。两张表分处两个文件且必须手工保持同步,本身也是设计隐患。

js/router/index.js:90 · 修复约 0.25 小时 · 来源:CMS 管理后台前端(w2r.site/ad

资产选择器的多选列是无处理函数的装饰件

表格第 40 行声明了 <el-table-column type="selection" width="55" />,但整个组件没有 @selection-change 监听,也没有 ref 去读取选中项。真正的选中逻辑是第 38 行的 @row-click → handleRowClick 写入单个 selectedAsset。用户看到复选框会以为可以多选,勾选后底部「确定」按钮依然处于 :disabled="!selectedAsset" 的禁用态,必须改点行本身才生效。该组件被内容封面、附件选择、富文本插图共 6 处调用,是高频交互点。同文件第 225 行还留着 TODO 注释(getAssetUrl 待按实际 API 调整),是全前端仅存的一处 TODO。

js/cms/components/AssetSelector.vue:40 · 修复约 0.25 小时 · 来源:CMS 管理后台前端(w2r.site/ad

两处 el-radio 使用已废弃的 label 传值写法

Channel.vue:118-119 的 i18n_strategy 与 AssetCategories.vue:99-100 的 access_level 写作 <el-radio label="loose">文案</el-radio>,即用 label 同时充当值。Element Plus 自 2.6 起已将取值属性改为 value、label 退化为展示文案,当前锁定版本 2.14.0 仍保留回退兼容,功能可用但控制台会打印废弃告警,升级到 3.x 时这四个单选框会静默失去绑定值。同一仓库的 Settings.vue:179 与 RichTextEditor.vue:103 已经用的是规范的 :value 写法,属于遗留不一致。

js/views/Channel.vue:118 · 修复约 0.25 小时 · 来源:CMS 管理后台前端(w2r.site/ad

FAQ 绑定保存无错误处理,失败时静默

saveFaqPageQuestions 没有 try/catch,直接 await faqApi.updateFaqPageQuestions 后就 ElMessage.success('FAQ 绑定已保存')。请求失败时 Promise 拒绝逃逸成未处理异常,用户既看不到成功提示也看不到错误提示,按钮无任何反馈。同文件其余所有异步操作都做了 try/catch。此外 Login.vue:304-309 的 handleFormValidate 是绑定在 @validate 上的空函数(函数体只有一条注释),handleMfaCodeInput 第 298-300 行也有一段只含注释的空 if 分支。

js/views/Content.vue:1504 · 修复约 0.25 小时 · 来源:CMS 管理后台前端(w2r.site/ad

06 · 取证分析

约九成代码由 AI 生成

git 历史是整树倾倒式提交,无法做行级溯源,因此判断基于代码内部特征。以下全部是实测计数。

AI 生成 约 90%人工 约 10%

决定性证据:风格零漂移

指标结果
缩进213/213 文件纯空格,零 Tab
花括号风格独占一行 572 处 vs 同行 3 处,99.5% 一致
字符串引号单引号 26,918 vs 双引号 593,97.8% 一致
strict_types213 个文件全部没有⁠,无一例外
TODO / FIXME27,252 行 PHP 里零个

关键点:没有任何 .php-cs-fixer.dist.phpphpcs.xml 配置文件⁠,composer 里那几个 cs 工具是 CI4 骨架自带的 require-dev,没配置就跑不起来。三个人手写 213 个文件跨越数月做到这种零漂移,实际上不可能。

最硬的一条:注释里留着助手的推理过程

……需要把现有分类都挂到网站素材下吗?用户说「把栏目的构架复制到资产分类的网站素材下面」,即栏目树复制到网站素材下,没有说把原有分类移进去。所以保持⁠:只插入「网站素材」根,不移动旧数据。 app/Database/Migrations/2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:43

分区判断

代码区AI 占比依据
CMS 后端 PHP88–95%风格零漂移、零 TODO、607 个 docblock 覆盖到私有方法
管理后台 Vue88–95%51 个文件仅 1 处 TODO、零调试残留
官网前端92–98%AGENTS.md + .cursor/rules + Figma MCP 导出,全项目最高
官网后端88–95%263 处横幅注释格式统一
Python 脚本75–88%注释密度低,但结构高度模板化

工具指纹

主力是 Cursor⁠:规格书正式收录 15 条 rules + 10 个 skills,全库 92 处提及。Codex 痕迹只有一处——demo.w2r.site/AGENTS.md⁠。没有 CLAUDE.md、copilot-instructions、windsurf、aider、cline 的任何痕迹。Figma 素材经 MCP 导出。

关于「人工那 10%」

这个数字指的是代码行⁠。人在项目里的实际作用远大于此——需求定义、技术选型、那 25 个规则与技能文件的编写、Figma 设计对齐、验收、线上排障,都是人做的,只是产出物不是代码。置信度中高⁠:风格一致性是可量化硬指标,但若那 25 个缺失的 rules 里有一条规定了代码风格,则「AI 被要求保持一致」,结论方向不变。

07 · 工作量

分模块评级

八名代理各自通读一个子系统后独立给出评级。上手人天的定义是:一名有 PHP / Vue 经验的中级工程师,达到「能安全独立修改此模块」所需时间。

模块行数理解难度接手风险上手月维护缺陷
CMS 后端 · HTTP 接口层(w2r.site/backend:Routes/Filters/Controllers) 7,942 5 人天 1.5 人天 13
CMS 后端 · 数据模型与迁移(w2r.site/backend 持久层) 5,895 6 人天 1.5 人天 17
CMS 后端 · 服务层(w2r.site/backend/app/Services/ + Services/Figma/ + app/Helpers/) 6,905 8 人天 2.5 人天 16
CMS 后端 · 配置、CLI、测试(w2r.site/backend:app/Config、app/Commands、app/Views、app/Language、public、scripts、tests、preload.php、spark、composer.json、README.md) 8,025 5 人天 1 人天 21
CMS 管理后台前端(w2r.site/admin-frontend,Vue3 + Element Plus) 13,656 6 人天 2 人天 18
demo.w2r.site 官网前台(frontend Vue3+Vite / backend CI4 薄壳 + public/api.php 独立入口) 10,822 5 人天 1.5 人天 19
w2r.site — Python 迁移脚本(scripts/)与文档体系(docs/ + 根级 README/安全扫描) 6,680 7 人天 0.8 人天 15
缺陷穷举与技术债(横向,覆盖 w2r.site + demo.w2r.site 全部首方代码) 55,543 15 人天 4 人天 35
57
上手合计人天
14.8
月维护人天
275.5
必修工时
62
全部缺陷人天

08 · 模块详解

逐模块交接文档

点开展开。每份含架构说明、文件职责、关键流程、隐式约定与坑,接手者读完可上手。

CMS 后端 · HTTP 接口层(w2r.site/backend:Routes/Filters/Controllers)
7,942
行 / 33 文件
5
上手人天
1.5
月维护人天
13
缺陷
理解难度
接手风险

主要风险

  • 授权判定散落在 24 个控制器方法体内,路由表不是权限的事实来源:Routes.php 里 105 条 API 只标了 auth/auth_no_mfa/logout_auth 三种过滤器,具体要什么权限码必须逐个方法读代码。新增接口时「忘了加 requirePermission」不会有任何静态或运行时告警,ContentController 与 ChannelController 就是这么整体漏掉的。
  • 身份来源双轨(JWT claim 与 Session)且两条路径校验强度不同:requirePermission 查库(含 is_active),requireRoles 只信 JWT。任何涉及禁用账号、角色降级、强制下线的需求,在当前实现下都无法在 token 有效期内生效。
  • user_sessions 表的吊销语义只写不读,「禁止并发登录」「登出即失效」两个安全承诺目前是纸面功能。改动认证链路时若不先补上 jti 校验,会持续误以为这些机制已生效。
  • confirm_token(二次确认)的 operation 字符串是纯约定:签发方 AuthController::confirmToken 与消费方各控制器靠字面量对齐(content_delete / content_publish / dam_force_delete / rbac_manage / user_reset_mfa 等十余种),没有常量表也没有集中注册。写错一个字符串的后果是「二次确认永远失败」,且只有跑到线上才发现。
  • 错误码 1xxx-9xxx 的分段是口头约定而非枚举:同一个 4000/4001/4004 在 DAM、栏目、分类三个域里含义不同,3001/3005/3006/3007/3008 在内容、FAQ、关联三处复用。前端按 code 做分支时极易误判。
  • 非 API 的三条交付路径(/AdM SPA 回退、/asset/:id、/page-css/:file)绕开了 ApiController 的响应封装与审计,各自手写鉴权与响应,其中 /asset 的 JWT 分支是坏的、/page-css 完全无鉴权(靠文件名正则防目录穿越)。

和 CMS 的其余部分相比,HTTP 层的代码本身不难读——写法高度重复,几乎每个方法都是「取当前用户 → 校权限 → 取参数 → 调 Service/Model → 写审计 → 返回封装」。真正的风险不在于看不懂,而在于这一套模板在若干控制器里被整段跳过了,而路由表上看不出任何区别。接手第一件事应该是拿本文的接口清单表逐条核对,而不是相信 Routes.php。

一、边界与规模

范围内共 33 个文件、7,942 行:app/Config/Routes.php(172 行)、app/Config/Filters.php(116 行)、app/Controllers/ 下 4 个非 API 控制器(AdminController 36、AssetDeliveryController 173、BaseController 45、Home 11)、app/Controllers/Api/ 下 24 个控制器(7,238 行,最大的是 ContentController 852 行、AuthController 769 行、DamController 645 行、UserController 569 行)、app/Filters/ 下 3 个过滤器(151 行)。

Routes.php 里共 111 条路由声明(GET 47 / POST 31 / PUT 22 / DELETE 11),其中 6 条是非 API 路径,105 条在 api 分组内(namespace 为 App\Controllers\Api⁠)。自动路由已关闭(Config/Routing.php:97 autoRoute = false⁠),所以路由表是穷尽的——不存在「没写在 Routes.php 里但能访问到」的控制器方法。

二、鉴权管线:三个 Filter 的差异

Config/Filters.php:30-43 注册了三个自研过滤器,全局过滤器($globals⁠,:79-89)与按方法/按 URI 的过滤器($methods :104、$filters :115)全部为空⁠——csrf、invalidchars、secureheaders、honeypot 都被注释掉了。也就是说:除了路由上显式声明的那一个过滤器,请求不经过任何其他前置处理⁠。$required.before 里的 forcehttps 因为 Config/App.php:160 forceGlobalSecureRequests = false 而是空转。

别名行为用在哪
authApp\Filters\AuthFilter先试 JWT(验签+exp),失败回退 Session;无 user_id → 401 / code 1001;有 user_id 但 mfa_verified 为假 → 403 / code 100295 条 API
auth_no_mfaApp\Filters\AuthNoMfaFilter同上但不检查 MFA⁠,只要求已登录auth/me⁠、auth/mfa/status⁠、auth/mfa/enroll⁠、auth/mfa/verify 共 4 条
logout_authApp\Filters\LogoutAuthFilter无条件放行LogoutAuthFilter.php:15-18 直接 return $request),身份识别推到控制器里做,允许过期 JWTauth/logout

AuthFilter.php:21-35 的关键逻辑:JWT 优先,JwtService::verify 失败(含过期)就静默回退到 Session。三个过滤器都不查库⁠——不校验 users.is_active⁠,不查 user_sessions.revoked_at⁠。

完整鉴权管线共四层,每层都可能缺席:

  1. 过滤器层(路由声明):登录 + MFA。
  2. 控制器入口层⁠:多数方法会再写一遍 $userId = $this->getCurrentUserId(); if (!$userId) return $this->fail('未登录', 1001);⁠——这是对 auth 过滤器的冗余重复,但也是 preview/access 这类无过滤器接口的唯一防线。
  3. 权限码层⁠:ApiController::requirePermission()(:200)查库,或 requireRoles()(:215)读 JWT claim。这一层在 ContentController、ChannelController 的 CRUD、DashboardController、EntryRelationController、FaqPageController、SearchController、SeoConfig 读接口、SystemConfig 读接口里完全不存在⁠。
  4. 对象级层⁠:created_by == $userId || role === 'sys_admin'(ContentController.php:493/:719、FaqQuestionController.php:233、FaqTopicController.php:182、FaqPageController.php:84、EntryRelationController.php:83),以及工作流的栏目级授权 WorkflowService::checkChannelPermission(WorkflowController.php:48/:122/:196)。二次确认 confirm_token 也归在这一层。

权限码校验的实现在 PermissionService::hasPermission(:23-49):查 role_permissions ⋈ roles ⋈ permissions⁠,要求 roles.is_active = 1⁠,并且明确注释了「不做 sys_admin 特例」⁠——这点必须记住:sys_admin 如果在角色管理里没被授予某权限码,一样会被拒。

三、JSON 响应封装与错误码分段

封装定义在 ApiController.php:47-70⁠,四个字段固定:

success: HTTP 200, { code: 0, message, data, trace_id }
fail:    HTTP 400, { code: <业务码>, message, data, trace_id }

注意三个坑:其一,fail 恒定 HTTP 400⁠,业务语义全在 body.code;而过滤器返的是 401/403(AuthFilter.php:39-58),两套语义并存。其二,trace_idbin2hex(random_bytes(8)) 现场生成的(:53、:68),与 AuditLogger.php:47 写进 operation_logs.trace_id 的那个(16 字节)毫无关系⁠,拿用户报的 trace_id 去审计表里查是查不到的。其三,路由未命中和未捕获异常走 CI4 的 HTML 错误页(Config/Routing.php:87 override404 = null⁠),而前端 admin-frontend/js/api/index.js:88 只按 data.code !== 0 判断,遇到 HTML 会直接解析炸掉。

两个接口不走封装⁠:GET /api/audit/export 返回 text/csv 带 BOM(AuditController.php:161-164),GET /asset/:id 返回文件二进制。

错误码分段(从 24 个控制器里逐个 fail 调用统计得出,注意这是事后归纳的约定,不是枚举常量):

高频码
1xxx认证 / 会话 / 二次确认1001 未登录(44 处)、1002 密码或验证失败、1003 锁定或需 MFA、1004 验证码错误、1005 确认码无效
2xxx工作流2000 参数错、2001 内容不存在、2007 无栏目权限、2008 配置保存失败
3xxx内容 / FAQ / 预览 / 发布3001 内容不存在(18 处)、3005 非草稿不可编辑、3006 无权限(对象级)、3007 需二次确认、3008 确认 token 无效、31xx 发布、32xx 预览与 FAQ
4xxxDAM / 栏目 / 分类4000 参数错(24 处)、4001 被引用或有子节点、4004 对象不存在、4011 未登录(DAM 专用,25 处)、4031 无权限(DAM/Figma 专用)、4091 二次确认失败
5xxx搜索5000 操作失败、5002 参数缺失、5003 不存在
6xxxRBAC / 用户 / 系统配置 / Figma6001 无权限(requirePermission 默认值)、6002 用户或角色不存在、6003-6017 用户管理细分、6101 无权限(配置类专用)、6201-6203 配置导入导出、6400/6500 Figma 导入失败
9xxx兜底9000 fail() 默认值、9001 系统配置参数错

分段并不互斥⁠:4000/4001/4004 在 DAM、栏目、分类三处含义不同;3001/3006/3007/3008 在内容、FAQ、关联三处复用;「无权限」有 6001、6101、4031、3006 四种写法。前端做分支判断时必须结合接口路径,不能只看 code。

四、完整接口清单(按业务域)

「权限」列写的是控制器内实际执行的校验;标注「⁠」的是只有过滤器把关、控制器内零校验,这些就是本次审计的主要发现。

4.1 非 API 路径(6 条)

方法路径控制器::方法过滤器权限用途
GET/Home::index返回 CI4 欢迎页(app/Views/welcome_message.php⁠);实际生产由 Apache DirectoryIndex 先命中 public/index.html
GET/asset/(:num)AssetDeliveryController::getAsset分类 access_level=auth 时要求登录资产文件交付,?quality=high 取高清变体
GET/asset/(:num)/infoAssetDeliveryController::getAssetInfo同上(只报告不拦截)前端探测资产是否存在、是否需登录、有哪些质量档
GET/page-css/(:any)PageCssDeliveryController::serveFigma 导入产生的版本化页面 CSS
GET/AdMAdminController::index管理后台 SPA 入口
GET/AdM/(:any)AdminController::indexSPA history 模式回退

4.2 认证与会话(11 条)

方法路径控制器::方法过滤器权限用途
GET/api/auth/captchaAuthController::captcha公开生成图形验证码
POST/api/auth/loginAuthController::login公开用户名密码登录;IP 级节流(LoginThrottleService)+ 强制图形验证码;成功签发 mfa_verified=false 的 JWT
POST/api/auth/logoutAuthController::logoutlogout_auth无(放行)登出;接受 reason 白名单(manual/inactivity/credential_invalid/mfa_not_verified/token_expired/other,:254-261)与 api_code 用于审计归类
GET/api/auth/meAuthController::meauth_no_mfa仅登录当前用户 + 角色显示名 + 权限码列表(前端菜单与路由守卫依赖此项)
GET/api/auth/mfa/statusAuthController::mfaStatusauth_no_mfa仅登录查 MFA 是否已注册、是否有未完成的密钥
POST/api/auth/mfa/enrollAuthController::mfaEnrollauth_no_mfa仅登录生成 TOTP 密钥
POST/api/auth/mfa/verifyAuthController::mfaVerifyauth_no_mfa仅登录校验 TOTP;成功后换发 mfa_verified=true 的新 JWT 并登记 user_sessions
POST/api/auth/mfa/resetAuthController::mfaResetauth需当前密码重置自己的 MFA
POST/api/auth/confirm-tokenAuthController::confirmTokenauth需当前密码;高危操作追加 MFA签发二次确认 token;:649-654 定义需 MFA 的四类操作:user_reset_password、user_reset_mfa、rbac_manage、dam_force_delete
POST/api/auth/change-passwordAuthController::changePasswordauth需旧密码 + 密码策略自助改密,写 password_changed_at
POST/api/preview/access/(:segment)PreviewController::access用户名+密码预览链接访问,不要求 JWT/MFA,带 IP 节流

4.3 内容(9 条)——全域无权限码

方法路径控制器::方法过滤器权限用途
GET/api/contentContentController::indexauth列表;支持 type/exclude_type/channel_id(JSON_CONTAINS)/status/lang 过滤,附封面与修订稿 id
GET/api/content/(:segment)ContentController::showauth详情,id 支持数字或 UUID
POST/api/contentContentController::createauth创建(非事务,i18n 插入失败会留孤儿 entry)
PUT/api/content/(:segment)ContentController::updateauth⁠;仅 draft 状态 + created_by/sys_admin更新中英文 i18n、封面、meta_json
DELETE/api/content/(:segment)ContentController::deleteauth⁠;created_by/sys_admin + confirm_token(content_delete)删除并清理 asset_refs
POST/api/content/(:segment)/draftContentController::createDraftauth为已发布内容生成修订稿
DELETE/api/content/(:segment)/draftContentController::deleteDraftauth + confirm_token(content_delete)删除未发布修订稿
GET/api/content/(:segment)/relationsEntryRelationController::listauth内链关系列表
PUT/api/content/(:segment)/relationsEntryRelationController::replaceauth⁠;仅 draft + created_by/sys_admin全量替换内链

4.4 栏目(8 条)——CRUD 无权限码

方法路径控制器::方法过滤器权限用途
GET/api/channelsChannelController::indexauth树形栏目
PUT/api/channels/reorderChannelController::reorderauth批量改 parent_id/sort_order
GET/api/channels/(:num)ChannelController::showauth详情
POST/api/channelsChannelController::createauth创建(并同步生成 DAM 分类)
PUT/api/channels/(:num)ChannelController::updateauth更新(改名时同步 DAM 分类名)
DELETE/api/channels/(:num)ChannelController::deleteauth⁠;有内容或子栏目时拒绝删除
GET/api/channels/(:num)/role-permissionsChannelController::rolePermissionsauthrbac:manage栏目级角色授权列表
PUT/api/channels/(:num)/role-permissionsChannelController::updateRolePermissionsauthrbac:manage + confirm_token(rbac_manage)全量替换栏目级授权(allow/deny)

4.5 工作流与发布(8 条)

方法路径控制器::方法过滤器权限用途
POST/api/workflow/submitWorkflowController::submitauthcontent:submit + 栏目级授权draft → review_l1
POST/api/workflow/review-l1WorkflowController::reviewL1authworkflow:review_l1 + 栏目级初审 pass/reject
POST/api/workflow/review-l2WorkflowController::reviewL2authworkflow:review_l2 + 栏目级终审 pass/reject
GET/api/workflow/actionsWorkflowController::actionsauth角色 sys_admin/audit_readonly 三个权限码任一(:260-265)审核链路
GET/api/workflow/configWorkflowController::getConfigauth角色 sys_admin工作流规则
PUT/api/workflow/configWorkflowController::updateConfigauth角色 sys_admin保存规则
POST/api/publish/publishPublishController::publishauthcontent:publish + confirm_token(content_publish)手动发布
POST/api/publish/unpublishPublishController::unpublishauthcontent:unpublish + confirm_token(content_unpublish)手动下线

4.6 DAM(16 条)

方法路径控制器::方法过滤器权限用途
POST/api/dam/chunkDamController::chunkauthdam:upload分片上传(multipart 或 base64)
GET/api/dam/chunk-status/(:segment)DamController::chunkStatusauthdam:upload断点续传状态
POST/api/dam/mergeDamController::mergeauthdam:upload合并分片建资产
GET/api/dam/assetsDamController::assetsauth仅登录资产列表(type/status/category/keyword/has_high_quality)
GET/api/dam/assets/(:num)DamController::assetShowauth仅登录详情 + 引用数 + 变体
GET/api/dam/assets/(:num)/refsDamController::assetRefsauth仅登录引用明细
PUT/api/dam/assets/(:num)DamController::assetUpdateauthdam:upload改标题/分类/发布日/隐藏
POST/api/dam/assets/(:num)/variantsDamController::mergeVariantauthdam:upload追加高清变体
DELETE/api/dam/assets/(:num)/variants/(:segment)DamController::deleteVariantauthdam:upload删变体及文件
DELETE/api/dam/assets/(:num)DamController::assetDeleteauthdam:delete + confirm_token(dam_delete)⁠;有引用即拒常规删
POST/api/dam/assets/(:num)/force-deleteDamController::assetForceDeleteauthdam:force_delete + confirm_token(dam_force_delete)(密码+MFA)强删
GET/POST/PUT/DELETE/api/dam/categories[/(:num)]AssetCategoryController::index/show/create/update/deleteauth全部 dam:upload分类树维护;access_level 取值 public/auth,直接决定 /asset/:id 是否要求登录

4.7 用户与 RBAC(11 条)

方法路径控制器::方法权限
GET/api/usersUserController::indexrbac:manage(并记异常检测)
GET/api/users/(:num)UserController::showrbac:manage
POST/api/usersUserController::createrbac:manage
PUT/api/users/(:num)UserController::updaterbac:manage⁠;升为 sys_admin 时额外记 user_role_escalation
DELETE/api/users/(:num)UserController::deleterbac:manage + confirm_token(user_delete)⁠;禁止删自己
PUT/api/users/(:num)/statusUserController::updateStatusrbac:manage
PUT/api/users/(:num)/passwordUserController::resetPasswordrbac:manage + confirm_token(user_reset_password)
PUT/api/users/(:num)/mfaUserController::resetMfamfa:reset_other + confirm_token(user_reset_mfa)
GET/api/roles /api/roles/(:num)RoleController::index/showrbac:manage
PUT/api/roles/(:num)RoleController::updaterbac:manage + confirm_token(rbac_manage)

4.8 审计、配置、搜索、FAQ、Figma、预览、仪表盘(其余 42 条)

方法路径控制器::方法权限
GET/api/audit/queryAuditController::query角色 sys_admin/audit_readonly;含「非常规查询」检测(:172-194)
GET/api/audit/exportAuditController::export同上;上限 10,000 条,超阈值记 bulk_export⁠;返回 CSV
GET/PUT/api/system/session-configSystemConfigController::sessionConfig/updateSessionConfig⁠;写角色 sys_admin(1-480 分钟)
GET/PUT/api/system/audit-policySystemConfigController::auditPolicy/updateAuditPolicy⁠;写角色 sys_admin
GET/PUT/api/seo/configSeoConfigController::show/update⁠;写角色 sys_admin
GET/api/seo/schema/previewSeoConfigController::schemaPreview
GET/api/config/exportConfigPromotionController::exportconfig:export
POST/api/config/importConfigPromotionController::importconfig:import(支持 dry_run)
GET/api/dashboard/statsDashboardController::stats
GET/api/faq/questions[/(:num)]FaqQuestionController::index/show
POST/PUT/DELETE/api/faq/questions[/(:num)]FaqQuestionController::create/update/deletecontent:create / content:edit+owner / content:delete+owner+confirm_token(faq_question_delete)
GET/api/faq/topics[/(:num)]FaqTopicController::index/show
POST/PUT/DELETE/api/faq/topics[/(:num)]FaqTopicController::create/update/delete同上,确认 token 为 faq_topic_delete
GET/PUT/api/faq-pages/(:num)/questionsFaqPageController::questions/updateQuestions⁠;写侧仅 draft + owner
POST/api/figma/importFigmaController::importfigma:import
POST/api/figma/css/persistFigmaController::persistCsscontent:edit
POST/api/figma/css/source-urlFigmaController::updateSourceUrlcontent:edit
GET/api/figma/css/versionsFigmaController::cssVersionscontent:edit
GET/api/search /api/search/suggest /api/search/hotwordsSearchController::index/suggest/hotwords仅登录
GET/POST/PUT/DELETE/api/search/hotwords*SearchHotwordController::*全部角色 sys_admin
GET/POST/PUT/DELETE/api/search/pins*SearchPinController::*全部角色 sys_admin
GET/api/preview/tokensPreviewController::index仅登录;非 sys_admin 只看自己创建的
POST/api/preview/tokensPreviewController::createpreview:create
PUT/api/preview/tokens/(:num)/revokePreviewController::revokepreview:revoke + confirm_token(preview_revoke)

五、三条非 API 路径的机制

/AdM SPA 回退Routes.php:20-21 + AdminController.php:18-35⁠):(:any) 在 CI4 里编译成 (.)⁠,所以 /AdM/任意/多级/路径 都会命中同一个控制器,返回 FCPATH . 'AdM/index.html' 的内容,并强制 Cache-Control: no-cache, no-store, must-revalidate⁠。静态资源不会走到这里的原因在 public/.htaccess⁠:RewriteCond %{REQUEST_FILENAME} !-f 让实际存在的文件(/AdM/assets/.js 等)由 Apache 直接吐出。这意味着换 Nginx 部署时必须手工复刻这条 !-f 判断,否则 SPA 的 JS/CSS 会全部被当成路由返回 HTML。 文件缺失时返回 404 纯文本「管理后台前端文件未找到,请先构建前端应用。」——看到这句话就是前端没构建或没同步到 public/AdM/⁠。

资产交付鉴权AssetDeliveryController.php:37-104⁠):查 assets → 若有 category_id 则查分类的 access_level⁠;只有 auth 级才拦截,public 直接放行。身份识别顺序是 Session 优先、JWT 兜底——但 JWT 分支在 :59 写错了(详见缺陷清单),会 500。通过后按 ?quality=high 选变体,从 WRITEPATH . storage_key 读文件,用 file_get_contents 一次性载入内存后 setBody 返回。注意这里没有 Range 支持、没有流式输出⁠,大视频会把整个文件读进 PHP 内存;也没有 Content-Disposition 的文件名转义(:102 直接拼 $filename⁠)。/asset/:id/info 是同一套判断的只读版本,返回 requires_auth⁠、can_access⁠、qualities 供前端决定是否弹登录。

page-css 交付PageCssDeliveryController.php:17-43⁠):这是三条里防护做得最规矩的一条。先 basename() 去掉路径成分,再用正则 ^page-\d+(?:-[a-z]+)?-\d{8}-\d{6}-[a-f0-9]{6}\.css$ 严格白名单(:21),然后拼 WRITEPATH + Config/Figma.php 的 cssDir(uploads/page-css)⁠,返回 text/css 并带 Cache-Control: public, max-age=300⁠。文件名由两处生成且格式一致:FigmaController::attachPreviewCssUrlIfNeeded(:161-165,预览用)与 EntryCssVersionService(:66-69,落盘版本用),都是 date('Ymd-His') + substr(sha1(...), 0, 6)⁠。改动任一处的文件名格式,必须同步改这条正则,否则 CSS 全部 404。 该路径不做任何鉴权——CSS 内容被视为公开资源。

六、接手前必须知道的隐式约定

  1. getVar()getJsonPayload() 混用⁠。CI4 的 getVar() 对 JSON 请求返回 stdClass 而非数组,所以 ApiController::getJsonPayload()(:234-254)做了归一化。但大部分控制器仍直接用 getVar('field') 取单字段(对 JSON body 是能工作的),只有需要整包的地方才用 getJsonPayload()⁠。ContentController::update 里两种混着用(:499-501)。PUT + JSON 的场景还有第三种写法:getJSON(true)getVar()getRawInput() 三级回退(WorkflowController.php:301-307、SystemConfigController.php:62-68、RoleController.php:146-149)。新写接口时照抄最近的那个就行,别自创第四种。
  2. 审计是手写的,不是切面⁠。每个需要留痕的分支都要显式调 AuditLogger::log(module, operation, {actor_id, actor_role, object_type, object_id}, result, error_code, request_summary, $request)⁠。漏写不会报错,只会在合规检查时暴露。唯一自动化的是 failForbidden()(ApiController.php:159-195),它会按 AuditPolicyService::shouldLogAccessDenied() 决定是否记 access_denied⁠,并同时喂给 AuditAnomalyService⁠。
  3. confirm_token 的 operation 是裸字符串约定⁠。签发端 AuthController::confirmToken 不校验 operation 取值合法性,消费端各自 verify($token, $userId, '<字面量>')⁠。目前在用的有 content_delete、content_publish、content_unpublish、dam_delete、dam_force_delete、faq_question_delete、faq_topic_delete、preview_revoke、rbac_manage、user_delete、user_reset_password、user_reset_mfa。拼错一个字母的表现是「二次确认永远失败」。
  4. requireRolesrequirePermission 强度不同⁠,见缺陷 3。需要「立即生效」的权限变更,只能用 requirePermission⁠。
  5. ContentController::index 的 channel_id 过滤是手拼 SQL(:63-71):JSON_CONTAINS(channel_id, CAST('[17]' AS JSON), '$')⁠,先 $db->escape() 再拼串、并用 where(..., null, false) 关掉转义。改这里要格外小心;$cid 已经 (int) 过,目前是安全的。类似的裸 SQL 还有 DamController::assetsEXISTS(...)(:256-261)和 FAQ 列表里 join(... 'i.lang = ' . $db->escape($lang))(FaqQuestionController.php:46、FaqTopicController.php:39、FaqPageController.php:43、EntryRelationController.php:42)。
  6. CSP 是开着的Config/App.php:201 CSPEnabled = true⁠,策略见 Config/ContentSecurityPolicy.php⁠,script/style 均为 self⁠、autoNonce=true)。这就是 Figma 导入必须把 CSS 落盘成外部文件而不能内联 <style> 的原因(FigmaController.php:113-115 的注释写明了)。
  7. WorkflowService.php:63 等处往 EntryModel?datetime cast 字段塞字符串导致工作流提交 500 这个已知缺陷,从 HTTP 层看表现为 POST /api/workflow/submit 返回 CI4 的 HTML 错误页而非 JSON——前端会显示成网络错误,排查时容易被误导到接口层,实际要去 Service 层修。

七、修复建议的优先级

第一批(阻断上线):给 ContentController 与 ChannelController 的 CRUD 补 requirePermission⁠;删掉 public/test-putenv.php⁠。第二批(安全基线):requireRoles 改为查库并校验 is_active⁠;AuthFilter 增加 is_activeuser_sessions.revoked_at 两项检查(这一步同时把「登出即失效」和「禁止并发登录」两个功能真正接通)。第三批(可用性):修 AssetDeliveryController 的 stdClass 下标 bug;把 trace_id 打通到审计;给 /api/* 加一个 JSON 版的 404 与异常处理器(Config/Routing.phpoverride404 + 自定义 ExceptionHandler)。

建议同时做一件结构性的事:把权限码从控制器方法体里挪到路由声明上(CI4 的 filter 支持传参,['filter' => 'perm:content:edit']⁠),这样 Routes.php 就重新成为授权的事实来源,「新接口忘了加校验」会在 code review 时一眼可见。这项改造约需 3 人天,但能一次性消除本次审计里最主要的一类风险。

CMS 后端 · 数据模型与迁移(w2r.site/backend 持久层)
5,895
行 / 78 文件
6
上手人天
1.5
月维护人天
17
缺陷
理解难度
接手风险

主要风险

  • 干净库不可重建:迁移 2026-03-04-100031 的 created_by=1 + RESTRICT 外键把 51 条迁移卡在第 33 条,加上数据库导出缺失、RolePermissionSeeder 必须手动执行且非幂等,本模块目前不具备「一条命令从零起环境」的能力,灾难恢复严重依赖生产库的物理备份——而备份本身也不在交接物里。
  • 隐式约定密度高且无文档承载:entries.channel_id 是 JSON 数组而非外键、draft_of_entry_id 表示「已发布内容的编辑副本」、asset_categories 靠中文名 '网站素材' 字符串定位根节点、封面同时存在 entries.cover_asset_id / entry_i18n.og_asset_id / asset_entry_i18n(role=thumbnail) 三套机制。这些只在注释和代码里,任何一处误改都会在前台静默表现为「图没了」而非报错。
  • DataCaster 时间字段是运行时地雷:同一个 EntryModel,publish_at 等字段必须传 CodeIgniter\I18n\Time 对象,传字符串直接抛异常;而 UserModel 通过重写 setUpdatedField / transformDataToArray / convertToReturnType 三个框架方法来规避同一问题(UserModel.php:45-230)。新人按 UserModel 的写法去改 EntryModel,或反过来,都会踩坑,且错误只在特定接口被调用时才暴露。
  • 写入路径缺事务与缺校验叠加:25 个 Model 全部没有 validationRules,内容创建(ContentController.php:348-446)无事务,权限的 scope 字段是假开关。数据完整性完全依赖 MySQL 的严格模式与外键,换环境(sql_mode 不同)或删除 id=1 用户,都会以难以定位的方式出问题。
  • 模式定义存在双写:sitecore_files_import / sitecore_news_import 由迁移和 Python 脚本各建一份(collation 不同),role_permissions 由迁移 100051 和 Seeder 各写一份 figma:import。真实 schema 取决于执行顺序,无法只靠读迁移目录推断线上表结构,必须以生产库的 SHOW CREATE TABLE 为准。

一、范围与总体判断

本文覆盖 /Volumes/ProjectsAPFS/VWCORP/w2r.site/backend 的整个持久层:app/Database/Migrations/ 全部 51 个迁移、app/Database/Seeds/RolePermissionSeeder.php⁠、app/Models/ 全部 25 个模型、app/Config/Database.php⁠,合计 78 个文件、约 5,895 行。

一句话结论:表设计本身是合格的企业 CMS 结构(双语分表、两级审核、DAM 引用计数、RBAC 三表),但迁移链在干净库上跑不通,且有大量约束不在数据库、也不在模型,而是散落在 Service/Controller 里。 接手者可以放心读表结构,但不能假设「迁移目录 = 线上 schema」。


二、35 张表的最终形态

数据库连接见 app/Config/Database.php:27-52⁠:MySQLi、utf8mb4 / utf8mb4_general_ci、DBPrefix 为空、strictOn = false(不主动追加 STRICT_ALL_TABLES,实际严格性取决于服务器 sql_mode)。凭据全部走 .env(键名见 backend/.env:25-31⁠,值不在此转录)。迁移状态表名 migrations⁠,时间戳格式 Y-m-d-His_app/Config/Migrations.php:29,47⁠)。

以下按功能域分组,34 张业务表 + 1 张框架 migrations 表 = 35。

2.1 账号与安全(5 张)

关键列索引/外键用途
usersid, username, email, password_hash, role VARCHAR(50) 默认 editor, is_active, last_login_at, password_changed_at, created_at, updated_atUK(username)、KEY(role);无任何外键后台账号。role 是字符串,与 roles.code 在应用层 join(2025-01-19-100000:33⁠、2026-02-03-100026⁠)
mfa_secretsuser_id, provider_type 默认 totp, secret, backup_codes, is_enrolledUK(user_id, provider_type)、FK user_id→users CASCADETOTP 密钥与备份码(100001⁠)
user_sessionsuser_id, jti, ip, user_agent, created_at, revoked_atUK(jti)、KEY(user_id, revoked_at)、FK user_id→users CASCADEJWT 会话登记与吊销(2026-05-21-100060:11-52⁠)
operation_logsactor_id, actor_role, module, operation, object_type, object_id, timestamp, result ENUM(success,fail), error_code, trace_id, request_summary, ip, user_agentKEY(timestamp)、(actor_id)、(module,operation)、(object_type,object_id)、(trace_id);actor_id 无外键(故意,允许 actor_id=0 表示系统)审计日志(100002⁠)
audit_rate_bucketsbucket_key, event_type, window_start, hit_count, threshold_loggedUK(bucket_key,event_type,window_start)、KEY(event_type,window_start)异常行为限频计数(2026-05-21-100060:54-95⁠)

2.2 RBAC(4 张)

关键列约束
rolescode, name, description, is_activeUK(code)(100007⁠)
permissionscode, name, module, requires_confirm, requires_mfaUK(code)、KEY(module)(100008⁠)
role_permissionsrole_id, permission_id, scope ENUM(own,all) 默认 ownUK(role_id,permission_id)、双 FK CASCADE(100009⁠)
channel_role_permissionschannel_id, role_id, permission_id, policy ENUM(allow,deny) 默认 allowUK(channel_id,role_id,permission_id)、三 FK CASCADE(100010⁠)

2.3 内容(8 张)

关键列约束
channelsparent_id, name_zh/name_en, slug_zh/slug_en, sort_order, is_active, show_in_menu, menu_position, i18n_strategy ENUM(loose,strict)KEY(parent_id,sort_order)、FK parent_id→channels 自引用 CASCADE(100004 + 100027 + 100028⁠)
entriesid, uuid CHAR(36) NOT NULL, type, channel_id JSON, status ENUM(draft/review_l1/review_l2/published/unpublished/expired), draft_of_entry_id, sort_order, is_pinned, cover_asset_id, meta_json LONGTEXT, publish_at/unpublish_at/published_at/unpublished_at, created_by/updated_by, submitted_at, reviewed_l1_by/at/note, reviewed_l2_by/at/note, last_rejected_by/at/reasonKEY(status,type,publish_at,created_at,draft_of_entry_id);FK created_by→users RESTRICT、draft_of_entry_id→entries CASCADE;channel_id 与 cover_asset_id 无外键100005 + 100011 + 100029 + 100030 + 2026-06-12-100060⁠)
entry_i18nentry_id, lang ENUM(zh,en), title, summary, content_json LONGTEXT, content_text, files JSON, slug, seo_title, seo_desc, og_asset_id, schema_jsonUK(entry_id,lang)、KEY(slug) 非唯一、FULLTEXT(title,summary,content_text)、FK entry_id→entries CASCADE(100006 + 100012 + 100021 + 2026-02-25-100000⁠)
entry_relationsentry_id, related_entry_id, relation_type, sort_orderUK(entry_id,related_entry_id,relation_type)、双 FK→entries CASCADE(100014⁠)
workflow_actionsentry_id, action, from_status, to_status, note, reject_reason, actor_id, actor_roleKEY(entry_id,action,created_at)、FK entry_id CASCADE / actor_id RESTRICT(100015⁠)
preview_tokenstoken, entry_id, device, expires_at, revoked_at, created_byUK(token)、FK entry_id CASCADE(100016⁠)
entry_css_versionsentry_id, lang, filename, storage_key, size_bytes, checksum, source_url, created_byUK(filename)、KEY(entry_id,lang)、FK entry_id CASCADE(2026-04-29-100050⁠)
configkey(唯一), value TEXT, descriptionUK(key)(100003⁠)。注意 key 是 MySQL 保留字,写裸 SQL 必须加反引号

2.4 FAQ 子域(5 张,2026-01-24-100013 一个迁移建全)

faq_questions(题库主表,status draft/published/archived)、faq_question_i18n(UK question_id+lang)、faq_topics(UK slug)、faq_topic_i18n(UK topic_id+lang)、faq_question_topics(多对多)、faq_page_questions(把题目挂到 entries.type='faq_page' 的页面上,UK entry_id+question_id)。共 6 张——注意这一个迁移建了 6 张表,是全仓最大的单个迁移(313 行)。

2.5 资产 / DAM(7 张)

关键列约束
assetstype, status TINYINT(0草稿/1已保存/2已发布/3已修改), category_id, filename, title_zh, title_en, mime, size_bytes, storage_key, checksum, sitecore_id, sitecore_path, is_hidden, publish_date, meta_json, poster_asset_id, created_byUK(sitecore_id)、KEY(type,created_at,created_by,category_id,status)、FK created_by RESTRICT / category_id→asset_categories SET NULL(100017 + 100025 + 100036 + 100038 + 100039 + 100041 + 120000 + 100043⁠)
asset_variantsasset_id, quality(high/low), filename, mime, size_bytes, storage_key, checksum, sitecore_id, sitecore_pathUK(asset_id,quality)、FK asset_id CASCADE(100035 + 100037⁠)
asset_categoriesparent_id, channel_id, name, description, access_level(public/auth), sort_order, created_byUK(channel_id)、FK parent_id 自引用 SET NULL、FK channel_id→channels SET NULL、FK created_by RESTRICT(100024 + 100031⁠)
asset_refsasset_id, object_type, object_id, field_pathKEY(asset_id)、(object_type,object_id)、FK asset_id CASCADE(100018⁠)
asset_entry_i18nasset_id, entry_i18n_id, role(默认 thumbnail)UK(entry_i18n_id, role)、双 FK CASCADE(100033⁠,100040 是补建守卫)
upload_sessionsupload_id(UK), status(uploading/merged/failed), filename, mime, total_size, total_chunks, asset_idFK created_by RESTRICT、asset_id SET NULL(100019⁠)
upload_chunksupload_id, chunk_index, chunk_hash, size_bytesUK(upload_id,chunk_index)、FK upload_id→upload_sessions.upload_id(引用非主键的唯一列)CASCADE(100020⁠)

2.6 搜索与迁移临时表(4 张)

search_hotwords(lang+keyword 热词,带 start_at/end_at 生效窗口)、search_pins(关键词置顶到某 entry,UK lang+keyword+entry_id)、sitecore_files_import100034⁠,31 列的 Sitecore 文件导入落地表)、sitecore_news_import100044⁠,Sitecore 新闻导入落地表,UK sitecore_id+language_val)。


三、核心实体关系图

erDiagram
    USERS ||--o{ ENTRIES : "created_by (RESTRICT)"
    USERS ||--o{ ASSETS : "created_by"
    USERS ||--o| MFA_SECRETS : "1:1 totp"
    USERS ||--o{ USER_SESSIONS : "jti"
    USERS }o--|| ROLES : "users.role = roles.code 字符串,无 FK"

    ROLES ||--o{ ROLE_PERMISSIONS : ""
    PERMISSIONS ||--o{ ROLE_PERMISSIONS : ""
    ROLES ||--o{ CHANNEL_ROLE_PERMISSIONS : ""
    PERMISSIONS ||--o{ CHANNEL_ROLE_PERMISSIONS : ""
    CHANNELS ||--o{ CHANNEL_ROLE_PERMISSIONS : "栏目级 allow/deny"

    CHANNELS ||--o{ CHANNELS : "parent_id 自引用"
    CHANNELS ||--o| ASSET_CATEGORIES : "UK channel_id 镜像栏目树"
    CHANNELS }o..o{ ENTRIES : "entries.channel_id 是 JSON 数组,无 FK 无索引"

    ENTRIES ||--o{ ENTRY_I18N : "UK(entry_id,lang) zh/en"
    ENTRIES ||--o| ENTRIES : "draft_of_entry_id 修订稿"
    ENTRIES ||--o{ ENTRY_RELATIONS : "entry_id"
    ENTRIES ||--o{ ENTRY_RELATIONS : "related_entry_id"
    ENTRIES ||--o{ WORKFLOW_ACTIONS : "状态流水"
    ENTRIES ||--o{ PREVIEW_TOKENS : "预览链接"
    ENTRIES ||--o{ ENTRY_CSS_VERSIONS : "Figma 导入样式版本"
    ENTRIES ||--o{ FAQ_PAGE_QUESTIONS : "type=faq_page 时组装题目"

    ENTRY_I18N ||--o| ASSET_ENTRY_I18N : "UK(entry_i18n_id,role) 每语言一张封面"
    ASSETS ||--o{ ASSET_ENTRY_I18N : ""
    ASSETS ||--o{ ASSET_REFS : "引用计数,删除保护"
    ASSETS ||--o{ ASSET_VARIANTS : "UK(asset_id,quality) 高清变体"
    ASSET_CATEGORIES ||--o{ ASSETS : "category_id SET NULL"
    ASSET_CATEGORIES ||--o{ ASSET_CATEGORIES : "parent_id 树"
    UPLOAD_SESSIONS ||--o{ UPLOAD_CHUNKS : "FK 指向 upload_id 唯一列"
    UPLOAD_SESSIONS ||--o| ASSETS : "合并后产出 asset_id"

    FAQ_QUESTIONS ||--o{ FAQ_QUESTION_I18N : ""
    FAQ_QUESTIONS ||--o{ FAQ_QUESTION_TOPICS : ""
    FAQ_TOPICS ||--o{ FAQ_QUESTION_TOPICS : ""
    FAQ_TOPICS ||--o{ FAQ_TOPIC_I18N : ""
    FAQ_QUESTIONS ||--o{ FAQ_PAGE_QUESTIONS : ""

四、内容模型的概念解释(最需要先理解的一节)

4.1 channels:栏目树,同时是三种东西的锚点

channels 是自引用树(parent_id⁠),双语字段直接平铺在主表上(name_zh/name_en/slug_zh/slug_en⁠),不像 entries 那样分 i18n 表——这是全仓第一个不一致点。它承担三个职责:内容归属、栏目级权限(channel_role_permissions⁠)、以及资产分类的镜像源asset_categories.channel_id 唯一外键)。i18n_strategy 列(loose/strict)决定提交审核时是否强制要求英文完整,判定逻辑在 WorkflowService.php:231-255⁠。

4.2 entries + entry_i18n:语言无关骨架 + 每语言正文

entries 存所有与语言无关的东西:类型、栏目、状态、时间线、审核痕迹、排序、置顶。entry_i18n 每条 entry 最多两行(lang ENUM 只有 zh/en,UK(entry_id,lang)),存标题、摘要、正文 content_json⁠、slug⁠、SEO 字段、附件 files JSON、结构化数据 schema_json⁠。中英不是「翻译对」而是「同一骨架下的两份内容」——英文可以完全缺失(loose 策略下只警告,WorkflowService.php:250-252⁠)。

content_json 名字有误导:它既可能是编辑器的 JSON,也可能是纯 HTML 富文本。AssetRefService::refreshEntryRefsAssetRefService.php:52-68⁠)先尝试 json_decode⁠,失败就当 HTML 用正则扫 data-asset-id/asset/{id}⁠。content_text 是从中抽出的纯文本,专供 FULLTEXT 搜索。

4.3 channel_id 是 JSON 数组,不是外键

2026-02-10-100030entries.channel_id 从 BIGINT 改成 JSON([1][1,2]⁠),为的是让新闻可以同时挂多个栏目。代价是删掉了外键和索引,并且再没补回⁠。取「主栏目」的约定是取数组第 0 个元素,封装在 EntryModel::primaryChannelId()EntryModel.php:85-92⁠)——工作流查 i18n_strategy、权限查栏目授权都走它。筛选则用 JSON_CONTAINSContentController.php:70⁠),全表扫描。

4.4 uuid:对外标识

entries.uuid 是 CHAR(36),从 Sitecore 新闻迁移时直接复用源站 itemid(回填 SQL 见 2026-02-09-100029:28-32⁠),其余条目用 EntryModel::generateUuid():134-141⁠)生成。所有对外 API 既接受数字 id 也接受 uuid,统一由 EntryModel::resolveId():97-105⁠)解析。主键仍是自增 id,外键全部用 id。

4.5 草稿与已发布:两套并存的机制

第一套是状态机⁠。entries.status 六态:draft → review_l1 → review_l2 → published⁠,另有 unpublished(手动下线)、expired(到期自动下线)。每一次跃迁写一行 workflow_actions 流水。审核级数可配(config 表 key=workflow.rulesreview_levels⁠,WorkflowService.php:93-95⁠),设为 1 时初审通过直接发布。

第二套是修订稿(draft_of_entry_id⁠)⁠。已发布内容不能直接编辑,必须先「生成修订稿」:EntryDraftService::createDraftFromPublished():40-104⁠)克隆出一条新 entry,status='draft'⁠、draft_of_entry_id 指向原条目,并连带克隆 i18n、缩略图关联、entry_relations、faq_page_questions、CSS 版本。修订稿在列表页被过滤掉(ContentController.php:77draft_of_entry_id IS NULL⁠),只通过正式条目的 edit_draft_id 字段暴露。发布修订稿时走 publishDraftOverOriginal():111-162⁠):把内容合并回原条目、原条目保持 published、然后删除修订稿⁠。所以修订稿是临时物,不是版本历史——本系统没有内容版本历史,只有 CSS 有(entry_css_versions⁠)。

4.6 封面图有三条路径,这是最容易踩的坑

  1. entries.cover_asset_id⁠——建表时就有,但没有外键,且当前控制器写入路径不用它;
  2. entry_i18n.og_asset_id⁠——SEO 分享图,按语言;
  3. asset_entry_i18n(role='thumbnail')——实际在用的封面⁠,UK(entry_i18n_id, role) 保证每语言每角色一张,读取见 ContentController.php:100-114(优先中文、回退英文),写入见 :429/:437(用 MySQL REPLACE INTO⁠)。

asset_refs 是第四张相关表,但它不是封面,而是引用索引⁠:每次内容保存后 AssetRefService::refreshEntryRefs() 先删后建,扫出内容里引用的所有 asset_id,供 DAM 删除保护使用(有引用则禁止删除)。asset_variants 则是同一逻辑资产的高清版本(Sitecore EntityList 里的 HD 文件),UK(asset_id, quality)。


五、RBAC 数据模型与 22 个权限点

5.1 判定链路

users.role(字符串)→ roles.coderole_permissionspermissions.code⁠。判定代码在 PermissionService::hasPermission():40-49⁠),三表 join 且要求 roles.is_active=1⁠;注释明确写了不做 sys_admin 特例⁠,超管的全部权限来自数据。前端菜单靠 getPermissionCodes():101-108⁠)返回的 code 数组驱动。

栏目级授权是第二层:WorkflowService::checkChannelPermission():197-229⁠)查 channel_role_permissions⁠,deny 优先于 allow,无记录默认放行⁠,且 sys_admin 在这一层被硬编码短路(:199-201⁠)——与上一层的「不做特例」互相矛盾,是设计不一致处。

permissions.requires_confirm / requires_mfa 两个标志位驱动二次确认与 MFA 复验。

5.2 7 个角色(RolePermissionSeeder.php:28-36⁠)

editor 内容编辑 / reviewer_l1 初审 / reviewer_l2 终审 / publisher 发布人 / dam_admin 资产管理员 / sys_admin 系统管理员 / audit_readonly 只读审计。

5.3 22 个权限点(RolePermissionSeeder.php:53-93⁠)

#codemoduleconfirmmfa
1content:createcontent
2content:editcontent
3content:edit_allcontent
4content:deletecontent
5content:submitcontent
6content:publishcontent
7content:unpublishcontent
8workflow:review_l1workflow
9workflow:review_l2workflow
10workflow:configworkflow
11dam:uploaddam
12dam:deletedam
13dam:force_deletedam
14preview:createpreview
15preview:revokepreview
16rbac:managerbac
17mfa:reset_othermfa
18audit:readaudit
19audit:exportaudit
20config:exportsystem
21config:importsystem
22figma:importcontent

授权矩阵共 58 条:editor 8、reviewer_l1 7、reviewer_l2 8、publisher 8、dam_admin 3、sys_admin 全部 22、audit_readonly 2。注意 content:edit_all 只给了 sys_admin;publisher 没有 content:submit⁠。

figma:import 存在两个来源:2026-04-29-100051 迁移(幂等追加)和 Seeder 第 92 行。哪个生效取决于执行顺序,Seeder 会 DELETE 全表重来,所以最终以 Seeder 为准。

5.4 scope 是假开关

role_permissions.scope(own/all)在界面上可编辑(RoleController.php:199-206⁠)、会被导出到配置包(ConfigPromotionService.php:120⁠),但判定代码从不读它⁠。真正的「只能改自己的」是硬编码的 created_by != userId && role !== 'sys_admin'(如 EntryDraftService.php:59⁠、:176⁠)。改 scope 不产生任何行为变化,这一点必须写进运维手册,否则会有人以为改了权限。


六、迁移在干净库上的可重放性 —— 逐条排查结论

按时间戳顺序执行 51 条,逐条评估结果如下(只列有问题的):

硬阻断(1 条)

  • 2026-03-04-100031:35 插入「网站素材」根分类时 created_by => 1⁠,而 asset_categories.created_by 是 RESTRICT 外键(100024:64⁠)。干净库 users 为空 → MySQL 1452 → 迁移在第 33 条中断。后续 18 条全部不执行。绕过方式:先 spark migrate --to 2026-02-25-100000⁠,再 spark admin:create-super⁠,再继续 migrate;根治方式是把 created_by 改成动态取 SELECT MIN(id) FROM users⁠,或新增一条「引导管理员」迁移排在 100024 之前。

数据错位(2 条)

  • 2026-03-12-100042:17 把 7 个 Sitecore channel GUID 映射到 asset_categories.id 2–8。这些 id 是原开发者机器上 100031 同步既有栏目树后的偶然结果,干净库上 asset_categories 只有 id=1。
  • 2026-03-04-100032 合并重复「网站素材」根节点,是针对 100031 被重复执行过的历史数据修复;干净库上 count($rows) <= 1 直接 return,无害但也说明 100031 曾在生产上跑重过。

约束可能没建上(1 条)

  • 2026-02-09-100029:21forge->addColumn(['uuid' => [... 'unique' => true]]) 声明唯一性。CI4 的 ALTER TABLE ADD COLUMN 路径对 unique 属性的处理不保证生成索引,全仓也没有别的地方建这个唯一键。接手第一步:SHOW INDEX FROM entries WHERE Column_name='uuid' 核实⁠,缺了就补。

不幂等 / 重跑必炸(4 条)

  • 2026-01-27-100025:24-25idx_category_id⁠、fk_assets_category⁠)、2026-03-04-100031:15-24idx_parent_id⁠、fk_asset_categories_parent⁠、uk_channel_id⁠、fk_asset_categories_channel⁠)、2026-03-11-100036:30idx_sitecore_id⁠)都是无守卫的裸 DDL。由于 100031 本身会中途失败,它前半段的 ALTER 已经生效但迁移未记账,重跑必然撞 Duplicate key name,只能手工 DROP 后重来。
  • 反面样板:100041(先查 INFORMATION_SCHEMA 再建 UK)、100040(tableExists 守卫)、100043(fieldExists 守卫)、2026-02-25-100000(fieldExists 守卫)写得是对的。

静默降级(1 条)

  • 2026-01-24-100021:24-28 建 FULLTEXT 索引整个包在 try/catch 里,失败不报错不记录。搜索会静默退化成 LIKE(SearchService.php:103-109 有运行时探测与回退,所以不会 500,但也永远不会有人发现索引没建上)。

前缀 / 环境假设(2 条)

  • 2026-02-10-100030:19-27,41 混用带前缀(getPrefix()⁠)与不带前缀的字面表名,且删索引时用 LIMIT 1 取 STATISTICS 第一行——若 channel_id 参与了复合索引,会删错索引。
  • 2026-03-12-120000:33-42 对 LONGTEXT 的 meta_jsonJSON_EXTRACT⁠。干净库 assets 为空无事;从生产备份恢复时,只要有一行 meta_json 不是合法 JSON,MySQL 8 报 3141 中断迁移。

down() 有损(3 条)

  • 100030 的 down 用 $[0] 把 JSON 压回单值,多栏目关联丢失;100032 的 down 是空实现;100031 的 down 直接 DELETE 所有 channel_id 非空的分类。这一层没有安全回滚,rollback 前必须先备份。

跨源双写(2 条)

  • sitecore_files_import / sitecore_news_import 同时由迁移(100034⁠、100044⁠)和 Python 脚本(scripts/import_sitecore_files.py:321⁠、scripts/import_sitecore_news.py:306⁠,CREATE TABLE IF NOT EXISTS⁠)创建,脚本显式用 utf8mb4_unicode_ci⁠,迁移走默认 utf8mb4_general_ci⁠。先跑脚本则迁移静默跳过(createTable(..., true)⁠),两条路径产出的 collation 不同。

迁移与 Seeder 互相依赖

  • 2026-04-29-100051 需要 roles/permissions 表里已有数据,而这些数据只来自 Seeder。干净库上它会 continue 掉所有角色授权、只插一条 permission;随后 Seeder 又把 permissions 全表 DELETE 重来。最终结果正确纯属巧合(Seeder 列表里也有 figma:import)。正确的新库启动顺序是:migrate(含 100031 绕过)→ spark admin:create-superspark db:seed RolePermissionSeeder⁠,且 Seeder 在已有库上禁止重跑。

七、模型层的实现约定与坑

  1. DataCaster 时间字段⁠:CI4 4.7 的 datetime cast 在写入时只接受 CodeIgniter\I18n\Time 实例。EntryModel.php:50-59 把 8 个时间字段声明成 ?datetime⁠,于是 WorkflowService.php:63 传字符串就抛异常(提交审核 500);同一个 Service 的 :99/:112/:128/:131/:157/:159/:174/:177/:276 是同样的写法。正确样板是 PublishService.php:27/118/134AssetRefService.php:145-148(返回 Time 对象)。
  2. UserModel 的三个方法重写:45-59 setUpdatedField、:65-151 transformDataToArray、:188-230 convertToReturnType)以及 updateLastLogin() 直接走裸 SQL(:235-249⁠),全都是为了绕开同一个 cast 问题。注释里写得很直白(:30-32⁠)。不要把 UserModel 的写法照搬到别的模型,也不要给 UserModel 加回时间字段的 cast。
  3. useTimestamps 混用⁠:EntryModel/EntryI18nModel/ChannelModel/ConfigModel 等开启(框架自动写 created_at/updated_at),AssetModel/AssetRefModel/WorkflowActionModel/PreviewTokenModel 等关闭并把 created_at 放进 allowedFields 由调用方自己传。判断依据是模型里的 protected $useTimestamps⁠,没有统一规则。
  4. JSON cast 只在模型路径生效⁠:EntryModelchannel_id => 'json':47⁠)让模型查询返回 PHP 数组,但 SearchService.php:88 用裸 builder 查同一列,返回的是原始 JSON 字符串。所以同一个字段在不同 API 里类型不一致,前端要两套处理(ContentController::normalizeChannelIdForResponse 就是补丁)。
  5. 8 张表没有模型⁠:roles / permissions / role_permissions / channel_role_permissions / user_sessions / audit_rate_buckets / asset_entry_i18n / 两张 sitecore 导入表,全靠 $db->table('...') 裸写。改这些表结构必须全仓 grep。
  6. 全部模型没有校验规则⁠:25 个模型的 $validationRules 都是空数组,唯一的输入校验散在控制器里,且很不完整(ContentController.php:327-329 的报错文案说「类型、栏目、中文标题和slug不能为空」,实际只检查了栏目)。

八、接手第一周建议动作

  1. 先在跑通的 Docker 库上导出 SHOW CREATE TABLE(34 张)作为真实 schema 基线⁠,不要以迁移目录为准;重点核实 entries.uuid 的唯一索引、ft_entry_i18n_search 是否存在、两张 sitecore 表的 collation。
  2. 补一条迁移修掉 100031 的 created_by(或加引导管理员迁移),使 spark migrate 在空库上一次跑通;这是恢复能力的前提。
  3. WorkflowService 的 9 处时间字符串(改成 Time::now(config('App')->appTimezone)⁠),并给 ContentController::create 包事务、补 type/title/slug 校验。
  4. entries.channel_id 建生成列 + 索引(或改成 entry_channels 关联表),恢复栏目筛选的可扩展性与引用完整性。
  5. role_permissions.scope 要么接进 PermissionService⁠,要么从界面上摘掉——留着比删掉更危险。
  6. 应用时区固定为 Asia/Shanghaiapp/Config/App.php:136⁠),所有 DATETIME 列都按本地时间裸存、无时区信息;任何跨时区需求都要先改这一层的约定。
CMS 后端 · 服务层(w2r.site/backend/app/Services/ + Services/Figma/ + app/Helpers/)
6,905
行 / 32 文件
8
上手人天
2.5
月维护人天
16
缺陷
理解难度
接手风险

主要风险

  • WorkflowService 的私有 now() 返回 string,向 EntryModel/WorkflowActionModel 的 datetime cast 字段写入,CI 4.7.2 的 DatetimeCast::set() 只接受 CodeIgniter\I18n\Time 实例,导致提交审核/初审/终审四条路径全部 500。这是 Services 层唯一残留的同类缺陷簇,共 11 个调用点。
  • user_sessions 表只写不读:AuthFilter 与 ApiController 从不校验 jti 是否已 revoke,登出与「禁止并发登录」都只是记账,被撤销的 JWT 在 exp(2 小时)内依然全权可用。
  • MFA 密钥与备用码只做 base64 编码却在注释里自称「加密存储」,且 /api/auth/mfa/verify 完全没有节流,6 位 TOTP 加 ±1 时间窗可被无限次爆破。
  • 发布链绕过审核状态机:PublishService::manualPublish 只查 content:publish 权限、不查 status,runSchedule 只看 publish_at 不看 status,草稿与在审内容都能直接变 published,且这两条路径不写 workflow_actions,审核链审计出现断点。
  • 栏目级授权 checkChannelPermission 默认放行且硬编码 sys_admin 特例,与 PermissionService 明确声明的「不做 sys_admin 特例」互相矛盾;entries.channel_id 已改为无索引的 JSON 列,栏目维度查询全表扫。
  • auth/RBAC/workflow/publish/DAM 五个核心域零单测,tests/ 下 576 行测试全部只覆盖 Figma 子服务。任何改动都没有回归网。

说明:本文只覆盖 w2r.site/backend/app/Services/(24 个顶层服务)与 Services/Figma/(8 个),共 32 个文件、6,905 行。app/Helpers/ 是空目录,0 个文件⁠——交接清单里若写了「Helpers 若干」,那是不存在的东西。所有行号均已按仓库真实文件核对。

0. 先说三件必须知道的事

0.1 全层唯一的硬伤:?datetime cast 只吃 Time 对象

CI 4.7.2(backend/system/CodeIgniter.php:58 确认版本)的 DatetimeCast::set() 写死了类型断言:

// system/DataCaster/Cast/DatetimeCast.php:50-57
public static function set(mixed $value, ...): string {
    if (! $value instanceof Time) {
        self::invalidTypeValueError($value);   // 抛 InvalidArgumentException
    }

只接受 CodeIgniter\I18n\Time⁠,字符串不行,DateTime/DateTimeImmutable 也不行⁠。另有一条容易漏的规则:system/DataCaster/DataCaster.php:152-156⁠,Model 侧的 DataCaster 是 strict = trueDataConverter.php:66 构造时不传 strict,默认 true),所以往没有 ? 前缀的 cast 字段传 null 同样抛异常(Field "x" is not nullable, but null was passed.⁠)。

好消息:created_at / updated_at 的自动时间戳不受影响⁠。BaseModel.php:888-890 显示 setCreatedField/setUpdatedField 是在 transformDataToArray()(做 cast 的地方,:886)之后才注入的,注入的字符串绕过了 caster。所以只有「调用方显式传了这个 key」时才会撞上——setCreatedField 里的 ! array_key_exists(...) 判断(BaseModel.php:931-936⁠)正是这个含义。

判定规则,接手后请当成 checklist 用:

任何 Model->insert()/update()/save() 的数组里出现了该 Model $casts 中声明为 datetime?datetime 的键,值必须是 Time 实例(?datetime 额外允许 null⁠)。否则 500。

0.2 已知缺陷清单里有一条是过时的,请更正

交接材料称「PublishService 的 manualPublish 已用 nowTime() 修复但 runSchedule() 第 46/68 行未修」。按当前文件状态,这条不成立⁠:

// Services/PublishService.php:27
$now    = $this->nowTime();              // 返回 Time(见 :138-141)
$nowStr = $now->format('Y-m-d H:i:s');   // 字符串只用于 SQL 比较
...
// :46 与 :68
'published_at'   => $now,                // 传的是 Time 对象,不是字符串
'unpublished_at' => $now,

PublishService 四个写入点(:46、:68、:118、:133)全部已经是 Time⁠,整个文件干净。真正没修的是 WorkflowService⁠,见下。

0.3 Services 层 ?datetime 缺陷穷举结果

我对 Services 层全部 37 处 Model 写入调用逐一核过(grep -rn "Model->insert(\|Model->update(\|model->insert(\|model->update(" Services/⁠)。残留缺陷全部集中在 WorkflowService 一个文件⁠,根因是它自己的私有 now() 返回字符串:

// Services/WorkflowService.php:280-288
private function now(): string      // ← 根因,返回 date('Y-m-d H:i:s')
#文件:行号字段目标 Modelcast 声明
1Services/WorkflowService.php:63submitted_atEntryModel?datetime (EntryModel.php:54)
2Services/WorkflowService.php:99reviewed_l1_atEntryModel?datetime (:55)
3Services/WorkflowService.php:101published_atEntryModel?datetime (:52)
4Services/WorkflowService.php:112reviewed_l1_atEntryModel?datetime
5Services/WorkflowService.php:128reviewed_l1_atEntryModel?datetime
6Services/WorkflowService.php:131last_rejected_atEntryModel?datetime (:57)
7Services/WorkflowService.php:157reviewed_l2_atEntryModel?datetime (:56)
8Services/WorkflowService.php:159published_atEntryModel?datetime
9Services/WorkflowService.php:174reviewed_l2_atEntryModel?datetime
10Services/WorkflowService.php:177last_rejected_atEntryModel?datetime
11Services/WorkflowService.php:276created_atWorkflowActionModeldatetime⁠,且 useTimestamps = false(WorkflowActionModel.php:30, :34)

第 11 条最容易被漏修:recordAction() 写的是另一张表,useTimestamps=false 意味着框架不会自动补 created_at⁠,代码必须自己传——所以哪怕把前 10 处的 entry 更新修好了,submit()/两级审核每一条都还会在写 workflow_actions 时二次崩溃。

修复方式⁠:把 WorkflowService::now() 改成 nowTime(): Time⁠,与 PublishService.php:138-141⁠、EntryDraftService.php:549-552⁠、PreviewService.php:116-119⁠、AssetRefService.php:145-148 保持一致(这四个已经是正确写法)。注意 :276 若继续走 Model 就传 Time⁠,若改走 $db->table() 就传字符串——别混⁠。

已核对为安全(无需改动)的写入点⁠,列出来是为了让接手者别做无用功:

  • PublishService.php:46/68/118/133Time⁠,安全。
  • PreviewService.php:41/44/58now()/parseDateTime() 都返回 Time(:116-125),revoked_at => null 命中 ?datetime(PreviewTokenModel.php:28-32),安全。
  • EntryDraftService.php:139/140/142/248/249$draft->publish_at 来自 find()⁠,已被 DatetimeCast::get() 转成 Time⁠;$now 来自 nowTime()⁠。安全。
  • DamUploadService.php:69/70/281/311/456/490/501 — 每处都先 Time::createFromFormat('Y-m-d H:i:s', $now)(:59, :265, :291, :306, :436, :484, :500),且源码里 :263-264 明确写了「DataCaster 的 DatetimeCast::set() 期望 Time 对象」,说明原作者踩过这个坑并修好了。安全。
  • AssetCategorySyncService.php:53/54/75Time::now()⁠。安全(但另有 created_by 硬编码问题,见 §3.6)。
  • AssetRefService.php:88now() 返回 Time(:145-148)。安全。
  • Figma/EntryCssVersionService.php:94⁠、Figma/FigmaImageToDamService.php:306 — 都先转 Time⁠。安全。
  • AuditLogger.php:75/77OperationLogModel$casts 故意留空(OperationLogModel.php:35-38 有注释说明「不设置 cast,避免 DataCaster 插入时类型转换错误」),传字符串正确。安全。
  • ChannelRolePermissionService.php:79/80⁠、UserSessionService.php:89/90/107/116⁠、AuditAnomalyService.php:142/153/162⁠、EntryDraftService.php:287/310/312/338/340/413/438/440/468/470 — 全部走 $db->table()->insert()/update() 绕过 Model⁠,不经 caster,传字符串正确。安全。

Services 层之外的同类缺陷(不在本次范围,但根因一致,建议同批修): Controllers/Api/SearchHotwordController.php:63-68start_at/end_at 直接透传 request 值,created_at/updated_at$this->now() 字符串)与 :114⁠;Controllers/Api/SearchPinController.php:72-76:122⁠。两个 Model 都把 start_at/end_at/created_at/updated_at 声明成不可空'datetime'(SearchHotwordModel.php:30-38、SearchPinModel.php:30-38),所以给值撞类型断言、不给值撞 not-nullable 断言,新建/编辑热词与置顶在任何入参下都必炸⁠。

其余控制器写入点已核对安全:EntryRelationController.php:115/117⁠、FaqPageController.php:116/118⁠、ContentController.php:433/441/663/675(均走 $db->table()⁠,且源码里有注释「Query Builder 的 insertBatch 需要字符串格式」)、AssetCategoryController.php:124/125/220⁠、FaqQuestionController.php:180/278Time::now()⁠)、RoleController.php:189/207/208(无对应 Model,走 QB)。


1. 分层与总体形态

Controllers/Api/*            ← HTTP 边界,权限门禁 + 审计埋点
  └── Services/*             ← 本文范围,无接口无抽象,全是 new 出来的具体类
        └── Models/*         ← CI4 Model,casts 是隐形雷区(见 §0)
              └── MySQL 8

共性特征,先看懂这几条能省一天:

  1. 无 DI 容器⁠。所有服务都在构造函数里 new XxxModel()⁠,服务间也是 new(如 PublishService.php:99 new EntryDraftService⁠、ConfigPromotionService.php:22-25 new 四个)。只有 Figma 那一支做了构造函数注入(FigmaImportService.php:24-36⁠),因此也只有它有单测。
  2. 返回值有两种风格⁠,不统一:业务服务返回 ['ok' => bool, 'code' => int, 'message' => string, 'data' => array] 数组(Workflow / Preview / EntryDraft / DamUpload / ConfigPromotion / Search),基础设施服务直接返回标量或 bool(Auth / Jwt / Permission / PasswordPolicy)。AuditLogger::log() 是唯一的静态方法。
  3. 有四套 now()⁠,返回类型不一致⁠——这是 §0 缺陷的土壤:
  4. 返回 Time⁠:PublishService.php:138⁠、PreviewService.php:116⁠、EntryDraftService.php:549⁠、AssetRefService.php:145
  5. 返回 string⁠:WorkflowService.php:280⁠、DamUploadService.php:540⁠、SearchService.php:211⁠、AuditLogger.php:62-66(内联)

其中 DamUploadServiceAuditLogger 返回字符串是对的(前者随后转 Time⁠,后者目标 Model 无 cast),WorkflowService 返回字符串是错的⁠。接手后建议统一成 Time⁠,字符串场景显式 ->format()⁠。

  1. 异常几乎不外抛⁠。业务错误走返回值,底层异常靠 catch-all 吞掉(JwtService.php:93-96⁠、SearchService.php:205-207⁠、WorkHoursService.php:60-62⁠)。好处是接口不 500,坏处是配置错误无声无息——JwtService 的密钥问题(§3.8)就是这么被藏起来的。
  2. 测试覆盖极不均衡⁠。backend/tests/unit/Figma/ 下 5 个文件 576 行,覆盖 Figma 子服务;auth 链、RBAC、workflow、publish、DAM 零测试⁠。

2. 逐服务速查表

服务行数职责主要调用方依赖
AuthService310密码 hash/校验、TOTP 生成与验证、备用码AuthController、UserController、4 个 Command、PreviewServiceusers/mfa_secrets⁠;random_bytes⁠、hash_hmac
JwtService308签发/验签/刷新/解析 JWT、从请求取 tokenAuthFilter、AuthNoMfaFilter、ApiController、AssetDeliveryController、AuthControllerfirebase/php-jwt⁠、Config\Jwt
LoginThrottleService144登录失败按 IP 节流与锁定AuthController、PreviewControllerCache
PasswordPolicyService92密码复杂度与有效期校验AuthController、UserController、2 个 CommandConfig\Auth
ConfirmTokenService63危险操作二次确认票据(5 分钟一次性)10 个控制器Session
UserSessionService118jti 会话登记、并发登录检测与撤销AuthController、AuditAcceptanceExtendeduser_sessions⁠、config
CaptchaService248图形验证码生成与校验AuthControllerCache、GD 扩展
PermissionService112全局 RBAC 判定与权限码列表ApiController、AuthController、ChannelControllerusers/roles/permissions/role_permissions
ChannelRolePermissionService103栏目级授权的读取与全量替换ChannelController、ConfigPromotionServicechannel_role_permissions
WorkflowService290提交/初审/终审状态机、栏目级授权判定、工作流配置WorkflowController、ConfigPromotionServiceentries/workflow_actions/channels/config
WorkHoursService82工作时间配置与「是否非工作时间」判定AuthController、AuditAnomalyService、AuditPolicyServiceconfig⁠、DateTimeZone
PublishService143定时发布/下线、手动发布/下线PublishController、RunPublishSchedule、PublishPagesAsUserentries
PreviewService136预览令牌签发/撤销/校验PreviewControllerpreview_tokens/entries
EntryDraftService553已发布内容的修订稿:克隆 → 合并回写 → 删副本ContentController、PublishServiceentries/entry_i18n/entry_relations/faq_page_questions/asset_entry_i18n/entry_css_versions⁠;文件系统
DamUploadService550分片上传会话、分片落盘、合并入库、高清变体DamControllerupload_sessions/upload_chunks/assets/asset_variants⁠;文件系统⁠、Config\Dam
AssetRefService150扫描内容中的资产引用并重建 asset_refsContentController、EntryDraftServiceasset_refs/entries/entry_i18n
AssetCategorySyncService78栏目 ↔ 资产分类树镜像同步ChannelControllerasset_categories/channels
AuditLogger112审计写入 + 敏感字段脱敏28 个文件(几乎所有控制器与 Command)operation_logs
AuditPolicyService151审计相关阈值/开关的读写ApiController、AuditController、SystemConfigControllerconfig⁠、WorkHoursService
AuditAnomalyService181频率桶统计与阈值告警ApiController、AuditController、UserControlleraudit_rate_buckets⁠、WorkHoursService、AuditLogger
ConfigPromotionService235配置导出/导入(workflow/seo/rbac/channel_auth/channels)ConfigPromotionController上述多个服务 + 多表
SearchService221前台搜索、热词、置顶、联想SearchControllerentries/entry_i18n/search_*⁠;MySQL FULLTEXT
SeoSchemaService307SEO 配置、JSON-LD 生成/合并/校验SeoConfigController、ConfigPromotionServiceconfig
ContentTextService64content_json → 可索引纯文本ContentController
Figma/FigmaClient258Figma REST 调用、链接解析、二进制下载FigmaImportServicecURL⁠、Config\Figma
Figma/FigmaToHtmlCssService842节点树 → HTML + CSSFigmaImportService
Figma/FigmaImageToDamService316导出图入 DAM 并回填 <img src>FigmaImportServiceassets⁠、文件系统、DOM 扩展
Figma/FigmaImageResizeService124按设计尺寸 × 倍率压缩FigmaImageToDamServiceGD 扩展
Figma/FigmaSanitizerService142HTML 白名单净化FigmaImportServiceDOM 扩展
Figma/FigmaImportService243导入管线编排FigmaController上述五个
Figma/EntryCssVersionService209页面 CSS 版本化落盘FigmaController、EntryDraftServiceentry_css_versions⁠、文件系统
Figma/FigmaApiException20异常类型

PHP 扩展依赖汇总(部署前必须确认):gd(CaptchaService、FigmaImageResizeService)、curl(FigmaClient)、dom/libxml(FigmaSanitizerService、FigmaImageToDamService)、fileinfo(FigmaImageToDamService.php:220-227,有降级)、mbstring⁠、hash⁠、json⁠。


3. 鉴权链(重点展开)

3.1 完整登录时序

POST /api/auth/captcha   → CaptchaService::generate()   写 Cache(captcha_md5(token)),返回 base64 PNG
POST /api/auth/login
  ├─ LoginThrottleService::checkLock(ip)          AuthController.php:58
  ├─ CaptchaService::verify(token, code)          :102       ← 失败 recordFailure
  ├─ AuthService::login(username, password)       :144       ← password_verify + is_active
  ├─ LoginThrottleService::clearThrottle(ip)      :182
  ├─ session: user_id / user_role / mfa_verified=false      :185-189
  ├─ JwtService::generate(uid, role, mfa=false)   :205       ← 此时已发 token
  └─ PasswordPolicyService::isPasswordExpired()   :213
POST /api/auth/mfa/setup  → AuthService::generateMfaSecret()   写 mfa_secrets,is_enrolled=0
POST /api/auth/mfa/verify
  ├─ AuthService::verifyMfaCode()                 :480       ← 无节流(见 §3.5)
  ├─ completeMfaEnrollment()(首次)              :498-500
  ├─ session mfa_verified=true                    :503
  ├─ JwtService::generate(uid, role, mfa=true)    :510       ← 换发第二枚 token
  └─ UserSessionService::establishAuthenticatedSession()  :535-543

注意⁠:MFA 未通过时已经发了一枚 tokenAuthController.php:205⁠)。这枚 token 的 mfa_verified=false⁠,AuthFilter 会在 :50-59 挡下(返回 1002);但 AuthNoMfaFilter 保护的路由不挡。接手时要清楚哪些路由挂了哪个 filter(见 app/Config/Filters.php⁠)。

3.2 请求鉴权(Filters/AuthFilter.php⁠)

// AuthFilter.php:21-35
if ($token) { $payload = $jwtService->verify($token); ... }
if (!$userId) {                       // ← JWT 失败静默回退到 Session
    $userId = session()->get('user_id');
    $mfaVerified = session()->get('mfa_verified') ?? false;
}

两个坑:

  • JWT 验签失败与「没带 token」不可区分⁠,都无声回退 Session。密钥配错(§3.8)时表现为「时灵时不灵」,没有任何日志线索。
  • 完全不查 user_sessions⁠。见 §3.4。

ApiController::getCurrentUserId()(:77-93)/ getCurrentUserRole()(:100-116)/ getMfaVerified()(:123-137)是同一套「JWT 优先、Session 兜底」逻辑的第二份拷贝。改鉴权语义要同时改这两个地方⁠。

3.3 JwtService 细节

  • generate()(:48-75):payload 结构 {iss, aud, iat, nbf, exp, jti, data:{user_id, role, mfa_verified}}⁠。jti 可由 $additionalClaims['jti'] 覆盖,refresh()(:210-240)正是靠这个保持 jti 不变⁠,从而不破坏 user_sessions 关联。
  • verifyAllowExpired()(:104-126):签名通过但已过期时仍返回 payload,用于登出审计(「谁退出的」)。过期超过 refreshExpiration 才拒绝。设计合理。
  • claimsData()(:134-146)是静态工具,处理 data 可能是 stdClass 的情况——所有读 claims 的地方都必须走它⁠,直接 $payload['data']['user_id'] 会在某些解码路径上拿不到。
  • getTokenFromRequest()(:181-202)接受三个来源:Authorization header、jwt_token cookie、?token= query 参数⁠。query 传 token 会进 access log 与 Referer,是给 AssetDeliveryController 用的便利,但也是泄露面。

3.4 会话撤销是摆设(高危)

UserSessionServiceuser_sessions(:84-91)、撤销(:100-117)、检测并发(:45-74)都实现了,但全仓没有任何地方读它做鉴权判断⁠:

grep -rn "user_sessions" app/  →  只命中 Services/UserSessionService.php 自身

后果:

  • AuthController.php:298 登出时把 jti 标 revoked,但那枚 JWT 在 exp(默认 7200 秒,Config/Jwt.php:35⁠)内继续全权可用。
  • auth.allow_concurrent_sessions=0 时,UserSessionService.php:77-82 批量把旧会话 revoked_at 置位,旧终端毫无感知地继续工作⁠。这个开关在 UI 上是有的(AuditPolicyService::KEY_ALLOW_CONCURRENT⁠),运维会以为它生效了。
  • 禁用/删除用户后已签发 token 仍能过 AuthFilterPermissionService 会在权限层拦住,因为它查 is_active⁠,但 filter 层先放行了)。

修法⁠:在 AuthFilter::before 里用 JwtService::extractJti() 取 jti,查 user_sessions.revoked_at IS NULL⁠,结果进 Cache(TTL 30-60 秒)避免每请求一次 DB。

3.5 MFA 三个问题

  1. 密钥不是加密是编码⁠。AuthService.php:110-112 注释写「加密存储(Base64编码)」,实际就是 base64_encode⁠。:217 读回来 base64_decodebase32Decode⁠。拿到 mfa_secrets 表 = 拿到所有人的 TOTP 种子 + 8 个明文备用码。
  2. verify 无节流⁠。LoginThrottleServiceAuthController 里只出现在 login()(:58/83/104/125/147/182),mfaVerify()(:453-495)一次都没调。verifyMfaCode 还接受 ±1 时间步(:240-247),单请求命中率 3/10⁶,可无限并发爆破。
  3. reset 后 verify 会 500⁠。resetMfa(:304-308)把 secret/backup_codes 写成 ''保留行⁠;verifyMfaCodefindByUserAndProvider 仍返回该行,:221json_decode(base64_decode('')) 得到 null⁠,:222in_array($code, null, true) 在 PHP 8 抛 TypeError。

另有两处小雷:AuthService.php:77:134 都是 $this->userModel->find($userId)->username⁠,无 null 检查,用户被删则致命错误。

3.6 其余环节

  • ConfirmTokenService(63 行)用 Session 存票据(:30、:40)。10 个控制器依赖它做危险操作二次确认。风险:管理后台是 JWT 无状态鉴权,这里却要求有 Session cookie;前端若跨域/不带 cookie,二次确认永远失败。另外 verify() 只在过期时删票据(:49-50),用户 ID/操作不匹配时(:54-56)不删,可被反复试。
  • PasswordPolicyService(92 行)⁠:干净,逻辑全在 Config/Auth.php(min 8、大小写数字特殊字符全开、passwordMaxDays = 90⁠)。唯一瑕疵是 :29strlen() 而非 mb_strlen()⁠,中文密码的长度按字节算(偏松,不是安全问题)。
  • CaptchaService(248 行)⁠:见缺陷表。核心问题是 :162WRITEPATH.'fonts/arial.ttf' 在交接件里必然不存在(backend/writable/ 整个缺失),所以永远走 imagestring 的位图分支,验证码强度极低。

4. RBAC 求值算法

系统里有两套并存且语义不同的授权,这是最容易写出越权 bug 的地方。

4.1 全局 RBAC — PermissionService

// PermissionService.php:39-49
$result = $db->table('role_permissions')
    ->join('roles',       'roles.id = role_permissions.role_id')
    ->join('permissions', 'permissions.id = role_permissions.permission_id')
    ->where('roles.code', $user->role)     // 用户只有一个 role(users.role 是字符串码)
    ->where('roles.is_active', 1)
    ->where('permissions.code', $permissionCode)
    ->get()->getRow();
return $result !== null;                   // 存在即允许,纯白名单

关键性质:

  • 用户 ↔ 角色是 1:N=1users.role 存 role code 字符串,不是关联表)。
  • 纯白名单,无 deny 概念⁠。
  • 无 sys_admin 特例⁠——:38:99 两处注释明确声明。sys_admin 的权限完全靠 role_permissions 里配全。这意味着 seed 数据一旦丢失,超管什么都干不了⁠。
  • 每次调用 2 次查询(find($userId) + join),无缓存⁠。ApiController::checkPermission()(:145-154)每次 new PermissionService()⁠。WorkflowController::actions()(:260-263)连调 3 次 → 6 次查询。

4.2 栏目级 ACL — WorkflowService::checkChannelPermission + ChannelRolePermissionService

// WorkflowService.php:197-229
if ($roleCode === 'sys_admin') return true;          // ← 硬编码特例,与 §4.1 矛盾
$rows = ... channel_role_permissions ⋈ roles ⋈ permissions
        where channel_id = ? and r.code = ? and p.code = ?
if (empty($rows)) return true;                        // ← 无配置 = 放行
foreach ($rows as $r) if ($r['policy'] === 'deny')  return false;   // deny 优先
foreach ($rows as $r) if ($r['policy'] === 'allow') return true;
return true;                                          // ← 兜底也是放行

求值顺序:sys_admin 直通 → 无规则放行 → deny 优先 → allow → 默认放行⁠。

这套 ACL 只能做减法⁠。运营若期望「只给编辑 A 开放栏目 X」,配了 allow 也没用——因为其它栏目没配规则同样默认放行。要实现真正的隔离必须给所有栏目铺 deny 规则,实践上没人会这么做。

ChannelRolePermissionService::replaceForChannel(:46-102)是全量替换语义:先 delete 整个 channel 的规则(:55)再逐条 insert,包在 transStart/transComplete 里(:53/:90)。逐条 insert 前还各查一次 roles/permissions 存在性(:68-69)——N 条规则 = 2N+1 次查询,规则多时慢。

调用点⁠:只有 WorkflowController 的 submit/reviewL1/reviewL2 三处(:48、:122、:196)在用栏目级判定。PublishController⁠、ContentController 都不调⁠——所以栏目级 deny 挡得住「提交审核」,挡不住「直接发布」。

4.3 权限码给前端

PermissionService::getPermissionCodes()(:84-111)返回当前用户全部权限码,前端拿去控制菜单与路由。它和 hasPermission 是两条独立的查询⁠,语义必须保持一致——改一个忘了另一个,就会出现「菜单能看到但点了报无权限」。


5. 编辑工作流状态机

5.1 状态图

stateDiagram-v2
    direction LR
    [*] --> draft: 新建内容

    draft --> review_l1: submit<br/>WorkflowService.php:45,63
    unpublished --> review_l1: submit(允许)
    expired --> review_l1: submit(允许)

    review_l1 --> published: reviewL1 pass<br/>且 review_levels<=1<br/>:94-107
    review_l1 --> review_l2: reviewL1 pass<br/>且 review_levels>=2<br/>:109-118
    review_l1 --> draft: reviewL1 reject<br/>(reject_reason 必填 :121)

    review_l2 --> published: reviewL2 pass<br/>:154-164
    review_l2 --> draft: reviewL2 reject<br/>:171-183

    published --> expired: runSchedule<br/>unpublish_at<=now<br/>PublishService.php:66
    published --> unpublished: manualUnpublish<br/>PublishService.php:131

    draft --> published: manualPublish 绕过审核<br/>PublishService.php:116 ⚠
    review_l1 --> published: manualPublish / runSchedule 绕过 ⚠
    review_l2 --> published: manualPublish / runSchedule 绕过 ⚠

    published --> published: 修订稿合并回写<br/>EntryDraftService.php:133-144

    note right of published
      修订稿是另一条 entries 记录
      (draft_of_entry_id 指向原件),
      原件在整个修订期间保持 published
    end note

5.2 提交前的 i18n 完整性闸门

checkI18nCompletenessWorkflowService.php:231-255⁠):

  • 中文标题与 slug 缺一 → 硬阻断(无视策略)。
  • 英文缺失:channel.i18n_strategy === 'strict' → 阻断;否则只产生 warning,不阻断(:246-252)。
  • 策略取自主栏目⁠:EntryModel::primaryChannelId($entry)channel_id JSON 数组的第一个元素(EntryModel.php:85-92⁠)。channel_id 已在迁移 2026-02-10-100030 里从 BIGINT 改成 JSON 并删掉了外键与索引(该迁移 :19-23),所以按栏目筛内容是全表扫。

5.3 审核级数配置

getConfig()(:25-31)从 config 表读 workflow.rules⁠,默认 ['review_levels' => 2]⁠。reviewL1:94review_levels⁠,<= 1 就直接发布。风险⁠:ConfigModel::getValue()(ConfigModel.php:52-61)把存储值 json_decode 后返回,若有人把 workflow.rules 存成了标量字符串,$cfg['review_levels'] 取到 null(int)null = 00 <= 1 成立 → 一级审核直接上线⁠。没有任何校验拦这个。

5.4 审核链断点

workflow_actions 只有 WorkflowService::recordAction(:257-278)在写。PublishService 的四条状态变更(:44、:66、:116、:131)与 EntryDraftService::publishDraftOverOriginal(:133)都不写⁠。所以从 workflow_actions 看,一条内容可能「提交了、初审过了」,然后状态莫名其妙变成 published——真实原因在 operation_logs 里(PublishControllerAuditLogger 埋点),但两张表没有关联字段可拼。合规审计时这是个说不清的洞。

5.5 WorkHoursService(82 行)

严格说不属于工作流,是审计的时间维度输入。getWorkHours()(:21-34)从 config.auth.work_hours{timezone, weekdays, start, end}⁠,默认周一到周五 09:00-18:00 / Asia/Shanghai。isOutsideWorkHours()(:53-81)支持跨夜班次(start > end 时走 :80 的反向判断)。时区非法时静默回落 Asia/Shanghai(:60-62)。被 AuthController(登录时标 outside_work_hours⁠)、AuditAnomalyService(:63、:72 决定是否统计)、AuditPolicyService(:54 透出配置)消费。


6. 发布 · 预览 · 草稿

6.1 PublishService(143 行)

  • runSchedule()(:24-86):两条独立扫描。发布条件 publish_at IS NOT NULL AND publish_at <= now AND published_at IS NULL(:35-37);下线条件 unpublish_at <= now AND status = 'published'(:57-59)。逐行 entryModel->update()⁠,不分批不加锁⁠,条目多时是一串单条 UPDATE。updated_by => null(:47、:69)表示系统操作(entries.updated_by 可空,迁移 2025-01-19-100005:79-83 确认)。由 php spark publish:run-scheduleCommands/RunPublishSchedule.php⁠)驱动,cron 配置不在仓库里,需要向运维索取⁠。
  • manualPublish()(:91-127):若目标是修订稿(draft_of_entry_id 非空,:98)就转交 EntryDraftService::publishDraftOverOriginal⁠;否则只拦「已发布」(:112),其余状态一律直接 published。这是审核绕过的入口⁠。
  • 幂等性:runSchedulepublished_at IS NULL 保证发布只做一次;但下线不幂等⁠——unpublish_at 满足条件的 published 内容每次跑都会被再置一次 expired(重复执行结果相同,但 unpublished_at 会被反复刷新)。

6.2 PreviewService(136 行)

  • createToken()(:20-54):32 位 hex 随机 token,可选 device(白名单 mobile/tablet/desktop,:28)与 expires_at⁠。
  • accessByToken()(:73-114):要求提供用户名密码(复用 AuthService::login⁠,:80-81),再验 revoked(:90)与过期(:93)。校验顺序是「先验密码后验 token」,因此无效 token 也会消耗一次密码校验——PreviewController 挂了 LoginThrottleService⁠,尚可。
  • timeToTimestamp()(:127-134)同时兼容 Time 与字符串,是因为 PreviewTokenModelexpires_at cast 会把值转成 Time⁠——防御性写法,保留。

6.3 EntryDraftService(553 行,本层最复杂)

核心模型⁠:修订稿是 entries 表里的另一条记录⁠,draft_of_entry_id 指回原件,status='draft'⁠。原件在整个修订期间保持 published,前台不受影响。

  • createDraftFromPublished()(:40-104):五道前置校验(不存在 / 已是修订稿 / 已是草稿 / 状态不在 EDITABLE_SOURCE_STATUSES(:16,published/unpublished/expired)/ 非本人且非 sys_admin,:59)→ 已有草稿则直接返回(:63-73,幂等)→ 事务内克隆 entry 行、i18n + 缩略图、关联内容、FAQ 页题目、CSS 版本,最后 refreshEntryRefs⁠。
  • publishDraftOverOriginal()(:111-162):事务内把草稿的字段回写原件(:133-144)、逐项 merge(i18n / 缩略图 / 关联 / FAQ / CSS)、delete($draftEntryId)(:152)、刷新资产引用。草稿行删除后,entry_i18n⁠、workflow_actions⁠、entry_css_versions⁠、preview_tokens 都靠 FK ON DELETE CASCADE 连带清理(各迁移文件已确认);但 asset_refsobject_id 没有外键(迁移 2026-01-24-100018:51 只对 asset_id 建了 FK),所以 deleteEditDraft(:190)才要手工 delete⁠。publishDraftOverOriginal 路径没有做这个手工清理⁠——草稿被删后,指向它的 asset_refs 行成为孤儿。这会让「资产被引用中,不可删除」的判断出现假阳性。
  • copyLatestCssVersionsBetweenEntries()(:485-523):读磁盘文件内容重新落盘⁠,而不是只改库里的文件名。注释(:481-483)解释了原因:只改库名会让前台 404。这是踩过坑之后的正确写法,别「优化」掉。
  • 事务缺陷::76-83transRollback() 后直接 return,没有 transComplete()⁠,事务深度泄漏(详见缺陷表)。

7. DAM

7.1 DamUploadService(550 行)

分片协议:initSession → N × saveChunkmerge(或 mergeAsVariant⁠)。

  • initSession()(:28-79):upload_id 由前端提供,已存在则做参数一致性校验(:52)后幂等返回。上限来自 Config/Dam.php⁠:单文件 5GB、分片 1-10MB、最多 1000 片。
  • saveChunk()(:81-142):分片落 writable/uploads/dam_chunks/{upload_id}/{i}.part⁠,可选 md5 校验,同 hash 则幂等跳过(:112-122)。
  • merge()(:163-318):流式拼接并同步算 SHA256(:209-245),校验总大小与最终 hash,落 writable/uploads/dam/YYYY/MM/⁠,写 assets⁠,更新会话状态。全程无事务(详见缺陷表)。合并后不删分片(:314 注释说留给 cron,但仓库里没有这个 cron)。
  • resolveAssetType()(:463-474):扩展名或 MIME 命中任一即通过$extOk || $mimeOk⁠,:469)。改扩展名即可绕过类型限制——但 sanitizeFilename(:523-529)会把文件名清洗成 [A-Za-z0-9._-]⁠,且文件落在 writable/ 下由 AssetDeliveryController 中转,未直接暴露,风险可控但值得记一笔。
  • mergeAsVariant()(:331-461):给已有资产加高清变体,同 quality 先删旧文件与记录(:440-446)——先删后插,中间失败就两边都没了⁠。

7.2 AssetRefService(150 行)

refreshEntryRefs($entryId)(:26-91):delete + rebuild。扫描三个来源——entries.cover_asset_id⁠、entry_i18n.og_asset_id⁠、content_json⁠。content_json 有两条解析路径:能 json_decode 成数组就递归扫 key(:96-114),否则当富文本 HTML 用正则抓 data-asset-id/asset/{id}(:121-143)。

⁠:递归扫描的匹配条件是 str_contains($k, 'asset')(:104),任何 key 里带 "asset" 且值是数字的字段都会被当成资产引用(比如 asset_count: 5 会生成一条指向 asset#5 的假引用)。这会让「资产删除保护」误判。

7.3 AssetCategorySyncService(78 行)

栏目建/改时镜像到「网站素材」资产分类树下。syncOnChannelCreate(:26-56)挂父分类(找不到父就挂根,:33-39),syncOnChannelUpdate(:61-77)只在 name_zh/name_en 变了才同步(:63)。:52 硬编码 'created_by' => 1⁠,而 asset_categories.created_by 带 FK RESTRICT(迁移 2026-01-27-100024:64⁠)——干净库上建栏目会因外键失败而无法生成镜像分类。


8. 搜索与 SEO

8.1 SearchService(221 行)

search()(:61-143):FULLTEXT 优先、LIKE 兜底。hasFulltextIndex()(:196-209)用 SHOW INDEX ... WHERE Key_name='ft_entry_i18n_search' 探测,结果缓存在 static 变量里(:198-201)——只在单次请求内有效,每个请求都会多一次 SHOW INDEX⁠。

置顶(pins)只在第一页生效(:78-84),置顶结果被 whereNotIn 从主查询排除(:97)后前置拼接(:126),并且 $total += count($pinnedList)(:127)——分页总数计算不严谨⁠,第一页的 total 与后续页不一致。

escape 用法:$db->escape($q) 的结果直接拼进 MATCH(...) AGAINST (...) 字符串(:101-104、:115),走的是 where(..., null, false) 关闭转义。escape() 会加引号并转义,SQL 注入面已堵住,但可读性差、审计时容易误判。

hotwords()/pins() 通过 ->builder() 绕开 Model(:22、:43),因此不受 §0 的 cast 问题影响⁠——但也意味着 start_at/end_at 读出来是原始字符串而非 Time⁠。同一个 Model 走 find() 就会因为 NULL 触发 DatetimeCast::get() 的类型断言(get() 要求 string,见 DatetimeCast.php:29-34)。读热词/置顶时不要改用 Model 的 find()/findAll()⁠,除非先把 cast 改成 ?datetime⁠。

8.2 SeoSchemaService(307 行)

纯计算,无副作用(配置读写除外)。三块能力:

  1. getSeoConfig()(:16-63):四组配置带内置默认值——seo.site⁠、seo.meta_rules⁠、seo.organization_profile(中英双份 Corporation 档案)、seo.schema_mappings⁠。
  2. buildSuggestedJsonLdForEntry()(:75-150):映射键优先 meta_json.page_kind⁠,回退 entry_type:{type}(:261-267),再回退 WebPage(:88)。
  3. mergeSuggestedAndOverride()(:158-189):override 为对象则深度合并(list 数组直接覆盖,关联数组递归,:290-305),为数组则输出 JSON-LD 数组。validateJsonLd()(:191-212)只查 @context@type 是否存在——校验很浅⁠,不保证 Google 能吃。

datePublished/dateModified 直接塞 $entry->published_at(:93),而它已被 cast 成 Time 对象——JSON 序列化时会变成 {"date":"...","timezone_type":3,...} 这种对象结构,不是 ISO8601 字符串⁠。这是 schema.org 输出格式的潜在问题,接手时值得实测一次。


9. 审计三件套

AuditLogger(写)  ←  AuditPolicyService(开关/阈值)  ←  AuditAnomalyService(频率告警)

9.1 AuditLogger(112 行)

唯一的静态入口 AuditLogger::log($module, $operation, $context, $result, $errorCode, $request, $httpRequest)⁠,返回 32 位 trace_id⁠。被 28 个文件调用,是全系统的审计动脉。

  • 脱敏:sanitizeRequest()(:95-111)递归把 password/password_hash/mfa_code/token/confirm_token/secret 换成 *(清单在 :11-18)。匹配是精确的键名小写比对new_password⁠、api_key⁠、access_token 这类变体不会被脱敏**。加字段时务必同步这个清单。
  • OperationLogModel$casts 故意留空(OperationLogModel.php:35-38),配 useTimestamps=false⁠,所以这里传字符串时间戳是对的(:75、:77)。
  • log() 不做任何异常处理⁠。审计表写失败(比如 request_summary 超长)会把主业务请求一起带崩。

9.2 AuditPolicyService(151 行)

config 表上的一层带边界的读写封装。阈值边界写死在常量里:bulk_export_threshold 限 100-10000(:22-24、:38-40),access_denied_window_sec 下限 60(:50),query_rate_window_sec 下限 300(:52)。注意类注释明说「不提供关闭审计写入的开关」(:8)——只能关 access_deniedaudit_query 两类的记录(:44-45),审计主干不可关。

性能坑:getBulkExportThreshold()/shouldLogAccessDenied()/shouldLogAuditQuery()(:125-138)每个都调一次完整的 getPolicy()⁠,而 getPolicy() 内含 9 次 config 表查询 + 1 次 WorkHours 查询⁠。ApiController::failForbidden(:168)每次拒绝都会触发一次。

9.3 AuditAnomalyService(181 行)

时间桶计数 + 阈值一次性告警。桶键 floor(now/window)*window 对齐(:125),桶行在 audit_rate_buckets 上有 (bucket_key, event_type, window_start) 唯一索引(迁移 :93)。

三类事件:access_denied(按 actor 或 IP 分桶,:40-41)、audit_query⁠、user_list(后两类只在非工作时间统计⁠,:63、:72)。达阈值时先把 threshold_logged=1(:157-162)再写审计,保证一个窗口只告警一次。

并发缺陷⁠::128-155 是「查 → 有则 update / 无则 insert」的经典 check-then-act,两个并发请求会同时走 insert 分支,被唯一索引挡下一个 → 抛异常打断主请求⁠。正确写法是 INSERT ... ON DUPLICATE KEY UPDATE hit_count = hit_count + 1⁠。

9.4 ConfigPromotionService(235 行)

配置导出/导入,支持 5 类(:13)。导出全部实现;导入只有 workflowchannel_auth 真正可用,rbacchannels 直接返回「未开放」(:88、:90)。dryRun(:72-83)是假的⁠——不做任何校验,直接返回 validated: true 和全零变更数。别信它的返回值。

importChannelAuth(:175-219)对每行规则各查一次 rolespermissions(:194-195),再按 channel 分组调 replaceForChannel⁠。N 行规则 = 2N 次查询 + M 次全量替换事务。


10. Figma 导入管线

10.1 编排(FigmaImportService⁠,243 行)

parseShareUrl(url)                      FigmaClient.php:33-67
  → getNodes(fileKey,[nodeId]) 或 getFile(fileKey)      :76-99
  → FigmaToHtmlCssService::convert(node)                 → {html, css, images[], stats}
  → getImageFills(fileKey)      imageRef → CDN URL       :138-150
  → getImages(fileKey, nodeIds) nodeId  → 导出 URL       :108-130
  → applyExportNodeIdFallbacks()  节点导出失败时回退父节点  FigmaImportService.php:209-225
  → FigmaImageToDamService::importBatch()  下载→压缩→入 DAM
  → FigmaImageToDamService::applyToHtml()  回填 src / data-asset-id
  → FigmaSanitizerService::sanitize()      白名单净化

唯一注入了依赖的服务FigmaImportService.php:24-36 全部可选注入),所以也是唯一有单测的backend/tests/unit/Figma/⁠,5 个文件 576 行)。

exportNodeIdFallbacks()(:168-200)是踩坑产物:Figma /images 对组件实例子节点(形如 I9006:2823;1000:3676⁠)返回 null,代码逐级剥 ; 段并尝试实例根。这段逻辑没有文档,只有 :162-165 的注释⁠,别删。

10.2 FigmaToHtmlCssService(842 行,本层最大文件)

节点树递归转 HTML + CSS。按 type 分派(:110-137):TEXT → renderText⁠,容器类 → renderContainer⁠,图形类 → renderShape⁠,其余有子节点则当容器。节点数上限 Config/Figma.php:41 的 800,超了标 truncated 并截断(:97-100)。

值得记住的隐式约定:

  • 语义标签靠图层名猜⁠:resolveContainerTag(:221-236)用 str_contains(name, 'header'/'footer'/'nav'/'main'/'aside') 决定标签;resolveTextTag(:238-250)用字号 + 字重推 h1~h4。
  • 布局双轨⁠:auto-layout(layoutMode⁠)映射 flex(:299-325),非 auto-layout 走绝对定位(:361-370),父容器 min-heightrecordAbsoluteChildExtent 反推(:775-793)。
  • compileCss()(:794-842)尾部硬编码了设计稿图层名⁠——card_awards⁠、Badge⁠、Badge Text(:826-831),带 !important⁠。设计师一改名,样式补丁静默失效,而没人会想到去看后端 PHP。

10.3 FigmaSanitizerService(142 行)

白名单净化,标签 22 个(:19-26)、属性 12 个(:28-33),一律剥 script/style/iframe/object/embed/form(:76-79)、on* 事件(:95-98)、内联 style(:99-102)。不在白名单的标签保留子节点、只脱掉自身(:81-87)。href 只放行 /⁠、#⁠、https/mailto/tel(:126-133);img src 只放行 /data:image/(:135-141)。质量不错。

10.4 EntryCssVersionService(209 行)

entry_id + lang 版本化落盘 page-{id}-{lang}-{Ymd-His}-{rand}.cssWRITEPATH/uploads/page-css⁠,checksum 相同则跳过(:50-63,只更新 source_url)。落盘失败或入库失败会 @unlink 回滚文件(:97-100)。历史版本永不清理⁠,磁盘只增不减。公开访问路径 /page-css/{filename}Config/Figma.php:63⁠),由 PageCssDeliveryController 中转。


11. 接手 checklist

第 1 天必做的四件事

  1. 读完本文 §0,把「datetime cast 只吃 Time」刻进肌肉记忆。
  2. WorkflowService::now()⁠,把 11 个调用点验一遍(含 :276workflow_actions⁠)。这是让系统可用的最小改动。
  3. 向前任/运维索取三样东西:.env 里的 JWT_SECRETfigma.token别看真实值,确认存在即可⁠,位置见 backend/.envConfig/Jwt.php:56-70⁠、Config/Figma.php:74⁠)、publish:run-schedule 的 cron 配置、backend/writable/ 的媒体备份。
  4. 确认 PHP 装了 gd/curl/dom/fileinfo⁠——缺任何一个,验证码、Figma 导入、图片压缩会静默降级而非报错。

改动前必查

  • Models/*$casts → 把 Services 里所有写该字段的地方过一遍(用 §0.3 的表当模板重跑一次 grep)。
  • 动鉴权语义 → Filters/AuthFilter.phpControllers/Api/ApiController.php:77-137两份独立实现⁠,必须同改。
  • PermissionServicehasPermission()getPermissionCodes() 是两条独立查询,必须同改,否则前端菜单和后端判定会打架。
  • 动工作流状态 → 记得 PublishService 的四条路径不受状态机约束也不写 workflow_actions⁠。
  • 动 Figma 转换 → 先跑 backend/tests/unit/Figma/⁠,这是全层唯一的回归网。

评级依据

  • 理解难度 4/5⁠:文件多但个体不复杂,难在三条无文档的隐式约定(DataCaster 只吃 Time⁠;getWithI18n 动态挂 ->i18n 属性;channel_id 是 JSON 数组必须走 primaryChannelId()⁠)、状态机散落在 Workflow/Publish/EntryDraft 三个服务且互不知情、同一层里四套返回类型不同的 now()⁠。docs/ 下 31 份文档没有一份讲 Services 架构(grep -rl "DataCaster\|casts" docs/ 零命中)。
  • 接手风险 4/5⁠:所有内容写入都要穿过带 cast 雷区的 EntryModel⁠;鉴权链有两处静默回退(JWT→Session)会把配置错误伪装成偶发掉线;user_sessions 只写不读意味着「已修复的安全需求」其实没生效,而没人会主动去验;auth/RBAC/workflow/publish/DAM 零单测⁠。
  • 上手 8 人天⁠:读代码 2 天 + 补 §0 的 cast 心智模型并修工作流 1 天 + 摸清双套授权语义 1 天 + 跑通 DAM 分片与 Figma 管线各 1 天 + 补关键路径测试 2 天。
  • 稳态维护 2.5 人天/月⁠:无重构前提下,按当前缺陷密度估算——bug 修复约 1.5 天,配置/权限/栏目类运营支持约 0.5 天,磁盘(CSS 历史版本、DAM 分片、审计日志)与 cron 巡检约 0.5 天。
CMS 后端 · 配置、CLI、测试(w2r.site/backend:app/Config、app/Commands、app/Views、app/Language、public、scripts、tests、preload.php、spark、composer.json、README.md)
8,025
行 / 108 文件
5
上手人天
1
月维护人天
21
缺陷
理解难度
接手风险

主要风险

  • 「验收」命令实为生产写操作:audit:accept 与 audit:accept-ext 名字像测试,实际会伪造 sys_admin token、TRUNCATE 表、改用户角色、覆盖全局审计策略、删除审计日志。它们由 spark 自动发现并出现在 php spark list 里,接手者极可能出于「先跑一下看看系统是否正常」的直觉执行,从而在第一天就造成不可逆的生产事故。这是整个交接中最危险的单点。
  • 部署面是黑盒:仓库内没有任何 nginx/Docker 配置,而现场证据(public/404.html 的 nginx 页、public/index.html 的宝塔面板页)表明 .htaccess 全部失效。同时 writable/ 目录缺失、.env 只有一份 CI_ENVIRONMENT=development 的开发配置。换机、扩容、灾备恢复这三件事目前都没有可执行路径,必须先在生产机上把 nginx server 段、目录权限、真实 .env 逐项抄回来。
  • 环境变量的生效链路是隐式的:数据库、会话、日志阈值全靠 CI4「.env 里 database.default.hostname 自动覆盖 Config\Database::$default['hostname']」这套约定,代码里搜不到读取点。App.php:19 的 baseURL 因为 .env 未提供 app.baseURL 而以硬编码 https://w2r.site/ 生效。接手者改 Config 类却被 .env 静默覆盖、或改 .env 却因键名拼写不匹配而无效,都不会有任何报错。
  • 安全基线是纸面上的:App.php:201 开了 CSP 且 ContentSecurityPolicy.php 全面收紧到 'self',但 Filters.php:87 的 secureheaders 被注释、CSP frame-ancestors 为 null(无点击劫持防护)、Cookie.php:57 secure=false(会话 cookie 无 Secure 标记)、Security.php 的 CSRF 全局未挂载,同时两个 MFA 命令把超管的 TOTP 密钥发往境外第三方二维码服务。等保验收时这套自相矛盾的配置很难解释。
  • 测试无法作为回归网:79 条断言全部集中在 Figma 导入链路,认证、JWT、MFA、RBAC、工作流、发布调度、DAM 分片上传、审计埋点全部零覆盖。已知的工作流 500(EntryModel ?datetime cast)和创建内容非事务性两个缺陷正是这块空白的直接后果。任何对 Config 或 Service 的改动目前都只能靠人工点击验证。
  • 破坏性命令缺少护栏:publish:revert-pages-to-draft 无权限校验无确认、dam:delete-video-mp4 无确认无审计、user:reset-mfa 直接清除现有绑定。多个命令的默认操作人硬编码为 'test' 账号(PublishPagesAsUser.php:30、RevertPagesToDraft.php:29),一旦执行,审计日志会把责任落到一个开发期测试账号头上,事后无法追溯真实执行人。

CMS 后端 · 配置 / CLI / 测试 交接文档

范围:/Volumes/ProjectsAPFS/VWCORP/w2r.site/backend 的配置与运维面。下文所有路径相对 w2r.site/⁠。框架为 CodeIgniter 4.7.2backend/system/CodeIgniter.php:58⁠),框架源码与 vendor/ 均被 git 跟踪(.gitignore 只排除了 .env⁠、writable/⁠、.DS_Store⁠),所以仓库自带一份可运行的框架副本,不需要 composer install 也能起。


1. 一句话结论

配置层本身很浅,八成是 CI4 骨架原样;真正需要交接的是四件事:哪些 Config 被动过⁠、.env 的键怎么隐式生效⁠、14 条 spark 命令里哪几条会毁生产⁠、部署产物为什么复原不了⁠。其中 audit:accept / audit:accept-ext 两条命令是整个交接里最危险的东西——它们名字叫「验收」,实际会伪造超管令牌、清空生产表、改用户角色、删审计日志。接手第一天不要跑它们。


2. app/Config 清单与相对 CI4 4.7.2 原版的改动

共 45 个 app/Config/.php + 3 个 app/Config/Boot/.php⁠。

2.1 项目自建(CI4 原版没有的类)

文件行数作用谁在读
Auth.php43密码策略:最小 8 位、大小写+数字+特殊字符全要、90 天强制改(:16-42⁠)app/Services/PasswordPolicyService.php:20
Jwt.php75JWT 密钥/算法 HS256/有效期 7200s/刷新 7 天/iss=CMS/aud=CMS-Adminapp/Services/JwtService.php:27
Dam.php52上传上限 5GB、分片 1–10MB、最多 1000 片、白名单扩展名与 MIME、存储目录app/Services/DamUploadService.php:21⁠、FigmaImageToDamService.php:33
Figma.php89Figma REST 地址/Token/超时/重试/节点上限 800/CSS 输出目录与 URL 前缀6 处(FigmaClient.php:19 等)
Hostnames.php40两段式 TLD 常量表无人引用,死代码

Dam.php:49-50⁠、Figma.php:58 都把目录钉在 WRITEPATH 下,见第 6 节。

2.2 被改动过的 CI4 原版类

  • App.php⁠::19 baseURL = 'https://w2r.site/'(生产域名硬编码,且 .env 未提供 app.baseURL⁠,所以这行真的在生效⁠);:136 appTimezone = 'Asia/Shanghai'(原版 UTC);:201 CSPEnabled = true(原版 false)。其余字段与原版一致。
  • ContentSecurityPolicy.php⁠:大改。scriptSrc/scriptSrcElem/scriptSrcAttr/styleSrc/styleSrcElem/styleSrcAttr/childSrc/connectSrc/fontSrc/objectSrc/formAction 一律 'self'⁠,imageSrc = ['self','data:']:114⁠,给验证码 base64 留口),reportOnly = false:27⁠,强制拦截而非只报告),autoNonce = true:225⁠)。frameAncestors 仍为 null(:164⁠)⁠——不输出 frame-ancestors 指令。
  • Filters.php⁠::40-42 新增三个业务别名 auth / auth_no_mfa / logout_auth⁠;:66 注释掉 toolbar⁠;:79-89 $globals 前后钩子全部注释掉;:115 $filters 为空。也就是说没有任何全局过滤器⁠,鉴权完全靠 Routes.php 每条路由手写 ['filter' => 'auth']⁠。
  • Events.php⁠::28 ini_set('session.cookie_lifetime','0') 强制关浏览器即失效;:48-57 注释掉 Debug Toolbar 监听;:60-64 保留了 development 下的 __hot-reload 路由。
  • Session.php⁠::34 cookieName = 'w2r_cms_session'(原版 ci_session,注释说明是为了甩掉旧的长过期 cookie);:47 expiration = 0⁠。注意 .env:44-45 又提供了 session.cookieNamesession.expiration⁠,会覆盖这两行。
  • Logger.php⁠::42 threshold 改成 production ? 4 : 9⁠;:48 新增了原版没有的 retentionDays = 180⁠,由 logs:cleanup 读取。
  • Routes.php⁠:172 行,全部业务路由,含 SPA fallback(:20-21⁠)、资产投递(:11-12⁠)、页面级 CSS 投递(:16⁠)和 api 分组下的全部接口。
  • Migrations.php⁠::64 lock = false(并发部署无迁移锁,多实例同时部署会打架)。

2.3 与原版逐行一致(可当作不存在)

Autoload / Cache / Constants / Cookie / Cors / CURLRequest / Database / DocTypes / Email / Encryption / Exceptions / Feature / ForeignCharacters / Format / Generators / Honeypot / Images / Kint / Mimes / Modules / Optimize / Pager / Paths / Publisher / Routing / Security / Services / Toolbar / UserAgents / Validation / View / WorkerMode / Boot/{development,production,testing}.php⁠。

其中几个「原版不动」本身就是问题:

  • Database.php 全空凭据(:29-32⁠),完全靠 .env 注入;
  • Security.php 的 CSRF 配置形同虚设——Filters.phpcsrf 从未挂载;
  • Cors.php 全空(:37-96⁠),前后端同域部署所以没影响,但一旦拆域名要从零配;
  • Cookie.php:57 secure = false⁠,HTTPS 站点的会话 cookie 没有 Secure 标记;
  • Encryption.php:24 key 为空——目前全仓库没有代码使用 encrypter 服务,暂时无害,但别指望它能用;
  • WorkerMode.php 是 4.7 给 FrankenPHP 准备的,项目没用 FrankenPHP,纯骨架残留。

3. 环境变量全清单与缺省行为

CI4 的 BaseConfig 会用 .env类名小写.属性名 形式的键自动覆盖 Config 类属性,代码里搜不到读取点⁠。这是本项目最容易踩的隐式约定。当前 .env(24 个有效键,值一律见 backend/.env:<行号>⁠)分四类:

A. 靠 CI4 约定自动覆盖 Config 属性

位置覆盖目标缺失时的行为
CI_ENVIRONMENT.env:5ENVIRONMENT 常量缺失则默认 production⁠。当前值是 development
database.default.{hostname,database,username,password,DBDriver,DBPrefix,port}.env:25-31Config\Database::$default缺失则用 Database.php:27-52 的空凭据,连不上库
session.{driver,cookieName,expiration,matchIP,timeToUpdate,regenerateDestroy}.env:43-49Config\Session缺失则回落到 Session.php:24-96
logger.threshold.env:55Config\Logger::$threshold缺失则 Logger.php:42 的三元表达式生效

B. 代码显式读取

读取点缺省值
figma.tokenFigma.php:69''(Figma 导入直接不可用)
figma.baseUrlFigma.php:74https://api.figma.com/v1
figma.timeoutFigma.php:7930 秒
figma.maxNodesFigma.php:84800
JWT_SECRETJwt.php:54$_ENV⁠)三段兜底,见下

JWT_SECRET 的三段兜底逻辑有坑::54$_ENV 是唯一真正生效的路径;:57-68 的手工读 .envstrpos($line,'JWT_SECRET=')===0 匹配,而实际 .env:37 写成 JWT_SECRET = ...(等号前有空格),永不命中,是死代码⁠;:71-73 在 development 下用 bin2hex(random_bytes(16)) 现编——每个请求一个新密钥,表现为「登录后随机掉线」,本地调试时看到这个现象先查这里。

C. 存在于 .env 但无任何代码读取(死键,需吊销)

sitecore.LOGGING_URL⁠、sitecore.username⁠、sitecore.password⁠、sitecore.backendnewslisturl.env:16-19⁠)。全仓库(PHP / Python / Vue / demo 站)grep 不到读取点,是 Sitecore 迁移期遗留的凭据。

D. 应该有但 .env 里没有的

app.baseURL(导致 App.php:19https://w2r.site/ 硬编码生效)、app.forceGlobalSecureRequests⁠、app.CSPEnabled⁠、encryption.key⁠、cookie.secure⁠。另外 env 模板文件 69 行全是注释⁠,没有任何项目专属键的说明——接手方无法从模板知道该配哪些。


4. 14 条 spark 命令逐条说明

CI4 自动发现 app/Commands/ 下所有 BaseCommand 子类,全部出现在 php spark list⁠。

#命令定义用途破坏性进 crontab?
1admin:create-super [user] [pwd] [mail]CreateSuperAdmin.php:14建 sys_admin写库;无审计;不带密码时自动生成并 echo 到 stdout:44⁠);生成算法(:84-95⁠)不保证满足策略,可能自己校验不过否(一次性)
2admin:setup-mfa [user]SetupSuperAdminMfa.php:13给超管发 MFA覆盖已有绑定(有 y/n 提示 :60⁠);密钥外发第三方 :111
3admin:verify-rbacVerifyRolePermission.php:11打印角色/权限/关联统计只读否(可做巡检)
4user:reset-password <user> <pwd>ResetUserPassword.php:14改密码写库;无审计;明文密码进 argv/history
5user:reset-mfa <user>ResetUserMfa.php:13清 MFA 并重新发放无确认直接清除现有绑定:44⁠);密钥外发第三方(:86⁠)
6user:activity <uid>QueryUserActivity.php:14统计某用户在 14 张表的记录数只读;注释自称「临时命令」
7audit:cleanup [months]CleanAuditLogs.php:19operation_logs 超期记录,默认 6 个月DELETE 不可逆;有完整 job_start/job_finish 审计埋点:34⁠、:79⁠)⁠,0 0 1
8logs:cleanup [days]CleanApplicationLogs.php:20writable/logs/log-*.log⁠,默认读 Logger.php:48 的 180 天unlink 不可逆;有审计埋点⁠,0 0 1
9publish:run-scheduleRunPublishSchedule.php:13执行定时发布/下线,幂等写库但幂等;有审计(:22⁠)⁠,建议每 5 分钟。注意已知缺陷:PublishService::runSchedule() 第 46/68 行的 datetime cast 尚未按 manualPublishnowTime() 方式修复
10publish:pages-as-user [user] [--type] [--dry-run]PublishPagesAsUser.php:20批量发布批量改状态; content:publish 权限校验(:46⁠)与 --dry-run⁠;默认操作人硬编码 'test':30⁠)
11publish:revert-pages-to-draft [user] [--type] [--dry-run]RevertPagesToDraft.php:19批量改回 draft最危险的常规命令⁠:无权限校验、无确认、默认操作人 'test':29⁠)、绕过 PublishService 直写 EntryModel(:78⁠)。空参运行即全站页面下线否,建议直接删除
12dam:delete-video-mp4 [--dry-run]DeleteVideoMp4Assets.php:20删所有 mp4 资产与物理文件高破坏性;有 --dry-run 但无确认、无审计;级联删 variants/refs否,建议改成必须带 --force
13audit:accept [base_url] [host]AuditAcceptance.php:21审计 P0 验收伪造 sys_admin/editor token(:50⁠);把用户 2 提权再硬编码还原成 publisher(:124-126⁠);锁定/解锁用户 3(:143-150⁠)否,应移出生产分支
14audit:accept-ext [base_url]AuditAcceptanceExtended.php:23审计 P1/P2 验收最高破坏性⁠:TRUNCATE audit_rate_buckets:151⁠,调用两次)、改写 auth.work_hoursaudit.query_rate_threshold 并硬编码还原(:189-220⁠)、伪造两个用户 1 的登录会话触发并发登录告警(:173-174⁠)、command('audit:cleanup 6') 真删审计日志(:237⁠)否,应移出生产分支

推荐 crontab(三条,其余一律手动):

*/5 * * * * cd <backend> && php spark publish:run-schedule
0 0 1 * *   cd <backend> && php spark audit:cleanup
5 0 1 * *   cd <backend> && php spark logs:cleanup

注意 CleanApplicationLogs.php:15 的注释里写死了生产路径 /www/wwwroot/w2r.site/backend⁠,这是目前唯一能推断线上部署位置的线索。


5. tests/ 现状:有真实断言,但覆盖面只有 Figma

8 个测试文件、33 个测试方法、79 条真实 $this->assert*⁠,不是空壳:

文件方法数断言数性质
tests/unit/Figma/FigmaToHtmlCssServiceTest.php1346项目自写,主力
tests/unit/Figma/FigmaSanitizerServiceTest.php510项目自写,测 XSS 清洗(脚本剥离、事件属性剥离、外链图片剥离)
tests/unit/Figma/FigmaClientUrlParseTest.php66项目自写,测分享链接解析
tests/unit/Figma/FigmaImageResizeServiceTest.php26项目自写,无 GD 时 skip
tests/unit/Figma/FigmaImportServiceTest.php24项目自写
tests/unit/HealthTest.php23CI4 骨架自带
tests/database/ExampleDatabaseTest.php23CI4 骨架示例
tests/session/ExampleSessionTest.php11CI4 骨架示例

对照 app/ 的规模:24 个 API 控制器、5 个根控制器、32 个 Service、25 个 Model、3 个 Filter、51 个迁移。认证、JWT、MFA、RBAC、工作流、发布调度、DAM 分片上传、审计埋点全部零覆盖。

phpunit.xml.dist 要点:bootstrap 指向 system/Test/bootstrap.php:5⁠);failOnRisky / failOnWarning 都是 true(:10-11⁠);覆盖率范围只含 ./app 并排除 Views 与 Routes(:37-42⁠);测试库走 Database.php:165-191 的 SQLite3 :memory: + 前缀 db_⁠。注意⁠:业务迁移里有 MySQL 专属裸 SQL(如 2026-03-12-100041_AddUniqueSitecoreIdToAssets.php:16INFORMATION_SCHEMA.STATISTICS⁠),一旦有人写带 $migrate = true 的数据库测试,在 SQLite 下必炸——要么把测试库改成 MySQL,要么别用 DatabaseTestTrait 跑业务迁移。

build/logs/(testdox.html、logfile.xml 等)是测试产物却被 git 跟踪,应加进 .gitignore。


6. public/ 暴露面:哪些文件不该对外可达

public/.htaccess:14-16 的规则是「文件存在就直接给」,所以文档根下每个文件都是可达的:

文件该不该在理由
public/test-putenv.php设计成向匿名访问者输出 PHP 版本、SAPI、disable_functions 清单(:28-40⁠、:72-74⁠)。且 :44 在文件中段写 namespace TestNamespace;⁠,这是 PHP 编译期致命错误,文件本身是坏的
public/groupui-showcase.html删或挪到内网28KB 设计规范演示页,全仓库无任何引用
public/index.html宝塔面板默认站点页,暴露主机用的是宝塔面板
public/404.htmlnginx 默认 404 页
public/AdM/.vite/manifest.json8KB Vite 构建清单,暴露前端全部模块图与 chunk 命名
public/assets/fonts/*.ttf评估4 个 MXiangHeHeiSCStd 商用中文字体明文直出,有授权风险
public/robots.txt当前 Disallow: 为空 = 允许全站抓取,管理后台 /AdM 也在内
public/index.php⁠、favicon.ico⁠、AdM/⁠、assets/⁠、images/保留正常

另一条关键事实:public/404.html:6 写着 nginx、public/index.html 是宝塔默认页 —— 生产跑的是 nginx,.htaccess 里的全部重写规则实际不生效⁠。仓库内 find 不到任何 .conf / Dockerfile / docker-compose⁠,真正生效的 nginx server 段没有交接。

AdminController.php:20-34 直接 file_get_contentspublic/AdM/index.html⁠,配合 index.html:20{csp-style-nonce} 占位符由 CI4 的 autoNonceContentSecurityPolicy.php:225⁠)在响应阶段替换。改动 CSP 配置前务必确认这条链路仍通⁠,否则管理后台会白屏。


7. 全部硬编码的绝对路径与生产域名

内容位置
https://w2r.site/ (baseURL,实际生效)app/Config/App.php:19
w2r.site(HTTP Host 头)app/Commands/AuditAcceptance.php:26
https://w2r.site(默认 base_url 注释)app/Commands/AuditAcceptance.php:16
/www/wwwroot/w2r.site/backend(生产部署路径)app/Commands/CleanApplicationLogs.php:15
https://api.qrserver.com/...(外发 MFA 密钥)app/Commands/SetupSuperAdminMfa.php:111⁠、app/Commands/ResetUserMfa.php:86
https://api.figma.com/v1app/Config/Figma.php:20
http://127.0.0.1:8799(验收默认地址)AuditAcceptance.php:35⁠、AuditAcceptanceExtended.php:27⁠、:37
/usr/sbin/sendmailapp/Config/Email.php:26(CI4 原版默认,邮件未使用)
/usr/local/bin/convertapp/Config/Images.php:20(CI4 原版默认,实际用 GD)
默认操作人 'test'PublishPagesAsUser.php:30⁠、RevertPagesToDraft.php:29
硬编码用户 id 1/2/3 与角色 publisherAuditAcceptance.php:50⁠、:123⁠、:126⁠、:143⁠;AuditAcceptanceExtended.php:48-49⁠、:173

8. 运行期目录:全部状态都在缺失的 writable/ 下

Paths.php:56 定义 WRITEPATH = backend/writable⁠,该目录不存在.gitignore:2 排除)。依赖它的有:

  • Session.php:64writable/session(会话)
  • Cache.php:84writable/cacheLoginThrottleService.php:24 登录限流、CaptchaService.php:25 验证码都靠它)
  • Logger FileHandler(Logger.php:127 path 为空即默认)→ writable/logs
  • Dam.php:49-50writable/uploads/dam⁠、writable/uploads/dam_chunks
  • Figma.php:58writable/uploads/page-css

这些缺失不会在启动时报错⁠,而是在运行时以「登录失败」「验证码不出」「上传 500」的形式零散冒出来。接手第一步就是按上面的清单把目录建好并给 php-fpm 用户写权限。


9. 接手第一周的行动清单

  1. 先给 audit:accept / audit:accept-ext 加保险⁠:最省事的做法是这两个类的 run() 开头判断 ENVIRONMENT === 'production' 就直接退出,或者干脆把两个文件移出 app/Commands/⁠。在这之前不要在生产机上敲 php spark list 之后手贱。
  2. public/test-putenv.php⁠、groupui-showcase.html⁠、index.html⁠、404.html⁠、AdM/.vite/⁠,并把 build/logs/ 加进 .gitignore⁠。
  3. 上生产机抄回三样东西⁠:nginx server 段、真实 .env(重点确认 CI_ENVIRONMENT 到底是不是 production)、writable/ 的目录结构与权限。抄回来后写成 docs/deploy.md 和一份填好键名的 env.example⁠。
  4. 吊销 sitecore.* 那套凭据.env:16-19⁠),确认无人使用后从 .env 删掉。
  5. 补两条安全配置⁠:Filters.php:87 打开 secureheaders⁠,ContentSecurityPolicy.php:164frameAncestors = 'self'⁠。改完必须回归 /AdM 能正常登录(CSP 一收紧最容易打死管理后台)。
  6. 把 MFA 二维码改成本地生成SetupSuperAdminMfa.php:111⁠、ResetUserMfa.php:86⁠),不要再把 TOTP 种子发给第三方。
  7. publish:revert-pages-to-draftdam:delete-video-mp4 加权限校验 + --force 确认 + 审计埋点⁠,参照 CleanAuditLogs.php:34/79 的 job_start/job_finish 写法。
  8. 建三条 crontab(第 4 节末尾),并先在预发跑一遍 publish:run-schedule 确认 datetime cast 缺陷已修。
  9. 补认证与工作流的冒烟测试⁠:登录→MFA→拿 token→提交工作流→发布,这条主链路一条测试都没有,是目前所有线上事故的共同来源。
  10. 重写 README.mdcomposer.json 的 name/description⁠,把 PHP 版本要求统一到 8.2(README.md:45 现在写的 8.1 是错的)。
CMS 管理后台前端(w2r.site/admin-frontend,Vue3 + Element Plus)
13,656
行 / 59 文件
6
上手人天
2
月维护人天
18
缺陷
理解难度
接手风险

主要风险

  • 工作流 UI 整体缺失:后端 workflow/submit、review-l1、review-l2、actions 四个路由无任何前端调用方,content:submit / workflow:review_l1 / workflow:review_l2 三个权限点在界面上无法行使。列表筛选器里的「待初审 / 待终审」状态在正常操作下永远筛不出数据,reviewer_l1 / reviewer_l2 两个角色登录后没有任何可做的事
  • 审计日志查询的筛选与分页全部失效:api/audit.js:5 把参数对象又包了一层 params,实际请求为 ?params=[object Object],后端读不到任何过滤条件,永远返回第 1 页 50 条。而导出走的是另一条正确的代码路径,会按筛选条件导出——屏幕上看到的和导出的 CSV 内容不一致,这在合规审计场景里是危险的
  • 按钮级权限管控几乎不存在:全部 174 个 @click 中只有「发布」和「栏目授权」两处做了权限判断,删除内容、删除栏目、上传/强删资产、创建用户等按钮对所有能进入该页面的角色一律可见。editor 角色没有 content:delete,却能看到并点击删除按钮,输入密码后才被后端 403 拒绝
  • 富文本正文的净化只用正则删除 <script> 标签(RichTextEditor.vue:240),onerror / onload / javascript: / iframe 等一概不过滤。这些 HTML 直接存入 content_json 并由官网前台渲染,构成存储型 XSS 通道
  • 交接件与 GitLab 不同步:admin-frontend 的 git HEAD 停在 2026-05-25,工作区有 8 个文件已改(675 行新增)、6 个功能文件(发布、预览、修订稿三套 composable 与 API)从未提交。远端 gitlab.onedevops.vw.com.cn 上没有这批代码,本地这份是唯一副本
  • 构建链路无自动化:.gitlab-ci.yml 只配了 SAST 与密钥扫描,没有 build/deploy job。产物 backend/public/AdM 靠人工在本机跑 npm run build 生成后提交进后端仓库,且 npm run build 会顺带删除 AdM/index.php 与 .htaccess,重建前必须先确认这两个文件是否为部署所需
  • cms/components 下的可视化页面搭建器(VisualEditor 及其 registry/components/ComponentNode/ComponentPreview/ComponentProperties)共约 1,890 行,占前端代码 14%,没有任何视图或路由引用它,属于完整的死代码分支,会持续误导接手者

CMS 管理后台前端 交接文档

路径⁠:/Volumes/ProjectsAPFS/VWCORP/w2r.site/admin-frontend 规模⁠:59 个一方文件,13,656 行(其中 js/ 13,570 行;任务书给的 12,840 是排除两个 .ts 文件后的数字) 技术栈⁠:Vue 3.5.34 + Vue Router 5.0.7 + Pinia 3.0.4 + Element Plus 2.14.0 + Vite 6.4.2,无 TypeScript 全量接入(仅 4 个文件用 TS),无测试,无 ESLint/Prettier 配置

README.md 是 GitLab 自动生成的模板原文,93 行里没有一个字与本项目相关。本模块没有任何文档⁠,下面的内容全部来自读源码。


一、构建与部署(先看这段,坑最多)

vite.config.js 关键配置:

  • base: '/AdM/'⁠,outDir 指向 ../backend/public/AdM⁠,emptyOutDir: true
  • 手工分包:element-plus / vue-vendor / pinia 三个 vendor chunk
  • 开发服务器固定 5173 端口,strictPort: true

package.json 的 build 脚本是:vite build && rm -f ../backend/public/AdM/index.php ../backend/public/AdM/.htaccess⁠。注意这个 rm⁠:构建会把后端 public/AdM 目录清空(emptyOutDir⁠)后再删掉两个文件。接手后第一次构建前,务必先确认生产环境的 AdM/index.php.htaccess 是否是部署所需产物——脚本这么写,说明历史上这两个文件被 CI4 或 nginx 生成过并且会干扰 SPA 路由。

.gitlab-ci.yml 只有 SAST 与 Secret Detection 两个 job,没有 build,没有 deploy⁠。也就是说构建是原开发者在本机手工执行、产物提交进后端仓库的。该文件顶部还有一段 :stages: / :sast: / :include: 带前导冒号的无效 YAML 键,下面才是正确的第二段配置,属于两次模板叠加的残留。

index.html 里有一处服务端协作约定,容易被误删:

<span id="csp-style-nonce" class="csp-nonce-holder" {csp-style-nonce}></span>

后端把 {csp-style-nonce} 占位符替换成真实的 nonce="..." 属性,js/main.js:5-17 在任何框架代码执行前劫持 document.createElement⁠,给所有动态创建的 <style> 打上该 nonce。这是 Element Plus 在 CSP style-src 'self' 'nonce-xxx' 下能正常显示样式的唯一保障⁠,改动 main.js 顶部或 index.html 那个 span,会导致整个后台样式和登录验证码白屏。css/csp-utils.css 唯一的作用就是把这个 span 移出视口。

构建产物与源码是否一致

backend/public/AdM 现有产物时间戳为 2026-06-21 22:14,源码 mtime 是 2026-07-20 与 07-22(两批文件时间戳完全相同,是整目录拷贝的痕迹,不是逐个编辑)。我用 20 多个中文 UI 字符串逐一比对产物:已生成修订稿⁠、发布修订⁠、删除修订⁠、网站跳转链接⁠、替换高清版本⁠、配置包导入导出⁠、审计策略与工作时间⁠、拖动左侧手柄⁠、exclude_type⁠、edit_draft_id⁠、figma_url_zh 全部命中⁠。.vite/manifest.json 的 17 个入口与当前 11 个视图 + 2 个 Auth 视图一一对应,没有多余也没有缺失,VisualEditor 无对应 chunk(已被 tree-shaking 剔除,符合它是死代码的判断)。

结论:产物与源码在功能上一致,未发现源码有产物中不存在的特性。 但无法做字节级验证(本次审计禁止执行构建),且这份产物比后端仓库对 public/AdM 的最后一次提交(2026-05-26)更新,说明生产上跑的这个 build 本身也没进版本库⁠。


二、应用架构

入口链路

index.htmljs/main.jsApp.vue(仅一个 <router-view />⁠)→ router/index.js⁠。

main.js 依次做四件事:注入 CSP nonce 劫持 → 全量注册 @element-plus/icons-vue 的所有图标 → 装配 Pinia / Router / ElementPlus(中文 locale)→ import './cms/components/components' 做组件注册表的副作用导入(这一句是死代码的入口,见第六节)。

路由表

createWebHistory('/AdM/')⁠。全部路由集中在 js/router/index.js:7-94⁠,两个顶层公开路由 + 一个 Layout 容器带 11 个子路由,全部懒加载:

路径组件meta 权限(满足其一即可)
/loginviews/Auth/Login.vue无需登录
/mfa-enrollviews/Auth/MfaEnroll.vue需登录,requiresMfa: false
/dashboardviews/Dashboard.vue[](全员)
/channelviews/Channel.vuecontent:create / content:edit / content:edit_all
/newsviews/News.vue同上
/contentviews/Content.vue同上
/faqviews/FaqQuestions.vue同上
/mediaviews/Media.vuedam:upload / dam:delete
/asset-categoriesviews/AssetCategories.vuedam:upload
/auditviews/Audit.vueaudit:read
/usersviews/Users.vuerbac:manage
/rolesviews/Roles.vuerbac:manage
/settingsviews/Settings.vueworkflow:config / rbac:manage

路由守卫(router/index.js:105-164⁠)

每次跳转都无条件 await authApi.getMe()⁠,没有任何缓存⁠。因此后台每切一次页面就打一次 /api/auth/me⁠。守卫顺序:

  1. 拿到 res.data.id 才算已登录,否则 next('/login')
  2. MFA 检查:requiresMfa && !mfa_verified 时,mfa_enrolled 为真跳 /login(去输 6 位码),为假跳 /mfa-enroll
  3. 权限检查:canAccessRoute(res.data.permissions, meta.requiresPermission)⁠,不通过静默重定向到 /dashboard(无任何提示)
  4. 副作用:每次都把用户信息写进 Pinia(appStore.setUserInfo⁠),这是 userInfo.permissions 的唯一来源

访问 /login 时若已登录且 mfa_verified⁠,反向重定向到 /dashboard⁠。

页面标题被硬编码统一为「大众汽车集团(中国)官方网站管理系统」,meta.title 定义了但从未使用。

Pinia store(js/store/index.js⁠,全部 25 行)

只有一个 useAppStore⁠,state 三项:sidebarCollapsed⁠、userInfo(含 permissions 数组、role⁠、role_name⁠)、sessionConfig.inactivity_logout_minutes⁠。actions 三个:toggleSidebar⁠、setUserInfo⁠、setSessionConfig⁠。没有持久化⁠,刷新后靠守卫的 getMe() 重新填充。

HTTP 客户端(js/api/index.js⁠,182 行,全部手写 fetch,无 axios)

  • baseURL⁠:/api(相对路径,前后端同源部署)
  • Token 存储⁠:sessionStoragejwt_token 键(tokenManager⁠,第 9-28 行)。选 sessionStorage 是有意的——关闭浏览器即失效
  • 请求拦截⁠:自动加 Authorization: Bearer⁠;body 是对象且非 FormData 时自动 JSON.stringify
  • Token 自动续期⁠:第 83-85 行,任何响应体里带 data.token 就自动覆写本地 token
  • 业务错误约定⁠:后端统一返回 {code, message, data}⁠,code !== 0 一律抛 Error 并挂上 error.code 供调用方分支判断(如 4001 资产被引用、6001 无权限)
  • 401 处理⁠:code === 1001(凭证失效)或 1002(MFA 未验证)时,先调 reportLogoutAudit() 上报登出原因(这条请求刻意绕开 request 以免递归),再清 token,再用 window.location.href 硬跳转到 /AdM/login⁠。第 96 行会先判断当前是否已在 login/mfa-enroll 页以避免循环跳转
  • 方法⁠:get(URLSearchParams 拼串)/ post / put / delete(可带 body,用于 confirm_token)/ getBlob(审计 CSV 导出专用)

js/api/ 下 18 个模块按后端控制器一一对应:auth、content、channel、dam、users、roles、system、config、seo、faq、workflow、publish、preview、relations、audit、dashboard、figma、index。

其中三个模块把「生成 confirm_token」封装进了业务方法内部——users.js:28/43/56⁠、roles.js:17⁠、channel.js:63⁠——这是重复生成 token 缺陷的根源(见缺陷 5)。

会话超时(composables/useInactivityTimeout.js⁠,167 行)

全前端设计最讲究的一个文件,在 layout/Index.vue:123 挂载,默认 30 分钟。要点:

  • 用「时间戳 + 每 30 秒轮询」代替单次长 setTimeout,绕开浏览器对后台标签页的定时器节流
  • mousemove/scroll 节流 5 秒,mousedown/keydown/touchstart/click 不节流
  • visibilitychange 回到前台立即校验
  • 启动后异步拉 /system/session-config 覆盖默认值;Settings 页保存后通过 window.dispatchEvent(new CustomEvent('w2r:session-config-updated')) 广播,无需刷新即时生效
  • 开发环境默认 2 分钟,且支持 ?idle_test_sec=N 覆盖(import.meta.env.DEV 保护,生产构建会被剔除)

三、权限模型:22 个后端权限点 ↔ 前端映射

后端 RolePermissionSeeder.php:54-90 定义 22 个权限点,7 个角色(editor / reviewer_l1 / reviewer_l2 / publisher / dam_admin / sys_admin / audit_readonly)。前端的实际覆盖情况:

权限点前端用途
content:create content:edit content:edit_all仅菜单与路由守卫
content:delete仅出现在 Roles.vue 的 scope 下拉判断里,不是权限门
content:submit同上,无功能入口
content:publishuseContentPublish.js:16 控制发布按钮显隐
content:unpublish前端零引用
workflow:review_l1 workflow:review_l2前端零引用
workflow:config菜单 + /settings 路由守卫
dam:upload dam:delete仅菜单与路由守卫
dam:force_delete前端零引用(强删走 dam_force_delete 这个 confirm 操作名,不查权限)
preview:createapi/preview.js 注释提及;失败时靠捕获 code === 6001 兜底
preview:revoke仅 Roles.vue scope 判断
rbac:manage菜单、/users /roles 守卫、Channel.vue:241 栏目授权按钮
mfa:reset_other前端零引用
audit:read菜单 + 路由守卫
audit:export前端零引用(导出按钮对所有能进审计页的人可见)
config:export config:importSettings.vue:271-273 控制配置包区块显隐
figma:import前端零引用(Figma 导入按钮无条件显示)

7 个权限点在前端完全没有对应实现⁠。菜单过滤逻辑在 config/menu-permissions.js⁠,MENU_ITEMS 是与路由表平行维护的第二份权限表——两张表已经出现不一致(缺陷 13)。


四、视图逐页说明(按业务域分组)

A. 认证域

views/Auth/Login.vue(618 行) — 单页完成「密码 + 图形验证码 → MFA 6 位码」两阶段登录。第一阶段调 /auth/login⁠,返回 mfa_enrolledhas_pending_mfa 时把表单切换成 MFA 输入框(mfaEnrolled ref 同时承担这两种语义),第二阶段调 /auth/mfa/verify 并用返回的新 token 覆写本地 token。未注册 MFA 则跳 /mfa-enroll⁠。前 235 行是纯装饰用的 SVG 动画背景(网格线、粒子、多边形),随机数刻意走 utils/random.js 的 Web Crypto 封装而非 Math.random⁠,注释写明是为了过 Sonar S2245 规则。

views/Auth/MfaEnroll.vue(278 行) — 两步:调 /auth/mfa/enroll 拿 secret 与 totp_uri⁠,用 qrcode 库画到 canvas,展示备用码,再调 /auth/mfa/verify 完成。挂载时先查 /auth/mfa/status⁠,若有 has_pending_secret 直接跳到第二步。

B. 内容域(本模块的核心,2,800+ 行)

views/Content.vue(1,573 行,全模块最大文件) — 非新闻内容(page / blog / faq_page)的列表与全屏编辑弹窗。列表查询固定带 exclude_type: 'news'⁠。调用接口:/content⁠、/content/{id}⁠、/channels⁠、/content/{id}/relations⁠、/seo/schema/preview⁠、/faq-pages/{id}/questions⁠、/figma/css/versions⁠、/figma/css/persist⁠、/figma/css/source-url⁠、/preview/tokens⁠、/auth/confirm-token⁠、/publish/publish⁠、/content/{id}/draft⁠。

views/News.vue(1,253 行) — 新闻专用,FIXED_TYPE = 'news'⁠,栏目限定为「新闻」「媒体新闻」两个多选。与 Content.vue 有约 600 行逐字重复的逻辑,但用 el-tabs 分中英文(Content.vue 是上下平铺)。缺预览和删除修订两个按钮(缺陷 10)。

views/FaqQuestions.vue(541 行) — 两个 tab:题库题目(带分页)与主题管理(无分页⁠,全量返回)。题目与主题的中英文在同一个弹窗内一起填。删除都要密码 + confirm_token(操作名 faq_question_delete / faq_topic_delete⁠)。

views/Channel.vue(691 行) — 栏目树。用 el-treedraggable 实现拖拽排序与改父子关系,handleNodeDrop 后用 collectTreeOrderItems 重算整棵树的 parent_idsort_order(步长 10)批量 PUT 到 /channels/reorder⁠,失败则重新拉取回滚。api/channel.js:5normalizeChannelTree 递归把 id 转 Number——因为 MySQL/CI4 会把 id 序列化成字符串,而 el-tree-select 的 v-model 需要严格相等。栏目授权弹窗是全前端唯一按 rbac:manage 做按钮级管控的地方,保存要密码 + MFA。

C. 资产域(DAM)

views/Media.vue(980 行) — 资产列表、编辑弹窗(含图片/视频/音频/PDF 分类型预览)、高清变体管理。删除是三级流程:确认 → 密码 → dam_delete token → 删除;若后端返回 code === 4001(被引用),弹出强删确认 → 密码 → MFA → dam_force_delete token → /dam/assets/{id}/force-delete⁠。资产状态 0/1/2/3 对应草稿/已保存/已发布/已修改。

views/AssetCategories.vue(305 行) — 分类树表,带 channel_id 的分类是从栏目同步来的,名称只读。

D. 系统域

views/Users.vue(834 行)⁠、views/Roles.vue(379 行)⁠、views/Audit.vue(215 行)⁠、views/Settings.vue(662 行)⁠、views/Dashboard.vue(173 行)⁠。

Settings 页按权限分四块:会话超时(全员)、审计策略(仅 role === 'sys_admin'⁠,硬编码角色名而非权限码)、配置包导入导出(config:export/config:import⁠)、工作流设置 + SEO 设置(无任何门禁⁠,workflow:config 用户也会看到「保存 SEO 设置」按钮,点了拿 403)。


五、内容编辑器细节

content_json 的三种形态

后端存的 content_json 历史上有三种格式,normalizeContentJsonContent.vue:1129⁠、News.vue:849 两份重复实现)负责统一还原成 HTML 字符串:

  1. 直接的 HTML 字符串(手工编辑产生)
  2. JSON 字符串,如 "\"<p>...</p>\""
  3. [{type:"html", html:"..."}] 数组(Python 迁移脚本产生)

写回时一律写成裸 HTML 字符串⁠,不还原成原格式。这是个单向收敛,接手时要知道数据库里三种格式会长期并存。

中英文切换

不是切换,是同页双份字段⁠。form 里每个可翻译字段都有 _zh / _en 两份(title、slug、cover_asset_id、summary、content、seo_title、seo_desc、og_asset_id、schema_json、files)。Content.vue 上下平铺,News.vue 用 tabs。只有中文标题和中文 slug 是必填formRules⁠),英文全部可选。栏目上有 i18n_strategy(loose/strict)字段控制发布时是否强制中英齐全,但该策略只在后端生效,前端不做任何校验⁠。

富文本编辑器(cms/components/RichTextEditor.vue⁠,483 行)

contenteditable div + document.execCommand(已废弃 API)。工具栏:粗斜体下划线删除线、p/h2/h3、有序无序列表、插入链接、清除格式、插入图片(打开 AssetSelector,插入 <img src="/asset/{id}" data-asset-id="{id}">⁠)、Figma 导入。

defineExpose 暴露 getHtml / getFigmaUrl / setFigmaUrl / applyCssPreview⁠。父组件保存前调 syncContentEditorsBeforeSave() 主动从 DOM 拉一次 HTML——因为 @input/@blur 的双向绑定在某些编辑路径下会漏更新,这是个已知的补丁式修复,改编辑器时别删⁠。

Figma 导入流程:粘贴链接 → 校验必须是 figma.com 域名 → POST /figma/import 拿回 {html, css, css_url, asset_ids} → 按 replace/append/cursor 三种模式插入 HTML → CSS 优先以 <link> 外链方式注入(CSP 下 inline style 会被拦)→ emit('figma-imported') 把 CSS 暂存到父组件的 figmaCssZh/figmaCssEn保存内容时才调 /figma/css/persist 落盘成版本化 CSS 文件⁠。Figma 源链接同时写进 meta_json.figma_url_zh/enmergeFigmaUrlsIntoMeta⁠)。编辑已有内容时会反查 /figma/css/versions 恢复预览,若查到 CSS 但正文为空会弹警告提示重新导入。

草稿 / 预览 / 发布交互

修订稿机制composables/useContentEditDraft.js⁠):编辑状态为 published / unpublished / expired 的内容时,不直接改线上,而是先 POST /content/{id}/draft 生成或复用一份修订稿,返回 draft_id 后编辑这份副本。列表行上有 edit_draft_id 字段标记存在未发布修订。

发布composables/useContentPublish.js⁠):canPublish 判断 content:publishrole === 'sys_admin'⁠;canPublishRow 判断——已发布的内容只有存在 edit_draft_id 时才可再发布(发布修订)。流程是确认弹窗 → 密码 → content_publish token → POST /publish/publish⁠。注意发布的是 edit_draft_id 而不是 row.id⁠。

删除修订composables/useContentDeleteDraft.js⁠):DELETE /content/{id}/draft⁠,只删修订不动线上。仅 Content.vue 接入,News.vue 没接⁠。

预览utils/contentPreview.js⁠):已发布且类型属于 page/faq_page/blog 且有 slug 的,直接开 https://demo.w2r.site/{slug}(英文加 /en 前缀);否则 POST /preview/tokens 拿 token 后开 /preview/{token}⁠。官网域名 PUBLIC_SITE_ORIGIN硬编码常量⁠,换环境必须改代码。

DAM 选择器与分片上传

AssetSelector.vue(261 行)是弹窗式资产选择器,被封面、附件、富文本插图共 6 处复用,内嵌 DamUploader 支持边选边传。

composables/useDamUpload.js(263 行)是分片上传的全部逻辑,UI 无关:

  • 分片 5 MB,单文件上限 5 GB
  • 上传前先查 /dam/chunk-status/{uploadId}uploaded_chunks断点续传⁠,已传分片直接跳过
  • upload_id 格式 upload_{timestamp}_{9位base36}⁠,随机数走 Web Crypto
  • 进度条:分片阶段占 0-90%,合并占 95%,完成 100%
  • 合并前用 crypto.subtle.digest('SHA-256')前端整文件算哈希传给后端做 final_hash 校验(大文件会把整个文件读进内存,5 GB 时有 OOM 风险)
  • 暂停用抛 USER_PAUSED 异常中断循环实现
  • 两种合并模式:新建主资产走 /dam/merge⁠,加高清变体走 /dam/assets/{id}/variants

DamUploader.vue 支持「主文件 + 高清版本」一次提交两步串行上传(第一步成功回调里自动发起第二步)。有个语义陷阱:开关叫「在列表中显示」,但 useDamUpload.js:77 的注释写明「打开=待审核(status=0),关闭=已发布(status=2)」,且默认值是关闭。开关名称与实际语义完全不对应⁠。


六、专项核查:按钮好看但没接后端

全仓库 .vue 文件共 174 处 @click(含 @click.stop⁠)。分类结果:

1. 处于死代码中、生产环境永远点不到:10 处VisualEditor.vue 6 处、ComponentNode.vue 2 处、ComponentProperties.vue 2 处。其中 VisualEditor.vue:23 的「保存」按钮是最典型的假成功:handleSave() 只做 emit('save', componentTree.value) 然后弹 ElMessage.success('已保存')⁠,而没有任何父组件监听这个事件——弹窗说保存成功,实际什么都没存⁠。

2. 可达但不发任何请求、且会产生错误后果:2 处(均在 Users.vue)

  • Users.vue:120 「注册MFA」→ handleEnrollMfa 只设三个 ref 打开弹窗,弹窗内容是纯图文步骤说明,全程零 API 调用
  • Users.vue:244 「打开MFA注册页面」→ window.open('/AdM/mfa-enroll')⁠,该页面读的是当前管理员自己的会话,不是目标用户

3. 纯 UI 状态操作(合理,不算缺陷):约 60 处 — 弹窗开关、取消、清除封面、增删表格行、树展开收起、富文本 execCommand、Tab 切换、预览设备切换等。

4. 正常调用后端:约 100 处。

真正的大问题不在 1-2 类,而在第 4 类⁠:这 100 个按钮里绝大多数没有权限判断,对无权角色而言就是「能点、走完一整套密码确认、最后拿 403」。editor 角色能看到删除内容、创建/删除栏目、删除资产等按钮。从用户视角这与「按钮没接后端」体验相同,但成因是权限门缺失(缺陷 3)。

另外还有一个非 @click 的非功能控件:AssetSelector.vue:40 声明了多选列复选框,但没有 @selection-change⁠,勾选它不会让「确定」按钮解禁(缺陷 14)。

全前端只有 1 处 TODOAssetSelector.vue:225⁠),没有 FIXME/HACK,没有空函数体的 @click⁠。就「有没有假按钮」这个问题本身,代码是干净的——ElMessage.success 都出现在真实 await 之后。


七、死代码清单

js/cms/components/ 下有一整套可视化页面搭建器,没有任何视图或路由引用⁠:

文件行数用途
VisualEditor.vue464拖拽式页面搭建主界面
components.ts559组件定义(propsSchema / defaultProps)
ComponentPreview.vue356组件渲染预览
ComponentProperties.vue214属性编辑面板
registry.ts171组件注册表与类型定义
ComponentNode.vue126树节点
合计1,890占前端代码 14%

registry.ts 的注释写明设计意图是「让 Editor 与 Runtime 共享同一套组件白名单」,说明这曾是个规划中的 JSON 化页面搭建方案,最后实际落地的是富文本 + Figma 导入路线,这套东西被弃用但没删。main.js:30 仍导入 components.ts 做副作用注册,把它和 registry.ts 打进了主包。

未被调用的 API 封装:publishApi.unpublish⁠、damApi.getAssetRefs⁠、authApi.mfaReset⁠、previewApi.listTokens⁠、channelApi.getDetail⁠。


八、接手后的建议顺序

  1. 立刻把工作区提交进 GitLab(6 个未跟踪文件 + 8 个已改文件,发布/预览/修订三套核心流程全在里面,本地是唯一副本)
  2. api/audit.js:5 一行,恢复审计筛选与分页(改动最小、影响最大)
  3. RichTextEditor.sanitizeHtml 换成 DOMPurify 白名单
  4. 决策工作流:要么补齐提交/审批 UI,要么明确废弃两级审核并清理相关权限点与状态筛选项
  5. 建立按钮级权限指令(如 v-perm="'content:delete'"⁠),把 22 个权限点逐一落到按钮上
  6. 删掉 VisualEditor 整套死代码,同时移除 main.js:30 的副作用导入
  7. 把 CI 补上 build 与产物校验,终结「本机手工构建、产物提交进后端仓库」的现状

九、评级依据

理解难度 3/5⁠:技术栈主流、无自定义抽象层、<script setup> 扁平直白、单个 store 只有 25 行,读起来不费劲。扣分项是零文档(README 是模板原文)、1,890 行死代码误导、content_json 三种格式的隐式约定、修订稿(edit_draft_id⁠)流转规则只能从三个 composable 里反推、DamUploader「在列表中显示」开关名称与语义相反、Content/News 两份 600 行重复逻辑各自演化。

接手风险 3.5/5⁠:前端是薄层,鉴权与业务规则由后端兜底,误改的爆炸半径有限,这是减分项。加分项是——零测试零 lint、构建无 CI 且 build 脚本带 rm⁠、生产产物未入库、发布与删除直接作用于对外官网、富文本净化形同虚设、权限门缺失导致任何 UI 改动都可能扩大暴露面。

上手 6 人天⁠:读懂 18 个 API 模块与路由/守卫/store(1 天)、吃透 Content.vue + News.vue 共 2,800 行内容编辑器与 i18n 双份字段(2 天)、DAM 分片上传与断点续传(1 天)、RBAC 前后端映射与 confirm_token/MFA 二次确认流程(1 天)、摸清构建部署链路与 CSP nonce 约定(1 天)。

月维护 2 人天⁠:按 11 个视图的 CRUD 体量、无测试导致每次改动都需手工回归、Content/News 重复代码需双份同步、以及产物需手工构建部署估算。

demo.w2r.site 官网前台(frontend Vue3+Vite / backend CI4 薄壳 + public/api.php 独立入口)
10,822
行 / 132 文件
5
上手人天
1.5
月维护人天
19
缺陷
理解难度
接手风险

主要风险

  • 生产到底是 api.php 还是 app/Controllers 在服务 /api/menu、/api/news、/api/asset,仓库里没有 nginx/Apache vhost 配置可以证明,两边逻辑各有一份且不完全等价(错误码、日期对象处理)。接手第一天必须用 curl /api/ 的 data.framework 字段实测确认,否则修改可能完全不生效。
  • api.php 把数据库故障一律翻译成「成功但没有数据」,站点会以「菜单消失、新闻列表空白」的形态静默降级,没有任何 5xx 触发告警。任何依赖告警发现问题的运维习惯在这里全部失效。
  • shared/dam-writable 直连生产 CMS(w2r.site)的 writable 目录。demo 侧的部署脚本、清理脚本、权限批处理若跟随符号链接,会直接破坏生产 CMS 的媒体原始文件。本机该链接悬空,所有封面在本地一律 404,容易误判为代码 bug。
  • 前端产物 dist/ 是提交进仓库并被网站根目录直接指向的,且当前产物 mtime 早于两个源文件。没有 CI,构建纪律只写在 .cursor/rules/frontend-build.mdc 里;接手者若只改源码不构建,线上不变;若构建环境 Node/Vite 版本不同,产物可能与线上现状产生大面积 diff。
  • 站点只完成了首页与新闻详情两个页面,顶栏菜单指向的所有栏目页都不存在,且前端没有 catch-all 路由——点开即空白页而非 404。对外可见范围远小于导航暗示的范围,上线前必须先决定「隐藏导航」还是「补页面」。
  • 无任何响应式实现,顶栏是 1440px 画布下的绝对定位像素坐标。移动端与窄屏笔记本上布局破坏,且改造成本(约 40 人天)远超其余所有缺陷之和。
  • DAM 资产接口可按 id 枚举,未校验资产是否归属于已发布内容;资产整文件读入内存交付。既是信息泄露面,也是可被单请求打满内存的可用性风险。
  • backend/.env 为 development 环境,CI4 入口出错会吐完整堆栈;writable/debugbar 已积累 424 个 json、logs 里已有含绝对路径的堆栈。

交接范围:/Volumes/ProjectsAPFS/VWCORP/demo.w2r.site 全部。首方代码约 10,822 行(frontend/src 3,781;backend PHP 6,997;构建配置 44),132 个源码/配置/文档文件,不含 backend/vendor(27MB)、frontend/node_modules⁠、frontend/dist⁠。

1. 这个系统是什么

demo.w2r.site 是大众汽车集团(中国)官网前台的重做版本,只读⁠。它不生产内容——内容由同机的 w2r.site CMS 生产,两边共用同一个 MySQL 库(backend/.env:12database.default.database = vgc⁠)和同一份 DAM 媒体文件(通过符号链接 shared/dam-writable⁠)。demo 侧只做三件事:查 channels 表画导航、查 entries/entry_i18n 出新闻、按 id 吐 DAM 二进制。

目录关系:

目录内容是否上线
frontend/srcVue 3 + Vite 源码,35 个代码文件否,需构建
frontend/dist构建产物,网站根目录直接指向这里.cursor/rules/frontend-build.mdc:18⁠)
backend/public/api.php683 行、绕过 CI4 框架的独立 API 入口见 §4
backend/app/Controllers/*CI4 控制器,功能与 api.php 重叠见 §4
backend/public/demo_asset_delivery.inc.phpDAM 交付函数,被上面两条路径共用
shared/dam-writable符号链接 → /www/wwwroot/w2r.site/backend/writable

根目录的 index.html⁠、404.html 是宝塔面板默认文件,不是站点内容;.user.ini:1 设了 open_basedir=/www/wwwroot/demo.w2r.site/:/tmp/⁠。

2. 前端:组件树与路由

入口链路:src/main.js:7-9App.vue(只有一个 <router-view>⁠)→ src/router/index.js⁠。

路由表(router/index.js:10-26⁠)只有四条半:

路径组件
/HomePage.vue
/en/HomePage.vue(同一组件,靠 menuLangFromPathname 区分语言)
/enredirect → /en/
/news/:uuidNewsDetailPage.vue
/en/news/:uuidNewsDetailPage.vue

没有 catch-all,没有 NotFound 组件⁠。router 里 props: true(:18,:24)是无效声明——NewsDetailPage.vue 根本没有 defineProps⁠,uuid 是在 useNewsArticleDetail.js:34 里用 useRoute() 自取的。

组件树:

HomePage.vue:3-10
├── HeaderNav.vue           顶栏 + 三列 mega menu(621 行,本目录最复杂的组件)
├── HeroBanner.vue          首屏视频 + 标题/导语文案(文案硬编码 :54-56)
├── HomeIntroBandSection    空占位,只有 TODO 注释(13 行,实际什么都不渲染)
├── LatestReleaseSection    Latest Release 三卡 → ReleaseCard × N
├── FeatureAlternatingSections → FeatureSplitSection × 3 → FeatureCtaButton
├── BrandLogoGridSection    → BrandLogoCard × 12
├── SocialQrSection         三个二维码位
└── SiteFooter              页脚

NewsDetailPage.vue:2-6
├── HeaderNav.vue           复用
├── NewsArticleContent.vue  正文(样式在 styles/news-detail.scss,543 行,非 scoped)
└── SiteFooter.vue          复用

三个 composable 是全部的数据入口:

  • useSiteMenu.js:27GET /api/menu?lang=normalizeMenuApiResponsecapMegaMenuDepth(…, 3) 截断到三层 → resolveMegaState 算高亮态。
  • useLatestNewsList.js:22-24GET /api/news?lang=&limit=3⁠。
  • useNewsArticleDetail.js:47-49GET /api/news/{uuid}?lang=adaptNewsDetailResponsesanitizeArticleHtml(DOMParser 去 script/on* 属性/javascript: URL)→ enhanceArticleHtml(删空 <p>⁠、把连续两个图块包成双列 gallery)→ v-html⁠。

lib/ 下有四个文件是完全无引用的死代码⁠:lib/news/normalizeContentJson.js(后端已经做了同样的事)、normalizeMegaMenuNav.js:95normalizeFromFlatMenuRows⁠、:142megaMenuDepth⁠、articleUrl.js:20parseNewsDetailFromLocation⁠。data/mockNewsArticle.js 182 行整个文件自述 deprecated(:2)。

3. api.php 逐端点:SQL 与返回结构

api.php 是纯过程式脚本,靠 preg_match($path) 分发(:12,:24,:31,:37,:43,:52),手写 .env 解析器(api_load_env :67-98),mysqli 直连。注意 DAM 分支必须在 JSON header 之前(:11-16 的注释解释了原因)。

GET /api/asset/{id}?quality=high

api.php:12-16demo_asset_delivery.inc.php:11-88⁠。

-- demo_asset_fetch_row :168-171
SELECT a.storage_key, a.mime, a.size_bytes, a.filename, a.category_id, c.access_level AS cat_access
FROM assets a LEFT JOIN asset_categories c ON c.id = a.category_id
WHERE a.id = ? LIMIT 1;
-- quality=high 时追加 :196
SELECT storage_key, mime, size_bytes, filename FROM asset_variants
WHERE asset_id = ? AND quality = 'high' LIMIT 1;

返回二进制。状态码:400(id<=0,:14)、401 JSON {code:4011}(分类 access_level='auth',:50-53)、404(记录不存在 :43 / 文件不存在 :62)、403(realpath 越界 :71)、500(storageRoot 未配 :24 / DB 不可用 :31)。物理路径 = asset.storageRoot.env:24⁠)+ storage_key⁠。

GET /api/menu?lang=zh|en

api.php:24-28api_menu_response :136-176。

SELECT id, parent_id, {name_zh|name_en} AS name, {slug_zh|slug_en} AS slug, sort_order
FROM channels
WHERE is_active = 1 AND show_in_menu = 1 AND menu_position = 'top'
ORDER BY sort_order ASC, id ASC;

api_build_menu_tree :103-131 递归组树,href = 前缀(''/en⁠)+ /slug⁠。返回 {success:true, data:[{id,name,slug,href,sort_order,children[]}]}⁠。三条失败路径(:142 未配库、:157 连接失败、:166 查询失败)全部返回 {success:true, data:[]}⁠。另有 :163 在 fetch_assoc()(:170)之前就 close() 的隐患。

GET /api/news?lang=&limit=&page=

api.php:37-40api_news_list_response :203-280。limit 钳在 1–20(:206),page ≥ 1。

-- 计数 :220-224
SELECT COUNT(*) AS c FROM entries e
INNER JOIN entry_i18n ei ON ei.entry_id = e.id AND ei.lang = ?
WHERE e.type='news' AND e.status='published';
-- 列表 :238-245
SELECT e.id, e.uuid, e.publish_at, e.published_at, ei.id AS entry_i18n_id, ei.title, ei.slug
FROM entries e INNER JOIN entry_i18n ei ON ei.entry_id = e.id AND ei.lang = ?
WHERE e.type='news' AND e.status='published'
ORDER BY COALESCE(e.published_at, e.publish_at) DESC, e.id DESC
LIMIT ? OFFSET ?;
-- 每行封面 :315
SELECT asset_id FROM asset_entry_i18n WHERE entry_i18n_id = ? AND role='thumbnail' LIMIT 1;

返回 {success:true, message:'ok', data:{list:[{id,uuid,title,slug,cover_url:'/api/asset/{id}'|null,date_display:'Y.m.d',date_iso:'Y-m-d'}], total, page, limit}}⁠。INNER JOIN 语言表是刻意的:英文首页不会混进中文标题。连接失败同样返回 success:true 空列表(:212-216)。

GET /api/news/{id|uuid}?lang=

api.php:31-34api_news_detail_response :360-383。先 api_news_resolve_published_id :385-410(纯数字走 id⁠,否则走 uuid⁠,都要求 type='news' AND status='published'⁠),再 api_news_build_payload :415-483。

用到的查询:entries 主行(:417)、entry_i18n 全字段(:299,含 content_json,files,seo_title,seo_desc,og_asset_id,schema_json⁠)、封面(:315)、面包屑栏目(api_channel_public :589)、上下篇(:628-631)。语言回退在 :430-432:目标语言缺失时退回 zh⁠,返回体里的 lang实际生效语言⁠。

返回体(:458-482):id, uuid, channel_ids[], meta{}, lang, title, summary, slug, content_html, cover_asset_id, files[], tags[], publish_at, published_at, display_date('Y-n-j' 不补零), breadcrumb_channel{id,name,slug,href}, prev, next, seo{title,description,og_asset_id}⁠。

content_htmlapi_normalize_content_json :556-580 还原:先当 JSON 解,是字符串就直接用,是 [{type:'html',html:'…'}] 就取 html⁠,否则原样当 HTML。这是 CMS 富文本的存储约定,前端 lib/news/normalizeContentJson.js 有一份等价实现但没人调用。

上下篇(:619-651)把该栏目全部已发布文章 id 一次性拉回 PHP 再 array_search 定位,无 LIMIT、JSON_CONTAINS 不走索引。而前端 adaptNewsDetailResponse.js 根本没读 prev/next⁠。

失败一律 {success:false,message,data:null}HTTP 200(:365,:371,:378)。

GET /api 与 /api/hello

api.php:43-59⁠,健康检查。data.framework'CodeIgniter 4 (轻量入口)'⁠。

4. 两份并行实现对照,以及谁在生产生效

功能public/api.phpapp/Controllers/*差异要点
健康检查:43-51,framework=CodeIgniter 4 (轻量入口)Api.php:22-32⁠,framework=CodeIgniter 4这是判定谁在服务的唯一探针
/hello:52-59Api.php:37-46一致
菜单api_menu_response :136-176,手写 SQL + 手写递归Api.php:52-71ChannelModel::getMenuTree :47-62 + buildMenuTree :69-96SQL 语义一致;失败时 api.php 返回 200+success:true+空数组,控制器返回 500+success:false
新闻列表:203-280,mysqli preparedNews.php:24-94⁠,QueryBuilder字段与排序一致;异常处理同上(News.php:59-63 是 500)
新闻详情:360-483News.php:100-133 + buildNewsPayload :154-219payload 字段完全一致;控制器有 404(:109)/500(:121),api.php 恒 200⁠;控制器的 formatDatetime :404-408 额外处理 CI4 Time 对象,api.php 只处理字符串
面包屑api_channel_public :585-613ChannelModel::getPublicById :103-129一致
上下篇:619-651(real_escape_string + 整数拼接)News.php:254-283$db->escape⁠)算法一致,同样的全量扫描问题
DAM:12-16 内联 requireAssetDeliver.php:14-24 require FCPATH 下同一文件共用 demo_asset_delivery.inc.php⁠,无分叉

谁生效?仓库无法给出确定答案,这是本模块头号交接风险。 三份互相矛盾的证据:

  1. backend/public/.htaccess:29RewriteRule ^(hello|)$ api.php [L,QSA]RewriteBase /api/只把 /api//api/hello 交给 api.php⁠,其余(menu/news/asset)落到 :36 的前端控制器 index.php → CI4。
  2. app/Config/Routes.php:11 的注释与 :20-23 额外注册的裸路由(menu⁠、news⁠、asset/(:num)⁠)只有在 CI4 真的要处理 /api/menu 时才有必要写——说明作者当时认定 CI4 在处理这些路径。
  3. frontend/README.md:24 却写「Nginx:将 location ^~ /api/ 转发到能执行 api.php 的配置(与现有 /api/menu 一致)」,暗示 nginx 把整段 /api/ 都给了 api.php。
  4. 硬证据:backend/writable/logs/log-2026-03-23.log:1-3log-2026-03-20.log:1-3 记录了线上 GET /api/exchangerateuserconfig!get.action 进入 CI4 Router 并抛 BadRequestException。也就是说线上确实有 /api/* 请求走到了 index.php⁠。

接手第一步⁠:curl -s https://demo.w2r.site/api/ | jq .data.framework⁠,再逐个 curl /api/menu⁠、/api/news?limit=1 并观察 writable/logs/writable/debugbar/ 是否新增文件(走 CI4 才会新增)。生产 nginx/Apache vhost 配置不在仓库里⁠,属于缺失交接物,必须从服务器上取回归档。

backend/README.md:5-8 记录了 api.php 存在的原因:服务器 PHP 禁用了 putenv⁠、未装 mbstring⁠。但 backend/public/index.php:7-9 已经加了 mbstring polyfill,前提条件可能已经部分消失——这也是收敛为单一实现的机会。

5. 前端:哪些来自接口,哪些是硬编码

来自接口的只有三块⁠:顶栏菜单树、首页 Latest Release 三张卡(标题/日期/封面/链接)、新闻详情正文(标题、日期、tags、正文 HTML、面包屑栏目、files[0] 的下载地址)。

其余全部硬编码在组件里⁠:

  • 首屏大标题与英文导语:HeroBanner.vue:54-56(中英文页共用同一段英文)
  • 三段 feature 文案(Mobility for Generations / Innovation / CSR):FeatureAlternatingSections.vue:54-70
  • 「Latest Release」「View All」:LatestReleaseSection.vue:5,7
  • 12 个品牌 logo:assets/figma/brandLogos.js:5-7 读本地 logo1..12.svg⁠,无链接、无 CMS 来源
  • 社媒区三个标签 ['Weibo','WeChat','WeChannel']三个位共用同一张二维码图⁠:SocialQrSection.vue:29,10
  • 页脚五条链接与备案号:SiteFooter.vue:43-49,52
  • CSR 轮播的 5 个圆点是静态的,第一个永远高亮,没有轮播逻辑:FeatureSplitSection.vue:24-33
  • 面包屑兜底中文「媒体中心」:adaptNewsDetailResponse.js:21-22
  • 新闻详情三处图标是 Figma MCP 外链:mcp-assets.js:59-62

6. 完成度与死链清单

存在且接了真数据⁠:首页(/⁠、/en/⁠)、新闻详情(/news/:uuid⁠、/en/news/:uuid⁠)。就这两个。

占位/未完成⁠:HomeIntroBandSection.vue 是空 section(真正的 intro 在 HeroBanner.vue:42-45⁠);详情页没有封面图、没有上下篇、没有 SEO 注入(frontend/index.html:6 标题恒定);无搜索、无语言切换、无栏目列表页。

死链清单⁠:

位置现状
SiteFooter.vue:43-49FAQ / Help / Sitemap / Privacy Policy / Legal Statement 五条 href="#"
FeatureCtaButton.vue:2三处 Read More,href="#"
LatestReleaseSection.vue:7View All,href="#"
HeaderNav.vue:34-38语言按钮,无 @click⁠,/en/ 只能手输 URL
HeaderNav.vue:40-45搜索按钮,无 @click
HeaderNav.vue:24-31,98,143顶栏一/二/三级栏目链接指向真实 slug,但前端无对应路由⁠,点开是空白页;且用 <a href> 触发整页刷新
NewsArticleContent.vue:11,38面包屑返回栏目,同上
BrandLogoGridSection.vue:5-712 个品牌格无任何链接

7. shared/dam-writable 符号链接

shared/dam-writable -> /www/wwwroot/w2r.site/backend/writable⁠,作用见 shared/README.md:1-18⁠:让 demo 站直接读生产 CMS 的 DAM 物理文件,配合 .env:24asset.storageRoot⁠。

本地影响⁠:本机没有 /www 目录,链接悬空。所有 /api/asset/{id}demo_asset_delivery.inc.php:61-66 命中 is_file() 失败返回 404 File missing⁠,本地新闻封面一律裂图——这不是代码 bug,别去改代码。本地要验证封面,需 mkdir -p 一个目录并把 .env:24 指过去,再放几个符合 storage_key 路径的文件。

风险面⁠:这条链接直通生产 CMS 的写目录。任何在 demo 目录下跟随符号链接的 rsync -a --delete⁠、rm -rf⁠、chown -R⁠、备份脚本,都会直接作用到 w2r.site 的媒体原始文件。已知缺失交接物里包含 backend/writable/(DAM 媒体),意味着这批文件目前只存在于生产服务器上,没有第二份副本。

8. 接手第一周建议顺序

  1. 取回并归档生产 nginx/Apache vhost 配置,用 /api/data.framework 实测确认 §4 的分发真相。在结论明确之前,任何后端改动都要两份一起改⁠。
  2. 本地重新执行前端构建(禁止在本次审计中执行),diff frontend/dist 与仓库现有产物,确认线上是否包含 HeaderNav.vue 最后一次改动。
  3. .env:5 改成 production⁠,清 writable/debugbar/(424 个 json)。
  4. api.php 的错误伪装(:142,157,166,212-216 改成真实 5xx),否则后续任何故障都查不到。
  5. mcp-assets.js:59-62 三个 Figma 外链换成本地 SVG。
  6. 给 router 加 catch-all → NotFound 页,先止住「点导航一片空白」。
  7. demo_asset_delivery.inc.php 加「资产必须挂在已发布 entry 上」的校验,并改成流式输出。

不要做的事⁠:不要顺手删 app/Controllers/News.php「因为它没用」——在 §4 结论出来之前它很可能就是线上正在跑的那一份。

w2r.site — Python 迁移脚本(scripts/)与文档体系(docs/ + 根级 README/安全扫描)
6,680
行 / 83 文件
7
上手人天
0.8
月维护人天
15
缺陷
理解难度
接手风险

主要风险

  • Sitecore 侧访问权限是单点依赖且很可能已随人员离职失效。三个抓取脚本(import_sitecore_files / import_sitecore_news / update_sitecore_channels_and_assets --update-import)都靠 backend/.env 里的 sitecore.LOGGING_URL / username / password 登录 cm.volkswagengroupchina.com.cn 后台,用 Playwright 填表拿 __RequestVerificationToken 再在页面上下文 fetch 内部接口。这套凭据、接口路径(/admin/FilesUpload/GetFilesList、/admin/AdminNews/GetNewsList、/admin/AdminNews/GetNews)和 7 个 channel GUID 只存在于本仓库文档里,Sitecore 侧没有任何契约保障。凭据一旦失效或对方改版,所有增量补抓能力立即归零,而原始数据(sitecore_files_import / sitecore_news_import 两张临时表)又不在交接的数据库导出里——导出本身也缺失。
  • 文档体系与代码存在成代际的脱节,且脱节部分正好是最需要文档的部分。docs/import_sitecore_news.md 与 scripts/README.md 关于新闻导入的整章描述的是一个已被删除的脚本版本;scripts/README.md 详述了 4 个不存在的脚本;docs/import_sitecore_files.md 引用了不存在的 stats 脚本。相对地,docs/cms/ 下 26 份设计文档质量很高、changelog.md 更新到 2026-06-21,容易让接手者误以为整个文档体系都可信。判定规则应当是:docs/cms/ 的设计文档可信度高(可与 backend/app/Database/Migrations/ 的 51 个迁移逐条对上),docs/ 根目录下 6 份脚本说明必须逐条对着源码复核后才能用。
  • 整条迁移链路没有任何回滚点。sitecore_files_import 每次导入前被 TRUNCATE;DAM 文件落在 backend/writable/uploads/dam/ 而该目录整个缺失;所有失败日志(migrate_sitecore_files_failures.log 等 5 个)都在 backend/writable/logs/ 下,同样缺失;数据库导出也没有。这意味着 migrate_video.log:132 记录的那 1 条失败视频({E8EC3D5F-85F4-4BF2-84B8-124DEC08F983} 驭势而上 RISE UP)现在无法查明失败原因,--retry-failures 模式(migrate_sitecore_files_to_dam.py:878-886)因为读不到失败日志而完全不可用。
  • 多个脚本把「环境相关的自增主键」当常量写死,任何库重建都会静默错配而不是报错。category_id 60(新闻封面)、2–8(7 个 Sitecore channel)都来自 asset_categories 的自增 id,而这些行是迁移 2026-03-04-100031 遍历 channels 表动态插入的。assets.category_id 没有外键约束,错配不会抛异常,只会让媒体库分类树看起来「差不多对」。已知迁移 2026-03-04-100031 在干净库上因硬编码 created_by=1 跑不通,一旦有人修复它并重建库,这些常量的对应关系就会整体偏移。
  • reset_sitecore_dam_assets.py 与 update_sitecore_channels_and_assets.py --update-assets 是两颗生产环境地雷,且后者是文档推荐的常规操作。前者有 --confirm 保护但删除时不清理无外键的 poster_asset_id / cover_asset_id 引用;后者完全没有保护,一条命令即可把大批资产的 category_id 置空。交接后应立刻在这两个脚本入口加环境白名单或强制二次确认,在此之前应视为禁止执行。
  • security-scan.sh(1236 行,其中 475-938 行是内嵌 HTML 报告模板)指向的项目根 /www/wwwroot/vgc 与实际部署路径 /www/wwwroot/w2r.site 不符,唯一一份扫描结果停留在 2026-01-25。加上 docs/software-bom.md 记录的生产栈(PHP 8.3.30 / MySQL 5.7.44 / CI4 4.7.2)与本机复现环境(PHP 8.2.33 / MySQL 8.0.46)不一致,合规材料(quickcheck 对照表、SBOM、日志清单 TSV)目前只能代表历史某一时点,不能作为当前系统的合规证据直接提交。

交接范围:/Volumes/ProjectsAPFS/VWCORP/w2r.site/scripts/ 全部 11 个 .py(5,325 行)、docs/ 全部文档(根目录 14 份 md + docs/cms/ 26 份 md + 2 份 .mmd + 1 个 .py + tsv/csv/docx/xlsx/png)、根级 README.md⁠、SECURITY-SCAN-README.md⁠、security-scan.sh(1,236 行)、migrate_video.log(220 行)、security-scan-results/(7 个文件)。

一、这个模块到底是什么

它不是「CMS 的一部分」,而是一次性的 Sitecore → 新 CMS 数据搬迁工程⁠,加上围绕它长出来的一整套设计与合规文档。搬迁已经基本做完(最后一次脚本改动是 migrate_sitecore_files_to_dam.py 的 2026-07-22),现在处于「跑完了但随时可能要补跑」的状态。

数据源是大众中国的 Sitecore 后台 https://cm.volkswagengroupchina.com.cn⁠。所有脚本都不走 Sitecore 官方 API,而是用 Playwright 无头浏览器登录后台、从页面上抓 __RequestVerificationToken⁠,再在页面 JS 上下文里 fetch 后台的内部接口(见 scripts/import_sitecore_files.py:234-258⁠)。这是个很脆的设计,但也是当时唯一能拿到数据的路子。

整条链路是「两张临时表 + 一组搬运脚本」:

Sitecore 后台
   │ Playwright 登录 + 页内 fetch
   ├─ /admin/FilesUpload/GetFilesList  ──► sitecore_files_import   (文件/图片/视频清单)
   └─ /admin/AdminNews/GetNewsList
      /admin/AdminNews/GetNews         ──► sitecore_news_import    (新闻正文清单)
                                              │
                          ┌───────────────────┴───────────────────┐
                          ▼                                       ▼
              assets / asset_variants                   entries / entry_i18n
              (HTTP 下载真实文件落盘)                   (封面、附件、正文关联)
                          │                                       │
                          └──────────► replace_sitecore_media ◄────┘
                                       (正文里的 Sitecore URL 改写成 /asset/{id})

两张临时表只存清单不存二进制docs/sitecore-files-import-plan.md:75 明确写了这一点),文件是后续脚本按 path_val 拼 URL 现下载的。


二、11 个脚本逐个说明

下面每条都给出:用途 / 调用 / 数据来源 / 写入目标 / 幂等性 / 破坏性。

1. import_sitecore_files.py(737 行)— 文件清单抓取

  • 用途⁠:把 Sitecore「附件管理 / 图片管理 / 视频管理」三个模块的全部条目抓进 sitecore_files_import⁠。
  • 调用⁠:python3 scripts/import_sitecore_files.py [--languages=zh-cn,en | --language zh-cn --language en]⁠,默认中英双抓。
  • 来源⁠:POST {base}/admin/FilesUpload/GetFilesList⁠,表单参数 __RequestVerificationToken / pageIndex / pageSize / language / searchName / Status / channel / type⁠。typeDocument|Image|Video⁠;Document 不传 channel,Image 循环 3 个 channel GUID(import_sitecore_files.py:141-145⁠),Video 循环 4 个(:146-151⁠)。token 优先从 /admin/FilesUpload/Index 取,被重定向时回退到 /admin/AdminNews/Index:513-525⁠)。
  • 写入⁠:MySQL 表 sitecore_files_import(30 列,建表语句内嵌在 :318-361⁠,也有对应迁移 2026-03-11-100034⁠)。调试快照写 writable/import_sitecore_files/getfileslist_debug.json⁠。
  • 幂等⁠:否,而且是反的⁠。:493 在登录之前就 TRUNCATE TABLE⁠,中途任何失败都会留下空表或半表。Image/Video 阶段用 _insert_or_merge_channel:408-451⁠)按 (sitecore_id, language_val) 合并 channel_ids,这一段是幂等的,但整体不是。
  • 破坏性⁠:中(只动临时表,但会清空下游四个脚本唯一的数据源)。
  • 实测规模⁠:writable/import_sitecore_files/getfileslist_debug.json 显示 Document/zh-cn 的 TotalCount=1650⁠;docs/import_sitecore_files.md:166 记录三类合计 4,205 条。

2. import_sitecore_news.py(494 行)— 新闻清单抓取

  • 用途⁠:抓 Sitecore AdminNews 的中英文新闻列表 + 逐条详情,写 sitecore_news_import⁠。
  • 调用⁠:python scripts/import_sitecore_news.py [--languages zh-cn,en]⁠。只有这两个参数⁠。
  • 来源⁠:POST /admin/AdminNews/GetNewsList(分页,pageSize 默认 10,可用 SITECORE_NEWS_PAGESIZE 覆盖)+ 逐条 POST /admin/AdminNews/GetNews⁠。响应字段见 writable/import_sitecore_news/get_news_raw_sample.json(25 个字段,含 HtmlContent⁠、AllocaitedPosition⁠、ArticleImgID⁠、DocumentIDs⁠)。注意 Sitecore 侧字段名 AllocaitedPosition 是拼错的,脚本 :271 原样沿用。
  • 写入⁠:sitecore_news_import⁠,UNIQUE KEY 是 (sitecore_id, language_val)⁠,写入用 INSERT ... ON DUPLICATE KEY UPDATE:348-360⁠)。
  • 幂等⁠:(upsert)。这是全套里唯一真正可重复执行的抓取脚本。
  • 破坏性⁠:低。
  • ⚠️ 文档陷阱⁠:docs/import_sitecore_news.mdscripts/README.md:12-70 描述的是一个已被删除的旧版本⁠——那个版本有 --step=1..4⁠、--clear⁠、--dump-list-page⁠、list_zh.json/list_en.json 缓存、LIST_SELECTORS/DETAIL_SELECTORS⁠,而且直接写 entries + entry_i18n + asset_entry_i18n⁠。现有代码一样都没有。旧版本的产物还留在 writable/import_sitecore_news/newslist.json(636KB,zh 1,576 条 / en 327 条,字段 item_id/edit_url/cover_img⁠),这也是判断这个版本真实存在过的直接证据。这份文档不能用。

3. update_sitecore_channels_and_assets.py(336 行)— channel 刷新与回写

  • 用途⁠:不重新全量导入的前提下,单独刷新 channel 归属。
  • 调用⁠:--update-import(只刷临时表,需 Playwright)/ --update-assets(只回写 assets,只需数据库)/ 不传参数则两步都做。
  • 来源⁠:同 1,复用 import_sitecore_files 的登录与请求函数(:53-63 直接 import)。
  • 写入⁠:sitecore_files_import.channel_ids⁠;以及 assets.category_idassets.publish_date⁠。
  • 幂等⁠:--update-import 是(覆盖写)。
  • 破坏性⁠:极高,见下方「地雷」一节⁠。

4. migrate_sitecore_files_to_dam.py(1,031 行)— 主力搬运脚本

  • 用途⁠:把 sitecore_files_import 里的清单变成真实的 DAM 资产。
  • 调用⁠:python scripts/migrate_sitecore_files_to_dam.py [--limit=N] [--dry-run] [--skip-existing] [--workers=N] [--type=Image|Video|Document] [--retry-failures]⁠。默认 workers=5⁠;--type=Video 会强制降为单线程并逐条打印(:898-901⁠)。
  • 来源⁠:sitecore_files_import 全表;文件本体从 https://cm.volkswagengroupchina.com.cn/{path_val} HTTP 下载(视频 timeout 300s/重试 3 次,文档 180s/3 次,图片 120s/2 次,见 :712-717⁠)。
  • 写入⁠:assets(主记录)、asset_variantsentity_list_json[0] 的高精度版,quality=high)、视频封面另建一条 type=image 的 assets 并回填 poster_asset_idensure_video_poster_asset⁠,:409-497⁠)。文件落 backend/writable/uploads/dam/YYYY/MM/sitecore_{ymd}_{guid}.{ext}⁠。
  • 关键逻辑⁠:merge_rows_by_sitecore_id:201-272⁠)把同一 GUID 的 zh-cn 与 en 两行合并成一条,分别填 title_zh/title_en⁠、description_zh/description_en⁠。这是为了修 docs/cms/sitecore-dam-migration-analysis.md:1321 记录的「title_en 从未写入」的老 bug。
  • 幂等⁠:部分⁠。已存在 sitecore_id 时走「只更新元数据、不重新下载」分支(:686-700⁠),设计上可重跑。但文件先落盘后写库(:738 vs :745⁠),INSERT 失败会留孤儿文件。
  • 破坏性⁠:中(会覆盖已有 assets 的 title/meta/category/status/publish_date)。

5. backfill_video_posters_from_sitecore.py(438 行)— 视频封面补全

  • 用途⁠:给 2026-06-10 之前迁移的 196 条视频补 poster_asset_id(当时 4 号脚本还没有封面逻辑)。
  • 调用⁠:[--limit=N] [--dry-run] [--skip-existing]⁠。注意 --skip-existing 语义是「封面资产不存在时跳过、不新建」,和别的脚本里同名参数含义不同。
  • 来源⁠:assets JOIN sitecore_files_importthumbnail_id/thumbnail:217-264⁠),封面路径形如 /-/media/Asset/Thumbnails/*.ashx⁠,实际响应是 JPEG,靠 Content-Type/Content-Disposition 推扩展名(:141-161⁠)。
  • 写入⁠:新增 assets(type=image)+ 回填视频行的 poster_asset_id⁠。
  • 幂等⁠::284-286 先查 sitecore_id,:377 的 UPDATE 带 poster_asset_id IS NULL OR = 0 条件)。
  • 破坏性⁠:低。现已被 4 号脚本内置逻辑取代⁠,属于历史补丁。

6. sync_video_assets_status_from_sitecore.py(295 行)— 视频状态同步

  • 用途⁠:把 Sitecore 的 status(1=已保存 / 2=已发布)和 publish_date 同步到 assets。
  • 调用⁠:[--dry-run]⁠。范围硬限定 assets.mime = 'video/mp4'⁠。
  • 来源/写入⁠:sitecore_files_importassets.status / assets.publish_date⁠。同一 GUID 多行时优先 zh-cn,其次 en(:138-166⁠)。
  • 幂等⁠:(先 diff 再更新,无变化不写)。
  • 破坏性⁠:低。docs/sync_video_assets_status.md:35-36 记录实际执行结果:status 195 条、publish_date 补 41 条。

7. migrate_news_covers_to_assets.py(521 行)— 新闻封面入 DAM

  • 用途⁠:把 sitecore_news_import.article_img/article_img_id 的封面图下载入 assets。
  • 调用⁠:[--limit=N] [--dry-run] [--skip-existing] [--no-cross-ref] [--workers=N]⁠。--workers 是假的(见缺陷 12)。
  • 来源⁠:article_img 优先;为空时用 article_img_idsitecore_files_import 反查 path_val/url_val--no-cross-ref 关掉这个兜底)。
  • 写入⁠:assets⁠,category_id 硬编码 60⁠、status 硬编码 2(已发布)。文件名 news_cover_{ymd}_{guid}.{ext}⁠。
  • 幂等⁠::322-326 sitecore_id 已存在则 skip)。
  • 破坏性⁠:低。
  • 边界⁠:明确不写 entries.cover_asset_id 也不写 asset_entry_i18n⁠,那是 8 号脚本的事(docs/migrate_news_covers_to_assets.md:66⁠)。

8. migrate_sitecore_news_to_entries.py(667 行)— 新闻正文入库

  • 用途⁠:sitecore_news_importentries + entry_i18n + asset_entry_i18n⁠。
  • 调用⁠:[--limit=N] [--dry-run] [--skip-existing] [--clear]⁠。
  • 关键映射⁠:
  • entries.uuid = sitecore GUID 去大括号转小写(:365⁠)——注意这是把 uuid 字段征用成了 Sitecore 溯源键。
  • entry_i18n.slug = news-{uuid}(zh)/ news-{uuid}-en(en)。
  • entries.channel_idJSON 数组⁠,由 allocated_position GUID 映射:{2FF9B990-…}→官网新闻、{CA41F3DD-…}→媒体新闻(:40-43⁠),优先读 config 表 sitecore.news_allocated_position_to_channel⁠,否则按 channels.name_zh 查。
  • 状态一律 draft:386⁠),要人工审核发布。
  • document_ids 查 assets 组装成 entry_i18n.files[{name,url:/asset/{id}}]⁠。
  • 幂等⁠:(slug 判重,:370-376⁠)。
  • 破坏性⁠:--clear 会按 sitecore_news_import 的 GUID 删除 entries/entry_i18n/asset_entry_i18nrun_clear⁠,:573-638⁠),范围受控但不可撤销。
  • 已知缺陷⁠:非事务,见缺陷 3。

9. replace_sitecore_media_in_content_json.py(371 行)— 正文 URL 改写

  • 用途⁠:把 entry_i18n.content_json 里残留的 Sitecore 图片/视频地址换成本地 /asset/{id}⁠。
  • 调用⁠:[--limit=N] [--dry-run] [--type=news](文档里写的 --verbose 无效)。
  • 匹配三条路⁠:URL 里的 sc_itemid={GUID}assets.sitecore_id⁠;data-id="{GUID}" 属性 → 同上;否则按 /-/media/ 路径去 assets.sitecore_pathsitecore_files_import.path_val 反查(:119-179⁠)。
  • 写入⁠:改写 <img src>(同时删掉 data-id⁠、加上 data-asset-id⁠)、<source src>⁠、<video src>⁠,回写 content_json⁠。会保留原本是 JSON 数组还是 JSON 字符串的格式(:326-329⁠)。
  • 幂等⁠:(改写后不再含 Sitecore 特征,二次运行直接 skip)。
  • 破坏性⁠:中(直接改正文,无备份;正则替换 HTML 存在边界风险)。

10. reset_sitecore_dam_assets.py(274 行)— 回滚删除器

  • 用途⁠:撤销 4 号脚本的成果。按 assets.meta_json 里的 "sitecore_migrate": true 标记找出记录,删数据库行 + 删磁盘文件。
  • 调用⁠:默认 dry-run⁠,必须显式 --confirm 才真删。支持 --type=image|video|file--category-id=N 过滤。
  • 安全措施(已有的)⁠:_safe_abs_path:95-109⁠)做了路径穿越校验,确保只删 backend/writable/ 底下的东西;删除前先打印命中统计。
  • 安全缺口(缺的)⁠:不清理引用它的 entries.cover_asset_idassets.poster_asset_id⁠——这两列都没有外键(迁移 2026-01-24-100017:80 只给 created_by 建了 FK),删完直接悬空;asset_entry_i18n 是 CASCADE,会被无声连带删掉。详见缺陷 5。
  • 破坏性⁠:最高。这是整个仓库里唯一一个会主动删生产数据和文件的脚本。

11. gen_diagram_images.py(161 行)— 文档配图生成

  • 与迁移无关。用 Pillow 画 5 张流程图 PNG 到 docs/images/⁠,给 project-introduction.md 转 Word 时插图用。字体路径全是 Linux 的(:12-16⁠),在 macOS 上会 fallback 到 ImageFont.load_default()⁠,中文会变方块。

附:docs/cms/gen_quickcheck_xlsx.py(119 行)

不在 scripts/ 下但同属本模块。把 quickcheck-corporate-website-0202.md 的 41 行合规对照表硬编码成 ROWS 常量再生成 xlsx,依赖 openpyxl。md 和这个 py 里的数据是两份拷贝⁠,改一处不会同步另一处。


三、迁移叙事:迁了什么、什么顺序、到哪一步了

推荐执行顺序(从代码交叉引用反推,文档里没有一处完整写出)

0. cd backend && php spark migrate      # 建两张临时表 + assets 的 sitecore 字段 + config 种子
1. import_sitecore_files.py             # → sitecore_files_import   (⚠️ 会 TRUNCATE)
2. update_sitecore_channels_and_assets.py --update-import   # 可选,仅刷 channel_ids
3. migrate_sitecore_files_to_dam.py     # → assets + asset_variants + 视频封面
4. sync_video_assets_status_from_sitecore.py                # → 视频 status/publish_date
5. import_sitecore_news.py              # → sitecore_news_import
6. migrate_news_covers_to_assets.py     # → 封面入 assets (category 60)
7. migrate_sitecore_news_to_entries.py  # → entries + entry_i18n
8. replace_sitecore_media_in_content_json.py                # → 正文 URL 改写

历史补丁:backfill_video_posters_from_sitecore.py(已被 3 内置取代)
回滚:reset_sitecore_dam_assets.py(撤销 3)
禁止:update_sitecore_channels_and_assets.py --update-assets(当前版本有数据损坏风险)

时间线(按文档变更记录 + 文件 mtime + 日志实证)

时间事件证据
2026-02-25新闻导入初版(旧代际,含 step1-4)docs/import_sitecore_news.md:79⁠、writable/import_sitecore_news/newslist.json
2026-03-11文件清单导入打通,三类 type 合计 4,205 条docs/import_sitecore_files.md:163-166
2026-03-12增加英文抓取;channel→category 映射入 configdocs/import_sitecore_files.md:167-168⁠、迁移 …-100042
2026-03-12 23:23 → 03-13 09:57197 条视频迁移,成功 196 / 失败 1,耗时 663 分钟migrate_video.log:2-220
2026-03-13新闻封面方案敲定(category 60、status published)docs/migrate_news_covers_to_assets.md:53-62
2026-06-10补视频封面 + 同步 status(195 条)/ publish_date(41 条)docs/backfill_video_posters.md:41⁠、docs/sync_video_assets_status.md:35-36
2026-07-22最后一次脚本改动(migrate_sitecore_files_to_dam.py⁠)文件 mtime

migrate_video.log 的读法

这是唯一一份保留下来的执行实证,值得逐条看:

  • :2共 197 条(已按 sitecore_id 合并中英行)| workers=1⁠,确认 --type=Video 触发了强制单线程。
  • :3 起 — 每条打印 sitecore_id / title_zh / title_en -> OK⁠。可以直接看出双语合并生效了(如 :20 中文「进博会:今天的展览 明天的生活」对英文「CCTV Interview during CIIE」)。
  • :132唯一一条 FAIL⁠:{E8EC3D5F-85F4-4BF2-84B8-124DEC08F983} 「驭势而上 RISE UP」。有意思的是 :215 有一条同名但不同 GUID 的 {1BD64F23-85FE-429F-B380-5FBD13CF130E} 迁移成功了,说明是单个文件的下载问题而非逻辑问题。
  • :13, :24, :35… — 进度行显示 约 0.0/s⁠、ETA 一度到 626 分钟。平均每条视频 3.4 分钟,主要是大文件下载 + 每条一次 HD 变体 + 一次封面共三次 HTTP 往返。
  • :220 — 结尾暴露了生产部署路径 /www/wwwroot/w2r.site/backend/writable/logs/⁠,这条信息在别处没有。

现在处于什么状态

文件侧和新闻侧都已搬完,最后一次调整在 2026-07。但验证材料全部缺失⁠:backend/writable/(DAM 文件 + 5 份失败日志)整个目录不在交接包里,数据库导出也没有。所以「196 条视频的文件现在还在不在」「那 1 条失败的原因是什么」「新闻迁了多少条」这三个问题,目前无法从交接物里回答。--retry-failures 模式(migrate_sitecore_files_to_dam.py:878-886⁠)因为读不到失败日志而不可用。


四、两颗地雷(接手第一天就该处理)

地雷 A — update_sitecore_channels_and_assets.py --update-assets

run_update_assetscategory_id = None 是初值(:273⁠),只有当 channel_ids 里有 GUID 命中映射时才被赋值。没命中就带着 None 进 UPDATE(:281-289⁠)。而 Document 类型天生没有 channeldocs/sitecore-files-import-plan.md:28 明说 channel 参数仅对 Image/Video 有效),新闻封面也没有。所以在当前生产库上执行这条文档推荐的常规命令⁠,会把 1,650+ 条文档资产和全部新闻封面的 category_id 置为 NULL。没有 --dry-run⁠,没有确认,没有日志,assets.category_id 也没有外键会拦。

地雷 B — reset_sitecore_dam_assets.py --confirm

删得干净但删得不够干净:删了 assets 行和磁盘文件,却不清 entries.cover_asset_id / assets.poster_asset_id(无外键,悬空),asset_entry_i18n 则被 CASCADE 静默清掉。带 --type=image 跑一次,所有视频的封面指针就都指向不存在的 id 了。

两个脚本在加保护之前都应视为禁止在生产执行⁠。


五、文档体系:哪些能信,哪些不能

40 份 Markdown 分成质量差异极大的三档。

可信(docs/cms/⁠,26 份)

这批是真正的资产。architecture.md(信任边界 + 5 张 Mermaid DFD)、requirements.md(范围与非目标写得很硬,明确「不做 PDF 全文检索 / 不做多码率流媒体 / 不做历史数据迁移」)、mysql-schema.md(v1.10,逐版变更记录)、field-dictionary.md(v1.8)、state-machine.md(9 个状态流转,每个都写清了权限点、前置条件、必记审计事件、字段更新)、security-model.md(7 角色 + 21 权限点)、dam-design.md(v1.4)、audit-logging.md + audit-gap-implementation.md(P0/P1/P2 差距清单,标注 2026-05-21 已完成)、data-model-diagrams.md(896 行,配 diagrams/*.mmd 可导出)。

与代码相符度:高。mysql-schema.md 的变更记录能和 backend/app/Database/Migrations/ 的 51 个迁移逐条对上——v1.3 对 2026-02-03-100026_AddPasswordChangedAtToUsers⁠、v1.4 对 …-100027_AddShowInMenuToChannels⁠、v1.5 对 …-100029_AddUuidToEntries⁠、v1.6 对 …-100030_ChangeEntriesChannelIdToJson⁠、v1.7 对 2026-02-25-100000_AddFilesToEntryI18n⁠、v1.8 对 …-100035_CreateAssetVariantsTable⁠、v1.10 对 2026-04-29-100050_CreateEntryCssVersionsTable + …-100051_AddFigmaImportPermission⁠。这种一一对应关系在离职交接里相当少见。

docs/changelog.md 维护到 2026-06-21,且每条都带模块和文件路径,是理解最近半年改了什么的最快入口。

小瑕疵:requirements.md:270 写「不做历史数据迁移」,而整个 scripts/ 目录就是历史数据迁移——这是需求边界后来被突破了但没回改文档。video-delivery-and-transcoding.md:20 倒是老实交代了另一处边界收窄(视频改成 720p/1080p 双精度)。

半可信(根级 docs/⁠,需逐条核)

project-introduction.md(465 行)、software-bom.md(169 行)、changelog.md 内容本身没问题,但环境信息已经漂移:

  • project-introduction.md:44 写生产域名 https://vgc.digirepub.com/⁠;migrate_video.log:220 显示部署路径 /www/wwwroot/w2r.site⁠;security-scan.sh:9 又写 /www/wwwroot/vgc⁠。项目在生命周期里至少换过三个身份(vgc → w2r.site → vgc.digirepub.com 域名),文档没统一。
  • software-bom.md:15-22 记录生产栈是 PHP 8.3.30 / MySQL 5.7.44 / CI4 4.7.2 / Node 18.19.1;而本机复现环境是 PHP 8.2.33 / MySQL 8.0.46。MySQL 大版本不同,JSON 函数和 utf8mb4 排序规则行为都有差异,entries.channel_idJSON_CONTAINS 筛选值得实测。
  • docs/cms/figma-import.md:5⁠、video-delivery-and-transcoding.md:4 引用 ../../../docs/*.md(workspace 级),checklist-implementation.md:3 引用 docs/checklist.xlsxchecklist-mapping.csv ——这 4 份文件都不在交接包内⁠。

不可信(脚本说明,6 份 + scripts/README.md⁠)

这是最需要提醒的部分。

文档判定依据
docs/import_sitecore_news.md完全不符描述已删除的 step1-4 版本,见前文第 2 节
scripts/README.md(新闻章节 :12-70⁠)完全不符同上
scripts/README.md:181-343描述了 4 个不存在的脚本import_vgc_news.py / import_mediacenter_news.py / md_to_docx.py / gen_interface_docs.py 均无源码
docs/import_vgc_news.md孤儿文档对应脚本不存在,且其描述(抓缩略图入 DAM)与 scripts/README.md:181 的描述(抓新闻详情入 entries)互相矛盾
docs/import_sitecore_files.md基本相符,一处引用失效:86 引用的 stats_sitecore_files_import.py 不存在
docs/migrate_sitecore_files_to_dam.md相符流程 6 步与代码一一对应
docs/migrate_news_covers_to_assets.md相符甚至诚实标注了 --workers 是「预留参数,当前为顺序执行」(而 scripts/README.md:91 没标)
docs/migrate_sitecore_news_to_entries.md相符字段映射表与 :353-485 完全一致
docs/replace_sitecore_media_in_content_json.md相符未列 --verbose⁠,比脚本 docstring 更准确
docs/backfill_video_posters.md / sync_video_assets_status.md相符且带实际执行条数,可作数据核对基准
docs/sitecore-files-import-plan.md相符接口参数速查表最实用,:96-101 的实现检查清单还留着一个未打勾项

__pycache__/import_vgc_news.cpython-312.pyc 还在(2026-02-09),必要时可以反编译找回那个脚本。

合规材料(security-scan.sh + 扫描结果 + quickcheck + 日志清单)

security-scan.sh 1,236 行,其中 :475-938 是一大块内嵌的 HTML 报告模板,实质逻辑只有几百行(npm audit / composer audit / 依赖树 / 许可证 / 文件统计)。:9PROJECT_ROOT 写死 /www/wwwroot/vgc⁠,加上 :6set -e⁠,在当前部署下跑不通。security-scan-results/ 里唯一一份结果是 2026-01-25 生成的,found 0 vulnerabilities⁠,项目名 vgc@1.0.0⁠,依赖版本(vue 3.5.26 / vite 7.3.1)与 software-bom.md:5 记的 2026-05-18 版本(vue 3.5.34 / vite 6.4.2)对不上——过去半年没有真正跑过扫描⁠。

docs/cms/quickcheck-corporate-website-0202.md(41 条大众集团 Measure 逐条对照,含 4 条明确「不满足」:应用内密级标注、导出文档密级、上传病毒扫描)和 docs/cms/log-inventory-excel-en.tsv(35 行英文日志清单,含 KSU 分级和留存期)质量很高,但同样属于「某个时点的快照」,作为当前合规证据前需要重新核。


六、运行这些脚本需要什么

Python 版本⁠:3.12(software-bom.md:22__pycache__cpython-312 一致)。最低 3.9——多数脚本有 from __future__ import annotations⁠,但 reset_sitecore_dam_assets.py:143 等处在函数体内用了 list[object]⁠。

pip 依赖(按脚本)⁠:

谁需要
pymysql全部 10 个数据库脚本
requestsmigrate_sitecore_files_to_dam / migrate_news_covers_to_assets / backfill_video_posters_from_sitecore
bcrypt上述三个 + migrate_sitecore_news_to_entries(都调 get_or_create_import_user⁠)
playwright + playwright install chromiumimport_sitecore_files / import_sitecore_news / update_sitecore_channels_and_assets --update-import
Pillowgen_diagram_images(另需 Linux 路径下的中文字体)
openpyxldocs/cms/gen_quickcheck_xlsx.py
python-docxmd_to_docx.py / gen_interface_docs.py(两个脚本都已丢失)

⚠️ 随包 .venv 不能用⁠:里面是 x86_64-linux-gnu 的二进制(从生产服务器整体拷来),且只装了 pymysql 1.2.0 / bcrypt 5.0.0 / requests 2.34.2,没有 playwright⁠。macOS 上必须重建:

python3 -m venv .venv && .venv/bin/pip install pymysql bcrypt requests playwright pillow openpyxl
.venv/bin/playwright install chromium

配置来源⁠:所有脚本都自带一份几乎相同的 load_env()⁠,依次读 backend/.env 再读项目根 .env(根 .env 不存在),把 database.default. 映射成 DB_⁠、sitecore. 映射成 SITECORE_⁠。当前 backend/.env 里 4 个 sitecore 键和 7 个 database 键都在(值见 backend/.env⁠,不在此复述)。可选环境变量:IMPORT_USERNAME(默认 vgc_news⁠)、IMPORT_PASSWORD(默认值硬编码在各脚本约 :123 行,属于弱默认口令,应改)、SITECORE_GETFILESLIST_PAGESIZE⁠、SITECORE_NEWS_PAGESIZE⁠、SITECORE_GETFILESLIST_PARENTID/NODEID/YEAR⁠。


七、接手建议(按优先级)

  1. 立刻给两颗地雷加锁⁠:给 update_sitecore_channels_and_assets.py --update-assets--dry-run 和「无匹配则跳过而非置 NULL」;给 reset_sitecore_dam_assets.py 加引用检查。约 4 小时。
  2. 补齐缺失物⁠:数据库导出、backend/writable/(DAM 文件 + 5 份失败日志)。没有这两样,任何数据核对都做不了。
  3. 把硬编码的 category_id 挪进 config 表⁠:60 和 2–8 都应该从 config 读,且启动时校验分类是否存在,不存在就报错而不是静默错配。
  4. 清理文档⁠:删掉 docs/import_vgc_news.md⁠,重写 docs/import_sitecore_news.mdscripts/README.md 的新闻章节,删掉 4 个不存在脚本的说明。这一步不做,下一个接手的人还会踩同样的坑。
  5. 写一份 scripts/PIPELINE.md⁠:把第三节那张执行顺序图固化下来。现在这个顺序只能靠交叉阅读 8 个脚本的 docstring 反推。
  6. security-scan.sh 的路径并重跑一次⁠,让合规材料回到当前时点。
缺陷穷举与技术债(横向,覆盖 w2r.site + demo.w2r.site 全部首方代码)
55,543
行 / 373 文件
15
上手人天
4
月维护人天
35
缺陷
理解难度
接手风险

主要风险

  • 鉴权是逐方法手写的,不在路由层声明——Routes.php 里 100 多条路由清一色只挂 auth 过滤器,具体权限码散落在每个控制器方法的前几行。看路由文件完全无法判断哪个端点受保护,已经漏了栏目管理全套写接口(ChannelController.php:111/164/237/43)和内容 CRUD(ContentController.php:304/474/705)。新增端点时忘写权限校验不会有任何提示,也没有测试覆盖。这是本项目最容易反复出事的结构性问题。
  • JWT 是「签发即终身有效到 exp」——撤销表 user_sessions 只写不读(AuthFilter.php:20 从不查 revoked_at,UserSessionService 里根本没有 isRevoked 方法),登出、踢并发会话、禁用账号三条控制对已签发 token 全部无效。加上 token 可从 URL 查询参数传入(JwtService.php:196),一旦泄露无法回收,只能改 JWT_SECRET 让全员掉线。
  • CI4 4.7 DataCaster 的类型契约是隐式的:EntryModel.php:50-57 声明了 ?datetime cast,UserModel.php:29 和 OperationLogModel.php:36 却用注释明说「不设 cast,避免 DataCaster 报错」,DamUploadService 里五处是 // 使用 CodeIgniter 的 Time 类 的补丁注释。同一仓库三种应对策略并存,WorkflowService.php:280 是漏网的那个。改任何写库代码前必须先查目标 model 的 $casts。
  • 两棵树共用同一个 MySQL 库(demo 的 News.php:9 与 ChannelModel.php:8 注释都写明「与 w2r.site 共用 entries/channels 表」),但 demo 侧完全绕过 CMS 的模型层用裸 mysqli 直查(public/api.php 里 11 处 SQL)。CMS 侧改表结构会静默打断官网前台,两边没有任何契约或集成测试。
  • demo 有两套并行接口实现,究竟哪套在线上生效由仓库外的 nginx 配置决定——.htaccess:29 的 rewrite 只把 /api/ 和 /api/hello 交给 api.php,与 api.php 自己的路由分发(第 12/24/31/37 行)互相矛盾,且两个文件 mtime 差 22 天。生产 web 服务器配置没有随代码交付,是交接的头号未知量:必须先从线上机器把 nginx 配置捞回来才能动 demo 的任何接口。
  • 迁移可以「51 个全过」但数据是错的:2026-03-12-100042 把 asset_categories 的 id 2..8 写死进 config 表,新库上这些 id 必然对不上,Python 素材归类脚本会静默把资产挂错分类;2026-03-04-100031 的 created_by=1 + RESTRICT 外键则让干净库根本跑不完,且前半段 6 条裸 ALTER 无存在性判断、失败后不能重跑。灾备重建必须按「先建 id=1 管理员 → 跑迁移 → 手工修正 sitecore.channel_to_category」的顺序,这个顺序没有任何文档。
  • 内容保存路径(entries + 两条 entry_i18n + asset_entry_i18n + asset_refs)全程无事务,而 asset_refs.asset_id 带外键(迁移 100018:51)。编辑在正文里粘一个失效的 /asset/{id},AssetRefService.php:135 的正则会抓到它,插入违反外键抛异常 → 保存 500,同时第 34 行已把该内容的全部资产引用删光,使被引用的素材可被误删。这是日常使用中最容易被触发的一条。
  • DAM 上传链路是当前唯一可达 RCE 的路径:upload_id 未校验直接拼路径(DamUploadService.php:106/202)+ 类型判定用 OR(:469)+ mime 完全取自请求(DamController.php:55)。三者独立看都算中等,串起来是「拿到 dam:upload 权限即可写入并执行任意 PHP」。修复前不建议给任何外部或临时账号 DAM 权限。

VWCORP 缺陷穷举与技术债 —— 交接文档

0. 范围与口径

本报告覆盖 /Volumes/ProjectsAPFS/VWCORP 下两棵树的全部首方代码(不含 vendor/⁠、node_modules/⁠、system/⁠、dist/⁠):

子系统路径行数文件数
CMS 后端(CI4 4.7.2,PHP 8.2)w2r.site/backend/app27,252213
CMS 管理后台(Vue3 + ElementPlus)w2r.site/admin-frontend/js12,84051
Sitecore 迁移脚本(Python)w2r.site/scripts5,32511
官网后端(CI4 薄壳 + 独立 api.php)demo.w2r.site/backend6,99764
官网前台(Vue3 + Vite)demo.w2r.site/frontend/src3,12934

框架版本以 w2r.site/backend/system/CodeIgniter.php:58CI_VERSION = '4.7.2' 为准。

共列出 35 条有代码依据的缺陷⁠,其中 blocker 6 条、high 13 条、medium 12 条、low 4 条。下文按「必须先修 → 结构性问题 → 逐类明细 → 上手路径」组织。


1. 必须在接手第一周内处理的 6 条

1.1 DAM 上传链路可达远程代码执行(blocker)

三处独立缺陷串成一条完整利用链:

  1. w2r.site/backend/app/Controllers/Api/DamController.php:50 把请求里的 upload_id 原样取出,只在第 58 行判断非空。
  2. w2r.site/backend/app/Services/DamUploadService.php:106 用它拼目录:$chunkDir = rtrim(chunksDir) . DIRECTORY_SEPARATOR . $uploadId⁠,第 107 行 mkdir($chunkDir, 0775, true) 递归创建,第 111 行写入 {$chunkIndex}.part⁠。合并时 DamUploadService.php:202 再拼一次 $targetDir . '/' . $uploadId . '_' . $safeName⁠。全程无 basename()⁠、无字符白名单。传 upload_id=../../../public/x 即可写到 web 根。
  3. w2r.site/backend/app/Services/DamUploadService.php:469 的类型判定是 if ($extOk || $mimeOk) —— ⁠,不是且。mime 完全来自请求参数(DamController.php:55⁠),从不做服务端嗅探。所以上传 shell.php 但把 mime_type 报成 image/png 就能通过;sanitizeFilename(第 523-529 行)保留 .⁠,扩展名原样保留。

结论⁠:任何拥有 dam:upload 权限的账号(editor 及以上全都有)可写入并执行任意 PHP。修复前不要给任何外部/临时账号 DAM 权限。

修法:upload_id 强制 preg_match('/^[A-Za-z0-9_-]{8,64}$/')⁠;类型判定改成 $extOk && $mimeOk⁠,并用 finfo 复核真实 MIME。

1.2 JWT 撤销完全无效(blocker)

w2r.site/backend/app/Filters/AuthFilter.php:20-28 只做 JwtService::verify()(验签 + exp),从不查 user_sessions.revoked_at⁠。而 UserSessionService 整个类里没有 isRevoked() 方法。

写入侧却是齐全的:AuthController.php:298 登出时调 revokeSession()⁠;UserSessionService.php:76-82 在「不允许并发会话」时批量 revoke。两者写进库的 revoked_at 对后续请求毫无作用——旧 token 在 exp 之前照常通行。

同时 UserController::update/updateStatus/delete 都没有调用 revokeAllForUser()(全仓库仅 AuthController.php:298⁠、:537 和一个验收命令引用了 UserSessionService⁠)。禁用或删除一个用户后,他的 token 仍能正常调所有接口。

这条对合规审计是致命的:「登出」「强制下线」「禁用账号」三个控制项在功能上都不成立。

1.3 MFA 可被无条件重置绕过(blocker)

w2r.site/backend/app/Config/Routes.php:32auth/mfa/enroll 挂的是 auth_no_mfa 过滤器(只验登录,不验 MFA)。

w2r.site/backend/app/Controllers/Api/AuthController.php:422mfaEnroll() 不检查用户是否已 enrolled,直接调 AuthService::generateMfaSecret()⁠;后者在 w2r.site/backend/app/Services/AuthService.php:116-121 对已有记录执行 update⁠,覆盖 secret 并把 is_enrolled 置 0⁠。

攻击者拿到用户名+口令后:登录 → 拿到 mfa_verified=false 的 token → 调 enroll 写入自己的 TOTP 种子 → verify 通过 → 完整会话。第二因子形同虚设。

修法:enroll 时若 is_enrolled=1⁠,必须要求先通过旧 TOTP 或走 mfaReset(后者在 AuthController.php:568 有口令校验)。

1.4 CSRF 保护被注释掉,且 Session 兜底认证还在(blocker)

w2r.site/backend/app/Config/Filters.php:80-89⁠:

  • 第 82 行 // 'csrf' —— 注释掉
  • 第 83 行 // 'invalidchars' —— 注释掉
  • 第 87 行 // 'secureheaders' —— 注释掉

如果系统纯用 Bearer token,CSRF 风险有限。但 AuthFilter.php:31-35ApiController.php:92⁠、:115 都保留了 session()->get('user_id') 兜底路径,Session Cookie 会被浏览器自动携带。配合 Config/Cookie.php:57 secure = false⁠、Config/App.php:160 forceGlobalSecureRequests = false⁠,明文 HTTP 下 Cookie 还可被嗅探。

决策点(必须二选一并落文档):彻底删除 Session 兜底只认 JWT,或开启 csrf 过滤器。当前是两头不着。

1.5 栏目管理写接口零权限校验(blocker)

w2r.site/backend/app/Controllers/Api/ChannelController.phpcreate(111)⁠、update(164)⁠、delete(237)⁠、reorder(43)⁠、index(31)⁠、show(97) 六个方法体内没有任何 requirePermission / requireRoles 调用⁠;只有 rolePermissions(278)updateRolePermissions(307) 校验了 rbac:manage⁠。路由(Routes.php:65-70⁠)只挂了 auth⁠。

结果:audit_readonly 这种设计上只读的角色能删除空栏目、能 PUT /api/channels/reorder 打乱整站导航结构。

1.6 工作流时间字段类型不匹配,提交/审核全部 500(blocker)

w2r.site/backend/app/Services/WorkflowService.php:280-288now() 返回 date('Y-m-d H:i:s') 字符串。它被送进 EntryModel?datetime cast 字段共 8 处:

行号字段
63submitted_at
99 / 112 / 128reviewed_l1_at
101 / 159published_at
157 / 174reviewed_l2_at
131 / 177last_rejected_at

w2r.site/backend/app/Models/EntryModel.php:50-57 把这些列声明为 '?datetime'⁠,CI4 4.7 的 DatetimeCast::set() 只接受 Time/DateTimeInterface⁠,传字符串抛异常。提交审核、初审、终审三条主流程全挂。

正确写法在同仓库里就有:w2r.site/backend/app/Services/PublishService.php:138-141nowTime() 返回 Time 对象。把 WorkflowService::now() 改成同样实现,8 处调用点即可全部修复。


2. 结构性问题(决定长期维护成本)

2.1 鉴权是逐方法手写,不在路由层声明

Routes.php 里 100 多条 API 路由清一色只挂 ['filter' => 'auth']⁠,真正的权限码散落在每个控制器方法开头。这带来两个后果:

  • 读路由文件无法判断哪个端点受保护。 已经漏掉了栏目管理全套(1.5)和内容 CRUD(见 3.1)。
  • 新增端点忘写校验不会有任何提示⁠,没有测试覆盖,没有 lint 规则。

已核对的完整状态(✓ 有校验 / ✗ 无校验):

控制器状态
UserController / RoleController / AuditController / SearchHotwordController / SearchPinController / ConfigPromotionController / FigmaController✓ 全部方法有
DamController✓ 写接口有;✗ assets(219)/assetShow(292)/assetRefs(319)
FaqQuestionController / FaqTopicController✓ 写接口有;✗ index/show
ChannelController✗ 除两个 role-permission 方法外全无
ContentController✗ 全无权限码校验(仅归属判断)
DashboardController / SeoConfigController::show / SystemConfigController 的两个 GET / EntryRelationController::list / FaqPageController::questions✗ 无

建议的中期改造:把权限码作为路由参数声明(['filter' => 'perm:content:create']⁠),一次性把校验收拢到过滤器。

2.2 CI4 DataCaster 的隐式类型契约

同一个仓库里存在三种应对策略,接手者极易踩错:

  1. 声明 cast 并传 Time 对象 —— EntryModel⁠、PreviewTokenModel⁠、AssetModel 等 24 个 model。
  2. 故意不声明 cast —— UserModel.php:29-31 注释「不设置 last_login_at 的 cast,避免 DataCaster 在更新时的类型转换错误」;OperationLogModel.php:36-38 注释「不设置 timestamp 和 created_at 的 cast」。
  3. 在调用点打补丁 —— DamUploadService.php:58⁠、:263⁠、:305⁠、:482⁠、:498 五处都有 // 使用 CodeIgniter 的 Time 类,而不是 PHP 的 DateTime 的注释和 Time::createFromFormat 转换。

规则:改任何写库代码前,先打开目标 model 看 $casts⁠。 WorkflowService 就是漏网的那个(1.6)。

顺带一提,DamUploadService 里的 Time::createFromFormat('Y-m-d H:i:s', $now) 没有传时区参数,而 $now 是按 appTimezone 生成的,两者不一致时会有偏移。

2.3 两棵树共库、且 demo 有两套并行实现

demo.w2r.site/backend/app/Controllers/News.php:9app/Models/ChannelModel.php:8 的注释都明说「与 w2r.site CMS 共用 entries / entry_i18n / channels 表」。但 demo 侧完全绕过模型层用裸 mysqli 直查public/api.php 683 行)。

更麻烦的是同一批端点有两套实现:

端点实现 A实现 B
/api/menupublic/api.php:24app/Controllers/Api.php
/api/newspublic/api.php:37app/Controllers/News.php::index
/api/news/{id}public/api.php:31app/Controllers/News.php::detail
/api/asset/{id}public/api.php:12app/Controllers/AssetDeliver.php

demo.w2r.site/backend/public/.htaccess:29 的规则是 RewriteRule ^(hello|)$ api.php [L,QSA] —— 只有 /api//api/hello 走 api.php⁠,其余按第 34-36 行落到 index.php 走 CI4。这与 api.php 自己写的路由分发直接矛盾。文件 mtime 显示 api.php 是 3-23 更新的(加了 menu/news/asset 分发),.htaccess 停在 3-01,说明 rewrite 规则从未同步。

生产实际跑的是 nginx,配置不在仓库里⁠。

交接第一件事:从线上机器把 nginx 配置捞回来,确认哪套实现生效,然后删掉另一套约 680 行死代码。 在此之前不要动 demo 的任何接口逻辑。


3. 按类别的缺陷明细

3.1 cast 与写入类型不匹配

  • WorkflowService.php:59 起 8 处 —— 见 1.6,blocker。
  • PublishService.php:46⁠、:68 —— 实测已经是正确的⁠:第 27 行 $now = $this->nowTime() 返回 Time 对象,两处写入传的是 $now 本身而非 $nowStr⁠。交接说明里标注的「未修」与当前代码不符,无需再改。
  • PreviewService.php:121-125parseDateTime 用用户可控字符串构造 Time⁠,Time::parse 对无法解析的输入抛异常,PreviewController::create 无 try/catch → 传 expires_at=abc 得到 500 而非 400。
  • SearchHotwordController.php:63-64 / SearchPinController.php:72-73getVar('start_at')⁠、getVar('end_at') 直接入库,对应 model 声明的是 'datetime'(非 nullable),传 null 或空串会出问题。

3.2 非事务性多表写入

位置涉及的表后果
ContentController.php:348-446entries → entry_i18n(zh) → entry_i18n(en) → asset_entry_i18n → asset_refsi18n 插入失败留下无正文的孤儿 entry,用户无法修复只能删
ContentController.php:527-681同上(update 路径)部分字段更新成功、部分失败
AssetRefService.php:34-89asset_refs 先删后插见下
DamUploadService.php:299-312assets insert → upload_sessions updatesession 更新失败则重复合并会产生重复 asset

对照 EntryDraftService::createDraftFromPublishedEntryDraftService.php:75-94⁠)和 publishDraftOverOriginal:128-159⁠)都正确用了 transStart/transComplete⁠,说明原作者知道怎么做,只是 ContentController 没做。

AssetRefService.php:34 值得单独说⁠:它先无条件删掉某 entry 的全部资产引用,再逐条插入,中间无事务;而迁移 2026-01-24-100018_CreateAssetRefsTable.php:51asset_id 建了指向 assets 的外键。scanHtmlForAssetIds:135⁠)用正则 #/asset/(\d+)# 从富文本抓 id —— 编辑只要在正文里粘一个已删除的 /asset/99999⁠,insert 就违反外键抛 DatabaseException⁠,保存 500,同时旧引用已被删光⁠,该资产的删除保护静默失效。这是日常使用中最容易被触发的一条。

另外 EntryDraftService.php:152 删除修订稿时没有清理 asset_refs⁠,而 deleteEditDraft:190⁠)和 ContentController::delete:740⁠)都清了——同一个概念三处实现、一处漏掉。

3.3 错误被静默吞掉

位置表现
demo/public/api.php:142/157/166/212数据库不可用、连接失败、SQL 失败一律 success:true, data:[]⁠。生产库挂掉时官网表现为「导航空白 + 新闻列表空 + HTTP 200」,任何基于状态码或 success 字段的告警都不会响,且无 error_log
AssetDeliveryController.php:61-62⁠、:145-147catch (\Exception $e) {} 空体,吞掉 JWT 验证异常
ApiController.php:38-41⁠、AuthController.php:42-45JwtService 构造失败吞成 $jwtService = null⁠,随后 AuthController.php:205⁠、:510 直接 $this->jwtService->generate() → 报出来的是「Call to a member function on null」而非真实的配置问题
ApiController.php:240-242JSON 解析失败静默返回 []⁠,请求体格式错表现为「所有字段都没传」
SearchService.php:205-207全文索引探测失败静默降级为 LIKE,性能塌方无告警

全部至少应加 log_message('error', ...)⁠。

3.4 迁移可重放性

51 个迁移文件,三类问题:

  1. 硬编码 id + RESTRICT 外键 —— 2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:35:59 写死 'created_by' => 1⁠,而 2026-01-27-100024_CreateAssetCategoriesTable.php:61 给该列建了 RESTRICT 外键。空库上跑不通。
  2. 裸 ALTER 无存在性判断 —— 同文件 :15-24 连续 6 条 db->query('ALTER TABLE ... ADD ...')⁠,没有 fieldExists 保护(对比 2026-02-09-100029_AddUuidToEntries.php:15 是有的)。上面的外键失败发生在第 28 行 insert,此时 6 条 DDL 已提交但迁移未记账,重跑必报 Duplicate column。
  3. 依赖已有数据的 id 映射 —— 2026-03-12-100042_SeedSitecoreChannelToCategoryConfig.php:19-27 把 7 个 Sitecore GUID 映射到写死的 category_id 2..8。迁移本身不报错(只往 config 表写 JSON),但 scripts/update_sitecore_channels_and_assets.pymigrate_sitecore_files_to_dam.py 读这个配置做素材归类,新库上 id 必然对不上 → 静默归错分类⁠。

另外 2026-02-10-100030_ChangeEntriesChannelIdToJson.php:18-28entries.channel_id 改成 JSON 时删掉了外键和索引,此后再没补回生成列+索引,导致 ContentController.php:70JSON_CONTAINS 筛选是全表扫描。

灾备重建的正确顺序(无文档,需补)⁠:建 id=1 管理员 → 跑迁移 → 手工修正 config 表里的 sitecore.channel_to_category⁠。

3.5 鉴权缺口

除 1.3、1.5 外:

  • 内容 CRUD 无权限码 —— ContentController::create(304) 完全无校验;update(474)delete(705) 只在 :493⁠、:719 做「created_by == 自己 或 sys_admin」的归属判断,既无权限码也没调 checkChannelPermission⁠。对比 WorkflowController::submit:46-49⁠)是调了栏目级授权的,FaqQuestionController.php:114/222/318 是校验了 content:create/edit/delete 的 —— 遗漏而非设计。
  • 栏目级授权 fail-open —— WorkflowService.php:214-216「无配置记录 → return true」,:228「有记录但既非 deny 也非 allow → return true」,:199sys_admin 直接短路。而 PermissionService.php:38 的注释明写「不做 sys_admin 特例」。两层授权模型策略相反⁠,接手者容易按其中一层的心智去改另一层。
  • JWT 可从 URL 查询参数传入 —— JwtService.php:196-199⁠。token 会落进 nginx access_log、Referer、浏览器历史;配合 1.2 泄露后无法回收。verify():86-89⁠)也没校验 iss/aud(config 里定义了但没用)。
  • 预览访问是免验证码的口令校验入口 —— Routes.php:40POST /api/preview/access/:token 是公开路由,PreviewController::access:167⁠)内部调 AuthService::login 做完整用户名+口令校验。它有 IP 节流但没有图形验证码/api/auth/login 有),是绕过验证码控制的口令猜测入口。而且 recordFailure 只在 code===3205 时调用(:205-207⁠),token 不存在/已撤销/已过期都不计次 → 预览 token 可无限枚举。
  • MFA 校验无次数限制 —— AuthController::mfaVerify(453) 全程没调 LoginThrottleService⁠;AuthService.php:240 允许 ±1 时间窗 = 每 90 秒 3 个有效码;备用码是 8 个 6 位纯数字(AuthService.php:107⁠)。
  • 登录节流只按 IP 不按账号 —— AuthController.php:58⁠、:147 全部以 IP 为键。NAT 后一人输错锁全办公室;换 IP 即绕过针对单账号的爆破。

3.6 输入校验缺口

  • 内容创建的必填校验与错误文案不符 —— ContentController.php:327-329 只判断栏目非空,错误信息却写「类型、栏目、中文标题和slug不能为空」。$type(312)、$titleZh(315)、$slugZh(317) 取到后从未校验。entries.typeVARCHAR(50) NOT NULL 无枚举约束(2025-01-19-100005_CreateEntriesTable.php:18-22⁠),传任意字符串都落库,产生前台路由识别不了的「幽灵类型」;slug 无格式与唯一性校验。
  • DAM 的 upload_id / mime —— 见 1.1。
  • 路径穿越 —— PageCssDeliveryController.php:19-23 做得对(basename + 严格正则);demo_asset_delivery.inc.php:68-73 有 realpath 校验(但 strpos($realFile,$realRoot) !== 0 缺尾分隔符,/data/assets-evil 能绕过前缀比对);AssetDeliveryController.php:94 没有任何越界校验⁠,WRITEPATH . ltrim($storageKey,'/') 直接拼,storage_key 被污染即可读任意文件。
  • 前台正文 XSS —— demo.w2r.site/frontend/src/lib/news/sanitizeArticleHtml.js:14-26 是 27 行自研消毒:只删 script/style/link[rel=stylesheet]⁠,只去 on* 属性,只查 hrefjavascript:⁠。未处理 <iframe srcdoc>⁠、<object>⁠、<embed>⁠、src="javascript:"⁠、formaction⁠、SVG xlink:href⁠。NewsArticleContent.vue:32v-html 渲染。写入侧(ContentController.php:379-382⁠、:544-546⁠)对 content_json 只做 json_encode⁠,零过滤。任何 editor 可注入持久化 XSS 打到官网所有访客。 第 11-12 行的 DOMParser 不可用兜底只有一个正则删 script,形同虚设。

3.7 死代码与并行实现

  • demo 的两套接口 —— 见 2.3,约 680 行。
  • 14 个 spark 命令中 9 个零引用 —— CleanAuditLogs⁠、RunPublishSchedule⁠、VerifyRolePermission⁠、CleanApplicationLogs⁠、DeleteVideoMp4Assets⁠、AuditAcceptanceExtended⁠、SetupSuperAdminMfa⁠、ResetUserPassword⁠、QueryUserActivityapp/docs/ 里搜不到任何引用。其中 RunPublishSchedule 是定时发布的唯一入口却没有任何 crontab 文档 —— 需要确认线上定时发布到底跑没跑。 PublishPagesAsUser.php:31 把用户名默认成 'test'⁠;RevertPagesToDraft.php:74-80 绕过工作流批量把已发布内容改回 draft 并清空 published_at⁠。一次性数据修复脚本混在正式代码里,接手者无法分辨哪些跑了会毁数据。
  • 密码哈希两套 —— UserController.php:206PASSWORD_DEFAULT⁠,AuthService.php:33PASSWORD_BCRYPT, cost 10⁠,Python 脚本用 bcrypt rounds=10⁠。PHP 8.2 下三者巧合一致,PHP 升级后 PASSWORD_DEFAULT 会变。
  • MFA 密钥「加密」是假的 —— AuthService.php:111 注释写「加密存储」,实际是 base64_encode⁠。库被拖走等于 MFA 种子明文泄露。

3.8 硬编码环境相关值

位置
ImportNews1!(自动创建 editor 账号的默认口令)scripts/migrate_sitecore_news_to_entries.py:99⁠、backfill_video_posters_from_sitecore.py:109⁠、migrate_sitecore_files_to_dam.py:123⁠、migrate_news_covers_to_assets.py:110
https://w2r.site/backend/app/Config/App.php:19⁠、Services/SeoSchemaService.php:20
w2r.site / http://127.0.0.1:8799Commands/AuditAcceptance.php:26⁠、:35⁠、AuditAcceptanceExtended.php:27
/www/wwwroot/w2r.site/backendCommands/CleanApplicationLogs.php:15(crontab 示例)
https://cm.volkswagengroupchina.com.cnscripts/backfill_video_posters_from_sitecore.py:44 等 3 处
asset_categories id 2..8Migrations/2026-03-12-100042_...php:19-27
用户名 'test'Commands/PublishPagesAsUser.php:31
https://demo.w2r.site/api/demo/backend/app/Config/App.php:19

ImportNews1! 是要立刻处理的⁠:只要有人不设 IMPORT_PASSWORD 跑过任一脚本,生产库里就有一个口令写在 Git 里的可登录 editor 账号。接手第一步应查 users 表里 email 形如 *@import.local 的记录并禁用。

3.9 性能与容量

  • AssetDeliveryController.php:103demo_asset_delivery.inc.php:75file_get_contents 把整个文件读进内存,而 Config/Dam.php:12 允许单文件 5GB 且 :34-37 明确支持 video/mp4。几个并发视频请求即耗尽 memory_limit;也不支持 Range,视频无法拖进度条。
  • demo/public/api.php:628-639api_news_resolve_neighbors 每次打开新闻详情都把同栏目全部已发布新闻 id 读进 PHP 数组再 array_search 找上下篇,配合 3.4 里 channel_id 无索引,是 O(n) 全表扫描。
  • ContentController::index 第 87-98 行对每条 entry 单独查一次 i18n(N+1)。
  • PermissionService::hasPermission 每次调用都 find($userId) + 一次三表 join,WorkflowController::actions:260-263⁠)单次请求最多调 3 次。

4. 上手路径建议

第 1 天:环境与真相核对

  1. 从线上机器取回 nginx 配置,确认 demo 到底跑哪套实现(2.3)。这是所有 demo 侧改动的前提。
  2. 确认 RunPublishSchedule 的 crontab 是否存在(3.7)。定时发布可能根本没在跑,也可能在跑但会绕过审核(1.6 之外还有 PublishService.php:33 的无状态过滤问题)。
  3. users 表里 *@import.local 账号(3.8)。

第 2-3 天:读代码的正确顺序 Config/Routes.phpConfig/Filters.php + Filters/AuthFilter.phpControllers/Api/ApiController.php(统一响应/鉴权基类)→ Services/PermissionService.php + WorkflowService::checkChannelPermission(两层授权)→ Models/EntryModel.php(cast 契约 + primaryChannelId 的 JSON 数组约定)→ Controllers/Api/ContentController.php(最长也最脏的一个)→ Services/EntryDraftService.php(修订稿状态机,是正确用事务的范本)。

第 4-10 天:先修 6 条 blocker 建议顺序:1.6(最快,改一个方法)→ 1.5 + 3.5 的内容 CRUD 权限(补 requirePermission 调用)→ 1.1(DAM 路径与类型)→ 1.3(MFA enroll 守卫)→ 1.2(AuthFilter 加撤销检查)→ 1.4(CSRF/Session 决策)。

注意事项

  • 改动写库逻辑前先看 model 的 $casts(2.2)。
  • 内容保存链路(ContentController::create/update⁠)没有事务,改这里必须同时补上,否则会放大 3.2 的孤儿数据问题。
  • 两棵树共库,改 CMS 的表结构必须同步检查 demo/public/api.php 里 6 处裸 SQL(:161⁠、:221⁠、:239⁠、:284⁠、:299⁠、:315⁠、:389⁠、:400⁠、:417⁠、:628⁠、:658⁠)。
  • 缺失的 /www/wwwroot/.cursor/ 下 15 条 rules + 10 个 skills 意味着原开发约定丢失了一半⁠——本报告里发现的「同一概念三处实现、一处漏掉」类问题(如 asset_refs 清理),很可能就是那些 rules 在约束的东西。补文档时优先补:权限码清单与每个端点的对应关系、model cast 约定、灾备重建顺序、nginx 路由事实。

5. 评级依据

理解难度 4/5⁠:抽象层数不深(Controller → Service → Model 三层,没有额外的仓储/DTO 层),但隐式约定密集:鉴权不在路由声明、cast 契约靠注释口口相传、channel_id 是 JSON 数组配一个静态 helper、修订稿状态机(draft_of_entry_id⁠)叠在 6 状态 workflow enum 之上、demo 有两套实现且哪套生效取决于仓库外的配置。31 份文档中有 23 份在 w2r.site/docs⁠,但 .cursor 下的 15 条 rules 和 10 个 skills 缺失,原始约定丢失了相当一部分。

接手风险 5/5⁠:6 条 blocker 中有 3 条是安全性质的(RCE 链、撤销失效、MFA 绕过),1 条是主流程直接 500。更关键的是结构性的——鉴权逐方法手写意味着新增一个路由而忘记写校验不会有任何提示⁠,内容保存链路无事务意味着任何改动都可能放大孤儿数据⁠,两棵树共库意味着改 CMS 的表会静默打断官网⁠。任一改动引发线上问题的概率都很高。

上手 15 人天⁠:backend/app 27k 行 / 213 文件(26 个 model 的 cast 约定各不相同、51 个迁移、workflow + 修订稿双状态机、DAM 分片上传、Figma 导入子系统 2000+ 行)+ admin-frontend 12.8k 行 + 两个部署目标 + 5.3k 行 Python 迁移脚本。按中级工程师读透主干 + 跑通一次完整内容生命周期 + 独立修完一个 blocker 计,约 3 周。

稳态维护 4 人天/月⁠:日常内容类型/栏目调整、DAM 问题排查、schema 变更时的迁移编写与两棵树同步验证,加上当前已知 500(工作流、资产引用外键)在修复前的持续救火。修完 blocker 后可降到 2.5 人天/月。

09 · 总评与建议

接手工作量与难度总评 · VW 企业官网 CMS(w2r.site + demo.w2r.site)

本评估在两棵交付树上做只读复核,抽样验证了八个模块报告中 30 余条结论,并补充了交接物层面的四项新证据(git 基线、web server 类型、两站共库、生产域名归属)。所有论断附 路径:行号⁠。

1. 总体评级

综合难度:A 级(高,但不是 S 级) — 换算 4.0 / 5

关键判断:这套系统的难,八成不在代码里,在交接物里。代码本身是一套写法朴素、可读的 CodeIgniter 4 + Vue 3 应用;真正卡住接手的是数据、部署配置、代码基线、域名控制权这四类非代码资产的缺失。

分项评级(有区分度,不是全高分)
维度评级依据
技术栈复杂度C(低)CI4 4.7 + Vue3/Vite + MySQL + Python,无微服务、无消息队列、无自研框架、无 DSL。w2r.site/backend/app 共 213 个 PHP 文件 / 27,252 行,规模小
代码可读性B(中)过程式写法,中文注释密度高;w2r.site/docs/cms/ 26 份设计文档可与 51 个迁移逐条对上
架构耦合度A(高)两站共用同一 MySQL 实例与同一库(w2r.site/backend/.env:25-26demo.w2r.site/backend/.env:11-12 的 hostname、database 值完全一致,仅账号口令不同);demo 侧绕过模型层裸 mysqli 直查
隐式约定密度A(高)entries.channel_id 是 JSON 数组不是外键(2026-02-10-100030_ChangeEntriesChannelIdToJson.php:19⁠);EntryModel.php:46-60?datetime cast 要求传 Time 对象而非字符串;封面存在三套并行机制
安全基线S(最差项)CSRF 全局注释掉(w2r.site/backend/app/Config/Filters.php:80-84 三行全被注释);ContentController.php 全文 grep requirePermission 零命中;DAM 上传可写任意路径
可测试性S(最差项)w2r.site/backend/tests/ 下仅 5 个 Figma 单测 + 3 个 CI4 官方样例(tests/unit/HealthTest.php⁠、tests/database/ExampleDatabaseTest.php⁠、tests/session/ExampleSessionTest.php⁠),auth / RBAC / workflow / publish / DAM 零覆盖
交接物完整性S(最差项)见下
前端复杂度B(中)admin-frontend 51 个 vue/js 文件,常规 CRUD;demo frontend 只有两个页面(demo.w2r.site/frontend/src/router/index.js:10-26 全部路由 5 条)
它难在哪(四条,按严重度)

① 生产 web server 配置不在交接物里,而"本机跑通"用的是另一种 web server。 w2r.site/backend/public/404.html:6 是 nginx 的默认错误页,w2r.site/backend/public/index.html:4 是宝塔面板的"站点创建成功"页 —— 说明生产跑的是 nginx。但改写规则全写在 Apache 语法的 w2r.site/.htaccess:2<IfModule mod_rewrite.c>⁠)与 demo.w2r.site/backend/public/.htaccess:10⁠,在 nginx 下整体是死文件⁠。全仓库唯一存在的 server 配置是本次为复现自建的 localdev/vhosts.conf:6,25⁠,而它是 Apache⁠。结论:已知事实里的"系统已在本机 Docker 跑通"证明的是代码能跑,不证明生产路由行为已被复现⁠。demo 到底是 api.php 还是 app/Controllers 在服务 /api/news⁠,仍是未知量。

② 干净库不可重建。 w2r.site/backend/app/Database/Migrations/2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:35 硬编码 'created_by' => 1⁠,而该列带 RESTRICT 外键;2026-03-12-100042_SeedSitecoreChannelToCategoryConfig.php:17asset_categories 的自增 id 2–8 写死进配置表。数据库导出缺失 + 迁移不可重放 = 灾难恢复目前没有可执行路径⁠。

③ 鉴权是逐方法手写的,路由表不是事实来源。 w2r.site/backend/app/Controllers/Api/ContentController.php 全文对 requirePermission / requireRoles 的 grep 零命中⁠,而该文件有 create():304⁠、update():474⁠、delete():705⁠、createDraft():163⁠、deleteDraft():203 五个写接口。ChannelController.php 同样:只有 rolePermissions():278updateRolePermissions():307 两处有 requirePermission('rbac:manage')⁠,而 reorder():43⁠、create():111⁠、update():164⁠、delete():237 四个写接口一处都没有。任意已登录账号可改站点导航与全部内容。

④ 代码基线不明,且交接的这份比远端新两个月。

它不难在哪(不要虚高定价)

2. 工作量测算

总计:阶段一 28 人天 + 阶段二 72 人天 = 100 人天进入可安全维护状态;此后 8–10 人天/月。 (不含两项产品决策项:demo 响应式改造 40 人天、demo 补齐栏目页;见 §6)

阶段一「恢复可运行」— 28 人天 / 日历 3 周(2.5 FTE)

验收标准(缺一不可)⁠:

  1. 空 MySQL 实例执行一条文档化的命令序列,能得到与生产结构一致的库(SHOW CREATE TABLE 对齐),全程无人工 SQL;
  2. 与生产同型的 nginx 配置起站,管理后台可登录 + MFA + 建内容 + 发布 + DAM 上传,官网前台可渲染新闻详情并显示封面图;
  3. 工作流四条路径(提交/初审/终审/驳回)不再 500;
  4. 三份工作区(backend / admin-frontend / demo)各自打上 handover-baseline tag 并推入受控仓库。
#工作项模块人天前置依赖
1.1交接物清点与差额书面索取(DB dump、backend/writable 归档、nginx server 段、/www/wwwroot/.cursor 的 15 rules + 10 skills)全局2
1.2代码基线固化⁠:三棵树建仓打 tag,把 22 个已改文件 + 12 个未提交文件的意图逐个确认后成为正式提交全局4无(可立即做,应是第一天第一件事⁠)
1.3生产环境测绘:从生产机抄回 nginx server 段、PHP-FPM 池、cron、目录权限、真实 .env运维3等生产机 SSH 权限
1.4干净库可重建:修 2026-03-04-100031:35⁠、2026-03-12-100042:17⁠,RolePermissionSeeder.php:14 改幂等,写 bootstrap 顺序文档持久层4
1.5工作流 500 修复:WorkflowService.php:280-288now(): string 改返回 Time⁠,同步核 11 个调用点(:63,:99,:101,:112,:128,:131,:157,:159,:174,:177,:276⁠),并顺带修 SearchHotwordController.php:63-68⁠、SearchPinController.php:72-76服务层2
1.6demo 双实现定性:curl /api/data.framework⁠,确认 backend/public/api.phpapp/Controllers/News.php 谁在生效,另一套下线demo2依赖 1.3
1.7媒体恢复:w2r.site/backend/writable 目录当前完全不存在(仅根级 w2r.site/writable/ 是抓取脚本的临时目录),需从生产拷回并修复 demo.w2r.site/shared/dam-writable 悬空软链DAM2等对方给媒体归档
1.8前端可构建性验证:两个前端在受控 Node 版本下构建,产物与仓库内 backend/public/AdM⁠、frontend/dist 比对差异(注意 admin-frontend/vite.config.js:11-12outDir 直写后端 public 且 emptyOutDir: true⁠)前端3
1.9密钥轮换:w2r.site/backend/.env:37 的 JWT_SECRET、:61 的 figma.token、:27-28demo.w2r.site/backend/.env:13-14 两套 DB 口令、:17-18 的 sitecore 凭据 —— 全部随交接件外泄,必须换安全2
1.10冒烟脚本:登录 → MFA → RBAC → 建内容 → 提交审核 → 发布 → DAM 上传 → 前台渲染,可重复执行全局4依赖 1.4 / 1.7

硬阻塞(等对方)⁠:1.3 生产 SSH、1.7 媒体归档、DB dump。在这三项到位前,1.1/1.2/1.4/1.5/1.8/1.9 共 17 人天可并行推进⁠,不必空等。

阶段二「达到可安全维护」— 72 人天 / 日历 6 周(2.5 FTE)

验收标准⁠:新人独立改一个接口并上线,全程只看文档不问人;php spark test 覆盖 auth / RBAC / workflow / publish / DAM 五域主干;P0/P1 缺陷全清;外部安全扫描无高危。

#工作包模块人天产出
2.1鉴权集中化⁠:把散在 24 个控制器方法里的权限判定收进路由声明或统一前置;补齐 ContentController 5 个 + ChannelController 4 个写接口的权限码;AuthFilter.php:12-62jti 撤销校验与 is_active 校验;关掉 AuthFilter.php:31-35 的 Session 兜底并启用 CSRF(Config/Filters.php:82⁠)HTTP 层 + 服务层20权限矩阵文档 + 新增接口漏写权限会被测试拦住
2.2数据层收口⁠:内容保存全链路上事务(ContentController.php:348⁠);AssetRefService.php:34 的 delete+insert 事务化;25 个 Model 补 $validationRules⁠;entry_i18n(lang, slug) 唯一约束;entries.channel_id 补函数索引或回退关系表持久层12干净库可重建 + 无孤儿数据
2.3测试网建立⁠:五个核心域主干用例 + CI 跑测试10回归网存在,后续改动有底
2.4admin 前端补齐⁠:工作流审批 UI(js/api/workflow.js:3 四个路由零调用方,reviewer_l1/reviewer_l2 两个角色目前登录后无事可做)、按钮级权限(174 个 @click 仅 2 处判权)、审计查询参数双层包裹修复(js/api/audit.js:6{ params } 给签名为 get(url, params = {})js/api/index.js:123⁠,实际请求成 ?params=[object Object]⁠)、富文本净化(js/cms/components/RichTextEditor.vue:240-242 只用正则删 <script>⁠)admin 前端14两个审核角色可用 + 存储型 XSS 通道关闭
2.5demo 收口⁠:删掉落选的那套接口实现、错误路径改真实 5xx(backend/public/api.php:142,157,166⁠)、补 catch-all 路由、DAM 资产接口补归属校验与流式输出、去掉 Figma MCP 外链(frontend/src/assets/figma/mcp-assets.js:59-62⁠)demo12故障可见 + 无外链裂图
2.6破坏性命令加护栏⁠:AuditAcceptanceExtended.php:151truncate()⁠、AuditAcceptance.php:50 的伪造 sys_admin token、RevertPagesToDraft.php:29 默认操作人 'test'⁠、DeleteVideoMp4Assets.php —— 全部加环境白名单 + 强制二次确认,或直接从 app/Commands/ 移出CLI4php spark list 里不再有能一键毁生产的命令
2.7文档校正⁠:docs/import_sitecore_news.md⁠、scripts/README.md 描述的是已删除的脚本版本,必须逐条对源码重写;补部署运行手册、权限矩阵、schema 事实来源说明文档8六份失效脚本文档归位
2.8死代码清理:admin-frontend/js/cms/components/VisualEditor.vue 及其配套约 1,890 行(占前端 14%,零引用)、demo 的空占位组件全局2代码量降约 12%

阶段三「稳态运维」— 8–10 人天/月

模块人天/月内容
CMS 后端(含持久层、服务层)2.5需求改动、数据修正、迁移评审
admin 前端1.5界面迭代、构建发布
demo 前台1.0内容配合、样式修正
Python 迁移脚本0.5增量补抓(前提是 Sitecore 凭据还有效⁠)
运维与安全2.0备份验证、证书、扫描、补丁、等保材料维护
内容侧支持1.0编辑答疑、权限调整
合计8.5八个模块报告的加总是 14.8 人天/月,去掉横向模块与各模块间的重复计入后为此数

3. 人员配置建议

结论:一个人扛不了。 最低 2.5 FTE,稳态可降到 1.5 FTE。

不能单人的四个理由(不是"工作量大"这么泛)⁠:

  1. 技能跨度真实存在⁠:CI4 4.7 的 DataCaster 语义、Vue3 组合式 API、MySQL DDL/索引、Playwright 抓取、nginx + 宝塔 + PHP-FPM。前三项之外还要能读 Python。单人具备全部且都到"能改不出事"的水平,市场上是资深架构师价位,比配两个人贵。
  2. 零测试环境下必须交叉复核⁠:tests/ 只有 5 个 Figma 单测。任何对鉴权、事务、时间字段的改动,单人自审等于没审 —— 而这三类恰恰是本项目的高发区。
  3. 阶段一有强并行需求⁠:1.3 生产测绘(等 SSH)、1.4 迁移修复(不等)、1.8 前端构建验证(不等)是三条独立轨。单人做只能串行,日历时间从 3 周拉到 7 周以上,而域名到期倒计时不会等。
  4. 单人接手=复制原开发者的单点风险⁠。整个 git 历史的提交者只有一个人(Arwen Fu <arwenfu@gmail.com>⁠,且是私人邮箱),项目今天的困境正是这么来的。再来一次是管理失误。

建议配置

角色人数负责模块技能要求(硬)
后端主力 / 技术负责人1.0backend/app/Services⁠、Models⁠、Database/Migrations⁠、Commands⁠、鉴权改造PHP 8.2+、CodeIgniter 4.7(必须懂 Model 生命周期与 $casts 的 DataCaster 契约)、MySQL 8 DDL 与索引、事务边界设计
全栈前端1.0admin-frontend(59 文件 / 13,656 行)+ demo.w2r.site/frontendVue 3 组合式 API、Vite 构建链、Element Plus;能读 PHP 接口定位问题
运维 / 安全0.5(阶段一按 1.0 投入 2 周)nginx / 宝塔 / PHP-FPM / cron / 备份 / 证书 / 等保材料Linux 运维、nginx rewrite(要把 .htaccess 逻辑翻成 nginx)、MySQL 备份恢复演练
数据 / Python0.3(点状)scripts/ 11 个脚本、Sitecore 抓取链Python 3.12、Playwright、SQL
项目负责人0.2对外索取交接物、demo 去留决策、域名与账号归属推进

稳态(阶段三)⁠:后端 0.7 + 前端 0.5 + 运维 0.3 = 1.5 FTE⁠,可与其他项目共享。

关键要求:后端主力必须在阶段一全程在岗。EntryModel.php:46-60?datetime cast 与 UserModel 通过重写三个框架方法来规避同一问题,两种应对策略在同一仓库并存 —— 这类隐式契约无法靠文档传递,只能靠一个人吃透后写成测试。


4. 缺陷总账(八模块合并去重)

八份报告原始条目 154 项、原始工时合计 518 小时。去重(横向模块与七个纵向模块高度重叠)后 92 项 / 净修复 318 小时⁠。因零回归测试⁠,实际应按 ×1.8 计入验证成本 → 约 572 小时 ≈ 72 人天⁠,与阶段二估算吻合。

4.1 必须先修清单(P0,15 项 / 净 62h / 含验证 112h ≈ 14 人天)

排序规则:可被利用或已在损坏 > 阻塞恢复 > 工时短。

#缺陷位置净工时为什么排在这
1DAM 上传 upload_id 未校验直接拼路径 → 任意路径写文件(可 getshell)w2r.site/backend/app/Services/DamUploadService.php:106,1113h与 #2 串联即为 RCE。修复前不得给任何临时账号 DAM 权限
2资产类型判定用 OR 而非 AND(`if ($extOk \\$mimeOk)`)w2r.site/backend/app/Services/DamUploadService.php:4692h扩展名与 MIME 中一个即放行
3CSRF 全局关闭 + Session 兜底认证w2r.site/backend/app/Config/Filters.php:80-84'csrf' 被注释)配合 app/Filters/AuthFilter.php:31-356h全部写接口可被跨站伪造
4ContentController 五个写接口零权限码w2r.site/backend/app/Controllers/Api/ContentController.php:163,203,304,474,705(全文 requirePermission grep 零命中)4h任意登录账号可改删全站内容
5ChannelController 四个写接口零权限码w2r.site/backend/app/Controllers/Api/ChannelController.php:43,111,164,2373h任意登录账号可改站点导航
6JWT 撤销表只写不读w2r.site/backend/app/Filters/AuthFilter.php:12-62(全文 68 行,无 revoked_at 读取)6h登出、踢并发、禁用账号三条控制对已签发 token 全部无效
7MFA 可被无条件重置绕过w2r.site/backend/app/Controllers/Api/AuthController.php:4224h第二因子形同虚设
8工作流四条路径全部 500w2r.site/backend/app/Services/WorkflowService.php:280-288now(): string⁠,11 个调用点2h核心功能当前不可用
9迁移 100031 硬编码 created_by = 1 + RESTRICT 外键w2r.site/backend/app/Database/Migrations/2026-03-04-100031_...php:353h干净库跑不完,灾备无路径
10audit:accept-ext TRUNCATE 生产表 / audit:accept 伪造 sys_admin tokenw2r.site/backend/app/Commands/AuditAcceptanceExtended.php:151⁠、AuditAcceptance.php:504h名字像测试、实为生产写操作,出现在 php spark list
11publish:revert-pages-to-draft 无鉴权无确认、默认操作人 'test'w2r.site/backend/app/Commands/RevertPagesToDraft.php:293h一条命令全站下线,审计责任落到测试账号
12生产部署产物完全缺失(无 nginx 配置,.htaccess 在 nginx 下不生效)w2r.site/.htaccess:2⁠、w2r.site/backend/public/404.html:6(nginx 默认页)8h换机、扩容、灾备三件事目前都无路径
13运行期目录 backend/writable/ 整体缺失w2r.site/backend/writable(不存在)3h全站媒体 404
14demo api.php 所有失败路径返回 success: true, data: []demo.w2r.site/backend/public/api.php:142,157,1663h故障静默降级为"没有数据",无任何 5xx 触发告警
15代码基线未固化(12 个源文件从未提交、demo 无 git、远端比本地旧两个月)见 §1-④8h不先做这条,后面所有修复都可能被覆盖或丢失

4.2 P1 高危(22 项 / 净 96h)— 阶段二前半必修

存储型 XSS(admin-frontend/js/cms/components/RichTextEditor.vue:240-242⁠)、定时发布绕过审核状态机(PublishService.php:33-52⁠,runSchedule() 查询条件仅 publish_at/published_at⁠,不含 status⁠,已复核)、MFA 无节流可暴破、MFA 密钥仅 base64 却注释自称"加密存储"、JWT 可从 URL 查询串取值(JwtService.php:196⁠)、内容创建四表非事务(ContentController.php:348⁠)、asset_refs 非事务 delete+insert、迁移 100042 硬编码 id 2–8、DAM 资产接口无归属校验(demo.w2r.site/backend/public/demo_asset_delivery.inc.php:166-219⁠)、工作流审批 UI 整体缺失、按钮级权限缺失、审计查询筛选失效、Python 脚本内置默认口令 ImportNews1!⁠、update_sitecore_channels_and_assets.py:273-289 无 dry-run 即置空 category_id、两个 .env 均为 CI_ENVIRONMENT = developmentw2r.site/backend/.env:5⁠、demo.w2r.site/backend/.env:5⁠)、secureheaders 未启用且无 frame-ancestors⁠、demo 前端无兜底路由(router/index.js:10-26 仅 5 条)、MFA 密钥经 URL 发往 api.qrserver.com⁠、public/test-putenv.php 公网可达。

4.3 P2 中(35 项 / 净 120h)、P3 低(20 项 / 净 40h)

按模块分批处理,不单独排期。单列不计入 318h 的两项⁠:demo 全站响应式改造 40 人天、admin-frontend 工作流 UI 24 小时(已计入 2.4)。


5. 风险登记册

风险概率影响触发条件 / 现有证据应对措施责任方
生产数据库导出缺失中(已知缺失,能否补给未知)致命交接物中无 dump;sitecore_files_import 等临时表每次导入前被 TRUNCATE,原始数据不可再生①书面限期索取 mysqldump 全量;②同步按迁移 + Seeder 重建空库作为 Plan B 并明确"历史内容丢失"的业务后果;③到位后立即做一次异地备份与恢复演练甲方 IT / 交接负责人
backend/writable/ 媒体缺失已发生w2r.site/backend/writable 不存在;demo.w2r.site/shared/dam-writable -> /www/wwwroot/w2r.site/backend/writable 本机悬空从生产机 tar 回全量 DAM;在此之前所有"图不显示"一律按环境问题排查,不得改代码运维
代码版本不明 / 唯一副本在本地已发生backend 92 项工作区变更、app/ 下 +990/-148 行未提交、6 个源文件未追踪;admin-frontend 6 个功能文件未提交;demo.w2r.site.git⁠;远端提交全部集中在 2026-05-21~26 且信息为 "Update files"第一天即固化基线⁠:三棵树建仓、打 tag、离线备份两份;逐个确认未提交文件的意图后转正式提交后端主力
生产 web server 配置不在交接物已发生仓库无任何 nginx 配置;public/404.html:6 是 nginx 默认页而 rewrite 全写在 Apache 语法的 .htaccess⁠;本机复现用的是 Apache(localdev/vhosts.conf:6,25⁠)从生产机抄回 server 段并翻成受版本管理的配置;在此之前不承诺任何"本机验证通过 = 生产可用"运维
域名 w2r.site 2027-02-08 到期,控制权不明致命w2r.site/backend/app/Config/App.php:19 硬编码 https://w2r.site/⁠;demo.w2r.site/backend/app/Config/App.php:19 硬编码 https://demo.w2r.site/api/ —— 到期即前台与管理后台同时不可达本周内查明注册商、注册人、续费账号归属并落到甲方名下;②设置到期前 90/30/7 天三级提醒;③把 baseURL 改为环境变量以降低换域成本项目负责人 + 甲方法务/IT
生产域名 vgc.digirepub.com 归属不明全仓库该字符串仅出现在一处⁠:w2r.site/admin-frontend/js/views/Settings.vue:206 的输入框 placeholder。没有任何配置、.env⁠、部署脚本指向它向甲方确认它究竟是现网域名还是历史遗留;若是现网,则说明还存在一份未交接的配置,必须一并索取项目负责人
知识体系缺失/www/wwwroot/.cursor 的 15 条 rules + 10 个 skills)已发生demo 侧仅剩 2 条(demo.w2r.site/.cursor/rules/frontend-build.mdc⁠、media-permissions-mfa.mdc⁠),w2r.site 侧一条不剩索取原件;索取不到则在阶段二用 §2.7 的文档补齐等价约束(尤其"改 frontend 必须 build"这类部署纪律)技术负责人
原开发者不可联系已发生全部提交作者为 Arwen Fu <arwenfu@gmail.com>(私人邮箱),最后提交 2026-05-26所有疑问只能靠代码 + docs/cms/ 自证;建立"判定规则":docs/cms/ 可信、docs/ 根目录 6 份脚本文档必须逐条对源码复核后端主力
Sitecore 抓取凭据已随离职失效w2r.site/backend/.env:16-18sitecore.LOGGING_URL / username / password(三个键均有值,未验证有效性)尽早实测一次;失效则明确"增量补抓能力归零",并评估是否还需要数据 / Python
内网 GitLab 访问权限未移交远端为 gitlab.onedevops.vw.com.cn(VW 内网)申请项目 Maintainer 权限;若拿不到,则以本地基线仓库为准并另建托管项目负责人
接手首日误跑破坏性 spark 命令中高致命AuditAcceptanceExtended.php:151 直接 truncate()⁠;AuditAcceptance.php:50 伪造 sys_admin token;RevertPagesToDraft.php:29 默认操作人 'test'⁠。全部由 spark 自动发现,出现在 php spark list交接第一天贴禁令清单⁠;阶段二加环境白名单或移出 app/Commands/后端主力
存储型 XSS 已可能在库RichTextEditor.vue:240-242 只正则删 <script>⁠;内容存 content_json 并由前台渲染修净化 + 对存量 content_json 做一次扫描前端 + 后端
demo 软链直连生产 CMS 可写目录致命demo.w2r.site/shared/dam-writable -> /www/wwwroot/w2r.site/backend/writable⁠;demo.w2r.site/backend/.env:24 指向它任何 demo 侧的清理/权限批处理脚本必须加 -P(不跟随软链);评估改为只读挂载运维
两站共库耦合w2r.site/backend/.envdemo.w2r.site/backend/.env 的 hostname、database 值完全一致(仅账号口令不同);demo.w2r.site/backend/app/Controllers/News.php:9 注释自述"与 w2r.site CMS 共用 entries / entry_i18n 等表"CMS 侧任何改表须先跑 demo 侧回归;建立表结构变更评审后端主力
.env 活密钥随交接件外泄已发生w2r.site/backend/.env 中 JWT_SECRET(:37)、figma.token(:61)、DB 口令(:28)、sitecore 口令(:18);demo.w2r.site/backend/.env:14 另一套 DB 口令 —— 全部明文随 ZIP 分发阶段一 1.9 全部轮换;轮换 JWT_SECRET 会让全员掉线,需选窗口运维 + 安全
合规材料已过期docs/software-bom.md 记录生产为 PHP 8.3.30 / MySQL 5.7.44,本机复现是 PHP 8.2.33 / MySQL 8.0.46;security-scan.sh:9 项目根写死 /www/wwwroot/vgc⁠,唯一扫描结果停在 2026-01-25阶段二重跑扫描、重出 SBOM,不得直接提交历史材料安全

6. 决策建议

结论:分治 —— CMS 侧接手维护(不重写),demo 官网前台重做。

不和稀泥,逐块给判断:

w2r.site/backend(27,252 行 PHP):接手维护。不重写。

它承载了 51 个迁移的数据模型、RBAC + 栏目级授权、审计体系、DAM 分片上传、两级审核工作流、Figma 导入 —— 且 docs/cms/ 26 份设计文档与迁移目录可逐条印证,这是一份有设计意图留存的资产。缺陷虽多但类型集中在四个模式(鉴权漏写、无事务、时间类型契约、错误静默),一次性收口成本约 72 人天⁠。重写等价于重做半年工作量,保守估 200–250 人天⁠,且会丢掉那 26 份文档对应的隐性需求(审计留存 6 个月、密码有效期策略、状态机六态等)。成本比约 1 : 3,没有争议。

w2r.site/admin-frontend(13,656 行 Vue):接手维护,先删死代码。

js/cms/components/VisualEditor.vue 及其配套约 1,890 行零引用,删掉后代码量降 14%。剩余是常规 Element Plus CRUD,重写零收益。真正要补的是工作流 UI(24h)与按钮级权限(16h)—— 是功能缺失⁠,不是代码质量问题⁠,重写不能让它们自己长出来。

demo.w2r.site(10,822 行):重做前端,删掉 api.php⁠。

理由是修复成本已经追平重写成本,而重写额外买到质量:

修(保留现有代码)重做
响应式改造40 人天(HeaderNav.vue:342,374,399 是 1440px 画布下的 left:600px/1089px/1200px 绝对定位,全站仅一条 @media 且是 prefers-reduced-motion⁠)含在内
双实现收口8 人天含在内(只留 CI4 一套)
兜底路由 + 补栏目页6 人天 + 补页面含在内
错误可见性 / 资产鉴权 / 流式输出13 人天含在内
死链与占位控件清理7 人天不产生
dist 与源码不同步、无 CI需另建含在内
零 git 历史无法补天然解决
合计约 74 人天,且改完仍是一份无版本历史、产物提交进仓库的代码35–45 人天,含 CI 与响应式

demo.w2r.site/backend/public/api.php(20,994 字节、11 处裸 SQL、:163:170 读结果集之前就 close() 了连接)应当直接删除⁠,/api 统一走 app/Controllers⁠。这是整个评估里最确定的一条重写建议:它只有 21KB,删除它同时消灭"两套实现谁在生效"这个交接头号未知量。

④ Python 脚本与文档:保留,但先降级为"参考"。

scripts/ 11 个脚本的价值取决于 Sitecore 凭据是否还有效。凭据一失效,抓取类脚本(3 个)立即归零,迁移类脚本(8 个)只在重建库时用一次。不投入重写,只加两道护栏⁠:update_sitecore_channels_and_assets.py --update-assetsreset_sitecore_dam_assets.py 在阶段一即列入禁执行清单。

给负责人的三条硬建议

  1. 先固化基线,再谈修复。 12 个源文件从未进版本库、demo 整棵树无 git、远端比本地旧两个月 —— 在这件事做完之前投入的任何修复工时都有归零风险。这是 4 人天,应该是第一天开工的事。
  2. 域名与生产环境的归属问题,走管理线不走技术线,且要设死线。 2027-02-08 到期、vgc.digirepub.com 归属不明、生产机 SSH 未移交、内网 GitLab 权限未移交 —— 这四件事技术团队解决不了,但它们卡着阶段一的 1.3 / 1.7 共 5 人天,并直接决定项目能否存续。建议给甲方一个 2 周书面回复期⁠。
  3. 不要把"本机已跑通"当成验收依据。 已知事实里的跑通用的是 Apache(localdev/vhosts.conf⁠),生产是 nginx(public/404.html:6⁠),rewrite 规则全在 Apache 语法的 .htaccess 里。这两者对 /AdM SPA 回退、/asset/:id⁠、/api/ 分发的行为不同。真正的阶段一验收,必须在与生产同型的 nginx 上完成。

生成于 2026-08-11 · 全部数据来自本机实测
八个并行代理独立通读 · 结论经交叉核对