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.md | Figma 保真度开发指令 |
系统规模
必须尽早决策的架构隐患
demo.w2r.site/backend/public/api.php 是 21 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 API | 200 |
localhost:1978/ | 官网前台 | 200 |
localhost:1978/api/* | 官网只读 API | 200 |
端到端验证
| 环节 | 结果 |
|---|---|
| PHP 8.2.33 + 全部必需扩展 | 通过 |
| 数据库迁移 51/51 | 通过 |
| RBAC 播种 7 角色 / 22 权限 | 通过 |
| 验证码 + 登录限流 + JWT | 通过 |
| TOTP MFA 注册与验证 | 通过 |
| 建栏目 → 官网导航显示 | 通过 |
| 内容提交审核 | 500 |
完整性已验证
verify-tree.sh check 对 31,997 个文件逐一比对大小与修改时间,结果为零差异。
启动方式
cd /Volumes/ProjectsAPFS/VWCORP/localdev
docker compose up -d
open http://localhost:1977/AdM/
03 · 环境地图
四个站点,三台服务器
| 域名 | 解析 | 归属 | 角色 |
|---|---|---|---|
| w2r.site | 47.242.46.253 | 阿里云 | CMS 后台 |
| demo.w2r.site | 47.242.46.253 | 阿里云 · 同一台 | 官网前台,交付码来源 |
| vgc.digirepub.com | 139.224.64.38 | 另一台 | 文档标注的「生产环境」,无任何代码 |
| volkswagengroupchina.com.cn | 211.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 题库、全部账号与审计历史。
阻断 DAM 媒体与运行时目录
backend/writable/ 整个目录不在包里。它同时是 CI4 运行时目录和 DAM 存储根,缺了框架无法启动。
unzip -l | grep -c 'backend/writable' 结果为 0;241 MB 包内媒体文件仅 13 个,而 migrate_video.log 显示 197 个视频迁移成功。阻断 生产 web server 配置
证据显示生产跑的是 nginx(public/404.html 是 nginx 默认错误页,index.html 是宝塔面板页),但改写规则全写在 Apache 语法的 .htaccess 里。nginx 不读 .htaccess,真实路由配置完全缺失。
阻断 工程知识体系
规格书第四章列出 15 条 .cursor/rules + 10 个 .cursor/skills,实际交付 2 个 rules、0 个 skills。它们在 /www/wwwroot/.cursor/,比站点根高一层,打包时漏了。
docs/cms/(26 份、313 KB)有对应设计文档,知识大部分可恢复。高 代码仓库与基线
原仓库在 VW 内网 GitLab。随包的 .git 只有 backend 13 个、admin-frontend 7 个提交,全是整树倾倒,集中在六天内。无法做任何行级溯源。更严重的是有核心功能代码从未提交,远端不存在这批实现。
高 官网前台落后三个月
changelog 记载的 /api/home(Figma 首页正文)与 /preview/{token}(外部预览页)在交付代码里都不存在。api.php 最后修改停在 2026-03-23。
05 · 缺陷总账
145 项缺陷,必修 275.5 小时
八个模块代理独立排查共报出 154 条,跨模块去重后 145 项。被多个代理同时报出的会标注——那通常意味着问题更值得优先处理。
阻断级(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/ad06 · 取证分析
约九成代码由 AI 生成
git 历史是整树倾倒式提交,无法做行级溯源,因此判断基于代码内部特征。以下全部是实测计数。
决定性证据:风格零漂移
| 指标 | 结果 |
|---|---|
| 缩进 | 213/213 文件纯空格,零 Tab |
| 花括号风格 | 独占一行 572 处 vs 同行 3 处,99.5% 一致 |
| 字符串引号 | 单引号 26,918 vs 双引号 593,97.8% 一致 |
| strict_types | 213 个文件全部没有,无一例外 |
| TODO / FIXME | 27,252 行 PHP 里零个 |
关键点:没有任何 .php-cs-fixer.dist.php 或 phpcs.xml 配置文件,composer 里那几个 cs 工具是 CI4 骨架自带的 require-dev,没配置就跑不起来。三个人手写 213 个文件跨越数月做到这种零漂移,实际上不可能。
最硬的一条:注释里留着助手的推理过程
……需要把现有分类都挂到网站素材下吗?用户说「把栏目的构架复制到资产分类的网站素材下面」,即栏目树复制到网站素材下,没有说把原有分类移进去。所以保持:只插入「网站素材」根,不移动旧数据。
app/Database/Migrations/2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:43
分区判断
| 代码区 | AI 占比 | 依据 |
|---|---|---|
| CMS 后端 PHP | 88–95% | 风格零漂移、零 TODO、607 个 docblock 覆盖到私有方法 |
| 管理后台 Vue | 88–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 |
08 · 模块详解
逐模块交接文档
点开展开。每份含架构说明、文件职责、关键流程、隐式约定与坑,接手者读完可上手。
CMS 后端 · HTTP 接口层(w2r.site/backend:Routes/Filters/Controllers)
主要风险
- 授权判定散落在 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 而是空转。
| 别名 | 类 | 行为 | 用在哪 |
|---|---|---|---|
auth | App\Filters\AuthFilter | 先试 JWT(验签+exp),失败回退 Session;无 user_id → 401 / code 1001;有 user_id 但 mfa_verified 为假 → 403 / code 1002 | 95 条 API |
auth_no_mfa | App\Filters\AuthNoMfaFilter | 同上但不检查 MFA,只要求已登录 | auth/me、auth/mfa/status、auth/mfa/enroll、auth/mfa/verify 共 4 条 |
logout_auth | App\Filters\LogoutAuthFilter | 无条件放行(LogoutAuthFilter.php:15-18 直接 return $request),身份识别推到控制器里做,允许过期 JWT | 仅 auth/logout |
AuthFilter.php:21-35 的关键逻辑:JWT 优先,JwtService::verify 失败(含过期)就静默回退到 Session。三个过滤器都不查库——不校验 users.is_active,不查 user_sessions.revoked_at。
完整鉴权管线共四层,每层都可能缺席:
- 过滤器层(路由声明):登录 + MFA。
- 控制器入口层:多数方法会再写一遍
$userId = $this->getCurrentUserId(); if (!$userId) return $this->fail('未登录', 1001);——这是对 auth 过滤器的冗余重复,但也是preview/access这类无过滤器接口的唯一防线。 - 权限码层:
ApiController::requirePermission()(:200)查库,或requireRoles()(:215)读 JWT claim。这一层在 ContentController、ChannelController 的 CRUD、DashboardController、EntryRelationController、FaqPageController、SearchController、SeoConfig 读接口、SystemConfig 读接口里完全不存在。 - 对象级层:
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_id 是 bin2hex(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 |
| 4xxx | DAM / 栏目 / 分类 | 4000 参数错(24 处)、4001 被引用或有子节点、4004 对象不存在、4011 未登录(DAM 专用,25 处)、4031 无权限(DAM/Figma 专用)、4091 二次确认失败 |
| 5xxx | 搜索 | 5000 操作失败、5002 参数缺失、5003 不存在 |
| 6xxx | RBAC / 用户 / 系统配置 / Figma | 6001 无权限(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)/info | AssetDeliveryController::getAssetInfo | 无 | 同上(只报告不拦截) | 前端探测资产是否存在、是否需登录、有哪些质量档 |
| GET | /page-css/(:any) | PageCssDeliveryController::serve | 无 | 无 | Figma 导入产生的版本化页面 CSS |
| GET | /AdM | AdminController::index | 无 | 无 | 管理后台 SPA 入口 |
| GET | /AdM/(:any) | AdminController::index | 无 | 无 | SPA history 模式回退 |
4.2 认证与会话(11 条)
| 方法 | 路径 | 控制器::方法 | 过滤器 | 权限 | 用途 |
|---|---|---|---|---|---|
| GET | /api/auth/captcha | AuthController::captcha | 无 | 公开 | 生成图形验证码 |
| POST | /api/auth/login | AuthController::login | 无 | 公开 | 用户名密码登录;IP 级节流(LoginThrottleService)+ 强制图形验证码;成功签发 mfa_verified=false 的 JWT |
| POST | /api/auth/logout | AuthController::logout | logout_auth | 无(放行) | 登出;接受 reason 白名单(manual/inactivity/credential_invalid/mfa_not_verified/token_expired/other,:254-261)与 api_code 用于审计归类 |
| GET | /api/auth/me | AuthController::me | auth_no_mfa | 仅登录 | 当前用户 + 角色显示名 + 权限码列表(前端菜单与路由守卫依赖此项) |
| GET | /api/auth/mfa/status | AuthController::mfaStatus | auth_no_mfa | 仅登录 | 查 MFA 是否已注册、是否有未完成的密钥 |
| POST | /api/auth/mfa/enroll | AuthController::mfaEnroll | auth_no_mfa | 仅登录 | 生成 TOTP 密钥 |
| POST | /api/auth/mfa/verify | AuthController::mfaVerify | auth_no_mfa | 仅登录 | 校验 TOTP;成功后换发 mfa_verified=true 的新 JWT 并登记 user_sessions |
| POST | /api/auth/mfa/reset | AuthController::mfaReset | auth | 需当前密码 | 重置自己的 MFA |
| POST | /api/auth/confirm-token | AuthController::confirmToken | auth | 需当前密码;高危操作追加 MFA | 签发二次确认 token;:649-654 定义需 MFA 的四类操作:user_reset_password、user_reset_mfa、rbac_manage、dam_force_delete |
| POST | /api/auth/change-password | AuthController::changePassword | auth | 需旧密码 + 密码策略 | 自助改密,写 password_changed_at |
| POST | /api/preview/access/(:segment) | PreviewController::access | 无 | 用户名+密码 | 预览链接访问,不要求 JWT/MFA,带 IP 节流 |
4.3 内容(9 条)——全域无权限码
| 方法 | 路径 | 控制器::方法 | 过滤器 | 权限 | 用途 |
|---|---|---|---|---|---|
| GET | /api/content | ContentController::index | auth | 无 | 列表;支持 type/exclude_type/channel_id(JSON_CONTAINS)/status/lang 过滤,附封面与修订稿 id |
| GET | /api/content/(:segment) | ContentController::show | auth | 无 | 详情,id 支持数字或 UUID |
| POST | /api/content | ContentController::create | auth | 无 | 创建(非事务,i18n 插入失败会留孤儿 entry) |
| PUT | /api/content/(:segment) | ContentController::update | auth | 无;仅 draft 状态 + created_by/sys_admin | 更新中英文 i18n、封面、meta_json |
| DELETE | /api/content/(:segment) | ContentController::delete | auth | 无;created_by/sys_admin + confirm_token(content_delete) | 删除并清理 asset_refs |
| POST | /api/content/(:segment)/draft | ContentController::createDraft | auth | 无 | 为已发布内容生成修订稿 |
| DELETE | /api/content/(:segment)/draft | ContentController::deleteDraft | auth | 无 + confirm_token(content_delete) | 删除未发布修订稿 |
| GET | /api/content/(:segment)/relations | EntryRelationController::list | auth | 无 | 内链关系列表 |
| PUT | /api/content/(:segment)/relations | EntryRelationController::replace | auth | 无;仅 draft + created_by/sys_admin | 全量替换内链 |
4.4 栏目(8 条)——CRUD 无权限码
| 方法 | 路径 | 控制器::方法 | 过滤器 | 权限 | 用途 |
|---|---|---|---|---|---|
| GET | /api/channels | ChannelController::index | auth | 无 | 树形栏目 |
| PUT | /api/channels/reorder | ChannelController::reorder | auth | 无 | 批量改 parent_id/sort_order |
| GET | /api/channels/(:num) | ChannelController::show | auth | 无 | 详情 |
| POST | /api/channels | ChannelController::create | auth | 无 | 创建(并同步生成 DAM 分类) |
| PUT | /api/channels/(:num) | ChannelController::update | auth | 无 | 更新(改名时同步 DAM 分类名) |
| DELETE | /api/channels/(:num) | ChannelController::delete | auth | 无;有内容或子栏目时拒绝 | 删除 |
| GET | /api/channels/(:num)/role-permissions | ChannelController::rolePermissions | auth | rbac:manage | 栏目级角色授权列表 |
| PUT | /api/channels/(:num)/role-permissions | ChannelController::updateRolePermissions | auth | rbac:manage + confirm_token(rbac_manage) | 全量替换栏目级授权(allow/deny) |
4.5 工作流与发布(8 条)
| 方法 | 路径 | 控制器::方法 | 过滤器 | 权限 | 用途 |
|---|---|---|---|---|---|
| POST | /api/workflow/submit | WorkflowController::submit | auth | content:submit + 栏目级授权 | draft → review_l1 |
| POST | /api/workflow/review-l1 | WorkflowController::reviewL1 | auth | workflow:review_l1 + 栏目级 | 初审 pass/reject |
| POST | /api/workflow/review-l2 | WorkflowController::reviewL2 | auth | workflow:review_l2 + 栏目级 | 终审 pass/reject |
| GET | /api/workflow/actions | WorkflowController::actions | auth | 角色 sys_admin/audit_readonly 或 三个权限码任一(:260-265) | 审核链路 |
| GET | /api/workflow/config | WorkflowController::getConfig | auth | 角色 sys_admin | 工作流规则 |
| PUT | /api/workflow/config | WorkflowController::updateConfig | auth | 角色 sys_admin | 保存规则 |
| POST | /api/publish/publish | PublishController::publish | auth | content:publish + confirm_token(content_publish) | 手动发布 |
| POST | /api/publish/unpublish | PublishController::unpublish | auth | content:unpublish + confirm_token(content_unpublish) | 手动下线 |
4.6 DAM(16 条)
| 方法 | 路径 | 控制器::方法 | 过滤器 | 权限 | 用途 |
|---|---|---|---|---|---|
| POST | /api/dam/chunk | DamController::chunk | auth | dam:upload | 分片上传(multipart 或 base64) |
| GET | /api/dam/chunk-status/(:segment) | DamController::chunkStatus | auth | dam:upload | 断点续传状态 |
| POST | /api/dam/merge | DamController::merge | auth | dam:upload | 合并分片建资产 |
| GET | /api/dam/assets | DamController::assets | auth | 仅登录 | 资产列表(type/status/category/keyword/has_high_quality) |
| GET | /api/dam/assets/(:num) | DamController::assetShow | auth | 仅登录 | 详情 + 引用数 + 变体 |
| GET | /api/dam/assets/(:num)/refs | DamController::assetRefs | auth | 仅登录 | 引用明细 |
| PUT | /api/dam/assets/(:num) | DamController::assetUpdate | auth | dam:upload | 改标题/分类/发布日/隐藏 |
| POST | /api/dam/assets/(:num)/variants | DamController::mergeVariant | auth | dam:upload | 追加高清变体 |
| DELETE | /api/dam/assets/(:num)/variants/(:segment) | DamController::deleteVariant | auth | dam:upload | 删变体及文件 |
| DELETE | /api/dam/assets/(:num) | DamController::assetDelete | auth | dam:delete + confirm_token(dam_delete);有引用即拒 | 常规删 |
| POST | /api/dam/assets/(:num)/force-delete | DamController::assetForceDelete | auth | dam:force_delete + confirm_token(dam_force_delete)(密码+MFA) | 强删 |
| GET/POST/PUT/DELETE | /api/dam/categories[/(:num)] | AssetCategoryController::index/show/create/update/delete | auth | 全部 dam:upload | 分类树维护;access_level 取值 public/auth,直接决定 /asset/:id 是否要求登录 |
4.7 用户与 RBAC(11 条)
| 方法 | 路径 | 控制器::方法 | 权限 |
|---|---|---|---|
| GET | /api/users | UserController::index | rbac:manage(并记异常检测) |
| GET | /api/users/(:num) | UserController::show | rbac:manage |
| POST | /api/users | UserController::create | rbac:manage |
| PUT | /api/users/(:num) | UserController::update | rbac:manage;升为 sys_admin 时额外记 user_role_escalation |
| DELETE | /api/users/(:num) | UserController::delete | rbac:manage + confirm_token(user_delete);禁止删自己 |
| PUT | /api/users/(:num)/status | UserController::updateStatus | rbac:manage |
| PUT | /api/users/(:num)/password | UserController::resetPassword | rbac:manage + confirm_token(user_reset_password) |
| PUT | /api/users/(:num)/mfa | UserController::resetMfa | mfa:reset_other + confirm_token(user_reset_mfa) |
| GET | /api/roles /api/roles/(:num) | RoleController::index/show | rbac:manage |
| PUT | /api/roles/(:num) | RoleController::update | rbac:manage + confirm_token(rbac_manage) |
4.8 审计、配置、搜索、FAQ、Figma、预览、仪表盘(其余 42 条)
| 方法 | 路径 | 控制器::方法 | 权限 |
|---|---|---|---|
| GET | /api/audit/query | AuditController::query | 角色 sys_admin/audit_readonly;含「非常规查询」检测(:172-194) |
| GET | /api/audit/export | AuditController::export | 同上;上限 10,000 条,超阈值记 bulk_export;返回 CSV |
| GET/PUT | /api/system/session-config | SystemConfigController::sessionConfig/updateSessionConfig | 读无;写角色 sys_admin(1-480 分钟) |
| GET/PUT | /api/system/audit-policy | SystemConfigController::auditPolicy/updateAuditPolicy | 读无;写角色 sys_admin |
| GET/PUT | /api/seo/config | SeoConfigController::show/update | 读无;写角色 sys_admin |
| GET | /api/seo/schema/preview | SeoConfigController::schemaPreview | 无 |
| GET | /api/config/export | ConfigPromotionController::export | config:export |
| POST | /api/config/import | ConfigPromotionController::import | config:import(支持 dry_run) |
| GET | /api/dashboard/stats | DashboardController::stats | 无 |
| GET | /api/faq/questions[/(:num)] | FaqQuestionController::index/show | 无 |
| POST/PUT/DELETE | /api/faq/questions[/(:num)] | FaqQuestionController::create/update/delete | content: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)/questions | FaqPageController::questions/updateQuestions | 无;写侧仅 draft + owner |
| POST | /api/figma/import | FigmaController::import | figma:import |
| POST | /api/figma/css/persist | FigmaController::persistCss | content:edit |
| POST | /api/figma/css/source-url | FigmaController::updateSourceUrl | content:edit |
| GET | /api/figma/css/versions | FigmaController::cssVersions | content:edit |
| GET | /api/search /api/search/suggest /api/search/hotwords | SearchController::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/tokens | PreviewController::index | 仅登录;非 sys_admin 只看自己创建的 |
| POST | /api/preview/tokens | PreviewController::create | preview:create |
| PUT | /api/preview/tokens/(:num)/revoke | PreviewController::revoke | preview: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 内容被视为公开资源。
六、接手前必须知道的隐式约定
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)。新写接口时照抄最近的那个就行,别自创第四种。- 审计是手写的,不是切面。每个需要留痕的分支都要显式调
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。 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。拼错一个字母的表现是「二次确认永远失败」。requireRoles和requirePermission强度不同,见缺陷 3。需要「立即生效」的权限变更,只能用requirePermission。ContentController::index的 channel_id 过滤是手拼 SQL(:63-71):JSON_CONTAINS(channel_id, CAST('[17]' AS JSON), '$'),先$db->escape()再拼串、并用where(..., null, false)关掉转义。改这里要格外小心;$cid已经(int)过,目前是安全的。类似的裸 SQL 还有DamController::assets的EXISTS(...)(:256-261)和 FAQ 列表里join(... 'i.lang = ' . $db->escape($lang))(FaqQuestionController.php:46、FaqTopicController.php:39、FaqPageController.php:43、EntryRelationController.php:42)。- CSP 是开着的(
Config/App.php:201 CSPEnabled = true,策略见Config/ContentSecurityPolicy.php,script/style 均为self、autoNonce=true)。这就是 Figma 导入必须把 CSS 落盘成外部文件而不能内联<style>的原因(FigmaController.php:113-115 的注释写明了)。 WorkflowService.php:63等处往EntryModel的?datetimecast 字段塞字符串导致工作流提交 500 这个已知缺陷,从 HTTP 层看表现为POST /api/workflow/submit返回 CI4 的 HTML 错误页而非 JSON——前端会显示成网络错误,排查时容易被误导到接口层,实际要去 Service 层修。
七、修复建议的优先级
第一批(阻断上线):给 ContentController 与 ChannelController 的 CRUD 补 requirePermission;删掉 public/test-putenv.php。第二批(安全基线):requireRoles 改为查库并校验 is_active;AuthFilter 增加 is_active 与 user_sessions.revoked_at 两项检查(这一步同时把「登出即失效」和「禁止并发登录」两个功能真正接通)。第三批(可用性):修 AssetDeliveryController 的 stdClass 下标 bug;把 trace_id 打通到审计;给 /api/* 加一个 JSON 版的 404 与异常处理器(Config/Routing.php 的 override404 + 自定义 ExceptionHandler)。
建议同时做一件结构性的事:把权限码从控制器方法体里挪到路由声明上(CI4 的 filter 支持传参,['filter' => 'perm:content:edit']),这样 Routes.php 就重新成为授权的事实来源,「新接口忘了加校验」会在 code review 时一眼可见。这项改造约需 3 人天,但能一次性消除本次审计里最主要的一类风险。
CMS 后端 · 数据模型与迁移(w2r.site/backend 持久层)
主要风险
- 干净库不可重建:迁移 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 张)
| 表 | 关键列 | 索引/外键 | 用途 |
|---|---|---|---|
users | id, username, email, password_hash, role VARCHAR(50) 默认 editor, is_active, last_login_at, password_changed_at, created_at, updated_at | UK(username)、KEY(role);无任何外键 | 后台账号。role 是字符串,与 roles.code 在应用层 join(2025-01-19-100000:33、2026-02-03-100026) |
mfa_secrets | user_id, provider_type 默认 totp, secret, backup_codes, is_enrolled | UK(user_id, provider_type)、FK user_id→users CASCADE | TOTP 密钥与备份码(100001) |
user_sessions | user_id, jti, ip, user_agent, created_at, revoked_at | UK(jti)、KEY(user_id, revoked_at)、FK user_id→users CASCADE | JWT 会话登记与吊销(2026-05-21-100060:11-52) |
operation_logs | actor_id, actor_role, module, operation, object_type, object_id, timestamp, result ENUM(success,fail), error_code, trace_id, request_summary, ip, user_agent | KEY(timestamp)、(actor_id)、(module,operation)、(object_type,object_id)、(trace_id);actor_id 无外键(故意,允许 actor_id=0 表示系统) | 审计日志(100002) |
audit_rate_buckets | bucket_key, event_type, window_start, hit_count, threshold_logged | UK(bucket_key,event_type,window_start)、KEY(event_type,window_start) | 异常行为限频计数(2026-05-21-100060:54-95) |
2.2 RBAC(4 张)
| 表 | 关键列 | 约束 |
|---|---|---|
roles | code, name, description, is_active | UK(code)(100007) |
permissions | code, name, module, requires_confirm, requires_mfa | UK(code)、KEY(module)(100008) |
role_permissions | role_id, permission_id, scope ENUM(own,all) 默认 own | UK(role_id,permission_id)、双 FK CASCADE(100009) |
channel_role_permissions | channel_id, role_id, permission_id, policy ENUM(allow,deny) 默认 allow | UK(channel_id,role_id,permission_id)、三 FK CASCADE(100010) |
2.3 内容(8 张)
| 表 | 关键列 | 约束 |
|---|---|---|
channels | parent_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) |
entries | id, 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/reason | KEY(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_i18n | entry_id, lang ENUM(zh,en), title, summary, content_json LONGTEXT, content_text, files JSON, slug, seo_title, seo_desc, og_asset_id, schema_json | UK(entry_id,lang)、KEY(slug) 非唯一、FULLTEXT(title,summary,content_text)、FK entry_id→entries CASCADE(100006 + 100012 + 100021 + 2026-02-25-100000) |
entry_relations | entry_id, related_entry_id, relation_type, sort_order | UK(entry_id,related_entry_id,relation_type)、双 FK→entries CASCADE(100014) |
workflow_actions | entry_id, action, from_status, to_status, note, reject_reason, actor_id, actor_role | KEY(entry_id,action,created_at)、FK entry_id CASCADE / actor_id RESTRICT(100015) |
preview_tokens | token, entry_id, device, expires_at, revoked_at, created_by | UK(token)、FK entry_id CASCADE(100016) |
entry_css_versions | entry_id, lang, filename, storage_key, size_bytes, checksum, source_url, created_by | UK(filename)、KEY(entry_id,lang)、FK entry_id CASCADE(2026-04-29-100050) |
config | key(唯一), value TEXT, description | UK(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 张)
| 表 | 关键列 | 约束 |
|---|---|---|
assets | type, 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_by | UK(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_variants | asset_id, quality(high/low), filename, mime, size_bytes, storage_key, checksum, sitecore_id, sitecore_path | UK(asset_id,quality)、FK asset_id CASCADE(100035 + 100037) |
asset_categories | parent_id, channel_id, name, description, access_level(public/auth), sort_order, created_by | UK(channel_id)、FK parent_id 自引用 SET NULL、FK channel_id→channels SET NULL、FK created_by RESTRICT(100024 + 100031) |
asset_refs | asset_id, object_type, object_id, field_path | KEY(asset_id)、(object_type,object_id)、FK asset_id CASCADE(100018) |
asset_entry_i18n | asset_id, entry_i18n_id, role(默认 thumbnail) | UK(entry_i18n_id, role)、双 FK CASCADE(100033,100040 是补建守卫) |
upload_sessions | upload_id(UK), status(uploading/merged/failed), filename, mime, total_size, total_chunks, asset_id | FK created_by RESTRICT、asset_id SET NULL(100019) |
upload_chunks | upload_id, chunk_index, chunk_hash, size_bytes | UK(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_import(100034,31 列的 Sitecore 文件导入落地表)、sitecore_news_import(100044,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::refreshEntryRefs(AssetRefService.php:52-68)先尝试 json_decode,失败就当 HTML 用正则扫 data-asset-id 和 /asset/{id}。content_text 是从中抽出的纯文本,专供 FULLTEXT 搜索。
4.3 channel_id 是 JSON 数组,不是外键
2026-02-10-100030 把 entries.channel_id 从 BIGINT 改成 JSON([1] 或 [1,2]),为的是让新闻可以同时挂多个栏目。代价是删掉了外键和索引,并且再没补回。取「主栏目」的约定是取数组第 0 个元素,封装在 EntryModel::primaryChannelId()(EntryModel.php:85-92)——工作流查 i18n_strategy、权限查栏目授权都走它。筛选则用 JSON_CONTAINS(ContentController.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.rules 的 review_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:77 的 draft_of_entry_id IS NULL),只通过正式条目的 edit_draft_id 字段暴露。发布修订稿时走 publishDraftOverOriginal()(:111-162):把内容合并回原条目、原条目保持 published、然后删除修订稿。所以修订稿是临时物,不是版本历史——本系统没有内容版本历史,只有 CSS 有(entry_css_versions)。
4.6 封面图有三条路径,这是最容易踩的坑
entries.cover_asset_id——建表时就有,但没有外键,且当前控制器写入路径不用它;entry_i18n.og_asset_id——SEO 分享图,按语言;asset_entry_i18n(role='thumbnail')——实际在用的封面,UK(entry_i18n_id, role) 保证每语言每角色一张,读取见ContentController.php:100-114(优先中文、回退英文),写入见:429/:437(用 MySQLREPLACE INTO)。
asset_refs 是第四张相关表,但它不是封面,而是引用索引:每次内容保存后 AssetRefService::refreshEntryRefs() 先删后建,扫出内容里引用的所有 asset_id,供 DAM 删除保护使用(有引用则禁止删除)。asset_variants 则是同一逻辑资产的高清版本(Sitecore EntityList 里的 HD 文件),UK(asset_id, quality)。
五、RBAC 数据模型与 22 个权限点
5.1 判定链路
users.role(字符串)→ roles.code → role_permissions → permissions.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)
| # | code | module | confirm | mfa |
|---|---|---|---|---|
| 1 | content:create | content | ||
| 2 | content:edit | content | ||
| 3 | content:edit_all | content | ||
| 4 | content:delete | content | ✓ | |
| 5 | content:submit | content | ||
| 6 | content:publish | content | ✓ | |
| 7 | content:unpublish | content | ✓ | |
| 8 | workflow:review_l1 | workflow | ||
| 9 | workflow:review_l2 | workflow | ||
| 10 | workflow:config | workflow | ✓ | |
| 11 | dam:upload | dam | ||
| 12 | dam:delete | dam | ✓ | |
| 13 | dam:force_delete | dam | ✓ | ✓ |
| 14 | preview:create | preview | ||
| 15 | preview:revoke | preview | ||
| 16 | rbac:manage | rbac | ✓ | ✓ |
| 17 | mfa:reset_other | mfa | ✓ | ✓ |
| 18 | audit:read | audit | ||
| 19 | audit:export | audit | ||
| 20 | config:export | system | ||
| 21 | config:import | system | ||
| 22 | figma:import | content |
授权矩阵共 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.id2–8。这些 id 是原开发者机器上 100031 同步既有栏目树后的偶然结果,干净库上 asset_categories 只有 id=1。2026-03-04-100032合并重复「网站素材」根节点,是针对 100031 被重复执行过的历史数据修复;干净库上count($rows) <= 1直接 return,无害但也说明 100031 曾在生产上跑重过。
约束可能没建上(1 条)
2026-02-09-100029:21用forge->addColumn(['uuid' => [... 'unique' => true]])声明唯一性。CI4 的 ALTER TABLE ADD COLUMN 路径对unique属性的处理不保证生成索引,全仓也没有别的地方建这个唯一键。接手第一步:SHOW INDEX FROM entries WHERE Column_name='uuid'核实,缺了就补。
不幂等 / 重跑必炸(4 条)
2026-01-27-100025:24-25(idx_category_id、fk_assets_category)、2026-03-04-100031:15-24(idx_parent_id、fk_asset_categories_parent、uk_channel_id、fk_asset_categories_channel)、2026-03-11-100036:30(idx_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_json调JSON_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-super→spark db:seed RolePermissionSeeder,且 Seeder 在已有库上禁止重跑。
七、模型层的实现约定与坑
- DataCaster 时间字段:CI4 4.7 的
datetimecast 在写入时只接受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/134与AssetRefService.php:145-148(返回 Time 对象)。 - UserModel 的三个方法重写(
:45-59setUpdatedField、:65-151transformDataToArray、:188-230convertToReturnType)以及updateLastLogin()直接走裸 SQL(:235-249),全都是为了绕开同一个 cast 问题。注释里写得很直白(:30-32)。不要把 UserModel 的写法照搬到别的模型,也不要给 UserModel 加回时间字段的 cast。 useTimestamps混用:EntryModel/EntryI18nModel/ChannelModel/ConfigModel 等开启(框架自动写 created_at/updated_at),AssetModel/AssetRefModel/WorkflowActionModel/PreviewTokenModel 等关闭并把 created_at 放进 allowedFields 由调用方自己传。判断依据是模型里的protected $useTimestamps,没有统一规则。- JSON cast 只在模型路径生效:
EntryModel的channel_id => 'json'(:47)让模型查询返回 PHP 数组,但SearchService.php:88用裸 builder 查同一列,返回的是原始 JSON 字符串。所以同一个字段在不同 API 里类型不一致,前端要两套处理(ContentController::normalizeChannelIdForResponse就是补丁)。 - 8 张表没有模型:roles / permissions / role_permissions / channel_role_permissions / user_sessions / audit_rate_buckets / asset_entry_i18n / 两张 sitecore 导入表,全靠
$db->table('...')裸写。改这些表结构必须全仓 grep。 - 全部模型没有校验规则:25 个模型的
$validationRules都是空数组,唯一的输入校验散在控制器里,且很不完整(ContentController.php:327-329的报错文案说「类型、栏目、中文标题和slug不能为空」,实际只检查了栏目)。
八、接手第一周建议动作
- 先在跑通的 Docker 库上导出
SHOW CREATE TABLE(34 张)作为真实 schema 基线,不要以迁移目录为准;重点核实entries.uuid的唯一索引、ft_entry_i18n_search是否存在、两张 sitecore 表的 collation。 - 补一条迁移修掉
100031的 created_by(或加引导管理员迁移),使spark migrate在空库上一次跑通;这是恢复能力的前提。 - 修
WorkflowService的 9 处时间字符串(改成Time::now(config('App')->appTimezone)),并给ContentController::create包事务、补 type/title/slug 校验。 - 给
entries.channel_id建生成列 + 索引(或改成entry_channels关联表),恢复栏目筛选的可扩展性与引用完整性。 - 把
role_permissions.scope要么接进PermissionService,要么从界面上摘掉——留着比删掉更危险。 - 应用时区固定为
Asia/Shanghai(app/Config/App.php:136),所有 DATETIME 列都按本地时间裸存、无时区信息;任何跨时区需求都要先改这一层的约定。
CMS 后端 · 服务层(w2r.site/backend/app/Services/ + Services/Figma/ + app/Helpers/)
主要风险
- 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 = true(DataConverter.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')
| # | 文件:行号 | 字段 | 目标 Model | cast 声明 |
|---|---|---|---|---|
| 1 | Services/WorkflowService.php:63 | submitted_at | EntryModel | ?datetime (EntryModel.php:54) |
| 2 | Services/WorkflowService.php:99 | reviewed_l1_at | EntryModel | ?datetime (:55) |
| 3 | Services/WorkflowService.php:101 | published_at | EntryModel | ?datetime (:52) |
| 4 | Services/WorkflowService.php:112 | reviewed_l1_at | EntryModel | ?datetime |
| 5 | Services/WorkflowService.php:128 | reviewed_l1_at | EntryModel | ?datetime |
| 6 | Services/WorkflowService.php:131 | last_rejected_at | EntryModel | ?datetime (:57) |
| 7 | Services/WorkflowService.php:157 | reviewed_l2_at | EntryModel | ?datetime (:56) |
| 8 | Services/WorkflowService.php:159 | published_at | EntryModel | ?datetime |
| 9 | Services/WorkflowService.php:174 | reviewed_l2_at | EntryModel | ?datetime |
| 10 | Services/WorkflowService.php:177 | last_rejected_at | EntryModel | ?datetime |
| 11 | Services/WorkflowService.php:276 | created_at | WorkflowActionModel | datetime,且 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/133—Time,安全。PreviewService.php:41/44/58—now()/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/75—Time::now()。安全(但另有created_by硬编码问题,见 §3.6)。AssetRefService.php:88—now()返回Time(:145-148)。安全。Figma/EntryCssVersionService.php:94、Figma/FigmaImageToDamService.php:306— 都先转Time。安全。AuditLogger.php:75/77—OperationLogModel的$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-68(start_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/278(Time::now())、RoleController.php:189/207/208(无对应 Model,走 QB)。
1. 分层与总体形态
Controllers/Api/* ← HTTP 边界,权限门禁 + 审计埋点
└── Services/* ← 本文范围,无接口无抽象,全是 new 出来的具体类
└── Models/* ← CI4 Model,casts 是隐形雷区(见 §0)
└── MySQL 8
共性特征,先看懂这几条能省一天:
- 无 DI 容器。所有服务都在构造函数里
new XxxModel(),服务间也是new(如PublishService.php:99newEntryDraftService、ConfigPromotionService.php:22-25new 四个)。只有 Figma 那一支做了构造函数注入(FigmaImportService.php:24-36),因此也只有它有单测。 - 返回值有两种风格,不统一:业务服务返回
['ok' => bool, 'code' => int, 'message' => string, 'data' => array]数组(Workflow / Preview / EntryDraft / DamUpload / ConfigPromotion / Search),基础设施服务直接返回标量或bool(Auth / Jwt / Permission / PasswordPolicy)。AuditLogger::log()是唯一的静态方法。 - 有四套
now(),返回类型不一致——这是 §0 缺陷的土壤: - 返回
Time:PublishService.php:138、PreviewService.php:116、EntryDraftService.php:549、AssetRefService.php:145 - 返回
string:WorkflowService.php:280、DamUploadService.php:540、SearchService.php:211、AuditLogger.php:62-66(内联)
其中 DamUploadService 和 AuditLogger 返回字符串是对的(前者随后转 Time,后者目标 Model 无 cast),WorkflowService 返回字符串是错的。接手后建议统一成 Time,字符串场景显式 ->format()。
- 异常几乎不外抛。业务错误走返回值,底层异常靠 catch-all 吞掉(
JwtService.php:93-96、SearchService.php:205-207、WorkHoursService.php:60-62)。好处是接口不 500,坏处是配置错误无声无息——JwtService的密钥问题(§3.8)就是这么被藏起来的。 - 测试覆盖极不均衡。
backend/tests/unit/Figma/下 5 个文件 576 行,覆盖 Figma 子服务;auth 链、RBAC、workflow、publish、DAM 零测试。
2. 逐服务速查表
| 服务 | 行数 | 职责 | 主要调用方 | 依赖 |
|---|---|---|---|---|
AuthService | 310 | 密码 hash/校验、TOTP 生成与验证、备用码 | AuthController、UserController、4 个 Command、PreviewService | users/mfa_secrets;random_bytes、hash_hmac |
JwtService | 308 | 签发/验签/刷新/解析 JWT、从请求取 token | AuthFilter、AuthNoMfaFilter、ApiController、AssetDeliveryController、AuthController | firebase/php-jwt、Config\Jwt |
LoginThrottleService | 144 | 登录失败按 IP 节流与锁定 | AuthController、PreviewController | Cache |
PasswordPolicyService | 92 | 密码复杂度与有效期校验 | AuthController、UserController、2 个 Command | Config\Auth |
ConfirmTokenService | 63 | 危险操作二次确认票据(5 分钟一次性) | 10 个控制器 | Session |
UserSessionService | 118 | jti 会话登记、并发登录检测与撤销 | AuthController、AuditAcceptanceExtended | user_sessions、config |
CaptchaService | 248 | 图形验证码生成与校验 | AuthController | Cache、GD 扩展 |
PermissionService | 112 | 全局 RBAC 判定与权限码列表 | ApiController、AuthController、ChannelController | users/roles/permissions/role_permissions |
ChannelRolePermissionService | 103 | 栏目级授权的读取与全量替换 | ChannelController、ConfigPromotionService | channel_role_permissions |
WorkflowService | 290 | 提交/初审/终审状态机、栏目级授权判定、工作流配置 | WorkflowController、ConfigPromotionService | entries/workflow_actions/channels/config |
WorkHoursService | 82 | 工作时间配置与「是否非工作时间」判定 | AuthController、AuditAnomalyService、AuditPolicyService | config、DateTimeZone |
PublishService | 143 | 定时发布/下线、手动发布/下线 | PublishController、RunPublishSchedule、PublishPagesAsUser | entries |
PreviewService | 136 | 预览令牌签发/撤销/校验 | PreviewController | preview_tokens/entries |
EntryDraftService | 553 | 已发布内容的修订稿:克隆 → 合并回写 → 删副本 | ContentController、PublishService | entries/entry_i18n/entry_relations/faq_page_questions/asset_entry_i18n/entry_css_versions;文件系统 |
DamUploadService | 550 | 分片上传会话、分片落盘、合并入库、高清变体 | DamController | upload_sessions/upload_chunks/assets/asset_variants;文件系统、Config\Dam |
AssetRefService | 150 | 扫描内容中的资产引用并重建 asset_refs | ContentController、EntryDraftService | asset_refs/entries/entry_i18n |
AssetCategorySyncService | 78 | 栏目 ↔ 资产分类树镜像同步 | ChannelController | asset_categories/channels |
AuditLogger | 112 | 审计写入 + 敏感字段脱敏 | 28 个文件(几乎所有控制器与 Command) | operation_logs |
AuditPolicyService | 151 | 审计相关阈值/开关的读写 | ApiController、AuditController、SystemConfigController | config、WorkHoursService |
AuditAnomalyService | 181 | 频率桶统计与阈值告警 | ApiController、AuditController、UserController | audit_rate_buckets、WorkHoursService、AuditLogger |
ConfigPromotionService | 235 | 配置导出/导入(workflow/seo/rbac/channel_auth/channels) | ConfigPromotionController | 上述多个服务 + 多表 |
SearchService | 221 | 前台搜索、热词、置顶、联想 | SearchController | entries/entry_i18n/search_*;MySQL FULLTEXT |
SeoSchemaService | 307 | SEO 配置、JSON-LD 生成/合并/校验 | SeoConfigController、ConfigPromotionService | config |
ContentTextService | 64 | content_json → 可索引纯文本 | ContentController | 无 |
Figma/FigmaClient | 258 | Figma REST 调用、链接解析、二进制下载 | FigmaImportService | cURL、Config\Figma |
Figma/FigmaToHtmlCssService | 842 | 节点树 → HTML + CSS | FigmaImportService | 无 |
Figma/FigmaImageToDamService | 316 | 导出图入 DAM 并回填 <img src> | FigmaImportService | assets、文件系统、DOM 扩展 |
Figma/FigmaImageResizeService | 124 | 按设计尺寸 × 倍率压缩 | FigmaImageToDamService | GD 扩展 |
Figma/FigmaSanitizerService | 142 | HTML 白名单净化 | FigmaImportService | DOM 扩展 |
Figma/FigmaImportService | 243 | 导入管线编排 | FigmaController | 上述五个 |
Figma/EntryCssVersionService | 209 | 页面 CSS 版本化落盘 | FigmaController、EntryDraftService | entry_css_versions、文件系统 |
Figma/FigmaApiException | 20 | 异常类型 | — | — |
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 未通过时已经发了一枚 token(AuthController.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_tokencookie、?token=query 参数。query 传 token 会进 access log 与 Referer,是给AssetDeliveryController用的便利,但也是泄露面。
3.4 会话撤销是摆设(高危)
UserSessionService 写 user_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 仍能过
AuthFilter(PermissionService会在权限层拦住,因为它查is_active,但 filter 层先放行了)。
修法:在 AuthFilter::before 里用 JwtService::extractJti() 取 jti,查 user_sessions.revoked_at IS NULL,结果进 Cache(TTL 30-60 秒)避免每请求一次 DB。
3.5 MFA 三个问题
- 密钥不是加密是编码。
AuthService.php:110-112注释写「加密存储(Base64编码)」,实际就是base64_encode。:217读回来base64_decode再base32Decode。拿到mfa_secrets表 = 拿到所有人的 TOTP 种子 + 8 个明文备用码。 - verify 无节流。
LoginThrottleService在AuthController里只出现在login()(:58/83/104/125/147/182),mfaVerify()(:453-495)一次都没调。verifyMfaCode还接受 ±1 时间步(:240-247),单请求命中率 3/10⁶,可无限并发爆破。 - reset 后 verify 会 500。
resetMfa(:304-308)把secret/backup_codes写成''但保留行;verifyMfaCode的findByUserAndProvider仍返回该行,:221的json_decode(base64_decode(''))得到null,:222的in_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)。唯一瑕疵是:29用strlen()而非mb_strlen(),中文密码的长度按字节算(偏松,不是安全问题)。CaptchaService(248 行):见缺陷表。核心问题是:162的WRITEPATH.'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=1(
users.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 完整性闸门
checkI18nCompleteness(WorkflowService.php:231-255):
- 中文标题与 slug 缺一 → 硬阻断(无视策略)。
- 英文缺失:
channel.i18n_strategy === 'strict'→ 阻断;否则只产生 warning,不阻断(:246-252)。 - 策略取自主栏目:
EntryModel::primaryChannelId($entry)取channel_idJSON 数组的第一个元素(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 在 :94 读 review_levels,<= 1 就直接发布。风险:ConfigModel::getValue()(ConfigModel.php:52-61)把存储值 json_decode 后返回,若有人把 workflow.rules 存成了标量字符串,$cfg['review_levels'] 取到 null → (int)null = 0 → 0 <= 1 成立 → 一级审核直接上线。没有任何校验拦这个。
5.4 审核链断点
workflow_actions 只有 WorkflowService::recordAction(:257-278)在写。PublishService 的四条状态变更(:44、:66、:116、:131)与 EntryDraftService::publishDraftOverOriginal(:133)都不写。所以从 workflow_actions 看,一条内容可能「提交了、初审过了」,然后状态莫名其妙变成 published——真实原因在 operation_logs 里(PublishController 有 AuditLogger 埋点),但两张表没有关联字段可拼。合规审计时这是个说不清的洞。
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-schedule(Commands/RunPublishSchedule.php)驱动,cron 配置不在仓库里,需要向运维索取。manualPublish()(:91-127):若目标是修订稿(draft_of_entry_id非空,:98)就转交EntryDraftService::publishDraftOverOriginal;否则只拦「已发布」(:112),其余状态一律直接 published。这是审核绕过的入口。- 幂等性:
runSchedule靠published_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与字符串,是因为PreviewTokenModel的expires_atcast 会把值转成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_refs的object_id没有外键(迁移2026-01-24-100018:51只对asset_id建了 FK),所以deleteEditDraft(:190)才要手工delete。publishDraftOverOriginal路径没有做这个手工清理——草稿被删后,指向它的asset_refs行成为孤儿。这会让「资产被引用中,不可删除」的判断出现假阳性。copyLatestCssVersionsBetweenEntries()(:485-523):读磁盘文件内容重新落盘,而不是只改库里的文件名。注释(:481-483)解释了原因:只改库名会让前台 404。这是踩过坑之后的正确写法,别「优化」掉。- 事务缺陷:
:76-83的transRollback()后直接 return,没有transComplete(),事务深度泄漏(详见缺陷表)。
7. DAM
7.1 DamUploadService(550 行)
分片协议:initSession → N × saveChunk → merge(或 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 行)
纯计算,无副作用(配置读写除外)。三块能力:
getSeoConfig()(:16-63):四组配置带内置默认值——seo.site、seo.meta_rules、seo.organization_profile(中英双份 Corporation 档案)、seo.schema_mappings。buildSuggestedJsonLdForEntry()(:75-150):映射键优先meta_json.page_kind,回退entry_type:{type}(:261-267),再回退WebPage(:88)。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_denied 与 audit_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)。导出全部实现;导入只有 workflow 与 channel_auth 真正可用,rbac 与 channels 直接返回「未开放」(:88、:90)。dryRun(:72-83)是假的——不做任何校验,直接返回 validated: true 和全零变更数。别信它的返回值。
importChannelAuth(:175-219)对每行规则各查一次 roles 和 permissions(: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-height由recordAbsoluteChildExtent反推(: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}.css 到 WRITEPATH/uploads/page-css,checksum 相同则跳过(:50-63,只更新 source_url)。落盘失败或入库失败会 @unlink 回滚文件(:97-100)。历史版本永不清理,磁盘只增不减。公开访问路径 /page-css/{filename}(Config/Figma.php:63),由 PageCssDeliveryController 中转。
11. 接手 checklist
第 1 天必做的四件事
- 读完本文 §0,把「datetime cast 只吃 Time」刻进肌肉记忆。
- 修
WorkflowService::now(),把 11 个调用点验一遍(含:276的workflow_actions)。这是让系统可用的最小改动。 - 向前任/运维索取三样东西:
.env里的JWT_SECRET与figma.token(别看真实值,确认存在即可,位置见backend/.env与Config/Jwt.php:56-70、Config/Figma.php:74)、publish:run-schedule的 cron 配置、backend/writable/的媒体备份。 - 确认 PHP 装了
gd/curl/dom/fileinfo——缺任何一个,验证码、Figma 导入、图片压缩会静默降级而非报错。
改动前必查
- 动
Models/*的$casts→ 把 Services 里所有写该字段的地方过一遍(用 §0.3 的表当模板重跑一次 grep)。 - 动鉴权语义 →
Filters/AuthFilter.php与Controllers/Api/ApiController.php:77-137是两份独立实现,必须同改。 - 动
PermissionService→hasPermission()与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)
主要风险
- 「验收」命令实为生产写操作: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.2(backend/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.php | 43 | 密码策略:最小 8 位、大小写+数字+特殊字符全要、90 天强制改(:16-42) | app/Services/PasswordPolicyService.php:20 |
Jwt.php | 75 | JWT 密钥/算法 HS256/有效期 7200s/刷新 7 天/iss=CMS/aud=CMS-Admin | app/Services/JwtService.php:27 |
Dam.php | 52 | 上传上限 5GB、分片 1–10MB、最多 1000 片、白名单扩展名与 MIME、存储目录 | app/Services/DamUploadService.php:21、FigmaImageToDamService.php:33 |
Figma.php | 89 | Figma REST 地址/Token/超时/重试/节点上限 800/CSS 输出目录与 URL 前缀 | 6 处(FigmaClient.php:19 等) |
Hostnames.php | 40 | 两段式 TLD 常量表 | 无人引用,死代码 |
Dam.php:49-50、Figma.php:58 都把目录钉在 WRITEPATH 下,见第 6 节。
2.2 被改动过的 CI4 原版类
App.php::19baseURL = 'https://w2r.site/'(生产域名硬编码,且.env未提供app.baseURL,所以这行真的在生效);:136appTimezone = 'Asia/Shanghai'(原版 UTC);:201CSPEnabled = 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::28ini_set('session.cookie_lifetime','0')强制关浏览器即失效;:48-57注释掉 Debug Toolbar 监听;:60-64保留了 development 下的__hot-reload路由。Session.php::34cookieName = 'w2r_cms_session'(原版 ci_session,注释说明是为了甩掉旧的长过期 cookie);:47expiration = 0。注意.env:44-45又提供了session.cookieName与session.expiration,会覆盖这两行。Logger.php::42threshold 改成production ? 4 : 9;:48新增了原版没有的retentionDays = 180,由logs:cleanup读取。Routes.php:172 行,全部业务路由,含 SPA fallback(:20-21)、资产投递(:11-12)、页面级 CSS 投递(:16)和api分组下的全部接口。Migrations.php::64lock = 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.php里csrf从未挂载;Cors.php全空(:37-96),前后端同域部署所以没影响,但一旦拆域名要从零配;Cookie.php:57secure = false,HTTPS 站点的会话 cookie 没有 Secure 标记;Encryption.php:24key 为空——目前全仓库没有代码使用encrypter服务,暂时无害,但别指望它能用;WorkerMode.php是 4.7 给 FrankenPHP 准备的,项目没用 FrankenPHP,纯骨架残留。
3. 环境变量全清单与缺省行为
CI4 的 BaseConfig 会用 .env 里 类名小写.属性名 形式的键自动覆盖 Config 类属性,代码里搜不到读取点。这是本项目最容易踩的隐式约定。当前 .env(24 个有效键,值一律见 backend/.env:<行号>)分四类:
A. 靠 CI4 约定自动覆盖 Config 属性
| 键 | 位置 | 覆盖目标 | 缺失时的行为 |
|---|---|---|---|
CI_ENVIRONMENT | .env:5 | ENVIRONMENT 常量 | 缺失则默认 production。当前值是 development |
database.default.{hostname,database,username,password,DBDriver,DBPrefix,port} | .env:25-31 | Config\Database::$default | 缺失则用 Database.php:27-52 的空凭据,连不上库 |
session.{driver,cookieName,expiration,matchIP,timeToUpdate,regenerateDestroy} | .env:43-49 | Config\Session | 缺失则回落到 Session.php:24-96 |
logger.threshold | .env:55 | Config\Logger::$threshold | 缺失则 Logger.php:42 的三元表达式生效 |
B. 代码显式读取
| 键 | 读取点 | 缺省值 |
|---|---|---|
figma.token | Figma.php:69 | ''(Figma 导入直接不可用) |
figma.baseUrl | Figma.php:74 | https://api.figma.com/v1 |
figma.timeout | Figma.php:79 | 30 秒 |
figma.maxNodes | Figma.php:84 | 800 |
JWT_SECRET | Jwt.php:54($_ENV) | 三段兜底,见下 |
JWT_SECRET 的三段兜底逻辑有坑::54 读 $_ENV 是唯一真正生效的路径;:57-68 的手工读 .env 用 strpos($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:19 的 https://w2r.site/ 硬编码生效)、app.forceGlobalSecureRequests、app.CSPEnabled、encryption.key、cookie.secure。另外 env 模板文件 69 行全是注释,没有任何项目专属键的说明——接手方无法从模板知道该配哪些。
4. 14 条 spark 命令逐条说明
CI4 自动发现 app/Commands/ 下所有 BaseCommand 子类,全部出现在 php spark list。
| # | 命令 | 定义 | 用途 | 破坏性 | 进 crontab? |
|---|---|---|---|---|---|
| 1 | admin:create-super [user] [pwd] [mail] | CreateSuperAdmin.php:14 | 建 sys_admin | 写库;无审计;不带密码时自动生成并 echo 到 stdout(:44);生成算法(:84-95)不保证满足策略,可能自己校验不过 | 否(一次性) |
| 2 | admin:setup-mfa [user] | SetupSuperAdminMfa.php:13 | 给超管发 MFA | 覆盖已有绑定(有 y/n 提示 :60);密钥外发第三方 :111 | 否 |
| 3 | admin:verify-rbac | VerifyRolePermission.php:11 | 打印角色/权限/关联统计 | 只读 | 否(可做巡检) |
| 4 | user:reset-password <user> <pwd> | ResetUserPassword.php:14 | 改密码 | 写库;无审计;明文密码进 argv/history | 否 |
| 5 | user:reset-mfa <user> | ResetUserMfa.php:13 | 清 MFA 并重新发放 | 无确认直接清除现有绑定(:44);密钥外发第三方(:86) | 否 |
| 6 | user:activity <uid> | QueryUserActivity.php:14 | 统计某用户在 14 张表的记录数 | 只读;注释自称「临时命令」 | 否 |
| 7 | audit:cleanup [months] | CleanAuditLogs.php:19 | 删 operation_logs 超期记录,默认 6 个月 | DELETE 不可逆;有完整 job_start/job_finish 审计埋点(:34、:79) | 是,0 0 1 |
| 8 | logs:cleanup [days] | CleanApplicationLogs.php:20 | 删 writable/logs/log-*.log,默认读 Logger.php:48 的 180 天 | unlink 不可逆;有审计埋点 | 是,0 0 1 |
| 9 | publish:run-schedule | RunPublishSchedule.php:13 | 执行定时发布/下线,幂等 | 写库但幂等;有审计(:22) | 是,建议每 5 分钟。注意已知缺陷:PublishService::runSchedule() 第 46/68 行的 datetime cast 尚未按 manualPublish 的 nowTime() 方式修复 |
| 10 | publish:pages-as-user [user] [--type] [--dry-run] | PublishPagesAsUser.php:20 | 批量发布 | 批量改状态;有 content:publish 权限校验(:46)与 --dry-run;默认操作人硬编码 'test'(:30) | 否 |
| 11 | publish:revert-pages-to-draft [user] [--type] [--dry-run] | RevertPagesToDraft.php:19 | 批量改回 draft | 最危险的常规命令:无权限校验、无确认、默认操作人 'test'(:29)、绕过 PublishService 直写 EntryModel(:78)。空参运行即全站页面下线 | 否,建议直接删除 |
| 12 | dam:delete-video-mp4 [--dry-run] | DeleteVideoMp4Assets.php:20 | 删所有 mp4 资产与物理文件 | 高破坏性;有 --dry-run 但无确认、无审计;级联删 variants/refs | 否,建议改成必须带 --force |
| 13 | audit:accept [base_url] [host] | AuditAcceptance.php:21 | 审计 P0 验收 | 伪造 sys_admin/editor token(:50);把用户 2 提权再硬编码还原成 publisher(:124-126);锁定/解锁用户 3(:143-150) | 否,应移出生产分支 |
| 14 | audit:accept-ext [base_url] | AuditAcceptanceExtended.php:23 | 审计 P1/P2 验收 | 最高破坏性:TRUNCATE audit_rate_buckets(:151,调用两次)、改写 auth.work_hours 与 audit.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.php | 13 | 46 | 项目自写,主力 |
tests/unit/Figma/FigmaSanitizerServiceTest.php | 5 | 10 | 项目自写,测 XSS 清洗(脚本剥离、事件属性剥离、外链图片剥离) |
tests/unit/Figma/FigmaClientUrlParseTest.php | 6 | 6 | 项目自写,测分享链接解析 |
tests/unit/Figma/FigmaImageResizeServiceTest.php | 2 | 6 | 项目自写,无 GD 时 skip |
tests/unit/Figma/FigmaImportServiceTest.php | 2 | 4 | 项目自写 |
tests/unit/HealthTest.php | 2 | 3 | CI4 骨架自带 |
tests/database/ExampleDatabaseTest.php | 2 | 3 | CI4 骨架示例 |
tests/session/ExampleSessionTest.php | 1 | 1 | CI4 骨架示例 |
对照 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:16 查 INFORMATION_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.html | 删 | nginx 默认 404 页 |
public/AdM/.vite/manifest.json | 删 | 8KB 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_contents 吐 public/AdM/index.html,配合 index.html:20 的 {csp-style-nonce} 占位符由 CI4 的 autoNonce(ContentSecurityPolicy.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/v1 | app/Config/Figma.php:20 |
http://127.0.0.1:8799(验收默认地址) | AuditAcceptance.php:35、AuditAcceptanceExtended.php:27、:37 |
/usr/sbin/sendmail | app/Config/Email.php:26(CI4 原版默认,邮件未使用) |
/usr/local/bin/convert | app/Config/Images.php:20(CI4 原版默认,实际用 GD) |
默认操作人 'test' | PublishPagesAsUser.php:30、RevertPagesToDraft.php:29 |
| 硬编码用户 id 1/2/3 与角色 publisher | AuditAcceptance.php:50、:123、:126、:143;AuditAcceptanceExtended.php:48-49、:173 |
8. 运行期目录:全部状态都在缺失的 writable/ 下
Paths.php:56 定义 WRITEPATH = backend/writable,该目录不存在(.gitignore:2 排除)。依赖它的有:
Session.php:64→writable/session(会话)Cache.php:84→writable/cache(LoginThrottleService.php:24登录限流、CaptchaService.php:25验证码都靠它)- Logger FileHandler(
Logger.php:127path 为空即默认)→writable/logs Dam.php:49-50→writable/uploads/dam、writable/uploads/dam_chunksFigma.php:58→writable/uploads/page-css
这些缺失不会在启动时报错,而是在运行时以「登录失败」「验证码不出」「上传 500」的形式零散冒出来。接手第一步就是按上面的清单把目录建好并给 php-fpm 用户写权限。
9. 接手第一周的行动清单
- 先给
audit:accept/audit:accept-ext加保险:最省事的做法是这两个类的run()开头判断ENVIRONMENT === 'production'就直接退出,或者干脆把两个文件移出app/Commands/。在这之前不要在生产机上敲php spark list之后手贱。 - 删
public/test-putenv.php、groupui-showcase.html、index.html、404.html、AdM/.vite/,并把build/logs/加进.gitignore。 - 上生产机抄回三样东西:nginx server 段、真实
.env(重点确认CI_ENVIRONMENT到底是不是 production)、writable/的目录结构与权限。抄回来后写成docs/deploy.md和一份填好键名的env.example。 - 吊销
sitecore.*那套凭据(.env:16-19),确认无人使用后从.env删掉。 - 补两条安全配置:
Filters.php:87打开secureheaders,ContentSecurityPolicy.php:164设frameAncestors = 'self'。改完必须回归/AdM能正常登录(CSP 一收紧最容易打死管理后台)。 - 把 MFA 二维码改成本地生成(
SetupSuperAdminMfa.php:111、ResetUserMfa.php:86),不要再把 TOTP 种子发给第三方。 - 给
publish:revert-pages-to-draft和dam:delete-video-mp4加权限校验 +--force确认 + 审计埋点,参照CleanAuditLogs.php:34/79的 job_start/job_finish 写法。 - 建三条 crontab(第 4 节末尾),并先在预发跑一遍
publish:run-schedule确认 datetime cast 缺陷已修。 - 补认证与工作流的冒烟测试:登录→MFA→拿 token→提交工作流→发布,这条主链路一条测试都没有,是目前所有线上事故的共同来源。
- 重写
README.md与composer.json的 name/description,把 PHP 版本要求统一到 8.2(README.md:45 现在写的 8.1 是错的)。
CMS 管理后台前端(w2r.site/admin-frontend,Vue3 + Element Plus)
主要风险
- 工作流 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.html → js/main.js → App.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 权限(满足其一即可) |
|---|---|---|
/login | views/Auth/Login.vue | 无需登录 |
/mfa-enroll | views/Auth/MfaEnroll.vue | 需登录,requiresMfa: false |
/dashboard | views/Dashboard.vue | [](全员) |
/channel | views/Channel.vue | content:create / content:edit / content:edit_all |
/news | views/News.vue | 同上 |
/content | views/Content.vue | 同上 |
/faq | views/FaqQuestions.vue | 同上 |
/media | views/Media.vue | dam:upload / dam:delete |
/asset-categories | views/AssetCategories.vue | dam:upload |
/audit | views/Audit.vue | audit:read |
/users | views/Users.vue | rbac:manage |
/roles | views/Roles.vue | rbac:manage |
/settings | views/Settings.vue | workflow:config / rbac:manage |
路由守卫(router/index.js:105-164)
每次跳转都无条件 await authApi.getMe(),没有任何缓存。因此后台每切一次页面就打一次 /api/auth/me。守卫顺序:
- 拿到
res.data.id才算已登录,否则next('/login') - MFA 检查:
requiresMfa && !mfa_verified时,mfa_enrolled为真跳/login(去输 6 位码),为假跳/mfa-enroll - 权限检查:
canAccessRoute(res.data.permissions, meta.requiresPermission),不通过静默重定向到/dashboard(无任何提示) - 副作用:每次都把用户信息写进 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 存储:
sessionStorage的jwt_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:publish | useContentPublish.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:create | 仅 api/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:import | Settings.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_enrolled 或 has_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-tree 的 draggable 实现拖拽排序与改父子关系,handleNodeDrop 后用 collectTreeOrderItems 重算整棵树的 parent_id 与 sort_order(步长 10)批量 PUT 到 /channels/reorder,失败则重新拉取回滚。api/channel.js:5 的 normalizeChannelTree 递归把 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 历史上有三种格式,normalizeContentJson(Content.vue:1129、News.vue:849 两份重复实现)负责统一还原成 HTML 字符串:
- 直接的 HTML 字符串(手工编辑产生)
- JSON 字符串,如
"\"<p>...</p>\"" [{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/en(mergeFigmaUrlsIntoMeta)。编辑已有内容时会反查 /figma/css/versions 恢复预览,若查到 CSS 但正文为空会弹警告提示重新导入。
草稿 / 预览 / 发布交互
修订稿机制(composables/useContentEditDraft.js):编辑状态为 published / unpublished / expired 的内容时,不直接改线上,而是先 POST /content/{id}/draft 生成或复用一份修订稿,返回 draft_id 后编辑这份副本。列表行上有 edit_draft_id 字段标记存在未发布修订。
发布(composables/useContentPublish.js):canPublish 判断 content:publish 或 role === '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 处 TODO(AssetSelector.vue:225),没有 FIXME/HACK,没有空函数体的 @click。就「有没有假按钮」这个问题本身,代码是干净的——ElMessage.success 都出现在真实 await 之后。
七、死代码清单
js/cms/components/ 下有一整套可视化页面搭建器,没有任何视图或路由引用:
| 文件 | 行数 | 用途 |
|---|---|---|
VisualEditor.vue | 464 | 拖拽式页面搭建主界面 |
components.ts | 559 | 组件定义(propsSchema / defaultProps) |
ComponentPreview.vue | 356 | 组件渲染预览 |
ComponentProperties.vue | 214 | 属性编辑面板 |
registry.ts | 171 | 组件注册表与类型定义 |
ComponentNode.vue | 126 | 树节点 |
| 合计 | 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。
八、接手后的建议顺序
- 立刻把工作区提交进 GitLab(6 个未跟踪文件 + 8 个已改文件,发布/预览/修订三套核心流程全在里面,本地是唯一副本)
- 修
api/audit.js:5一行,恢复审计筛选与分页(改动最小、影响最大) - 给
RichTextEditor.sanitizeHtml换成 DOMPurify 白名单 - 决策工作流:要么补齐提交/审批 UI,要么明确废弃两级审核并清理相关权限点与状态筛选项
- 建立按钮级权限指令(如
v-perm="'content:delete'"),把 22 个权限点逐一落到按钮上 - 删掉 VisualEditor 整套死代码,同时移除
main.js:30的副作用导入 - 把 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 独立入口)
主要风险
- 生产到底是 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:12 的 database.default.database = vgc)和同一份 DAM 媒体文件(通过符号链接 shared/dam-writable)。demo 侧只做三件事:查 channels 表画导航、查 entries/entry_i18n 出新闻、按 id 吐 DAM 二进制。
目录关系:
| 目录 | 内容 | 是否上线 |
|---|---|---|
frontend/src | Vue 3 + Vite 源码,35 个代码文件 | 否,需构建 |
frontend/dist | 构建产物,网站根目录直接指向这里(.cursor/rules/frontend-build.mdc:18) | 是 |
backend/public/api.php | 683 行、绕过 CI4 框架的独立 API 入口 | 见 §4 |
backend/app/Controllers/* | CI4 控制器,功能与 api.php 重叠 | 见 §4 |
backend/public/demo_asset_delivery.inc.php | DAM 交付函数,被上面两条路径共用 | 是 |
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-9 → App.vue(只有一个 <router-view>)→ src/router/index.js。
路由表(router/index.js:10-26)只有四条半:
| 路径 | 组件 |
|---|---|
/ | HomePage.vue |
/en/ | HomePage.vue(同一组件,靠 menuLangFromPathname 区分语言) |
/en | redirect → /en/ |
/news/:uuid | NewsDetailPage.vue |
/en/news/:uuid | NewsDetailPage.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:27→GET /api/menu?lang=→normalizeMenuApiResponse→capMegaMenuDepth(…, 3)截断到三层 →resolveMegaState算高亮态。useLatestNewsList.js:22-24→GET /api/news?lang=&limit=3。useNewsArticleDetail.js:47-49→GET /api/news/{uuid}?lang=→adaptNewsDetailResponse→sanitizeArticleHtml(DOMParser 去 script/on* 属性/javascript: URL)→enhanceArticleHtml(删空<p>、把连续两个图块包成双列 gallery)→v-html。
lib/ 下有四个文件是完全无引用的死代码:lib/news/normalizeContentJson.js(后端已经做了同样的事)、normalizeMegaMenuNav.js:95 的 normalizeFromFlatMenuRows、:142 的 megaMenuDepth、articleUrl.js:20 的 parseNewsDetailFromLocation。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-16 → demo_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-28 → api_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-40 → api_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-34 → api_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_html 由 api_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.php | app/Controllers/* | 差异要点 |
|---|---|---|---|
| 健康检查 | :43-51,framework=CodeIgniter 4 (轻量入口) | Api.php:22-32,framework=CodeIgniter 4 | 这是判定谁在服务的唯一探针 |
| /hello | :52-59 | Api.php:37-46 | 一致 |
| 菜单 | api_menu_response :136-176,手写 SQL + 手写递归 | Api.php:52-71 → ChannelModel::getMenuTree :47-62 + buildMenuTree :69-96 | SQL 语义一致;失败时 api.php 返回 200+success:true+空数组,控制器返回 500+success:false |
| 新闻列表 | :203-280,mysqli prepared | News.php:24-94,QueryBuilder | 字段与排序一致;异常处理同上(News.php:59-63 是 500) |
| 新闻详情 | :360-483 | News.php:100-133 + buildNewsPayload :154-219 | payload 字段完全一致;控制器有 404(:109)/500(:121),api.php 恒 200;控制器的 formatDatetime :404-408 额外处理 CI4 Time 对象,api.php 只处理字符串 |
| 面包屑 | api_channel_public :585-613 | ChannelModel::getPublicById :103-129 | 一致 |
| 上下篇 | :619-651(real_escape_string + 整数拼接) | News.php:254-283($db->escape) | 算法一致,同样的全量扫描问题 |
| DAM | :12-16 内联 require | AssetDeliver.php:14-24 require FCPATH 下同一文件 | 共用 demo_asset_delivery.inc.php,无分叉 |
谁生效?仓库无法给出确定答案,这是本模块头号交接风险。 三份互相矛盾的证据:
backend/public/.htaccess:29的RewriteRule ^(hello|)$ api.php [L,QSA]在RewriteBase /api/下只把/api/和/api/hello交给 api.php,其余(menu/news/asset)落到 :36 的前端控制器index.php→ CI4。app/Config/Routes.php:11的注释与 :20-23 额外注册的裸路由(menu、news、asset/(:num))只有在 CI4 真的要处理/api/menu时才有必要写——说明作者当时认定 CI4 在处理这些路径。frontend/README.md:24却写「Nginx:将location ^~ /api/转发到能执行 api.php 的配置(与现有 /api/menu 一致)」,暗示 nginx 把整段/api/都给了 api.php。- 硬证据:
backend/writable/logs/log-2026-03-23.log:1-3和log-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-49 | FAQ / Help / Sitemap / Privacy Policy / Legal Statement 五条 href="#" |
FeatureCtaButton.vue:2 | 三处 Read More,href="#" |
LatestReleaseSection.vue:7 | View 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-7 | 12 个品牌格无任何链接 |
7. shared/dam-writable 符号链接
shared/dam-writable -> /www/wwwroot/w2r.site/backend/writable,作用见 shared/README.md:1-18:让 demo 站直接读生产 CMS 的 DAM 物理文件,配合 .env:24 的 asset.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. 接手第一周建议顺序
- 取回并归档生产 nginx/Apache vhost 配置,用
/api/的data.framework实测确认 §4 的分发真相。在结论明确之前,任何后端改动都要两份一起改。 - 本地重新执行前端构建(禁止在本次审计中执行),diff
frontend/dist与仓库现有产物,确认线上是否包含HeaderNav.vue最后一次改动。 - 把
.env:5改成production,清writable/debugbar/(424 个 json)。 - 修
api.php的错误伪装(:142,157,166,212-216 改成真实 5xx),否则后续任何故障都查不到。 - 把
mcp-assets.js:59-62三个 Figma 外链换成本地 SVG。 - 给 router 加 catch-all → NotFound 页,先止住「点导航一片空白」。
demo_asset_delivery.inc.php加「资产必须挂在已发布 entry 上」的校验,并改成流式输出。
不要做的事:不要顺手删 app/Controllers/News.php「因为它没用」——在 §4 结论出来之前它很可能就是线上正在跑的那一份。
w2r.site — Python 迁移脚本(scripts/)与文档体系(docs/ + 根级 README/安全扫描)
主要风险
- 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。type取Document|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.md和scripts/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_id和assets.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_variants(entity_list_json[0]的高精度版,quality=high)、视频封面另建一条type=image的 assets 并回填poster_asset_id(ensure_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),设计上可重跑。但文件先落盘后写库(:738vs: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语义是「封面资产不存在时跳过、不新建」,和别的脚本里同名参数含义不同。 - 来源:
assetsJOINsitecore_files_import取thumbnail_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_import→assets.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_id去sitecore_files_import反查path_val/url_val(--no-cross-ref关掉这个兜底)。 - 写入:
assets,category_id硬编码 60、status硬编码 2(已发布)。文件名news_cover_{ymd}_{guid}.{ext}。 - 幂等:是(
:322-326sitecore_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_import→entries+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_id是 JSON 数组,由allocated_positionGUID 映射:{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_i18n(run_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_path或sitecore_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_id和assets.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 映射入 config | docs/import_sitecore_files.md:167-168、迁移 …-100042 |
| 2026-03-12 23:23 → 03-13 09:57 | 197 条视频迁移,成功 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_assets 里 category_id = None 是初值(:273),只有当 channel_ids 里有 GUID 命中映射时才被赋值。没命中就带着 None 进 UPDATE(:281-289)。而 Document 类型天生没有 channel(docs/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_id的JSON_CONTAINS筛选值得实测。docs/cms/figma-import.md:5、video-delivery-and-transcoding.md:4引用../../../docs/*.md(workspace 级),checklist-implementation.md:3引用docs/checklist.xlsx和checklist-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 / 依赖树 / 许可证 / 文件统计)。:9 的 PROJECT_ROOT 写死 /www/wwwroot/vgc,加上 :6 的 set -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 个数据库脚本 |
requests | migrate_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 chromium | import_sitecore_files / import_sitecore_news / update_sitecore_channels_and_assets --update-import |
Pillow | gen_diagram_images(另需 Linux 路径下的中文字体) |
openpyxl | docs/cms/gen_quickcheck_xlsx.py |
python-docx | md_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。
七、接手建议(按优先级)
- 立刻给两颗地雷加锁:给
update_sitecore_channels_and_assets.py --update-assets加--dry-run和「无匹配则跳过而非置 NULL」;给reset_sitecore_dam_assets.py加引用检查。约 4 小时。 - 补齐缺失物:数据库导出、
backend/writable/(DAM 文件 + 5 份失败日志)。没有这两样,任何数据核对都做不了。 - 把硬编码的 category_id 挪进 config 表:60 和 2–8 都应该从
config读,且启动时校验分类是否存在,不存在就报错而不是静默错配。 - 清理文档:删掉
docs/import_vgc_news.md,重写docs/import_sitecore_news.md和scripts/README.md的新闻章节,删掉 4 个不存在脚本的说明。这一步不做,下一个接手的人还会踩同样的坑。 - 写一份
scripts/PIPELINE.md:把第三节那张执行顺序图固化下来。现在这个顺序只能靠交叉阅读 8 个脚本的 docstring 反推。 - 修
security-scan.sh的路径并重跑一次,让合规材料回到当前时点。
缺陷穷举与技术债(横向,覆盖 w2r.site + demo.w2r.site 全部首方代码)
主要风险
- 鉴权是逐方法手写的,不在路由层声明——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/app | 27,252 | 213 |
| CMS 管理后台(Vue3 + ElementPlus) | w2r.site/admin-frontend/js | 12,840 | 51 |
| Sitecore 迁移脚本(Python) | w2r.site/scripts | 5,325 | 11 |
| 官网后端(CI4 薄壳 + 独立 api.php) | demo.w2r.site/backend | 6,997 | 64 |
| 官网前台(Vue3 + Vite) | demo.w2r.site/frontend/src | 3,129 | 34 |
框架版本以 w2r.site/backend/system/CodeIgniter.php:58 的 CI_VERSION = '4.7.2' 为准。
共列出 35 条有代码依据的缺陷,其中 blocker 6 条、high 13 条、medium 12 条、low 4 条。下文按「必须先修 → 结构性问题 → 逐类明细 → 上手路径」组织。
1. 必须在接手第一周内处理的 6 条
1.1 DAM 上传链路可达远程代码执行(blocker)
三处独立缺陷串成一条完整利用链:
w2r.site/backend/app/Controllers/Api/DamController.php:50把请求里的upload_id原样取出,只在第 58 行判断非空。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 根。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:32 给 auth/mfa/enroll 挂的是 auth_no_mfa 过滤器(只验登录,不验 MFA)。
w2r.site/backend/app/Controllers/Api/AuthController.php:422 的 mfaEnroll() 不检查用户是否已 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-35 和 ApiController.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.php 的 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 这种设计上只读的角色能删除空栏目、能 PUT /api/channels/reorder 打乱整站导航结构。
1.6 工作流时间字段类型不匹配,提交/审核全部 500(blocker)
w2r.site/backend/app/Services/WorkflowService.php:280-288 的 now() 返回 date('Y-m-d H:i:s') 字符串。它被送进 EntryModel 的 ?datetime cast 字段共 8 处:
| 行号 | 字段 |
|---|---|
| 63 | submitted_at |
| 99 / 112 / 128 | reviewed_l1_at |
| 101 / 159 | published_at |
| 157 / 174 | reviewed_l2_at |
| 131 / 177 | last_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-141 的 nowTime() 返回 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 的隐式类型契约
同一个仓库里存在三种应对策略,接手者极易踩错:
- 声明 cast 并传
Time对象 ——EntryModel、PreviewTokenModel、AssetModel等 24 个 model。 - 故意不声明 cast ——
UserModel.php:29-31注释「不设置 last_login_at 的 cast,避免 DataCaster 在更新时的类型转换错误」;OperationLogModel.php:36-38注释「不设置 timestamp 和 created_at 的 cast」。 - 在调用点打补丁 ——
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:9 与 app/Models/ChannelModel.php:8 的注释都明说「与 w2r.site CMS 共用 entries / entry_i18n / channels 表」。但 demo 侧完全绕过模型层用裸 mysqli 直查(public/api.php 683 行)。
更麻烦的是同一批端点有两套实现:
| 端点 | 实现 A | 实现 B |
|---|---|---|
/api/menu | public/api.php:24 | app/Controllers/Api.php |
/api/news | public/api.php:37 | app/Controllers/News.php::index |
/api/news/{id} | public/api.php:31 | app/Controllers/News.php::detail |
/api/asset/{id} | public/api.php:12 | app/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-125的parseDateTime用用户可控字符串构造Time,Time::parse对无法解析的输入抛异常,PreviewController::create无 try/catch → 传expires_at=abc得到 500 而非 400。SearchHotwordController.php:63-64/SearchPinController.php:72-73把getVar('start_at')、getVar('end_at')直接入库,对应 model 声明的是'datetime'(非 nullable),传 null 或空串会出问题。
3.2 非事务性多表写入
| 位置 | 涉及的表 | 后果 |
|---|---|---|
ContentController.php:348-446 | entries → entry_i18n(zh) → entry_i18n(en) → asset_entry_i18n → asset_refs | i18n 插入失败留下无正文的孤儿 entry,用户无法修复只能删 |
ContentController.php:527-681 | 同上(update 路径) | 部分字段更新成功、部分失败 |
AssetRefService.php:34-89 | asset_refs 先删后插 | 见下 |
DamUploadService.php:299-312 | assets insert → upload_sessions update | session 更新失败则重复合并会产生重复 asset |
对照 EntryDraftService::createDraftFromPublished(EntryDraftService.php:75-94)和 publishDraftOverOriginal(:128-159)都正确用了 transStart/transComplete,说明原作者知道怎么做,只是 ContentController 没做。
AssetRefService.php:34 值得单独说:它先无条件删掉某 entry 的全部资产引用,再逐条插入,中间无事务;而迁移 2026-01-24-100018_CreateAssetRefsTable.php:51 给 asset_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-147 | catch (\Exception $e) {} 空体,吞掉 JWT 验证异常 |
ApiController.php:38-41、AuthController.php:42-45 | JwtService 构造失败吞成 $jwtService = null,随后 AuthController.php:205、:510 直接 $this->jwtService->generate() → 报出来的是「Call to a member function on null」而非真实的配置问题 |
ApiController.php:240-242 | JSON 解析失败静默返回 [],请求体格式错表现为「所有字段都没传」 |
SearchService.php:205-207 | 全文索引探测失败静默降级为 LIKE,性能塌方无告警 |
全部至少应加 log_message('error', ...)。
3.4 迁移可重放性
51 个迁移文件,三类问题:
- 硬编码 id + RESTRICT 外键 ——
2026-03-04-100031_AssetCategoriesTreeAndChannelSync.php:35和:59写死'created_by' => 1,而2026-01-27-100024_CreateAssetCategoriesTable.php:61给该列建了RESTRICT外键。空库上跑不通。 - 裸 ALTER 无存在性判断 —— 同文件
:15-24连续 6 条db->query('ALTER TABLE ... ADD ...'),没有fieldExists保护(对比2026-02-09-100029_AddUuidToEntries.php:15是有的)。上面的外键失败发生在第 28 行 insert,此时 6 条 DDL 已提交但迁移未记账,重跑必报 Duplicate column。 - 依赖已有数据的 id 映射 ——
2026-03-12-100042_SeedSitecoreChannelToCategoryConfig.php:19-27把 7 个 Sitecore GUID 映射到写死的category_id2..8。迁移本身不报错(只往 config 表写 JSON),但scripts/update_sitecore_channels_and_assets.py和migrate_sitecore_files_to_dam.py读这个配置做素材归类,新库上 id 必然对不上 → 静默归错分类。
另外 2026-02-10-100030_ChangeEntriesChannelIdToJson.php:18-28 把 entries.channel_id 改成 JSON 时删掉了外键和索引,此后再没补回生成列+索引,导致 ContentController.php:70 的 JSON_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」,:199对sys_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:40的POST /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.type是VARCHAR(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*属性,只查href的javascript:。未处理<iframe srcdoc>、<object>、<embed>、src="javascript:"、formaction、SVGxlink:href。NewsArticleContent.vue:32用v-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、QueryUserActivity在app/和docs/里搜不到任何引用。其中RunPublishSchedule是定时发布的唯一入口却没有任何 crontab 文档 —— 需要确认线上定时发布到底跑没跑。PublishPagesAsUser.php:31把用户名默认成'test';RevertPagesToDraft.php:74-80绕过工作流批量把已发布内容改回 draft 并清空published_at。一次性数据修复脚本混在正式代码里,接手者无法分辨哪些跑了会毁数据。 - 密码哈希两套 ——
UserController.php:206用PASSWORD_DEFAULT,AuthService.php:33用PASSWORD_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:8799 | Commands/AuditAcceptance.php:26、:35、AuditAcceptanceExtended.php:27 |
/www/wwwroot/w2r.site/backend | Commands/CleanApplicationLogs.php:15(crontab 示例) |
https://cm.volkswagengroupchina.com.cn | scripts/backfill_video_posters_from_sitecore.py:44 等 3 处 |
| asset_categories id 2..8 | Migrations/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:103和demo_asset_delivery.inc.php:75用file_get_contents把整个文件读进内存,而Config/Dam.php:12允许单文件 5GB 且:34-37明确支持 video/mp4。几个并发视频请求即耗尽 memory_limit;也不支持 Range,视频无法拖进度条。demo/public/api.php:628-639的api_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 天:环境与真相核对
- 从线上机器取回 nginx 配置,确认 demo 到底跑哪套实现(2.3)。这是所有 demo 侧改动的前提。
- 确认
RunPublishSchedule的 crontab 是否存在(3.7)。定时发布可能根本没在跑,也可能在跑但会绕过审核(1.6 之外还有PublishService.php:33的无状态过滤问题)。 - 查
users表里*@import.local账号(3.8)。
第 2-3 天:读代码的正确顺序 Config/Routes.php → Config/Filters.php + Filters/AuthFilter.php → Controllers/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-26 与 demo.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:17 把 asset_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():278 和 updateRolePermissions():307 两处有 requirePermission('rbac:manage'),而 reorder():43、create():111、update():164、delete():237 四个写接口一处都没有。任意已登录账号可改站点导航与全部内容。
④ 代码基线不明,且交接的这份比远端新两个月。
w2r.site/backendHEAD =37906d2e/ 2026-05-26 / Arwen Fu,工作区有 92 项变更,其中app/下 22 个被追踪文件已改(git diff --stat合计 +990 / -148 行),另有 6 个源文件从未提交:app/Services/EntryDraftService.php、app/Commands/{PublishPagesAsUser,ResetUserMfa,RevertPagesToDraft}.php、app/Database/Migrations/2026-06-12-100060_AddDraftOfEntryIdToEntries.php、app/Services/Figma/FigmaImageResizeService.php。w2r.site/admin-frontendHEAD =b033e302/ 2026-05-25,8 个文件已改 + 6 个功能文件未提交(js/api/publish.js、js/api/preview.js、js/composables/useContentPublish.js等)。demo.w2r.site整棵树没有任何.git(find demo.w2r.site -maxdepth 3 -name .git无输出)—— 10,822 行代码零版本历史。- 远端是内网
gitlab.onedevops.vw.com.cn/c-sdxx/corporate-website/{backend,frontend}.git,全部 10 次提交集中在 2026-05-21 至 05-26、信息一律是 "Update files" / "Initial commit"。这不是真实开发历史,是一次性倾倒;而最新迁移日期是 2026-06-12、多数源文件 mtime 到 2026-07-22。本地这份是唯一权威副本。
它不难在哪(不要虚高定价)
- 技术栈全是主流,招人不难,没有任何需要专门培训的私有技术。
- 后端 27,252 行、admin 前端 51 个文件、demo 前端 3,129 行 —— 总首方代码 55,543 行,一个人两周能全部读完。
w2r.site/docs/cms/下 26 份设计文档质量高(data-model-diagrams.md、security-model.md、state-machine.md、audit-logging.md、field-dictionary.md),且与迁移目录可逐条印证。这是本次交接最值钱的资产,抵掉了大部分"读代码猜意图"的成本。- 缺陷虽多(合并后 90+ 项),但类型高度集中:鉴权漏写、无事务、时间类型契约、错误静默 —— 四个模式覆盖七成缺陷,一次性收口比逐条修便宜得多。
- 51 个迁移已验证全过、登录 + MFA + RBAC + 审计已验证通过 —— 系统不是"跑不起来",是"跑起来了但不安全、不可重建"。
2. 工作量测算
总计:阶段一 28 人天 + 阶段二 72 人天 = 100 人天进入可安全维护状态;此后 8–10 人天/月。 (不含两项产品决策项:demo 响应式改造 40 人天、demo 补齐栏目页;见 §6)
阶段一「恢复可运行」— 28 人天 / 日历 3 周(2.5 FTE)
验收标准(缺一不可):
- 从空 MySQL 实例执行一条文档化的命令序列,能得到与生产结构一致的库(
SHOW CREATE TABLE对齐),全程无人工 SQL; - 用与生产同型的 nginx 配置起站,管理后台可登录 + MFA + 建内容 + 发布 + DAM 上传,官网前台可渲染新闻详情并显示封面图;
- 工作流四条路径(提交/初审/终审/驳回)不再 500;
- 三份工作区(backend / admin-frontend / demo)各自打上
handover-baselinetag 并推入受控仓库。
| # | 工作项 | 模块 | 人天 | 前置依赖 |
|---|---|---|---|---|
| 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-288 的 now(): string 改返回 Time,同步核 11 个调用点(:63,:99,:101,:112,:128,:131,:157,:159,:174,:177,:276),并顺带修 SearchHotwordController.php:63-68、SearchPinController.php:72-76 | 服务层 | 2 | — |
| 1.6 | demo 双实现定性:curl /api/ 看 data.framework,确认 backend/public/api.php 与 app/Controllers/News.php 谁在生效,另一套下线 | demo | 2 | 依赖 1.3 |
| 1.7 | 媒体恢复:w2r.site/backend/writable 目录当前完全不存在(仅根级 w2r.site/writable/ 是抓取脚本的临时目录),需从生产拷回并修复 demo.w2r.site/shared/dam-writable 悬空软链 | DAM | 2 | 等对方给媒体归档 |
| 1.8 | 前端可构建性验证:两个前端在受控 Node 版本下构建,产物与仓库内 backend/public/AdM、frontend/dist 比对差异(注意 admin-frontend/vite.config.js:11-12 的 outDir 直写后端 public 且 emptyOutDir: true) | 前端 | 3 | — |
| 1.9 | 密钥轮换:w2r.site/backend/.env:37 的 JWT_SECRET、:61 的 figma.token、:27-28 与 demo.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-62 补 jti 撤销校验与 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.4 | admin 前端补齐:工作流审批 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.5 | demo 收口:删掉落选的那套接口实现、错误路径改真实 5xx(backend/public/api.php:142,157,166)、补 catch-all 路由、DAM 资产接口补归属校验与流式输出、去掉 Figma MCP 外链(frontend/src/assets/figma/mcp-assets.js:59-62) | demo | 12 | 故障可见 + 无外链裂图 |
| 2.6 | 破坏性命令加护栏:AuditAcceptanceExtended.php:151 的 truncate()、AuditAcceptance.php:50 的伪造 sys_admin token、RevertPagesToDraft.php:29 默认操作人 'test'、DeleteVideoMp4Assets.php —— 全部加环境白名单 + 强制二次确认,或直接从 app/Commands/ 移出 | CLI | 4 | php 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。
不能单人的四个理由(不是"工作量大"这么泛):
- 技能跨度真实存在:CI4 4.7 的 DataCaster 语义、Vue3 组合式 API、MySQL DDL/索引、Playwright 抓取、nginx + 宝塔 + PHP-FPM。前三项之外还要能读 Python。单人具备全部且都到"能改不出事"的水平,市场上是资深架构师价位,比配两个人贵。
- 零测试环境下必须交叉复核:
tests/只有 5 个 Figma 单测。任何对鉴权、事务、时间字段的改动,单人自审等于没审 —— 而这三类恰恰是本项目的高发区。 - 阶段一有强并行需求:1.3 生产测绘(等 SSH)、1.4 迁移修复(不等)、1.8 前端构建验证(不等)是三条独立轨。单人做只能串行,日历时间从 3 周拉到 7 周以上,而域名到期倒计时不会等。
- 单人接手=复制原开发者的单点风险。整个 git 历史的提交者只有一个人(
Arwen Fu <arwenfu@gmail.com>,且是私人邮箱),项目今天的困境正是这么来的。再来一次是管理失误。
建议配置
| 角色 | 人数 | 负责模块 | 技能要求(硬) |
|---|---|---|---|
| 后端主力 / 技术负责人 | 1.0 | backend/app/Services、Models、Database/Migrations、Commands、鉴权改造 | PHP 8.2+、CodeIgniter 4.7(必须懂 Model 生命周期与 $casts 的 DataCaster 契约)、MySQL 8 DDL 与索引、事务边界设计 |
| 全栈前端 | 1.0 | admin-frontend(59 文件 / 13,656 行)+ demo.w2r.site/frontend | Vue 3 组合式 API、Vite 构建链、Element Plus;能读 PHP 接口定位问题 |
| 运维 / 安全 | 0.5(阶段一按 1.0 投入 2 周) | nginx / 宝塔 / PHP-FPM / cron / 备份 / 证书 / 等保材料 | Linux 运维、nginx rewrite(要把 .htaccess 逻辑翻成 nginx)、MySQL 备份恢复演练 |
| 数据 / Python | 0.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 人天)
排序规则:可被利用或已在损坏 > 阻塞恢复 > 工时短。
| # | 缺陷 | 位置 | 净工时 | 为什么排在这 | ||
|---|---|---|---|---|---|---|
| 1 | DAM 上传 upload_id 未校验直接拼路径 → 任意路径写文件(可 getshell) | w2r.site/backend/app/Services/DamUploadService.php:106,111 | 3h | 与 #2 串联即为 RCE。修复前不得给任何临时账号 DAM 权限 | ||
| 2 | 资产类型判定用 OR 而非 AND(`if ($extOk \ | \ | $mimeOk)`) | w2r.site/backend/app/Services/DamUploadService.php:469 | 2h | 扩展名与 MIME 中一个即放行 |
| 3 | CSRF 全局关闭 + Session 兜底认证 | w2r.site/backend/app/Config/Filters.php:80-84('csrf' 被注释)配合 app/Filters/AuthFilter.php:31-35 | 6h | 全部写接口可被跨站伪造 | ||
| 4 | ContentController 五个写接口零权限码 | w2r.site/backend/app/Controllers/Api/ContentController.php:163,203,304,474,705(全文 requirePermission grep 零命中) | 4h | 任意登录账号可改删全站内容 | ||
| 5 | ChannelController 四个写接口零权限码 | w2r.site/backend/app/Controllers/Api/ChannelController.php:43,111,164,237 | 3h | 任意登录账号可改站点导航 | ||
| 6 | JWT 撤销表只写不读 | w2r.site/backend/app/Filters/AuthFilter.php:12-62(全文 68 行,无 revoked_at 读取) | 6h | 登出、踢并发、禁用账号三条控制对已签发 token 全部无效 | ||
| 7 | MFA 可被无条件重置绕过 | w2r.site/backend/app/Controllers/Api/AuthController.php:422 | 4h | 第二因子形同虚设 | ||
| 8 | 工作流四条路径全部 500 | w2r.site/backend/app/Services/WorkflowService.php:280-288 的 now(): string,11 个调用点 | 2h | 核心功能当前不可用 | ||
| 9 | 迁移 100031 硬编码 created_by = 1 + RESTRICT 外键 | w2r.site/backend/app/Database/Migrations/2026-03-04-100031_...php:35 | 3h | 干净库跑不完,灾备无路径 | ||
| 10 | audit:accept-ext TRUNCATE 生产表 / audit:accept 伪造 sys_admin token | w2r.site/backend/app/Commands/AuditAcceptanceExtended.php:151、AuditAcceptance.php:50 | 4h | 名字像测试、实为生产写操作,出现在 php spark list 里 | ||
| 11 | publish:revert-pages-to-draft 无鉴权无确认、默认操作人 'test' | w2r.site/backend/app/Commands/RevertPagesToDraft.php:29 | 3h | 一条命令全站下线,审计责任落到测试账号 | ||
| 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 | ||
| 14 | demo api.php 所有失败路径返回 success: true, data: [] | demo.w2r.site/backend/public/api.php:142,157,166 | 3h | 故障静默降级为"没有数据",无任何 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 = development(w2r.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-18 的 sitecore.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/.env 与 demo.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-assets 与 reset_sitecore_dam_assets.py 在阶段一即列入禁执行清单。
给负责人的三条硬建议
- 先固化基线,再谈修复。 12 个源文件从未进版本库、demo 整棵树无 git、远端比本地旧两个月 —— 在这件事做完之前投入的任何修复工时都有归零风险。这是 4 人天,应该是第一天开工的事。
- 域名与生产环境的归属问题,走管理线不走技术线,且要设死线。 2027-02-08 到期、
vgc.digirepub.com归属不明、生产机 SSH 未移交、内网 GitLab 权限未移交 —— 这四件事技术团队解决不了,但它们卡着阶段一的 1.3 / 1.7 共 5 人天,并直接决定项目能否存续。建议给甲方一个 2 周书面回复期。 - 不要把"本机已跑通"当成验收依据。 已知事实里的跑通用的是 Apache(
localdev/vhosts.conf),生产是 nginx(public/404.html:6),rewrite 规则全在 Apache 语法的.htaccess里。这两者对/AdMSPA 回退、/asset/:id、/api/分发的行为不同。真正的阶段一验收,必须在与生产同型的 nginx 上完成。