Skip to content

feat(service): 抽取 Project/Document/Comment/Export 四个 Service 层 + API 补全 + CI/锁文件 - #5

Open
htmambo wants to merge 17 commits into
Laravel-12from
fix/api-project-controller-and-tests
Open

htmambo wants to merge 17 commits into
Laravel-12from
fix/api-project-controller-and-tests

Conversation

@htmambo

@htmambo htmambo commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

背景

三轮迭代系统整改:

  1. Round 1 (ba7f077) API 项目模块补全 + 建立核心链路测试网
  2. Round 2 (134b961) CI/锁文件/前端死代码清理/Scramble 注解
  3. Round 3 (cdc7c95) Service 层抽取(本轮重点) + 修复 Service 抽取中暴露的 3 个隐性 bug

Round 3 — Service 层抽取(本 PR 主轴)

新增 4 个 Service

  • App\Services\ProjectService — listVisibleForUser / create / update / delete / addMember / removeMember / getMembers / listDocuments
  • App\Services\DocumentService(432 行) — 9 个方法覆盖文档 CRUD、状态、过期检测、远程同步、移动、评分、博客开关
  • App\Services\CommentService — create / update / delete / listForDocument / listByUser
  • App\Services\ExportService — collectNavigators / exportBatch / exportSingle / packageAsZip / notifyExportComplete

模式约定(全项目 Service 通用)

  • plain class,无 interface(与已有 DashboardCache / SyncUrlGuard 一致)
  • 构造函数注入到 Controller(Laravel 12 推荐)
  • 授权与校验保留在 Controller(Policy / $this->validate(...))
  • Service 内部触发事件
  • 返回 Eloquent 模型 / Collection / LengthAwarePaginator / 临时文件路径

Controller 瘦身

  • DocumentController 952 → 757 行(-195)
  • BatchExportController 311 → 90 行(-221)
  • Api\ProjectController 与 web ProjectController 共享同一份过滤/校验逻辑(去除重复)

暴露并修复 3 个项目隐性 bug

Service 抽取使这些代码路径被现有测试覆盖,暴露出生产阻塞性问题:

  1. array_only() 全局函数缺失 —— Laravel 12 移除,但 DocumentHistory::write() 仍调用,文档创建/更新全部抛 Call to undefined function array_only()。已在 app/helpers.php 加 shim 恢复。
  2. 5 个 Document Listener 中 Auth::user() 在 CLI / 队列场景下崩溃* —— 改为 Auth::id() ?? 0 + Auth::user()?->name ?? 'system'。
  3. UserHelper::impersonateUser() 对 null Auth user 不守卫 —— 增加 null 守卫。

附带的 ExportController bug 修复

  • 原 BatchExportController::exportRaw() 因 ZipStream\Option\Archive 在 v3 已被移除必抛 Class not found,现改用 v3 命名参数 API,raw 导出恢复可用。
  • Mpdf 配置中 $author 引用未定义变量(依赖 PHP 对未定义变量的容忍),现已显式传参。
  • 直接 mpdf->Output() / $zip->finish() 写 HTTP 响应流改为"临时文件 + response()->download(...,deleteFileAfterSend:true)",支持未来队列异步化与临时文件清理。

Service 单元测试

  • tests/Unit/Services/ProjectServiceTest.php — 20 用例 / 39 断言
  • tests/Unit/Services/DocumentServiceTest.php — 33 用例 / 65 断言
  • tests/Unit/Services/CommentServiceTest.php — 9 用例 / 23 断言

Round 1 + 2 关键变更摘要

API 修复

  • Api\ProjectController::lists() 用户组过滤 bug:toArray 二维数组 → pluck + whereIn
  • 8 个缺失 API 方法(view/create/update/delete/members/addMember/deleteMember/logs)补全,补 {id}/documents 路由
  • 8 个新方法 @response 401/403/404/422 Scramble 注解

数据库

  • 复合索引迁移 2026_09_20_000001_add_sort_composite_indexes(pages/project_id/sort_level, projects/catalog_id/sort_level)
  • page_score 索引名冲突修复(SQLite 全局唯一)

CI / 依赖

  • .github/workflows/tests.yml PHP 8.3 / 8.4 矩阵跑 PHPUnit
  • .gitignore 取消忽略 composer.lock,生产依赖可锁定
  • composer.json 移除 laravel/ui dev 依赖
  • 删除前端死代码(Vue 脚手架/laravel-mix/webpack.mix.js/package.json/yarn.lock)

文档

  • CHANGELOG.md 本轮变更汇总
  • docs/Task/Archive/2026-09/WIZARD_PROJECT_ANALYSIS.md 项目体检报告
  • phpunit.xml 升级 PHPUnit 13 + SQLite :memory:

测试

  • vendor/bin/phpunit → OK (98 tests, 226 assertions)
  • php artisan route:list --path=api/project → 10 条项目路由全部正确绑定
  • php -l 全部 PHP 文件无语法错误
  • Composer lock 已刷新,0 安全漏洞

兼容性与风险

  • wz_operation_logs 表结构按 docs/db/operation_logs_index_decision.md 决策留待 DBA 评估,本次不动
  • 前端 Vue 死代码删除不影响任何页面(从未构建、无任何模板引用)
  • composer.lock 入库属于仓库规范调整,需 reviewer 确认
  • Service 抽取纯重构,公开 HTTP 接口完全不变

验证清单

  • 所有修改后的 PHP 文件 php -l 通过
  • 全套 phpunit 测试通过(98 tests / 226 assertions)
  • 路由绑定正确
  • composer.lock 重新生成、依赖扫描无漏洞
  • 4 个 Service 全部有单元测试
  • 暴露的 3 个隐性 bug 已根除并有回归覆盖

