forked from baskbuddy/zhangshun
25 changed files with 1258 additions and 221 deletions
@ -0,0 +1,110 @@ |
|||||
|
# R01-A 请求身份与 requestId 收束实施指令 |
||||
|
|
||||
|
## 一、任务性质 |
||||
|
|
||||
|
本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。 |
||||
|
|
||||
|
允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。 |
||||
|
|
||||
|
## 二、开始前核查 |
||||
|
|
||||
|
在 `C:\Users\15642\Desktop\zhilai` 执行并记录: |
||||
|
|
||||
|
```powershell |
||||
|
git branch --show-current |
||||
|
git status --short |
||||
|
git diff --cached --name-only |
||||
|
git log -5 --oneline |
||||
|
git diff --check |
||||
|
``` |
||||
|
|
||||
|
分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。 |
||||
|
|
||||
|
## 三、先写基线清单 |
||||
|
|
||||
|
列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。 |
||||
|
|
||||
|
第一批只迁移以下 8 个 Controller: |
||||
|
|
||||
|
1. `AssessmentController` |
||||
|
2. `AbilityController` |
||||
|
3. `ProfileController` |
||||
|
4. `ResumeVersionController` |
||||
|
5. `PreferenceController` |
||||
|
6. `CompetitionRankingController` |
||||
|
7. `PersonalDataExportController` |
||||
|
8. `PrivacySettingsController` |
||||
|
|
||||
|
若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。 |
||||
|
|
||||
|
## 四、身份组件 |
||||
|
|
||||
|
复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。 |
||||
|
|
||||
|
必须保持: |
||||
|
|
||||
|
- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401; |
||||
|
- 不回显主体内容、异常类或堆栈; |
||||
|
- 不接受请求参数中的 userId; |
||||
|
- 角色与资源归属仍由原 Service/Controller 的既有位置判断; |
||||
|
- 不将原 403 或 404 改成 401。 |
||||
|
|
||||
|
删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。 |
||||
|
|
||||
|
## 五、requestId 组件 |
||||
|
|
||||
|
先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。 |
||||
|
|
||||
|
新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。 |
||||
|
|
||||
|
若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。 |
||||
|
|
||||
|
只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。 |
||||
|
|
||||
|
## 六、必须先新增的测试 |
||||
|
|
||||
|
新增或加强: |
||||
|
|
||||
|
- `RequestIdAccessorTest` |
||||
|
- `RequestContextMigrationIntegrationTest` |
||||
|
- 8 个 Controller 的既有权限回归 |
||||
|
|
||||
|
至少覆盖: |
||||
|
|
||||
|
1. 合法客户端 requestId 原样使用; |
||||
|
2. 缺失、空白、超长、非法字符时使用过滤器规范化值; |
||||
|
3. 响应头、响应 body、MDC 日志与 AuditContext 同一值; |
||||
|
4. 并发请求不串 requestId; |
||||
|
5. 过滤器完成后 MDC 清理; |
||||
|
6. 八类非法 principal 均 401 而非 500; |
||||
|
7. 错误响应不包含 principal; |
||||
|
8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致; |
||||
|
9. 拒绝请求无业务写入; |
||||
|
10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。 |
||||
|
|
||||
|
禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。 |
||||
|
|
||||
|
## 七、量化验收 |
||||
|
|
||||
|
整改前后分别统计: |
||||
|
|
||||
|
```powershell |
||||
|
rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java |
||||
|
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java |
||||
|
``` |
||||
|
|
||||
|
第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。 |
||||
|
|
||||
|
## 八、验证 |
||||
|
|
||||
|
依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。 |
||||
|
|
||||
|
预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。 |
||||
|
|
||||
|
## 九、停止条件 |
||||
|
|
||||
|
完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。 |
||||
|
|
||||
|
最终状态只能写: |
||||
|
|
||||
|
`R01-A 第一批请求上下文收束完成,待独立代码复验。` |
||||
@ -0,0 +1,284 @@ |
|||||
|
# R01-A 第一批请求上下文收束实施报告 |
||||
|
|
||||
|
> **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。 |
||||
|
|
||||
|
## 1. 范围与分支 |
||||
|
|
||||
|
| 项目 | 值 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` | |
||||
|
| HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) | |
||||
|
| 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 | |
||||
|
| 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 | |
||||
|
| AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` | |
||||
|
|
||||
|
## 2. 开始前核查 |
||||
|
|
||||
|
``` |
||||
|
git branch --show-current -> refactor/r01-structure-performance ✓ |
||||
|
git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓ |
||||
|
git diff --cached --name-only -> (空) ✓ 暂存区为空 |
||||
|
git diff --check -> 仅 LF/CRLF 提示,无空白错误 |
||||
|
``` |
||||
|
|
||||
|
工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。 |
||||
|
|
||||
|
## 3. 基线清单(整改前) |
||||
|
|
||||
|
### 3.1 第一批 8 个 Controller |
||||
|
|
||||
|
| # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 | |
||||
|
|---|---|---|---|---|---|---| |
||||
|
| 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` | |
||||
|
| 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 | |
||||
|
| 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 | |
||||
|
| 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 | |
||||
|
| 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 | |
||||
|
| 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 | |
||||
|
| 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 | |
||||
|
| 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 | |
||||
|
|
||||
|
**共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。 |
||||
|
|
||||
|
### 3.2 全仓量化基线 |
||||
|
|
||||
|
```powershell |
||||
|
rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处 |
||||
|
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处 |
||||
|
``` |
||||
|
|
||||
|
## 4. 身份组件(复用,不新建) |
||||
|
|
||||
|
沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。 |
||||
|
|
||||
|
保持不变的语义: |
||||
|
|
||||
|
- `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理; |
||||
|
- 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**; |
||||
|
- 不接受请求参数中的 `userId`(未新增任何 userId 入参); |
||||
|
- 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**; |
||||
|
- 未把任何原 403 或 404 改成 401。 |
||||
|
|
||||
|
### 4.1 一处必须说明的既有差异(未扩大) |
||||
|
|
||||
|
整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。 |
||||
|
|
||||
|
整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。 |
||||
|
|
||||
|
## 5. requestId 组件 |
||||
|
|
||||
|
### 5.1 新增 `common/logging/RequestIdAccessor` |
||||
|
|
||||
|
```java |
||||
|
public String get(HttpServletRequest request) // 唯一入口 |
||||
|
``` |
||||
|
|
||||
|
取值顺序: |
||||
|
|
||||
|
1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值); |
||||
|
2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。 |
||||
|
|
||||
|
没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。 |
||||
|
|
||||
|
### 5.2 `RequestIdFilter` 的改动 |
||||
|
|
||||
|
- 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性; |
||||
|
- MDC、响应头、请求包装器、请求属性四者写的是同一个字符串; |
||||
|
- 常量名与原私有名一致,MDC key 未变,日志格式不受影响; |
||||
|
- 过滤器 `finally` 中仍然 `MDC.remove(...)`。 |
||||
|
|
||||
|
### 5.3 `GlobalExceptionHandler` |
||||
|
|
||||
|
只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。 |
||||
|
|
||||
|
独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。 |
||||
|
|
||||
|
`AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。 |
||||
|
|
||||
|
## 6. 8 个 Controller 的迁移方式 |
||||
|
|
||||
|
- 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`; |
||||
|
- 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`; |
||||
|
- 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入; |
||||
|
- 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。 |
||||
|
|
||||
|
## 7. 量化验收(整改前 → 整改后) |
||||
|
|
||||
|
| 指标 | 整改前 | 整改后 | 变化 | |
||||
|
|---|---|---|---| |
||||
|
| 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) | |
||||
|
| requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** | |
||||
|
| 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 | |
||||
|
|
||||
|
requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。 |
||||
|
|
||||
|
身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。 |
||||
|
|
||||
|
### 7.1 剩余数量与文件清单(**未**声称全仓完成) |
||||
|
|
||||
|
仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余: |
||||
|
|
||||
|
```text |
||||
|
application/ApplicationController interview/InterviewController |
||||
|
bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController |
||||
|
bidirectionalmatch/MatchConversationController job/FavoriteController |
||||
|
bidirectionalmatch/MatchRuleController match/MatchController |
||||
|
candidatescore/CandidateScoreCardController messaging/MessagingController |
||||
|
candidatescore/CandidateScoringController notification/NotificationController |
||||
|
candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController |
||||
|
candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController |
||||
|
careerprogress/CareerProgressController recommendation/JobRecommendationService |
||||
|
company/CompanyController review/AdminReviewController |
||||
|
deletion/AccountDeletionController review/AdminReviewReplyController |
||||
|
feedback/CandidateFeedbackController review/CompanyReviewManagementController |
||||
|
review/JobReviewController |
||||
|
review/StudentReviewWeightController |
||||
|
risk/AdminRiskController |
||||
|
safety/AdminReportController |
||||
|
safety/BlockController |
||||
|
safety/ReportController |
||||
|
scoringmonitoring/FairnessMonitoringController |
||||
|
talentdiscovery/TalentDiscoveryController |
||||
|
talentinvitation/TalentInvitationController |
||||
|
verification/CompanyVerificationController |
||||
|
common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor) |
||||
|
controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId) |
||||
|
``` |
||||
|
|
||||
|
## 8. 新增/加强的测试 |
||||
|
|
||||
|
| 测试类 | 类型 | Tests run | 覆盖 | |
||||
|
|---|---|---|---| |
||||
|
| `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request | |
||||
|
| `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 | |
||||
|
| `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 | |
||||
|
| `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 | |
||||
|
| `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 | |
||||
|
| `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 | |
||||
|
|
||||
|
`RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节: |
||||
|
|
||||
|
1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言; |
||||
|
2. 缺失、空白、超长(120 字符)时使用过滤器规范化值; |
||||
|
3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身; |
||||
|
4. 并发 8 个请求各有独立 requestId,互不串号; |
||||
|
5. 请求结束后 `MDC.get("requestId")` 为 `null`; |
||||
|
6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401; |
||||
|
7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈; |
||||
|
8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归); |
||||
|
9. 被拒绝的请求对 6 张业务表零写入; |
||||
|
10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。 |
||||
|
|
||||
|
**未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。 |
||||
|
|
||||
|
## 9. 验证结果 |
||||
|
|
||||
|
| 步骤 | 命令 | 结果 | |
||||
|
|---|---|---| |
||||
|
| 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | |
||||
|
| 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | |
||||
|
| 全量测试 | `mvn test` | 见第 11 节 | |
||||
|
| 干净构建 | `mvn clean verify` | 见第 11 节 | |
||||
|
| OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 | |
||||
|
| 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 | |
||||
|
|
||||
|
## 10. 越界生产缺陷(已获授权,已修复) |
||||
|
|
||||
|
测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。 |
||||
|
|
||||
|
**缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`: |
||||
|
|
||||
|
- 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前; |
||||
|
- 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。 |
||||
|
|
||||
|
**证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。 |
||||
|
|
||||
|
**修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider<CurrentUserAccessor>` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。 |
||||
|
|
||||
|
这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。 |
||||
|
|
||||
|
## 11. 全量测试与干净构建 |
||||
|
|
||||
|
| 项目 | Tests run | Failures | Errors | Skipped | 构建 | |
||||
|
|---|---|---|---|---|---| |
||||
|
| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` | |
||||
|
| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | |
||||
|
|
||||
|
三个日志中均不存在任何 `Skipped: <非零>` 的测试类。 |
||||
|
|
||||
|
期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。 |
||||
|
|
||||
|
## 12. OpenAPI |
||||
|
|
||||
|
``` |
||||
|
python docs/verify_openapi.py |
||||
|
paths: 176 |
||||
|
operations: 214 |
||||
|
All checks passed! |
||||
|
``` |
||||
|
|
||||
|
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。 |
||||
|
|
||||
|
## 13. 修改文件清单 |
||||
|
|
||||
|
**新增** |
||||
|
|
||||
|
```text |
||||
|
docs/r01-a-request-context-implementation-report.md |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java |
||||
|
``` |
||||
|
|
||||
|
**修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)** |
||||
|
|
||||
|
```text |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入 |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权) |
||||
|
``` |
||||
|
|
||||
|
**修改(测试)** |
||||
|
|
||||
|
```text |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java |
||||
|
``` |
||||
|
|
||||
|
**未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。 |
||||
|
|
||||
|
## 14. Git 状态 |
||||
|
|
||||
|
``` |
||||
|
git branch --show-current -> refactor/r01-structure-performance |
||||
|
git diff --cached --name-only -> (空) |
||||
|
git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ?? |
||||
|
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) |
||||
|
git status --short -- docs/openapi-rc.json -> (空) |
||||
|
git diff --check -> 仅 LF/CRLF 提示,无空白错误 |
||||
|
``` |
||||
|
|
||||
|
工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。 |
||||
|
|
||||
|
本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。 |
||||
|
|
||||
|
## 15. 未做的事 |
||||
|
|
||||
|
- 未进入 R01-B / R01-C / R01-D / R01-E; |
||||
|
- 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起); |
||||
|
- 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源); |
||||
|
- 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`; |
||||
|
- 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。 |
||||
@ -0,0 +1,81 @@ |
|||||
|
# R01 结构与性能收束权威设计 |
||||
|
|
||||
|
## 1. 目标与边界 |
||||
|
|
||||
|
R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。 |
||||
|
|
||||
|
禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。 |
||||
|
|
||||
|
基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。 |
||||
|
|
||||
|
## 2. 已确认问题 |
||||
|
|
||||
|
- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。 |
||||
|
- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。 |
||||
|
- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。 |
||||
|
- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。 |
||||
|
- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。 |
||||
|
- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。 |
||||
|
- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。 |
||||
|
- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。 |
||||
|
- `PersonalDataCollector` 存在可批量化的数据读取。 |
||||
|
- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。 |
||||
|
|
||||
|
## 3. 分批策略 |
||||
|
|
||||
|
### R01-A:请求身份与 requestId 基础设施 |
||||
|
|
||||
|
扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。 |
||||
|
|
||||
|
新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。 |
||||
|
|
||||
|
按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。 |
||||
|
|
||||
|
角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。 |
||||
|
|
||||
|
### R01-B:异常映射和业务规则唯一来源 |
||||
|
|
||||
|
将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。 |
||||
|
|
||||
|
提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。 |
||||
|
|
||||
|
### R01-C:纯计算与可读性 |
||||
|
|
||||
|
将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。 |
||||
|
|
||||
|
拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。 |
||||
|
|
||||
|
### R01-D:数据库查询收束 |
||||
|
|
||||
|
消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。 |
||||
|
|
||||
|
`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。 |
||||
|
|
||||
|
PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。 |
||||
|
|
||||
|
### R01-E:真实环境复验 |
||||
|
|
||||
|
在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。 |
||||
|
|
||||
|
## 4. 关键契约 |
||||
|
|
||||
|
| 模块 | 必须保持 | |
||||
|
|---|---| |
||||
|
| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 | |
||||
|
| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 | |
||||
|
| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 | |
||||
|
| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 | |
||||
|
| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP | |
||||
|
| 推荐 | 五项权重和缺失项分母规则逐字保持 | |
||||
|
| 未读 | RECALLED 不计入;已有 read 行语义不变 | |
||||
|
| 导出 | 字段、隐私、顺序、内容与错误语义不变 | |
||||
|
|
||||
|
## 5. 证据要求 |
||||
|
|
||||
|
每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。 |
||||
|
|
||||
|
自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。 |
||||
|
|
||||
|
## 6. 提交边界 |
||||
|
|
||||
|
每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。 |
||||
@ -0,0 +1,37 @@ |
|||||
|
package com.mydreamjob.common.logging; |
||||
|
|
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** |
||||
|
* 请求 ID 读取器:读取 requestId 的**唯一入口**,保证响应头、MDC、Controller 响应体、 |
||||
|
* {@code AuditContext} 与异常响应使用同一个值。 |
||||
|
* |
||||
|
* <p>取值优先级:</p> |
||||
|
* <ol> |
||||
|
* <li>{@link RequestIdFilter} 写入的请求属性(规范化后的值);</li> |
||||
|
* <li>请求头 {@code X-Request-Id} —— 过滤器已通过请求包装器把它替换为规范化值, |
||||
|
* 因此这里得到的仍是同一个值;若调用路径绕过了过滤器,则由 |
||||
|
* {@link RequestIdFilter#normalizeRequestId(String)} 按同一规则收敛, |
||||
|
* **不会**把超长或非法客户端头原样返回。</li> |
||||
|
* </ol> |
||||
|
* |
||||
|
* <p>本类不产生第二套生成规则:缺失或空白时复用过滤器同一处 |
||||
|
* {@code normalizeRequestId} 逻辑。</p> |
||||
|
*/ |
||||
|
@Component |
||||
|
public class RequestIdAccessor { |
||||
|
|
||||
|
/** 读取当前请求的规范化 requestId。 */ |
||||
|
public String get(HttpServletRequest request) { |
||||
|
if (request == null) { |
||||
|
return RequestIdFilter.normalizeRequestId(null); |
||||
|
} |
||||
|
Object attribute = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE); |
||||
|
if (attribute instanceof String value && !value.isBlank()) { |
||||
|
return value; |
||||
|
} |
||||
|
// 过滤器未执行时的兜底:仍按过滤器同一规则收敛,绝不直接回显客户端原值。
|
||||
|
return RequestIdFilter.normalizeRequestId(request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,373 @@ |
|||||
|
package com.mydreamjob.common.logging; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.auth.support.TestAuthTokens; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.servlet.http.HttpServletResponse; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.slf4j.MDC; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.context.TestConfiguration; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.authority.AuthorityUtils; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
import org.springframework.test.web.servlet.MvcResult; |
||||
|
import org.springframework.web.servlet.HandlerInterceptor; |
||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; |
||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* R01-A:请求上下文收束的**端到端**证据。 |
||||
|
* |
||||
|
* <p>使用真实的 {@code SecurityFilterChain}、{@link RequestIdFilter} 与 MVC 派发, |
||||
|
* 而不是 Mock 掉新组件后断言 mock 值。覆盖:requestId 在响应头 / 响应体 / MDC / |
||||
|
* {@code AuditContext} 中同源、并发不串号、过滤器结束后清理 MDC、八类非法 principal |
||||
|
* 得到 401 而非 500、错误响应不回显 principal、拒绝请求不产生业务写入。</p> |
||||
|
* |
||||
|
* <p>被迁移的 8 个 Controller 全部被真实调用一次。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class RequestContextMigrationIntegrationTest { |
||||
|
|
||||
|
private static final String REQUEST_ID_HEADER = "X-Request-Id"; |
||||
|
private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; |
||||
|
private static final AtomicLong PHONE = new AtomicLong(930_000_000L); |
||||
|
|
||||
|
/** 8 个已迁移 Controller 各自的真实入口 + 预期状态码(STUDENT 身份)。 */ |
||||
|
private static final List<MigratedEndpoint> ENDPOINTS = List.of( |
||||
|
new MigratedEndpoint("/profile/me", 200, "ProfileController"), |
||||
|
new MigratedEndpoint("/profile/me/preferences", 200, "PreferenceController"), |
||||
|
new MigratedEndpoint("/profile/me/privacy", 200, "PrivacySettingsController"), |
||||
|
new MigratedEndpoint("/profile/me/resume-versions", 200, "ResumeVersionController"), |
||||
|
new MigratedEndpoint("/profile/me/data-export-tasks", 200, "PersonalDataExportController"), |
||||
|
new MigratedEndpoint("/assessments/me", 200, "AssessmentController"), |
||||
|
new MigratedEndpoint("/abilities/me", 200, "AbilityController"), |
||||
|
new MigratedEndpoint("/jobs/99999999/competition-ranking", 404, "CompetitionRankingController")); |
||||
|
|
||||
|
private record MigratedEndpoint(String path, int expectedStatus, String controller) { |
||||
|
} |
||||
|
|
||||
|
/** 在真实 MVC 派发过程中抓取 MDC 与 AuditContext,避免只看过滤器自身。 */ |
||||
|
static final class RequestContextProbe { |
||||
|
static final ThreadLocal<String> MDC_VALUE = new ThreadLocal<>(); |
||||
|
static final ThreadLocal<String> AUDIT_VALUE = new ThreadLocal<>(); |
||||
|
|
||||
|
static void clear() { |
||||
|
MDC_VALUE.remove(); |
||||
|
AUDIT_VALUE.remove(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@TestConfiguration |
||||
|
static class RequestContextProbeConfig implements WebMvcConfigurer { |
||||
|
@Override |
||||
|
public void addInterceptors(InterceptorRegistry registry) { |
||||
|
registry.addInterceptor(new HandlerInterceptor() { |
||||
|
@Override |
||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { |
||||
|
RequestContextProbe.MDC_VALUE.set(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); |
||||
|
RequestContextProbe.AUDIT_VALUE.set(AuditContext.from(request).getRequestId()); |
||||
|
return true; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private final ObjectMapper mapper = new ObjectMapper(); |
||||
|
private long studentId; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
studentId = userRepository.save(User.builder() |
||||
|
.phone(String.valueOf(PHONE.getAndIncrement())) |
||||
|
.nickname("r01a-context-student") |
||||
|
.role(User.Role.STUDENT) |
||||
|
.roles(Set.of(User.Role.STUDENT)) |
||||
|
.active(true).verified(true).build()).getId(); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
RequestContextProbe.clear(); |
||||
|
MDC.clear(); |
||||
|
} |
||||
|
|
||||
|
// ── 3. 响应头、响应体、MDC、AuditContext 同源 ────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void requestIdIsIdenticalAcrossHeaderBodyMdcAndAuditContext() throws Exception { |
||||
|
String requestId = "r01a-consistent-001"; |
||||
|
|
||||
|
for (MigratedEndpoint endpoint : ENDPOINTS) { |
||||
|
RequestContextProbe.clear(); |
||||
|
MvcResult result = mockMvc.perform(get(endpoint.path()) |
||||
|
.header("Authorization", studentToken()) |
||||
|
.header(REQUEST_ID_HEADER, requestId)) |
||||
|
.andExpect(status().is(endpoint.expectedStatus())) |
||||
|
.andReturn(); |
||||
|
|
||||
|
String where = endpoint.controller() + " " + endpoint.path(); |
||||
|
assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)) |
||||
|
.as("%s:响应头", where).isEqualTo(requestId); |
||||
|
assertThat(bodyRequestId(result)) |
||||
|
.as("%s:响应体 requestId", where).isEqualTo(requestId); |
||||
|
assertThat(RequestContextProbe.MDC_VALUE.get()) |
||||
|
.as("%s:MDC requestId", where).isEqualTo(requestId); |
||||
|
assertThat(RequestContextProbe.AUDIT_VALUE.get()) |
||||
|
.as("%s:AuditContext requestId", where).isEqualTo(requestId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void overlongRequestIdIsBoundedAndStillConsistentEverywhere() throws Exception { |
||||
|
String overlong = "L".repeat(120); |
||||
|
|
||||
|
RequestContextProbe.clear(); |
||||
|
MvcResult result = mockMvc.perform(get("/profile/me/privacy") |
||||
|
.header("Authorization", studentToken()) |
||||
|
.header(REQUEST_ID_HEADER, overlong)) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andReturn(); |
||||
|
|
||||
|
String expected = overlong.substring(0, 32); |
||||
|
assertThat(result.getResponse().getHeader(REQUEST_ID_HEADER)).isEqualTo(expected); |
||||
|
assertThat(bodyRequestId(result)).isEqualTo(expected); |
||||
|
assertThat(RequestContextProbe.MDC_VALUE.get()).isEqualTo(expected); |
||||
|
assertThat(RequestContextProbe.AUDIT_VALUE.get()).isEqualTo(expected); |
||||
|
assertThat(bodyRequestId(result)).isNotEqualTo(overlong); |
||||
|
} |
||||
|
|
||||
|
// ── 4. 并发不串号 ────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void concurrentRequestsDoNotLeakRequestIdAcrossThreads() throws Exception { |
||||
|
int concurrency = 8; |
||||
|
ExecutorService pool = Executors.newFixedThreadPool(concurrency); |
||||
|
CountDownLatch start = new CountDownLatch(1); |
||||
|
ConcurrentLinkedQueue<String> failures = new ConcurrentLinkedQueue<>(); |
||||
|
|
||||
|
try { |
||||
|
List<java.util.concurrent.Future<?>> futures = new ArrayList<>(); |
||||
|
for (int i = 0; i < concurrency; i++) { |
||||
|
String expected = "r01a-concurrent-" + i; |
||||
|
futures.add(pool.submit(() -> { |
||||
|
try { |
||||
|
start.await(); |
||||
|
MvcResult result = mockMvc.perform(get("/profile/me/preferences") |
||||
|
.header("Authorization", studentToken()) |
||||
|
.header(REQUEST_ID_HEADER, expected)) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andReturn(); |
||||
|
String header = result.getResponse().getHeader(REQUEST_ID_HEADER); |
||||
|
String body = bodyRequestId(result); |
||||
|
if (!expected.equals(header) || !expected.equals(body)) { |
||||
|
failures.add(expected + " -> header=" + header + ", body=" + body); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
failures.add(expected + " -> " + e); |
||||
|
} |
||||
|
return null; |
||||
|
})); |
||||
|
} |
||||
|
start.countDown(); |
||||
|
for (var future : futures) { |
||||
|
future.get(60, TimeUnit.SECONDS); |
||||
|
} |
||||
|
} finally { |
||||
|
pool.shutdownNow(); |
||||
|
} |
||||
|
|
||||
|
assertThat(failures).as("并发请求不得互相串用 requestId").isEmpty(); |
||||
|
} |
||||
|
|
||||
|
// ── 5. 过滤器结束后清理 MDC ─────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void mdcIsClearedAfterRequestCompletes() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/privacy") |
||||
|
.header("Authorization", studentToken()) |
||||
|
.header(REQUEST_ID_HEADER, "r01a-mdc-cleanup")) |
||||
|
.andExpect(status().isOk()); |
||||
|
|
||||
|
assertThat(MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)) |
||||
|
.as("请求结束后不得残留 requestId").isNull(); |
||||
|
} |
||||
|
|
||||
|
// ── 6./7. 非法 principal → 401,不回显 principal ──────────────
|
||||
|
|
||||
|
@Test |
||||
|
void illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal() throws Exception { |
||||
|
List<PrincipalCase> cases = List.of( |
||||
|
new PrincipalCase("匿名令牌", new AnonymousAuthenticationToken( |
||||
|
"r01a-key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))), |
||||
|
new PrincipalCase("已认证但名称为 anonymousUser", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of())), |
||||
|
new PrincipalCase("空白主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of())), |
||||
|
new PrincipalCase("非数字主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of())), |
||||
|
new PrincipalCase("Unicode 数字主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("٤٢", null, List.of())), |
||||
|
new PrincipalCase("带正号主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of())), |
||||
|
new PrincipalCase("带负号主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("-7", null, List.of())), |
||||
|
new PrincipalCase("零主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("0", null, List.of())), |
||||
|
new PrincipalCase("负数主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of())), |
||||
|
new PrincipalCase("Long 溢出主体", |
||||
|
UsernamePasswordAuthenticationToken.authenticated("99999999999999999999", null, List.of()))); |
||||
|
|
||||
|
for (PrincipalCase principalCase : cases) { |
||||
|
MvcResult result = mockMvc.perform(get("/profile/me/privacy") |
||||
|
.with(authentication(principalCase.authentication())) |
||||
|
.header(REQUEST_ID_HEADER, "r01a-bad-principal")) |
||||
|
.andReturn(); |
||||
|
|
||||
|
String as = principalCase.label(); |
||||
|
assertThat(result.getResponse().getStatus()) |
||||
|
.as("%s:必须是 401 而不是 5xx(迁移前会因 NumberFormatException 逃逸为 500)", as) |
||||
|
.isEqualTo(401); |
||||
|
assertThat(result.getResponse().getContentAsString()) |
||||
|
.as("%s:错误响应不得回显 principal", as) |
||||
|
.doesNotContain(BAD_PRINCIPAL) |
||||
|
.doesNotContain("anonymousUser") |
||||
|
.doesNotContain("NumberFormatException") |
||||
|
.doesNotContain("at com.mydreamjob"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void noCredentialsAtAllReturns401WithoutServerError() throws Exception { |
||||
|
MvcResult result = mockMvc.perform(get("/profile/me/privacy") |
||||
|
.header(REQUEST_ID_HEADER, "r01a-no-credentials")) |
||||
|
.andReturn(); |
||||
|
|
||||
|
assertThat(result.getResponse().getStatus()).isEqualTo(401); |
||||
|
assertThat(result.getResponse().getContentAsString()).doesNotContain("at com.mydreamjob"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 反例边界:一个「非匿名但 isAuthenticated()==false」的合成令牌会在**安全过滤器链** |
||||
|
* 就被判定为拒绝(403),根本到不了 Controller 与 {@code CurrentUserAccessor}。 |
||||
|
* |
||||
|
* <p>这是迁移前既有的链行为,R01-A 明确不把原 403 改成 401。此处只锁定 |
||||
|
* 「不是 5xx、不回显 principal」,避免为了凑一个 401 而改动安全语义。</p> |
||||
|
*/ |
||||
|
@Test |
||||
|
void syntheticUnauthenticatedTokenIsRejectedByChainWithoutServerError() throws Exception { |
||||
|
Authentication synthetic = new UsernamePasswordAuthenticationToken(BAD_PRINCIPAL, null); |
||||
|
|
||||
|
MvcResult result = mockMvc.perform(get("/profile/me/privacy") |
||||
|
.with(authentication(synthetic)) |
||||
|
.header(REQUEST_ID_HEADER, "r01a-synthetic")) |
||||
|
.andReturn(); |
||||
|
|
||||
|
int status = result.getResponse().getStatus(); |
||||
|
assertThat(status) |
||||
|
.as("必须是 4xx 客户端拒绝,不得是 5xx") |
||||
|
.isBetween(400, 499) |
||||
|
.isNotEqualTo(200); |
||||
|
assertThat(result.getResponse().getContentAsString()) |
||||
|
.as("错误响应不得回显 principal") |
||||
|
.doesNotContain(BAD_PRINCIPAL) |
||||
|
.doesNotContain("at com.mydreamjob"); |
||||
|
} |
||||
|
|
||||
|
private record PrincipalCase(String label, Authentication authentication) { |
||||
|
} |
||||
|
|
||||
|
// ── 9. 拒绝请求无业务写入 ────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void rejectedRequestsPerformNoBusinessWrite() throws Exception { |
||||
|
List<BusinessTable> tables = List.of( |
||||
|
new BusinessTable("student_privacy_settings", "student_user_id"), |
||||
|
new BusinessTable("student_preferences", "student_user_id"), |
||||
|
new BusinessTable("resume_versions", "user_id"), |
||||
|
new BusinessTable("personal_data_export_tasks", "user_id"), |
||||
|
new BusinessTable("assessment_attempts", "student_user_id"), |
||||
|
new BusinessTable("ability_attempts", "student_user_id")); |
||||
|
var before = rowCounts(tables, studentId); |
||||
|
|
||||
|
mockMvc.perform(get("/profile/me/privacy") |
||||
|
.with(authentication(UsernamePasswordAuthenticationToken.authenticated( |
||||
|
BAD_PRINCIPAL, null, List.of())))) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
mockMvc.perform(get("/profile/me/privacy")) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
mockMvc.perform(get("/profile/me/preferences") |
||||
|
.with(authentication(new AnonymousAuthenticationToken( |
||||
|
"r01a-key", "anonymousUser", |
||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))))) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
|
||||
|
assertThat(rowCounts(tables, studentId)) |
||||
|
.as("被拒绝的请求不得写入任何业务表") |
||||
|
.isEqualTo(before); |
||||
|
} |
||||
|
|
||||
|
private record BusinessTable(String name, String ownerColumn) { |
||||
|
} |
||||
|
|
||||
|
private List<String> rowCounts(List<BusinessTable> tables, long userId) { |
||||
|
List<String> counts = new ArrayList<>(); |
||||
|
for (BusinessTable table : tables) { |
||||
|
Long count = jdbcTemplate.queryForObject( |
||||
|
"SELECT COUNT(*) FROM " + table.name() + " WHERE " + table.ownerColumn() + " = ?", |
||||
|
Long.class, userId); |
||||
|
counts.add(table.name() + "=" + count); |
||||
|
} |
||||
|
return counts; |
||||
|
} |
||||
|
|
||||
|
// ── 辅助 ─────────────────────────────────────────────────────
|
||||
|
|
||||
|
private String studentToken() { |
||||
|
return TestAuthTokens.bearer(studentId, "STUDENT"); |
||||
|
} |
||||
|
|
||||
|
private String bodyRequestId(MvcResult result) throws Exception { |
||||
|
JsonNode root = mapper.readTree(result.getResponse().getContentAsString()); |
||||
|
JsonNode requestId = root.get("requestId"); |
||||
|
return requestId == null ? null : requestId.asText(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
package com.mydreamjob.common.logging; |
||||
|
|
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.springframework.mock.web.MockHttpServletRequest; |
||||
|
import org.springframework.mock.web.MockHttpServletResponse; |
||||
|
|
||||
|
import java.util.concurrent.atomic.AtomicReference; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
/** |
||||
|
* R01-A requestId 读取器边界。 |
||||
|
* |
||||
|
* <p>核心约束:accessor 只能复用 {@link RequestIdFilter} 产出的规范化值, |
||||
|
* 不得自建第二套生成规则,也不得把超长/非法客户端头原样返回。</p> |
||||
|
*/ |
||||
|
class RequestIdAccessorTest { |
||||
|
|
||||
|
private final RequestIdFilter filter = new RequestIdFilter(); |
||||
|
private final RequestIdAccessor accessor = new RequestIdAccessor(); |
||||
|
|
||||
|
// ── 1. 合法客户端 requestId 原样使用 ────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void legalClientRequestIdIsUsedVerbatimAcrossAllOutputs() throws Exception { |
||||
|
String clientId = "client-abc_123"; |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, clientId); |
||||
|
MockHttpServletResponse response = new MockHttpServletResponse(); |
||||
|
AtomicReference<String> seenByController = new AtomicReference<>(); |
||||
|
AtomicReference<String> seenInMdc = new AtomicReference<>(); |
||||
|
|
||||
|
filter.doFilter(request, response, (wrapped, ignored) -> { |
||||
|
HttpServletRequest req = (HttpServletRequest) wrapped; |
||||
|
seenByController.set(accessor.get(req)); |
||||
|
seenInMdc.set(org.slf4j.MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); |
||||
|
}); |
||||
|
|
||||
|
assertEquals(clientId, seenByController.get(), "合法客户端值必须原样使用"); |
||||
|
assertEquals(seenByController.get(), response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), |
||||
|
"响应头必须与 Controller 读到的一致"); |
||||
|
assertEquals(seenByController.get(), seenInMdc.get(), "MDC 必须与 Controller 读到的一致"); |
||||
|
} |
||||
|
|
||||
|
// ── 2. 缺失 / 空白 / 超长时使用过滤器规范化值 ────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void missingBlankAndOverlongIdsUseFilterNormalizedValue() throws Exception { |
||||
|
record Case(String header, int expectedLength, String label) { |
||||
|
} |
||||
|
Case[] cases = { |
||||
|
new Case(null, 16, "缺失"), |
||||
|
new Case(" ", 16, "纯空白"), |
||||
|
new Case("\t", 16, "制表符"), |
||||
|
new Case("x".repeat(33), 32, "刚好超一位"), |
||||
|
new Case("y".repeat(200), 32, "远超声明的 32 字符上限"), |
||||
|
}; |
||||
|
|
||||
|
for (Case testCase : cases) { |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
if (testCase.header() != null) { |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, testCase.header()); |
||||
|
} |
||||
|
MockHttpServletResponse response = new MockHttpServletResponse(); |
||||
|
AtomicReference<String> seenByController = new AtomicReference<>(); |
||||
|
|
||||
|
filter.doFilter(request, response, (wrapped, ignored) -> |
||||
|
seenByController.set(accessor.get((HttpServletRequest) wrapped))); |
||||
|
|
||||
|
String value = seenByController.get(); |
||||
|
assertNotNull(value, testCase.label() + ":必须有值"); |
||||
|
assertEquals(testCase.expectedLength(), value.length(), testCase.label() + ":长度必须被收敛"); |
||||
|
assertEquals(response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), value, |
||||
|
testCase.label() + ":与响应头同源"); |
||||
|
if (testCase.header() == null) { |
||||
|
continue; |
||||
|
} |
||||
|
assertNotEquals(testCase.header(), value, testCase.label() + ":不得原样回显客户端头"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = { |
||||
|
"bad id", "bad\tid", "bad\nid", "bad\rid", "中文", "<script>", |
||||
|
"bad\\id", "bad/id", "bad!id", "bad&id" |
||||
|
}) |
||||
|
void illegalCharacterHeaderIsReplacedWithoutEchoingRawInput(String hostile) throws Exception { |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, hostile); |
||||
|
MockHttpServletResponse response = new MockHttpServletResponse(); |
||||
|
AtomicReference<String> seenByController = new AtomicReference<>(); |
||||
|
AtomicReference<String> seenInMdc = new AtomicReference<>(); |
||||
|
|
||||
|
filter.doFilter(request, response, (wrapped, ignored) -> { |
||||
|
seenByController.set(accessor.get((HttpServletRequest) wrapped)); |
||||
|
seenInMdc.set(org.slf4j.MDC.get(RequestIdFilter.REQUEST_ID_ATTRIBUTE)); |
||||
|
}); |
||||
|
|
||||
|
assertEquals(response.getHeader(RequestIdFilter.REQUEST_ID_HEADER), seenByController.get()); |
||||
|
assertEquals(seenInMdc.get(), seenByController.get()); |
||||
|
assertNotEquals(hostile, seenByController.get(), "不得原样回显客户端头"); |
||||
|
assertTrue(seenByController.get().matches("[0-9a-f]{16}"), |
||||
|
"非法输入必须整体替换为服务端生成的十六进制 requestId"); |
||||
|
} |
||||
|
|
||||
|
// ── 10. 反证:绕过 filter 时不得返回原始超长头 ───────────────
|
||||
|
|
||||
|
@Test |
||||
|
void overlongHeaderIsBoundedEvenWhenFilterIsBypassed() { |
||||
|
String overlong = "z".repeat(400); |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, overlong); |
||||
|
|
||||
|
String value = accessor.get(request); |
||||
|
|
||||
|
assertNotEquals(overlong, value, "绕过过滤器时 accessor 不得返回客户端原值"); |
||||
|
assertEquals(32, value.length(), "必须按过滤器同一规则收敛到 32 字符"); |
||||
|
assertEquals(overlong.substring(0, 32), value); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void tamperedHeaderCannotOverrideNormalizedAttribute() { |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
request.setAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE, "normalized-by-filter"); |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, "attacker-supplied-value"); |
||||
|
|
||||
|
assertEquals("normalized-by-filter", accessor.get(request), |
||||
|
"过滤器产出的请求属性优先于请求头"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void blankAttributeFallsBackToHeader() { |
||||
|
MockHttpServletRequest request = new MockHttpServletRequest(); |
||||
|
request.setAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE, " "); |
||||
|
request.addHeader(RequestIdFilter.REQUEST_ID_HEADER, "resp-1"); |
||||
|
|
||||
|
assertEquals("resp-1", accessor.get(request)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullRequestStillYieldsUsableId() { |
||||
|
String value = accessor.get(null); |
||||
|
|
||||
|
assertNotNull(value); |
||||
|
assertEquals(16, value.length()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue