46 KiB
R01-D4-2「GET 评分接口停写与快照可靠物化」独立代码复验与真实 MySQL/Docker/JAR/HTTP 黑盒验收
日期:2026-09-16
分支:refactor/r01-structure-performance
HEAD:b10c4a7 docs: design scoring snapshot materialization
验收性质:最终独立验收(非开发)。审查正式生产路径 + 项目既有 Docker Compose + 真实 MySQL 8.4 + 最新构建 JAR + 真实 HTTP。
验收前缀:R01D42_ACC_20260916_K4GDNT
0. 最终结论
已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。
判定为 FAIL。触发任务书第二十节 FAIL 判据中的第 4 条「写侧成功但任务未登记」,并另有一处测试断言弱化(触碰「禁止修改或放宽既有测试」红线)。
同时必须明确记录:本次验收的核心目标项全部实测通过 —— 三个评分 GET 在真实 HTTP 下确凿零写、V44 在真实 MySQL 成功、clean verify 2131/2131 全绿、补偿扫描游标与预算正确、趋势/排名/看板/申诉/公平性监控均未退化、RabbitMQ 终态正常。FAIL 由写侧事件覆盖不全这一独立缺陷触发,与 GET 停写本身无关。
1. Git 基线与工作区状态
| 检查项 | 结果 |
|---|---|
| 分支 | refactor/r01-structure-performance ✔ |
| HEAD | b10c4a7(验收全程未变) |
| 暂存区 | 空(git diff --cached --name-only 无输出)✔ |
git add -A / commit / push |
均未执行 ✔ |
| V1–V43 修改 | 无(git diff --name-only -- db/migration 为空)✔ |
| V44 | V44__candidate_scoring_refresh_tasks.sql 为唯一新增迁移 ✔ |
| 删除文件 | 无 ✔ |
| 历史未跟踪脚本/日志/JSON/SQL/xlsx/.env | 全部保留,未删未移 ✔ |
git diff --check |
无空白错误(仅既有 LF/CRLF 提示)✔ |
AGENTS.md |
全仓搜索未发现 |
本轮新增(未跟踪):docs/acceptance/r01-d4-2-20260916/(验收脚本与脱敏证据)、docs/r01-d4-2-independent-mysql-acceptance-20260916.md(本报告)。未修改任何 D4-1/D4-2 生产代码、未修改 V1–V44、未新增 Flyway/表/索引/ErrorCode/公网接口。
2. 独立代码审查 A1–A6
方法:9 个并行静态审查维度 + 对每条 critical/major 发现的对抗性复核(20 个独立子代理,全部复核结论为「发现成立」),另由本人逐条打开源码亲自确认关键项。
A1 三个 GET 是否真正只读 —— 通过
CandidateScoringEngineService.java:52 / :61 / :106三个方法均为@Transactional(readOnly = true)。- 三个方法体内仅含
LocalDateTime.now(clock)+calculator.computeAll(...)+ 只读仓储/JobService.getJob查询 + DTO 组装,无任何写语句。 git diff HEAD -- .../CandidateScoringEngineService.java全部改动仅为 3 处注解变更 + 删除 3 行calculator.upsertSnapshots(...)。CandidateScoringCalculator.computeAll(:122-146)只含findById+ 6 个纯 SELECT(queryApps/queryAudit/queryOffers/queryFeedback/queryResume/queryMessages),无写路径;全 main 源码中upsertSnapshots已无生产调用方。explanation/包 grepjdbc|Repository|EntityManager|@Transactional|save(无命中。- 响应仍来自实时
computeAll;status(CandidateOverviewStatusPolicy)、weight(DEFAULT_WEIGHT = 12.50)、rulesVersion(currentRulesVersion())与 D4-1 前逐字节一致。 - 企业授权校验(company→job 归属)保留,越权负例实测 403。
遗留(非缺陷,记录):CandidateScoringCalculator.java:582 javadoc 仍写「普通物化入口(含 GET 兜底)」,与 GET 兜底已删除的现状不符(注释陈旧)。
A2 写侧登记覆盖 —— 不通过(存在两处真实遗漏)
任务书列出的 8 个服务方法全部正确接入,且均为同事务 REQUIRED 登记:
| 服务.方法 | 登记位置 |
|---|---|
ProfileService#updateProfile |
ProfileService.java:75 |
ResumeVersionService#createVersion / updateVersion / deleteVersion |
ResumeVersionService.java:71 / :163 / :189 |
PreferenceService#savePreferences |
PreferenceService.java:76 |
AbilityAttemptService#submit |
AbilityAttemptService.java:294 |
ApplicationService#apply / withdraw / updateStatus |
ApplicationService.java:90 / :129 / :224 |
OfferService#transitionStatus |
OfferService.java:304 |
CandidateFeedbackService#createFeedback |
CandidateFeedbackService.java:82 |
MessagingService#sendMessage(仅 STUDENT) |
MessagingService.java:197-200 |
角色判定核实:MessagingService.java:197-200 用 u.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT),只有学生发送者登记,企业发送者不登记 ✔
遗漏一:Offer 自动过期不登记(major,命中 FAIL 判据)
OfferExpiryWorker.java:41 expireOne(...)在:48直接offer.setStatus(OfferStatus.EXPIRED.name())后saveAndFlush,全文requestRefresh/scoringRefresh出现次数 = 0。- 调用方为定时任务
OfferExpiryService.java:34 worker.expireOne(...),不经过OfferService#transitionStatus。 - 同一行
:59还调用counterOfferRepository.expirePendingByOfferId(...),:68 notifyExpired(saved)。 - 该写入直接影响评分输入:
CandidateScoringCalculator.java:188-194 queryOffers的 SQL 为SELECT SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END), SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END) FROM offers WHERE candidate_id = ?,EXPIRED计数参与 INTEGRITY 维度。 docs/r01-d4-get-scoring-snapshot-migration-design.md:105明确要求「Offer 状态变化 → Offer 状态机成功提交后 → 标记待刷新」,而 D4-1 报告「明确未接入」清单(§12,:43)并未把 Offer 自动过期列为有意不接入项 —— 属实现遗漏。
遗漏二:候选人反馈的修改/删除不登记(major,命中 FAIL 判据)
CandidateFeedbackService.java:107 updateFeedback(...)与:124 deleteFeedback(...)均未调用requestRefresh;仅:82 createFeedback登记。- 评分读取该表:
CandidateScoringCalculator.java:196-200 queryFeedback的 SQL 为SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?。 - 修改
rating会改变AVG,删除反馈会同时改变COUNT与AVG,两者都不触发刷新。 - 设计文档
:106明确要求「候选人反馈创建/修改的真实写入口」触发刷新 —— 属实现遗漏。
遗漏三:ResumeVersionService.createVersion 的事务边界不对称(major)
ResumeVersionService.java:71的登记位于createVersion的@Transactional内(REQUIRED,加入外层事务);- 但版本行由
versionWriter.saveNewVersion(...)(@Transactional(REQUIRES_NEW),文件内 javadoc:52-55自述「写入成功即独立提交,外层事务回滚不会让版本行消失」)独立提交; - 因此存在窗口:版本行已提交、外层事务在提交阶段失败 → 业务数据已落库但刷新任务登记丢失。
其余 A2 结论:登记不触发线程内重算、不发 RabbitMQ、不写通知、不写审计、不记录敏感值 —— 全部成立。
A3 唯一快照 Writer —— 通过
CandidateScoringSnapshotWriter.java:105-112:score / status / sub_scores_json / weight / rules_version全部相同则 early return,不改 version 与 updated_at ✔calculated_at不参与变化判定 ✔rules_version参与判定,变化必推进 version ✔canonicalJson(:183-214)对 ObjectNode 递归按字典序重排、对 ArrayNode 逐元素递归 ✔- 并发安全:
:91-98先INSERT ... ON DUPLICATE KEY UPDATE id=id,:126-137再SELECT ... FOR UPDATE,:113-119条件更新WHERE id=? AND version=?,冲突抛OptimisticLockingFailureException—— 无 JVM 锁 ✔ - Governance 强制路径
:66-69 upsertForcingVersion→:75-88无条件version=version+1;调用方CandidateScoringGovernanceService.java:294保持afterVersion = beforeVersion + 1语义 ✔ - 普通物化不写
candidate_scoring_snapshot_history✔(黑盒已证,见 §10) - 单维写失败整体回滚:
upsertIfChanged(:51-60)为单一@Transactional循环,CandidateScoringRefreshTaskExecutor.materialize同为单个REQUIRES_NEW—— 无部分提交 ✔
A4 Worker 与失败恢复 —— 不通过(两处独立缺陷)
claim(:43-62)、materialize(:64-89)、recordFailure(:91-108)、recoverStale(:29-41)分属 4 个独立REQUIRES_NEW事务 ✔- 并发领取唯一:
claim的WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)+updated != 1 → null✔(真实 MySQL 集成测试亦覆盖) last_error_code只写固定分类(WRITE_FAILED/COMPUTE_FAILED/UNKNOWN_REFRESH_FAILURE/WORKER_TIMEOUT),:82的log.warn只记录 taskId 与 category ✔- PROCESSING 超时回收、达上限转 FAILED ✔
缺陷一:materialize 的「回到 PENDING」分支缺 version 闸门(major)
CandidateScoringRefreshTaskExecutor.java:78-88:
UPDATE ... SET status='COMPLETED' ... WHERE id=? AND status='PROCESSING' AND version=? -- 有闸门
if (completed == 0) {
UPDATE ... SET status='PENDING' ... WHERE id=? AND status='PROCESSING' -- 无闸门
当原 Worker 因超时已被 recoverStale 置为 RETRY/FAILED 并被另一个 Worker 重新领取(处于 PROCESSING)后,慢的原 Worker 返回时 version 不匹配走 PENDING 分支,会把新 Worker 正在处理的任务改回 PENDING;随后新 Worker 完成时 completed=0、pending=0 → 抛 IllegalStateException。
缺陷二:失败记录异常会打断同批任务(major)
CandidateScoringRefreshWorker.java:77-86 的 catch (Exception e) 块内调用 taskExecutor.recordFailure(...);该方法在 updated != 1 时抛 IllegalStateException(TaskExecutor.java:105-107)。异常在 catch 块内抛出不会被自身捕获,将传播到 processDue 的 for (Long id : due) 循环(Worker.java:63-67,循环体内无 try/catch),导致同批剩余任务本轮全部不被处理,违反 Worker.java:24 javadoc 声称的「异常不回抛,不中断其他任务」。
触发前提为物化耗时超过 processing-timeout-ms(默认 600000ms),概率低但路径真实。
A5 补偿扫描 —— 通过
- 游标持久化于
candidate_scoring_refresh_scan_checkpoints(:79-85 lockCursor用SELECT ... FOR UPDATE),非内存字段 ✔ - 单轮预算:
:48budget = max(1, min(limit, 500)),:98LIMIT ?绑定同一budget✔ - 尾部回绕:
:59ids.size() < budget || ids.isEmpty() ? 0 : 末位 id;:52-55游标已在尾部且空结果时重置 0 重查 ✔ - 游标推进与任务登记同事务:
scanDueStudents为@Transactional,requestService.requestRefresh为REQUIRED✔ - 多实例串行:checkpoint 行
FOR UPDATE✔ - 查询预算:1 次聚合查询(
findEligibleAfter)+ ≤budget 次 upsert,无逐用户 N+1 ✔ - 不直接写
candidate_dimension_scores✔
A6 配置 —— 通过(无断档)
application.yml:156enabled: ${SCORING_REFRESH_ENABLED:true},:158compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}—— 生产默认启用 ✔application-test.yml:23-26/application-integration-test.yml:37-40/application-mysql-it.yml:26-29三处均显式enabled: false+compensation-enabled: false✔- 不依赖测试 profile 才能工作 ✔;Scheduler
@Value的:falsefallback 仅在该键完全缺失时生效,yml 提供true时实际生效值为true(黑盒已验证补偿扫描确实运行)✔
遗留(记录):CandidateScoringRefreshScheduler.java:15 javadoc 仍写「默认关闭」,与现状相反。
3. Docker / MySQL / Redis / RabbitMQ 版本与状态
| 组件 | 版本 | 地址 | 验收前 | 验收中/后 |
|---|---|---|---|---|
| Docker Engine | 29.6.2 | — | 已启动 | 未停止 ✔ |
| MySQL | 8.4.11 | 127.0.0.1:3307 | Exited(137) | healthy ✔ |
| Redis | 7.4-alpine | 127.0.0.1:6379 | Exited(0) | healthy ✔ |
| RabbitMQ | 3.13-management-alpine | 127.0.0.1:5672 / 15672 | Exited(137) | healthy ✔ |
启动方式:docker compose --env-file backend-infra/.env -f backend-infra/compose.yaml up -d(仅使用项目既有 Compose)。未执行 down -v、未删除/重建 volume、未修改 compose.yaml、未新建业务数据库、未打印凭据。
volume 清单(backend-infra_mysql_data / _redis_data / _rabbitmq_data)与验收前一致,未删除。
4. V44 Flyway 与 schema validation(真实 MySQL)
启动最新 JAR 前:flyway_schema_history 中 max(version)=43、COUNT(*)=43、V44 行数 0、V44 表不存在 —— 与「尚未执行 V44」的基线预期一致。
启动后:
| 项 | 值 |
|---|---|
version |
44 ✔ |
description |
candidate scoring refresh tasks |
type |
SQL ✔ |
success |
1 ✔ |
| V44 行数 | 1(唯一)✔ |
success=0 的迁移数 |
0 ✔ |
max_version / 总迁移数 |
44 / 44 ✔ |
启动日志:Started MyDreamjobApplication in 15.425 seconds;主 profile 为 default;ddl-auto=validate 通过,无 schema validation / SQL 语法 / Bean 装配错误。
表结构(SHOW COLUMNS/SHOW INDEX):
candidate_scoring_refresh_tasks:13 列id, student_user_id, target_period(7), status(20), requested_at, processing_started_at, processed_at, next_attempt_at, attempts, last_error_code(40), version, created_at, updated_at✔ 状态/重试/租约/version/时间字段齐全- 唯一键
uk_refresh_task_student_period (student_user_id, target_period)✔ - 领取索引
idx_refresh_task_claim (status, next_attempt_at)✔ - 约束齐全:
PRIMARY、uk_refresh_task_student_period(UNIQUE)、fk_refresh_task_student → users(id) ON DELETE CASCADE、ck_refresh_task_status CHECK (status IN ('PENDING','PROCESSING','RETRY','COMPLETED','FAILED'))✔ candidate_scoring_refresh_scan_checkpoints(scan_keyPK,last_user_id,version,updated_at)存在 ✔
V1–V43 全程未修改 ✔
5. 最新 JAR 启动与健康检查
- JAR:
mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar(mvn clean verify产出,82,440,778 bytes) - 启动方式:
docs/acceptance/r01-d4-2-20260916/start-app.ps1,显式提供DB_URL=127.0.0.1:3307、REDIS_HOST/PORT、RABBITMQ_*、SMS_MOCK_ENABLED=true、SCORING_REFRESH_*;未修改application.yml - 验收前 8080/8081 空闲;验收后已全部释放(无 java 进程残留)
GET http://127.0.0.1:8080/api/v1/health→ 200 ✔;http://127.0.0.1:8081/actuator/health→ 200 ✔- 启动耗时 14.9–15.4s,Flyway 成功,validate 成功
实例使用(方案 A):三个实例互斥启动、PID 各自记录:
| 用途 | PID | REFRESH | 说明 |
|---|---|---|---|
| 只读验证 #1 | 24624(失败,见下) / 22640(dev profile 失败) / 12656 | false | 三个目标 GET 零写验证 |
| 物化验证 | 23436 | true | 写侧链路 + 补偿扫描 |
| 只读验证 #2 | 28456 | false | 消费者回归 + 零写 |
| 重启验证 | 6576 | true | 跨重启游标延续 |
启动调试记录(自我暴露,如实记录):首次启动因 application-dev.yml 硬编码短密钥覆盖环境变量而失败;修正为默认 profile 后又因脚本以 UTF-8 无 BOM 保存、PowerShell 5.1 按 ANSI 解码中文注释导致换行被吞、JWT_ACCESS_SECRET 赋值行被并入注释(诊断值为 ACCESS_LEN=0)而失败;加 BOM 后 ACCESS_LEN=48 正常启动。此为验收脚本自身缺陷,与产品无关。
6. 自动化实际测试数
| 命令 | 结果 |
|---|---|
| 定向批次一(8 个 D4 核心类) | 55 / 55,F=0 E=0 S=0,BUILD SUCCESS(49.3s) |
定向批次二(*CandidateScoring*Test,*Competition*Test,*ScoringDashboard*Test,*FairnessMonitoring*Test) |
253 / 253,F=0 E=0 S=0,BUILD SUCCESS |
mvn clean verify |
2131 / 2131,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS(4:08,211 个测试类) |
python docs\verify_openapi.py |
paths: 176 / operations: 214,All checks passed ✔ |
批次一逐类:CandidateScoringRefreshInfrastructureTest 13/13、CandidateScoringRefreshFailureTransactionTest 3/3、CandidateScoringRefreshMySqlIntegrationTest 6/6(20.13s,真实 MySQL Testcontainers)、CandidateScoringEngineServiceTest 14/14、R01D4GetWritesSnapshotEvidenceTest 5/5、CandidateScoringPersistenceIntegrationTest 3/3(17.51s)、V38AbilityRankingIntegrationTest 1/1、CandidateScoringClockConsistencyTest 10/10。
Q01 与 V44 的 MySQL Testcontainers 用例真实执行(耗时 20.1s / 17.5s 表明容器真实启动),Docker 全程可用,无因环境缺失导致的 skip。
7. 三个目标 GET 与趋势 GET 的真实 HTTP 结果
前提:被测实例 SCORING_REFRESH_ENABLED=false(后台 Worker 与补偿扫描关闭),因此任何表变化都只能来自 HTTP 请求本身。
每个接口连续调用 3 次,共 12 次真实 HTTP:
| 接口 | HTTP | 契约 | 敏感字段 |
|---|---|---|---|
GET /api/v1/profile/me/candidate-scoring ×3 |
200,200,200 | 稳定(10 字段) | 无泄露 |
GET /api/v1/profile/me/candidate-scoring/dimensions/EDUCATION ×3 |
200,200,200 | 稳定(17 字段) | 无泄露 |
GET /api/v1/company/candidates/86/scoring ×3 |
200,200,200 | 稳定(6 字段) | 无泄露 |
GET /api/v1/profile/me/candidate-scoring/trend?months=6 ×3 |
200,200,200 | 稳定(3 字段) | 无泄露 |
- 响应不含
phone/email/resumeUrl/storagePath/accessToken/refreshToken/passwordHash,正则扫描无 11 位手机号。 - S1 零写验收:PASS=24 / FAIL=0 / SKIP=0
8. GET 前后逐字段数据库快照
快照覆盖 22 张表(显式列、稳定 ORDER BY 全列、逐行比对)。敏感列(手机号/邮箱/Token 摘要/文件路径/IP/UA/幂等键)以 SHA2(...,256) 参与比对以保持逐行可区分且不落真实值;非敏感列保留原始值。列不存在时 SQL 直接报错(不 catch)。
结果:22 张表逐字段零变化 ✔
| 关键表 | 行数 | 结果 |
|---|---|---|
candidate_dimension_scores |
88 | 零变化 ✔ |
candidate_scoring_refresh_tasks |
2 | 零变化 ✔ |
candidate_scoring_refresh_scan_checkpoints |
0 | 零变化 ✔ |
audit_logs |
2620 | 零变化 ✔ |
notifications |
158 | 零变化 ✔ |
messages |
8 | 零变化 ✔ |
candidate_scoring_snapshot_history |
4 | 零变化 ✔ |
最强证据:该只读实例下,验收学生 A(userId 8002)的 candidate_dimension_scores 在多次 GET 后仍为 0 行 —— 若 D4-2 前行为(GET 兜底写快照)仍存在,此处必为 8 行。同时任务表存在 1 行 PENDING 任务,证明写侧登记已发生但后台物化被正确关闭。
反证(扫描器有效性):snap.selftest() 对内存中的一行仅改动单个字段,扫描器正确检出 [('1', [('b','y','Z')])] → PASS。
9. 写侧事件 → 任务 → Worker → 快照的真实链路
前提:物化实例 SCORING_REFRESH_ENABLED=true,worker-interval=1500ms。
S2 结果:PASS=14 / FAIL=0 / SKIP=0
| 项 | 实测 |
|---|---|
W1 PUT /profile/me(资料更新) |
st=200;任务恰好 1 条 (8002, 2026-09);Worker 0.7s 处理为 COMPLETED;last_error_code 为空;八维快照齐备(8 行);rulesVersion 全部 candidate-scoring-v0.1 |
| 同事务登记 | 请求成功后立即出现任务行,无需等待 |
W2 PUT /profile/me/preferences |
st=200 → COMPLETED → 八维仍 8 行(无部分写) |
W3 POST /applications |
st=409(本轮已投递,幂等)→ 仍合并为 1 条任务 |
| 重复同一事件 ×3 | 仍只有 1 条任务,requested_at 推进不新增 |
| 完全相同输入重放 | 八维 version 全等(ABILITY:0, ACTIVITY:0, COMPLIANCE:1, EDUCATION:1, GROWTH:0, INTEGRITY:0, INTERNSHIP:0, STABILITY:0)→ version 不推进 ✔ |
candidate_scoring_snapshot_history |
4 → 4 零变化(普通 Worker 不写历史)✔ |
notifications |
158 → 158 零变化 ✔ |
audit_logs |
2620 → 2620 零变化 ✔ |
messages |
8 → 8 零变化 ✔ |
PROCESSING 防丢失:本项在真实 HTTP 下难以稳定卡住窗口,按任务书允许的方式引用真实 MySQL 并发集成测试 —— CandidateScoringRefreshInfrastructureTest#requestDuringProcessingIsNotLost 走真实完成路径 taskExecutor.claim → 处理期间新请求 → taskExecutor.materialize,断言回到 PENDING 而非 COMPLETED;CandidateScoringRefreshMySqlIntegrationTest 6/6 在真实 MySQL 上覆盖重登记与租约恢复。写侧正常主链路(W1/W2/W3)未跳过。
源业务失败不留残留任务:CandidateScoringRefreshFailureTransactionTest 3/3 覆盖外层业务回滚时登记一并回滚 ✔(该项为 H2/集成层证据;A2 中 ResumeVersionService.createVersion 的独立提交边界为静态代码发现,见 §2)。
10. 重复登记与幂等 version
- 同学生同周期重复登记合并为一行:
:56-71的ON DUPLICATE KEY UPDATE只推进requested_at、version,不新增行 ✔(黑盒 W1b 实测 1 行) COMPLETED/FAILED重新打开为PENDING且attempts归零;RETRY回到PENDING保留attempts✔(CandidateScoringRefreshInfrastructureTest#completedTaskCanBeRequestedAgain)PROCESSING期间新事件不丢失:登记保留status='PROCESSING'并推进任务version;完成时version不匹配即回退PENDING✔- 幂等物化:普通物化值完全不变时
version与updated_at不变(黑盒 W1b 确证)✔
11. 租约、重试与失败分类
recoverStale(TaskExecutor.java:29-41):WHERE status='PROCESSING' AND processing_started_at < ?,置RETRY(或达上限FAILED)、attempts+1、last_error_code='WORKER_TIMEOUT'、清空processing_started_at✔recordFailure(:91-108):attempts+1 >= maxAttempts → FAILED否则RETRY+next_attempt_at退避 ✔- 错误分类为固定字符串,不含异常原文/SQL/堆栈/手机号 ✔
- 调度兜底:
CandidateScoringRefreshScheduler.java:49-57 / :66-70各自 try/catch,单轮失败不影响后续轮次 ✔ - 真实 MySQL 证据:
CandidateScoringRefreshMySqlIntegrationTest6/6(含租约恢复、部分写失败全回滚且同批成功任务不受影响)
但:§2-A4 的两处缺陷(PENDING 分支缺 version 闸门、recordFailure 抛出打断 for 循环)表明「同批兄弟任务互不影响」在租约竞争边界下不成立。
12. 补偿游标、跨重启、单轮预算、多实例
参数:compensation-limit=2,compensation-interval=4000ms。
| 验证项 | 实测证据 |
|---|---|
| 单轮登记数 ≤ limit | 连续 12 轮 created_at 分组计数全部为 2,无一超标 ✔ |
| 游标持久推进 | last_user_id 113 → 126 → 130 → 134 → 138 → 142 → 146…,version 每轮 +1 ✔ |
| 不会永远只扫首批用户 | 游标推进至 7948 → 7954 → 7963 → 7967 → 7973 → 8002(接近学生最大 id 8005)✔ |
| 扫到尾部后从头继续 | 8002 之后下一轮 last_user_id 回到 15 → 20 … ✔ |
| 跨重启从持久游标继续 | 重启前 cursor=113 / version=104;重启后首轮 cursor=126 / version=105(version 恰好 +1)—— 未回到 0 ✔ |
| 只读实例不扫描 | SCORING_REFRESH_ENABLED=false 下 cursor=113/104 12 秒内完全冻结 ✔ |
| 多实例并发不重复推进 | 代码层 lockCursor 的 SELECT ... FOR UPDATE 串行化 + 游标推进与登记同事务 ✔(未做双 JAR 黑盒,见 §23 SKIP) |
| 每轮查询预算有上限 | 1 次聚合查询 + ≤2 次 upsert,无 N+1 ✔ |
| 不直接写快照 | 扫描器文件内无 candidate_dimension_scores 写入 ✔ |
任务终态:candidate_scoring_refresh_tasks 共 181 行,181/181 全部 COMPLETED,无 PENDING / RETRY / FAILED / 永久 PROCESSING ✔
13. 趋势回归
GET /profile/me/candidate-scoring/trend?months=6→ 200;当前周期2026-09存在;overallScore=65.2;rulesVersion=candidate-scoring-v0.1;维度数 5(趋势按既有语义只收录OK/ESTIMATED维度,非 8 —— 与CandidateScoringEngineService.java:91的过滤逻辑一致)- 多次调用结果逐字节稳定 ✔
- 请求本身零写(逐字段比对)✔
- 能读到后台物化的当前周期快照 ✔(该数据即由 §9 的 Worker 物化产生)
14. 排名回归
GET /jobs/47/competition-ranking→ 200;rank=1、cohortSize=2、overallScore=65.2、percentile=100.0、asOfPeriod=2026-09- 验收学生(8002)与对照学生(8003)各有 8 维快照,排名未因 GET 停写而缺失候选人 ✔
- 快照经真实物化入口产生,未直接插入伪造 ✔
- 只读 ✔
15. 企业看板回归
| 接口 | 结果 |
|---|---|
GET /company/scoring/dashboard/overview |
200;companyId=27, totalJobs=1, publishedJobs=1, totalCandidates=2, avgCandidateScore=61.7 |
GET .../job-rankings?page=0&size=20 |
200;含验收岗位 47 |
GET .../candidate-rankings?page=0&size=20 |
200;applicationId=86 → overallScore=65.2, percentile=100.0;applicationId=87 亦在列 |
GET .../trend?months=6 |
200;2026-09, avgCandidateScore=61.7, candidateCount=2 |
- 数据来自物化快照 ✔;多次 GET 零写 ✔
- 越权负例:企业 B 访问企业 A 的投递评分 → 403 ✔
16. 申诉与强制 version 回归
- 学生创建隔离申诉 → 200;
appealId=4289;dimension=EDUCATION、period=2026-09、status=PENDING scoreAtAppeal=76.00、snapshotVersionAtAppeal=1均非空 ✔ —— 关键:D4-2 停写后申诉取证仍能取到快照,因为写侧登记 + Worker 已先行物化GET /profile/me/candidate-scoring/appeals(我的列表)与/appeals/4289(详情)均 200 ✔GET /admin/candidate-scoring/appeals→ 200,total=8(历史 7 条 + 本轮 1 条)✔- 申诉副作用核对:相对上一快照仅
candidate_scoring_appeals +1、candidate_scoring_appeal_history +1、audit_logs +1,其余表零变化(含candidate_dimension_scores、notifications、messages、snapshot_history、refresh_tasks)✔ - 未执行审核/回滚(避免改动非本轮治理数据),因此「rollback 后强制 version 恰好 +1」本项未做黑盒,改由
CandidateScoringCalculatorTest#recomputeAndSnapshot_*+CandidateScoringGovernancePersistenceIntegrationTest(第二批 253/253 内)覆盖,静态确认CandidateScoringSnapshotWriter.java:75-88无条件version=version+1。见 §23 SKIP。 - 普通 Worker 物化不写
snapshot_history✔(黑盒 4 → 4)
17. 公平性监控回归
以 ADMIN(复用既有活跃管理员 userId 91,未恢复 V1_1 旧演示账号 id=1)调用:
| 接口 | 结果 |
|---|---|
GET /admin/scoring/fairness/overview?period=2026-09 |
200,basis=CURRENT_STORED_SNAPSHOTS、scope=SCORED_SNAPSHOT_POPULATION |
.../dimensions?period=2026-09 |
200 |
.../drift |
200,availability=UNAVAILABLE, reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE(既有抑制语义保留) |
.../ranking-stability |
200,availability=UNAVAILABLE, reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE(既有抑制语义保留) |
.../appeals |
200,basis=APPEAL_CREATION_QUEUE_CURRENT_STATE |
- 小样本抑制与互补风险抑制保持(以上
UNAVAILABLE即为抑制输出)✔ - 不泄露候选人身份(响应扫描无
subjectUserId/手机号/邮箱)✔ - 当前快照聚合可用 ✔;GET 零写 ✔
18. 数据库副作用
允许的变化(全部落入任务书允许范围):
- 本轮唯一前缀夹具(创建后已按主键清理,见 §22)
- 写侧业务请求按契约产生的行(
student_profiles、student_preferences、applications、jobs、companies、notifications) - 刷新任务与候选人快照:
candidate_scoring_refresh_tasks共 181 行(其中 4 行为本轮学生,其余为补偿扫描按设计登记的其他活跃学生),candidate_dimension_scores相应物化行 candidate_scoring_refresh_scan_checkpoints1 行(游标)- 本轮申诉 1 条 + 申诉历史 1 条 + 相应审计 1 条
明确未发生:
- 非本轮用户的业务数据(
users/companies/jobs/applications等)未被删除或改写 —— 唯一受影响的是其候选人快照,且这是启用生产后台补偿扫描后的设计内正常行为(任务书明确允许「候选人快照」变化) - 评分规则、权重、规则版本、排名规则未变
candidate_scoring_snapshot_history在普通 Worker 场景下为零变化(4 → 4)✔ —— 该表的 4 行历史数据全程未被普通物化触碰- 既有历史快照未被改写(version 语义为「变化才 +1」)
- 非本轮审计未变
19. RabbitMQ 前后状态
队列(vhost /)在验收前与验收后分别查询:
| 队列 | 验收前 ready/unacked | 验收后 ready/unacked |
|---|---|---|
queue.sms.verification |
0 / 0 | 0 / 0 |
queue.notification |
0 / 0 | 0 / 0 |
queue.notification.dlq |
0 / 0 | 0 / 0 |
queue.audit |
0 / 0 | 0 / 0 |
4 个队列真实存在,终态 ready=0、unacked=0,D4-2 的 GET、任务登记、Worker、补偿扫描全程未新增任何消息 ✔
代码层佐证:candidatescore/refresh/ 全包对 rabbit|Rabbit 的匹配仅出现在 3 处 javadoc 注释(「不发送 RabbitMQ」),无任何 RabbitTemplate/convertAndSend/NotificationService/AuditService 引用 ✔
20. 静态与运行时 OpenAPI
| 项 | 静态 docs/openapi-rc.json |
运行时 /api/v1/v3/api-docs |
|---|---|---|
| paths | 176 | 176 |
| operations | 214 | 214 |
| method+path 集合 | — | 完全一致(only-runtime=[], only-static=[]) |
python docs\verify_openapi.py→All checks passed!(含 V36/V37/V39–V43/N02/N07/S02/S03 各项契约校验)- D4-2 未新增公网刷新、任务、补偿、管理接口 ✔
- 四个评分 GET 响应 schema 不变(S1 实测响应字段集合与既有契约一致)✔
21. 日志脱敏
扫描对象:本轮 6 个 JAR 日志/err 文件 + 2 个 Maven 测试日志,共 8 个文件。
探针有效性:临时文件注入 Authorization: Bearer <JWT>、11 位手机号、「验证码 482913」、DB_PASSWORD=…,扫描器命中 5 类 → [PASS] 扫描器有效;探针文件已删除 ✔
真实命中与甄别:
| 类别 | 命中 | 甄别结论 |
|---|---|---|
| 「口令赋值」 | 63 | 全部为同一模式:Spring Boot UserDetailsServiceAutoConfiguration 启动时打印的 Using generated security password: <UUID>。经字符集核验为大小写十六进制 + 连字符的 UUID,非数据库口令、非 JWT 密钥、非项目任何凭据,且项目采用 JWT 认证不使用该内存用户。判定为误报。 |
| 11 位手机号 | 1 | 位于 clean-verify.log,上下文为既有测试 AdminBootstrapConcurrencyLoggingTest 的 S03_SCANNER_PROBE_<值> 扫描器探针(测试夹具),非真实用户号码 |
| Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | 0 | ✔ |
应用运行日志(r01d42-*.log)中手机号、Token、验证码、JWT、简历路径命中均为 0 ✔
报告与本文件均未复制任何真实凭据值。
22. 结束处理
进程:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 Get-Process java 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。Docker 未停止(按任务书要求)。
数据清理(仅本轮精确主键,无模糊 DELETE):
| 表 | 删除前 | 删除后 |
|---|---|---|
users(8002/8003/8004/8005) |
4 | 0 ✔ |
companies(27/28) |
2 | 0 ✔ |
jobs(47) |
1 | 0 ✔ |
applications(86/87)+ application_status_history |
2 + 2 | 0 ✔ |
candidate_dimension_scores |
32 | 0 ✔ |
candidate_scoring_refresh_tasks |
4 | 0 ✔ |
candidate_scoring_appeals / appeal_history |
1 / 1 | 0 ✔ |
student_profiles / student_preferences |
2 / 1 | 0 ✔ |
notifications |
2 | 0 ✔ |
auth_tokens / user_roles / sms_verification_codes |
6 / 6 / 6 | 0 ✔ |
清理前做了归属校验:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(phone IN (4 个号码) 与 id IN (…) 双向匹配数均为 4,完全一致),否则脚本 [ABORT] 拒绝删除。删除按外键顺序执行(两轮:首轮被 application_status_history 阻断后补删)。
保留并登记:audit_logs 中 actor 为本轮 4 个用户的 15 行 属 append-only 合规记录,按任务书要求不冒险删除,保留在库中。关联 ID:user_id ∈ {8002, 8003, 8004, 8005}。
唯一允许的测试夹具(精确 INSERT,已登记):user_roles 插入 (8004,'COMPANY')、(8005,'COMPANY') 各 1 行 —— 原因:项目无任何公网接口可授予 COMPANY 角色(CompanyService.java:76 assertCompanyRole 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 user_roles WHERE user_id IN (…) 一并删除。
Git 收尾:分支 refactor/r01-structure-performance;暂存区空;未 git add -A;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;git diff --check 无空白错误。未进入下一项 R01 工作。
23. PASS / FAIL / SKIP 清单
PASS
- Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误)
- A1 三个 GET 真正只读(代码 + 真实 HTTP 双证)
- A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚)
- A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照)
- A6 配置无断档(生产默认启用,测试 profile 显式关闭)
- Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy
- V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0)
ddl-auto=validate通过,无 schema/SQL/Bean 装配错误- 最新 JAR 正常启动;健康检查 8080/8081 均 200
mvn clean verify2131/2131,F=0 E=0 S=0- 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行)
- 三个目标 GET 真实零写(22 张表逐字段不变;学生快照保持 0 行)
- 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3)
- 重复登记合并 + 完全相同输入重放 version 不推进
- 普通 Worker 不写
snapshot_history、不写通知/审计/RabbitMQ - 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕
- 趋势回归(可读物化快照、稳定、零写)
- 排名回归(cohortSize=2、双学生均有快照)
- 企业看板回归(4 端点在列、越权 403)
- 申诉回归(取证字段非空、副作用限于契约表)
- 公平性监控回归(5 端点、抑制语义保留、零写)
- RabbitMQ 终态 4 队列 ready=0/unacked=0
- 静态与运行时 OpenAPI 均 176/214 且集合完全一致
git diff --check、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露)- 隔离数据精确清理,无残留
FAIL(4 项)
| # | 缺陷 | 位置 | 命中判据 |
|---|---|---|---|
| F1 | Offer 自动过期改变评分输入但不登记刷新任务 | offer/OfferExpiryWorker.java:48(登记计数=0);评分读取 candidatescore/CandidateScoringCalculator.java:188-194 |
「写侧成功但任务未登记」 |
| F2 | 候选人反馈的修改/删除改变评分输入但不登记刷新任务 | feedback/CandidateFeedbackService.java:107、:124(仅 :82 创建时登记);评分读取 CandidateScoringCalculator.java:196-200 |
「写侧成功但任务未登记」 |
| F3 | createVersion 登记与独立提交的版本行事务边界不对称 |
resume/ResumeVersionService.java:71(外层事务)vs 同文件 :52-55 自述的 REQUIRES_NEW 独立提交 |
「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 |
| F4 | 测试断言弱化(红线:禁止修改或放宽既有测试) | test/.../candidatescore/CandidateScoringCalculatorTest.java:248 起,ABILITY 溯源 payload 的敏感字段黑名单断言(answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email 共 10 项)被删除,无等价替代 |
「修改或放宽既有测试」 |
FAIL(次生,A4,未单独计入但需一并整改)
| # | 缺陷 | 位置 |
|---|---|---|
| F5 | materialize 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING |
refresh/CandidateScoringRefreshTaskExecutor.java:78-88 |
| F6 | recordFailure 在 updated != 1 时抛异常,且异常点位于 Worker 的 catch 块内、processDue 的 for 循环无保护 → 同批剩余任务本轮全部不处理 |
refresh/CandidateScoringRefreshWorker.java:83 + :63-67 + CandidateScoringRefreshTaskExecutor.java:105-107 |
SKIP(2 项,均非核心判据)
- 多实例并发补偿扫描双 JAR 黑盒:以代码层
SELECT ... FOR UPDATE串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。 - 申诉审核/回滚黑盒:为避免改动非本轮治理数据(
candidate_scoring_rollback_limits等)未执行 rollback,改由CandidateScoringCalculatorTest#recomputeAndSnapshot_*与CandidateScoringGovernancePersistenceIntegrationTest覆盖(第二批 253/253 内),静态确认强制 version 路径。
BLOCKED
无。
24. 剩余风险
- F1/F2 的实际影响受补偿扫描部分缓解:补偿扫描的资格条件为「近 90 天有投递 或 近 30 天有审计」(
CandidateScoringRefreshCompensationScanner.java:89-99)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生近 90 天无投递、近 30 天无审计,则其快照将永久停留在过期值,无任何兜底路径。 - F3 触发概率低但后果不可逆:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。
- F5/F6 触发前提为物化耗时超过
processing-timeout-ms(默认 600s),正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。 - 补偿扫描会物化全库活跃学生的快照:启用生产后台刷新后,单实例每轮最多
compensation-limit个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。 CandidateScoringRefreshInfrastructureTest未验证 8 个写侧 Service 真的调用了requestRefresh:现有测试直接驱动requestService/taskExecutor,各 Service 单测只补了@Mock依赖而无一处verify(...).requestRefresh(...)。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。- 注释与现实不符(低危):
CandidateScoringCalculator.java:582(「含 GET 兜底」)、CandidateScoringRefreshScheduler.java:15、D4-1 报告 §20 的「默认关闭」表述均已过时。
25. 是否允许冻结 D4-2
不允许冻结。
R01-D4-2 的核心目标(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「写侧成功但任务未登记」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。
26. 下一轮定向整改指令
必做(阻断冻结)
R1. 补齐 Offer 自动过期的评分刷新登记
- 文件:
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java - 方法:
expireOne(Long offerId)(:41),在:48 setStatus(EXPIRED)成功且:51 saveAndFlush之后、与该方法同一REQUIRES_NEW事务内,追加:scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock)) - 注入
CandidateScoringRefreshRequestService;OfferExpiryWorker已使用clock,capturedNow应复用同一时间源 - 不得因此改动
OfferService#transitionStatus的既有语义 - 补测试:真实驱动
expireOne后断言candidate_scoring_refresh_tasks出现/合并一条(candidateId, 当期)任务
R2. 补齐候选人反馈修改/删除的评分刷新登记
- 文件:
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java - 方法:
updateFeedback(…)(:107)与deleteFeedback(…)(:124),在各自成功路径末尾、同一@Transactional内追加requestRefresh(app.getStudentUserId(), LocalDateTime.now())(deleteFeedback注意在删除前先取得studentUserId) - 与既有
:82 createFeedback保持完全一致的调用形态 - 补测试:
updateFeedback改变rating后断言任务被登记/合并
R3. 修正 createVersion 的登记事务边界
- 文件:
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java - 问题:
:71的登记位于外层事务,而版本行经versionWriter.saveNewVersion(REQUIRES_NEW)独立提交;外层提交阶段失败会造成「数据已提交、登记丢失」 - 要求(二选一,须在报告中说明选择理由):
(a) 将
requestRefresh增补为Propagation.REQUIRES_NEW的独立调用(放在版本行成功返回之后);或 (b) 在createVersion返回后由调用方在已提交的上下文中登记(TransactionSynchronization#afterCommit) - 补测试:模拟外层事务回滚,断言版本行存在时必定存在对应刷新任务
R4. 修正 materialize 的 PENDING 回退闸门
- 文件:
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java - 位置:
:78-88 - 要求:
status='PENDING'的回退UPDATE增加AND version=?(与 claim 时记录的claimedVersion比对);updated != 1时应静默返回(该任务已被recoverStale或新 Worker 接管,属正常竞争),不得抛IllegalStateException
R5. 隔离单任务失败对同批任务的影响
- 文件:
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java - 位置:
:63-67(processDue循环)与:83(catch 内recordFailure) - 要求:
processDue的 for 循环体加try/catch,或让recordFailure自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与:24javadoc 的承诺一致
R6. 恢复被删除的敏感字段断言(红线项)
- 文件:
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java - 位置:
:248起(原for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))断言) - 要求:在新的委托架构下重建等价断言 —— 序列化
CandidateScoringSnapshotWriter为 ABILITY 维度生成的sub_scores_json(可通过CandidateScoringRefreshInfrastructureTest走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段 - 不得以「类型安全」为由省略:
weights/dimensionScores为Map,其 key 不受编译期保护
R7. 补齐写侧登记的行为验证
- 文件:
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java - 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一
verify(scoringRefreshRequestService, times(1)).requestRefresh(…);覆盖失败回滚不登记的负例
建议(不阻断,但应同批处理)
R8. 修正过期注释:CandidateScoringCalculator.java:582、CandidateScoringRefreshScheduler.java:15,以及 docs/r01-d4-1-scoring-materialization-implementation-report.md §13/§20 中「补偿扫描为简化版/默认关闭」的表述。
R9. 补 CandidateScoringRefreshInfrastructureTest 中缺失的黑盒项:多实例并发扫描(双实例 scanDueStudents 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。
R10. 评估 getTrend 的时钟一致性:CandidateScoringEngineService.java:78 使用 LocalDate.now() 而非注入的 clock(预存在问题,非本轮引入)。
整改后复验要求
完成 R1–R7 后,重新执行:mvn clean verify(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。
附:本轮验收产出物(docs/acceptance/r01-d4-2-20260916/)
| 文件 | 说明 |
|---|---|
acc_lib.py |
HTTP/DB/登录/前缀公共库 |
snap.py |
22 表显式列快照 + 差异比较 + 自检反证 |
p1-provision.py / acc-context.json / acc-prefix.txt |
隔离验收数据创建 |
s1-zerowrite.py / snap-before-get.json / snap-after-get.json |
三个 GET 零写 |
s2-writepath.py |
写侧链路 |
s4-consumers.py / s4-out.txt / snap-after-consumers.json |
消费者回归 |
logscan.py |
日志脱敏扫描(含探针) |
cleanup.py |
精确主键清理 |
start-app.ps1 + r01d42-*.log/.err.log/.pid/.envdiag.txt |
实例启动与运行日志 |
q01–q13*.sql |
只读 SQL 证据 |
runtime-openapi.json |
运行时 OpenAPI |
clean-verify.log / batch2.log |
自动化构建日志 |
本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、
.env内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(p1-provision.py首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。