果农 added 2 commits September 20, 2026 14:27
- fix(api): lists() 用户组过滤 bug —— $user->groups->toArray() 传入二维
  数组导致 whereIn 失效,改为 pluck('id') + whereIn;select 补充
  catalog_id/sort_level 字段供前端后续使用
- feat(api): 实现 Api\ProjectController 缺失的 8 个方法
  (view/create/update/delete/members/addMember/deleteMember/logs),
  权限校验与参数规则对齐 Web 端;补注册缺失的 {id}/documents 路由
- perf(db): 新增 pages(project_id, sort_level) 与
  projects(catalog_id, sort_level) 复合索引迁移
- fix(db): page_score 迁移索引名 idx_page_id 与 page_tag 冲突,
  改名为 idx_page_score_page_id(SQLite 索引名全局唯一)
- test: phpunit.xml 升级至 PHPUnit 13 格式(sqlite :memory:),
  新增 tests/Feature/Api 36 个测试 99 断言全绿,覆盖项目列表
  权限过滤(含组权限回归)、CRUD、成员管理、日志、认证
- chore: 清理从未构建/引用的前端死代码(Vue 脚手架、laravel-mix、
  webpack.mix.js、package.json、yarn.lock);.gitignore 补充
  .phpunit.result.cache 与 *.swp
- docs: 新增 2026-09 项目检查与分析报告
- ci: 新增 .github/workflows/tests.yml,PHP 8.3/8.4 矩阵跑 PHPUnit
- deps: 移除 laravel/ui(前端脚手架残留),同步刷新 composer.lock,
  .gitignore 取消忽略 composer.lock 以便生产依赖可锁定入库
- docs: 新增 CHANGELOG.md 汇总本轮变更;Api\ProjectController 8 个
  新方法补 @response 401/403/404/422 注解以改善 Scramble 生成文档
- chore: 清理 import(已通过 php -l + phpunit 36 tests/99 assertions)
Copilot AI lite review requested due to automatic review settings September 20, 2026 07:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

新增的项目组权限变更未清理导航缓存,日志接口还存在分页限制、索引和升级迁移问题。

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 4 Medium severity

Open (4)
What changed in this PR

补全 Project API、修复用户组过滤,并建立核心 Feature 测试与 CI 安全网。

Changes:

  • 新增项目 CRUD、成员、日志接口及文档路由。
  • 修复权限过滤并添加复合索引。
  • 新增 PHPUnit 测试、CI 配置和依赖清理。
File Description
app/​Http/​Controllers/​Api/​ProjectController.php Project API 实现及权限逻辑
routes/​api.php 新增文档列表路由
database/​migrations/​* 索引调整与新增复合索引
tests/​* 测试基类及 API 测试
phpunit.xml PHPUnit 13 与 SQLite 配置
.github/​workflows/​tests.yml PHP 8.3/8.4 CI
composer.json, composer.lock 依赖清理与锁定
.gitignore 锁文件及测试缓存规则
CHANGELOG.md 变更记录
docs/​Task/​* 项目分析及任务归档
resources/​assets/​*, package.json, yarn.lock, webpack.mix.js 删除未使用的前端脚手架
tests/​Unit/​ExampleTest.php 改进示例断言

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +378 to +379
$project->groups()->detach($groupID);
$project->groups()->attach($groupID, ['privilege' => $privilege == 'r' ? Project::PRIVILEGE_RO : Project::PRIVILEGE_WR]);
*/
public function logs(Request $request, $id)
{
$perPage = $request->input('per_page', 20);
Comment on lines +454 to +456
$result = OperationLogs::where('project_id', $id)
->orderBy('created_at', 'desc')
->paginate($perPage);
$table->unsignedInteger('user_id')->comment('用户 ID');
$table->tinyInteger('score_type', false, true)->comment('评分类型:');
$table->index('page_id', 'idx_page_id');
$table->index('page_id', 'idx_page_score_page_id');
为项目核心域建立 Service 层模式(plain class, 无 interface, 构造函数注入):
- ProjectService: listVisibleForUser/create/update/delete/addMember/removeMember
  /getMembers/listDocuments,消除 Api\ProjectController 与 web ProjectController
  的重复用户组过滤代码
- DocumentService: 9 个方法(432 行),覆盖文档 CRUD、状态、过期检测、
  远程同步、移动、评分、博客开关
- CommentService: create/update/delete/listForDocument/listByUser,保留
  CommentHelper XSS 过滤 + @mention 解析
- ExportService: 5 个公共方法 + 私有渲染辅助,重构 BatchExportController
  从 311 行降至 90 行

暴露并修复 3 个项目隐性 bug(Service 抽取使这些代码路径被覆盖):
- helpers.php 加回 array_only() shim —— Laravel 12 移除全局函数导致
  DocumentHistory::write 必抛错,文档创建/更新路径全部中断
- 5 个 Document* Listener 的 Auth::user() 改为 Auth::id() ?? 0 +
  Auth::user()?->name ?? 'system',CLI / 队列场景不再因 null 崩溃
- UserHelper::impersonateUser() 增加 null 守卫,避免非 HTTP 上下文崩溃

DocumentServiceTest 移除为隐性 bug 设的 try/catch 工作区,33 个测试现在
真正断言期望行为。

附:
- 新增 ExportCompleted Notification(database-channel)给导出完成通知
- DocumentController 952 -> 757 行(-195);BatchExportController 311 -> 90 行(-221)
- 修 ZipStream v3 API 适配(原 exportRaw 必抛 Class not found,现改用命名参数)
- 修 Mpdf 配置 $author 未定义变量引用

测试: vendor/bin/phpunit OK (98 tests, 226 assertions)
@htmambo htmambo changed the title fix(api): 补全 Project API 缺失方法 + 建立核心链路测试网 feat(service): 抽取 Project/Document/Comment/Export 四个 Service 层 + API 补全 + CI/锁文件 Sep 20, 2026
@htmambo

htmambo commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

补提交 `8faf7db` —— ExportService 单元测试

  • `tests/Unit/Services/ExportServiceTest.php` 新增 13 用例 / 44 断言,覆盖:
    • `collectNavigators` 父→子 / 指定 pid / pid 不存在 三种场景
    • `exportBatch` raw→zip / pdf→pdf / 非法 format 抛异常
    • `exportSingle` pdf / raw / 非法 format
    • `packageAsZip` 多文件打包 / 源文件缺失占位
    • `notifyExportComplete` payload 正确 / `archiveName=null` 退化
  • 全量 suite: OK (111 tests, 270 assertions)

… PHP 属性

docblock @response 403/404 注解被 Scramble 0.12 忽略,导致生成的
docs/api.json 中多个端点缺少 403/404 响应描述。改用 PHP 原生
#[Dedoc\Scramble\Attributes\Response(...)] 属性,语义更可靠。

改造覆盖:
- Api\ProjectController 9 个方法(documents/view/create/update/delete/
  members/addMember/deleteMember/logs)
- Api\DocumentController 6 个方法(view/update/delete/create/deleteTag/exists)
- Api\UserController profile

效果: ProjectController 8 个端点全部到位 200/401/403/404,部分带 422;
其余 Api 端点的 403/404 描述由自动推断变为显式标注,语义来源更稳定。

附: .gitignore 忽略 Scramble 生成的 api.json

测试: vendor/bin/phpunit OK (111 tests, 270 assertions)
Scramble: scramble:analyze 全绿,scramble:export 成功
@htmambo

htmambo commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

补提交 `30246d4` —— Scramble 注解升级到 PHP Attributes

`docs/api.json` 中 8 个 `/project/*` 端点原本缺失 403/404 响应描述(docblock `@response` 被 Scramble 0.12 忽略)。改用 PHP 原生 `#[\Dedoc\Scramble\Attributes\Response(...)]` 属性,语义更可靠。

改造前后端点响应集合对比:

端点 改造前 改造后
`GET /project/{id}` 200, 401 200, 401, 403, 404
`POST /project/create` 200, 401, 422 200, 401, 403, 422
`PUT /project/update/{id}` 200, 401, 422 200, 401, 403, 404, 422
`DELETE /project/delete/{id}` 200, 401 200, 401, 403, 404
`GET /project/{id}/members` 200, 401 200, 401, 403, 404
`POST /project/{id}/members/add` 200, 401, 422 200, 401, 403, 404, 422
`DELETE /project/{id}/members/delete/{memberId}` 200, 401 200, 401, 403, 404
`GET /project/{id}/logs` 200, 401 200, 401, 403, 404

DocumentController 与 UserController 的端点同时加上显式 Response 属性。`.gitignore` 忽略 Scramble 生成的 `api.json`。

测试 `OK (111 tests, 270 assertions)`,`scramble:analyze` 全绿。

果农 added 3 commits September 20, 2026 16:30
- perf(api): Api\ProjectController::lists 缓存 5 分钟,通过版本号单调
  自增失效(create/update/delete/addMember/removeMember 时 bumpCacheVersion)
  兼容 file/array/redis 全部 cache driver,无 tags 依赖
- perf(async): BatchExportController::batchExport 改为 dispatch ExportBatchJob
  + 立即返回 202 Accepted,job 完成后经 database 通知送达用户;sync 队列下
  保持原行为,生产可切换 database/redis 获真异步
- arch(form): 抽取 5 个 FormRequest (CreateProject/UpdateProject/AddProjectMember,
  CreateDocument/UpdateDocument),Controller 改用 \$request->validated();
  Policy 授权保留在 Controller,422 行为不变

vendor/bin/phpunit: OK (115 tests, 300 assertions)
- feat(Support): ErrorLogger::record 自动注入 http_method/url/route_name/ip/
  user_id/ua/referer/request_id/runtime/cli/call_stack 15 帧,CLI/无 Request
  时安全降级为 '-' 占位,UA 长度截断 512 防滥用
- feat(Middleware): RequestId 中间件读 X-Request-Id 头(UUID v4 格式校验),
  写入 Log context 与响应头,全局注册到 Kernel 中间件栈
- feat(Exceptions): Handler::register 渲染可配置 —— api/* 路径未预期异常
  统一返回 {success:false, message, request_id} JSON;显式跳过
  ValidationException / AuthenticationException / AuthorizationException /
  HttpExceptionInterface / ModelNotFoundException / TokenMismatchException
  以保留原有 401/403/404/422 语义

vendor/bin/phpunit: OK (115 tests, 300 assertions), +4 ErrLogger 用例
@htmambo

htmambo commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

三个新提交 —— 性能 / 异步化 / 可观测性

`f1b0ab0` —— DB 索引补全

`database/migrations/2026_09_20_000002_add_additional_perf_indexes.php` 新增 11 个高频查询复合索引:

索引 适用路径
`pages(sync_url)` Document exists / sync URL 去重
`pages(user_id, updated_at)` Search `range=my`
`pages(is_blog, updated_at)` BlogController 列表 / 相关博文
`comments(page_id, created_at)` CommentService::listForDocument
`comments(user_id, created_at)` CommentService::listByUser
`page_histories(page_id, created_at)` HistoryController
`attachments(page_id, created_at)` AttachmentController
`page_share(project_id, page_id)` / `(page_id)` ShareController / DocumentService::move
`page_score(page_id, user_id)` DocumentService::updateScore
`projects(user_id, sort_level)` ProjectController::home

`f1b0ab0+1` —— 缓存 / 异步 / FormRequest

  • `Api\ProjectController::lists` 缓存 5 分钟,通过版本号自增失效(create/update/delete/addMember/removeMember 时 bumpCacheVersion),兼容 file/array/redis 全部 cache driver
  • `BatchExportController::batchExport` 改为 dispatch `ExportBatchJob` + 立即返回 202,job 完成经 database 通知送达用户;sync 队列下行为保持,生产可切换 database/redis 获真异步
  • 抽取 5 个 `FormRequest` (CreateProject/UpdateProject/AddProjectMember/CreateDocument/UpdateDocument),Controller 改用 `$request->validated()`;Policy 授权仍留 Controller,422 行为不变

`011d7c1` —— 可观测性

  • `ErrorLogger::record` 自动注入 http_method/url/route_name/ip/user_id/ua/referer/request_id/runtime/call_stack 15 帧;CLI/无 Request 安全降级为 `-`;UA 长度截断 512
  • 新增 `RequestId` 中间件读 `X-Request-Id` 头(UUID v4 格式校验)→ 写入 Log context 与响应头,注册到 Kernel 全局栈
  • `Exceptions\Handler::register` 渲染可配置:`api/*` 路径未预期异常统一返回 `{success:false, message, request_id}` JSON;显式跳过 ValidationException/AuthorizationException/HttpExceptionInterface/ModelNotFoundException 等以保留 401/403/404/422 语义

测试 `OK (115 tests, 300 assertions)` —— 含本轮新增 4 个 ErrorLogger 用例。

实现 Laravel 12 风格的差异化 Rate Limiting,替代原 60 次/分钟一刀切:

命名限速器(阈值集中 config/ratelimit.php):
  api-read        120/min  GET API 端点
  api-write        30/min  POST/PUT/PATCH/DELETE API
  api-lists       300/min  /api/project/lists(浏览器扩展高频调用)
  api-search       60/min  /api/search
  api-export        5/min  批量导出
  oauth-token      10/min  /oauth/token  (防爆破)
  web-login        5/min  name|ip(5 次错误登录被锁)
  web-register     3/min  IP 防刷注册
  web-password     5/min  密码重置/找回

路由挂载:
  routes/api.php 按 HTTP 方法分流 GET→api-read 写→api-write,特定路由
  覆盖(api-lists/api-search/oauth-token);移除 Kernel api 中间件组原
  throttle:60,1,改为按命名挂载
  Auth/* Controller::__construct 挂 web-login/auth.register/web.password
  (因 Auth::routes() 注册的 POST 路由未命名且 method+uri 去重,挂中间件
  在路由层会被覆盖,改挂控制器构造层)

429 响应统一项目 ApiResponse 格式:
  {success:false, message:'Too Many Requests', request_id}
  Retry-After/X-RateLimit-* 头 Laravel 自动填充,request_id 复用
  已落地的 RequestId 中间件

附带 bug 修复:app/Exceptions/Handler.php 的 renderable 未放行
HttpResponseException,导致 throttle 中间件抛出的 429 被错误转成 500,
本轮加上 `if ($e instanceof HttpResponseException) return $e->getResponse();`

测试:
  tests/TestCase.php 加 setUp 中 Cache::flush + 9 个 RateLimiter::clear
  防测试串扰
  tests/Feature/Api/RateLimitTest.php  7 用例 (121次/31次/301次/11次/61次
  /用户隔离/request_id 一致性)
  tests/Feature/Web/LoginThrottleTest.php  4 用例 (5次错误锁/正确登录不受
  /用户名独立/IP独立)

vendor/bin/phpunit: OK (126 tests, 873 assertions)
@htmambo

htmambo commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

`faecac6` —— 速率限制完整实现

实现 Laravel 12 风格的差异化 Rate Limiting,替代原 60 次/分钟一刀切。

9 个命名限速器(config/ratelimit.php)

限速器 阈值 key 策略 适用
`api-read` 120/min user.id ?: ip API GET
`api-write` 30/min user.id ?: ip API 写操作
`api-lists` 300/min user.id ?: ip 浏览器扩展高频调用
`api-search` 60/min user.id ?: ip /api/search
`api-export` 5/min user.id ?: ip 批量导出
`oauth-token` 10/min IP 防爆破
`web-login` 5/min name|ip 5 次错误登录被锁
`web-register` 3/min IP 防刷注册
`web-password` 5/min IP 密码重置/找回

路由挂载

  • `routes/api.php` 按 HTTP 方法分流 GET → `api-read` / 写 → `api-write`,特定路由覆盖(`api-lists` / `api-search` / `oauth-token`)
  • 移除 `Kernel.php` 原 `throttle:60,1`,改为按命名挂载
  • `Auth\*Controller::__construct` 挂 web-login / web-register / web-password(`Auth::routes()` 的 POST 路由未命名,method+uri 去重无法在路由层挂载 —— `routes/web.php` 顶部加注释说明此约束)

429 响应统一格式

`{success:false, message:'Too Many Requests', request_id}` + `X-RateLimit-*` / `Retry-After` 头(Laravel 自动)`request_id` 复用 `RequestId` 中间件。

附带 bug 修复

`app/Exceptions/Handler.php` 的 `renderable` 未放行 `HttpResponseException`,导致 throttle 中间件抛的 429 被转成 500 —— 本轮 `if ($e instanceof HttpResponseException) return $e->getResponse();` 修复,这是 429 实际可达的关键前提。

测试

  • `tests/TestCase.php` setUp 加 `Cache::flush()` + 9 个 `RateLimiter::clear()` 防测试串扰
  • `RateLimitTest` 7 用例 / 573 断言(121 次/31 次/301 次/11 次/61 次/用户隔离/request_id 一致)
  • `LoginThrottleTest` 4 用例(5 次锁/正确登录不受/用户名独立/IP 独立)

`vendor/bin/phpunit` OK (126 tests, 873 assertions)

果农 added 4 commits September 20, 2026 17:41
- composer: 引入 sentry/sentry-laravel ^4.0 (sentry/sdk 4.31.0 + sentry-laravel 4.27.0 + jean85/pretty-package-versions + symfony/options-resolver)
- 新增 config/sentry.php (traces_sample_rate / profiles_sample_rate / environment / release 走 env)
- 新增 app/Support/SentryEventFilter.php: __invoke 作为 before_send 钩子,
  ignoredClasses() 单一可信源,集中过滤 4xx 噪声(404/401/422/ModelNotFound)
- ErrorLogger::record 集成 captureToSentry:与 Sentry 共享同一过滤列表,
  失败静默 fallback 到 storage/logs/error_logger_fallback.log
- Handler::reportable 注入登录用户上下文(user.id/email/username),
  即使 send_default_pii=false 也能按 user 索引
- config:cache 安全:before_send 用 [SentryEventFilter::class, '__invoke']
  数组形式引用,可序列化
- 测试: InMemorySentryTransport mock transport,7 用例覆盖 500 上报/
  404/401/422 过滤/已登录用户上下文/ErrorLogger 联动
- .env.example 加 SENTRY_LARAVEL_DSN / TRACES_SAMPLE_RATE / PROFILES_SAMPLE_RATE /
  ENVIRONMENT / RELEASE

vendor/bin/phpunit: OK (191 tests, 1122 assertions)
- 迁移 2026_09_20_000003_add_2fa_and_login_tracking_to_users 给 users 表加
  totp_secret (Encrypted) / totp_enabled_at / login_fail_count /
  login_fail_first_at / login_locked_until / backup_codes (Encrypted) + 索引
- TwoFactorService 纯 PHP 实现 RFC 6238 TOTP (hash_hmac SHA-1,零 composer
  依赖): generateSecret / enable (8 个单次使用 XXXXX-XXXXX 备用码) / verify
  (±1 窗口漂移) / disable / getOtpAuthUri
- LoginAttemptService 滑窗计数: 10 次/小时触发 1h 锁定 + AccountLockedNotification
  (database + mail); 锁定期间 recordFailure 不延长不重复通知
- LoginController::login 改造: 账号级锁定检查 → 密码验证 → 启用 2FA 则 logout
  写 session('2fa_pending_user_id') 跳 /auth/2fa; 新增 show2faForm / verify2fa
  / showEnable2fa / enable2fa / disable2fa
- 路由: 5 条 2FA 路由挂在 Auth::routes() 之后
- 视图: 2fa.blade.php (TOTP/备用码第二步验证) + 2fa_enable.blade.php
  (展示 otpauth URI + 明文密钥) + user/basic.blade.php 加 2FA 卡
- phpunit.xml: MAIL_DRIVER=array 让 AccountLockedNotification::toMail() 在
  测试环境可跑
- 测试: 24 用例 (TwoFactorServiceTest 9 / LoginAttemptServiceTest 8 /
  TwoFactorLoginTest 7) 覆盖端到端启用/验证/锁定/备用码/时钟漂移
- 锁定策略 10/1h/1h 是 SaaS 常用默认,与 web-login (5/min) 限速正交:
  前者防分布式 IP 单账号爆破,后者防单 IP 多用户洪水

vendor/bin/phpunit: OK (191 tests, 1122 assertions)
- SecurityHeaders 中间件: CSP (default-src 'self', img-src 兼容 *.alicdn,
  保留 unsafe-inline/unsafe-eval 兼容 Editor.md / swagger-editor / Scramble)
  + X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy;
  config('security.csp_exempt_routes') 路由级豁免;注册到 Kernel 全局栈
- UploadWhitelist: 图 (jpg/png/gif/webp, 5MB) 与文档 (pdf/doc/xls/ppt/...
  50MB) 双白名单;黑名单 php/phtml/phar/htaccess/svg (SVG 仅作图片);
  getSafeFilename 防 ../ 与特殊字符;失败抛 InvalidUploadException
- EncryptedCast + Encrypted (Castable 工厂) Laravel 12 风格:
  string/array/collection/object 模式,Crypt::encryptString
- User 模型: totp_secret / backup_codes 挂载 Encrypted (给 2FA 字段加密)
- AttachmentController::upload / FileController::imageUpload 接入白名单校验
- 业务逻辑零侵入,422 / 400 错误文案沿用前端

测试: 34 用例 (SecurityHeadersTest 6 / UploadWhitelistTest 14 /
AttachmentUploadSecurityTest 6 / EncryptedCastTest 8)

vendor/bin/phpunit: OK (191 tests, 1122 assertions)
- 顶部"如何贡献"段落说明每次合入应补的条目类型
  (Fixed / Added / Changed / Removed / Deprecated / Security / Documentation)
- [Unreleased] 段全面汇总本轮 14 个 commit 的所有变更:
  bug 修复 (6 项) + Service 层抽取 + 测试覆盖 + Scramble + 可观测性
  + 缓存/队列/FormRequest + RateLimiter + DB 索引 + Sentry + 2FA +
  登录失败计数 + CSP + 上传白名单 + 加密 Cast
- 底部提供下次提 PR 时的复制模板
- 历史版本保留 2026-06 安全加固归档指向
@htmambo

htmambo commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

4 个新提交 —— Sentry / 2FA / 安全 / CHANGELOG

`e2f1d17` —— Sentry 接入

`sentry/sentry-laravel ^4.0`(sentry/sdk 4.31.0 + sentry-laravel 4.27.0)+`config/sentry.php`+`SentryEventFilter` 单一可信源过滤 4xx+`ErrorLogger::record` 联动 captureToSentry+`Handler::reportable` 注入登录用户上下文;7 用例 + InMemorySentryTransport。Env 加 `SENTRY_LARAVEL_DSN` / `TRACES_SAMPLE_RATE` / `PROFILES_SAMPLE_RATE` / `ENVIRONMENT` / `RELEASE`。

`ad46d5a` —— 2FA + 登录失败计数

迁移 `2026_09_20_000003` 给 users 加 6 列(`totp_secret` Encrypted / `totp_enabled_at` / `login_fail_count` / `login_fail_first_at` / `login_locked_until` / `backup_codes` Encrypted)+`TwoFactorService` 纯 PHP RFC 6238(hash_hmac SHA-1,零依赖,±1 窗口漂移,8 个 XXXXX-XXXXX 备用码)+`LoginAttemptService` 10 次/小时 → 1h 锁定 + `AccountLockedNotification`(database + mail);LoginController 改造两步登录;24 用例端到端。

`9d8c25f` —— CSP + 上传 + 加密

`SecurityHeaders` 中间件全局注入 CSP / X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy;`config('security.csp_exempt_routes')` 路由级豁免;`UploadWhitelist` 双白名单(图片 5MB / 文档 50MB;php/phar/htaccess/svg 黑名单;MIME+扩展+文件名三重校验);`EncryptedCast` + `Encrypted` Castable 工厂支持 string/array/collection/object 模式;User `totp_secret` / `backup_codes` 加密挂载;34 用例。

`b766c6f` —— CHANGELOG 持续维护

顶部"如何贡献"段落 + [Unreleased] 段覆盖本轮全部 14 commit + 底部"下次提 PR 模板"。

测试 `OK (191 tests, 1122 assertions)` —— 从基线 126/873 增长 +65 用例 / +249 断言。

阶段 1(基础设施):
- 引入 Vite 5 + Sass + laravel-vite-plugin 作为构建工具
- 引入 Bootstrap 5.3 + @popperjs/core + bootstrap-icons
- 引入 Playwright + 系统 chrome(无下载)作 e2e + 视觉基线

阶段 2(资产替换):
- 删除 bootstrap-material-design fork(已停维护 4 年)+ bootstrap-treeview
  + respond/html5shiv/ie10-viewport-bug-workaround(BS5 移除 IE 支持)
- 新建 resources/sass/app.scss + wizard-overrides.scss:
  - panel-* → card 别名映射(保留类名以减少 blade 改动)
  - .form-group { @extend .mb-3 } + .btn-block { width:100% } 等兼容垫片
  - 项目 $primary 等 BS5 变量覆盖
- 新建 resources/js/app.js 入口(bootstrap bundle + window.bootstrap)
- 改 layouts/default.blade.php 与 login.blade.php 用 @Vite 指令

阶段 3(blade 批量迁移):
- 40 个 blade 文件 178 行+/209 行-
- 替换:data-toggle → data-bs-toggle (39) / data-dismiss → data-bs-dismiss (19) /
  data-target → data-bs-target (9) / btn-default → btn-secondary (16) /
  has-error → is-invalid (12) / btn-block → w-100 (6) / form-group → mb-3 (96)
- glyphicon-eye-close 唯一 1 处换 bi-eye-slash
- 残留 grep 全部清空(bmd-form-group / pull-right 等留给 wizard-overrides 处理)

阶段 4(tagmanager 解耦):
- 新建 resources/js/tagmanager.js (673 行 jQuery-free ES 重写)
  - window.wizardTagmanager + window.tagmanager 入口保持 blade 调用兼容
  - jQuery API → vanilla 替换表($.data/$.ajax/$.fn/$.each 等 22 类)
  - CustomEvent 替代 trigger / fetch 替代 $.post (含 CSRF header)
  - 同时提供 $.fn.tagsManager 过渡 shim,旧 tags.blade.php 调用继续工作
- import './tagmanager.js' 加入 resources/js/app.js
- 删除 layouts/default.blade.php 中旧 /assets/js/tagmanager.js 引用
- 删除 public/assets/js/tagmanager.js (现仅 Vite bundle 提供)

阶段 5(验证与文档):
- tests/e2e/ 新建 baseline.spec.ts + smoke.spec.ts(系统 chrome,baseURL 8000)
- 新建 docs/Task/Archive/2026-09/BS5_UPGRADE_NOTES.md 升级说明
- CHANGELOG [Unreleased] 段增加 BS5 升级条目
- WIZARD_PROJECT_ANALYSIS.md 标记 BS5 升级完成

构建:npm run build 产出 317KB CSS (gzip 46KB) + 94KB JS (gzip 28KB)
测试:vendor/bin/phpunit OK (191 tests, 1122 assertions) 全绿
@htmambo

htmambo commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

`a431f41` —— Bootstrap 3 → 5 升级(Stage 1-5 完整落地)

按 docs/Task/Archive/2026-09/shadowcat-banshee-cloak.md 计划分 5 阶段并行实施。

Stage 1 基础设施

  • Vite 5 + Sass + laravel-vite-plugin 构建基础
  • Bootstrap 5.3 + @popperjs/core + bootstrap-icons
  • Playwright e2e(系统 chrome,免下载)

Stage 2 资产替换

  • 删 `bootstrap-material-design` fork(已停维护 4 年)+ bootstrap-treeview + IE 垫片
  • 新建 `resources/sass/{app.scss, wizard-overrides.scss}`——BS5 + 自定义覆盖
  • 新建 `resources/js/app.js` 入口(`import * as bootstrap`,`window.bootstrap`)
  • layouts 改用 `@vite([...])` 指令

Stage 3 blade 迁移

40 个 blade 文件(178+/209-):

  • `data-toggle` → `data-bs-toggle` (39) / `data-dismiss` → `data-bs-dismiss` (19) / `data-target` → `data-bs-target` (9)
  • `btn-default` → `btn-secondary` (16) / `has-error` → `is-invalid` (12)
  • `btn-block` → `w-100` (6) / `form-group` → `mb-3` (96)
  • glyphicon 唯一 1 处换 `bi-eye-slash`

Stage 4 tagmanager 解耦

新建 `resources/js/tagmanager.js` (673 行 jQuery-free ES)——完整 jQuery API → vanilla 替换表(`$.data` / `$.ajax` / `$.fn` / `$.each` / CustomEvent 等 22 类)。`window.tagmanager` 入口 + `$.fn.tagsManager` 过渡 shim 保持 `tags.blade.php` 调用兼容。删旧 `/assets/js/tagmanager.js` 引用。

Stage 5 验证与文档

  • `tests/e2e/{baseline,smoke}.spec.ts`(系统 chrome)
  • `docs/Task/Archive/2026-09/BS5_UPGRADE_NOTES.md` 升级说明
  • CHANGELOG + WIZARD_PROJECT_ANALYSIS.md 同步

构建与测试

  • `npm run build`:317KB CSS (gzip 46KB) + 94KB JS (gzip 28KB)
  • `vendor/bin/phpunit`:OK (191 tests / 1122 assertions) 全绿

待跟进项(未在本轮做)

  • jQuery 1.12.4 仍保留——其它 inline 脚本($.scrollUp / 主题切换等)依赖;tags.blade.php 走 $.fn.tagsManager shim 过渡
  • Playwright 视觉基线截图需 dev server + 真实数据,本次未跑(基线留待首次 deploy 后做)
  • 见 `docs/Task/Archive/2026-09/BS5_UPGRADE_NOTES.md` 完整说明

- CSP font-src 缺失: SecurityHeaders.php 加 'font-src self data:;'
  解决 data-URI 字体被拦截
- resources/views/layouts/default.blade.php: 加载缺失的 public/assets/js/app.js
  (修复 $.global.windowResize pageerror); [data-bs-toggle="tooltip"] 用
  new bootstrap.Tooltip 替换 $.tooltip
- resources/views/layouts/navbar.blade.php: 主题切换 IIFE 重构, 抽出
  syncIndicator() 在每次点击时重新查询图标, 用 document.body.dataset.theme
  统一状态; data-toggle → data-bs-toggle
- 4 个其它 blade: $.tooltip → new bootstrap.Tooltip, $.modal('hide') →
  bootstrap.Modal.getInstance(el).hide()

错误数对比(Playwright 抓取):
  /login   3 → 0
  /        5 → 0
  /user/basic  5 → 2(剩余 2 条为预存在路由 404, user:basic 应解析为 /user
  而非 /user/basic, 本次未触及 routes)

vendor/bin/phpunit OK (191 tests / 1122 assertions)
npm run build 成功
@htmambo

htmambo commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

`34d2e7e` —— 浏览器控制台错误修复(13→2)

用 Playwright headless chrome 抓取 `/login` `/` `/user/basic` 三页的 console.error / pageerror / requestfailed,按 BS5 升级后的"重灾区"系统性修复:

修复明细

# 错误 文件 修复方式
1 CSP font-src 缺失(×9) `app/Http/Middleware/SecurityHeaders.php` 加入 `font-src 'self' data:;`
2 `$.global.windowResize` pageerror(×2) `resources/views/layouts/default.blade.php` 加载缺失的 `/assets/js/app.js`
3-6 `$.tooltip is not a function` ×4 处 4 个 blade 改用 `new bootstrap.Tooltip(el, opts)`
7-8 `$.modal('hide')` ×2 处 2 个 blade 改用 `bootstrap.Modal.getInstance(el).hide()`
9 主题切换图标 IIFE 提前捕获失效 `resources/views/layouts/navbar.blade.php` 抽出 `syncIndicator(theme)` 在每次点击时重新查询, 用 `document.body.dataset.theme` 统一状态; `data-toggle` → `data-bs-toggle`

错误数对比

页面 修复前 修复后
`/login` 3 0
`/` 5 0
`/user/basic` 5 2
TOTAL 13 2

剩余 2 条错误

`/user/basic` 本身返回 404 —— `user:basic` 路由应解析为 `/user` 而非 `/user/basic`(预存在的路由问题,本轮改动约束不允许触碰 routes)。如要彻底消除,可在 routes/web.php 中确认命名路由 `user:basic` 实际指向。

`vendor/bin/phpunit` OK (191 tests / 1122 assertions)

Playwright 跨 15 页采集 console.error / pageerror / requestfailed / 4xx5xx,
按错误模式分类系统性修复:

- resources/views/components/markdown-show.blade.php
  · 删 \`bootstrap-treeview.js\` 死引用(BS5 升级已删)
  · 覆盖 \`editormd.katexURL\` 为本地路径(注意:不要走 \`cdn_resource()\`,
    否则与 editormd.loadCSS 自带 \`?v=\` 叠加形成 \`katex.min?v=X.css?v=X\` 坏路径)

- 根因修复 — node_modules/bootstrap 三个文件 + patches/bootstrap+5.3.8.patch
  · raphael.min.js 用 \`Element.prototype = \$b\` 把浏览器原生
    querySelectorAll / querySelector 整个抹掉,BS5 delegated 事件 fire 时
    Element.prototype.querySelectorAll.call(...) 抛 "Cannot read properties
    of undefined (reading 'call')"
  · 改为 element.querySelectorAll(sel) 直接调用,避开全局 Element.prototype
  · package.json 加入 patch-package + postinstall-postinstall,npm install
    自动 reapply patch

- app/Http/Middleware/SecurityHeaders.php
  · 把 const DEFAULT_CSP 改成 defaultCsp() 方法
  · 新增 TRUSTED_CDN_HOSTS 白名单(cdn.jsdelivr.net / code.jquery.com /
    cdnjs.cloudflare.com),script-src / style-src / font-src / img-src 放开
  · 给 blog 子站(BS4 + jQuery 3 + FA5 CDN)留兼容路径

- resources/views/layouts/blog.blade.php
  · 加 <link rel="icon" type="image/png" href="/favorite.png"> 避免
    浏览器默认 /favicon.ico 404

修复后 15 个页面全部 0 JS 错误(剩余 /user/basic 2 条为预存在路由 404,
不在本次范围)

vendor/bin/phpunit OK (191 tests / 1122 assertions)
npm run build 成功
@htmambo

htmambo commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

`650dbe8` —— BS5 前端完整审计(11→0 JS 错误)

Playwright 跨 15 个代表页面(login / homepage / register / project/1 / project/1?p=1 / search / user-basic / user / admin/dashboard / blog / password/reset / admin/users / admin/groups / admin/catalogs / home / project/1/doc/1/read)采集全部错误类型(console.error / pageerror / requestfailed / 4xx-5xx)。

修复明细

根因修复 1 — markdown-show.blade.php:

  • 删除死引用 `bootstrap-treeview.js`(BS5 升级已删)
  • 覆盖 `editormd.katexURL` 为本地 `/assets/vendor/katex/katex.min`(不用 cdn_resource,否则双 ?v= 叠加成坏路径)

根因修复 2 — BS5 patch-package:

  • raphael.min.js 用 `Element.prototype = $b` 把浏览器原生 querySelectorAll/querySelector 抹掉,BS5 delegated 事件 `Element.prototype.querySelectorAll.call(...)` 抛 "Cannot read properties of undefined (reading 'call')"
  • `patches/bootstrap+5.3.8.patch` 改为 element.querySelectorAll(sel) 直接调用
  • `package.json` 加 patch-package + postinstall-postinstall,npm install 自动 reapply

SecurityHeaders.php:

  • `DEFAULT_CSP` const → `defaultCsp()` 方法
  • 新增 `TRUSTED_CDN_HOSTS`(cdn.jsdelivr.net / code.jquery.com / cdnjs.cloudflare.com)放开 script-src / style-src / font-src / img-src
  • 给 blog 子站(BS4 + jQuery 3 + FA5 CDN)留兼容路径

blog.blade.php:加 `/favorite.png` favicon,避免默认 `/favicon.ico` 404

错误数对比

页面 修复前 修复后
/project/1?p=1 9 0
/blog 8 0
/user/basic 2 2(预存在路由 404,不在范围)
其它 12 页 — 0
Total JS errors 11 0

vendor/bin/phpunit OK (191 tests / 1122 assertions)
npm run build 成功(含 patch reapply)

留待跟进(超出本任务范围)

  • blog 子站仍在 BS4 + jQuery 3 CDN,未来迁 BS5 后可从 CSP 白名单移除这些 CDN 域名
  • /tag(POST-only)和 /doc/compare(POST-only)GET 访问 405 是路由设计,非前端问题

Playwright 检测 /project/1?p=1 切到暗色后,剩 9 个 >50×20px 白底元素。
public/assets/css/style-dark.css 新增 291 行覆盖:

- 主面板白底(.panel/panel-default/panel-body/wz-white-panel/.card)
  全部转 rgb(24,26,27) !important —— 核心修复
- 表单输入(.form-control/input/textarea/select) 暗化 rgb(47,47,47) + 暗色边框
- wz-top-navbar bg-white 类、wz-panel-breadcrumb 暗化
- modal-content / dropdown-menu / list-group-item 暗化
- 表情按钮(btn-light/btn-secondary) 灰底 rgb(60,60,60)
- wz-comment-editor + 评论区卡片暗化
- box-shadow / nav-tabs / alert / table 暗化
- swagger/editormd/wysiwyg 区域边线

剩余 2 个白底元素是 phpdebugbar(Laravel Debugbar 开发底栏),
生产环境不显示,不算主题问题。

vendor/bin/phpunit OK (191 tests / 1122 assertions)
@htmambo

htmambo commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

`bc1f14e` —— 暗色主题白底切换(9→2)

Playwright 检测 `/project/1?p=1` 切到暗色后,9 个 >50×20px 白底元素被全部覆盖。
`public/assets/css/style-dark.css` 新增 291 行覆盖。

主要覆盖

元素 修复前 修复后
`.panel .panel-default .panel-body` 主面板 白底 1074×383 rgb(24,26,27)
`.wz-white-panel` 显式白底 白 rgb(24,26,27)
`.wz-top-navbar .bg-white` 白 暗色 navbar
`.wz-panel-breadcrumb` 顶部面包屑 白 暗色
表单 .form-control/input/textarea 白 rgb(47,47,47) + 暗色边框
表情按钮 btn-light/btn-secondary 浅灰 rgb(60,60,60)
.modal-content / .dropdown-menu / .list-group-item 白 暗化
wz-comment-editor + 评论区 白 暗化
swagger / editormd / wysiwyg 区域 白边线 暗化
box-shadow / nav-tabs / alert / table 浅 暗化

剩余 2 个白底

均为 phpdebugbar —— Laravel Debugbar 开发期底栏,生产环境不显示,与主题无关。

`vendor/bin/phpunit` OK (191 tests / 1122 assertions)
`/tmp/project-dark.png` (修复前) → `/tmp/project-dark-after.png` (修复后) —— 中部主面板白底消失,顶部面包屑/输入框/表情按钮/底部空白全部暗化

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants