Compare commits
26 Commits
master
...
fix/mysql5
| Author | SHA1 | Date |
|---|---|---|
|
|
c6ccab5404 | 5 hours ago |
|
|
af80abddf7 | 4 days ago |
|
|
7f5c22575f | 4 days ago |
|
|
e46efe55ac | 5 days ago |
|
|
d5889ba98c | 6 days ago |
|
|
2770d4c544 | 6 days ago |
|
|
216267c628 | 7 days ago |
|
|
67b095724b | 1 week ago |
|
|
41ab7ba9d4 | 1 week ago |
|
|
91cbf6cd4d | 1 week ago |
|
|
1cbadc3e18 | 2 weeks ago |
|
|
5841e09190 | 2 weeks ago |
|
|
554c6eaef4 | 2 weeks ago |
|
|
6fff2a2217 | 2 weeks ago |
|
|
439c25be9c | 2 weeks ago |
|
|
fa6555d9e5 | 2 weeks ago |
|
|
c3ca89a186 | 3 weeks ago |
|
|
f966dfa291 | 3 weeks ago |
|
|
43739f42ed | 3 weeks ago |
|
|
0609b9422a | 3 weeks ago |
|
|
5f82c125ed | 3 weeks ago |
|
|
b1d9dd2d31 | 4 weeks ago |
|
|
80969c65c3 | 4 weeks ago |
|
|
32fb6470a4 | 4 weeks ago |
|
|
9c14abd498 | 4 weeks ago |
|
|
bcea6b0295 | 4 weeks ago |
504 changed files with 53294 additions and 4421 deletions
@ -0,0 +1,18 @@ |
|||
# 实时聊天转发片段:合并到现有 HTTPS server 中,不要覆盖整个 Nginx 配置。 |
|||
# 部署前核对真实后端地址、端口以及 /api/v1 context-path。 |
|||
# 此示例仅用于单后端实例;多实例需要共享消息代理,不能仅添加负载均衡。 |
|||
# 前端使用 wss://你的业务域名/api/v1/ws;Token 放在 STOMP CONNECT 头,不放 URL。 |
|||
location = /api/v1/ws { |
|||
proxy_pass http://127.0.0.1:8080; |
|||
proxy_http_version 1.1; |
|||
proxy_set_header Upgrade $http_upgrade; |
|||
proxy_set_header Connection "upgrade"; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_read_timeout 75s; |
|||
proxy_send_timeout 75s; |
|||
proxy_buffering off; |
|||
} |
|||
# 握手限流目前按直接连接来源计数;经过反向代理时来源可能都是网关。 |
|||
# 大规模部署需在网关增加按可信真实 IP 的限流,并核对后端限流配置。 |
|||
@ -0,0 +1,77 @@ |
|||
# N03 行业技能词云来源与覆盖清单 |
|||
|
|||
## 1. 固定来源 |
|||
|
|||
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
|||
| --- | --- | --- | --- | --- | |
|||
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
|||
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
|||
|
|||
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
|||
|
|||
## 2. 首版覆盖边界 |
|||
|
|||
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
|||
|
|||
| 行业 | 代表性职位 | 首批技能分类范围 | |
|||
| --- | --- | --- | |
|||
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
|||
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
|||
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
|||
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
|||
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
|||
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
|||
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
|||
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
|||
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
|||
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
|||
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
|||
|
|||
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
|||
|
|||
## 3. 导入规则 |
|||
|
|||
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
|||
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
|||
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
|||
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
|||
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
|||
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
|||
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
|||
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
|||
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
|||
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
|||
|
|||
## 4. 来源版本、许可与归属 URL |
|||
|
|||
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
|||
| --- | --- | --- | --- | --- | |
|||
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
|||
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
|||
|
|||
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
|||
|
|||
## 5. 映射责任边界 |
|||
|
|||
| 事项 | 上游来源责任 | 项目责任 | |
|||
| --- | --- | --- | |
|||
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
|||
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
|||
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
|||
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
|||
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
|||
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
|||
|
|||
## 6. 验收检查项 |
|||
|
|||
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
|||
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
|||
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
|||
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
|||
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
|||
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
|||
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
|||
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
|||
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
|||
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
|||
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
|||
@ -0,0 +1,47 @@ |
|||
# AI-001 工作经历AI优化前端对接方案 |
|||
|
|||
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。 |
|||
|
|||
|
|||
日期:2026-10-23。交付范围:后端AI建议生成;前端由前端负责人实施,复用原页面及保存流程。 |
|||
|
|||
POST /api/v1/profile/me/work-experience-optimization,Content-Type application/json,Authorization: Bearer <access-token>,仅当前STUDENT。不得传用户ID。 |
|||
|
|||
```json |
|||
{"position":"后端开发工程师","description":"负责订单查询接口开发,使用Java和MySQL,参与问题排查。","targetPosition":"Java后端开发","externalProcessingConsent":true,"consentNoticeVersion":"work-experience-external-processing-v2"} |
|||
``` |
|||
position、targetPosition可选各最多100字符;description必填非空最多3000字符。2026-10-26 后端新候选要求 externalProcessingConsent 为原生布尔 true,且 consentNoticeVersion 与本次告知接口一致;旧请求缺版本将被拒绝。具体展示、拒绝及重新确认流程见 [当前告知与同意接入说明](ai-work-experience-consent-frontend-20261026.md)。下文未涉及该版本字段的来源记录仅作历史。 |
|||
|
|||
成功HTTP200使用ApiResponse,code=200、message=ok,data字段:status为OPTIMIZED/NEED_MORE_INFO;optimizedDescription为建议,信息不足可null或省略;suggestions、followUpQuestions为字符串数组;promptVersion为后端确定的提示词版本标识(不代表内容事实已校验);simulated标识是否模拟。真实模式为false/work-experience-deepseek-v2,stub为true/work-experience-v1,不限制dev profile。前端不要按固定提示词版本阻断展示。无流式或分页;429读取Retry-After但不自动重试,Redis故障返回503。 |
|||
|
|||
错误HTTP400参数或敏感输入拒绝(code可能400/1001);401未登录;403角色拒绝(可能code2002);404用户不存在;502无效模型结果;503关闭或模型不可用;504超时。模型错误仅安全固定文案,不回传上游正文。HTTP状态与业务code不能一概等同。 |
|||
|
|||
按钮流程:取当前一条描述→显示加载并防重复点击→展示原文/建议/补充问题→允许编辑、采用、取消→采用回填工作内容→沿用已有保存。失败/取消保留原文,原文已改变则丢弃旧响应。可设置25秒前端等待,超时不自动重试(供应商仍可能计费)。本接口不写数据库,不改变公司/时间、评分或企业可见性。 |
|||
|
|||
验证:源目录离线22项20通过2跳过;用户本机单例真实调用与5例合成质量评审有证据。目标仓库移植验证见后端整合说明。实际启动后的HTTP/JWT黑盒、前端、完整生产数据治理与至少30例评测仍未完成。当前只允许dev合成联调,告知文案按用户要求暂缓,不视为可上线。 |
|||
|
|||
## v2 规则与版本(后续修正候选) |
|||
|
|||
历史验证段描述来源交付时的状态。随后 v1 在目标工程完成离线/JAR 链路验证和单次真实联调,但质量批次在第 29 例因“不负责”被改成“不参与”而停止,第 30 例未执行,不能作为质量放行。 |
|||
|
|||
v2 要求保留否定条件、参与程度、指导/责任/权限边界及时间/上线状态。补充建议必须以“仅在真实发生且可核实时补充”为前提;原文已清晰时允许不改并诚实说明。采用前仍应让用户核对,不默认覆盖原文;API 字段、路径、保存流程、stub 和默认关闭行为均不变。 |
|||
|
|||
## 离线验证边界 |
|||
|
|||
离线回归只校验 v2 资源与版本一致、规则被发送、原文无损传递、合成响应解析及权限等程序行为;不能证明真实模型遵守规则,也不代表输出已脱敏或事实忠实。 |
|||
|
|||
## 待授权真实复验 |
|||
|
|||
v2 尚未执行真实模型质量复验。需用新候选制品重新验证已失败及边界遗漏用例,再经单独授权完成至少 30 例质量评测;不得复用 v1 通过项宣称 v2 质量通过。当前不发布、不开放生产真实模式。 |
|||
|
|||
## 2026-10-26 信息不足修正候选 |
|||
|
|||
### 规则与版本 |
|||
|
|||
此前 A3 v2 已实际复验 16 次:15 例质量通过,Q09“暂无相关经历。”被返回为 OPTIMIZED 后停止,14 例未执行,不是完整通过。新候选对有限登记的无经历、占位、空泛评价和纯愿望语句增加本地不足信息判定;只有整段各分句均符合才直接要求补充,短真实职责及含具体事实的混合句不因长度或“暂无”字样被拒绝。模型若返回同类无事实语句,也归一为空描述。 |
|||
|
|||
接口字段和路径不变。`status=NEED_MORE_INFO` 时展示 `followUpQuestions`,不显示“优化成功”、不启用采用/覆盖原文;`optimizedDescription` 可 null 或省略。`simulated=false` 仅代表真实运行模式,不保证本次调用过供应商,本地规则也可能直接返回;不得用它推算计费或质量案例数。提示词仍为 v2,新 JAR/基线哈希区分本次规则修正。 |
|||
|
|||
### 离线验证边界与真实复验状态 |
|||
|
|||
新回归覆盖本地不足信息、短真实经历及混合事实放行、权限/同意/敏感输入保护和无自动保存。新基线的全量、同次 JAR 验证与真实模型质量需分别记录;原有 v1/A3 质量失败保留。有限规则不保证识别所有自然语言;新候选未据此开放生产。本批不改前端,以上供后续联调对接。 |
|||
@ -0,0 +1,72 @@ |
|||
# AI-001 工作经历AI优化后端整合说明 |
|||
|
|||
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。 |
|||
|
|||
|
|||
日期:2026-10-23;目标仓库baskbuddy/zhangshun,分支qingsongpin-AIDevelop,移植基线8c94291;由用户提交推送。本交付尚无AI提交SHA。 |
|||
|
|||
## 功能与文件 |
|||
新增mydreamjob-server/src/main/java/com/mydreamjob/resume/optimization/下12个Java文件:Controller入口、Service权限与校验、Router模式选择、局部Advice安全错误、请求响应DTO、模型接口、模拟及DeepSeek实现、配置、输入guard和模型异常。 |
|||
新增src/main/resources/ai/下work-experience-v1.txt及work-experience-deepseek-v1.txt;新增src/test/java/com/mydreamjob/resume/optimization/下8个测试类。src均相对mydreamjob-server。 |
|||
修改docs/openapi-rc.json仅新增AI路径;docs/verify_openapi.py同步183 paths/221 operations和AI断言;docs/resume-api.md追加模块说明。交付本说明、前端对接方案和质量评审。无新依赖、迁移、数据库写入、全局认证或配置文件修改。 |
|||
|
|||
## 配置与启动 |
|||
|
|||
2026-10-26 告知候选接口变更:优化前读取 `GET /profile/me/work-experience-optimization/notice`,每次优化须提交原生布尔 true 和当前 `consentNoticeVersion`;缺失/旧版本拒绝,不向模型转发同意元数据。旧 DTO 四字段请求不再可用。日志和前端交互边界详见 [当前告知与同意说明](ai-work-experience-consent-frontend-20261026.md);下文来源配置/历史结果不替代本批验证。本批仍仅开发合成资料,不是生产合规批准。 |
|||
复用原后端基础设施/JWT运行配置;不提交任何真实凭据。模式配置AI_WORK_EXPERIENCE_MODE=off/stub/deepseek,默认off;未指定mode且旧ai.work-experience.stub-enabled=true时兼容stub。deepseek要求AI_WORK_EXPERIENCE_REAL_ENABLED=true,保持原有运行profile,不再要求dev。显式mode优先且非法值失败,Key缺失不降级。真实外呼受Redis额度及并发门禁保护,详细配置见A7正式环境配置说明。 |
|||
DEEPSEEK_API_KEY通过本机或部署环境安全注入;DEEPSEEK_MODEL默认deepseek-flash,可选deepseek-v4-pro;DEEPSEEK_TIMEOUT_SECONDS默认20秒(1至60)。无需开启DEEPSEEK_LIVE_TEST或DEEPSEEK_QUALITY_TEST启动服务。可选SERVER_PORT=8081避免冲突,API前缀/api/v1;生产政策与前端同意展示须另外确认。 |
|||
官方端点固定https://api.deepseek.com/chat/completions,非流式、关闭思考、JSON、1500最大输出token,不跟随重定向、不自动重试,64KiB响应上限。异常不携带原始cause或上游正文。输入guard只覆盖列举模式,不保证全面脱敏;JSON结构不能证明事实忠实。 |
|||
|
|||
## 整合与验收 |
|||
先检查AI路径及共享契约冲突,保留目标其他模块;不整包覆盖认证/业务代码。目标定向命令在mydreamjob-server执行: |
|||
```powershell |
|||
mvn -q '-Dtest=*WorkExperience*Test' test |
|||
``` |
|||
离线验证时保证DEEPSEEK_LIVE_TEST和DEEPSEEK_QUALITY_TEST未设为true,否则会触发付费真实请求;两项默认跳过。在仓库根执行python ./docs/verify_openapi.py。 |
|||
负责人后续在隔离开发环境启动,以STUDENT开发账号向POST /api/v1/profile/me/work-experience-optimization发送合成请求,检查simulated=false、promptVersion=work-experience-deepseek-v1及错误路径;请求示例见前端方案。不要用refresh-token访问业务接口。完整JWT过滤链及实际HTTP尚未验收,不能用standalone MockMvc替代。生产限流、数据政策供应商确认、正式告知/授权及至少30例评测尚未完成;不得直接生产发布。 |
|||
源历史证据:用户IDEA单例真实调用通过;5例合成质量评审未见描述事实新增,有表达提升有限及建议措辞观察项,见质量评审。本次不会重复外呼。 |
|||
|
|||
## 回滚与交付边界 |
|||
回滚先mode=off重启;必要时撤销本批精确文件,无数据库回滚。无自动保存、前端修改、评分变化;复用现有保存流程。暂停AI-002不在本交付。不要提交target、日志、.idea、.env、真实Key或用户资料。用户核对精确暂存清单后自行提交/推送,先确认分支CI是否自动部署;助手不提交推送、不触发Jenkins。 |
|||
|
|||
|
|||
## 目标仓库实际验证(2026-10-23) |
|||
在qingsongpin-AIDevelop基线8c94291移入后,定向测试22项:20通过、2跳过、0失败0错误;OpenAPI183 paths/221 operations及AI断言通过;git diff --check通过。Key长字串模式扫描未发现疑似真实Key(仅模式检查,不代表全面秘密检测)。无生产环境、HTTP黑盒或真实模型新增调用;未提交/推送。关键认证及pom未修改。 |
|||
|
|||
## v2 规则与版本(目标工程后续修正) |
|||
|
|||
上文为来源交付时的历史记录,不是目标工程的最新验收结论。目标工程已合并其他模块,静态契约为 212 paths / 260 operations。v1 质量评测计划最多 30 次,实际在第 29 例因职责边界语义偏差停止,1 例未执行;另有改写价值有限、显式限制省略等观察项。 |
|||
|
|||
新增资源 `ai/work-experience-deepseek-v2.txt`,真实适配器用 `PROMPT_VERSION` 推导资源路径,Router 用同一常量返回版本。旧 v1 保留为历史,不再由真实适配器加载。stub 仍用 `work-experience-v1`,默认 off、dev 门禁、超时配置、无自动保存等行为不变。 |
|||
|
|||
v2 明确禁止把“不负责”变成“不参与”,不得省略或增强否定范围;保留指导、协助、权限、次数和上线状态。事实不足时追问,原文清晰时可保持原文并说明,不以制造改动替代真实价值。 |
|||
|
|||
## 离线验证能力边界 |
|||
|
|||
`WorkExperiencePromptRegressionTest` 用本机合成上游覆盖 Q29、Q16/Q17/Q21/Q24 及测试/上线和权限边界;检查发送的系统规则、用户原文与结构解析,不是事实语义校验器。返回版本和 JSON 结构通过不能证明模型忠实度。 |
|||
|
|||
离线执行仍必须关闭 `DEEPSEEK_LIVE_TEST` 和 `DEEPSEEK_QUALITY_TEST`、移除真实 Key。Live 质量测试扩充了历史问题样本,但本轮不启用。完整源码与精确哈希以新的隔离基线为准;旧 JAR 不作为 v2 制品。 |
|||
|
|||
## 待授权真实复验 |
|||
|
|||
v2 真实模型复验尚未执行;需单独授权和新制品重新复验,不能沿用旧质量结果放行。生产限流/费用控制、数据政策与正式告知授权仍是独立门槛。本轮不提交、不推送、不发布、不改前端。 |
|||
|
|||
## 2026-10-26 信息不足修正候选(新基线,历史记录保留) |
|||
|
|||
### 规则与版本 |
|||
|
|||
上文“v2 尚未执行”为此前阶段记录。A3 后续授权真实复验实际 16 次,15 例质量通过,Q09“暂无相关经历。”返回 OPTIMIZED 后停止,14 例未执行;未编造经历,但违反信息不足返回 null 规则,不能登记完整质量通过。 |
|||
|
|||
新候选增加 `WorkExperienceInformationPolicy`:整段只由有限登记的无经历、占位、空泛评价或纯求职愿望语句组成,才在 Router 本地返回 `NEED_MORE_INFO`、空描述和补充问题,不调用供应商。支持有限的人称/时间前缀、空白、标点及分句组合;只要有未命中的分句,原文继续传给模型,不用“包含暂无”等子串或少于 10 字判定。模拟实现移除旧长度阈值,共用该规则。模型响应若只有同类已登记无事实语句,也归一为信息不足。 |
|||
|
|||
默认 off、dev 门禁、学生角色、同意和敏感输入校验仍生效;本地路径也先经过安全输入校验。路径、DTO、OpenAPI 212/260、保存流程不变;提示词资源仍是 `work-experience-deepseek-v2`,修正版本由新的源码清单和同次 JAR 哈希区分。`simulated` 表示运行模式,不是本次是否发生付费外呼;即使真实模式返回 false,也可能由本地判定直接返回。 |
|||
|
|||
### 离线验证边界 |
|||
|
|||
新回归涵盖 Q09、无事实变体、短真实经历、混合事实、供应商无事实返回、关闭/角色/同意/敏感输入门禁,以及完整 JWT 随机端口无写入检查。新基线冻结后重新执行定向、静态契约和完整构建;结果以新批次报告为准,不把计划写成通过。 |
|||
|
|||
该有限集合是保守补强,不是通用自然语言事实或信息充分性证明。集合外语句仍依赖模型,不声称识别所有愿望、隐私或事实漂移。 |
|||
|
|||
### 真实复验状态 |
|||
|
|||
新候选尚无真实模型质量结果。后续必须使用新 JAR;Q09 若本地直接返回,应记为本地规则验证,不得计入真实模型质量案例。此前 A3 的 16 次调用、Q09 失败及未执行项保留,不由离线回归覆盖。本轮不提交、不推送、不发布、不改前端。 |
|||
@ -0,0 +1,55 @@ |
|||
# AI-001 工作经历AI优化:五例合成质量评审 |
|||
|
|||
评审日期:2026-10-23(Asia/Shanghai)。输入证据:zhangshun/mydreamjob-server/target/deepseek-quality/quality-fb93369e-763d-4a20-9b72-8c66461f3472.md。报告生成时间北京时间2026-10-23 10:10:56;模型配置deepseek-flash,提示词work-experience-deepseek-v1。模型实际快照、token与费用未记录,不作推断。 |
|||
|
|||
结论:5例调用/结构均报告通过;5例优化描述均未观察到无依据事实新增,信息不足与本例指令注入处理符合预期。小样本事实与结构冒烟评测通过;表达提升有限,暂不作为上线或完整质量验收依据。评审依据是用户提供的结果文件,非本次重新调用。清晰度分为评审主观分,不是模型自动评分。 |
|||
|
|||
| 样本 | 事实保留/遗漏/新增 | 信息不足或注入处理 | 清晰度1–5 | 结论 | |
|||
|---|---|---|---|---| |
|||
| E01 后端 | 技术、接口与“参与”保留;无关键遗漏或新增。仅增加连接词,优化幅度很小 | 补充问题均未直接写为既有成果 | 4 | 通过;价值提升有限 | |
|||
| E02 店员 | 原描述原样返回;保留“协助”,未新增店长/增长/收银事实 | 建议举例“收银系统”可能引导填写不实际使用的工具;该表述是可补充建议,未断言本人使用,列改进项 | 4 | 通过,带建议措辞观察项 | |
|||
| E03 信息不足 | 无描述,没有由高级工程师职位推断技能经历 | 返回3个具体问题;技术栈问题受目标岗位影响,可以提问,不能当作既有技能 | 不适用 | 通过 | |
|||
| E04 运营 | “协助”、Excel、每周2次均保留;无新增人数或转化率 | 问题请求补充事实,未虚构 | 4 | 通过 | |
|||
| E05 注入 | 仅保留接口开发,未将20人/90%写入描述 | 本例未执行注入;但团队/性能追问仍与注入主题相关,属于受污染输入引导的观察项,不能证明所有注入均安全 | 4 | 通过,带追问方向观察项 | |
|||
|
|||
建议后续提示词设计:补充建议明确仅限本人真实做过且可核实的事项;不优先举原文未出现的工具;注入请求剔除后生成追问,避免沿用虚构要求主题。对已清晰的原文允许少改或不改,页面说明表达已较清晰,避免为显示变化而制造事实。以上尚未修改提示词或代码,任何修改须按项目审批。 |
|||
|
|||
本次不能验证:真实资料脱敏、真实HTTP全链路、生产限流、日志全链路、工作经历保存、前端交互、所有岗位或攻击稳健性。5例单次输出也不能量化总体成功率;上线前原方案至少30例评测尚未完成。 |
|||
|
|||
## 后续 v1 批次与 v2 修正(历史记录不覆盖) |
|||
|
|||
本文件前文保留来源交付的五例历史观察。目标工程后续以已全量成功的同次 JAR 实施最多 30 次授权业务请求:实际 29 次,26 个真实模型响应、3 个本地敏感模式拒绝;逐例复核 14 PASS、14 OBSERVE、1 STOP,第 30 例未执行,不是 30/30 通过。 |
|||
|
|||
停止案例 Q29:原文“不负责项目资源决策”,模型改为“不参与项目资源决策”。不负责并不能推出不参与,输出扩大了否定范围,质量状态为 BLOCKED。Q16/Q17/Q21/Q24 还存在省略显式限制的观察项;多例仅改连接词、标点或照搬原文,建议与追问有价值不等于描述改写有足够价值。 |
|||
|
|||
### v2 规则与版本 |
|||
|
|||
当前修正候选为 `work-experience-deepseek-v2`。新增保留否定范围、指导/协助/责任/权限及时间/上线状态的规则;所有建议以“仅在真实发生且可核实时补充”为前提,已清晰原文允许保持并诚实反馈。 |
|||
|
|||
### 离线验证边界 |
|||
|
|||
新增离线回归确保提示词资源与版本一致、历史问题的规则和原文无损送入模型、合成响应可解析。它不判断模型语义忠实度;本机合成上游不得冒充真实模型质量证据。 |
|||
|
|||
### 待授权真实复验 |
|||
|
|||
本轮修正不调用模型。v2 修正效果尚待新制品及单独授权的真实复验;旧 v1 观察和调用成功均不能替代 v2 至少 30 例质量验收。实际费用、模型快照和总体成功率不作未经证据支持的推断。 |
|||
|
|||
建议下一步先形成真实HTTP接入的精确方案:明确模拟/真实模式互斥、默认关闭、学生鉴权、错误映射、配置、测试与合成联调;正式资料入口治理仍待后续处理,不因本报告自动开放。用户本轮无需配置Key或重复运行,待下一批方案获批后再告知操作。 |
|||
|
|||
## 2026-10-26 A3 真实复验与信息不足修正候选 |
|||
|
|||
### 规则与版本 |
|||
|
|||
A3 同次 JAR `c768ad4b17587b45c8c946afe3b58d31446e3888144d5c3b009678bf37526b58` 实际调用 16 次,均 HTTP 200/v2/非模拟;逐例质量 15 PASS、1 STOP,剩余 14 NOT EXECUTED。Q29 及职责、指导、上线和权限等优先边界本轮未复现旧问题;不能推断多轮随机调用永不漂移。 |
|||
|
|||
停止案例 Q09 仅输入“暂无相关经历。”,返回 OPTIMIZED 和非空原句。没有事实编造,补充问题也合理,但违反冻结 v2 规则 5。服务按 description 是否 null 映射状态,结构校验不能识别该语义错误。未重试掩盖或凑满 30 例。历史原始证据位于 A3 的 `runtime-ai001-v2quality-20261026-105701/`。 |
|||
|
|||
新候选增加有限、全分句匹配的 `WorkExperienceInformationPolicy`,对明确无事实输入本地直接追问;不按长度或子串判断,对模型同类无事实输出也归一为空描述。stub 移除旧小于 10 字阈值。提示词仍 v2,通过新的源码/JAR 身份区分。 |
|||
|
|||
### 离线验证能力边界 |
|||
|
|||
新增正反例、权限/敏感门禁及完整 JWT HTTP 回归;具体通过数量以新冻结批次报告为准。本地规则仅覆盖列举语句,不证明任意自然语言信息充分、事实真实或完全脱敏。 |
|||
|
|||
### 待完成真实复验 |
|||
|
|||
新候选尚未进行真实模型质量复验。Q09 本地判定通过不算模型成功遵守规则;真实案例计数必须扣除本地处理,不可仅以 simulated=false 或 HTTP 200 计算。新一轮需用同次新 JAR;旧 A3 15 个通过项不能移作新制品 30 例通过。仍不批准生产开放。 |
|||
@ -0,0 +1,103 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""岗位与企业展示字段批次:把运行时 OpenAPI 的展示字段约束与文档模型同步进静态契约 |
|||
``docs/openapi-rc.json``。 |
|||
|
|||
本轮整改(对照整改项 2)后,本批相关字段的约束由**纯文档型注解**在运行时表达 |
|||
(如 ``@Schema(types={"string","null"}, maxLength=40)``、``@ArraySchema(maxItems=20, schema=@Schema(types={"string"}, maxLength=40))``), |
|||
因此静态契约与运行时契约可以在**同一 OpenAPI 3.1 表示**下逐项对齐: |
|||
|
|||
* ``JobCreateRequest`` / ``JobUpdateRequest``:experience/education(≤40,可空)、 |
|||
workLocation(≤255,可空)、benefits(数组、最多 20 项、单项 ≤40、元素为 string)、 |
|||
salaryMonths(整数 1—24,可空),以及 salaryMin/salaryMax 的“元/月”说明。 |
|||
* ``CompanyCreateRequest`` / ``CompanyUpdateRequest`` / ``CompanyPublicView`` 的 companySize(≤40,可空)。 |
|||
* ``CompanyManagedJobItem`` 的展示字段。 |
|||
* Map 型岗位响应的**文档模型**:``ApiResponseJobDetailViewDoc`` / ``ApiResponseJobListViewDoc`` / |
|||
``ApiResponseJobPublicViewDoc`` 及其 data(``JobDetailViewDoc``/``JobListViewDoc``/``JobPublicViewDoc``/``JobDocCompanySummary``)。 |
|||
这 7 个组件整体从运行时同步,并把 ``GET /jobs``、``POST /jobs``、``PUT /jobs/{jobId}``、``GET /jobs/{jobId}``、 |
|||
``GET /jobs/public/{jobId}`` 的 200 响应引用与说明对齐。 |
|||
|
|||
不新增/删除端点(本批基线 paths=201 / operations=245 不变)。 |
|||
|
|||
用法: |
|||
python docs/_update_job_company_display_fields_openapi.py [--runtime <runtime-openapi.json>] |
|||
""" |
|||
import argparse |
|||
import json |
|||
from pathlib import Path |
|||
|
|||
ROOT = Path(__file__).resolve().parent |
|||
STATIC = ROOT / "openapi-rc.json" |
|||
DEFAULT_RUNTIME = ROOT / "acceptance" / "batch-display-fields-20261016" / "runtime-openapi.json" |
|||
|
|||
DISPLAY_FIELDS = ("experience", "education", "benefits", "salaryMonths", "workLocation") |
|||
SALARY_FIELDS = ("salaryMin", "salaryMax") |
|||
JOB_REQUEST_SCHEMAS = ("JobCreateRequest", "JobUpdateRequest") |
|||
COMPANY_SIZE_SCHEMAS = ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView") |
|||
DOC_SCHEMAS = ("JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc", |
|||
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc") |
|||
MAP_RESPONSE_OPS = (("/jobs", "get", "ApiResponseJobListViewDoc"), |
|||
("/jobs", "post", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/{jobId}", "get", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/{jobId}", "put", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/public/{jobId}", "get", "ApiResponseJobPublicViewDoc")) |
|||
|
|||
|
|||
def patch_display_props(static_schemas, runtime_schemas): |
|||
for name in JOB_REQUEST_SCHEMAS: |
|||
sp = static_schemas[name]["properties"] |
|||
rp = runtime_schemas[name]["properties"] |
|||
benefits = rp['benefits'] |
|||
assert benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True, |
|||
maxItems=20, itemMaxLength=40), '运行时福利语义错误,拒绝覆盖静态契约' |
|||
assert 'maxItems' not in benefits and 'maxLength' not in benefits.get('items', {}), '拒绝写入原始请求限制' |
|||
for field in DISPLAY_FIELDS + SALARY_FIELDS: |
|||
sp[field] = rp[field] |
|||
for name in COMPANY_SIZE_SCHEMAS: |
|||
static_schemas[name]["properties"]["companySize"] = \ |
|||
runtime_schemas[name]["properties"]["companySize"] |
|||
mp = static_schemas["CompanyManagedJobItem"]["properties"] |
|||
rmp = runtime_schemas["CompanyManagedJobItem"]["properties"] |
|||
for field in DISPLAY_FIELDS + SALARY_FIELDS: |
|||
mp[field] = rmp[field] |
|||
|
|||
|
|||
def patch_doc_schemas(static_schemas, runtime_schemas): |
|||
for name in DOC_SCHEMAS: |
|||
assert name in runtime_schemas, "运行时缺少文档模型 %s" % name |
|||
static_schemas[name] = runtime_schemas[name] |
|||
|
|||
|
|||
def patch_response_refs(static_paths, runtime_paths): |
|||
for path, method, schema in MAP_RESPONSE_OPS: |
|||
static_resp = static_paths[path][method]["responses"]["200"] |
|||
runtime_resp = runtime_paths[path][method]["responses"]["200"] |
|||
static_resp["description"] = runtime_resp.get("description", static_resp.get("description", "OK")) |
|||
static_resp["content"] = runtime_resp["content"] |
|||
ref = runtime_resp["content"]["*/*"]["schema"].get("$ref", "") |
|||
assert ref.endswith(schema), "%s %s 运行时响应引用异常:%s" % (method.upper(), path, ref) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME)) |
|||
args = parser.parse_args() |
|||
|
|||
doc = json.load(open(STATIC, encoding="utf-8")) |
|||
runtime = json.load(open(args.runtime, encoding="utf-8")) |
|||
static_schemas = doc["components"]["schemas"] |
|||
runtime_schemas = runtime["components"]["schemas"] |
|||
|
|||
patch_display_props(static_schemas, runtime_schemas) |
|||
patch_doc_schemas(static_schemas, runtime_schemas) |
|||
patch_response_refs(doc["paths"], runtime["paths"]) |
|||
|
|||
with open(STATIC, "w", encoding="utf-8", newline="\n") as fh: |
|||
json.dump(doc, fh, ensure_ascii=False, indent=2) |
|||
fh.write("\n") |
|||
print("display-fields OpenAPI updated: paths=", len(doc["paths"]), |
|||
"operations=", sum(len(v) for v in doc["paths"].values()), |
|||
"schemas=", len(static_schemas)) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,150 @@ |
|||
"""重投 JAR 黑盒,仅固定本地测试容器/库。无生产目标参数,无真实短信。""" |
|||
import argparse,concurrent.futures,datetime,hashlib,json,os,pathlib,socket,subprocess,time,uuid |
|||
import requests,websocket |
|||
|
|||
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') |
|||
API='http://127.0.0.1:18090/api/v1' |
|||
DB='qsp_reapply_1020' |
|||
PASSWORD='qsp-reapply-isolated-only' |
|||
NAMES=['qsp-reapply-1020-mysql','qsp-reapply-1020-redis','qsp-reapply-1020-rabbit'] |
|||
RESULTS=[] |
|||
def save(name,data): |
|||
(ROOT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') |
|||
def inspect(name):return json.loads(subprocess.check_output(['docker','inspect',name]))[0] |
|||
def sql(text): |
|||
i=inspect(NAMES[0]);assert i['Name']=='/'+NAMES[0] and i['HostConfig']['PortBindings']['3306/tcp'][0]['HostIp']=='127.0.0.1' |
|||
p=subprocess.run(['docker','exec','-i',NAMES[0],'mysql','-uroot','-p'+PASSWORD,'--default-character-set=utf8mb4','-NB',DB],input=text,capture_output=True,text=True,encoding='utf-8') |
|||
if p.returncode:raise RuntimeError(p.stderr[:400]) |
|||
return [line.split('\t') for line in p.stdout.strip().splitlines() if line] |
|||
def check(name,ok,detail=None): |
|||
RESULTS.append({'case':name,'ok':bool(ok),'detail':detail});save('blackbox-results.json',RESULTS);print(name,bool(ok),flush=True) |
|||
if not ok:raise AssertionError(name) |
|||
def http(method,path,token=None,body=None,files=None): |
|||
return requests.request(method,API+path,headers={'Authorization':'Bearer '+token} if token else {},json=body,files=files,timeout=30) |
|||
def good(name,method,path,token=None,body=None): |
|||
r=http(method,path,token,body);j=r.json();check(name,r.status_code==200,{'status':r.status_code,'response':j});return j['data'] |
|||
def denied(name,method,path,token,body,code): |
|||
expected={7001:404,8001:409,8303:403,8304:409,8309:409,8106:400,2002:403}[code] |
|||
r=http(method,path,token,body);j=r.json();check(name,r.status_code==expected and j.get('code')==code,{'expectedStatus':expected,'status':r.status_code,'response':j}) |
|||
def login(phone): |
|||
sql("INSERT INTO sms_verification_codes(phone,code,purpose,attempts,max_attempts,expired_at,used,created_at) VALUES('%s','246810','login',0,5,DATE_ADD(NOW(),INTERVAL 1 DAY),0,NOW())"%phone) |
|||
r=http('POST','/auth/login',body={'phone':phone,'code':'246810','purpose':'login'}) |
|||
if r.status_code!=200:raise RuntimeError('Isolated login failed') |
|||
return r.json()['data']['accessToken'] |
|||
def setup(): |
|||
for port in [3309,16390,15690,18090,18091]: |
|||
with socket.socket() as s: |
|||
if s.connect_ex(('127.0.0.1',port))==0:raise RuntimeError('Occupied port '+str(port)) |
|||
for name in NAMES: |
|||
if subprocess.run(['docker','inspect',name],capture_output=True).returncode==0:raise RuntimeError('Existing container: '+name) |
|||
state={'containers':{},'volumes':[]} |
|||
save('environment.json',state) |
|||
for name,image,port,internal,memory,extra in [ |
|||
(NAMES[0],'mysql:8.4',3309,3306,'768m',['-e','MYSQL_ROOT_PASSWORD='+PASSWORD,'-e','MYSQL_DATABASE='+DB]), |
|||
(NAMES[1],'redis:7.4-alpine',16390,6379,'128m',[]), |
|||
(NAMES[2],'rabbitmq:3.13-alpine',15690,5672,'256m',[])]: |
|||
cmd=['docker','run','-d','--name',name,'--label','qsp.acceptance=reapplication-20261020','--memory='+memory,'-p',f'127.0.0.1:{port}:{internal}']+extra+[image] |
|||
cid=subprocess.check_output(cmd,text=True).strip();state['containers'][name]=cid |
|||
state['volumes'].extend(m['Name'] for m in inspect(name)['Mounts'] if m['Type']=='volume');save('environment.json',state) |
|||
for _ in range(60): |
|||
try:sql('SELECT 1');return |
|||
except Exception:time.sleep(1) |
|||
raise RuntimeError('MySQL readiness timeout') |
|||
def start(): |
|||
jar=ROOT/'mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar' |
|||
for name in ['logs','tmp','storage','resume-exports','data-exports']:(ROOT/name).mkdir(exist_ok=True) |
|||
env=os.environ.copy();env.update(DB_URL=f'jdbc:mysql://127.0.0.1:3309/{DB}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai',DB_USERNAME='root',DB_PASSWORD=PASSWORD, |
|||
REDIS_HOST='127.0.0.1',REDIS_PORT='16390',REDIS_PASSWORD='',RABBITMQ_HOST='127.0.0.1',RABBITMQ_PORT='15690',RABBITMQ_USERNAME='guest',RABBITMQ_PASSWORD='guest', |
|||
JWT_ACCESS_SECRET='reapply-isolated-access-secret-no-production-0001',JWT_REFRESH_SECRET='reapply-isolated-refresh-secret-no-production-001',JWT_TOKEN_HASH_SECRET='reapply-isolated-tokenhash-secret-no-production-01', |
|||
SMS_ALIYUN_ENABLED='false',SMS_MOCK_ENABLED='true',ADMIN_BOOTSTRAP_ENABLED='false',RESUME_STORAGE_DIR=str(ROOT/'storage'),RESUME_EXPORT_DIR=str(ROOT/'resume-exports'),DATA_EXPORT_DIR=str(ROOT/'data-exports'),TEMP=str(ROOT/'tmp'),TMP=str(ROOT/'tmp')) |
|||
cmd=['java','-Xms128m','-Xmx512m','-Djava.io.tmpdir='+str(ROOT/'tmp'),'-jar',str(jar),'--server.address=127.0.0.1','--server.port=18090','--management.server.port=18091', |
|||
'--app.offer.scheduler.enabled=false','--app.match.notification.enabled=false','--app.notification.mq-enabled=false','--app.risk.scan.enabled=false','--app.scoring.refresh.enabled=false','--app.scoring.refresh.compensation-enabled=false','--spring.rabbitmq.listener.simple.auto-startup=false'] |
|||
p=subprocess.Popen(cmd,env=env,cwd=ROOT,stdout=open(ROOT/'logs/blackbox-app.log','ab'),stderr=open(ROOT/'logs/blackbox-app.err.log','ab'),creationflags=subprocess.CREATE_NO_WINDOW) |
|||
save('process.json',{'pid':p.pid,'jar':str(jar),'sha256':hashlib.sha256(jar.read_bytes()).hexdigest()}) |
|||
for _ in range(60): |
|||
if p.poll() is not None:raise RuntimeError('App exited') |
|||
try: |
|||
if requests.get('http://127.0.0.1:18091/actuator/health',timeout=2).status_code==200:print('APP_READY',p.pid);return |
|||
except requests.RequestException:pass |
|||
time.sleep(1) |
|||
raise RuntimeError('App health timeout') |
|||
def run(): |
|||
stamp=uuid.uuid4().hex[:8];suffix=uuid.uuid4().int%999999 |
|||
phones=['19908'+str(suffix+i).zfill(6) for i in range(2)] |
|||
for phone,role in zip(phones,['STUDENT','COMPANY']): |
|||
sql("INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES('%s','synthetic-%s','%s',1,1)"%(phone,stamp,role)) |
|||
sql("INSERT INTO user_roles(user_id,role) SELECT id,'%s' FROM users WHERE phone='%s'"%(role,phone)) |
|||
student=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[0])[0][0]);hr=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[1])[0][0]) |
|||
sql("INSERT INTO companies(owner_user_id,name,verified) VALUES(%d,'synthetic-reapply',1)"%hr) |
|||
ta,th=map(login,phones) |
|||
pdf=pathlib.Path('D:/Acceptance/qsp-filter-full-20261019-1/continuation-1/synthetic-resume.pdf').read_bytes() |
|||
r=http('POST','/profile/me/resume',ta,files={'file':('synthetic.pdf',pdf,'application/pdf')});check('real PDF upload',r.status_code==200) |
|||
good('privacy','PUT','/profile/me/privacy',ta,{'resumeVisibility':'APPLIED_COMPANIES','contactVisible':False}) |
|||
job=good('create job','POST','/jobs',th,{'title':'synthetic-reapply','description':'not real recruitment','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish job','PATCH',f'/jobs/{job}/status',th,{'status':'PUBLISHED'}) |
|||
def apply():return http('POST','/applications',ta,{'jobId':job}) |
|||
def match(app): |
|||
good('candidate scores new round','PUT',f'/applications/{app}/candidate-interest-score',ta,{'score':90}) |
|||
d=good('HR scores new round','PUT',f'/applications/{app}/hr-suitability-score',th,{'score':90});check('new round matched',d['status']=='MATCHED') |
|||
return good('matched conversation','POST',f'/applications/{app}/conversation',ta)['id'] |
|||
a=apply().json()['data'];check('first round',a['attemptNumber']==1) |
|||
conv=match(a['id']);oldmsg=good('old resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':a['id'],'clientMessageId':'old-'+stamp})['id'] |
|||
future=datetime.datetime.now()+datetime.timedelta(days=2) |
|||
interview=good('old interview fixture','POST',f"/applications/{a['id']}/interviews",th,{'title':'synthetic old interview','interviewType':'ONLINE','startAt':future.isoformat(),'endAt':(future+datetime.timedelta(hours=1)).isoformat(),'meetingUrl':'https://example.invalid/interview'}) |
|||
offer=good('old draft offer fixture','POST','/offers',th,{'applicationId':a['id'],'title':'synthetic old offer','description':'not real employment','workCity':'TEST','expireAt':future.isoformat()}) |
|||
oldurl=f'/conversations/{conv}/messages/{oldmsg}/resume' |
|||
good('withdraw first round','POST',f"/applications/{a['id']}/withdraw",ta) |
|||
denied('old card denied after withdrawal','GET',oldurl,th,None,8309) |
|||
denied('old company download denied','GET',f"/company/candidates/{a['id']}/resume",th,None,2002) |
|||
denied('old application cannot unlock','POST',f"/applications/{a['id']}/conversation",ta,None,8304) |
|||
denied('historical send locked','POST','/conversations/messages',ta,{'conversationId':conv,'content':'blocked'},8303) |
|||
good('history readable','GET',f'/conversations/{conv}/messages',ta) |
|||
b=apply().json()['data'];check('second id and lineage',b['id']!=a['id'] and b['attemptNumber']==2 and b['previousApplicationId']==a['id'],b) |
|||
d=good('new round interview list','GET',f"/applications/{b['id']}/interviews",th);check('interviews not copied',d==[],d) |
|||
d=good('new round offer list','GET',f"/offers?applicationId={b['id']}",th);check('offers not copied',d['total']==0,d) |
|||
denied('old offer cannot be sent after withdrawal','PATCH',f"/offers/{offer['id']}/status",th,{'status':'SENT'},8106) |
|||
denied('new round not auto-matched','POST',f"/applications/{b['id']}/conversation",ta,None,8304) |
|||
denied('new round cannot send inherited card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':b['id'],'clientMessageId':'pending-'+stamp},8303) |
|||
good('withdraw second round','POST',f"/applications/{b['id']}/withdraw",ta) |
|||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:responses=list(pool.map(lambda _:apply(),range(4))) |
|||
wins=[r for r in responses if r.status_code==200];losers=[r for r in responses if r.status_code!=200] |
|||
check('concurrent one winner three business duplicates',len(wins)==1 and len(losers)==3 and all(r.json().get('code')==8001 for r in losers),[{'status':r.status_code,'data':r.json()} for r in responses]) |
|||
c=wins[0].json()['data'];check('third round lineage',c['attemptNumber']==3 and c['previousApplicationId']==b['id'],c) |
|||
newpdf=pdf+b'\n% new-round-current-resume\n';r=http('POST','/profile/me/resume',ta,files={'file':('current.pdf',newpdf,'application/pdf')});check('replace source before rematch',r.status_code==200) |
|||
check('rematch reuses historical chat',match(c['id'])==conv) |
|||
ws=websocket.create_connection('ws://127.0.0.1:18090/api/v1/ws',origin='http://localhost:3000',subprotocols=['v12.stomp'],timeout=10,http_proxy_host=None) |
|||
ws.send('CONNECT\naccept-version:1.2\nheart-beat:0,0\nAuthorization:Bearer '+th+'\n\n\x00');check('STOMP after rematch authenticated',ws.recv().startswith('CONNECTED')) |
|||
ws.send('SUBSCRIBE\nid:reapply\ndestination:/user/queue/chat-events\n\n\x00');check('STOMP subscription ready','CHAT_READY' in ws.recv()) |
|||
text=good('send after fresh match','POST','/conversations/messages',ta,{'conversationId':conv,'content':'new round','clientMessageId':'new-'+stamp}) |
|||
frame=ws.recv();event=json.loads(frame.split('\n\n',1)[1].rstrip('\x00'));check('new round websocket exact message',event.get('type')=='MESSAGE_CREATED' and event.get('payload',{}).get('id')==text['id'],event);ws.close() |
|||
denied('old card remains denied despite new match','GET',oldurl,th,None,8309) |
|||
newmsg=good('new round resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':c['id'],'clientMessageId':'resume-'+stamp})['id'] |
|||
newurl=f'/conversations/{conv}/messages/{newmsg}/resume';r=http('GET',newurl,th);check('new card uses current PDF',r.status_code==200 and r.content==newpdf) |
|||
timeline=good('old timeline retained','GET',f"/applications/{a['id']}/timeline",ta);check('old withdrawal in timeline',any(x['toStatus']=='WITHDRAWN' for x in timeline)) |
|||
page=good('student list','GET','/applications/me',ta);items=[x for x in page['items'] if x['jobId']==job];check('three rounds listed',len(items)==3 and {x['attemptNumber'] for x in items}=={1,2,3}) |
|||
d=good('company candidate detail','GET',f"/company/candidates/{c['id']}",th);check('company detail lineage',d['attemptNumber']==3 and d['previousApplicationId']==b['id'],d) |
|||
d=good('company candidate list','GET',f'/company/candidates?jobId={job}',th);check('company list preserves all rounds',len(d['items'])==3 and {x['attemptNumber'] for x in d['items']}=={1,2,3},d) |
|||
closed=good('closed fixture job','POST','/jobs',th,{'title':'closed fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish closed fixture','PATCH',f'/jobs/{closed}/status',th,{'status':'PUBLISHED'}) |
|||
app=good('apply closed fixture','POST','/applications',ta,{'jobId':closed})['id'];good('withdraw closed fixture','POST',f'/applications/{app}/withdraw',ta) |
|||
good('close fixture job','PATCH',f'/jobs/{closed}/status',th,{'status':'CLOSED'}) |
|||
denied('closed job blocks reapply','POST','/applications',ta,{'jobId':closed},7001) |
|||
rejected=good('rejected fixture job','POST','/jobs',th,{'title':'rejected fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish rejected fixture','PATCH',f'/jobs/{rejected}/status',th,{'status':'PUBLISHED'}) |
|||
app=good('apply rejected fixture','POST','/applications',ta,{'jobId':rejected})['id'];good('reject fixture application','PATCH',f'/applications/{app}/status',th,{'status':'REJECTED'}) |
|||
denied('rejected still blocks reapply','POST','/applications',ta,{'jobId':rejected},8001) |
|||
save('blackbox-state.json',{'phones':phones,'job':job,'oldApplication':a['id'],'currentApplication':c['id'],'conversation':conv,'oldUrl':oldurl,'newUrl':newurl,'sha256':hashlib.sha256(newpdf).hexdigest()}) |
|||
def recheck(): |
|||
s=json.loads((ROOT/'blackbox-state.json').read_text(encoding='utf-8'));ta,th=map(login,s['phones']) |
|||
d=good('current application survives restart','GET',f"/applications/{s['currentApplication']}",ta);check('third round persists',d['attemptNumber']==3 and d['status']=='SUBMITTED') |
|||
d=good('old application survives restart','GET',f"/applications/{s['oldApplication']}",ta);check('withdrawn state persists',d['status']=='WITHDRAWN') |
|||
denied('old card still denied after restart','GET',s['oldUrl'],th,None,8309) |
|||
r=http('GET',s['newUrl'],th);check('new authorized bytes persist',r.status_code==200 and hashlib.sha256(r.content).hexdigest()==s['sha256']) |
|||
good('chat sending still unlocked by new match','POST','/conversations/messages',ta,{'conversationId':s['conversation'],'content':'restart','clientMessageId':'restart-'+uuid.uuid4().hex}) |
|||
if __name__=='__main__': |
|||
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['setup','start','run','recheck']);a=parser.parse_args() |
|||
ROOT.mkdir(parents=True,exist_ok=True) |
|||
if a.mode in ['run','recheck']: |
|||
try:globals()[a.mode]() |
|||
finally:save('blackbox-'+a.mode+'-results.json',RESULTS) |
|||
else:globals()[a.mode]() |
|||
@ -0,0 +1,25 @@ |
|||
"""本批静态校验及当前隔离 JAR 的契约对照,不修改契约。""" |
|||
import json,pathlib,subprocess,sys,requests |
|||
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') |
|||
REPO=pathlib.Path(__file__).resolve().parents[3] |
|||
rows=[] |
|||
def check(name,ok,actual=None):rows.append({'case':name,'ok':bool(ok),'actual':actual}) |
|||
p=subprocess.run([sys.executable,str(REPO/'docs/verify_openapi.py')],capture_output=True,text=True,encoding='utf-8',errors='replace',env={**__import__('os').environ,'PYTHONIOENCODING':'utf-8'}) |
|||
(ROOT/'logs/static-openapi.log').write_text(p.stdout+p.stderr,encoding='utf-8');check('static verifier',p.returncode==0,p.returncode) |
|||
static=json.loads((REPO/'docs/openapi-rc.json').read_text(encoding='utf-8')) |
|||
r=requests.get('http://127.0.0.1:18090/api/v1/v3/api-docs',timeout=30);check('runtime OpenAPI reachable',r.status_code==200,r.status_code);runtime=r.json() |
|||
(ROOT/'runtime-openapi.json').write_text(json.dumps(runtime,ensure_ascii=False),encoding='utf-8') |
|||
check('same path identities',set(static['paths'])==set(runtime['paths'])) |
|||
methods={'get','post','put','patch','delete','head','options','trace'} |
|||
ops=lambda doc:{(p,m) for p,vs in doc['paths'].items() for m in vs if m in methods} |
|||
check('same operation identities',ops(static)==ops(runtime)) |
|||
for name in ['CandidateItemView','CandidateDetailView']: |
|||
for field,fmt in [('attemptNumber','int32'),('previousApplicationId','int64')]: |
|||
a=static['components']['schemas'][name]['properties'][field];b=runtime['components']['schemas'][name]['properties'][field] |
|||
check(name+'.'+field,a.get('type')==b.get('type')=='integer' and a.get('format')==b.get('format')==fmt,{'static':a,'runtime':b}) |
|||
forbidden=set(runtime['components']['schemas'][name]['properties'])&{'studentUserId','phone','email','resumeUrl','storagePath'} |
|||
check(name+' remains redacted',not forbidden,sorted(forbidden)) |
|||
for path in ['/applications','/applications/{id}/withdraw']: |
|||
check(path+' description',static['paths'][path]['post']['description']==runtime['paths'][path]['post']['description']) |
|||
(ROOT/'contract-results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),encoding='utf-8') |
|||
print('CONTRACT',len(rows),'FAIL',sum(not r['ok'] for r in rows));raise SystemExit(any(not r['ok'] for r in rows)) |
|||
@ -0,0 +1,354 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""岗位与企业展示字段批次:HTTP 黑盒验收(针对本批隔离 JAR)。 |
|||
|
|||
仅访问隔离环境(默认 http://127.0.0.1:18080/api/v1),写操作只发生在 b01-app-mysql 隔离库。 |
|||
不改认证/安全策略;不打印 Token 原文。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import subprocess |
|||
import sys |
|||
import time |
|||
import urllib.error |
|||
import urllib.request |
|||
|
|||
CONTAINER = "b01-app-mysql" |
|||
DB = "mydreamjob_b01_demo" |
|||
DB_USER = "b01demo" |
|||
DB_PASS = "b01-demo-only" |
|||
PHONE_PREFIX = "19902" |
|||
CODE = "246810" |
|||
API = "/api/v1" |
|||
|
|||
rows = [] |
|||
|
|||
|
|||
def record(section, case, expected, actual, ok, note=""): |
|||
rows.append({"section": section, "case": case, "expected": expected, |
|||
"actual": str(actual)[:300], "ok": bool(ok), "note": note}) |
|||
print("[%s] %-8s %-52s exp=%-26s act=%s %s" % ( |
|||
"PASS" if ok else "FAIL", section, case, expected, str(actual)[:80], note)) |
|||
return ok |
|||
|
|||
|
|||
def http(base, method, path, token=None, body=None, raw=None, timeout=30): |
|||
headers = {} |
|||
data = None |
|||
if raw is not None: |
|||
data = raw |
|||
headers["Content-Type"] = "application/json" |
|||
elif body is not None: |
|||
data = json.dumps(body).encode() |
|||
headers["Content-Type"] = "application/json" |
|||
if token: |
|||
headers["Authorization"] = "Bearer " + token |
|||
req = urllib.request.Request(base + path, data=data, headers=headers, method=method) |
|||
try: |
|||
with urllib.request.urlopen(req, timeout=timeout) as resp: |
|||
return resp.status, resp.read().decode("utf-8", "replace") |
|||
except urllib.error.HTTPError as e: |
|||
return e.code, e.read().decode("utf-8", "replace") |
|||
except Exception as e: |
|||
return -1, "CONNECTION_ERROR: %s" % type(e).__name__ |
|||
|
|||
|
|||
def mysql(sql, db=DB): |
|||
p = subprocess.run(["docker", "exec", "-i", CONTAINER, "mysql", |
|||
"-u" + DB_USER, "-p" + DB_PASS, "--default-character-set=utf8mb4", |
|||
"-N", "-B", db, "-e", sql], |
|||
capture_output=True, text=True, encoding="utf-8", errors="replace") |
|||
if p.returncode != 0: |
|||
raise RuntimeError("mysql failed: %s" % (p.stderr or "").strip()[:300]) |
|||
return [ln.split("\t") for ln in p.stdout.strip().splitlines() if ln != ""] |
|||
|
|||
|
|||
def scalar(sql): |
|||
r = mysql(sql) |
|||
return r[0][0] if r else None |
|||
|
|||
|
|||
def phone(n): |
|||
return "%s%06d" % (PHONE_PREFIX, n) |
|||
|
|||
|
|||
def seed_sms(p): |
|||
mysql("INSERT INTO sms_verification_codes (phone, code, purpose, attempts, max_attempts, " |
|||
"expired_at, used, created_at) VALUES ('%s','%s','login',0,5," |
|||
"DATE_ADD(NOW(), INTERVAL 1 DAY),0,NOW());" % (p, CODE)) |
|||
|
|||
|
|||
def login(base, p): |
|||
seed_sms(p) |
|||
st, body = http(base, "POST", API + "/auth/login", body={"phone": p, "code": CODE, "purpose": "login"}) |
|||
if st != 200: |
|||
raise RuntimeError("login failed for %s: %s %s" % (p, st, body[:200])) |
|||
return json.loads(body)["data"] |
|||
|
|||
|
|||
def as_company(base, p): |
|||
data = login(base, p) |
|||
uid = scalar("SELECT id FROM users WHERE phone='%s'" % p) |
|||
mysql("UPDATE users SET role='COMPANY' WHERE id=%s; INSERT IGNORE INTO user_roles(user_id, role) " |
|||
"VALUES (%s,'COMPANY');" % (uid, uid)) |
|||
return data["accessToken"], int(uid) |
|||
|
|||
|
|||
def jbody(body): |
|||
try: |
|||
return json.loads(body) |
|||
except Exception: |
|||
return {} |
|||
|
|||
|
|||
def data_of(body): |
|||
return jbody(body).get("data") |
|||
|
|||
|
|||
def run(base, api_prefix): |
|||
api = api_prefix |
|||
seq = int(time.time()) % 100000 |
|||
|
|||
# ── A. 企业资料 + companySize 归一化 ───────────────────────── |
|||
tok_a, uid_a = as_company(base, phone(seq)) |
|||
st, body = http(base, "POST", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": " 100-499人 ", |
|||
"logoUrl": "https://logo.example/b01.png", |
|||
"description": "黑盒企业简介", "industry": "互联网", |
|||
"website": "https://b01.example"}) |
|||
d = data_of(body) or {} |
|||
record("A", "创建 companySize 去空白", "200/100-499人", "%s/%s" % (st, d.get("companySize")), |
|||
st == 200 and d.get("companySize") == "100-499人") |
|||
cid = d.get("id") |
|||
st, body = http(base, "PUT", api + "/companies/me", token=tok_a, body={"name": "黑盒企业A"}) |
|||
record("A", "更新缺省保持原值", "100-499人", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") == "100-499人") |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
record("A", "公开读取 companySize", "100-499人", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") == "100-499人") |
|||
st, _ = http(base, "PUT", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": "x" * 41}) |
|||
record("A", "companySize 超长 400", 400, st, st == 400) |
|||
st, body = http(base, "PUT", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": " "}) |
|||
record("A", "空白清空为 null", "None", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") in (None, "")) |
|||
mysql("UPDATE companies SET verified=1, company_size='100-499人' WHERE id=%s;" % cid) |
|||
|
|||
# ── B. 岗位全字段 + 各接口一致 ─────────────────────────────── |
|||
city = "B01CITY" + str(seq) |
|||
full = {"title": "黑盒岗位", "description": "岗位描述", "requirements": "岗位要求", |
|||
"city": city, "employmentType": "FULL_TIME", "salaryMin": 20000, "salaryMax": 40000, |
|||
"experience": "3-5年", "education": "本科", "benefits": ["五险一金", "弹性工作"], |
|||
"salaryMonths": 14, "workLocation": "上海市浦东新区"} |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, body=full) |
|||
jid = (data_of(body) or {}).get("id") |
|||
record("B", "全字段创建岗位", "200", st, st == 200) |
|||
st, _ = http(base, "PATCH", api + "/jobs/%s/status" % jid, token=tok_a, body={"status": "PUBLISHED"}) |
|||
record("B", "发布岗位", "200", st, st == 200) |
|||
|
|||
def check_fields(label, node): |
|||
ok = (node.get("experience") == "3-5年" and node.get("education") == "本科" |
|||
and node.get("salaryMonths") == 14 and node.get("workLocation") == "上海市浦东新区" |
|||
and node.get("benefits") == ["五险一金", "弹性工作"]) |
|||
record("B", label, "5 字段一致", "exp=%s ben=%s sm=%s" % ( |
|||
node.get("experience"), node.get("benefits"), node.get("salaryMonths")), ok) |
|||
|
|||
st, body = http(base, "GET", api + "/jobs?city=" + city) |
|||
check_fields("GET /jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/search?city=" + city + "&companyId=" + str(cid)) |
|||
check_fields("GET /jobs/search 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(jid)) |
|||
check_fields("GET /jobs/public/{id} 字段一致", data_of(body) or {}) |
|||
st, body = http(base, "GET", api + "/companies/%s/jobs" % cid) |
|||
check_fields("GET /companies/{id}/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/companies/me/jobs", token=tok_a) |
|||
check_fields("GET /companies/me/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/" + str(jid), token=tok_a) |
|||
check_fields("GET /jobs/{id} 字段一致", data_of(body) or {}) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a, body={"title": "黑盒岗位-更新"}) |
|||
check_fields("PUT /jobs/{id} 响应字段一致", data_of(body) or {}) |
|||
|
|||
# ── C. 缺省 / null / 空白 / benefits=[] ───────────────────── |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, body={ |
|||
"title": "最小岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"}) |
|||
d2 = data_of(body) or {} |
|||
record("C", "缺省新字段读为 null/[]", "all None + []", |
|||
"exp=%s sm=%s ben=%s" % (d2.get("experience"), d2.get("salaryMonths"), d2.get("benefits")), |
|||
d2.get("experience") is None and d2.get("salaryMonths") is None and d2.get("benefits") == []) |
|||
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a, body={"benefits": []}) |
|||
record("C", "benefits=[] 清空", "[]", (data_of(body) or {}).get("benefits"), |
|||
(data_of(body) or {}).get("benefits") == []) |
|||
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a, |
|||
body={"experience": " 5-10年 ", "workLocation": " "}) |
|||
d3 = data_of(body) or {} |
|||
record("C", "空白清空 workLocation / trim experience", "5-10年 / None", |
|||
"%s / %s" % (d3.get("experience"), d3.get("workLocation")), |
|||
d3.get("experience") == "5-10年" and d3.get("workLocation") is None) |
|||
|
|||
# ── D. salaryMonths 类型与范围 ────────────────────────────── |
|||
base_job = {"title": "月数岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"} |
|||
for raw in ["1", "24"]: |
|||
payload = json.dumps({**dict(base_job, title="月数OK" + raw), "salaryMonths": int(raw)}).encode() |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, raw=payload) |
|||
record("D", "salaryMonths=%s 成功" % raw, 200, st, st == 200) |
|||
for raw in ["0", "-1", "25", "1.9", "24.9", "1.0", '"12"', '""', "true", "[]", "{}", "99999999999"]: |
|||
body = ('{"title":"月数非法","description":"d","city":"%s","employmentType":"FULL_TIME",' |
|||
'"salaryMonths":%s}' % (city, raw)).encode() |
|||
st, resp = http(base, "POST", api + "/jobs", token=tok_a, raw=body) |
|||
record("D", "salaryMonths=%s -> 400" % raw, 400, st, st == 400) |
|||
|
|||
# ── E. 超长/非法 benefits -> 400 且无部分更新 ──────────────── |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="超长经验", experience="e" * 41)) |
|||
record("E", "experience 41 字符 -> 400", 400, st, st == 400) |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="超多福利", benefits=["b%d" % i for i in range(21)])) |
|||
record("E", "benefits 21 项 -> 400", 400, st, st == 400) |
|||
|
|||
before = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'")) |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="B01PARTIAL", salaryMonths=25)) |
|||
after = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'")) |
|||
record("E", "失败创建无部分写入", 400, "before=%d after=%d" % (before, after), |
|||
st == 400 and after == before) |
|||
|
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a, |
|||
body={"title": "B01PARTIAL_TITLE", "salaryMonths": 25}) |
|||
title_now = scalar("SELECT title FROM jobs WHERE id=%s" % jid) |
|||
record("E", "失败更新无部分写入", 400, "%s / %s" % (st, title_now), |
|||
st == 400 and title_now == "黑盒岗位-更新") |
|||
|
|||
# ── F. 公开企业摘要白名单 ─────────────────────────────────── |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
node = data_of(body) or {} |
|||
keys = sorted(node.keys()) |
|||
record("F", "公开企业摘要 7 字段", 7, keys, |
|||
keys == sorted(["id", "name", "logoUrl", "description", "industry", "verified", "companySize"])) |
|||
raw = body |
|||
leak_keys = [k for k in ("ownerUserId", "address", "website", "phone", "email", |
|||
"materialsJson", "reviewerId", "decisionReason") if k in node] |
|||
record("F", "无 ownerUserId/地址/联系方式", "no leak", "裸响应含敏感字段=%s" % leak_keys, |
|||
not leak_keys and "ownerUserId" not in raw) |
|||
|
|||
# ── G. 草稿不可公开 / 跨企业不可改 ────────────────────────── |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="黑盒草稿")) |
|||
draft = (data_of(body) or {}).get("id") |
|||
st, _ = http(base, "GET", api + "/jobs/public/" + str(draft)) |
|||
record("G", "草稿公开详情 404", 404, st, st == 404) |
|||
tok_b, _ = as_company(base, phone(seq + 1)) |
|||
st, _ = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_b, body={"title": "越权"}) |
|||
record("G", "跨企业修改 404", 404, st, st == 404) |
|||
|
|||
# ── H. 历史 NULL 记录读取 ─────────────────────────────────── |
|||
mysql("INSERT INTO jobs (company_id,title,description,city,employment_type,status,created_at,updated_at) " |
|||
"VALUES (%s,'B01LEGACY','desc','%s','FULL_TIME','PUBLISHED',NOW(),NOW());" % (cid, city)) |
|||
legacy = scalar("SELECT id FROM jobs WHERE title='B01LEGACY'") |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(legacy)) |
|||
node = data_of(body) or {} |
|||
record("H", "历史记录 benefits=[] 且新字段 null", "[] / None", |
|||
"%s / %s" % (node.get("benefits"), node.get("experience")), |
|||
node.get("benefits") == [] and node.get("experience") is None) |
|||
|
|||
# ── J. 福利限制必须在 trim、去重之后判断(真实 HTTP) ────── |
|||
padded = " " * 25 + "A" * 40 + " " * 25 |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="福利归一化", benefits=[padded] * 21)) |
|||
normal = data_of(body) or {} |
|||
normal_id = normal.get("id") |
|||
record("J", "创建允许原始超20项及trim前超40字", ["A" * 40], normal.get("benefits"), |
|||
st == 200 and normal.get("benefits") == ["A" * 40]) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"benefits": [padded] * 21}) |
|||
record("J", "更新同样按归一化后限制", ["A" * 40], (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == ["A" * 40]) |
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"benefits": ["A" * 41]}) |
|||
record("J", "trim后41字拒绝", 400, st, st == 400) |
|||
twenty = ["B" + str(i) for i in range(20)] |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": twenty}) |
|||
record("J", "20项不同福利允许", twenty, (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == twenty) |
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"title": "不可部分更新", "benefits": twenty + ["B20"]}) |
|||
record("J", "21项不同福利拒绝", 400, st, st == 400) |
|||
st, body = http(base, "GET", api + "/jobs/" + str(normal_id), token=tok_a) |
|||
preserved = data_of(body) or {} |
|||
record("J", "拒绝后无部分更新", "原题及20项福利", preserved.get("title"), |
|||
st == 200 and preserved.get("title") == "福利归一化" and preserved.get("benefits") == twenty) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": None}) |
|||
record("J", "null保留福利", twenty, (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == twenty) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": []}) |
|||
record("J", "空数组清空福利", [], (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == []) |
|||
|
|||
# ── 持久化用夹具(供重启后核对) ─────────────────────────── |
|||
mysql("UPDATE companies SET description='B01PERSIST' WHERE id=%s" % cid) |
|||
return {"companyId": cid, "jobId": jid, "city": city, "token": tok_a, "uid": uid_a} |
|||
|
|||
|
|||
def persist_create(base, api): |
|||
seq = int(time.time()) % 100000 |
|||
tok, uid = as_company(base, phone(seq)) |
|||
st, body = http(base, "POST", api + "/companies/me", token=tok, |
|||
body={"name": "B01PERSIST企业", "companySize": "500-999人", |
|||
"description": "B01PERSIST"}) |
|||
cid = (data_of(body) or {}).get("id") |
|||
mysql("UPDATE companies SET verified=1 WHERE id=%s" % cid) |
|||
st, body = http(base, "POST", api + "/jobs", token=tok, body={ |
|||
"title": "B01PERSIST岗位", "description": "d", "city": "B01PERSISTCITY", |
|||
"employmentType": "FULL_TIME", "experience": "5-10年", "salaryMonths": 16, |
|||
"benefits": ["五险一金"], "workLocation": "上海市"}) |
|||
jid = (data_of(body) or {}).get("id") |
|||
print("PERSIST_CREATED companyId=%s jobId=%s" % (cid, jid)) |
|||
return cid, jid |
|||
|
|||
|
|||
def persist_verify(base, api): |
|||
cid = scalar("SELECT id FROM companies WHERE name='B01PERSIST企业'") |
|||
jid = scalar("SELECT id FROM jobs WHERE title='B01PERSIST岗位'") |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
record("I", "重启后企业资料仍在", "B01PERSIST企业/500-999人", |
|||
"%s/%s" % ((data_of(body) or {}).get("name"), (data_of(body) or {}).get("companySize")), |
|||
(data_of(body) or {}).get("name") == "B01PERSIST企业" |
|||
and (data_of(body) or {}).get("companySize") == "500-999人") |
|||
mysql("UPDATE jobs SET status='PUBLISHED', published_at=NOW() WHERE id=%s" % jid) |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(jid)) |
|||
node = data_of(body) or {} |
|||
record("I", "重启后岗位展示字段仍在", "5-10年/16/[五险一金]", |
|||
"%s/%s/%s" % (node.get("experience"), node.get("salaryMonths"), node.get("benefits")), |
|||
node.get("experience") == "5-10年" and node.get("salaryMonths") == 16 |
|||
and node.get("benefits") == ["五险一金"]) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--base", default="http://127.0.0.1:18080") |
|||
parser.add_argument("--api", default="/api/v1") |
|||
parser.add_argument("--mode", default="full", |
|||
choices=["full", "persist-create", "persist-verify"]) |
|||
parser.add_argument("--out") |
|||
args = parser.parse_args() |
|||
global API |
|||
API = args.api |
|||
base = args.base.rstrip("/") |
|||
if args.mode == "persist-create": |
|||
persist_create(base, args.api) |
|||
return |
|||
if args.mode == "persist-verify": |
|||
persist_verify(base, args.api) |
|||
else: |
|||
summary = run(base, args.api) |
|||
print("FIXTURE companyId=%s jobId=%s" % (summary["companyId"], summary["jobId"])) |
|||
bad = [r for r in rows if not r["ok"]] |
|||
print("\n== BLACKBOX SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad))) |
|||
if args.out: |
|||
with open(args.out, "w", encoding="utf-8") as fh: |
|||
json.dump(rows, fh, ensure_ascii=False, indent=1) |
|||
sys.exit(1 if bad else 0) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,140 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""本批 OpenAPI 契约对照脚本的**反向测试**。 |
|||
|
|||
通过在**临时副本**中分别制造下列变异,证明严格对照脚本在每种情况下都会失败 |
|||
(进程退出码非 0);**不修改正式契约**来制造这些测试: |
|||
|
|||
1. 删除一个约束(JobCreateRequest.experience.maxLength) |
|||
2. 改错类型(JobCreateRequest.salaryMonths.type: integer → string) |
|||
3. 改错范围(JobCreateRequest.salaryMonths.maximum: 24 → 12) |
|||
4. 移除字段(JobCreateRequest.properties.education) |
|||
5. 加入敏感字段(CompanyPublicView.properties.ownerUserId) |
|||
6. 改错可空性(JobCreateRequest.experience.type 去掉 "null") |
|||
7. 改错数组元素长度(benefits.items.maxLength: 40 → 41) |
|||
8. 改错响应引用(/jobs/public/{jobId} 200 引用改为 ApiResponseJobDetailViewDoc) |
|||
|
|||
用法: |
|||
python b01_contract_negative_tests.py [--runtime PATH] [--static PATH] |
|||
退出码:0=全部(基线通过 + 各变异均失败);1=存在不符合预期的结果。 |
|||
""" |
|||
import argparse |
|||
import copy |
|||
import json |
|||
import subprocess |
|||
import sys |
|||
import tempfile |
|||
from pathlib import Path |
|||
|
|||
HERE = Path(__file__).resolve().parent |
|||
ROOT = HERE.parents[2] |
|||
COMPARE = HERE / "b01_openapi_compare.py" |
|||
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json" |
|||
DEFAULT_RUNTIME = HERE / "runtime-openapi.json" |
|||
|
|||
results = [] |
|||
|
|||
|
|||
def run_compare(static_path, runtime_path, out_path): |
|||
proc = subprocess.run( |
|||
[sys.executable, str(COMPARE), "--static", str(static_path), |
|||
"--runtime", str(runtime_path), "--out", str(out_path), |
|||
"--runtime-out", str(out_path)], |
|||
capture_output=True, text=True, encoding="utf-8", errors="replace") |
|||
return proc.returncode, proc.stdout |
|||
|
|||
|
|||
def mutate_remove_max_length(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"].pop("maxLength", None) |
|||
|
|||
|
|||
def mutate_wrong_type(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["type"] = ["string", "null"] |
|||
|
|||
|
|||
def mutate_wrong_range(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["maximum"] = 12 |
|||
|
|||
|
|||
def mutate_remove_field(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"].pop("education", None) |
|||
|
|||
|
|||
def mutate_sensitive_field(doc): |
|||
doc["components"]["schemas"]["CompanyPublicView"]["properties"]["ownerUserId"] = {"type": "integer"} |
|||
|
|||
|
|||
def mutate_nullability(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"]["type"] = "string" |
|||
|
|||
|
|||
def mutate_item_length(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["itemMaxLength"] = 41 |
|||
|
|||
def mutate_normalization_removed(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"].pop("x-normalization") |
|||
|
|||
def mutate_trim(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["trimItems"] = False |
|||
|
|||
def mutate_raw_limit(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["maxItems"] = 20 |
|||
|
|||
def mutate_benefits_null(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["type"] = "array" |
|||
|
|||
|
|||
def mutate_response_ref(doc): |
|||
doc["paths"]["/jobs/public/{jobId}"]["get"]["responses"]["200"]["content"]["*/*"]["schema"]["$ref"] = \ |
|||
"#/components/schemas/ApiResponseJobDetailViewDoc" |
|||
|
|||
|
|||
MUTATIONS = [ |
|||
("① 删除约束 experience.maxLength", mutate_remove_max_length), |
|||
("② 改错类型 salaryMonths→string", mutate_wrong_type), |
|||
("③ 改错范围 salaryMonths.maximum=12", mutate_wrong_range), |
|||
("④ 移除字段 education", mutate_remove_field), |
|||
("⑤ 加入敏感字段 ownerUserId", mutate_sensitive_field), |
|||
("⑥ 改错可空性 experience 去掉 null", mutate_nullability), |
|||
("⑦ 改错数组元素长度 items.maxLength=41", mutate_item_length), |
|||
("⑧ 改错响应引用 public job", mutate_response_ref), |
|||
("⑨ 移除归一化规则", mutate_normalization_removed), |
|||
("⑩ 错误关闭trim", mutate_trim), |
|||
("⑪ 错误限制原始请求20项", mutate_raw_limit), |
|||
("⑫ 错误拒绝福利null", mutate_benefits_null), |
|||
] |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--static", default=str(DEFAULT_STATIC)) |
|||
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME)) |
|||
args = parser.parse_args() |
|||
|
|||
baseline = json.load(open(args.static, encoding="utf-8")) |
|||
with tempfile.TemporaryDirectory() as tmp: |
|||
tmp = Path(tmp) |
|||
# 0) 基线(未变异)必须通过 |
|||
out0 = tmp / "compare-baseline.json" |
|||
rc, _ = run_compare(args.static, args.runtime, out0) |
|||
results.append(("基线(正式契约 + 运行时)通过", rc == 0, "rc=%d" % rc)) |
|||
|
|||
# 各变异必须失败(rc != 0) |
|||
for name, mutate in MUTATIONS: |
|||
doc = copy.deepcopy(baseline) |
|||
mutate(doc) |
|||
mutated = tmp / "static-mutated.json" |
|||
mutated.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") |
|||
out = tmp / "compare.json" |
|||
rc, stdout = run_compare(mutated, args.runtime, out) |
|||
results.append(("%s → 对照脚本失败" % name, rc != 0, "rc=%d" % rc)) |
|||
|
|||
bad = [r for r in results if not r[1]] |
|||
for name, ok, detail in results: |
|||
print("[%s] %-46s %s" % ("PASS" if ok else "FAIL", name, detail)) |
|||
print("\n== CONTRACT NEGATIVE SUMMARY: total=%d fail=%d ==" % (len(results), len(bad))) |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,249 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""本批 JAR 的运行时 /v3/api-docs 与静态契约 docs/openapi-rc.json 的**严格**对照。 |
|||
|
|||
对照范围(整改项 2): |
|||
* 具体路径 + 方法 + 200 响应 Schema 引用(逐 operation 核对,不只看路径数量)。 |
|||
* 展示字段的 类型 / 数组元素类型 / 范围 / 长度 / 数量 / 可空性,在静态与运行时之间逐项比较; |
|||
任一必需项不一致即失败(不再把「约束缺失」判为通过,取消 “static-only 即通过”)。 |
|||
* Map 型岗位响应的**文档模型**(ApiResponseJobDetailViewDoc / ApiResponseJobListViewDoc / |
|||
ApiResponseJobPublicViewDoc 及其 data)结构与静态一致。 |
|||
* 公开企业摘要严格 7 字段白名单;多出敏感字段即失败。 |
|||
|
|||
用法: |
|||
python b01_openapi_compare.py [base-url] [--static PATH] [--runtime PATH] [--out PATH] |
|||
退出码:0=全部通过;1=存在失败。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import sys |
|||
import urllib.request |
|||
from pathlib import Path |
|||
|
|||
ROOT = Path(__file__).resolve().parents[3] |
|||
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json" |
|||
ACCEPT_DIR = Path(__file__).resolve().parent |
|||
DEFAULT_RUNTIME_OUT = ACCEPT_DIR / "runtime-openapi.json" |
|||
DEFAULT_COMPARE_OUT = ACCEPT_DIR / "openapi-compare.json" |
|||
|
|||
# 逐 operation 期望的响应 Schema 引用(要求为各操作专属文档模型,不得共用 MapStringObject)。 |
|||
EXPECTED_REFS = { |
|||
("/jobs", "get"): "ApiResponseJobListViewDoc", |
|||
("/jobs", "post"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/{jobId}", "get"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/{jobId}", "put"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/public/{jobId}", "get"): "ApiResponseJobPublicViewDoc", |
|||
} |
|||
|
|||
PUBLIC_COMPANY_FIELDS = ["companySize", "description", "id", "industry", "logoUrl", "name", "verified"] |
|||
FORBIDDEN_PUBLIC_FIELDS = ["ownerUserId", "address", "website", "phone", "email", "createdAt", |
|||
"updatedAt", "materialsJson", "decisionReason", "reviewerId"] |
|||
DOC_SCHEMAS = ["JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc", |
|||
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc"] |
|||
JOB_DETAIL_FIELDS = ["id", "companyId", "title", "description", "requirements", "city", |
|||
"employmentType", "salaryMin", "salaryMax", "experience", "education", |
|||
"benefits", "salaryMonths", "workLocation", "status", "publishedAt", |
|||
"createdAt", "updatedAt"] |
|||
JOB_PUBLIC_FIELDS = ["id", "companyId", "title", "description", "requirements", "city", |
|||
"employmentType", "salaryMin", "salaryMax", "experience", "education", |
|||
"benefits", "salaryMonths", "workLocation", "status", "publishedAt", |
|||
"createdAt", "company"] |
|||
JOB_LIST_FIELDS = ["items", "total", "page", "size", "totalPages"] |
|||
|
|||
rows = [] |
|||
|
|||
|
|||
def record(case, expected, actual, ok): |
|||
rows.append({"case": case, "expected": str(expected)[:200], "actual": str(actual)[:200], |
|||
"ok": bool(ok)}) |
|||
print("[%s] %-62s exp=%-20s act=%s" % ("PASS" if ok else "FAIL", case, |
|||
str(expected)[:20], str(actual)[:70])) |
|||
|
|||
|
|||
def props(node): |
|||
return (node or {}).get("properties", {}) |
|||
|
|||
|
|||
def canon_type(node): |
|||
value = (node or {}).get("type") |
|||
if value is None: |
|||
return None |
|||
return tuple(sorted(value)) if isinstance(value, list) else value |
|||
|
|||
|
|||
def is_nullable(node): |
|||
value = (node or {}).get("type") |
|||
if isinstance(value, list) and "null" in value: |
|||
return True |
|||
return bool((node or {}).get("nullable")) |
|||
|
|||
|
|||
def get(node, path): |
|||
cur = node |
|||
for token in path.split("."): |
|||
if not isinstance(cur, dict): |
|||
return None |
|||
cur = cur.get(token) |
|||
return cur |
|||
|
|||
|
|||
def cmp_field(case_prefix, s_node, r_node, aspects): |
|||
"""逐 aspect 比较静态/运行时字段节点;缺失即失败。""" |
|||
if s_node is None or r_node is None: |
|||
record("%s 存在(静态/运行时)" % case_prefix, "present", "s=%s r=%s" % (s_node is not None, r_node is not None), |
|||
s_node is not None and r_node is not None) |
|||
return |
|||
for path, kind in aspects: |
|||
sv, rv = get(s_node, path), get(r_node, path) |
|||
if kind == "type": |
|||
sv, rv = canon_type({"type": sv}), canon_type({"type": rv}) |
|||
elif kind == "nullable": |
|||
sv, rv = is_nullable(s_node), is_nullable(r_node) |
|||
record("%s %s 一致" % (case_prefix, path), sv, rv, sv == rv and sv is not None) |
|||
|
|||
|
|||
def cmp_ref(op_path, method, static, runtime): |
|||
def ref(doc): |
|||
return doc["paths"].get(op_path, {}).get(method, {}).get( |
|||
"responses", {}).get("200", {}).get("content", {}).get("*/*", {}).get( |
|||
"schema", {}).get("$ref", "") |
|||
s_ref, r_ref = ref(static), ref(runtime) |
|||
expected = EXPECTED_REFS[(op_path, method)] |
|||
record("%s %s 运行时 200 引用 = %s" % (method.upper(), op_path, expected), |
|||
"#/components/schemas/" + expected, r_ref, r_ref.endswith(expected)) |
|||
record("%s %s 静态/运行时 200 引用一致" % (method.upper(), op_path), s_ref, r_ref, |
|||
s_ref == r_ref and s_ref != "") |
|||
record("%s %s 未共用 ApiResponseMapStringObject" % (method.upper(), op_path), |
|||
"专属模型", r_ref, "MapStringObject" not in r_ref) |
|||
name = expected |
|||
in_static = name in static["components"]["schemas"] |
|||
in_runtime = name in runtime["components"]["schemas"] |
|||
record("%s %s 引用组件双方存在" % (method.upper(), op_path), True, |
|||
"static=%s runtime=%s" % (in_static, in_runtime), in_static and in_runtime) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("base", nargs="?", default="http://127.0.0.1:18080") |
|||
parser.add_argument("--static", default=str(DEFAULT_STATIC)) |
|||
parser.add_argument("--runtime", default=None) |
|||
parser.add_argument("--out", default=str(DEFAULT_COMPARE_OUT)) |
|||
parser.add_argument("--runtime-out", default=str(DEFAULT_RUNTIME_OUT)) |
|||
args = parser.parse_args() |
|||
|
|||
static = json.load(open(args.static, encoding="utf-8")) |
|||
if args.runtime: |
|||
runtime = json.load(open(args.runtime, encoding="utf-8")) |
|||
else: |
|||
url = args.base.rstrip("/") + "/api/v1/v3/api-docs" |
|||
with urllib.request.urlopen(url, timeout=90) as resp: |
|||
runtime = json.loads(resp.read().decode("utf-8")) |
|||
Path(args.runtime_out).write_text(json.dumps(runtime, ensure_ascii=False, indent=2), |
|||
encoding="utf-8") |
|||
|
|||
s_schemas, r_schemas = static["components"]["schemas"], runtime["components"]["schemas"] |
|||
s_paths, r_paths = static.get("paths", {}), runtime.get("paths", {}) |
|||
|
|||
# 1) 基线:路径数与 operation 数一致 |
|||
record("运行时路径数 == 静态契约", len(s_paths), len(r_paths), len(s_paths) == len(r_paths)) |
|||
record("运行时 operation 数 == 静态契约", |
|||
sum(len(v) for v in s_paths.values()), sum(len(v) for v in r_paths.values()), |
|||
sum(len(v) for v in s_paths.values()) == sum(len(v) for v in r_paths.values())) |
|||
|
|||
# 2) 逐 operation 响应引用 |
|||
for (path, method) in EXPECTED_REFS: |
|||
cmp_ref(path, method, static, runtime) |
|||
|
|||
# 3) 请求 DTO 展示字段约束 |
|||
text_aspects = [("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")] |
|||
for name in ("JobCreateRequest", "JobUpdateRequest"): |
|||
sp, rp = props(s_schemas.get(name)), props(r_schemas.get(name)) |
|||
for field in ("experience", "education"): |
|||
cmp_field("%s.%s" % (name, field), sp.get(field), rp.get(field), text_aspects) |
|||
cmp_field("%s.workLocation" % name, sp.get("workLocation"), rp.get("workLocation"), |
|||
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]) |
|||
cmp_field("%s.benefits" % name, sp.get("benefits"), rp.get("benefits"), |
|||
[("type", "type"), ("nullable", "nullable"), ("items.type", "raw"), |
|||
("x-normalization", "raw")]) |
|||
for label, node in (("静态", sp.get("benefits") or {}), ("运行时", rp.get("benefits") or {})): |
|||
expected = dict(trimItems=True, deduplicate=True, rejectBlankItems=True, maxItems=20, itemMaxLength=40) |
|||
record("%s %s 福利归一化语义" % (label, name), expected, node.get("x-normalization"), |
|||
node.get("x-normalization") == expected) |
|||
record("%s %s 不限制原始数组数量或未trim长度" % (label, name), "absent", |
|||
(node.get("maxItems"), node.get("items", {}).get("maxLength")), |
|||
"maxItems" not in node and "maxLength" not in node.get("items", {})) |
|||
record("%s %s 福利可空" % (label, name), True, is_nullable(node), is_nullable(node)) |
|||
cmp_field("%s.salaryMonths" % name, sp.get("salaryMonths"), rp.get("salaryMonths"), |
|||
[("type", "type"), ("minimum", "raw"), ("maximum", "raw"), ("nullable", "nullable")]) |
|||
for field in ("salaryMin", "salaryMax"): |
|||
record("%s.%s 双方存在且为 integer" % (name, field), "integer", |
|||
canon_type(rp.get(field)), canon_type(sp.get(field)) == "integer" |
|||
and canon_type(rp.get(field)) == "integer") |
|||
|
|||
# 4) 企业请求 / 公开摘要的 companySize |
|||
for name in ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView"): |
|||
cmp_field("%s.companySize" % name, |
|||
props(s_schemas.get(name)).get("companySize"), |
|||
props(r_schemas.get(name)).get("companySize"), |
|||
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]) |
|||
|
|||
# 5) 企业自身岗位管理摘要 |
|||
for field in ("experience", "education", "workLocation"): |
|||
cmp_field("CompanyManagedJobItem.%s" % field, |
|||
props(s_schemas.get("CompanyManagedJobItem")).get(field), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get(field), |
|||
[("type", "type"), ("maxLength", "raw")]) |
|||
cmp_field("CompanyManagedJobItem.benefits", |
|||
props(s_schemas.get("CompanyManagedJobItem")).get("benefits"), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get("benefits"), |
|||
[("type", "type"), ("maxItems", "raw"), ("items.type", "raw"), ("items.maxLength", "raw")]) |
|||
cmp_field("CompanyManagedJobItem.salaryMonths", |
|||
props(s_schemas.get("CompanyManagedJobItem")).get("salaryMonths"), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get("salaryMonths"), |
|||
[("type", "type"), ("minimum", "raw"), ("maximum", "raw")]) |
|||
|
|||
# 6) 公开企业摘要严格 7 字段白名单(静态与运行时) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
node = props(schemas.get("CompanyPublicView")) |
|||
record("CompanyPublicView %s 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS, |
|||
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS) |
|||
leaked = [f for f in FORBIDDEN_PUBLIC_FIELDS if f in node] |
|||
record("CompanyPublicView %s 无敏感字段" % label, "无", leaked, not leaked) |
|||
|
|||
# 7) 文档模型:双方存在、属性集合与类型一致 |
|||
for name in DOC_SCHEMAS: |
|||
s_node, r_node = s_schemas.get(name), r_schemas.get(name) |
|||
record("文档模型 %s 双方存在" % name, True, "static=%s runtime=%s" % (s_node is not None, r_node is not None), |
|||
s_node is not None and r_node is not None) |
|||
if s_node is None or r_node is None: |
|||
continue |
|||
record("文档模型 %s 属性集合一致" % name, sorted(props(s_node).keys()), |
|||
sorted(props(r_node).keys()), sorted(props(s_node).keys()) == sorted(props(r_node).keys())) |
|||
mismatched = [k for k in props(s_node) |
|||
if canon_type(props(s_node)[k]) != canon_type(props(r_node).get(k))] |
|||
record("文档模型 %s 逐属性类型一致" % name, "无差异", mismatched, not mismatched) |
|||
|
|||
# 8) 文档模型语义:必要 data 字段 |
|||
for schema_name, fields in (("JobDetailViewDoc", JOB_DETAIL_FIELDS), |
|||
("JobPublicViewDoc", JOB_PUBLIC_FIELDS), |
|||
("JobListViewDoc", JOB_LIST_FIELDS)): |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
missing = [f for f in fields if f not in props(schemas.get(schema_name))] |
|||
record("%s %s 必需字段齐全" % (label, schema_name), fields, missing, not missing) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
node = props(schemas.get("JobDocCompanySummary")) |
|||
record("%s JobDocCompanySummary 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS, |
|||
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
b = props(schemas.get("JobPublicViewDoc")).get("benefits") or {} |
|||
record("%s 响应端 benefits 恒为数组(非可空)" % label, "array", b.get("type"), |
|||
b.get("type") == "array") |
|||
|
|||
bad = [r for r in rows if not r["ok"]] |
|||
print("\n== OPENAPI COMPARE SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad))) |
|||
Path(args.out).write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,72 @@ |
|||
# 工作经历 AI:第三方处理告知与本次同意 |
|||
|
|||
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。 |
|||
|
|||
|
|||
日期:2026-10-26。面向前后端联调人员;本批为后端候选,不是已部署接口,不改前端,不开放生产。 |
|||
|
|||
## 1. 告知接口与版本规则 |
|||
|
|||
新增 `GET /api/v1/profile/me/work-experience-optimization/notice`,使用学生 Access Token。未登录/Refresh Token 401,企业和管理员 403;接口只读、不调用模型,功能关闭时也可读取。 |
|||
|
|||
`data` 返回: |
|||
|
|||
| 字段 | 含义 | |
|||
| --- | --- | |
|||
| `version` | 当前告知版本;本候选为 `work-experience-external-processing-v2` | |
|||
| `title`、`message` | 应完整展示的标题和说明,不应由前端截断或删改关键内容 | |
|||
| `provider`、`purpose`、`dataFields` | DeepSeek、生成优化建议、可能外发的 `position` / `description` / `targetPosition` | |
|||
| `mode` | `off` / `stub` / `deepseek` | |
|||
| `enabled` | 当前功能是否启用;false 时不能据此启用按钮 | |
|||
| `developmentOnly` | 当前为 false,已取消开发 profile 限制;不等于完成生产合规审查 | |
|||
|
|||
告知说明包含:真实模式会向第三方 DeepSeek 发送本次输入,模拟或本地规则直接处理不会外发;不得填写联系方式、证件、凭据、商业秘密或他人个人信息;结果可能不准确,须核对后手动采用;不自动保存或覆盖简历;拒绝不影响手动编辑和保存;同意仅针对本次优化。 |
|||
|
|||
第三方留存、训练用途等政策尚待核实并纳入正式告知。本候选不承诺第三方零留存、不用于训练,也不等于完成法律或运营合规审查。 |
|||
|
|||
优化仍为 `POST /api/v1/profile/me/work-experience-optimization`,示例: |
|||
|
|||
```json |
|||
{ |
|||
"position": "开发实习生", |
|||
"description": "在导师指导下协助修改订单接口参数校验。", |
|||
"targetPosition": "后端开发", |
|||
"externalProcessingConsent": true, |
|||
"consentNoticeVersion": "work-experience-external-processing-v2" |
|||
} |
|||
``` |
|||
|
|||
示例版本仅说明当前契约;实际请求必须使用本次已展示的 GET 返回版本,不要永久硬编码。版本不是 `promptVersion`,两者不可混用。`externalProcessingConsent` 只接受原生 JSON 布尔 true;false、null、缺失、字符串 `"true"`、数字或对象均拒绝。版本缺失、空白、旧版本或大小写不符也拒绝,HTTP 400,且不调用模型或走本地信息不足捷径。 |
|||
|
|||
**这是不兼容的请求变更**:旧客户端只有布尔同意而未带版本的请求不能继续调用。前后端须协调部署,未接入前不要开启功能。优化响应字段和 revision 保存流程不变,API 静态/运行时契约更新为 213 paths / 261 operations。 |
|||
|
|||
## 2. 前端展示及同意/拒绝流程 |
|||
|
|||
1. 用户点击 AI 优化后读取告知接口,检查 enabled 和 developmentOnly;读取失败则保留原文,不外呼优化接口。 |
|||
2. 完整展示 title/message、第三方名称、用途与发送字段。仅在 developmentOnly=true 时显示开发联调限制;false 不得当作合规审核通过标志。 |
|||
3. 不默认勾选、不将注册或保存简历与同意绑定;提供“同意并优化”和“暂不使用”。关闭/拒绝不发送 POST,也不清空、修改原文。 |
|||
4. 用户本次明确确认后,提交当前输入、原生 true 和本次已展示的 version。不要把本地缓存的“曾同意”自动用于后续优化,不自动提交或自动重试。 |
|||
5. 收到 400 时检查输入和版本;版本已变化则重新获取、展示并让用户再次确认,不静默替换版本后重发。429 读取 Retry-After,不能自动重试或承诺倒计时后必定可用;503/504 等失败保留原文,展示错误后由用户决定是否重试。 |
|||
6. OPTIMIZED 展示对比并由用户手动采用;NEED_MORE_INFO 展示补充问题,不显示“优化成功”,不启用采用/覆盖。原文已改变或用户已切账号时丢弃旧响应。 |
|||
7. 采用只回填编辑框;真正保存继续使用已有带 revision 的简历接口。 |
|||
|
|||
## 3. 日志保护与验证边界 |
|||
|
|||
后端 AI 链路不记录输入字段、Key、完整模型输出或上游正文。请求/响应/模型建议对象的 toString 已脱敏,正常 JSON 业务返回不受影响;模块异常只返回固定安全文案,不记录未知异常正文、cause 或堆栈。 |
|||
|
|||
AI 请求使用服务端生成的 requestId,不回显调用方提供的请求 ID 文本;日志路径归一化,非常规 HTTP 方法归为 OTHER。保留的请求元数据包括规范化路径、方法、状态、耗时、服务端 requestId 及既有网络地址信息,不包含请求/响应正文或查询参数。前端排障使用后端返回的 requestId,不要将正文或 Key 放进 URL、跟踪头、埋点或 console。 |
|||
|
|||
在 AI 请求范围内抑制 Spring Web/HTTP/Security、Hibernate 及 AI 模块的正文/异常诊断日志;其他接口仍沿用现有日志行为。JDK HTTP 诊断开关可能打印 Authorization/正文,创建 DeepSeek 客户端时发现非空且非 none 的 `jdk.httpclient.HttpClient.log` 将失败关闭;不得在启动后动态开启该诊断。 |
|||
|
|||
该保护针对本工程应用日志,不能替代对反向代理、网关、APM、系统转储或第三方平台的独立审计;不声称任意日志组件或未来新增 logger 都已被全面验证。合成验收资料另存测试证据,不是允许生产记录真实正文。`simulated=false` 仍只是模式标记,不能当成外呼/计费凭证。 |
|||
|
|||
后端只校验客户端提交的同意声明与版本,不能证明前端真的展示、用户阅读或点击;本批不新增永久同意记录或数据库迁移,也不把技术门禁当作完整知情同意审计证据。正式政策、供应商处理约定、前端交互及生产部署仍待独立确认。 |
|||
|
|||
## 4. 联调清单 |
|||
|
|||
- 告知可在关闭模式下读取;匿名、企业、管理员、Refresh Token 和禁用账号不能绕过权限。 |
|||
- 明确 true+当前版本可继续;其他类型、拒绝、遗漏或旧版本必须 400 且零模型调用;对“暂无相关经历”同样校验同意,不能走捷径绕过。 |
|||
- 拒绝/关闭/超时/切账号/输入变化不丢原文,不自动重试,不自动保存。 |
|||
- 用合成原文、Key 标记和完整响应标记覆盖成功、校验、畸形 JSON、未知异常,检查应用日志中均不存在;TRACE 测试也必须保留安全元数据而不是关闭所有日志取巧。 |
|||
- A5 定向173项及A6独立全量3848项均是历史基线结果,不能替代本次A7配置、门禁与告知v2的验证。A7结果以对应新基线验收报告为准。 |
|||
- 本批未调用真实模型,未提交、推送或发布;前端真机、服务器实际配置与真实连通不能由后端离线测试代替。 |
|||
@ -0,0 +1,54 @@ |
|||
# 工作经历 AI:正式环境配置与调用保护 |
|||
|
|||
日期:2026-10-26。面向后端与运维;A7 候选的部署操作说明,不代表已发布或合规审批通过。 |
|||
|
|||
## 1. 正式环境配置 |
|||
|
|||
不再要求 `dev`,也不要为启用 AI 改动现有服务的数据库、JWT、短信等 profile。保持原 profile,显式设置以下非秘密项: |
|||
|
|||
```ini |
|||
AI_WORK_EXPERIENCE_MODE=deepseek |
|||
AI_WORK_EXPERIENCE_REAL_ENABLED=true |
|||
DEEPSEEK_MODEL=deepseek-flash |
|||
DEEPSEEK_TIMEOUT_SECONDS=20 |
|||
AI_WORK_EXPERIENCE_LIMITS_USER_PER_MINUTE=3 |
|||
AI_WORK_EXPERIENCE_LIMITS_USER_PER_DAY=20 |
|||
AI_WORK_EXPERIENCE_LIMITS_GLOBAL_PER_DAY=200 |
|||
AI_WORK_EXPERIENCE_LIMITS_GLOBAL_CONCURRENCY=4 |
|||
``` |
|||
|
|||
上述环境变量由 Spring Environment 解析;等价属性为 `ai.work-experience.mode`、`ai.work-experience.real-enabled`、`ai.work-experience.limits.user-per-minute/user-per-day/global-per-day/global-concurrency`。 |
|||
|
|||
默认 `off`,只设置真实 Key 不会启用功能。`deepseek` 缺少显式 real-enabled、Key 或配置非法时启动失败,不偷偷降级 stub。`off/stub` 不创建真实客户端;旧 stub-enabled 兼容保留。不得在公开端点、请求参数或前端控制模式/模型/地址/Key。 |
|||
|
|||
告知版本升级为 `work-experience-external-processing-v2`,`developmentOnly=false` 表示取消开发 profile 限制,不是生产合规审批标志。旧 v1 必须重新获取告知并确认,否则 POST 400。优化仍然只生成建议,不写简历、不改变 revision。既有前端须完成告知、同意、预览与手动保存。 |
|||
|
|||
## 2. Key 安全注入 |
|||
|
|||
应用仅从进程环境的 `DEEPSEEK_API_KEY` 读取;不增加配置文件明文默认值,不接受前端 Key。由服务器的凭据管理或受保护服务环境注入,不把真实值放到 Git、JAR、前端、命令行参数、Jenkins 控制台或聊天截图。 |
|||
|
|||
Linux/systemd 可使用仓库外的 `/etc/mydreamjob/ai.env` 作为 `EnvironmentFile`,仅管理员及必要服务管理进程可读(例如 root 所有、0600),由管理员在无命令回显的安全渠道填写 Key。保留既有服务配置,使用受限 drop-in 引用该文件,不输出文件内容来“验证”。进程环境并非对 root/同权限诊断进程保密;服务器权限、转储、APM 另行管控。 |
|||
|
|||
本批不写入真实 Key,不修改服务器,不触发真实调用。部署时只记录“是否配置”,不要记录值或长度摘要。此前在聊天中暴露的 Key 建议轮换后再注入。 |
|||
|
|||
## 3. 限流、并发、超时及错误处理 |
|||
|
|||
只有学生 Access Token 可优化;企业、管理员、失效/禁用会话拒绝。输入、敏感字段、原生 true 和当前版本验证在额度门禁前。GET 告知、stub 和本地信息不足路径不消耗外呼额度。 |
|||
|
|||
Redis Lua 在一次原子操作中检查并增加用户分钟/日、全局日额度及并发租约,所有键使用 `{work-experience}` 同槽。默认每账号 3/分钟、20/天,全局 200/天,同账号 1 并发、全局 4 并发;所有应用实例必须使用同一 Redis DB、相同配置。Redis TIME 决定固定分钟及 UTC 日窗口,边界附近不是严格滚动窗口。次数上限不是货币预算;已有 1500 最大输出 token 也不是费用精确预测。 |
|||
|
|||
允许范围:user-per-minute 1..100、user-per-day 1..10000、global-per-day 1..1000000、global-concurrency 1..100;0/负数/越界不能关闭保护。生产按预期流量调整,不要用超大上限绕开保护。 |
|||
|
|||
成功接受的外呼尝试即计次,供应商失败、超时也不退回;被门禁拒绝不计次。全局和用户并发只在请求期间持有,完成/异常均释放;进程崩溃或释放时 Redis 断连,75 秒租约到期回收。释放携带随机所有者 token,旧请求不能释放新租约。75 秒大于最大 60 秒请求超时。进程重启不清除 Redis 配额;Redis 重建/清库/驱逐可能重置计数,部署需监控并配置持久化和不随意驱逐,不能宣称绝对账单上限。 |
|||
|
|||
Redis 未配置、断连、脚本异常或无效结果返回 503,不使用进程内后备放行。额度/并发满返回 429、code=429、`Retry-After: 60`;这是最早重新检查提示,不承诺 60 秒后必定有日额度,前端不得自动重试。503 为关闭/不可用,502 为模型结构错误,504 为超时,400 为输入/同意版本问题。 |
|||
|
|||
`DEEPSEEK_TIMEOUT_SECONDS` 默认20、范围1..60,连接超时5秒,整体响应等待有硬超时并取消未完成 Future;不自动重试、不跟随重定向,官方 HTTPS 地址不变,64KiB 响应上限保留。应用日志仍只保留安全元数据,JDK HTTP 报文诊断开启时失败关闭。不承诺已经审计网关、APM、JVM 转储等外层设施。 |
|||
|
|||
## 4. 验证与回滚边界 |
|||
|
|||
本批新增代码不能沿用 A6 的全量/JAR PASS:以 A7 清单为新身份,先隔离定向回归与真实 Redis 验证,再全量 clean verify 和同次 JAR 黑盒、契约及重启。离线测试禁止开启两项 Live 开关,不调用真实模型。上线前由授权人员完成服务器真实连通和前端闭环。 |
|||
|
|||
回滚配置可设 `AI_WORK_EXPERIENCE_MODE=off` 后重启;需要回滚代码时用备份 JAR/配置,不清库、不清 Redis。关闭只影响 AI,不影响手动编辑保存。旧 JAR 不识别新告知版本,回滚须同步前端刷新告知,不能假定请求向后兼容。 |
|||
|
|||
第三方留存/训练政策、正式告知细节和前端实际展示仍需上线负责人确认;代码允许正式环境启用不等于这些事项已验证。无需数据库迁移,没有新依赖,不更改前端、不提交、不推送、不发布。 |
|||
@ -0,0 +1,67 @@ |
|||
# 撤回后重新投递:后端与前端联调约定 |
|||
|
|||
范围:仅后端重投规则、兼容字段和关联权限;不调整已拒绝后的重投政策,不修改前端,不删除历史数据。测试结果以本批最终验收记录为准;本说明不代表已经上线。 |
|||
|
|||
## 1. 重投规则 |
|||
|
|||
| 当前情况 | POST /applications | |
|||
| --- | --- | |
|||
| 没有投递历史 | 创建首轮,SUBMITTED | |
|||
| 同岗位所有历史申请均为 WITHDRAWN | 创建新 id、新轮次,SUBMITTED | |
|||
| 存在 SUBMITTED/VIEWED/INTERVIEW | 拒绝重复投递 | |
|||
| 存在 REJECTED/HIRED | 仍拒绝,本批不放开 | |
|||
| 岗位不是 PUBLISHED | 不允许投递,包括撤回后重投 | |
|||
|
|||
撤回仍只允许 SUBMITTED/VIEWED,不放开面试中撤回。旧记录不恢复为 SUBMITTED;每轮有独立时间线,旧匹配、面试、Offer 及简历卡片继续关联旧 id。 |
|||
|
|||
V58 新增 attempt_number、previous_application_id 和按状态生成的 active_application_key。旧记录轮次为 1;仅 WITHDRAWN 的占位键为 NULL。数据库同时限制“同岗位同求职者同轮次唯一”和“同岗位同求职者非撤回申请唯一”。投递事务锁定岗位,检查其发布状态、占位及上一轮,防止并发重投。岗位级锁会串行处理同岗位投递,是本批明确的正确性取舍。 |
|||
|
|||
## 2. 接口字段与错误码 |
|||
|
|||
继续调用 `POST /api/v1/applications`,请求不变: |
|||
|
|||
```json |
|||
{"jobId":123} |
|||
``` |
|||
|
|||
重投成功仍返回 HTTP 200,新响应 `data` 示例(其他既有字段保留): |
|||
|
|||
```json |
|||
{"id":456,"jobId":123,"status":"SUBMITTED","attemptNumber":2,"previousApplicationId":321} |
|||
``` |
|||
|
|||
- `id`:本轮投递 ID。后续评分、企业处理、面试、Offer、会话解锁及简历卡片操作必须使用对应轮次的 ID。 |
|||
- `attemptNumber`:从 1 开始;用于区分同岗位不同轮次,不代表新的投递状态。 |
|||
- `previousApplicationId`:直接上一轮 id;首轮为 null,受全局非空序列化影响也可能省略。 |
|||
- 上述字段下发于本人投递列表/详情、企业投递列表、企业候选人列表/详情。候选人 DTO 不新增手机号、用户 ID、简历存储引用等敏感字段。 |
|||
- 重复投递沿用 HTTP 409 / `code=8001`,岗位不可投递沿用 HTTP 404 / `code=7001`。不要把所有非 200 都显示为“网络错误”。 |
|||
- `code=8003`:撤回/状态流转不允许;`8304`:指定轮次未匹配;`8303`:企业与求职者当前没有有效匹配,不能建立/恢复发送。 |
|||
|
|||
前端撤回后将该轮显示为“已撤回”,在岗位仍可投递时展示“重新投递”。成功后使用服务端返回的新 id 刷新列表,不覆盖旧记录。防连点只用于体验,后端仍负责并发约束;请求超时后先刷新投递记录,不能擅自推断成功。 |
|||
|
|||
同岗位多轮全部保留在列表;判断最新轮次用该岗位的最大 attemptNumber,不依赖秒级 createdAt 相同情况下的数组顺序。 |
|||
|
|||
## 3. 旧会话与简历权限 |
|||
|
|||
- 新一轮不复制旧匹配分、阈值快照或匹配结果。旧 id 即使保留 MATCHED 历史记录,只要投递已撤回,不能通过该 id 解锁会话。 |
|||
- 历史 DIRECT 会话可阅读。对有撤回历史的企业/求职者组合,发送消息和通过通用接口获取旧会话,必须存在双方当前有效 MATCHED 申请。重新匹配后可以复用原会话,不复制历史消息。 |
|||
- 沟通解锁仍采用既有“企业—求职者关系”口径:若双方另一个岗位存在有效匹配,可以继续沟通;不能把它描述为每个岗位拥有完全独立的聊天权限。 |
|||
- 聊天简历快照继续绑定原 applicationId,撤回后不能因新一轮匹配而恢复旧卡片授权;已显式撤销的卡片不复活。 |
|||
- 本批不引入“投递时固化简历”新机制。企业当前简历下载仍读取求职者当前资料,并依据指定 applicationId、企业归属与隐私策略授权;主动发送卡片时才保存不可变快照。 |
|||
- PRIVATE 为动态限制;VERIFIED_COMPANIES 等既有显式隐私授权语义不在本批调整。不可将用户面向认证企业的全局授权误称为旧轮次权限恢复。 |
|||
- 面试和 Offer 不迁移到新 id,不恢复旧流程。 |
|||
|
|||
## 4. 前端联调验收步骤 |
|||
|
|||
1. 使用合成账号和已发布岗位首次投递,记录 id、轮次及列表状态。 |
|||
2. 企业查看后,求职者撤回,确认旧时间线仍在。 |
|||
3. 同一岗位重投,确认返回新 id、轮次加一、previousApplicationId 指向旧 id,企业收到新的待处理申请。 |
|||
4. 连续点击/并发请求,确认最多一条有效申请,其余返回重复投递错误。 |
|||
5. 对进行中、已拒绝、已录用和关闭岗位分别验证禁止重投。 |
|||
6. 旧轮次匹配不能解锁新轮次;历史聊天可读,新轮次重新满足匹配后才按关系级规则发送。 |
|||
7. 用真实结构的合成 PDF 验证旧卡片仍拒绝、新卡片绑定新轮次;更换源简历不能篡改旧快照。 |
|||
8. 重启隔离 JAR 后核对两轮记录、时间线和授权行为。 |
|||
|
|||
发布前还需:真实 MySQL 5.7/8 迁移、专项回归、最新 JAR 黑盒与后续全量验证。前端接入与提交/推送/发布均另行安排。 |
|||
|
|||
本批本地验证记录(2026-10-20):专项 Maven 测试合计 129 项,0 失败/错误/跳过;新 JAR 黑盒 61 项及重启复验 7 项通过;静态与运行时契约专项 12 项通过。已覆盖真实 MySQL 5.7/8 的 V57→V58 升级及 MySQL 并发。证据与制品在 `D:\Workspaces\qsp-reapply-20261020`。本批完整 `mvn verify` 尚未执行;上述结果不代表线上已经更新。 |
|||
@ -0,0 +1,266 @@ |
|||
# 岗位与企业展示字段 · 前端交接说明 |
|||
|
|||
> 批次:job-company-display-fields |
|||
> 后端基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`(分支 `fix/mysql57-module-migrations-20261009`) |
|||
> 数据库迁移:`V57__job_company_display_fields.sql` |
|||
> 本文档面向前端工程师,描述本批岗位展示字段的接入参考,不改变既有字段语义。 |
|||
> 更新:2026-10-19。本地后端验收已通过,但尚未提交、推送或发布;目标服务器是否具备这些字段必须按实际部署版本确认。本文不是前端已经完成接入的证明。 |
|||
|
|||
--- |
|||
|
|||
## 1. 新字段在哪些端点返回 |
|||
|
|||
| 端点 | 新增顶层字段 | `company` 摘要 | |
|||
|---|---|---| |
|||
| `GET /jobs`(公开列表) | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /jobs/search`(公开搜索) | ✅ 5 个 | ✅ 新增(兼容扩展) | |
|||
| `GET /jobs/public/{jobId}`(公开详情) | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /companies/{companyId}/jobs` | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /jobs/{jobId}`(企业端自己看) | ✅ 5 个 | ❌ 不返回 | |
|||
| `POST /jobs`、`PUT /jobs/{jobId}`、`PATCH /jobs/{jobId}/status` | ✅ 5 个(响应同企业端视图) | ❌ 不返回 | |
|||
| `GET /companies/me/jobs`(企业端岗位管理) | ✅ 5 个 | ❌ 不返回 | |
|||
| `GET /companies/{companyId}`(公开企业详情) | `companySize` | — | |
|||
| `GET/POST/PUT /companies/me`(企业端自己看) | `companySize` | — | |
|||
| `GET /jobs/favorites`(我的收藏) | ✅ 5 个 | ❌ 不返回 | |
|||
|
|||
「5 个」= `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。 |
|||
|
|||
> 说明:收藏列表复用公开视图组装,因此自动带上 5 个新增顶层字段;本批未为收藏域增加 `company` 嵌套。若前端需要企业名,请先用岗位的 `companyId` 调 `GET /companies/{companyId}`。 |
|||
|
|||
--- |
|||
|
|||
## 2. 字段类型、单位、缺省值与更新语义 |
|||
|
|||
### 2.1 岗位新增字段 |
|||
|
|||
| 字段 | 类型 | 缺省值 | 写入语义 | |
|||
|---|---|---|---| |
|||
| `experience` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = 去两端空白后保存;最长 40 | |
|||
| `education` | string \| null | `null` | 同 `experience`,最长 40 | |
|||
| `benefits` | string[] | `[]`(**永不返回 null**) | `null`/缺省 = 保持原值;**`[]` = 清空**;非空 = 逐项 trim → 拒绝空白项 → 单项 ≤40 → **去重且保留首次出现顺序** → 最多 20 项;违反任一条返回 400 | |
|||
| `salaryMonths` | int \| null | `null` | `null`/缺省 = 保持原值(**本批不提供显式清空**);非 null 必须 **1—24**,**`0` 视为非法**返回 400;**只接受 JSON 整数——小数(`1.9`/`1.0`)、数字字符串(`"12"`/`""`)、布尔、数组、对象、超出 int 范围一律 400**,绝不截断或四舍五入 | |
|||
| `workLocation` | string \| null | `null` | 同 `experience`,最长 255 | |
|||
|
|||
### 2.2 企业新增字段 |
|||
|
|||
| 字段 | 类型 | 缺省值 | 写入语义 | |
|||
|---|---|---|---| |
|||
| `companySize` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = trim 后保存;**超过 40 字符返回 400,不截断** | |
|||
|
|||
上述「缺省/null 保持原值」针对更新;创建时没有旧值,对应可选字段存 NULL。响应中的福利始终为数组,请求中的 `benefits` 可以为 null。 |
|||
|
|||
### 2.3 福利归一化与 OpenAPI 3.1 |
|||
|
|||
- 去重后的数量上限为 20,不是原始请求数组上限:21 个相同标签会成为 1 项并可接受,21 个不同标签返回 400。 |
|||
- 单项长度在 trim 后判断;空白项拒绝,不是自动删去。 |
|||
- 更新 `benefits: null` 保留原值,`benefits: []` 清空。不要把这两种请求合并。 |
|||
- 静态及运行时契约用 `x-normalization` 描述 `trimItems`、`deduplicate`、`rejectBlankItems`、`maxItems=20`、`itemMaxLength=40`,不对原始请求误加 `maxItems`/元素 `maxLength`。 |
|||
- 这是项目的业务扩展,通用 OpenAPI 客户端不会自动执行归一化。若前端加校验,应按相同顺序处理;后端仍是最终校验方。 |
|||
- 运行时为 OpenAPI 3.1,可空字段使用包含 null 的联合类型,不以缺少 OpenAPI 3.0 的 `nullable` 判为不可空。 |
|||
|
|||
### 2.4 单位与语义(**保持不变**) |
|||
|
|||
- `salaryMin` / `salaryMax`:**单位仍是「元/月」**,本批未改成 K。前端展示 K 时自行 `/1000`(既有约定)。 |
|||
- 行业仍来自 `companies.industry`,**不在岗位表重复存储**。 |
|||
|
|||
### 2.5 只读约定 |
|||
|
|||
- `benefits` 读取时:库中为 `NULL` → 返回 `[]`(不是 `null`)。前端可安全地对它做 `.length` / `.map`,无需判空。 |
|||
- `company`:企业不存在时返回 `null`,**不会编造数据、也不会 500**。前端需容忍 `company === null`。 |
|||
|
|||
--- |
|||
|
|||
## 3. `company` 嵌套结构 → 前端类型映射 |
|||
|
|||
后端 `company` 对象(白名单,**仅这 7 个字段**): |
|||
|
|||
```ts |
|||
interface BackendJobCompany { |
|||
id: number; |
|||
name: string; |
|||
logoUrl: string | null; |
|||
description: string | null; |
|||
industry: string | null; |
|||
verified: boolean; |
|||
companySize: string | null; |
|||
} |
|||
``` |
|||
|
|||
建议按此前前端类型映射到 `src/types/job.ts`;本轮未修改或重新验收前端,具体类型请以接手工程师当前代码为准: |
|||
|
|||
| 后端字段 | 前端字段 | 备注 | |
|||
|---|---|---| |
|||
| `company.id` | —(可用于跳企业页) | 前端 `Job` 无对应字段 | |
|||
| `company.name` | `Job.companyName` | 已有映射 `company.name → companyName` | |
|||
| `company.logoUrl` | — | 前端暂无对应字段 | |
|||
| `company.description` | — | 前端暂无对应字段 | |
|||
| **`company.industry`** | **`Job.industry`** | 本批接通,取代前端写死的空值 | |
|||
| `company.verified` | — | 前端暂无对应字段 | |
|||
| **`company.companySize`** | **`JobDetail.companySize`** | 本批接通 | |
|||
| `job.workLocation` | (见 §4) | | |
|||
| `job.city` | `Job.city` | 既有 | |
|||
| `job.title` | `Job.positionName` | 既有 | |
|||
| `job.salaryMin/salaryMax` | `Job.salaryMin/salaryMax` | **单位元→K 需 /1000** | |
|||
| `job.experience` | `Job.experience` | 本批接通 | |
|||
| `job.education` | `Job.education` | 本批接通 | |
|||
| `job.benefits` | `Job.benefits` | 本批接通 | |
|||
| `job.salaryMonths` | `JobDetail.salaryMonths` | 本批接通(展示为「N 薪」) | |
|||
| `job.description` / `requirements` | `JobDetail.description` / `requirements` | 既有,原样保留换行、不截断 | |
|||
|
|||
**逐条要点**: |
|||
1. `company.industry` → 前端 `industry`:直接取 `company.industry`,缺失时为 `null`。 |
|||
2. `company.companySize` → 前端 `companySize`:直接取 `company.companySize`,缺失时为 `null`。 |
|||
3. 顶层 `experience`、`education`、`benefits`、`salaryMonths` 直接映射到同名前端字段。 |
|||
|
|||
--- |
|||
|
|||
## 4. `workLocation` 的处理 |
|||
|
|||
- `workLocation` 是**岗位明确录入的工作地点**,独立于 `city`。 |
|||
- 若 `workLocation` 缺失(`null`),**前端可退化为展示 `city`**。 |
|||
- **但不得据此回写**:不要因为展示用了 `city`,就把 `city` 当作详细办公地址保存或提交回后端。后端的「企业登记地址」(`companies.address`)也**不会被**当作公开招聘地址下发,前端同样不要拿它当工作地点展示。 |
|||
|
|||
--- |
|||
|
|||
## 5. 评分与评价:继续调用既有真实接口 |
|||
|
|||
本批**没有**新增/修改任何评分、评价、排名相关字段或端点。前端请继续使用既有接口: |
|||
|
|||
- 岗位个性化评分:`GET /jobs/{jobId}/personalized-review-score` |
|||
- 岗位评价列表 / 汇总:`GET /jobs/{jobId}/reviews`、`GET /jobs/{jobId}/reviews/summary` |
|||
|
|||
本批也**不补造**综合评分、浏览量、排名或员工评价——这些字段若前端仍写死,属前端侧待接,不是后端缺字段。 |
|||
|
|||
--- |
|||
|
|||
## 6. 前端工程师需核对的字段映射 |
|||
|
|||
以下字段在本批后端验收制品中可返回。此前交接发现部分前端映射为空;接手工程师需核对当前实现,不能由本文件推断前端仍未接入或服务器已发布: |
|||
|
|||
| 前端字段 | 后端来源 | 备注 | |
|||
|---|---|---| |
|||
| `Job.experience` | `job.experience` | 本批新增下发 | |
|||
| `Job.education` | `job.education` | 本批新增下发 | |
|||
| `Job.benefits` | `job.benefits` | 本批新增下发,恒为数组 | |
|||
| `Job.industry` | `company.industry` | 本批新增下发 | |
|||
| `JobDetail.companySize` | `company.companySize` | 本批新增下发 | |
|||
| `JobDetail.salaryMonths` | `job.salaryMonths` | 本批新增下发 | |
|||
|
|||
需接入的展示字段应按上述来源映射;`JobDetail.views`(浏览数)本批**未新增提供**,不要把本地模拟值当服务器真实数据。 |
|||
|
|||
--- |
|||
|
|||
## 7. 投递 / 沟通按钮 |
|||
|
|||
投递、沟通、邀请面试等按钮属于**前端入口**。本批只是补齐后端岗位与企业资料字段,**不会自动生成任何按钮**,也不会新增/修改投递与沟通的接口契约。 |
|||
|
|||
--- |
|||
|
|||
## 8. 响应示例 |
|||
|
|||
### 8.1 新增字段完整(`GET /jobs/public/{jobId}`) |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"id": 101, |
|||
"companyId": 5, |
|||
"title": "前端开发工程师", |
|||
"description": "负责招聘平台 App 的页面开发。", |
|||
"requirements": "1. 熟悉 React Native\n2. 熟悉 TypeScript", |
|||
"city": "北京", |
|||
"employmentType": "FULL_TIME", |
|||
"salaryMin": 18000, |
|||
"salaryMax": 26000, |
|||
"experience": "3-5年", |
|||
"education": "本科", |
|||
"benefits": ["五险一金", "年终奖", "弹性工作"], |
|||
"salaryMonths": 14, |
|||
"workLocation": "北京市海淀区中关村大街 1 号 A 座 12 层", |
|||
"status": "PUBLISHED", |
|||
"publishedAt": "2026-10-01T10:00:00", |
|||
"createdAt": "2026-09-30T18:20:00", |
|||
"company": { |
|||
"id": 5, |
|||
"name": "演示示例科技有限公司", |
|||
"logoUrl": "https://example.invalid/logo.png", |
|||
"description": "非真实招聘,仅用于本地演示。", |
|||
"industry": "互联网/IT", |
|||
"verified": true, |
|||
"companySize": "100-499人" |
|||
} |
|||
}, |
|||
"requestId": "app-xxxxxx", |
|||
"timestamp": "2026-10-01T10:00:01Z" |
|||
} |
|||
``` |
|||
|
|||
### 8.2 历史数据字段缺失(老岗位,未补录) |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"id": 7, |
|||
"companyId": 2, |
|||
"title": "后端开发实习生", |
|||
"description": "参与接口开发。", |
|||
"requirements": null, |
|||
"city": "武汉", |
|||
"employmentType": "INTERN", |
|||
"salaryMin": 3000, |
|||
"salaryMax": 5000, |
|||
"experience": null, |
|||
"education": null, |
|||
"benefits": [], |
|||
"salaryMonths": null, |
|||
"workLocation": null, |
|||
"status": "PUBLISHED", |
|||
"publishedAt": "2026-09-01T09:00:00", |
|||
"createdAt": "2026-08-31T20:00:00", |
|||
"company": { |
|||
"id": 2, |
|||
"name": "演示联合信息科技有限公司", |
|||
"logoUrl": null, |
|||
"description": null, |
|||
"industry": null, |
|||
"verified": false, |
|||
"companySize": null |
|||
} |
|||
}, |
|||
"requestId": "app-yyyyyy", |
|||
"timestamp": "2026-10-01T10:00:01Z" |
|||
} |
|||
``` |
|||
|
|||
> 缺失即为 `null` / `[]`,**后端不会填充「本科」「3—5年」「13薪」等默认值**。前端请按缺失处理(隐藏区块或显示「未填写」)。 |
|||
|
|||
--- |
|||
|
|||
## 9. 兼容性提示 |
|||
|
|||
1. **旧请求不受影响**:不传新字段的创建/修改请求行为完全不变。 |
|||
2. **旧响应字段不变**:`companyId` 等既有字段保留,未删除、未改名、未改类型;`GET /jobs/search` 原 `company` 结构为**兼容扩展**(只在其中新增字段)。 |
|||
3. **草稿与已关闭岗位的可见性规则不变**:DRAFT 岗位公开详情仍不可见(404)。 |
|||
4. `error_code` 约定不变:本批所有新增字段的校验失败统一返回既有参数错误响应(`code=1001`)。 |
|||
|
|||
## 10. 发布后联调检查(尚未执行) |
|||
|
|||
以下为待办,不是已完成的验收结果。仅在对应后端版本发布后,使用获得授权的测试账号执行。 |
|||
|
|||
1. 确认响应来自目标服务而非本地示例;企业名、规模、行业和岗位五字段按实际值显示,缺失时隐藏或提示未填写。 |
|||
2. 月薪元到 K 的转换只做一次;`salaryMonths: null` 不默认为 13 或 0,不向更新接口提交 0。 |
|||
3. 检查字段缺省/null/空白/空数组语义,福利重复和 trim 边界与后端一致。 |
|||
4. 验证公开摘要仅七字段、`company: null` 可正常渲染;不要用企业登记地址代替工作地点。 |
|||
5. 评分和员工评价继续使用真实接口,不能用演示值补齐;投递和沟通入口按已有业务权限处理。 |
|||
6. 对参数错误展示可理解提示,失败后重新读取确认没有部分更新;不伪造成功。 |
|||
|
|||
## 11. 验收与相关说明 |
|||
|
|||
- 最新本地全量 `clean verify`:3464 项通过,失败/错误/跳过均为 0。真实 HTTP 黑盒合计 106 项,其中岗位企业展示 48 项;重启合计 6 项。 |
|||
- 静态/运行时契约对照 129 项通过,契约反向测试 13 项通过。不是前端端到端验收结果。 |
|||
- 交付报告:`docs/batch-job-company-display-fields-report.md`;简历权限与文件名规则:`docs/resume-ownership-hardening-20261016.md`。 |
|||
- 最新证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。未发布、未连接生产库、未向线上导入演示企业岗位。 |
|||
@ -0,0 +1,115 @@ |
|||
# 岗位与企业展示字段 · 交付报告(2026-10-19 验收更新) |
|||
|
|||
> 批次:job-company-display-fields;关联简历归属、文件名兼容和评分测试时钟修复。 |
|||
> 结论:冻结的本地后端工作区快照验收通过;尚未提交、推送、发布或完成线上复验。本文替代此前报告中的过期状态,不把本地结果等同于线上结果。 |
|||
|
|||
## 0. 本次更新与历史结论纠正 |
|||
|
|||
- 全量验证已从历史失败状态更新为 3464 项通过、0 失败、0 错误、0 跳过。 |
|||
- `benefits` 的归一化约束已在静态与运行时契约中一致表达,不再存在此前标注的 `STATIC_ONLY_CONSTRAINTS` 差异。 |
|||
- 福利请求限制按 trim、去重后的结果计算,不能用原始请求的 `maxItems: 20` 或原始元素的 `maxLength: 40` 代替。 |
|||
- 历史报告、旧 JAR 和旧黑盒产物不作为本次验收证据;最新证据目录见第 3、5、6 节。 |
|||
- 历史上传接口 500 的根因仍未证实。当前验证未复现,不将其直接归因于旧副本不一致。 |
|||
|
|||
## 1. 交付范围与身份 |
|||
|
|||
- 原工作区:`C:/Users/15642/Desktop/zhilai`,后端目录为 `mydreamjob-server`。 |
|||
- Git 基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`;分支:`fix/mysql57-module-migrations-20261009`。 |
|||
- 验收副本:`D:/Acceptance/qsp-final-20261019/app`。验收对象是包含未提交改动的冻结快照,不是仅 checkout 上述 HEAD 的代码。 |
|||
- 主要内容:企业规模与岗位展示字段、公开企业摘要、严格整数绑定、V57 迁移、契约及演示工具安全测试。 |
|||
- 关联修复:本人/企业/聊天简历来源归属、中文文件名兼容、评分测试固定时钟。详见 `docs/resume-ownership-hardening-20261016.md`。 |
|||
- 新增源码、测试和迁移存在未跟踪文件,提交时必须使用精确白名单,不能只提交已跟踪 diff,也不能 `git add .`。 |
|||
- 精确验收源文件清单与哈希:`D:/Acceptance/qsp-final-20261019/evidence/source-manifest-current.csv`;契约和工具清单:同目录 `contract-tool-manifest.csv`。 |
|||
|
|||
## 2. 实现与字段规则 |
|||
|
|||
- 企业新增 `companySize`;岗位新增 `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。详情及列表按对应 DTO 下发,公开 `company` 严格使用七字段白名单。 |
|||
- `companySize` 创建与更新共用归一化:创建缺省/null 存 NULL,更新缺省/null 保持原值;空白清空,非空 trim,超过 40 字符返回 400,不截断。 |
|||
- `salaryMonths` 仅接受 JSON 整数 1—24。小数(含 `1.0`)、数字字符串、布尔、数组、对象、超 int 范围均返回 400;仅字段级严格反序列化,未修改全局 Jackson 强制转换规则。 |
|||
- `benefits` 逐项 trim、拒绝空白项,trim 后单项最多 40 字符,去重保序后最多 20 项。21 个相同标签可归一为 1 项;21 个不同标签拒绝。 |
|||
- 福利创建缺省/null 存 NULL;更新缺省/null 保持原值;`[]` 清空。读取 NULL 返回 `[]`。 |
|||
- 岗位列表按页批量查询企业,查询预算测试覆盖空页及分页场景,避免逐岗位查企业的 N+1。 |
|||
- 只提供字段和接口,不会自动生成前端的投递、沟通或评价按钮,也不会补造评分或评价。 |
|||
|
|||
## 3. 测试结果与数量口径 |
|||
|
|||
| 验证范围 | 最新结果 | |
|||
|---|---| |
|||
| 全量 `clean verify` | 3464 项,失败 0,错误 0,跳过 0;BUILD SUCCESS | |
|||
| 真实 HTTP 黑盒 | 简历归属 32 + 来源归属与文件名 26 + 岗位企业展示 48 = 106 项通过 | |
|||
| 重启验证 | 简历字节与授权 4 + 企业岗位字段 2 = 6 项通过 | |
|||
| 静态/运行时契约 | 129 项通过;重启后重复通过,不重复累计 | |
|||
| 契约反向测试 | 13 项通过(含正确基线及错误变异拒绝) | |
|||
| 工具安全验证 | 隔离 MySQL 61 + 容器生命周期单测 3 = 64 项通过 | |
|||
|
|||
全量日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。本次未使用 skip/ignore 参数,测试报告保留在验收副本的 target 中。 |
|||
|
|||
此前 3439 项快照不含后续文件名修复与恢复的三条正式福利 HTTP 测试等变更;源文件差异清单为 `evidence/changes-since-3439-snapshot.csv`。不同快照数字不得混用。未完成的早期执行日志不计入当前通过结果。 |
|||
|
|||
评分测试时间依赖已在测试配置中使用固定 Clock,与测试数据同一时刻,并覆盖 30 天边界;未修改生产评分规则。诊断探针保留为历史证据,不加入正式源码全量测试的计数。 |
|||
|
|||
## 4. MySQL 迁移与存储决策 |
|||
|
|||
- V57 为新增迁移,包含 `companies.company_size` 及岗位五个展示字段对应列;历史 V1—V56 未改。 |
|||
- 最新全量中的 `JobCompanyDisplayFieldsMysqlMigrationTest`、`JobCompanyDisplayFieldsMysqlValidateTest` 在真实 MySQL 容器执行,不用 H2 代替,也未跳过。 |
|||
- 测试覆盖 MySQL 5.7/8 的旧库升级、空库迁移、旧数据保留、新字段写读与 `ddl-auto=validate`。 |
|||
- `benefits_json` 保持 TEXT,沿用仓库兼容性决策;内容仍是服务层控制的 JSON 数组。未宣称使用 MySQL 原生 JSON 列,也未擅自改历史列类型。 |
|||
- MySQL 5.7 的既有兼容迁移钩子和工具链方言/版本警告仍需在目标部署环境关注;本地验证不替代线上迁移核对。 |
|||
|
|||
## 5. 最新验收 JAR |
|||
|
|||
- 路径:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。 |
|||
- 构建方式:冻结快照完整 `clean verify` 成功产生制品,不是跳过测试的 package。 |
|||
- 身份证据:`evidence/jar-sha256.csv`、`source-manifest-current.csv`、`contract-tool-manifest.csv`。 |
|||
- 发布时须核对实际提交及 Jenkins 构建身份;JAR 哈希差异本身不能证明源码错误或一致,需结合源码清单。 |
|||
|
|||
## 6. HTTP 黑盒与重启 |
|||
|
|||
- 岗位企业 48 项覆盖字段一致、缺省/清空语义、严格整数绑定、公开摘要白名单、访问控制、福利归一化与失败无部分更新。 |
|||
- 最新岗位结果:`evidence/display-full.json`;日志:`logs/display-blackbox-complete.log`。先前 40 项执行已被这份 48 项完整结果替代,不另计。 |
|||
- 简历结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`。 |
|||
- 重启后检查文件字节一致、授权/撤销状态保持、岗位企业字段持久化;日志为 `logs/resume-persistence.log`、`logs/display-persistence.log`。 |
|||
- 全部使用隔离环境合成账号和 PDF;未访问真实用户简历或生产数据。 |
|||
|
|||
## 7. OpenAPI 与福利归一化契约 |
|||
|
|||
- 静态文件:`docs/openapi-rc.json`;静态检查:`docs/verify_openapi.py`。 |
|||
- 运行时为 OpenAPI 3.1,可空类型使用类型联合(如 `type: [array, 'null']`),不得依赖 3.0 的 `nullable` 写法。 |
|||
- 福利请求的 `x-normalization`:`trimItems=true`、`deduplicate=true`、`rejectBlankItems=true`、`maxItems=20`、`itemMaxLength=40`。 |
|||
- 此扩展描述服务端归一化规则,不是通用 JSON Schema 自动验证关键字。前端若实现校验,应按相同步骤归一化后判断,不应对原始数组直接限 20 项。 |
|||
- 对照脚本要求归一化规则、字段类型、可空性、响应引用及七字段白名单一致;错误变异必须非零退出,不允许宽松静态独有豁免。 |
|||
- 最新证据:`evidence/openapi-compare.json`、`evidence/openapi-after-restart.json`,日志 `logs/contract-negative.log`、`logs/static-openapi-frozen.log`。 |
|||
|
|||
## 8. 演示数据与工具边界 |
|||
|
|||
- 工具:`docs/tools/b01_demo_display_data.py`;默认 dry-run,写入/清理需要显式参数。 |
|||
- 只允许明确的回环地址、端口 3308 和白名单演示库;非白名单目标在连接前拒绝。该工具不能直接用于线上补数。 |
|||
- 工具账本绑定批次、真实服务器身份、库名、资源 ID、演示键及指纹;同时检查实际用户/企业身份和岗位父子关系,禁止仅凭名称或手机号认领。 |
|||
- 冲突预检后再写入;业务写入与账本登记同事务。清理仅依据精确归属及依赖检查,遇外部依赖拒绝,不关闭外键或级联清除。 |
|||
- 安全测试不得删除同名既有容器,只清理本次创建并记录 ID 的测试容器。 |
|||
- 数据为 3 家虚构企业、12 个明确标记的演示岗位,无真实个人资料及伪造评分、评价。本轮未向线上导入。 |
|||
- 最新工具结果:`evidence/tool-safety.json`、`logs/tool-safety.log`、`logs/tool-container-guard.log`。 |
|||
|
|||
## 9. 前端交接 |
|||
|
|||
参见 `docs/batch-job-company-display-fields-frontend-handover.md`。需要前端工程师按目标环境实际响应接入新字段并处理 null/空数组;不能假定本地修复已经发布到服务器。 |
|||
|
|||
该说明保留字段映射、单位、更新语义和示例,补充福利归一化及本地/线上边界。简历权限和文件名规则另见简历修复说明,不在岗位说明中混入账号口令。 |
|||
|
|||
## 10. 尚待完成与风险 |
|||
|
|||
- Git 精确范围确认、提交、推送、Jenkins 发布及线上小范围复验均未执行。 |
|||
- 前端代码与目标页面本轮未修改、未重新验收;字段映射及账号切换体验须交前端工程师验证。 |
|||
- 历史缺失展示字段不会自动补录;需要授权后由业务录入真实资料,不能把演示数据作为真实招聘信息。 |
|||
- 历史污染简历引用、已生成快照未在线审计或自动修复;不得据本地用例宣称线上历史记录已全部清理。 |
|||
- 旧上传 500 根因仍未证实;当前不复现不等于已证明原因。 |
|||
- 当前仅完成本地后端验收,不宣称线上、前后端完整联调或生产数据验收通过。 |
|||
|
|||
## 11. 边界与证据保留 |
|||
|
|||
- 前端未改;两处受保护文件保持既有内容,不还原、不混入本批提交。 |
|||
- 未暂存、提交、推送、操作 Jenkins 或连接生产数据库;未使用真实账号和简历。 |
|||
- 未弱化/跳过测试、改历史迁移或执行 Docker 全局清理。 |
|||
- 最新验收应用与本轮服务容器已停止;测试资源和日志保留,不删除既有容器或卷。 |
|||
- 汇总证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。旧 `logs2`、`logs3`、`logs4` 资料作为历史记录保留,不作为当前验收结论。 |
|||
@ -0,0 +1,119 @@ |
|||
# 聊天图片、文件消息接入 |
|||
|
|||
适用:企业与求职者的 DIRECT 会话。本文对应本批后端候选源码;尚未提交、推送或部署,线上 #94 还没有这些新接口。联调前确认本批已部署。 |
|||
|
|||
基础路径沿用项目 API_BASE_URL `/api/v1`。全部接口均要求 `Authorization: Bearer <accessToken>`,不能把 Token 放在 URL。现有文字、简历卡片接口保持原用法。普通 Emoji 仍走文字消息。 |
|||
|
|||
## 1. 上传并发送 |
|||
|
|||
`POST /conversations/{id}/attachment-messages` |
|||
|
|||
请求类型 `multipart/form-data`,字段: |
|||
|
|||
| 字段 | 必填 | 内容 | |
|||
| --- | --- | --- | |
|||
| `file` | 是 | 一个文件;文件名和 MIME 必须正确 | |
|||
| `kind` | 是 | `IMAGE` 或 `FILE`,大写 | |
|||
| `clientMessageId` | 是 | 客户端生成,1–64 位字母、数字、`_`、`-` | |
|||
|
|||
调用一次即上传并发送一条消息,没有单独“先上传再发 attachmentId”的步骤。使用项目现有鉴权请求封装;传 FormData 时不要手动写缺少 boundary 的 Content-Type。 |
|||
|
|||
成功:HTTP 200,沿用 `{code:200,data:MessageResponse,requestId:...}` 信封。`IMAGE` 和 `FILE` 消息都在既有 `attachment` 字段返回元数据。例如(示意数据): |
|||
|
|||
```json |
|||
{ |
|||
"id": 123, |
|||
"clientMessageId": "image_abc123", |
|||
"conversationId": 10, |
|||
"senderUserId": 62, |
|||
"messageType": "IMAGE", |
|||
"content": "[图片]", |
|||
"status": "SENT", |
|||
"version": 1, |
|||
"attachment": { |
|||
"fileName": "office.png", |
|||
"contentType": "image/png", |
|||
"size": 18420, |
|||
"sha256": "服务端实际保存图片的SHA256", |
|||
"width": 1280, |
|||
"height": 720, |
|||
"available": true, |
|||
"downloadPath": "/conversations/10/messages/123/attachment", |
|||
"previewPath": "/conversations/10/messages/123/attachment/preview", |
|||
"thumbnailPath": "/conversations/10/messages/123/attachment/thumbnail" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`FILE` 的 `previewPath`、`thumbnailPath`、`width`、`height` 为空或省略。以上路径均相对 `/api/v1`,不是公开下载链接。不要自行拼接本地文件路径或对象存储地址。 |
|||
|
|||
### 文件范围 |
|||
|
|||
- 图片:JPEG(`.jpg/.jpeg`)与 PNG;实际解码校验,最多 1600 万像素、任一边长不超过 8192。首版不支持 GIF、WebP、HEIC、SVG,前端选取时过滤或在明确告知后转换为支持格式。 |
|||
- 普通文件:PDF、DOC、DOCX、UTF-8 TXT;不支持 XLS/XLSX、ZIP、安装包或可执行文件。 |
|||
- 正确 MIME 分别为 `image/jpeg`、`image/png`、`application/pdf`、`application/msword`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document`、`text/plain`。 |
|||
- 单文件最多 10 MiB(10,485,760 字节),空文件拒绝。会话有效附件默认累计最多 100 MiB。缩略图最长边 320,JPEG 格式。 |
|||
- 图片重新编码以移除 EXIF 等附加元数据,`size/sha256` 对应保存后的图片,可能与客户端原文件不同。 |
|||
- 文档执行扩展名、MIME、签名及 DOCX 压缩包结构检查;这不等于病毒扫描。 |
|||
|
|||
### 重试与错误 |
|||
|
|||
发送前生成 clientMessageId,网络超时后重试必须使用相同会话、账号、文件字节、文件名、MIME、kind 和 clientMessageId。相同请求返回原消息,不重复入库或推送;相同 key 改文件、改类型或与文字/简历消息混用返回 400。不要为一次自动重试重新生成 key。 |
|||
|
|||
- HTTP 400:缺字段、非法类型、伪造内容、尺寸/业务大小超限、重复 key 内容不一致或会话配额耗尽;显示后端错误并保留重试/重新选择入口。 |
|||
- HTTP 413:Servlet 层拦截超大 multipart,请提示文件过大。 |
|||
- HTTP 401:未登录、会话失效或账号禁用。 |
|||
- HTTP 403:非成员、屏蔽、企业未认证、当前无有效投递沟通权限,或不是 DIRECT 会话。 |
|||
- HTTP 404:会话/消息不存在、附件已撤回/清除或发送者账号已禁用。 |
|||
|
|||
失败不能显示“已发送”。上传进度是否展示由前端传输层实现;当前后端不提供断点续传、分片或服务器进度接口。 |
|||
|
|||
## 2. 缩略图、预览和下载 |
|||
|
|||
| 接口 | 返回 | |
|||
| --- | --- | |
|||
| `GET /conversations/{conversationId}/messages/{messageId}/attachment` | 附件原始保存内容,Content-Disposition 为 attachment | |
|||
| `GET .../attachment/preview` | 仅 IMAGE,重新编码的全尺寸图片,inline | |
|||
| `GET .../attachment/thumbnail` | 仅 IMAGE,JPEG 缩略图,inline | |
|||
|
|||
均返回二进制,不经过 JSON 解包;响应头包括真实 Content-Type、Content-Length、`Cache-Control: no-store` 和 `X-Content-Type-Options: nosniff`。 |
|||
|
|||
列表优先加载 thumbnail,点击后鉴权加载 preview;文件消息显示 fileName、size 和下载操作。图片请求也要带 Authorization,不能只把受保护地址直接丢给不带鉴权的图片组件。 |
|||
|
|||
若先下载到 App 私有缓存再预览,应按账号隔离,退出账号、切换账号、撤回和失效时清除相关缓存;不要复制到公共相册或公共目录后仍宣称能被服务端撤回。已下载到设备或截图的内容,服务端不能远程收回。 |
|||
|
|||
每次下载重新检查会话成员、企业认证、有效沟通投递、屏蔽、消息状态和发送者有效性。列表里的 `available:true` 只代表列表读取时附件尚存,不保证之后每次请求仍获授权。 |
|||
|
|||
## 3. 消息列表、实时事件、撤回 |
|||
|
|||
现有 `GET /conversations/{id}/messages` 及 `GET /conversations/{id}/events` 会返回同样的 MessageResponse/attachment。WebSocket STOMP 的 `MESSAGE_CREATED`、`MESSAGE_RECALLED` 事件也使用同一结构。 |
|||
|
|||
- 上传成功响应和实时事件可能都到达:按 `message.id + version` 合并,不能直接 append 两次。 |
|||
- 重连后使用既有游标补拉,补拉成功合并后才推进 nextCursor;不要用实时事件 eventId 直接覆盖补拉游标。 |
|||
- `IMAGE/FILE` 走对应气泡;保留 TEXT 和 RESUME 的现有渲染。 |
|||
- 撤回沿用 `POST /conversations/{conversationId}/messages/{messageId}/recall`:仅本人,沿用两分钟窗口。 |
|||
- 撤回成功后删除服务端原件/缩略图,消息保留“消息已撤回”,attachment.available=false,地址与元数据清空,前端停止展示/加载旧图。 |
|||
|
|||
## 4. 现有前端修改位置 |
|||
|
|||
- `app/chat/[id].tsx`:把图片/文件按钮的占位提示替换为选择器;新增图片气泡、文件卡片、预览及下载反馈。 |
|||
- `src/types/message.ts`:扩展消息类型与 attachment 元数据,兼容无 attachment 的旧消息。 |
|||
- `src/api/api.ts`:multipart 发送、鉴权二进制请求与错误转换;所有请求沿用当前登录态。 |
|||
- `src/hooks/useConversation.ts`:发送状态、稳定 clientMessageId、重试、按 ID/版本合并、实时事件与重连补拉。 |
|||
|
|||
页面只处理展示和交互,请求与状态编排仍放在 API/Hook 层。地图功能不在本批范围。 |
|||
|
|||
## 5. 联调验收 |
|||
|
|||
1. 企业、求职者在已允许沟通的投递内互发 PNG/JPEG/TXT/PDF,双方当前聊天页立即出现且不重复。 |
|||
2. 缩略图、全图、文件名/大小正确,下载字节与后端 sha256 一致;图片按服务端重新编码后的摘要核对。 |
|||
3. 超时重试复用 key,只产生一条;换文件复用同 key 被拒绝。 |
|||
4. 切账号、陌生账号、另一会话 ID、屏蔽、撤回、禁用账号无法继续读取附件。 |
|||
5. 接收方断网后重连补拉,退出重进及后端重启后历史附件仍在。 |
|||
6. 空文件、超限文件、伪装 PNG、非法文档和不支持格式均明确失败,页面无假成功或白屏。 |
|||
|
|||
## 6. 后端部署前提 |
|||
|
|||
需迁移 V62(新表 chat_attachments 与消息元数据列)。二进制在数据库中和消息一起提交,无需开放公开文件目录。MySQL max_allowed_packet 应至少 16 MiB,代理上传限制应允许约 11 MiB 的 multipart 请求;业务单文件仍受 10 MiB 限制。生产部署、代理配置修改需单独授权。 |
|||
|
|||
Servlet 上传边界至少为单文件 10 MiB、请求 11 MiB;既有更大的配置予以保留。会话业务配额可配置 `app.chat.attachments.max-conversation-bytes`(默认 104857600)。首次版本采用限额与撤回释放空间,尚无长期附件管理/任意时间删除接口。 |
|||
@ -0,0 +1,68 @@ |
|||
# 轻松聘聊天实时接口 |
|||
|
|||
本文补充 `openapi-rc.json` 中的消息发送、事件补拉和消息字段。所有 HTTP 路径以 `/api/v1` 为前缀;下文省略该前缀。 |
|||
|
|||
## 连接和发送 |
|||
|
|||
通过 STOMP WebSocket 连接 `/api/v1/ws`,在 **STOMP CONNECT 帧**中设置 `Authorization: Bearer <accessToken>`。不得放在 URL 中,refresh token 不可代替 access token。连接成功后订阅唯一用户队列 `/user/queue/chat-events`,收到 `{"type":"CHAT_READY"}` 后开始补拉。业务消息继续通过 HTTP 接口发送,STOMP SEND 不开放。 |
|||
|
|||
`POST /conversations/messages` 请求: |
|||
|
|||
```json |
|||
{ |
|||
"conversationId": 1, |
|||
"content": "你好", |
|||
"clientMessageId": "example-message-001" |
|||
} |
|||
``` |
|||
|
|||
`clientMessageId` 为 1–64 位英文字母、数字、下划线或连字符;为兼容旧客户端可省略,但新客户端必须为一次逻辑发送生成固定标识,并在超时、断线或未收到响应时复用。同一会话、同一发送者下,相同标识和正文返回原消息,不重复生成事件;相同标识搭配不同正文返回 400。撤回后重试也不会恢复旧正文。 |
|||
|
|||
消息响应增加 `clientMessageId`(旧消息可为空)和 `version`(消息版本)。`MessageResponse` 在普通会话返回 `senderUserId`;人才邀请会话继续使用 `senderScope` 隐藏用户标识。撤回消息只返回“消息已撤回”占位。 |
|||
|
|||
## 事件补拉 |
|||
|
|||
`GET /conversations/{id}/events?after=0&size=50` 必须携带普通 HTTP Bearer access token,且当前账号必须属于该会话。`after` 是非负事件游标;`size` 默认 50,合法范围 1–100。 |
|||
|
|||
响应的 `data`: |
|||
|
|||
```json |
|||
{ |
|||
"items": [ |
|||
{ |
|||
"eventId": 2, |
|||
"type": "MESSAGE_CREATED", |
|||
"conversationId": 1, |
|||
"occurredAt": "2026-10-15T10:00:00", |
|||
"actorScope": "OTHER_PARTICIPANT", |
|||
"payload": { |
|||
"id": 1, |
|||
"clientMessageId": "example-message-001", |
|||
"version": 0, |
|||
"conversationId": 1, |
|||
"content": "你好", |
|||
"status": "SENT", |
|||
"isRead": false |
|||
} |
|||
} |
|||
], |
|||
"nextCursor": 2, |
|||
"hasMore": false |
|||
} |
|||
``` |
|||
|
|||
事件包括 `MESSAGE_CREATED`、`MESSAGE_READ`、`MESSAGE_RECALLED`。`actorScope` 为 `CURRENT_USER` 或 `OTHER_PARTICIPANT`;`payload.isRead` 表示当前接收用户的已读状态。payload 读取消息当前状态,而不是保存历史正文快照。 |
|||
|
|||
客户端按以下顺序恢复: |
|||
|
|||
1. 连接、鉴权、订阅并等待 `CHAT_READY`。 |
|||
2. 使用该账号、该会话已持久化的补拉游标调用事件接口;首次使用 0。 |
|||
3. 以 `eventId` 去重,以消息 `id` 和 `version` 合并,避免旧版本覆盖新状态。 |
|||
4. 本页成功合并后才持久化 `nextCursor`;`hasMore=true` 时继续请求,直到为 false。 |
|||
5. 补拉期间同时合并实时事件。实时事件 ID 可能乱序,不得直接用其最大值推进补拉游标。同版本的已读状态还需按 `actorScope` 和本地已读操作处理,不能仅按版本丢弃已读事件。 |
|||
|
|||
断线后重新获取有效 access token、重连、订阅,再重复上述流程。账号切换必须隔离游标、消息缓存和连接。 |
|||
|
|||
## 运行边界 |
|||
|
|||
这些接口沿用企业认证、会话成员、屏蔽和现有匹配准入规则;不会改变投递或开聊门槛。目前 WebSocket 使用单实例内存 broker,多实例部署需要共享消息分发能力。Nginx 示例文件须经部署配置和验证后才能使用。 |
|||
@ -0,0 +1,73 @@ |
|||
# 短信登录后首次设置密码:实施与联调指令 |
|||
|
|||
本批面向后端与前端开发人员,实现首次设置引导。设置密码后,短信与密码登录继续并存;不新增必须设置密码才能使用业务的门禁。 |
|||
|
|||
## 1. 登录与密码状态 |
|||
|
|||
在 POST /auth/login、POST /auth/password/login 和 GET /auth/me 中读取 passwordConfigured。它由 password_hash 是否存在派生,不增加 users 状态列。 |
|||
|
|||
无密码账号只有在 login 用途短信验证成功后,才获得 passwordSetupToken 和 passwordSetupExpiresIn=600。凭证不是 JWT;使用安全随机数生成 32 字节,数据库只保存带 password-setup 域前缀的 HMAC 摘要。 |
|||
|
|||
凭证绑定 userId、sessionId 和固定到期时间。密码登录、刷新、身份选择都不会签发设置凭证;刷新不能延长有效期。当前会话退出或被撤销后,凭证不可使用。 |
|||
|
|||
## 2. 请求与响应 |
|||
|
|||
前置地址为既有 /api/v1。新接口 POST /auth/password/setup 必须携带当前 Access Token。 |
|||
|
|||
```json |
|||
{"passwordSetupToken":"短信登录返回的一次性凭证","newPassword":"用户输入的新密码"} |
|||
``` |
|||
|
|||
成功返回现有 ApiResponse 包装,data 含 passwordConfigured=true、reauthenticationRequired=false、identitySelectionRequired、accessToken、refreshToken、expiresIn。有效期沿用既有认证配置与契约,不写死为一小时。 |
|||
|
|||
前端应先替换 Access/Refresh Token 并清除设置凭证,再根据 identitySelectionRequired 进入身份选择或原角色首页。已有密码不重复弹设置页。凭证过期或响应丢失时,重新短信登录并读取密码状态;不得反复提交创建操作。 |
|||
|
|||
参数/密码策略错误为 400,无效会话或设置凭证为 401,已有密码冲突为 409,限流为 429,安全依赖不可用为 503。不得把 SQL、密码、令牌或凭证摘要返回给客户端。 |
|||
|
|||
## 3. 后端事务顺序 |
|||
|
|||
新增 V54 password_setup_challenges,不修改 V1–V53。核查 MySQL 5.7/8.4 和 H2 的兼容性。 |
|||
|
|||
先锁定用户并校验 active/注销状态,再锁定当前服务端会话记录,串行处理并发登出;检查操作限流与尚无密码,复用现有密码策略。数据库条件更新必须同时匹配用户、会话、摘要、未消费、未过期,且更新行数恰好为 1。 |
|||
|
|||
随后 BCrypt 保存密码及 passwordChangedAt,flush 用户,撤销全部旧会话,建立新会话,并写固定、脱敏审计。上述操作使用同一数据库事务;任何步骤失败均回滚。Redis 限流计数不宣称自动回滚。 |
|||
|
|||
未选身份账号只新增放行 POST /auth/password/setup,继续保留其他业务门禁。客户端不传 userId、role 或 sessionId。 |
|||
|
|||
## 4. 兼容与库存 |
|||
|
|||
保留 /auth/password/set 的 password-set 短信用途与成功后重新认证语义;保留修改、找回密码及原有登录、刷新、登出接口。设置成功不能覆盖企业认证或账号角色。 |
|||
|
|||
静态 OpenAPI 与运行时均增加一个路径、一个操作,目标 198 路径、241 操作。接口库存测试随真实新增接口更新;不降低原有身份门禁断言。 |
|||
|
|||
## 5. 验收步骤 |
|||
|
|||
用明确假号段准备无密码账号,经真实验证码消费逻辑短信登录,检查未设置状态和凭证有效期。首次设置后检查 BCrypt 匹配、新会话可用、旧 Access/Refresh Token 被撤销;再次短信登录不发设置凭证,密码登录也应成功。 |
|||
|
|||
测试跨用户、跨会话、匿名、过期、已消费、弱密码和安全依赖异常。两个并发请求同时使用凭证,只允许一个成功;审计或会话创建失败应回滚密码、消费状态、会话变更。 |
|||
|
|||
同一套断言在 H2 和真实 MySQL 8.4 执行,并运行 MySQL 5.7 迁移及认证关联回归。记录真实 F/E/S 与 OpenAPI 结果后再安排独立复验、提交和部署。 |
|||
|
|||
当前文档描述本地实现;测试结果以本轮实际报告为准,不表示已上线或已完成前端接入。 |
|||
|
|||
## 6. 本轮验证结果与交付边界 |
|||
|
|||
2026-10-12:认证关联回归执行 68 项,Failures=0、Errors=0、Skipped=0,覆盖旧密码接口、刷新、会话、统一注册、企业注册,以及真实 MySQL 5.7/8.4 迁移和运行时接口库存。 |
|||
|
|||
最终补充数据库异常安全响应与 DTO 脱敏后,重新编译最新源码并执行 H2 和真实 MySQL 8.4 的首次设置密码测试,共 20 项,Failures=0、Errors=0、Skipped=0;定向 mvn verify 成功并生成最新 JAR。两组测试存在重叠,不合计为 88 个独立用例。 |
|||
|
|||
静态 OpenAPI 检查通过:198 路径、241 操作。历史 V1–V53 无内容修改;新增 V54。git diff --check 通过,暂存区为空。 |
|||
|
|||
2026-10-12 补充全量与最新 JAR 验收:`mvn -o clean verify` 成功,3278 项,Failures=0、Errors=0、Skipped=0。首次全量发现 MySql57MigrationConfigurationTest 仍断言 53 个迁移,与新增 V54 不一致;仅更新计数为 54,定向 4/4 通过后重跑全量成功。全量日志位于本机临时目录 password-setup-full-verify-final.log。 |
|||
|
|||
验收对象为上述 clean verify 生成的 mydreamjob-server-0.0.1-SNAPSHOT.jar,SHA256:`635EB9F268771D266EED8965FCD39F03F9B24E8B7219945980FB54037D303822`。 |
|||
|
|||
使用独立、一次性 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 容器与该 JAR 做真实 HTTP 黑盒,应用及管理健康检查均为 200,Flyway 达到 V54。使用假号段测试账号,短信发送通过 Mock sender,但验证码生成、数据库消费、Redis 限流及认证会话均执行真实实现;验证码从隔离数据库读取,没有调用运营商。 |
|||
|
|||
黑盒通过:无密码账号短信登录返回首次设置凭证;弱密码 400、匿名设置 401;设置成功返回新会话且无需重新登录;旧 Access 与 Refresh 均 401,新 Access 可用;再次使用设置凭证 409;设置后选择 STUDENT 成功;密码登录成功;再次发送验证码并短信登录成功,返回 passwordConfigured=true 且不再返回设置凭证。 |
|||
|
|||
数据库逐项证据:测试用户 1 行、STUDENT 主角色与 user_roles 各 1 条、BCrypt 格式密码与 password_changed_at 均已保存;设置凭证 1 条且已消费;两条短信验证码均已消费。四个 RabbitMQ 队列(短信、通知、审计、通知死信)ready/unacknowledged 全为 0。应用日志扫描未发现测试手机号、明文测试密码或 JWT/Bearer。 |
|||
|
|||
静态及运行时 OpenAPI 均为 198 路径、241 操作,方法/路径集合完全一致。黑盒脚本最初使用 PowerShell 属性集合的 .Count 导致误报,改用数组计数复核后通过。首次 JAR 启动遇到默认管理端口 8081 与前端 Metro 冲突,将本次管理端口设为 18081 后启动成功;应用端口为 18080。 |
|||
|
|||
验收后已停止本次 JAR,并删除三个一次性容器及其测试数据;13307、16379、25672、18080、18081 无本次残留监听。前端 Metro 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。 |
|||
@ -0,0 +1,39 @@ |
|||
# 岗位地图服务端配置与认证锁修复 |
|||
|
|||
本说明属于“认证锁修复+地图超时配置化”新批次,不代表已部署。前一31文件/JAR的8秒/12秒仅是隔离候选对照,不能与本批实际默认值混用。 |
|||
|
|||
## 地图超时 |
|||
|
|||
| 属性 | 默认值 | 合法范围 | |
|||
|---|---:|---| |
|||
| app.amap.connect-timeout-ms | 8000 | 500—10000毫秒 | |
|||
| app.amap.total-timeout-ms | 12000 | 1000—20000毫秒,并且严格大于连接预算 | |
|||
|
|||
使用服务端配置绑定,不接受业务请求参数控制预算。非法值、非整数或非法预算关系导致启动失败,不静默退回默认值;错误不包含Key。 |
|||
|
|||
配置示例(不含凭据,可通过部署系统的外部配置注入): |
|||
|
|||
```yaml |
|||
app: |
|||
amap: |
|||
connect-timeout-ms: 8000 |
|||
total-timeout-ms: 12000 |
|||
``` |
|||
|
|||
总预算覆盖一次HTTP请求的响应头与响应体,超时取消异步请求。保持固定高德HTTPS目的地、禁止重定向、响应上限262144字节、失败关闭和企业/全局限流,不新增自动重试。 |
|||
|
|||
真实网络的1.5秒连接预算在本机多次失败,8秒连接/12秒总预算曾获得真实成功。这不保证所有环境必然成功,仍需同次新制品的真实调用、延迟和容量验证。前端请求预算需要容纳后端总预算和业务处理开销,避免短于后端预算就提前报失败;不建议无限等待或自动连续重试。 |
|||
|
|||
Key仍只通过安全配置 `AMAP_WEB_SERVICE_KEY` 或 `app.amap.web-service-key` 注入,不在源码或前端分发。没有修改原 application.yml,也不覆盖现有注销冷静期等运行配置。 |
|||
|
|||
## 认证锁顺序 |
|||
|
|||
登出在撤销auth_tokens前,先锁users当前用户行,与登录、刷新、密码变更统一 `users -> auth_tokens` 顺序。刷新先拿到锁时,登出等待刷新提交后才启动批量撤销;登出先拿到锁时,刷新随后看到已撤销状态,不能签发活动后继。 |
|||
|
|||
保留当前会话范围、幂等登出语义和既有角色/状态判断,不扩大到其它会话,不更改Token格式或API路径,不增加数据库迁移。 |
|||
|
|||
回归使用真实事务/SQL、提交前屏障和数据库阻塞观察,验证两个执行顺序、旧/新Access拒绝及其他会话不受影响。诊断时H2复现过残留,而MySQL同一交错未复现;不能用MySQL一次通过声称所有并发交错已穷尽。 |
|||
|
|||
## 验证与发布 |
|||
|
|||
新批次必须更新源码manifest并重新专项、全量及同次JAR验证,旧A2通过不自动继承。这里只提供配置与行为说明,不授予提交、推送、发布或服务器修改权限。 |
|||
@ -0,0 +1,434 @@ |
|||
# 入职清单与公司知识介绍 API(N04) |
|||
|
|||
> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。 |
|||
> |
|||
> 数据库迁移:`V50__onboarding_checklist_and_company_knowledge.sql`、`V51__onboarding_standard_checklist_seed.sql`。 |
|||
|
|||
## 1. Base URL 与认证 |
|||
|
|||
本地 Base URL: |
|||
|
|||
```text |
|||
http://127.0.0.1:8080/api/v1 |
|||
``` |
|||
|
|||
所有 `/onboarding/**` 接口都要求登录,并携带: |
|||
|
|||
```http |
|||
Authorization: Bearer <accessToken> |
|||
Content-Type: application/json |
|||
X-Request-Id: <optional-trace-id> |
|||
``` |
|||
|
|||
细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": {}, |
|||
"requestId": "req-...", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 2. 接口与权限总览 |
|||
|
|||
| # | Method | Path | 角色 | 说明 | |
|||
|---|---|---|---|---| |
|||
| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 | |
|||
| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 | |
|||
| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 | |
|||
| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 | |
|||
| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 | |
|||
| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 | |
|||
| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 | |
|||
| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 | |
|||
| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 | |
|||
|
|||
权限规则: |
|||
|
|||
- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。 |
|||
- COMPANY 接口只允许拥有 COMPANY 角色且 `companies.verified=true` 的账号访问;未认证企业统一返回 `403/5004`,在读取或写入本企业清单、知识内容前拒绝。 |
|||
- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。 |
|||
- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。 |
|||
- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。 |
|||
- 企业条目不存在或属于其他企业时统一返回 `404/10501`,不泄露其他企业数据。 |
|||
|
|||
## 3. 入职清单语义 |
|||
|
|||
### 3.1 通用项与自定义项合并 |
|||
|
|||
有效清单由以下两部分组成: |
|||
|
|||
1. 所有活动通用项 `source=STANDARD`;V51 固定写入 12 条。 |
|||
2. 当前企业所有活动自定义项 `source=COMPANY`。 |
|||
|
|||
合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。 |
|||
|
|||
`category` 固定为: |
|||
|
|||
```text |
|||
DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER |
|||
证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他 |
|||
``` |
|||
|
|||
### 3.2 学生读取有效清单 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/companies/10/checklist |
|||
Authorization: Bearer <studentAccessToken> |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"companyId": 10, |
|||
"companyName": "示例科技", |
|||
"customized": true, |
|||
"totalItems": 13, |
|||
"completedItems": 1, |
|||
"completionRate": 7.7, |
|||
"items": [ |
|||
{ |
|||
"itemId": 101, |
|||
"source": "STANDARD", |
|||
"itemKey": "standard-id-card", |
|||
"title": "准备身份证件", |
|||
"description": "准备身份证原件或有效身份证明,用于入职登记。", |
|||
"category": "DOCUMENT", |
|||
"required": true, |
|||
"sortOrder": 100, |
|||
"active": true, |
|||
"completed": true, |
|||
"completedAt": "2026-09-17T09:00:00", |
|||
"progressVersion": 1 |
|||
}, |
|||
{ |
|||
"itemId": 501, |
|||
"source": "COMPANY", |
|||
"itemKey": "company-10-8f1c", |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"required": false, |
|||
"sortOrder": 1000, |
|||
"active": true, |
|||
"completed": false, |
|||
"completedAt": null, |
|||
"progressVersion": 0 |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-onboarding-checklist", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。 |
|||
|
|||
### 3.3 更新完成状态 |
|||
|
|||
请求使用绝对状态,而不是切换: |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion |
|||
Authorization: Bearer <studentAccessToken> |
|||
|
|||
{"completed": true, "version": 0} |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"itemId": 501, |
|||
"completed": true, |
|||
"completedAt": "2026-09-17T01:31:00Z", |
|||
"progressVersion": 1, |
|||
"totalItems": 13, |
|||
"completedItems": 2, |
|||
"completionRate": 15.4 |
|||
}, |
|||
"requestId": "req-onboarding-progress", |
|||
"timestamp": "2026-09-17T01:31:00Z" |
|||
} |
|||
``` |
|||
|
|||
版本规则: |
|||
|
|||
- 首次保存某条目时可省略 `version` 或传 `0`。 |
|||
- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/10505`。 |
|||
- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。 |
|||
- 首次提交 `completed=false` 不创建记录。 |
|||
- `completed=false` 会清除 `completedAt`。 |
|||
- 一名学生与一个清单项最多一条进度记录。 |
|||
|
|||
### 3.4 企业创建自定义项 |
|||
|
|||
```http |
|||
POST /api/v1/onboarding/company/checklist-items |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"sortOrder": 1000, |
|||
"required": false, |
|||
"idempotencyKey": "company-10-access-card-20260917" |
|||
} |
|||
``` |
|||
|
|||
响应 `data`: |
|||
|
|||
```json |
|||
{ |
|||
"itemId": 501, |
|||
"source": "COMPANY", |
|||
"itemKey": "company-10-8f1c", |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"required": false, |
|||
"sortOrder": 1000, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
``` |
|||
|
|||
约束: |
|||
|
|||
- `title`:必填,最多 120 字符。 |
|||
- `description`:可选,最多 1000 字符。 |
|||
- `category`:必填,仅允许固定枚举。 |
|||
- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。 |
|||
- `required`:可选,默认 `false`。 |
|||
- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。 |
|||
- 活动自定义项上限为 50 条;达到上限返回 `409/10503`。 |
|||
|
|||
幂等重试: |
|||
|
|||
- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。 |
|||
- 同一企业、同一 key、请求语义不同:返回 `409/10504`。 |
|||
- 不同企业可使用相同 key,互不冲突。 |
|||
- 未传 key 时,每次请求创建新条目。 |
|||
|
|||
### 3.5 企业查询、更新与软删除 |
|||
|
|||
查询本企业全部条目: |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/company/checklist-items |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求: |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/company/checklist-items/501 |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"title": "领取门禁卡并激活", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"sortOrder": 900, |
|||
"required": true, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
``` |
|||
|
|||
更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/10502`。软删除: |
|||
|
|||
```http |
|||
DELETE /api/v1/onboarding/company/checklist-items/501?version=1 |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。 |
|||
|
|||
## 4. 公司知识介绍语义 |
|||
|
|||
### 4.1 学生读取与回退 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/companies/10/knowledge |
|||
Authorization: Bearer <studentAccessToken> |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"company": { |
|||
"id": 10, |
|||
"name": "示例科技", |
|||
"logoUrl": "https://cdn.example.com/company-10.png", |
|||
"description": "面向企业提供数据基础设施产品。", |
|||
"industry": "互联网", |
|||
"website": "https://example.com", |
|||
"address": "杭州市余杭区", |
|||
"verified": true |
|||
}, |
|||
"knowledgeState": "CONFIGURED", |
|||
"knowledge": { |
|||
"culture": "以用户价值为中心,保持透明和长期主义。", |
|||
"workPolicies": "弹性上班;每周一、三线下办公。", |
|||
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
|||
"updatedAt": "2026-09-17T01:33:00Z" |
|||
}, |
|||
"reviewSummary": { |
|||
"state": "SUFFICIENT", |
|||
"totalReviews": 4, |
|||
"averageRating": 4.5, |
|||
"ratingDistribution": { |
|||
"rating1": 0, |
|||
"rating2": 0, |
|||
"rating3": 1, |
|||
"rating4": 1, |
|||
"rating5": 2 |
|||
}, |
|||
"topTags": ["发展空间", "同事友好"], |
|||
"dimensionSummary": { |
|||
"AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"} |
|||
} |
|||
} |
|||
}, |
|||
"requestId": "req-company-overview", |
|||
"timestamp": "2026-09-17T01:34:00Z" |
|||
} |
|||
``` |
|||
|
|||
回退与状态: |
|||
|
|||
- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。 |
|||
- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。 |
|||
- 企业不存在:`404/5001`。 |
|||
- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。 |
|||
|
|||
评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价: |
|||
|
|||
| `reviewSummary.state` | 条件 | 返回内容 | |
|||
|---|---|---| |
|||
| `EMPTY` | 0 条 | `state`、`totalReviews=0` | |
|||
| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 | |
|||
| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 | |
|||
|
|||
热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。 |
|||
|
|||
### 4.2 企业查询知识配置 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/company/knowledge |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
未配置时: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"configured": false, |
|||
"version": 0 |
|||
}, |
|||
"requestId": "req-company-knowledge", |
|||
"timestamp": "2026-09-17T01:33:00Z" |
|||
} |
|||
``` |
|||
|
|||
已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。 |
|||
|
|||
### 4.3 企业创建或更新知识配置 |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/company/knowledge |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"culture": "以用户价值为中心,保持透明和长期主义。", |
|||
"workPolicies": "弹性上班;每周一、三线下办公。", |
|||
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
|||
"version": 1 |
|||
} |
|||
``` |
|||
|
|||
规则: |
|||
|
|||
- 至少一个内容字段必须非空。 |
|||
- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。 |
|||
- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。 |
|||
- 首次创建可以省略 `version`,创建后版本为 `0`。 |
|||
- 更新已有记录必须提交当前版本;版本过期返回 `409/10506`。 |
|||
- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。 |
|||
|
|||
## 5. 错误码 |
|||
|
|||
| HTTP | code | 含义 | 前端处理 | |
|||
|---|---:|---|---| |
|||
| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 | |
|||
| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 | |
|||
| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 | |
|||
| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 | |
|||
| 404 | 10501 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 | |
|||
| 409 | 10502 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 | |
|||
| 409 | 10503 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 | |
|||
| 409 | 10504 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 | |
|||
| 409 | 10505 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` | |
|||
| 409 | 10506 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` | |
|||
|
|||
401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`: |
|||
|
|||
```json |
|||
{"code": 401, "message": "not authenticated", "timestamp": ""} |
|||
``` |
|||
|
|||
其他错误示例: |
|||
|
|||
```json |
|||
{ |
|||
"code": 10504, |
|||
"message": "idempotency key conflict: request body differs", |
|||
"data": null, |
|||
"requestId": "req-10504", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 6. 前端推荐流程 |
|||
|
|||
学生端: |
|||
|
|||
1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。 |
|||
2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。 |
|||
3. 收到 `10505` 时重新读取清单,避免覆盖其他设备的新状态。 |
|||
4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。 |
|||
5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。 |
|||
|
|||
企业端: |
|||
|
|||
1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。 |
|||
2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。 |
|||
3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。 |
|||
4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。 |
|||
5. 收到 `10503` 时停止自动新增;收到 `10504` 时不要复用旧请求体。 |
|||
@ -0,0 +1,104 @@ |
|||
# 本人在线简历:非 AI 接入约定 |
|||
|
|||
范围:基础信息与联系方式、求职意向、多段教育/工作/项目经历、技能详情、自我评价、在线简历保存与 DOCX 生成。此文对应工作区新增实现,不代表已经部署。既有附件上传下载、附件版本和权限规则继续使用原接口。 |
|||
|
|||
## 接口 |
|||
|
|||
以下路径均加 `/api/v1`;所有接口需要当前 STUDENT 的 Bearer Token。身份只来自认证上下文,不接受 userId 指定他人。COMPANY、ADMIN 不可使用,企业候选人响应不新增手机号或邮箱。 |
|||
|
|||
| 方法 | 路径 | 用途 | |
|||
| --- | --- | --- | |
|||
| GET | `/profile/me/online-resume` | 整份读取,首次 revision=0,不自动写库 | |
|||
| PUT | `/profile/me/online-resume` | 整份保存,body 为 `{revision, content}` | |
|||
| GET | `/profile/me/online-resume/{section}` | 某类条目列表 | |
|||
| POST | `/profile/me/online-resume/{section}` | 新增条目,body 为 `{revision, entry}` | |
|||
| GET | `/profile/me/online-resume/{section}/{id}` | 本人条目详情 | |
|||
| PUT | `/profile/me/online-resume/{section}/{id}` | 替换一条,body 为 `{revision, entry}` | |
|||
| DELETE | `/profile/me/online-resume/{section}/{id}?revision=数字` | 删除一条 | |
|||
| GET | `/profile/me/online-resume/download` | 当前已保存内容生成 DOCX,二进制响应 | |
|||
|
|||
`section` 仅限 `education-experiences`、`work-experiences`、`project-experiences`、`skills`。动态路由不表示可修改其他字段。 |
|||
|
|||
GET、保存和条目修改的成功响应仍用 `{code:200,message:"ok",data:...}`。整份 data 为 `{revision,content,attachment,updatedAt}`;条目列表/详情只返回条目自身。attachment 无附件时为 null 或省略,有附件时包含 fileName/contentType/size/downloadUrl;downloadUrl 为鉴权接口地址,不能直接把存储键拼成公网 URL。 |
|||
|
|||
## 完整保存示例 |
|||
|
|||
```json |
|||
{ |
|||
"revision": 0, |
|||
"content": { |
|||
"basicInfo": { |
|||
"realName": "合成测试", |
|||
"phone": "13800000000", |
|||
"email": "synthetic@example.test", |
|||
"location": "测试城市", |
|||
"education": "本科", |
|||
"school": "合成大学", |
|||
"graduationYear": 2022 |
|||
}, |
|||
"jobIntention": { |
|||
"targetPosition": "后端工程师", |
|||
"expectedSalaryMin": 10000, |
|||
"expectedSalaryMax": 20000, |
|||
"preferredCities": ["测试城市"], |
|||
"employmentTypes": ["FULL_TIME"], |
|||
"preferredIndustries": [], |
|||
"preferredJobTypes": [], |
|||
"remoteAllowed": false, |
|||
"jobSeekingStatus": "LOOKING", |
|||
"employmentStatus": "UNEMPLOYED", |
|||
"availableFrom": "2026-11-01" |
|||
}, |
|||
"educationExperiences": [ |
|||
{"school":"合成大学","major":"计算机","degree":"本科","startDate":"2018-09-01","endDate":"2022-06-30","ongoing":false,"description":"合成教育描述"} |
|||
], |
|||
"workExperiences": [ |
|||
{"company":"合成企业","position":"开发工程师","startDate":"2022-07-01","ongoing":true,"description":"合成工作内容"} |
|||
], |
|||
"projectExperiences": [ |
|||
{"name":"合成项目","role":"开发","startDate":"2023-01-01","ongoing":true,"description":"合成项目内容"} |
|||
], |
|||
"skills": [{"name":"Java","level":"PROFICIENT","years":3,"description":"合成技能说明"}], |
|||
"selfEvaluation": "合成自我评价" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
这是完整替换接口,不能只提交当前编辑的一小块 content:空数组删除该类全部条目,可选字段 null/空字符串清空;省略某个集合会使用空集合,不能把省略理解为保持原值。局部编辑经历优先使用条目接口;基础信息、意向、自评编辑应先读完整内容,再修改相应块后整体保存。 |
|||
|
|||
新条目省略 id,服务器返回 UUID。保留既有条目时必须保留其 id;不存在或重复 id 返回 400,不接受自行指定新 id。条目 PUT 为完整替换,不能只提交某一个属性。条目顺序就是显示与导出的顺序。 |
|||
|
|||
## 字段及兼容 |
|||
|
|||
- basicInfo.phone/email 是本人简历联系方式,首次未保存时从账号手机号/邮箱带出。保存不修改登录手机号、账号邮箱或密码,不表示邮箱已验证。空联系方式可以清空;联系电话支持数字、开头加号、空格和常见座机分隔符,最多 30 字符。 |
|||
- 姓名、所在地、单组学历/学校/毕业年份复用既有个人资料;selfEvaluation 对应既有 bio;skills 的 name 同步到既有技能列表。旧资料接口更新后,再读在线简历会反映最新基础资料和技能名称;同名技能保留熟练度等扩展信息。老接口 skills 仍为历史字符串,新接口 skills 才是对象数组。 |
|||
- 多段 educationExperiences 与旧的单组 education/school/graduationYear 不自动推断相互覆盖;前端需要展示主学历时明确填写 basicInfo 对应字段。 |
|||
- 薪资单位为元/月、非负且下限不得高于上限;城市、用工类型、行业、岗位类型、远程意愿复用已有求职偏好。 |
|||
- jobSeekingStatus:LOOKING 正在求职、OPEN_TO_OPPORTUNITIES 考虑机会、NOT_LOOKING 暂不求职、STUDENT 在校求职身份。employmentStatus:EMPLOYED 在职、UNEMPLOYED 离职/未就业、STUDENT 在校。availableFrom 是可到岗日期。 |
|||
- 每段经历 startDate 必填,使用 YYYY-MM-DD。ongoing=true 时 endDate 必须省略或为 null;否则 endDate 必填且不早于 startDate。教育必填学校/学历,工作必填公司/职位,项目必填名称。 |
|||
- 教育最多 20 段,工作/项目各 30 段,技能 50 项;技能 name 不重复,level 为 BEGINNER/FAMILIAR/PROFICIENT/EXPERT,years 为 0—80 的整数。经历 description、自评各最多 5000 字;技能 description 最多 1000 字。其他长度约束见 OpenAPI。 |
|||
- 在线简历保存不修改现有附件、不创建附件快照,也不把多段经历自动下发给企业或恢复任何简历授权。 |
|||
|
|||
## 保存、登录回填和错误 |
|||
|
|||
1. 页面进入或切回账号后 GET 完整简历,按当前账号重建页面状态。不能只依赖上传控件、缓存或上一次登录的本地对象。 |
|||
2. 修改时提交最近一次成功响应中的 revision。成功后用响应整体替换页面数据,包括服务器分配的 id 和新 revision。 |
|||
3. 所有在线简历入口共用 revision,整份保存和条目修改都会加一。HTTP 409 / code 6002 表示版本过期:提示用户刷新并合并编辑,不能自动拿新 revision 盲重试覆盖。 |
|||
4. HTTP 404 / code 6003 表示 section 或本人条目不存在;400 表示字段、日期、id 或大小不合法;401/403 按既有登录、角色流程处理。 |
|||
5. revision 保护的是这些新增在线简历写入入口。不要并发用旧资料/偏好接口和新接口编辑同一份表单;旧接口并未加入在线简历 revision 协议。 |
|||
|
|||
## 附件、预览与导出 |
|||
|
|||
已有附件仍使用 POST/GET/DELETE `/profile/me/resume`。文件名、大小从新 GET 的 attachment 恢复展示,不能通过简历控件是否还保留本地 File 对象判断服务器有无附件。 |
|||
|
|||
在线预览可由前端根据 content 渲染。下载按钮携带 Bearer 获取 `/profile/me/online-resume/download` 的 Blob,按响应文件名保存 `.docx`;生成内容为服务器当前已保存的各段内容,不包含上传附件正文,编辑后应先保存再下载。此接口生成可编辑 Word 文件,不提供 PDF 排版或 AI 处理。 |
|||
|
|||
## 数据与验证边界 |
|||
|
|||
V59 新建 online_resumes,保存扩展内容与 revision;不回写历史迁移,不迁移或解析真实简历内容。新增数据纳入本人个人数据导出,账号注销完成时清理相应在线简历记录。未自动开放企业读取。 |
|||
|
|||
后端测试和制品证据单独保存在 D 盘;测试通过不表示已发布,前端接入与线上发布仍需分别安排。 |
|||
|
|||
本批本地验证(2026-10-20):最终源码的在线简历业务测试在 H2 和 MySQL 8 各 16 项通过;MySQL 5.7/8 的 V58→V59 迁移及 V58 重投回归通过;两种 MySQL 的运行时/静态接口集合一致。最新 16 个测试套件报告合并为 502 项,0 失败、0 错误、0 跳过,是多次定向执行的汇总,不是一次全量测试。静态契约为 205 paths / 253 operations,既有 paths 和 schema 未改写。 |
|||
|
|||
证据目录:`D:\Workspaces\qsp-online-resume-20261020-113017`,详见 verification-summary.json、source-manifest.json、artifact-identity.json、logs/。构建副本基于 67b0957,只叠加本批白名单文件,不含两个受保护文件的工作区修改。本批尚未执行无筛选的全量 mvn verify、独立 JAR HTTP 黑盒或前端验收;DOCX 已做 ZIP/XML 结构检查,未做 Word 视觉渲染。未提交、推送、部署或运行线上迁移。 |
|||
@ -0,0 +1,36 @@ |
|||
# 在线简历批次 R01-E 查询预算调整说明 |
|||
|
|||
日期:2026-10-20 |
|||
范围:仅在线简历扩展数据进入个人数据导出;不改变其他章节的查询方式。 |
|||
|
|||
## 1. 变更 |
|||
|
|||
`PersonalDataCollector.collect(userId)` 在 profile、preferences 之后新增一次: |
|||
|
|||
```text |
|||
onlineResumeRepository.findById(userId) |
|||
``` |
|||
|
|||
对应 SQL 只按 `online_resumes.user_id` 主键读取当前登录用户的一条记录,不按投递数量循环,也不读取其他用户记录。 |
|||
|
|||
因此 R01-E 的 E1 最小夹具预算由 **20 次固定 SQL** 调整为 **21 次固定 SQL**: |
|||
|
|||
- 1 条投递:21 |
|||
- 10 条投递:21 |
|||
- 50 条投递:21 |
|||
- H2 与真实 MySQL 均重新实测 |
|||
- 所有被计数语句仍必须为 `SELECT` |
|||
- 丰富夹具下查询数不随记录数增长的断言保留 |
|||
|
|||
## 2. 独立校验 |
|||
|
|||
`R01EIndependentCrossDatabaseVerificationTest` 同时增加: |
|||
|
|||
- `online_resumes` 查询恰好一次; |
|||
- SQL 含 `user_id` 条件与绑定参数; |
|||
- 夹具同时放入本人与他人的在线简历,导出结果只含本人内容; |
|||
- 清理夹具删除 `online_resumes`,残留检查包含该表。 |
|||
|
|||
这不是放宽上限,也不是通过缓存或跳过导出内容规避查询;新增的一次查询是导出包含本人在线简历所必需的主键读取。 |
|||
|
|||
历史文档 `docs/r01-e-independent-verification-20260918.md` 中的 20/20/20 保留为当时批次的实测记录,不回溯改写。 |
|||
File diff suppressed because it is too large
@ -0,0 +1,163 @@ |
|||
# Phase B:已认证企业业务门禁实施报告 |
|||
|
|||
日期:2026-09-23 |
|||
范围:仅 Phase B;未暂存、未提交、未推送、未部署。 |
|||
|
|||
## 结论 |
|||
|
|||
Phase B 门禁已在服务层实施并通过全量验证。`mvn test` 与 `mvn clean verify` 均为 **2880 / 0 failures / 0 errors / 0 skipped**;真实 MySQL/Testcontainers 用例实际运行。OpenAPI 检查为 **182 paths / 220 operations,All checks passed**。API 路径和 operation 集合未改,`docs/openapi-rc.json` 无差异。 |
|||
|
|||
本轮没有启动应用部署或连接生产 RabbitMQ。拒绝路径的事件/通知调用通过测试中的 mock `never()` 断言验证;本机 RabbitMQ 容器当时为停止状态,因此不报告未经测量的 broker 队列深度。没有数据库迁移,也没有新增业务状态字段。 |
|||
|
|||
## 1. 开工核查与基线 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`(Phase A 已交付基线) |
|||
- 暂存区:空;`git diff --check`:exit 0 |
|||
- 工作区:除本轮明确列出的改动外,原有历史未跟踪文件全部保留;未运行 `reset`、`restore`、`checkout`、`clean` 或任何目录级 `git add`。 |
|||
- `git status` 曾将 `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标成修改;逐文件 `git hash-object` 与 HEAD blob 相同,且 `git diff --quiet`,确认是 Windows stat 缓存假阳性,内容未改。 |
|||
- 迁移库存最高为 **V46**;V1–V46 未改、无 V47。 |
|||
- 上线健康接口可见版本仅为 `0.0.1-SNAPSHOT`,不能据此映射出精确线上 commit。Phase B 未部署,线上版本未改变。 |
|||
- Docker 可用;最终全量验证中的 MySQL/Testcontainers 测试真实执行,Skipped=0。 |
|||
|
|||
## 2. 权威设计和门禁语义 |
|||
|
|||
以 `docs/company-onboarding-admin-user-management-design.md` §7 及独立评审为准:企业认证事实只读 `companies.verified`;`users.is_verified` 不用于企业认证判断。 |
|||
|
|||
检查顺序保持为:现有认证/活动账号校验 → `user_roles` 的 COMPANY 身份 → 当前企业资料存在及 owner 归属 → 仅对受限招聘业务检查 `companies.verified=true`。认证门禁不能取代任何角色、归属、资源可见性判断。资源查询前优先校验调用者所属企业;按资源授权的入口仍保留其原归属检查,避免跨企业数据探测。 |
|||
|
|||
未认证、审核中、已拒绝均按 `companies.verified=false` 统一拒绝受限操作,固定响应 `COMPANY_NOT_VERIFIED` / code **5004** / HTTP **403** / 文案 `company not verified`。该错误码与已有数字码无冲突,HTTP 状态集中登记在 `ErrorHttpStatusRegistry`,没有在 Controller 中复制异常映射。 |
|||
|
|||
## 3. 端点库存与允许/禁止矩阵 |
|||
|
|||
逐个检查了 Controller 映射及其 Service 路径。下表中“COMPANY”表示现有 Controller/服务中的 COMPANY 身份要求仍保留;“own/资源归属”表示原企业 owner、岗位、投递或参与者校验仍执行。受限入口在服务端、候选人/Offer/面试等敏感查询或副作用之前执行认证门禁。 |
|||
|
|||
| 接口范围(HTTP method + path) | Controller → Service | Phase B 结果及既有校验 | |
|||
|---|---|---| |
|||
| `POST /jobs`;`PUT /jobs/{jobId}`;`PATCH /jobs/{jobId}/status` | `JobController` → `JobService` | 仍要求 COMPANY 和本企业岗位归属。创建 DRAFT、编辑自己的 DRAFT 允许;已发布内容编辑以及向 `PUBLISHED` 转换要求认证。将自己已发布岗位转为 `CLOSED` 仍允许,保留安全下架能力和旧状态语义。跨企业岗位仍按旧归属错误拒绝。公开 `GET /jobs`、`GET /jobs/search`、`GET /jobs/public/{jobId}`、`GET /companies/{companyId}/jobs` 不改变。 | |
|||
| `GET /company/applications`;`PATCH /applications/{id}/status`;`GET /company/candidates`;`GET /company/candidates/{applicationId}`;`GET /company/candidates/{applicationId}/resume`;`GET /applications/{id}/timeline`(企业分支) | `ApplicationController` → `ApplicationService` / `CompanyResumeAccessPolicy` | COMPANY、公司/岗位归属与既有可见性校验保留。企业认证先于敏感投递/候选人查询;下载门禁先于简历存储读取。拒绝时不读文件;学生本人的投递/timeline 分支不加企业门禁。 | |
|||
| `GET /company/candidates/{applicationId}/scoring`;`GET /company/scoring/dashboard/{overview,job-rankings,job-rankings/{jobId},candidate-rankings,candidate-rankings/{applicationId},trend}` | `CandidateScoringController`、`CompanyScoringDashboardController` → scoring services | 企业评分/候选人评分属于候选人敏感数据,所有公司入口要求 verified;原公司归属与管理员分支保持不变。 | |
|||
| `GET/POST/PUT/DELETE /applications/{applicationId}/feedback` | `CandidateFeedbackController` → `CandidateFeedbackService` | 企业侧 feedback 先校验公司归属及认证,再访问投递或写反馈;学生自己的反馈路径/权限保持原语义。 | |
|||
| `GET /companies/me/jobs/{jobId}/talent-recommendations`;`POST /companies/me/jobs/{jobId}/talent-invitations`;`GET /companies/me/talent-invitations`;`GET /companies/me/talent-invitations/{invitationId}` | `TalentDiscoveryController`、`TalentInvitationController` → respective services | 企业身份、岗位/邀请归属后要求认证;门禁先于候选人匹配、资料加载及邀请写入/通知。`/profile/me/talent-discovery` 与求职者邀请列表/决定接口仍为求职者自身功能。 | |
|||
| `POST/GET /applications/{applicationId}/interviews`;`GET /interviews/me`;`GET/PUT /interviews/{interviewId}`;`POST /interviews/{interviewId}/cancel`、`/complete`;`GET /interviews/{interviewId}/timeline` | `InterviewController` → `InterviewService` | 企业安排、修改、取消、完成及查看面试时先检查企业认证及原面试/投递归属;求职者查看和回应自己的面试保留原路径与行为。 | |
|||
| `PUT/GET /interviews/{interviewId}/score`;`POST /interviews/{interviewId}/score/submit`;`GET /interviews/{interviewId}/score/history` | `InterviewScoreController` → `InterviewScoreService` | 企业侧评分读写要求认证,并保留面试参与者/归属检查;求职者侧不被企业门禁误伤。 | |
|||
| `POST/GET /offers`;`GET/PUT /offers/{id}`;`PATCH /offers/{id}/status`;`GET /offers/{id}/timeline`;`POST /offers/{id}/remind`;`GET/POST /offers/{id}/counter-offers`;`PATCH /offers/{id}/counter-offers/{coId}/accept`、`/reject`;`GET /offers/statistics` | `OfferController` → `OfferService` | 对公司发起、读取、修改、发送/流转 Offer 及统计入口,在查询/写入/提醒副作用之前验证公司。原 application/offer/counter-offer 所有权检查保留。`GET /offers/compare` 为求职者接口,不改。 | |
|||
| `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile`;`GET/PUT /companies/me/match-rule` | recommendation、`MatchRuleController` → services | 公司侧候选人推荐/匹配配置要求 verified,并保留 owner 校验;`/profile/me/match-rule` 等求职者配置不改。 | |
|||
| `PUT /applications/{id}/hr-suitability-score`;`GET /applications/{id}/match`;`POST /applications/{id}/conversation`;`GET/POST /conversations`;`GET /conversations/{id}`、`/{id}/messages`;`POST /conversations/messages`、`/{id}/read`、`/{conversationId}/messages/{messageId}/recall` | match、conversation、`MessagingController` → services | 企业候选人匹配、发起/读取招聘会话和消息均需 verified。会话成员、投递和消息归属校验保留;求职者自己的候选兴趣分/匹配路径不变。消息列表读取还使用明确加载 `user_roles` 的查询,避免 detached lazy roles 导致角色分支错误。 | |
|||
| `GET /notifications`、`/unread-count`;`POST /notifications/{id}/read`、`/read-all`(企业主体) | `NotificationController` → `NotificationService` | 企业用户侧通知查看/读状态操作要求认证,拒绝先于通知列表读取或读状态写入;其他主体语义保留。系统 worker/事件生产路径不套用户发起的企业门禁。 | |
|||
| `GET/POST/PUT /companies/me`;`GET /companies/me/onboarding`;`POST/GET /companies/me/verification` | `CompanyController`、`CompanyVerificationController` → company/onboarding/verification services | 未认证企业仍可查改自己的资料、查询聚合状态、提交和查询自己的认证申请。身份及 owner 关系照旧验证;认证审核状态机未改。 | |
|||
|
|||
### 明确未加门禁的入口 |
|||
|
|||
- 公开岗位、公司资料和公开评价查询没有候选人隐私或企业招聘副作用,保持公开行为。 |
|||
- `CompanyReviewManagementController` 的本企业公开岗位评价/回复用于企业声誉运营,不读取候选人敏感资料,也不是招聘动作;按 Phase B 设计范围不设认证门禁,既有公司归属照旧。 |
|||
- `ApplicationMatchController` 中求职者的 candidate-interest-score、求职者岗位匹配与个人设置仍按原 STUDENT 路径处理。 |
|||
- `OfferExpiryScheduler`/expiry service、匹配通知重试、候选评分 refresh worker、邀请过期清理和通知消费是系统维护/既有任务,不是未认证企业发起的招聘请求;逐一检查没有从这些后台入口调用受限用户写 API,因此不加企业认证门禁,避免误伤既有系统处理。 |
|||
|
|||
## 4. 实施内容和精确文件范围 |
|||
|
|||
新增服务层共用规则 `mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java`。它只依据传入的 `Company.verified` 判定;角色、活动状态、公司归属和资源归属仍由现有 service/guard 流程负责。 |
|||
|
|||
修改的生产文件(20 个): |
|||
|
|||
- `application/ApplicationService.java`、`application/CompanyResumeAccessPolicy.java` |
|||
- `auth/UserRepository.java` |
|||
- `bidirectionalmatch/BidirectionalMatchService.java`、`MatchConversationService.java`、`MatchRuleService.java` |
|||
- `candidatescore/CandidateScoringEngineService.java`、`candidatescore/dashboard/CompanyScoringDashboardService.java` |
|||
- `common/exception/ErrorCode.java`、`ErrorHttpStatusRegistry.java` |
|||
- `feedback/CandidateFeedbackService.java` |
|||
- `interview/InterviewService.java`、`interviewscore/InterviewScoreService.java` |
|||
- `job/JobService.java` |
|||
- `messaging/MessagingService.java`、`notification/NotificationService.java` |
|||
- `offer/OfferService.java` |
|||
- `recommendation/JobRecommendationService.java` |
|||
- `talentdiscovery/TalentDiscoveryService.java`、`talentinvitation/TalentInvitationService.java` |
|||
|
|||
新增测试类(3 个): |
|||
|
|||
- `phaseb/PhaseBVerifiedCompanyGateContractTest.java`:静态端点—Service 覆盖映射,并锁定认证只看 `companies.verified`。 |
|||
- `phaseb/VerifiedCompanyGuardTest.java`:verified / false / null / company missing 与固定业务码和 403 映射。 |
|||
- `phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java`:真实 HTTP `PATCH /jobs/{id}/status` 对 UNVERIFIED、PENDING、REJECTED 均返回 403/code 5004,并确认 DRAFT 仍未被发布;verified 企业发布成功。 |
|||
|
|||
另有既有 service/controller/persistence 测试被扩展:求职者、企业、管理员认证角色及旧行为回归;简历读取计数与租户边界;Job 发布;Offer、Interview、TalentInvitation 的拒绝副作用;feedback、matching、messaging、notification、scoring、resume、interview-score 的企业测试夹具现在明确使用已认证 Company,避免旧测试把默认 `verified=false` fixture 误当已认证企业。R01-B/R01-C/R01-E 错误映射和消息查询预算测试只调整了 Phase B 新码映射/现有 eager-role 读取所增加的固定查询基线。 |
|||
|
|||
被修改的既有测试文件(27 个): |
|||
|
|||
- `application/ApplicationServiceTest.java`、`CompanyResumeAccessPolicyTest.java`、`CompanyResumeDownloadIntegrationTest.java` |
|||
- `bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java`、`BidirectionalMatchServiceTest.java`、`MatchConversationServiceTest.java`、`MatchRuleServiceTest.java` |
|||
- `candidatescore/CandidateScoringClockConsistencyTest.java`、`CandidateScoringEngineServiceTest.java` |
|||
- `common/exception/GlobalExceptionHandlerStatusContractTest.java` |
|||
- `common/r01b/ErrorHttpStatusRegistryTest.java`、`GlobalExceptionHandlerMappingParityTest.java`、`R01BIndependentHttpMappingParityTest.java` |
|||
- `common/r01e/R01EIndependentCrossDatabaseVerificationTest.java`、`R01EQueryBudgetAcceptanceTest.java` |
|||
- `feedback/CandidateFeedbackRefreshRegistrationTest.java`、`CandidateFeedbackServiceTest.java` |
|||
- `interview/InterviewServiceTest.java` |
|||
- `interviewscore/InterviewScoreControllerTest.java`、`InterviewScorePersistenceIntegrationTest.java`、`InterviewScoreServiceTest.java` |
|||
- `job/JobServiceTest.java` |
|||
- `messaging/MessageRecallHttpIntegrationTest.java`、`MessagingServiceTest.java` |
|||
- `notification/NotificationServiceTest.java` |
|||
- `offer/OfferServiceTest.java` |
|||
- `talentinvitation/TalentInvitationServiceTest.java` |
|||
|
|||
没有修改 Controller mappings、DTO、`pom.xml`、Flyway、V1–V46、OpenAPI 文件、短信服务、Phase A 注册/验证码/Token/企业认证审核流程,也没有任何 Phase C 用户/角色管理代码。 |
|||
|
|||
## 5. TDD 失败证据与测试覆盖 |
|||
|
|||
- 先增加 `JobServiceTest.transitionStatus_unverifiedCompanyCannotPublish`,在尚无门禁时失败:期望 `BusinessException`,旧实现没有抛出。修复前失败日志保存在系统临时目录,不加入仓库。 |
|||
- 对简历策略做单点变异(移除认证判断)后,`CompanyResumeAccessPolicyTest` 新增负向用例失败:未认证时仍访问 `applicationRepository.findById`,与“门禁先于候选人/简历数据读取”相违。源码恢复后,变异目标测试转绿;变异失败日志保存在系统临时目录。 |
|||
- HTTP 集成覆盖 UNVERIFIED/PENDING/REJECTED 统一 403 及 verified 可发布。跨企业资源仍由原 service ownership tests 和既有集成回归覆盖。 |
|||
- 简历测试包含 storage read probe,拒绝路径 `READ_CALLS=0`。Job 拒绝后状态仍 DRAFT。Offer、Interview、TalentInvitation 的未认证路径对投递/岗位/候选人/offer/interview/invitation/history/audit/notification/刷新等依赖均断言 `never()`,无业务写入或通知触发。 |
|||
- 无效 Token、匿名、STUDENT、ADMIN 的 401/403 使用现有 SecurityFilterChain/Controller 回归;Phase B 没有放开匿名路由,也没有改 SecurityConfig。 |
|||
|
|||
本轮测试驱动过程中的非产品问题:部分旧单测严格 Mockito stubbing 仍 stub 旧的 `findById`;用户/角色集合在 service 离开事务后为 lazy,导致企业身份分支不能可靠判定。通过更新这些测试的 stub,并新增 `UserRepository.findByIdWithRoles` 明确加载 `user_roles` 修复;没有降低断言,也没有删角色检查。OpenAPI 初次调用时使用了不存在的 `docs/verify/_openapi.py` 路径;依据仓库文件清单改用 `docs/verify_openapi.py` 后全部通过,未改脚本。 |
|||
|
|||
## 6. 验证结果 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| Phase B 定向(Contract + Guard + persistence + Job/Application/Resume/Offer/Interview/Invitation 及关联服务) | **189/189,F=0 E=0 S=0**;另外最新版 persistence HTTP 类 **2/2** 单独通过 | |
|||
| 全量 `mvn test` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS** | |
|||
| 最终 `mvn clean verify` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS**;MySQL/Testcontainers 实测执行 | |
|||
| `python ..\docs\verify_openapi.py`(从 server 模块目录) | **182 paths / 220 operations,All checks passed** | |
|||
| 数据库 schema/migration | V1–V46 无差异;无迁移需求;无 V47 | |
|||
| OpenAPI/API surface | Controller 路径和 operation 集合未改变;`docs/openapi-rc.json` 无差异 | |
|||
|
|||
Phase B 新增用例共 **12**,全套从 2868 增至 2880。Docker 中 MySQL 8.4/Testcontainers 用例真实执行,Skipped=0。Hikari 在 Testcontainers 销毁/重建连接时输出连接已关闭的校验 WARN;两轮全量均以 0 errors 成功完成。 |
|||
|
|||
## 7. 副作用、安全与已知观测 |
|||
|
|||
- 认证事实静态断言确认 Guard 只读取 `Company.verified`,不读取 `User.isVerified`。 |
|||
- 租户 A 即使已认证仍须通过原 owner/资源关系校验;新门禁不构成跨企业授权。服务先验证企业/权限,再查候选人敏感资源;简历 policy 在 `read(storagePath)` 之前失败关闭。 |
|||
- 拒绝请求不会改变 DRAFT/Offer/Interview/Invitation 等业务记录;未调用 audit、notification、message/event publisher 或 scoring refresh 依赖。没有真实 RabbitMQ 运行,因此不声称 broker queue depth 被独立读取;拒绝路径的 publisher 无交互由测试逐项锁定。 |
|||
- 新增代码和报告未包含凭据、JWT、密码、真实手机号、数据库连接秘密、容器动态口令或本机路径;测试数据使用非真实 fixture。修复前日志留在临时目录,未加入 Git。 |
|||
- 没有通过设置 `verified=true` 绕过生产流程;“verified”测试场景仅在测试 fixture 中按显式场景构造,以验证认证企业原行为。 |
|||
|
|||
## 8. Git 结束状态 |
|||
|
|||
- HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,分支仍为 `master`。 |
|||
- 暂存区为空,`git diff --check` exit 0;没有提交、推送、部署或合并。 |
|||
- 新增生产组件和 Phase B 测试及本报告仍未跟踪;所有此前历史未跟踪文件原样保留。 |
|||
- 工作区中 20 个已跟踪生产文件有 Phase B 改动;额外两个显示为修改的既有文件已通过 blob hash 证实内容与 HEAD 完全一致。 |
|||
- 最终核查无 Java/Maven 残留进程,8080/8081 无监听;未停止或删除 Docker 容器/镜像。 |
|||
|
|||
Phase C 管理员用户与角色管理未进入;Phase B 未部署。下一步是独立代码复验,不是提交或部署。 |
|||
|
|||
## 9. 两项独立复验阻断修复(2026-09-23) |
|||
|
|||
独立复验发现岗位发布门禁发生在岗位 ID/归属查询之后,可能通过业务码与 HTTP 状态差异探测跨企业岗位;另发现 `VerifiedCompanyGatePersistenceIntegrationTest` 使用全局 `TestAuthTokens` 数据源注册表,在组合运行时偶发无法签发会话。 |
|||
|
|||
- `JobService.transitionStatus` 对目标 `PUBLISHED` 先调用 `requireVerifiedCompany`,再查询岗位;未认证公司的有效 ID、其他公司 ID 和不存在 ID 均先得到固定认证拒绝,不触发岗位查询。 |
|||
- 新增 `JobRepository.findByIdAndCompanyId`,并用于企业岗位读取、修改和状态操作。跨企业岗位和不存在岗位都按 `JOB_NOT_FOUND`/404 处理;`JobController.getJob` 改为单次归属限定查询,避免泄露资源存在性。 |
|||
- 保留设计允许的未认证企业自有 DRAFT 岗位查看与编辑;已认证企业仍必须通过原企业归属条件,不获得跨企业访问权限。 |
|||
- `VerifiedCompanyGatePersistenceIntegrationTest` 不再调用静态全局 `TestAuthTokens`,改由当前 Spring 测试上下文的真实 `AuthSessionService` 创建会话,Token 和会话记录落在当前事务数据源中,事务结束回滚,不依赖其他测试上下文的注册/销毁顺序。 |
|||
- 先加 HTTP 回归再改生产代码时,新测试复现了原差异:未认证发布外企业岗位得到 400、读取/修改外企业岗位得到 400,而目标契约为认证门禁 403 或与不存在资源一致的 404。更新后的 HTTP 集成类 4/4 通过;岗位与认证边界组合 33/33 通过;原 10 类定向组合现为 **191/191**,F=0/E=0/S=0。 |
|||
- 两条既有跨企业岗位 HTTP 断言改为校验 404/`JOB_NOT_FOUND`;夹具明确设成 VERIFIED,确保发布测试实际走到资源归属门禁而不是被未认证门禁短路。 |
|||
|
|||
修复后验证: |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| `mvn test` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS** | |
|||
| `mvn clean verify` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS**;Testcontainers 真实执行 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
两项阻断已修复并验证。本轮仍未暂存、提交、推送或部署;Phase B 后续仍需独立复验结论确认后才能进入相应检查点。 |
|||
@ -0,0 +1,136 @@ |
|||
# Phase B「已认证企业业务门禁」独立代码复验与真实 MySQL 黑盒验收 |
|||
|
|||
日期:2026-09-23 |
|||
范围:Phase B 独立代码复验、缺陷修复后回归、MySQL 8.4 + HTTP 黑盒 |
|||
结论:**通过;未暂存、未提交、未推送、未部署** |
|||
|
|||
## 1. 结论摘要 |
|||
|
|||
独立复核确认,招聘类受限操作由 Service 层的共用企业认证门禁保护,判定依据为 `companies.verified`;角色、公司归属和资源归属仍分别执行。补充修复两处复验阻断后,新增真实 MySQL 8.4 + 随机端口 HTTP 黑盒测试通过;Phase B 定向回归 **208/208**,全量 `mvn test` 与 `mvn clean verify` 各 **2884/2884,Failures=0、Errors=0、Skipped=0**。OpenAPI 检查 **182 paths / 220 operations,All checks passed**。 |
|||
|
|||
本轮没有连接或修改服务器生产数据库,没有部署服务,没有修改迁移文件或 `docs/openapi-rc.json`。测试中 Redis 使用 mock,RabbitMQ listener 被关闭,因此本文不把本轮结论扩展为真实 Redis/RabbitMQ 黑盒验收。 |
|||
|
|||
## 2. Git 与工作区边界 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3` |
|||
- upstream:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`;ahead/behind `0/0` |
|||
- 暂存区:空;`git diff --check`:exit 0(仅有 Git 的 LF/CRLF 提示) |
|||
- 本地工作区存在既有未暂存改动与历史未跟踪材料。没有清理、覆盖、暂存或删除这些文件。 |
|||
- Phase B 现有差异包含 **22 个已跟踪生产文件 + 新增 `VerifiedCompanyGuard.java`**,以及 **28 个已跟踪测试文件 + 4 个 Phase B 新测试类**。本报告和 Phase B 实施报告均未跟踪;本轮没有执行任何 Git 写入操作。 |
|||
- V1–V46 未修改;未新增 V47;`docs/openapi-rc.json` 未修改。 |
|||
- 未进入 Phase C。 |
|||
|
|||
## 3. 独立审查范围与规则 |
|||
|
|||
复核了 Phase B 实施报告、企业入驻设计及复审文档、`VerifiedCompanyGuard`、服务层调用点、岗位资源归属查询、错误码映射、认证边界测试,以及候选人/简历下载入口。 |
|||
|
|||
规则核对结果: |
|||
|
|||
1. 未认证、审核中、已拒绝企业均以 `companies.verified=false` 拒绝受限招聘业务;未使用 `users.is_verified` 代替企业认证。 |
|||
2. 认证门禁在 Service 业务路径执行,不只依赖 Controller;角色/企业归属/资源可见性仍单独保留。 |
|||
3. 企业资料与认证申请、入驻状态等允许操作未被本轮认证门禁一并封锁。 |
|||
4. 错误使用固定 `COMPANY_NOT_VERIFIED`(HTTP 403),经统一 `ErrorHttpStatusRegistry` 映射,不在 Controller 复制转换逻辑。 |
|||
5. 岗位访问必须按企业范围查找;对外企业岗位记录不存在与不属于当前企业均按相同 `JOB_NOT_FOUND` / 404 处理。 |
|||
|
|||
## 4. 复验阻断及修复 |
|||
|
|||
### 4.1 岗位资源边界与检查顺序 |
|||
|
|||
复验期间确认岗位接口需要同时满足两个条件:未认证企业发布岗位时应被认证门禁拒绝;已认证企业也不能借岗位 ID 探测或操作其他企业的岗位。已调整为: |
|||
|
|||
- 发布状态转换在读取目标岗位前先执行认证门禁,使未认证企业对目标 ID 不获得差异化响应; |
|||
- 已认证企业的岗位读取/更新使用 `findByIdAndCompanyId` 范围查询;跨企业与不存在资源统一返回 `JOB_NOT_FOUND` / 404; |
|||
- 企业自己的草稿维护及既有关闭行为按设计保留。 |
|||
|
|||
覆盖测试:`JobServiceTest`、`AuthenticationBoundaryIntegrationTest`,以及本报告新增的真实 MySQL + HTTP 黑盒。 |
|||
|
|||
### 4.2 持久化认证会话测试接线 |
|||
|
|||
原持久化测试通过静态 Token 测试助手建会话,不能确保会话写入当前 Testcontainers 数据源。测试现改为从 Spring 上下文调用 `AuthSessionService.createSession`,令令牌与当前测试数据库一致,避免测试仅凭伪造/错库 Token 得出授权结论。 |
|||
|
|||
## 5. 新增真实 MySQL + HTTP 黑盒 |
|||
|
|||
新增测试:`mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java`。 |
|||
|
|||
- Testcontainers 使用真实 `mysql:8.4`,创建隔离数据库 `phase_b_gate_blackbox`,正式执行 Flyway,并使用 Hibernate `ddl-auto=validate`。 |
|||
- Spring Boot 完整应用在随机端口启动;通过 HTTP socket 请求 `/api/v1/...`,覆盖 Spring Security、Controller、Service、Repository 和真实 MySQL。 |
|||
- 使用精确假号段夹具,测试结束按本类登记的主键清理;容器随后由 Testcontainers 回收。 |
|||
|
|||
结果 **2/2,F=0、E=0、S=0**: |
|||
|
|||
1. UNVERIFIED、PENDING、REJECTED 三种 `companies.verified=false` 企业对岗位发布均返回 403 与 `COMPANY_NOT_VERIFIED`。每个拒绝前后比较 15 张表的稳定行快照,并核对岗位仍为 DRAFT。 |
|||
2. 未认证企业读取候选人列表、候选人详情与简历下载均被拒绝。简历存储 `read()` 计数为 0;响应非 PDF;15 张表拒绝前后逐行快照相同。 |
|||
3. VERIFIED 企业能够发布自己的岗位;不能读取、更新或发布另一企业岗位。外企业资源与不存在资源均返回相同 404/`JOB_NOT_FOUND`,跨企业拒绝前后数据库快照无变化。 |
|||
4. 用户夹具的 `users.is_verified` 被有意设为 true,而公司认证状态分别按测试构造。这证明被测门禁取决于 `companies.verified`,而不是用户通用 verified 字段。 |
|||
|
|||
此黑盒未启动真实 Redis 或 RabbitMQ:RedisTemplate 为 mock,RabbitMQ listener 显式禁用。拒绝请求的数据库快照覆盖 `notifications`、`audit_logs`、`messages` 等表;但这不能替代 broker 队列深度检查,也不能证明真实 Redis 可用性。 |
|||
|
|||
## 6. 测试结果 |
|||
|
|||
| 验证 | 结果 | |
|||
|---|---:| |
|||
| Phase B 定向(门禁/角色/岗位/投递/简历/Offer/面试/邀约/消息等 12 类) | **208/208,F=0、E=0、S=0** | |
|||
| 真实 MySQL + HTTP 黑盒 | **2/2,F=0、E=0、S=0**(包含在定向结果内) | |
|||
| `mvn test` | **2884/2884,F=0、E=0、S=0** | |
|||
| `mvn clean verify` | **2884/2884,F=0、E=0、S=0** | |
|||
| 全量 Surefire XML | 277 个测试类;2884 项;F/E/S 均为 0 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
MySQL/Testcontainers 在本机 Docker 上真实运行;新增黑盒类标记 `disabledWithoutDocker=false`,不会在 Docker 缺失时静默跳过。全量测试 `Skipped=0`。 |
|||
|
|||
## 7. 测试驱动器自身问题记录 |
|||
|
|||
初次黑盒执行出现 HTTP 404,是 `TestRestTemplate` 测试 URL 使用相对路径造成的 harness 错误;随后改为以 `@LocalServerPort` 构造 `127.0.0.1` 绝对 URL,黑盒 **2/2** 通过。测试初次编译还因 `DirtiesContext` 导入包路径错误失败,改为 Spring Test 正确包后通过。两项均为测试代码接线问题,不是产品缺陷;修正后定向与全量测试均已重跑通过。 |
|||
|
|||
## 8. 运行环境收尾 |
|||
|
|||
检查时 Docker 容器列表为空;8080/8081 无监听;无残留 `java` 或 `mvn` 进程。黑盒使用随机端口,并未占用上述端口。临时 MySQL 为 Testcontainers 隔离容器,没有触及本机开发库或服务器库。 |
|||
|
|||
## 9. 未执行/不在本轮结论内 |
|||
|
|||
- 未做生产服务器部署或真实公网 MySQL/Redis/RabbitMQ 验收。 |
|||
- 未做运行时 OpenAPI 路径集合与静态 OpenAPI 的双集合比较;本轮运行的是仓库 OpenAPI 检查器,且 `docs/openapi-rc.json` 保持未改。 |
|||
- 未覆盖到的每一个招聘入口仍以既有 Phase B 结构/服务测试清单和全量回归为依据;本轮新增真实黑盒聚焦岗位发布、候选人列表/详情/简历,以及跨企业岗位访问,不能解读为每个业务端点都由真实 HTTP 重放。 |
|||
- 未实现 Phase C 管理员用户与角色管理;未修改短信服务;未伪造任何真实企业认证状态。 |
|||
|
|||
## 10. 最终判定 |
|||
|
|||
**Phase B 独立代码复验及真实 MySQL + HTTP 黑盒验收通过。** 两处复验阻断已修复;定向、全量测试及 OpenAPI 检查通过。当前仍是未暂存的本地工作区状态,后续如需提交,必须另行进行提交前精确文件范围审查;本轮没有暂存、提交、推送或部署。 |
|||
|
|||
## 11. 2026-09-29 精确暂存与隔离验证检查点 |
|||
|
|||
本节追加记录最新检查点,不覆盖第 1–10 节的历史执行结果与当时仓库状态。 |
|||
|
|||
### 11.1 批次边界与验证来源 |
|||
|
|||
- 当前分支为 `feat/phase-b-company-verification-gate`;基线 HEAD 为 `bcea6b02955f20f933572e8c8f2c85b1ad05843c`,包含此前单独本地提交的短信 P0 修复。 |
|||
- Phase B 暂存范围为 **57 个文件:23 个生产文件、32 个测试文件、2 份报告**,没有删除文件。未纳入迁移、`pom.xml`、OpenAPI 文件、基础设施脚本或历史验收输出。 |
|||
- 共用错误码与映射测试只暂存 Phase B 的 `COMPANY_NOT_VERIFIED` 及其 403 映射,未纳入统一注册/身份选择的新增错误码和接口。 |
|||
- 验证目录由 HEAD 归档加暂存补丁生成,不读取原工作区未暂存的统一注册/身份选择实现。57 个暂存文件与验证快照按 Git 换行规范化后的 blob 逐项比较,**不一致数为 0**。原工作区其他批次改动与历史未跟踪文件均保留。 |
|||
|
|||
### 11.2 本次失败记录及纠正 |
|||
|
|||
1. 初次 32 类组合运行 **928 项,F=1、E=0、S=0**。简历下载测试的 OpenAPI 数量期望误混入未提交身份选择批次的 183/221,而当前隔离快照实际为 182/220。已从暂存区移除这两行无关 hunk,使用 HEAD 的 182/220 契约;原工作区 183/221 改动仍保留。没有修改生产实现或放宽授权断言。随后 12 类定向及全量验证通过。 |
|||
2. 9 月 28 日首次全量尝试未完成,跨库测试报告 1 项容器启动错误,根因链包含 Docker 命名管道不存在(`docker_engine`)。该次没有 Maven 最终汇总,不记为通过,也不推算其全量测试总数。日志保留在系统临时验证目录,不纳入提交。Docker 恢复后确认服务版本 29.6.2,以原暂存代码快照重新执行完整 `clean verify`。 |
|||
|
|||
### 11.3 最新验证结果 |
|||
|
|||
| 验证 | 实测结果 | |
|||
|---|---:| |
|||
| Phase B 12 类定向测试 | **208 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| Phase B 真实 MySQL + HTTP 黑盒 | **2 项,F=0、E=0、S=0**(包含在定向与全量结果内) | |
|||
| 隔离暂存快照 `mvn clean verify` | **2889 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| 全量 Surefire XML 独立汇总 | **281 个测试类,2889 项,F/E/S 均为 0** | |
|||
| 短信错误次数真实 MySQL 并发测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 短信限流真实 Redis 测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 静态 OpenAPI 检查器 | **182 paths / 220 operations,All checks passed** | |
|||
| Spring 测试上下文运行时 OpenAPI 与静态集合比较 | **双方均 182/220,路径差集 0,路径/HTTP 方法 operation 差集 0** | |
|||
|
|||
本次全量命令使用 `-Ds03.openapi.snapshot=<临时输出目录>` 留存既有简历测试通过 MockMvc 获取的运行时文档;比较以解析后的路径与 HTTP 方法集合为准,不只比较数量。该证据来自完整 Spring 测试上下文,**不是独立 JAR 或公网部署版本的 OpenAPI 验收**。此次没有另行宣称一次新的 `mvn test` 结果;`clean verify` 已实际执行全量测试并打包成功。 |
|||
|
|||
### 11.4 暂存安全扫描与结论边界 |
|||
|
|||
暂存新增行扫描发现 2 处手机号形状的既有测试夹具字面量、1 处明确仅用于临时 Testcontainers 数据库的测试口令;均不是实际用户或服务器凭据。疑似绝对路径命中均为路由数字正则或 loopback 随机端口 HTTP URL,不是本机文件路径。未发现真实 JWT、Bearer、邮箱、BCrypt 摘要、私钥、AWS key 或服务器连接密钥;原始运行日志和容器输出不暂存。 |
|||
|
|||
**Phase B 精确暂存与隔离验证检查点通过。** 本节完成后仅更新并暂存本报告,尚未创建 Phase B 本地提交,未推送、未部署,未进入 Phase C。真实 MySQL HTTP 黑盒的 Redis mock 与禁用 RabbitMQ listener 边界仍如第 5 节所述;本次全量中的真实 Redis 测试不能替代 Phase B 全链路三中间件或公网验收。 |
|||
@ -0,0 +1,129 @@ |
|||
# 简历文件归属与文件名修复(2026-10-19 验收更新) |
|||
|
|||
> 分支 `fix/mysql57-module-migrations-20261009`,基线 HEAD `1cbadc3e18a786ef642b0d329adde6b173c7b2ac`。 |
|||
> 本文是简历安全与文件名兼容的参考说明。关联批次已完成冻结快照的本地后端验收;尚未提交、推送、发布或完成线上复验。下述规则不是线上已生效的承诺。 |
|||
|
|||
## 1. 漏洞事实 |
|||
|
|||
| 环节 | 修复前行为 | |
|||
| --- | --- | |
|||
| 存储键 | `LocalResumeStorageService.store(userId, file)` 生成 `"{userId}_{UUID8}{ext}"`,**第一个下划线之前的数字即所有者** | |
|||
| 客户端可控引用 | `StudentProfileUpdateRequest.resumeUrl`(仅 `@Size(max=500)`)可被客户端任意填写 | |
|||
| 落库 | `ProfileService.updateProfile()` 直接 `profile.setResumeUrl(req.getResumeUrl())` | |
|||
| 读取 | `ProfileService.getResume()` 按资料引用直接 `storage.read(...)`;`LocalResumeStorageService.read()` 只校验路径越界与文件存在 | |
|||
| 删除 / 替换 | `deleteResume()` / `uploadResume()` 同样按该引用处理旧文件 | |
|||
|
|||
结论:**B 只要把自己的资料引用写成 A 的存储键,就能用自己的下载接口读到 A 的简历文件**(独立探针已复现)。 |
|||
|
|||
## 2. 调用链梳理 |
|||
|
|||
1. **普通资料修改**:`PUT /profile/me` → `ProfileController.updateMyProfile` → `ProfileService.updateProfile`(`@Transactional`,逐字段非空合并 + 保存 + 登记评分刷新)。 |
|||
2. **简历上传**:`POST /profile/me/resume` → `uploadResume`(锁资料行 → 存新文件 → 注册提交后删旧文件 → 保存 → 提交后删旧文件;失败时清理新文件)。 |
|||
3. **简历下载**:`GET /profile/me/resume` → `getResume` → `storage.read(resumeUrl)`。 |
|||
4. **简历删除**:`DELETE /profile/me/resume` → `deleteResume`(先 `chatResumeLifecycle.purgeForUser` 撤销聊天快照 → 清空资料字段 → 提交后删文件)。 |
|||
5. **简历版本**:`ResumeVersionService.createVersion/updateVersion/deleteVersion/setDefault`,`ResumeExportService`/`ResumeExportWorker` 导出。版本行有 `userId` 且 `requireOwnActiveVersion()` 已做「本人版本」校验;`file_storage_path` 同样由 `store(userId, file)` 生成,**不接受任何客户端引用**。 |
|||
6. **快照复用**: |
|||
- 聊天简历卡片:`ChatResumeService.send` 在读本人资料引用前验证源文件键归属,再将字节写入 `chat_resume_snapshots`;下载继续使用快照授权,不直接读资料引用。 |
|||
- 企业下载:`CompanyResumeService.load` 先由 `CompanyResumeAccessPolicy` 决定投递关系、隐私及企业授权,再验证源文件键属于该决策中的求职者(不是 HR),最后才读取。 |
|||
|
|||
**身份与校验分工**:身份一律来自已验证 JWT/SecurityContext(`CurrentUserAccessor`),无请求参数入口;`UserRoleGuard` 只做角色校验、不做归属校验。 |
|||
|
|||
**「事务提交前删除旧文件」问题**:**不存在**。`uploadResume` 的旧文件删除注册在 `afterCommit`(失败时清理本次新文件);`deleteResume` 的文件删除同样 `deleteFileAfterCommit`;`ResumeVersionService` 亦然。 |
|||
|
|||
## 3. 修复方案 |
|||
|
|||
### 3.1 新增归属校验器(`profile/storage/ResumeOwnershipVerifier.java`,NEW) |
|||
|
|||
- 解析规则:取**第一个下划线之前**的十进制数字作为所有者;与 `userId` **按数值精确比较**(禁止 `startsWith`,避免 `1` 与 `10` 前缀混淆)。 |
|||
- 失败关闭:`null`/空白、无下划线、前缀非纯数字、下划线后为空、含 `/` `\` `:`(路径穿越/绝对路径/盘符)、含 `..` → 一律视为「无法证明归属」并拒绝。 |
|||
- `assertOwnedBy()` 抛出 `ErrorCode.FORBIDDEN`(HTTP 403),消息为固定文案,**不回显存储键、磁盘路径、文件名或他人标识**。 |
|||
- 只做「键字符串归属解析」,**不介入** `ResumeStorageService.read()`——企业按投递关系下载、投递/聊天快照等合法授权流程保持原样通过。 |
|||
|
|||
### 3.2 禁止普通资料接口修改简历引用(`ProfileService.updateProfile`) |
|||
|
|||
- 补齐 **STUDENT 角色校验**(此前 `PUT /profile/me` 无角色校验,COMPANY/ADMIN 可创建/篡改学生资料)。 |
|||
- `req.getResumeUrl() != null`(含空串)→ `BusinessException(BAD_REQUEST)` → **HTTP 400**,且校验发生在任何字段赋值/保存之前,**整次拒绝、无部分更新**。 |
|||
- 缺省或显式 `null` → 不改变原简历引用(保持兼容)。 |
|||
- 说明:选择「保留 DTO 字段 + 服务层显式判断」而非 `@JsonIgnore`——`@JsonIgnore` 会让「未提供」与「显式传了非法值」都映射为 `null`,无法区分;本方案用 `!= null` 精确区分:未提供/显式 null 合法放行,任何提供的值一律拒绝。 |
|||
- 服务层保留该限制,内部调用同样无法绕过;简历引用只能由受控上传/版本/既有业务流程生成。 |
|||
|
|||
### 3.3 本人简历路径的归属校验(`ProfileService`) |
|||
|
|||
| 操作 | 校验位置 | 失败语义 | |
|||
| --- | --- | --- | |
|||
| 下载 `getResume` | 角色 → 资料存在 → `resumeUrl` 非空 → **归属校验** → 才 `storage.read` | 403,**不读文件** | |
|||
| 删除 `deleteResume` | 角色 → 资料行锁定 → **归属校验** → 才 `purgeForUser`/清空字段/登记删文件 | 403,资料、聊天快照、文件**均不变**,**不删除他人文件** | |
|||
| 替换 `uploadResume` | 角色 → 校验新文件 → 锁定资料 → **旧引用归属校验** → 才 `store(新文件)` | 403,不改资料、不删他人文件、**不留新文件** | |
|||
|
|||
`getResume` 未创建资料 / `resumeUrl` 为空 / 合法引用但文件缺失 → 沿用既有 `PROFILE_NOT_FOUND`(404)。 |
|||
|
|||
### 3.4 历史污染引用的后续读取 |
|||
|
|||
- `ChatResumeService.send` 在读源文件和生成聊天快照前调用归属校验器,拒绝借本人资料中的他人引用复制简历。 |
|||
- `CompanyResumeService.load` 在既有企业访问授权通过后,对 `decision.studentUserId()` 做源文件归属校验;不能用当前 HR 的 ID 校验候选人文件。 |
|||
- 保持 `ResumeStorageService` 与 `LocalResumeStorageService` 实现不变,避免将「必须本人读取」下沉到存储层而破坏合法 HR 下载。 |
|||
- 不同来源使用不同授权:源文件归属校验不能代替聊天成员、快照撤销或企业投递关系校验。 |
|||
|
|||
### 3.5 中文上传文件名与下载响应 |
|||
|
|||
- 共享 `ResumeFilenameSanitizer` 对可明确识别的含非 ASCII 字节 `%HH` 序列做一次严格 UTF-8 解码,修复中文名称被再次编码为 `%25E5...` 的问题。 |
|||
- 字面 `+` 保持为 `+`,不使用会把它变成空格的 `URLDecoder`。 |
|||
- 纯 ASCII 百分号序列(例如字面 `%20`、`%25`)存在歧义,保守保留;不能理解为任意百分号串都会解码。 |
|||
- 新上传按解码后的名称执行长度及路径/控制字符检查,非法编码或危险名称拒绝为 400;不递归解码嵌套的非 ASCII 百分号内容。 |
|||
- 历史文件下载时尽力归一化并净化名称,不重写历史记录、不改存储键、不改文件字节;不能安全解码时保留后净化,而非猜测内容。 |
|||
- 当前隔离 HTTP 用例覆盖本人和合法企业下载的中文文件名响应;尚未完成真实浏览器/前端目标版本的端到端验收。 |
|||
|
|||
## 4. 错误码与接口兼容 |
|||
|
|||
- 普通资料写入非法 `resumeUrl` → **HTTP 400 / code 1001**(沿用统一参数错误结构,未新增错误码)。 |
|||
- **已登录用户访问不属于自己的资源 → 403 / code 2002**(选择理由:与项目既有语义一致——`ResumeVersionService.requireOwnActiveVersion` 对他人版本返回 `FORBIDDEN`,`CompanyResumeAccessPolicy` 对越权企业同样返回 `FORBIDDEN`;且在日志中可与企业下载等正常授权区分)。 |
|||
- 接口形态未变:`PUT /profile/me` 的响应结构、`GET /profile/me` 的响应字段(含 `resumeUrl`)保持原样,未新增/删除字段;`PUT /profile/me` 的 Swagger `@Operation` 描述已补充「resumeUrl 只读」约束。 |
|||
- 未修改共享的 `docs/openapi-rc.json`(该文件已被其他批次改动,避免混入本次修复)。 |
|||
|
|||
## 5. 兼容性边界 |
|||
|
|||
- **历史键**:当前本地存储实现生成 `userId_UUID8.ext`。可证明归属且格式合法的键继续使用;不能证明归属的历史引用失败关闭。未审计线上全部历史记录,不宣称所有历史键均符合该格式。 |
|||
- **版本文件**:`resume_versions.file_storage_path` 由同一 `store()` 生成、格式一致;版本操作另有「版本行所属用户」校验,本轮未改动,合法版本/默认简历/导出行为不回退。 |
|||
- **其他存储类型**:目前只有本地实现。若将来引入对象存储等新键格式,必须同步扩展 `ResumeOwnershipVerifier` 的解析规则,否则会失败关闭。 |
|||
- **历史污染引用**:本轮**只做安全拒绝**,不自动删除、重绑、迁移或修复历史记录;也未新增任何联网排查工具。 |
|||
|
|||
## 6. 测试与验证(隔离环境合成数据) |
|||
|
|||
最新验收基于 `D:/Acceptance/qsp-final-20261019/app` 冻结快照,不复用旧副本的测试结论。 |
|||
|
|||
- 归属校验器单测、服务层测试和真实 SecurityFilterChain/Controller 参数绑定的 HTTP 测试全部纳入全量执行。 |
|||
- `ResumeOwnershipHttpIntegrationTest` 现含新增文件名兼容用例;另有 `ResumeFilenameEncodingTest`、企业下载回归与 `HistoricalResumeOwnershipIntegrationTest` 覆盖历史污染源及合法授权。 |
|||
- 跨账号拒绝路径检查文件、数据库状态及读写调用;中文文件名测试检查下载响应、字节一致以及旧记录不被回写。 |
|||
- 全量 `clean verify`:3464 项、0 失败、0 错误、0 跳过,BUILD SUCCESS。该数是全部后端测试总量,不是简历专项数。 |
|||
- 日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。 |
|||
|
|||
历史说明:旧副本曾出现上传返回 500、HTTP 类 7/11 失败;当前快照未复现,根因未证实,不作绝对归因。旧漏洞探针作为诊断历史证据保留,不改弱断言,不将其当作正式源码新增回归计数。 |
|||
|
|||
## 7. 最新 JAR 黑盒与重启验证 |
|||
|
|||
- JAR:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。 |
|||
- 简历归属真实 HTTP 黑盒 32 项通过;补充的来源归属与文件名黑盒 26 项通过。岗位展示另有 48 项,共 106 项,不混算为简历专项。 |
|||
- 重启后简历源文件与聊天快照字节一致、已撤销卡片继续拒绝、非成员继续拒绝,共 4 项;另有岗位持久化 2 项。 |
|||
- 本轮应用使用隔离业务端口 18119、管理端口 18120 与独立 MySQL/Redis/RabbitMQ,仅合成账号和 PDF,不访问生产资源。 |
|||
- 结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`;重启日志:`logs/resume-persistence.log`,均相对于上述验收目录。 |
|||
- 源码与工具身份由 `evidence/source-manifest-current.csv`、`evidence/contract-tool-manifest.csv` 和 `evidence/jar-sha256.csv` 固定;验收结束时漂移为 0。 |
|||
|
|||
## 8. 未完成项与权限边界 |
|||
|
|||
- 尚未提交、推送、Jenkins 发布、线上验证或验收前端账号切换;本地通过不代表线上修复已生效。 |
|||
- 历史污染引用只安全拒绝,没有自动清理、重绑或迁移;已生成聊天快照未在线审计,不能宣称线上历史泄露风险全部排除。 |
|||
- 个人数据导出的 `PersonalDataCollector.safeRead` 当前输入来自简历版本的 `fileStoragePath`,不是此前误写的本人资料 `resumeUrl`。本轮未证明该路径存在相同漏洞,也未修改该模块;不得仅因同名 read 方法就下漏洞结论。 |
|||
- 对象存储等未来键格式必须同时调整归属策略与测试;当前无法证明归属的键继续失败关闭。 |
|||
- 评分测试时钟依赖已单独修复并纳入最新全量,未修改生产评分规则;旧 Docker/PATH 与契约失败日志保留为历史记录,不继续标为当前阻断。 |
|||
- 前端、受保护文件、历史迁移及底层存储实现未因本批安全修复而修改;本轮服务已停止,测试数据与证据保留,未做 Docker 全局清理。 |
|||
|
|||
## 9. 前端接入要点 |
|||
|
|||
- 普通 `PUT /profile/me` 不提交非 null `resumeUrl`;简历修改使用受控简历上传/版本流程,不能通过个人资料写入文件引用。 |
|||
- 请求身份由当前有效令牌解析,不能用客户端手机号、角色字段或用户 ID 代替授权;换账号后须使用新令牌并隔离/清除旧账号缓存。 |
|||
- 对 400/403/404 按实际接口结果提示,不把权限拒绝降级为展示缓存简历,不通过本地文件名正确推断后端响应头已正确。 |
|||
- 上传名称传正常文件名即可,后端仅兼容明确的历史/客户端 UTF-8 编码,不要求客户端手写 multipart body,也不允许任意多次 URL 解码。 |
|||
- 交付的是本地验证规则;前端工程师需待对应版本发布后,在授权的测试账号下复验上传、本人下载、企业授权下载及账号切换。 |
|||
|
|||
详细汇总:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。本文未包含账号口令、令牌、真实简历或生产数据库配置。 |
|||
@ -0,0 +1,198 @@ |
|||
# 薪资计算器 API(N05) |
|||
|
|||
> Base URL:`/api/v1` |
|||
> 认证:`Authorization: Bearer <access-token>` |
|||
> 角色:当前登录用户必须具备 `STUDENT` 角色。 |
|||
|
|||
> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。 |
|||
|
|||
## 1. 能力边界 |
|||
|
|||
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 |
|||
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 |
|||
- 支持用户覆盖社保基数、公积金基数和个人比例。 |
|||
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 |
|||
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 |
|||
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 |
|||
|
|||
## 2. 查询城市列表 |
|||
|
|||
`GET /salary-calculator/cities` |
|||
|
|||
仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。 |
|||
|
|||
响应示例: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": [ |
|||
{ |
|||
"cityCode": "110000", |
|||
"provinceCode": "110000", |
|||
"provinceName": "北京市", |
|||
"cityName": "北京市", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"dataLevel": "CITY" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
## 3. 查询城市默认参数 |
|||
|
|||
`GET /salary-calculator/cities/{cityCode}` |
|||
|
|||
返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。 |
|||
|
|||
## 4. 计算并保存 |
|||
|
|||
`POST /salary-calculator/calculations` |
|||
|
|||
请求示例: |
|||
|
|||
```json |
|||
{ |
|||
"cityCode": "110000", |
|||
"grossMonthlySalary": 10000.00, |
|||
"monthIndex": 1, |
|||
"specialAdditionalDeduction": 0.00, |
|||
"expectedPolicyVersion": "2026-09-20-v1", |
|||
"idempotencyKey": "optional-client-key", |
|||
"overrides": { |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pensionRate": 0.08, |
|||
"medicalRate": 0.02, |
|||
"unemploymentRate": 0.005, |
|||
"housingFundRate": 0.12, |
|||
"seriousIllnessMonthlyFee": 0.00 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。 |
|||
- `monthIndex` 默认 1,范围 1–12。 |
|||
- `specialAdditionalDeduction` 默认 0。 |
|||
- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/10604`。 |
|||
- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。 |
|||
|
|||
成功响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"historyId": 123, |
|||
"createdAt": "2026-09-20T16:00:00", |
|||
"result": { |
|||
"cityCode": "110000", |
|||
"cityName": "北京市", |
|||
"dataLevel": "CITY", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"taxRulesVersion": "CN_IIT_2019_V1", |
|||
"estimate": true, |
|||
"monthIndex": 1, |
|||
"grossMonthlySalary": 10000.00, |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pension": 800.00, |
|||
"medical": 200.00, |
|||
"unemployment": 50.00, |
|||
"housingFund": 1200.00, |
|||
"currentMonthTax": 82.50, |
|||
"netSalary": 7667.50 |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 5. 查询历史 |
|||
|
|||
`GET /salary-calculator/calculations?page=0&size=20` |
|||
|
|||
- 仅返回当前用户的记录。 |
|||
- 按创建时间倒序、历史 ID 倒序。 |
|||
- `size` 最大 100。 |
|||
|
|||
`GET /salary-calculator/calculations/{id}` |
|||
|
|||
返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/10603`。 |
|||
|
|||
## 6. 删除历史 |
|||
|
|||
`DELETE /salary-calculator/calculations/{id}` |
|||
|
|||
物理删除本人历史;成功后再次查询返回 `404/10603`。删除后原幂等键可重新使用。 |
|||
|
|||
## 7. 错误码 |
|||
|
|||
| HTTP | code | 含义 | |
|||
|---|---|---| |
|||
| 400 | 10602 | 工资、比例、基数或分页参数非法 | |
|||
| 401 | 401 | 未登录或 Token 无效 | |
|||
| 403 | 2002 | 已登录但不是 `STUDENT` | |
|||
| 404 | 10601 | 城市不存在、停用、未配置或政策未发布 | |
|||
| 404 | 10603 | 历史不存在或不属于当前用户 | |
|||
| 409 | 10604 | 幂等键冲突 | |
|||
| 409 | 10605 | 城市政策版本已变化 | |
|||
|
|||
`10605` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。 |
|||
|
|||
## 8. 计算口径 |
|||
|
|||
```text |
|||
社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限) |
|||
公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限) |
|||
|
|||
累计收入 = 税前月薪 × monthIndex |
|||
累计基本减除费用 = 5000 × monthIndex |
|||
累计专项附加扣除 = 月度专项附加扣除 × monthIndex |
|||
累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费 |
|||
- 累计基本减除费用 - 累计专项附加扣除, 0) |
|||
当月个税 = max(累计应纳税额 - 累计已预扣税额, 0) |
|||
到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 |
|||
- 大病固定费 - 当月个税, 0) |
|||
``` |
|||
|
|||
## 9. 政策核验与发布门禁 |
|||
|
|||
### 默认关闭 |
|||
|
|||
`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。 |
|||
|
|||
### 白名单配置 |
|||
|
|||
仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产: |
|||
|
|||
```yaml |
|||
app: |
|||
salary-publication: |
|||
approved-policies: |
|||
"110000": "已核验记录对应的实际政策版本" |
|||
``` |
|||
|
|||
城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。 |
|||
|
|||
测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。 |
|||
|
|||
### 前端行为 |
|||
|
|||
- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。 |
|||
- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。 |
|||
- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。 |
|||
- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。 |
|||
|
|||
### 历史兼容 |
|||
|
|||
本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。 |
|||
|
|||
### 核验记录要求 |
|||
|
|||
每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。 |
|||
|
|||
当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。 |
|||
@ -0,0 +1,275 @@ |
|||
# 行业技能词云、个人匹配度与学习路径 API |
|||
|
|||
> 版本:N03 设计锁定版 |
|||
> 状态:已编码实现 |
|||
> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。 |
|||
> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。 |
|||
|
|||
## 1. 权限与共同参数 |
|||
|
|||
三个接口均: |
|||
|
|||
- 仅允许 `STUDENT` 访问。 |
|||
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。 |
|||
- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。 |
|||
- 只读,不写入学习进度、学生资料或词库数据。 |
|||
|
|||
查询参数: |
|||
|
|||
| 参数 | 类型 | 必填 | 说明 | |
|||
|---|---|---|---| |
|||
| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 | |
|||
| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 | |
|||
| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 | |
|||
|
|||
任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。 |
|||
|
|||
## 2. 通用响应与排序 |
|||
|
|||
三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果: |
|||
|
|||
1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。 |
|||
2. 热度相同时按 `nameZh` 升序。 |
|||
3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。 |
|||
4. `rank` 从 1 连续递增。 |
|||
|
|||
`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。 |
|||
|
|||
技能身份来源: |
|||
|
|||
- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。 |
|||
- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。 |
|||
- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。 |
|||
- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。 |
|||
- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。 |
|||
|
|||
## 3. 查询词云 |
|||
|
|||
```http |
|||
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
`categoryId` 可省略: |
|||
|
|||
```http |
|||
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2 |
|||
``` |
|||
|
|||
成功响应为 `ApiResponse<SkillCloudResponse>`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"industry": { "id": 1, "code": "INTERNET", "name": "互联网" }, |
|||
"occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" }, |
|||
"category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" }, |
|||
"total": 1, |
|||
"items": [ |
|||
{ |
|||
"rank": 1, |
|||
"skillId": 26, |
|||
"skillCode": "REACT", |
|||
"nameZh": "React", |
|||
"nameEn": "React", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1254.00|React|2.E.7.h", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"heat": 4.38, |
|||
"heatSourceCode": "15-1254.00", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-123", |
|||
"timestamp": "2026-09-15T10:00:00Z" |
|||
} |
|||
``` |
|||
|
|||
`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。 |
|||
|
|||
## 4. 查询个人技能匹配度 |
|||
|
|||
```http |
|||
GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母: |
|||
|
|||
```text |
|||
matchScore = round(matchedCount * 100 / min(10, total)) |
|||
``` |
|||
|
|||
- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。 |
|||
- 范围少于 10 项时按实际技能数计算。 |
|||
- 重复技能或指向同一标准技能的多个别名只计一次。 |
|||
- 示例:范围 15 条、命中 5 条,`matchScore=33`。 |
|||
- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。 |
|||
|
|||
成功响应为 `ApiResponse<SkillMatchResponse>`: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"matchScore": 50, |
|||
"matchedCount": 1, |
|||
"totalCount": 2, |
|||
"matchedSkills": [ |
|||
{ |
|||
"rank": 1, |
|||
"skillId": 26, |
|||
"skillCode": "REACT", |
|||
"nameZh": "React", |
|||
"nameEn": "React", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1254.00|React|2.E.7.h", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"heat": 4.38, |
|||
"heatSourceCode": "15-1254.00", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
], |
|||
"missingSkills": [ |
|||
{ |
|||
"rank": 2, |
|||
"skillId": 31, |
|||
"skillCode": "DOCKER", |
|||
"nameZh": "Docker", |
|||
"nameEn": "Docker", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1299.08|Docker|2.E.7.h", |
|||
"categoryId": 105, |
|||
"categoryName": "工程化工具", |
|||
"heat": 4.12, |
|||
"heatSourceCode": "15-1299.08", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-456", |
|||
"timestamp": "2026-09-15T10:01:00Z" |
|||
} |
|||
``` |
|||
|
|||
`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。 |
|||
|
|||
## 5. 生成学习路径 |
|||
|
|||
```http |
|||
GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
学习路径只消费个人匹配结果中的 `missingSkills`: |
|||
|
|||
- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。 |
|||
- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。 |
|||
- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。 |
|||
- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。 |
|||
- 系统不持久化学习进度或完成状态。 |
|||
- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。 |
|||
|
|||
建议模板按技能分类确定性生成: |
|||
|
|||
| 模板类别 | 建议方向 | |
|||
|---|---| |
|||
| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 | |
|||
| 框架 | 核心概念与生命周期、小型项目集成实践。 | |
|||
| 工具 | 安装与基础配置、结合工程完成实际操作。 | |
|||
| 通用 | 核心概念和常见应用场景、简短实践巩固。 | |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"total": 1, |
|||
"items": [ |
|||
{ |
|||
"priority": 1, |
|||
"skillId": 26, |
|||
"skillName": "React", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。" |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-789", |
|||
"timestamp": "2026-09-15T10:02:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 6. 错误响应示例 |
|||
|
|||
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。 |
|||
|
|||
```json |
|||
{ |
|||
"code": 1001, |
|||
"message": "bad request", |
|||
"data": null, |
|||
"requestId": "req-400", |
|||
"timestamp": "2026-09-15T10:03:00Z" |
|||
} |
|||
``` |
|||
|
|||
| HTTP | 场景 | 典型 `code` | |
|||
|---|---|---| |
|||
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` | |
|||
| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` | |
|||
| `403` | 已登录但不是 STUDENT | `2002` | |
|||
|
|||
示例 401: |
|||
|
|||
```json |
|||
{ |
|||
"code": 401, |
|||
"message": "not authenticated", |
|||
"timestamp": "" |
|||
} |
|||
``` |
|||
|
|||
`401` 响应不包含 `data` 和 `requestId`。 |
|||
|
|||
示例 403: |
|||
|
|||
```json |
|||
{ |
|||
"code": 2002, |
|||
"message": "forbidden", |
|||
"data": null, |
|||
"requestId": "req-403", |
|||
"timestamp": "2026-09-15T10:05:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 7. 字段参考 |
|||
|
|||
### SkillCloudNode |
|||
|
|||
`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。 |
|||
|
|||
### SkillCloudItem |
|||
|
|||
`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。 |
|||
|
|||
### SkillMatchResponse |
|||
|
|||
`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。 |
|||
|
|||
### SkillLearningPathItem / SkillLearningPathResponse |
|||
|
|||
`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。 |
|||
|
|||
`SkillLearningPathResponse`:`total`、`items`。 |
|||
@ -0,0 +1,79 @@ |
|||
# 行业技能词库来源与覆盖说明 |
|||
|
|||
本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。 |
|||
|
|||
## 1. 固定来源 |
|||
|
|||
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
|||
| --- | --- | --- | --- | --- | |
|||
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
|||
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
|||
|
|||
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
|||
|
|||
## 2. 首版覆盖边界 |
|||
|
|||
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
|||
|
|||
| 行业 | 代表性职位 | 首批技能分类范围 | |
|||
| --- | --- | --- | |
|||
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
|||
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
|||
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
|||
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
|||
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
|||
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
|||
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
|||
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
|||
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
|||
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
|||
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
|||
|
|||
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
|||
|
|||
## 3. 导入规则 |
|||
|
|||
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
|||
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
|||
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
|||
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
|||
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
|||
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
|||
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
|||
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
|||
9. **迁移不可改写**:T2/T3 计划使用 `V47__skill_cloud_taxonomy.sql` 和 `V48__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
|||
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
|||
|
|||
## 4. 来源版本、许可与归属 URL |
|||
|
|||
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
|||
| --- | --- | --- | --- | --- | |
|||
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
|||
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
|||
|
|||
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
|||
|
|||
## 5. 映射责任边界 |
|||
|
|||
| 事项 | 上游来源责任 | 项目责任 | |
|||
| --- | --- | --- | |
|||
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
|||
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
|||
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
|||
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
|||
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
|||
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
|||
|
|||
## 6. 验收检查项 |
|||
|
|||
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
|||
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
|||
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
|||
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
|||
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
|||
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
|||
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
|||
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
|||
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
|||
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
|||
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
|||
@ -0,0 +1,866 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""B01 展示字段批次:安全演示数据工具(预检 / 预览 / 导入 / 定向清理 / 核对)。 |
|||
|
|||
设计目标(本版重写归属逻辑) |
|||
--------------------------- |
|||
* **先预检、后写入**:任何一次导入在**业务写入之前**先完成全部冲突预检;只要出现 |
|||
无法证明属本批的冲突(固定手机号被非本批账号占用、同名企业/岗位已被非本批记录占用、 |
|||
账本丢失/资源丢失/身份变化),立即以**非零退出码**失败,绝不自动认领、覆盖、补角色、 |
|||
置认证或删除。 |
|||
* **可靠的批次归属账本**:仅工具使用的辅助表 ``b01_demo_ledger``(**不进入** Flyway 迁移或 |
|||
应用启动流程)。账本只登记**本工具本次实际创建**的记录,逐条记录:批次 ID、目标库实例 |
|||
身份、资源类型、**实际创建的主键 ID**、稳定演示条目键(demo_key)、确认资源身份所需的 |
|||
指纹。**禁止**凭手机号/企业名/岗位标题/描述推断归属。 |
|||
* **写入与归属登记同事务**:业务记录创建与账本写入在**同一事务**完成,失败整体回滚。 |
|||
账本表的 DDL(``CREATE TABLE IF NOT EXISTS``)在业务事务**之前**单独执行——MySQL DDL |
|||
会隐式提交且不可回滚,故必须与业务事务分开说明与执行。 |
|||
* **重复导入先校验归属**:账本中资源存在且身份一致 → 跳过(幂等);资源丢失 / ID 指向 |
|||
异常 / 身份变化 / 账本不完整 → 明确报冲突,不静默接管、不自动修复。 |
|||
* **清理只用账本精确 ID**:删除前核验资源身份与**依赖**(通过 information_schema 枚举外键 |
|||
引用方);发现本批之外的依赖、角色或业务关联 → 拒绝清理并给出原因;不级联清除他人数据, |
|||
不关闭外键检查。全程在事务内以 ``SELECT ... FOR UPDATE`` 加行锁,避免「检查—删除」竞态。 |
|||
* **预览与执行共用同一套计划**:默认 dry-run、零写入(**不创建**任何辅助表);导入/清理预览 |
|||
与执行都基于同一 ``build_plan`` / ``build_cleanup_plan``,执行前针对事务内最新状态**重新 |
|||
校验**;verify 按账本精确范围统计,不使用 ``LIKE`` 模糊统计;明确区分「没有本批数据」与 |
|||
「账本缺失,无法证明归属」。 |
|||
* 保留既有隔离目标限制(回环地址 + 显式白名单三元组 + 线上地址阻断)、显式 ``--apply``、 |
|||
虚构演示标识;**不伪造**评价、评分、浏览量、真实投递;不关闭任何认证/安全策略。 |
|||
|
|||
用法 |
|||
---- |
|||
python docs/tools/b01_demo_display_data.py # dry-run 预览(零写入) |
|||
python docs/tools/b01_demo_display_data.py --apply # 首次/重复导入 |
|||
python docs/tools/b01_demo_display_data.py --cleanup # 清理预览(零写入) |
|||
python docs/tools/b01_demo_display_data.py --cleanup --apply # 定向清理 |
|||
python docs/tools/b01_demo_display_data.py --verify # 按账本精确核对 |
|||
|
|||
退出码:0=成功;2=参数/目标拒绝;3=归属冲突/无法证明归属而拒绝;1=意外错误。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import sys |
|||
|
|||
import pymysql |
|||
|
|||
BATCH_ID = "b01-demo-2026" |
|||
DEMO_MARK = "[演示数据·非真实招聘]" |
|||
|
|||
LEDGER_TABLE = "b01_demo_ledger" |
|||
|
|||
# 显式白名单:只有精确匹配的三元组才允许操作。端口 3308 为本批隔离容器固定映射端口。 |
|||
# 测试隔离库 mydreamjob_b01_demo_test 与演示库同属本批隔离范围,一并显式登记。 |
|||
ALLOWED_TARGETS = { |
|||
("127.0.0.1", 3308, "mydreamjob_b01_demo"), |
|||
("127.0.0.1", 3308, "mydreamjob_b01_demo_test"), |
|||
} |
|||
# 已知线上/生产地址特征:命中即拒绝。 |
|||
BLOCKED_HOST_MARKERS = ( |
|||
"prod", "production", "online", "rds.aliyuncs.com", "mydreamjob.com", |
|||
"zhilai.com", "aliyuncs", "amazonaws", |
|||
) |
|||
LOOPBACK = {"127.0.0.1", "localhost", "::1"} |
|||
|
|||
COMPANIES = [ |
|||
dict(phone="19901000001", name="星河智造科技(演示)", industry="智能制造", |
|||
company_size="500-999人", website="https://demo.example/starship", |
|||
description=DEMO_MARK + " 虚构智能制造企业,用于展示岗位字段,不涉及真实招聘。"), |
|||
dict(phone="19901000002", name="云栖数据服务(演示)", industry="大数据", |
|||
company_size="100-499人", website="https://demo.example/cloudnest", |
|||
description=DEMO_MARK + " 虚构数据服务企业,用于展示岗位字段,不涉及真实招聘。"), |
|||
dict(phone="19901000003", name="沐光文化传媒(演示)", industry="文化传媒", |
|||
company_size="20-99人", website="https://demo.example/sunlit", |
|||
description=DEMO_MARK + " 虚构文化传媒企业,用于展示岗位字段,不涉及真实招聘。"), |
|||
] |
|||
|
|||
JOBS = [ |
|||
# 星河智造科技(演示) |
|||
dict(phone="19901000001", title="工业机器人算法实习生", city="上海", employment_type="INTERNSHIP", |
|||
salary_min=4000, salary_max=6000, experience="应届生", education="本科", |
|||
benefits=["导师带教", "免费班车", "实习转正"], salary_months=12, work_location="上海市浦东新区", |
|||
description=DEMO_MARK + " 参与机器人运动控制算法开发。", requirements="自动化/计算机相关专业。"), |
|||
dict(phone="19901000001", title="嵌入式软件工程师", city="上海", employment_type="FULL_TIME", |
|||
salary_min=20000, salary_max=35000, experience="3-5年", education="本科", |
|||
benefits=["五险一金", "弹性工作", "年度体检", "股票期权"], salary_months=14, |
|||
work_location="上海市闵行区", description=DEMO_MARK + " 负责机器人控制器嵌入式软件开发。", |
|||
requirements="熟悉 C/C++ 与 RTOS。"), |
|||
dict(phone="19901000001", title="高级机械结构工程师", city="苏州", employment_type="FULL_TIME", |
|||
salary_min=30000, salary_max=50000, experience="5-10年", education="硕士", |
|||
benefits=["五险一金", "补充医疗", "带薪年假"], salary_months=15, work_location="江苏省苏州市工业园区", |
|||
description=DEMO_MARK + " 负责精密机械结构设计与验证。", requirements="机械工程硕士,5 年以上经验。"), |
|||
dict(phone="19901000001", title="供应链计划专员", city="上海", employment_type="FULL_TIME", |
|||
salary_min=12000, salary_max=18000, experience="1-3年", education="大专", |
|||
benefits=["五险一金", "餐补", "节日福利"], salary_months=13, work_location="上海市嘉定区", |
|||
description=DEMO_MARK + " 负责物料计划与库存管理。", requirements="供应链或物流相关背景。"), |
|||
# 云栖数据服务(演示) |
|||
dict(phone="19901000002", title="数据平台后端工程师", city="杭州", employment_type="FULL_TIME", |
|||
salary_min=25000, salary_max=45000, experience="3-5年", education="本科", |
|||
benefits=["五险一金", "弹性工作", "免费三餐", "技术分享"], salary_months=16, |
|||
work_location="浙江省杭州市余杭区", description=DEMO_MARK + " 负责数据平台服务端研发。", |
|||
requirements="扎实的 Java 与分布式基础。"), |
|||
dict(phone="19901000002", title="大数据开发工程师", city="杭州", employment_type="FULL_TIME", |
|||
salary_min=22000, salary_max=40000, experience="3-5年", education="本科", |
|||
benefits=["五险一金", "年度体检", "带薪年假"], salary_months=14, work_location="浙江省杭州市滨江区", |
|||
description=DEMO_MARK + " 负责离线与实时数仓开发。", requirements="熟悉 Spark/Flink。"), |
|||
dict(phone="19901000002", title="数据分析师(校招)", city="杭州", employment_type="FULL_TIME", |
|||
salary_min=15000, salary_max=22000, experience="应届生", education="本科", |
|||
benefits=["五险一金", "租房补贴", "培训体系"], salary_months=13, work_location="浙江省杭州市余杭区", |
|||
description=DEMO_MARK + " 负责业务数据分析与报表建设。", requirements="统计学/数学相关专业。"), |
|||
dict(phone="19901000002", title="SRE 运维工程师", city="远程", employment_type="FULL_TIME", |
|||
salary_min=28000, salary_max=48000, experience="5-10年", education="不限", |
|||
benefits=["五险一金", "远程办公", "弹性工作", "年度体检"], salary_months=15, work_location="全国远程", |
|||
description=DEMO_MARK + " 负责平台稳定性与可观测性建设。", requirements="熟悉 Kubernetes 与监控体系。"), |
|||
# 沐光文化传媒(演示) |
|||
dict(phone="19901000003", title="短视频内容运营", city="成都", employment_type="FULL_TIME", |
|||
salary_min=8000, salary_max=13000, experience="1-3年", education="大专", |
|||
benefits=["五险一金", "下午茶", "节日福利"], salary_months=12, work_location="四川省成都市武侯区", |
|||
description=DEMO_MARK + " 负责短视频账号内容策划与运营。", requirements="有内容运营经验优先。"), |
|||
dict(phone="19901000003", title="视觉设计师", city="成都", employment_type="FULL_TIME", |
|||
salary_min=12000, salary_max=20000, experience="3-5年", education="本科", |
|||
benefits=["五险一金", "弹性工作", "作品集补贴"], salary_months=13, work_location="四川省成都市高新区", |
|||
description=DEMO_MARK + " 负责品牌视觉与活动设计。", requirements="设计相关专业,作品集优秀。"), |
|||
dict(phone="19901000003", title="市场活动策划", city="成都", employment_type="PART_TIME", |
|||
salary_min=6000, salary_max=9000, experience="1-3年", education="不限", |
|||
benefits=["五险一金", "项目奖金"], salary_months=12, work_location="四川省成都市锦江区", |
|||
description=DEMO_MARK + " 负责线下市场活动策划与执行。", requirements="具备活动执行经验。"), |
|||
dict(phone="19901000003", title="客户成功经理", city="重庆", employment_type="FULL_TIME", |
|||
salary_min=15000, salary_max=25000, experience="3-5年", education="本科", |
|||
benefits=["五险一金", "补充医疗", "带薪年假", "团队旅游"], salary_months=14, |
|||
work_location="重庆市渝北区", description=DEMO_MARK + " 负责重点客户关系维护与续约。", |
|||
requirements="具备 B 端客户服务经验。"), |
|||
] |
|||
|
|||
# 稳定演示条目键与身份指纹:账本据此判定「本批资源身份」是否与当前库内记录一致。 |
|||
def user_demo_key(phone): |
|||
return "user:" + phone |
|||
|
|||
|
|||
def company_demo_key(phone): |
|||
return "company:" + phone |
|||
|
|||
|
|||
def job_demo_key(phone, title): |
|||
return "job:%s|%s" % (phone, title) |
|||
|
|||
|
|||
def user_fingerprint(phone): |
|||
return "phone=%s" % phone |
|||
|
|||
|
|||
def company_fingerprint(phone, name): |
|||
return "owner_phone=%s;name=%s" % (phone, name) |
|||
|
|||
|
|||
def job_fingerprint(phone, title): |
|||
return "company_phone=%s;title=%s" % (phone, title) |
|||
|
|||
|
|||
LEDGER_DDL = """ |
|||
CREATE TABLE IF NOT EXISTS %s ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
batch_id VARCHAR(64) NOT NULL, |
|||
target_identity VARCHAR(160) NOT NULL, |
|||
resource_type VARCHAR(16) NOT NULL, |
|||
resource_id BIGINT NOT NULL, |
|||
demo_key VARCHAR(191) NOT NULL, |
|||
identity_fingerprint VARCHAR(255) NOT NULL, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
UNIQUE KEY uk_ledger_target_key (target_identity, demo_key), |
|||
UNIQUE KEY uk_ledger_target_res (target_identity, resource_type, resource_id), |
|||
KEY idx_ledger_batch (batch_id, target_identity) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci |
|||
""" % LEDGER_TABLE |
|||
|
|||
|
|||
# ── 连接与目标限制 ────────────────────────────────────────────────── |
|||
|
|||
def assert_safe_target(host, port, database): |
|||
"""显式白名单 + 线上地址阻断。任何不满足都直接终止,不降级。""" |
|||
for marker in BLOCKED_HOST_MARKERS: |
|||
if marker in host.lower() or marker in database.lower(): |
|||
raise SystemExit(f"[REFUSED] 目标疑似线上地址,已拒绝:host={host} database={database}") |
|||
if host not in LOOPBACK: |
|||
raise SystemExit(f"[REFUSED] 目标 host 非回环地址:{host}") |
|||
if (host, port, database) not in ALLOWED_TARGETS: |
|||
raise SystemExit( |
|||
f"[REFUSED] 目标不在显式白名单内:{(host, port, database)};" |
|||
f"允许:{sorted(ALLOWED_TARGETS)}") |
|||
if "b01" not in database: |
|||
raise SystemExit(f"[REFUSED] 数据库名缺少本批标识 b01:{database}") |
|||
|
|||
|
|||
def connect(args): |
|||
assert_safe_target(args.host, args.port, args.database) |
|||
return pymysql.connect(host=args.host, port=args.port, user=args.user, |
|||
password=args.password, database=args.database, |
|||
charset="utf8mb4", autocommit=False) |
|||
|
|||
|
|||
# ── 查询助手 ──────────────────────────────────────────────────────── |
|||
|
|||
def query(cur, sql, params=None): |
|||
cur.execute(sql, params if params else None) |
|||
cols = [d[0] for d in cur.description] |
|||
return [dict(zip(cols, row)) for row in cur.fetchall()] |
|||
|
|||
|
|||
def query_one(cur, sql, params=None): |
|||
rows = query(cur, sql, params) |
|||
return rows[0] if rows else None |
|||
|
|||
|
|||
def scalar(cur, sql, params=None): |
|||
row = query_one(cur, sql, params) |
|||
if not row: |
|||
return None |
|||
return list(row.values())[0] |
|||
|
|||
|
|||
def table_exists(cur, name): |
|||
n = scalar(cur, "SELECT COUNT(*) FROM information_schema.tables " |
|||
"WHERE table_schema=DATABASE() AND table_name=%s", (name,)) |
|||
return int(n or 0) > 0 |
|||
|
|||
|
|||
REQUIRED_BUSINESS_TABLES = ("users", "user_roles", "companies", "jobs") |
|||
|
|||
|
|||
def assert_business_schema(cur): |
|||
missing = [t for t in REQUIRED_BUSINESS_TABLES if not table_exists(cur, t)] |
|||
if missing: |
|||
raise SystemExit("[REFUSED] 目标库缺少必需业务表 %s,拒绝操作" % missing) |
|||
|
|||
|
|||
def target_identity(cur, args): |
|||
"""目标库实例身份:优先 server_uuid(可区分不同实例),退化到 host:port。""" |
|||
uuid = None |
|||
try: |
|||
uuid = scalar(cur, "SELECT @@server_uuid") |
|||
except Exception: |
|||
uuid = None |
|||
if not uuid: |
|||
uuid = "%s:%s" % (args.host, args.port) |
|||
return "%s/%s" % (uuid, args.database) |
|||
|
|||
|
|||
# ── 账本读写 ──────────────────────────────────────────────────────── |
|||
|
|||
def read_ledger(cur, identity, for_update=False): |
|||
"""返回本批本目标的账本行;账本表不存在返回 None(区别于空列表)。""" |
|||
if not table_exists(cur, LEDGER_TABLE): |
|||
return None |
|||
sql = ("SELECT id, resource_type, resource_id, demo_key, identity_fingerprint " |
|||
"FROM %s WHERE batch_id=%%s AND target_identity=%%s" % LEDGER_TABLE) |
|||
if for_update: |
|||
sql += " FOR UPDATE" |
|||
rows = query(cur, sql, (BATCH_ID, identity)) |
|||
for r in rows: |
|||
r["resource_id"] = int(r["resource_id"]) |
|||
return rows |
|||
|
|||
|
|||
def insert_ledger(cur, identity, resource_type, resource_id, demo_key, fingerprint): |
|||
cur.execute( |
|||
"INSERT INTO %s(batch_id, target_identity, resource_type, resource_id, demo_key, " |
|||
"identity_fingerprint) VALUES(%%s,%%s,%%s,%%s,%%s,%%s)" % LEDGER_TABLE, |
|||
(BATCH_ID, identity, resource_type, resource_id, demo_key, fingerprint)) |
|||
|
|||
|
|||
# ── 计划构建(预检) ──────────────────────────────────────────────── |
|||
|
|||
def validate_ledger_shape(ledger): |
|||
"""账本键、类型和指纹必须属于当前定义;不能用损坏的账本证明归属。""" |
|||
expected = {} |
|||
for c in COMPANIES: |
|||
expected[user_demo_key(c['phone'])] = ('USER', user_fingerprint(c['phone'])) |
|||
expected[company_demo_key(c['phone'])] = ('COMPANY', company_fingerprint(c['phone'], c['name'])) |
|||
for j in JOBS: |
|||
expected[job_demo_key(j['phone'], j['title'])] = ('JOB', job_fingerprint(j['phone'], j['title'])) |
|||
conflicts = [] |
|||
seen = set() |
|||
for row in ledger or []: |
|||
key = row['demo_key'] |
|||
if (key in seen or expected.get(key) != (row['resource_type'], row['identity_fingerprint']) |
|||
or row['resource_id'] <= 0): |
|||
conflicts.append('账本键/类型/指纹/ID 异常:%s,拒绝操作' % key) |
|||
seen.add(key) |
|||
return conflicts |
|||
|
|||
|
|||
def build_plan(cur, identity, ledger): |
|||
"""只读地计算导入计划。返回 (actions, conflicts)。 |
|||
|
|||
actions 中每条:kind = create_*/keep_*;携带 type/demo_key/label 与已有 id(如有)。 |
|||
conflicts 非空表示无法证明本批归属,调用方必须拒绝写入。 |
|||
""" |
|||
actions, conflicts = [], validate_ledger_shape(ledger) |
|||
if conflicts: |
|||
return actions, conflicts |
|||
ledger_by_key = {r["demo_key"]: r for r in (ledger or [])} |
|||
used_keys = set() |
|||
user_ids, company_ids = {}, {} |
|||
|
|||
# 用户 |
|||
for c in COMPANIES: |
|||
phone = c["phone"] |
|||
key = user_demo_key(phone) |
|||
used_keys.add(key) |
|||
existing = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,)) |
|||
lr = ledger_by_key.get(key) |
|||
if existing is None: |
|||
if lr is not None: |
|||
conflicts.append("账本记录本批用户 %s(id=%s) 已丢失:拒绝静默重建或接管" |
|||
% (phone, lr["resource_id"])) |
|||
user_ids[phone] = None |
|||
else: |
|||
actions.append(dict(kind="create_user", type="USER", demo_key=key, |
|||
phone=phone, label=phone, id=None)) |
|||
user_ids[phone] = None |
|||
continue |
|||
if lr is None: |
|||
conflicts.append("手机号 %s 已被非本批账号占用(users.id=%s)且账本无本批归属记录:" |
|||
"拒绝认领/覆盖/补角色" % (phone, existing["id"])) |
|||
user_ids[phone] = existing["id"] |
|||
continue |
|||
if int(lr["resource_id"]) != int(existing["id"]) \ |
|||
or lr["identity_fingerprint"] != user_fingerprint(phone): |
|||
conflicts.append("手机号 %s 账本与现有账号不一致(账本 id=%s vs 实际 id=%s):拒绝接管" |
|||
% (phone, lr["resource_id"], existing["id"])) |
|||
user_ids[phone] = existing["id"] |
|||
continue |
|||
actions.append(dict(kind="keep_user", type="USER", demo_key=key, phone=phone, |
|||
label=phone, id=existing["id"])) |
|||
user_ids[phone] = existing["id"] |
|||
|
|||
# 企业 |
|||
for c in COMPANIES: |
|||
phone, name = c["phone"], c["name"] |
|||
key = company_demo_key(phone) |
|||
used_keys.add(key) |
|||
uid = user_ids.get(phone) |
|||
label = "%s / %s" % (phone, name) |
|||
lr = ledger_by_key.get(key) |
|||
by_owner = (query_one(cur, "SELECT id, name FROM companies WHERE owner_user_id=%s", (uid,)) |
|||
if uid is not None else None) |
|||
by_name = query_one(cur, "SELECT id, owner_user_id FROM companies WHERE name=%s", (name,)) |
|||
|
|||
if uid is None: |
|||
# 演示账号即将新建 → 企业必为新建;但必须确保没有同名企业被他方占用 |
|||
if lr is not None: |
|||
conflicts.append("账本记录本批企业 %s(id=%s) 已丢失:拒绝静默重建" |
|||
% (name, lr["resource_id"])) |
|||
company_ids[phone] = None |
|||
elif by_name is not None: |
|||
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):" |
|||
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"])) |
|||
company_ids[phone] = None |
|||
else: |
|||
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key, |
|||
phone=phone, name=name, label=label, id=None)) |
|||
company_ids[phone] = None |
|||
continue |
|||
|
|||
if by_owner is None: |
|||
if lr is not None: |
|||
conflicts.append("账本记录本批企业 %s(id=%s, owner=%s) 已丢失:拒绝静默重建" |
|||
% (name, lr["resource_id"], phone)) |
|||
company_ids[phone] = None |
|||
continue |
|||
if by_name is not None: |
|||
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):" |
|||
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"])) |
|||
company_ids[phone] = None |
|||
continue |
|||
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key, |
|||
phone=phone, name=name, label=label, id=None)) |
|||
company_ids[phone] = None |
|||
continue |
|||
|
|||
# 演示账号名下已有企业:必须能由账本证明归属,且不能另有同名企业 |
|||
ok = (lr is not None and int(lr["resource_id"]) == int(by_owner["id"]) |
|||
and lr["identity_fingerprint"] == company_fingerprint(phone, by_owner["name"])) |
|||
if not ok: |
|||
conflicts.append("演示账号 %s 名下已有企业(companies.id=%s)但无本批账本归属或身份不一致:" |
|||
"拒绝覆盖企业资料/置认证" % (phone, by_owner["id"])) |
|||
company_ids[phone] = by_owner["id"] |
|||
continue |
|||
if by_name is not None and int(by_name["id"]) != int(by_owner["id"]): |
|||
conflicts.append("企业名 %s 存在同名重复记录(companies.id=%s 非本批):拒绝继续" |
|||
% (name, by_name["id"])) |
|||
company_ids[phone] = by_owner["id"] |
|||
continue |
|||
actions.append(dict(kind="keep_company", type="COMPANY", demo_key=key, phone=phone, |
|||
name=name, label=label, id=by_owner["id"])) |
|||
company_ids[phone] = by_owner["id"] |
|||
|
|||
# 岗位 |
|||
for j in JOBS: |
|||
phone, title = j["phone"], j["title"] |
|||
key = job_demo_key(phone, title) |
|||
used_keys.add(key) |
|||
cid = company_ids.get(phone) |
|||
label = "%s / %s" % (phone, title) |
|||
if cid is None: |
|||
lr = ledger_by_key.get(key) |
|||
if lr is not None: |
|||
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建" |
|||
% (label, lr["resource_id"])) |
|||
else: |
|||
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone, |
|||
title=title, label=label, id=None)) |
|||
continue |
|||
existing = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s", |
|||
(cid, title)) |
|||
lr = ledger_by_key.get(key) |
|||
if existing is None: |
|||
if lr is not None: |
|||
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建" |
|||
% (label, lr["resource_id"])) |
|||
else: |
|||
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone, |
|||
title=title, label=label, id=None)) |
|||
continue |
|||
ok = (lr is not None and int(lr["resource_id"]) == int(existing["id"]) |
|||
and lr["identity_fingerprint"] == job_fingerprint(phone, title)) |
|||
if not ok: |
|||
conflicts.append("本批企业 %s 下已存在同名岗位 '%s'(jobs.id=%s)但无本批账本归属:" |
|||
"拒绝认领/覆盖" % (phone, title, existing["id"])) |
|||
continue |
|||
actions.append(dict(kind="keep_job", type="JOB", demo_key=key, phone=phone, |
|||
title=title, label=label, id=existing["id"])) |
|||
|
|||
# 账本中存在当前批次定义之外的记录 → 拒绝(不自动处理、不猜测归属) |
|||
for r in (ledger or []): |
|||
if r["demo_key"] not in used_keys: |
|||
conflicts.append("账本含当前批次定义之外的记录(%s/%s id=%s):拒绝自动处理" |
|||
% (r["resource_type"], r["demo_key"], r["resource_id"])) |
|||
return actions, conflicts |
|||
|
|||
|
|||
# ── 导入执行 ──────────────────────────────────────────────────────── |
|||
|
|||
def _benefits_json(benefits): |
|||
return json.dumps(benefits, ensure_ascii=False, separators=(",", ":")) |
|||
|
|||
|
|||
def apply_import(conn, cur, identity): |
|||
"""执行导入:先预检(零写入),无冲突后再建账本表 + 业务写入/账本登记同事务。 |
|||
|
|||
返回 (created, conflicts)。conflicts 非空时不写入任何业务数据、也不创建账本表。 |
|||
""" |
|||
# 1) 预检:只读,不创建任何表、不写任何行。 |
|||
ledger = read_ledger(cur, identity) |
|||
_, conflicts = build_plan(cur, identity, ledger) |
|||
if conflicts: |
|||
conn.rollback() |
|||
return None, conflicts |
|||
|
|||
# 2) 账本表 DDL(MySQL DDL 隐式提交、不可回滚,故独立于业务事务)。 |
|||
cur.execute(LEDGER_DDL) |
|||
conn.commit() |
|||
|
|||
# 3) 业务事务:针对最新状态重新校验后写入。 |
|||
try: |
|||
ledger = read_ledger(cur, identity) |
|||
actions, conflicts = build_plan(cur, identity, ledger) |
|||
if conflicts: |
|||
conn.rollback() |
|||
return None, conflicts |
|||
|
|||
comp_by_phone = {c["phone"]: c for c in COMPANIES} |
|||
job_by_key = {job_demo_key(j["phone"], j["title"]): j for j in JOBS} |
|||
created = dict(users=0, companies=0, jobs=0) |
|||
user_ids, company_ids = {}, {} |
|||
for a in actions: |
|||
if a["kind"] == "keep_user": |
|||
user_ids[a["phone"]] = a["id"] |
|||
elif a["kind"] == "create_user": |
|||
cur.execute("INSERT INTO users(phone, nickname, role, is_active, is_verified) " |
|||
"VALUES(%s,%s,'COMPANY',1,1)", |
|||
(a["phone"], a["phone"] + "(演示账号)")) |
|||
uid = cur.lastrowid |
|||
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'COMPANY')", (uid,)) |
|||
insert_ledger(cur, identity, "USER", uid, a["demo_key"], user_fingerprint(a["phone"])) |
|||
user_ids[a["phone"]] = uid |
|||
created["users"] += 1 |
|||
elif a["kind"] == "keep_company": |
|||
company_ids[a["phone"]] = a["id"] |
|||
elif a["kind"] == "create_company": |
|||
src = comp_by_phone[a["phone"]] |
|||
uid = user_ids[a["phone"]] |
|||
cur.execute("INSERT INTO companies(owner_user_id, name, logo_url, description, " |
|||
"industry, company_size, website, address, verified) " |
|||
"VALUES(%s,%s,NULL,%s,%s,%s,%s,NULL,1)", |
|||
(uid, src["name"], src["description"], src["industry"], |
|||
src["company_size"], src["website"])) |
|||
cid = cur.lastrowid |
|||
company_ids[a["phone"]] = cid |
|||
insert_ledger(cur, identity, "COMPANY", cid, a["demo_key"], |
|||
company_fingerprint(a["phone"], src["name"])) |
|||
created["companies"] += 1 |
|||
elif a["kind"] == "keep_job": |
|||
continue |
|||
elif a["kind"] == "create_job": |
|||
j = job_by_key[a["demo_key"]] |
|||
cid = company_ids[a["phone"]] |
|||
cur.execute("INSERT INTO jobs(company_id, title, description, requirements, city, " |
|||
"employment_type, salary_min, salary_max, experience, education, " |
|||
"benefits_json, salary_months, work_location, status, published_at) " |
|||
"VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'PUBLISHED',NOW())", |
|||
(cid, j["title"], j["description"], j["requirements"], j["city"], |
|||
j["employment_type"], j["salary_min"], j["salary_max"], j["experience"], |
|||
j["education"], _benefits_json(j["benefits"]), j["salary_months"], |
|||
j["work_location"])) |
|||
jid = cur.lastrowid |
|||
insert_ledger(cur, identity, "JOB", jid, a["demo_key"], |
|||
job_fingerprint(a["phone"], j["title"])) |
|||
created["jobs"] += 1 |
|||
conn.commit() |
|||
return created, [] |
|||
except Exception: |
|||
conn.rollback() |
|||
raise |
|||
|
|||
|
|||
# ── 清理计划与执行 ────────────────────────────────────────────────── |
|||
|
|||
def _dependent_columns(cur, table): |
|||
return query(cur, |
|||
"SELECT TABLE_NAME AS t, COLUMN_NAME AS c FROM information_schema.KEY_COLUMN_USAGE " |
|||
"WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=%s " |
|||
"AND REFERENCED_COLUMN_NAME='id' ORDER BY TABLE_NAME", (table,)) |
|||
|
|||
|
|||
def _count_refs(cur, table, column, rid): |
|||
return int(scalar(cur, "SELECT COUNT(*) FROM `%s` WHERE `%s`=%%s" % (table, column), (rid,)) or 0) |
|||
|
|||
|
|||
def build_cleanup_plan(cur, identity, ledger, for_update=False): |
|||
"""只读(或加锁)地计算清理计划。返回 (plan, conflicts)。 |
|||
|
|||
ledger 为 None → 账本缺失,无法证明归属。ledger 为空 → 无本批数据。 |
|||
""" |
|||
if ledger is None: |
|||
return None, ["账本表不存在:无法证明本批归属,拒绝清理"] |
|||
plan = dict(jobs=[], companies=[], users=[], ledger_ids=[]) |
|||
conflicts = [] |
|||
|
|||
if not ledger: |
|||
return plan, conflicts # 已清理或无本批数据 → 安全无操作 |
|||
|
|||
conflicts.extend(validate_ledger_shape(ledger)) |
|||
if conflicts: |
|||
return plan, conflicts |
|||
ledger_by_key = {r['demo_key']: r for r in ledger} |
|||
|
|||
job_rows = [r for r in ledger if r["resource_type"] == "JOB"] |
|||
comp_rows = [r for r in ledger if r["resource_type"] == "COMPANY"] |
|||
user_rows = [r for r in ledger if r["resource_type"] == "USER"] |
|||
ledger_company_ids = {r["resource_id"] for r in comp_rows} |
|||
ledger_job_ids = {r["resource_id"] for r in job_rows} |
|||
|
|||
# 账本完整性:COMPANY 必须有对应 USER,JOB 必须有对应 COMPANY |
|||
for r in comp_rows: |
|||
owner_phone = r["demo_key"].split(":", 1)[1] |
|||
if not any(u["demo_key"] == user_demo_key(owner_phone) for u in user_rows): |
|||
conflicts.append("账本不完整:企业 %s 缺少归属用户账本记录" % r["demo_key"]) |
|||
for r in job_rows: |
|||
company_key = r["demo_key"].split("|", 1)[0].replace("job:", "company:") |
|||
if not any(c["demo_key"] == company_key for c in comp_rows): |
|||
conflicts.append("账本不完整:岗位 %s 缺少归属企业账本记录" % r["demo_key"]) |
|||
|
|||
# 用户 |
|||
for r in user_rows: |
|||
uid = r["resource_id"] |
|||
phone = r["demo_key"].split(":", 1)[1] |
|||
row = query_one(cur, "SELECT id, phone FROM users WHERE id=%s" + (" FOR UPDATE" if for_update else ""), (uid,)) |
|||
if row is None: |
|||
conflicts.append("账本记录用户 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, uid)) |
|||
continue |
|||
if row['phone'] != phone or r["identity_fingerprint"] != user_fingerprint(row['phone']): |
|||
conflicts.append("账本用户 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, uid)) |
|||
continue |
|||
# 允许的依赖:名下本批企业 + 仅 COMPANY 角色;其余依赖一律拒绝 |
|||
for dep in _dependent_columns(cur, "users"): |
|||
t, c = dep["t"], dep["c"] |
|||
n = _count_refs(cur, t, c, uid) |
|||
if n == 0: |
|||
continue |
|||
if t == "companies" and c == "owner_user_id": |
|||
owned = [int(x["id"]) for x in query( |
|||
cur, "SELECT id FROM companies WHERE owner_user_id=%s", (uid,))] |
|||
extra = [cid for cid in owned if cid not in ledger_company_ids] |
|||
if extra: |
|||
conflicts.append("用户 %s 名下存在非本批企业 %s:拒绝清理" % (phone, extra)) |
|||
elif t == "user_roles" and c == "user_id": |
|||
roles = [x["role"] for x in query( |
|||
cur, "SELECT role FROM user_roles WHERE user_id=%s", (uid,))] |
|||
extra = [x for x in roles if x != "COMPANY"] |
|||
if extra: |
|||
conflicts.append("用户 %s 存在非本批角色 %s:拒绝清理" % (phone, extra)) |
|||
else: |
|||
conflicts.append("用户 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n)) |
|||
plan["users"].append(dict(id=uid, phone=phone, demo_key=r["demo_key"])) |
|||
|
|||
# 企业 |
|||
for r in comp_rows: |
|||
cid = r["resource_id"] |
|||
phone = r["demo_key"].split(":", 1)[1] |
|||
suffix = " FOR UPDATE" if for_update else "" |
|||
row = query_one(cur, "SELECT c.id AS id, c.name AS name, c.owner_user_id, u.phone AS owner_phone " |
|||
"FROM companies c JOIN users u ON u.id=c.owner_user_id " |
|||
"WHERE c.id=%s" + suffix, (cid,)) |
|||
if row is None: |
|||
conflicts.append("账本记录企业 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, cid)) |
|||
continue |
|||
parent = ledger_by_key.get(user_demo_key(phone)) |
|||
if (parent is None or row['owner_user_id'] != parent['resource_id'] |
|||
or row['owner_phone'] != phone |
|||
or r["identity_fingerprint"] != company_fingerprint(row["owner_phone"], row["name"])): |
|||
conflicts.append("账本企业 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, cid)) |
|||
continue |
|||
for dep in _dependent_columns(cur, "companies"): |
|||
t, c = dep["t"], dep["c"] |
|||
n = _count_refs(cur, t, c, cid) |
|||
if n == 0: |
|||
continue |
|||
if t == "jobs" and c == "company_id": |
|||
jobs_here = [int(x["id"]) for x in query( |
|||
cur, "SELECT id FROM jobs WHERE company_id=%s", (cid,))] |
|||
extra = [jid for jid in jobs_here if jid not in ledger_job_ids] |
|||
if extra: |
|||
conflicts.append("企业 %s 下存在非本批岗位 %s:拒绝清理" % (phone, extra)) |
|||
else: |
|||
conflicts.append("企业 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n)) |
|||
plan["companies"].append(dict(id=cid, phone=phone, demo_key=r["demo_key"])) |
|||
|
|||
# 岗位 |
|||
for r in job_rows: |
|||
jid = r["resource_id"] |
|||
suffix = " FOR UPDATE" if for_update else "" |
|||
row = query_one(cur, "SELECT j.id AS id, j.title AS title, j.company_id, u.phone AS owner_phone " |
|||
"FROM jobs j JOIN companies c ON c.id=j.company_id " |
|||
"JOIN users u ON u.id=c.owner_user_id WHERE j.id=%s" + suffix, (jid,)) |
|||
if row is None: |
|||
conflicts.append("账本记录岗位 id=%s 已丢失:无法确认清理范围,拒绝" % jid) |
|||
continue |
|||
parent_key = r['demo_key'].split('|', 1)[0].replace('job:', 'company:', 1) |
|||
parent = ledger_by_key.get(parent_key) |
|||
if (parent is None or row['company_id'] != parent['resource_id'] |
|||
or r["identity_fingerprint"] != job_fingerprint(row["owner_phone"], row["title"])): |
|||
conflicts.append("账本岗位 id=%s 身份指纹异常:拒绝清理" % jid) |
|||
continue |
|||
for dep in _dependent_columns(cur, "jobs"): |
|||
t, c = dep["t"], dep["c"] |
|||
n = _count_refs(cur, t, c, jid) |
|||
if n > 0: |
|||
conflicts.append("岗位 id=%s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (jid, t, c, n)) |
|||
plan["jobs"].append(dict(id=jid, company_id=row['company_id'], demo_key=r["demo_key"])) |
|||
|
|||
plan["ledger_ids"] = [r["id"] for r in ledger] |
|||
return plan, conflicts |
|||
|
|||
|
|||
def apply_cleanup(conn, cur, identity): |
|||
"""执行清理:事务 + 行锁,只删除账本精确 ID。返回 (removed, conflicts)。""" |
|||
try: |
|||
ledger = read_ledger(cur, identity, for_update=True) |
|||
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=True) |
|||
if conflicts: |
|||
conn.rollback() |
|||
return None, conflicts |
|||
removed = dict(jobs=0, companies=0, users=0, ledger=0) |
|||
for job in plan["jobs"]: |
|||
cur.execute("DELETE FROM jobs WHERE id=%s AND company_id=%s", (job["id"], job['company_id'])) |
|||
if cur.rowcount != 1: |
|||
raise RuntimeError('岗位删除目标变化,回滚全部清理') |
|||
removed["jobs"] += cur.rowcount |
|||
for comp in plan["companies"]: |
|||
cur.execute("DELETE FROM companies WHERE id=%s AND owner_user_id=" |
|||
"(SELECT id FROM users WHERE phone=%s)", (comp["id"], comp["phone"])) |
|||
if cur.rowcount != 1: |
|||
raise RuntimeError('企业删除目标变化,回滚全部清理') |
|||
removed["companies"] += cur.rowcount |
|||
for user in plan["users"]: |
|||
cur.execute("DELETE FROM user_roles WHERE user_id=%s", (user["id"],)) |
|||
cur.execute("DELETE FROM users WHERE id=%s AND phone=%s", (user["id"], user['phone'])) |
|||
if cur.rowcount != 1: |
|||
raise RuntimeError('用户删除目标变化,回滚全部清理') |
|||
removed["users"] += cur.rowcount |
|||
if plan["ledger_ids"]: |
|||
fmt = ",".join(["%s"] * len(plan["ledger_ids"])) |
|||
cur.execute("DELETE FROM %s WHERE id IN (%s)" % (LEDGER_TABLE, fmt), |
|||
tuple(plan["ledger_ids"])) |
|||
removed["ledger"] = cur.rowcount |
|||
if cur.rowcount != len(plan['ledger_ids']): |
|||
raise RuntimeError('账本删除范围变化,回滚全部清理') |
|||
conn.commit() |
|||
return removed, [] |
|||
except Exception: |
|||
conn.rollback() |
|||
raise |
|||
|
|||
|
|||
# ── 核对 ──────────────────────────────────────────────────────────── |
|||
|
|||
def verify(cur, identity): |
|||
"""按账本精确范围核对(不使用 LIKE 模糊统计)。""" |
|||
ledger = read_ledger(cur, identity) |
|||
demo_phones = [c["phone"] for c in COMPANIES] |
|||
demo_names = [c["name"] for c in COMPANIES] |
|||
demo_titles = [(j["phone"], j["title"]) for j in JOBS] |
|||
|
|||
def present_demo_users(): |
|||
fmt = ",".join(["%s"] * len(demo_phones)) |
|||
return [r["id"] for r in query( |
|||
cur, "SELECT id FROM users WHERE phone IN (%s)" % fmt, tuple(demo_phones))] |
|||
|
|||
def present_demo_companies(): |
|||
fmt = ",".join(["%s"] * len(demo_names)) |
|||
return [r["id"] for r in query( |
|||
cur, "SELECT id FROM companies WHERE name IN (%s)" % fmt, tuple(demo_names))] |
|||
|
|||
def present_demo_jobs(): |
|||
ids = [] |
|||
for phone, title in demo_titles: |
|||
owner = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,)) |
|||
if not owner: |
|||
continue |
|||
comp = query_one(cur, "SELECT id FROM companies WHERE owner_user_id=%s", (owner["id"],)) |
|||
if not comp: |
|||
continue |
|||
job = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s", |
|||
(comp["id"], title)) |
|||
if job: |
|||
ids.append(job["id"]) |
|||
return ids |
|||
|
|||
if ledger is None: |
|||
return dict(status="LEDGER_MISSING", |
|||
detail="账本表不存在:无法证明本批归属;不得据此清理", |
|||
demo_users_present=present_demo_users(), |
|||
demo_companies_present=present_demo_companies(), |
|||
demo_jobs_present=present_demo_jobs()) |
|||
|
|||
counts = dict(USER=0, COMPANY=0, JOB=0) |
|||
for r in ledger: |
|||
counts[r["resource_type"]] = counts.get(r["resource_type"], 0) + 1 |
|||
alive = dict(USER=0, COMPANY=0, JOB=0) |
|||
for r in ledger: |
|||
if r["resource_type"] == "USER": |
|||
row = query_one(cur, "SELECT id FROM users WHERE id=%s", (r["resource_id"],)) |
|||
elif r["resource_type"] == "COMPANY": |
|||
row = query_one(cur, "SELECT id FROM companies WHERE id=%s", (r["resource_id"],)) |
|||
else: |
|||
row = query_one(cur, "SELECT id FROM jobs WHERE id=%s", (r["resource_id"],)) |
|||
if row is not None: |
|||
alive[r["resource_type"]] += 1 |
|||
|
|||
if not ledger: |
|||
status = "NO_BATCH_DATA" |
|||
detail = "账本无本批记录" |
|||
else: |
|||
status = "HAS_BATCH_DATA" |
|||
detail = "按账本精确范围统计" |
|||
return dict(status=status, detail=detail, ledger_counts=counts, ledger_alive=alive, |
|||
demo_users_present=present_demo_users(), |
|||
demo_companies_present=present_demo_companies(), |
|||
demo_jobs_present=present_demo_jobs()) |
|||
|
|||
|
|||
# ── 计划打印 ──────────────────────────────────────────────────────── |
|||
|
|||
def print_plan(actions, counters): |
|||
print("[%s] 导入预览(dry-run,未写入,共 %d 项动作):" % (BATCH_ID, len(actions))) |
|||
for a in actions: |
|||
print(" %-14s %s" % (a["kind"], a["label"])) |
|||
print(" 计划新建:", counters) |
|||
|
|||
|
|||
def print_cleanup_plan(plan): |
|||
print("[%s] 清理预览(dry-run,未写入):" % BATCH_ID) |
|||
print(" 将删除岗位:", [j["id"] for j in plan["jobs"]]) |
|||
print(" 将删除企业:", [c["id"] for c in plan["companies"]]) |
|||
print(" 将删除用户:", [u["id"] for u in plan["users"]]) |
|||
print(" 将删除账本行:", len(plan["ledger_ids"])) |
|||
|
|||
|
|||
# ── CLI ───────────────────────────────────────────────────────────── |
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser(description="B01 展示字段批次演示数据工具(默认 dry-run)") |
|||
parser.add_argument("--host", default="127.0.0.1") |
|||
parser.add_argument("--port", type=int, default=3308) |
|||
parser.add_argument("--database", default="mydreamjob_b01_demo") |
|||
parser.add_argument("--user", default="b01demo") |
|||
parser.add_argument("--password", default="b01-demo-only") |
|||
parser.add_argument("--apply", action="store_true", help="显式确认执行写入") |
|||
parser.add_argument("--cleanup", action="store_true", help="执行定向清理(需配合 --apply)") |
|||
parser.add_argument("--verify", action="store_true", help="按账本精确核对") |
|||
args = parser.parse_args() |
|||
|
|||
conn = connect(args) |
|||
try: |
|||
with conn.cursor() as cur: |
|||
assert_business_schema(cur) |
|||
identity = target_identity(cur, args) |
|||
|
|||
if args.verify: |
|||
print("[%s] verify identity=%s:" % (BATCH_ID, identity), |
|||
json.dumps(verify(cur, identity), ensure_ascii=False)) |
|||
return 0 |
|||
|
|||
if args.cleanup: |
|||
ledger = read_ledger(cur, identity, for_update=args.apply) |
|||
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=args.apply) |
|||
if conflicts: |
|||
print("[%s] 清理拒绝:" % BATCH_ID) |
|||
for c in conflicts: |
|||
print(" -", c) |
|||
conn.rollback() |
|||
return 3 |
|||
if not args.apply: |
|||
print_cleanup_plan(plan) |
|||
conn.rollback() |
|||
return 0 |
|||
removed, conflicts = apply_cleanup(conn, cur, identity) |
|||
if conflicts: |
|||
print("[%s] 清理拒绝:" % BATCH_ID) |
|||
for c in conflicts: |
|||
print(" -", c) |
|||
return 3 |
|||
print("[%s] cleanup applied:" % BATCH_ID, removed) |
|||
return 0 |
|||
|
|||
ledger = read_ledger(cur, identity) |
|||
actions, conflicts = build_plan(cur, identity, ledger) |
|||
if conflicts: |
|||
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID) |
|||
for c in conflicts: |
|||
print(" -", c) |
|||
conn.rollback() |
|||
return 3 |
|||
if not args.apply: |
|||
counters = dict( |
|||
users=sum(1 for a in actions if a["kind"] == "create_user"), |
|||
companies=sum(1 for a in actions if a["kind"] == "create_company"), |
|||
jobs=sum(1 for a in actions if a["kind"] == "create_job")) |
|||
print_plan(actions, counters) |
|||
conn.rollback() |
|||
return 0 |
|||
# 执行前针对事务内最新状态重新校验(apply_import 内部重新 build_plan) |
|||
created, conflicts = apply_import(conn, cur, identity) |
|||
if conflicts: |
|||
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID) |
|||
for c in conflicts: |
|||
print(" -", c) |
|||
return 3 |
|||
print("[%s] apply 完成,新建:" % BATCH_ID, created, |
|||
"核对:", json.dumps(verify(cur, identity), ensure_ascii=False)) |
|||
return 0 |
|||
finally: |
|||
conn.close() |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,594 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""B01 演示数据工具的**归属安全**测试(隔离库 + 合成数据 + 哨兵数据)。 |
|||
|
|||
自包含:启动**仅本测试使用**的 MySQL 容器(b01-demo-test-mysql),建库 |
|||
``mydreamjob_b01_demo_test``、合成业务表结构 + 哨兵数据,逐项验证工具的归属安全。 |
|||
结束时只销毁本测试容器(不触碰其他容器/数据卷)。 |
|||
|
|||
不能只用全空库证明安全:冲突与哨兵数据是本项验收重点。 |
|||
|
|||
用法:python docs/tools/b01_demo_display_data_test.py |
|||
退出码:0=全部通过;1=存在失败;2=环境不可用。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import os |
|||
import subprocess |
|||
import sys |
|||
import time |
|||
|
|||
import pymysql |
|||
|
|||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
|||
import b01_demo_display_data as tool # noqa: E402 |
|||
|
|||
CONTAINER = "qsp-final-demo-1019" |
|||
IMAGE = "mysql:8.4" |
|||
HOST = "127.0.0.1" |
|||
PORT = 3308 |
|||
DB = "mydreamjob_b01_demo_test" |
|||
APP_USER = "b01demo" |
|||
APP_PASS = "b01-demo-only" |
|||
ROOT_PASS = "b01-root-test" |
|||
|
|||
DEMO_PHONES = [c["phone"] for c in tool.COMPANIES] |
|||
DEMO_NAMES = [c["name"] for c in tool.COMPANIES] |
|||
DEMO_TITLES = [(j["phone"], j["title"]) for j in tool.JOBS] |
|||
|
|||
RESULTS = [] |
|||
OWN_CONTAINER_ID = None |
|||
|
|||
|
|||
def check(case, ok, detail=""): |
|||
RESULTS.append(dict(case=case, ok=bool(ok), detail=str(detail)[:500])) |
|||
print("[%s] %-60s %s" % ("PASS" if ok else "FAIL", case, str(detail)[:120])) |
|||
return bool(ok) |
|||
|
|||
|
|||
# ── Docker / 连接 ─────────────────────────────────────────────────── |
|||
|
|||
def _run(cmd): |
|||
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", |
|||
errors="replace") |
|||
|
|||
|
|||
def start_container(): |
|||
global OWN_CONTAINER_ID |
|||
if _run(["docker", "inspect", CONTAINER]).returncode == 0: |
|||
raise RuntimeError("同名容器存在,拒绝删除或接管:" + CONTAINER) |
|||
r = _run(["docker", "run", "-d", "--name", CONTAINER, |
|||
"-e", "MYSQL_ROOT_PASSWORD=" + ROOT_PASS, |
|||
"-e", "MYSQL_ROOT_HOST=%", |
|||
"-e", "MYSQL_DATABASE=" + DB, |
|||
"-p", "127.0.0.1:%d:3306" % PORT, IMAGE, |
|||
"--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]) |
|||
if r.returncode != 0: |
|||
raise RuntimeError("docker run 失败: " + (r.stderr or "")[:400]) |
|||
OWN_CONTAINER_ID = r.stdout.strip() |
|||
for _ in range(90): |
|||
try: |
|||
c = root_conn() |
|||
c.close() |
|||
return |
|||
except Exception: |
|||
time.sleep(2) |
|||
raise RuntimeError("MySQL 容器未就绪") |
|||
|
|||
|
|||
def stop_container(): |
|||
if OWN_CONTAINER_ID: |
|||
_run(["docker", "rm", "-f", OWN_CONTAINER_ID]) |
|||
|
|||
|
|||
def root_conn(db=DB, autocommit=True): |
|||
return pymysql.connect(host=HOST, port=PORT, user="root", password=ROOT_PASS, |
|||
database=db, charset="utf8mb4", autocommit=autocommit) |
|||
|
|||
|
|||
def app_conn(): |
|||
return pymysql.connect(host=HOST, port=PORT, user=APP_USER, password=APP_PASS, |
|||
database=DB, charset="utf8mb4", autocommit=False) |
|||
|
|||
|
|||
class Args: |
|||
def __init__(self): |
|||
self.host, self.port, self.database = HOST, PORT, DB |
|||
|
|||
|
|||
def identity_of(cur): |
|||
return tool.target_identity(cur, Args()) |
|||
|
|||
|
|||
# ── 合成表结构 + 哨兵数据 ─────────────────────────────────────────── |
|||
|
|||
DDL = [ |
|||
"DROP TABLE IF EXISTS applications", |
|||
"DROP TABLE IF EXISTS b01_demo_ledger", |
|||
"DROP TABLE IF EXISTS jobs", |
|||
"DROP TABLE IF EXISTS companies", |
|||
"DROP TABLE IF EXISTS user_roles", |
|||
"DROP TABLE IF EXISTS users", |
|||
"""CREATE TABLE users ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE, |
|||
email VARCHAR(100), nickname VARCHAR(50), avatar_url VARCHAR(500), |
|||
password_hash VARCHAR(255), role VARCHAR(30) NOT NULL DEFAULT 'STUDENT', |
|||
is_active TINYINT(1) NOT NULL DEFAULT 1, is_verified TINYINT(1) NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE user_roles ( |
|||
user_id BIGINT NOT NULL, role VARCHAR(30) NOT NULL, PRIMARY KEY (user_id, role), |
|||
CONSTRAINT fk_ur FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE companies ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, owner_user_id BIGINT NOT NULL, |
|||
name VARCHAR(120) NOT NULL, logo_url VARCHAR(500), description TEXT, |
|||
industry VARCHAR(80), website VARCHAR(255), address VARCHAR(255), |
|||
company_size VARCHAR(40) NULL, verified TINYINT(1) NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT uk_owner UNIQUE (owner_user_id), |
|||
CONSTRAINT fk_co FOREIGN KEY (owner_user_id) REFERENCES users(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE jobs ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, company_id BIGINT NOT NULL, |
|||
title VARCHAR(120) NOT NULL, description TEXT NOT NULL, requirements TEXT, |
|||
city VARCHAR(80) NOT NULL, employment_type VARCHAR(30) NOT NULL, |
|||
salary_min INT, salary_max INT, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', |
|||
published_at DATETIME, experience VARCHAR(40), education VARCHAR(40), |
|||
benefits_json TEXT, salary_months INT, work_location VARCHAR(255), |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT fk_job_co FOREIGN KEY (company_id) REFERENCES companies(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE applications ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL, |
|||
student_user_id BIGINT NOT NULL, |
|||
CONSTRAINT fk_app_job FOREIGN KEY (job_id) REFERENCES jobs(id), |
|||
CONSTRAINT fk_app_stu FOREIGN KEY (student_user_id) REFERENCES users(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
] |
|||
|
|||
|
|||
def reset_schema(cur): |
|||
for stmt in DDL: |
|||
cur.execute(stmt) |
|||
|
|||
|
|||
def seed_sentinels(cur): |
|||
cur.execute("INSERT INTO users(phone, email, nickname, role, is_active, is_verified) " |
|||
"VALUES('13900000001','s1@x.com','哨兵甲','STUDENT',1,0)") |
|||
s1 = cur.lastrowid |
|||
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'STUDENT')", (s1,)) |
|||
cur.execute("INSERT INTO users(phone, nickname, role) VALUES('13900000002','哨兵乙','STUDENT')") |
|||
s2 = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id, name, description, industry, company_size, " |
|||
"verified) VALUES(%s,'哨兵企业(非本批)','哨兵简介','其它','10-19人',0)", (s2,)) |
|||
sc = cur.lastrowid |
|||
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status," |
|||
"experience,education,benefits_json,salary_months,work_location) " |
|||
"VALUES(%s,'哨兵岗位','哨兵描述','北京','FULL_TIME','PUBLISHED','1-3年','本科'," |
|||
"'[\"哨兵福利\"]',12,'北京市海淀区')", (sc,)) |
|||
sj = cur.lastrowid |
|||
return dict(user1=s1, user2=s2, company=sc, job=sj) |
|||
|
|||
|
|||
# ── 快照工具 ──────────────────────────────────────────────────────── |
|||
|
|||
def table_rows(cur, table, order="1"): |
|||
cur.execute("SELECT * FROM `%s` ORDER BY %s" % (table, order)) |
|||
cols = [d[0] for d in cur.description] |
|||
return [dict(zip(cols, row)) for row in cur.fetchall()] |
|||
|
|||
|
|||
def norm(rows): |
|||
return json.dumps([{k: (None if v is None else str(v)) for k, v in r.items()} for r in rows], |
|||
ensure_ascii=False, sort_keys=True) |
|||
|
|||
|
|||
def snapshot(cur, tables=("users", "user_roles", "companies", "jobs")): |
|||
return {t: norm(table_rows(cur, t)) for t in tables} |
|||
|
|||
|
|||
def count(cur, sql, params=None): |
|||
cur.execute(sql, params if params else None) |
|||
return int(cur.fetchone()[0]) |
|||
|
|||
|
|||
def demo_counts(cur): |
|||
fmt_p = ",".join(["%s"] * len(DEMO_PHONES)) |
|||
fmt_n = ",".join(["%s"] * len(DEMO_NAMES)) |
|||
users = count(cur, "SELECT COUNT(*) FROM users WHERE phone IN (%s)" % fmt_p, tuple(DEMO_PHONES)) |
|||
comps = count(cur, "SELECT COUNT(*) FROM companies WHERE name IN (%s)" % fmt_n, tuple(DEMO_NAMES)) |
|||
jobs = 0 |
|||
for phone, title in DEMO_TITLES: |
|||
jobs += count(cur, "SELECT COUNT(*) FROM jobs j JOIN companies c ON c.id=j.company_id " |
|||
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s", |
|||
(phone, title)) |
|||
return dict(users=users, companies=comps, jobs=jobs) |
|||
|
|||
|
|||
def ledger_state(cur, identity): |
|||
if not tool.table_exists(cur, tool.LEDGER_TABLE): |
|||
return None |
|||
rows = tool.read_ledger(cur, identity) |
|||
types = {} |
|||
for r in rows: |
|||
types[r["resource_type"]] = types.get(r["resource_type"], 0) + 1 |
|||
return dict(total=len(rows), types=types) |
|||
|
|||
|
|||
# ══ 测试用例 ════════════════════════════════════════════════════════ |
|||
|
|||
def t01_first_import(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T1 空库首次导入无冲突", conflicts == [], conflicts) |
|||
check("T1 新建 3 企业/12 岗位(含 3 用户)", created == dict(users=3, companies=3, jobs=12), created) |
|||
dc = demo_counts(cur) |
|||
check("T1 库内本批计数 = 3/3/12", dc == dict(users=3, companies=3, jobs=12), dc) |
|||
ls = ledger_state(cur, ident) |
|||
check("T1 账本登记 18 行(3/3/12)", ls and ls["total"] == 18 |
|||
and ls["types"] == dict(USER=3, COMPANY=3, JOB=12), ls) |
|||
|
|||
|
|||
def t02_repeat_import_idempotent(conn, cur): |
|||
ident = identity_of(cur) |
|||
before = snapshot(cur) |
|||
before_ledger = ledger_state(cur, ident) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T2 重复导入无冲突且不重复创建", |
|||
conflicts == [] and created == dict(users=0, companies=0, jobs=0), (created, conflicts)) |
|||
check("T2 既有业务资料逐字段不变", snapshot(cur) == before) |
|||
check("T2 账本不变", ledger_state(cur, ident) == before_ledger, ledger_state(cur, ident)) |
|||
|
|||
|
|||
def t03_occupied_phone_refused(conn, cur): |
|||
reset_schema(cur) |
|||
cur.execute("INSERT INTO users(phone,nickname,role,is_active,is_verified) " |
|||
"VALUES(%s,'他人账号','STUDENT',1,0)", (DEMO_PHONES[0],)) |
|||
occ = cur.lastrowid |
|||
cur.execute("INSERT INTO user_roles(user_id,role) VALUES(%s,'STUDENT')", (occ,)) |
|||
conn.commit() |
|||
before_u = norm(table_rows(cur, "users")); before_r = norm(table_rows(cur, "user_roles")) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T3 固定手机号被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T3 占用账号完全不变(users)", norm(table_rows(cur, "users")) == before_u) |
|||
check("T3 占用账号完全不变(user_roles)", norm(table_rows(cur, "user_roles")) == before_r) |
|||
check("T3 未创建任何本批业务数据", count(cur, "SELECT COUNT(*) FROM companies") == 0 |
|||
and count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
|
|||
|
|||
def t04_nonbatch_company_and_title_refused(conn, cur): |
|||
# 4a 同名企业被非本批占用 |
|||
reset_schema(cur) |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13911112222','他人','COMPANY')") |
|||
other = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) " |
|||
"VALUES(%s,%s,'他人企业',0)", (other, DEMO_NAMES[0])) |
|||
oc = cur.lastrowid |
|||
conn.commit() |
|||
b_co = norm(table_rows(cur, "companies")) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T4a 同名企业被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T4a 他方企业不被覆盖/认领", norm(table_rows(cur, "companies")) == b_co) |
|||
check("T4a 未新增本批企业/岗位", count(cur, "SELECT COUNT(*) FROM companies") == 1 |
|||
and count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
# 4b 同名岗位:本批企业下已存在同名岗位但账本缺失 → 拒绝,不认领 |
|||
reset_schema(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("DELETE FROM b01_demo_ledger WHERE resource_type='JOB' ORDER BY id LIMIT 1") |
|||
conn.commit() |
|||
b_j = norm(table_rows(cur, "jobs")); b_led = norm(table_rows(cur, "b01_demo_ledger")) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T4b 同名岗位无本批账本归属 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T4b 岗位与账本均不被改写", |
|||
norm(table_rows(cur, "jobs")) == b_j and norm(table_rows(cur, "b01_demo_ledger")) == b_led) |
|||
|
|||
|
|||
def t05_demo_text_without_ledger_not_deleted(conn, cur): |
|||
reset_schema(cur) |
|||
# 只有演示文字标记、没有账本 |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES(%s,%s,'COMPANY')", |
|||
(DEMO_PHONES[0], tool.DEMO_MARK)) |
|||
u = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) " |
|||
"VALUES(%s,%s,%s,1)", (u, DEMO_NAMES[0], tool.DEMO_MARK)) |
|||
c = cur.lastrowid |
|||
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status) " |
|||
"VALUES(%s,%s,%s,'上海','FULL_TIME','PUBLISHED')", (c, DEMO_TITLES[0][1], tool.DEMO_MARK)) |
|||
conn.commit() |
|||
before = snapshot(cur) |
|||
ident = identity_of(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T5 无账本 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T5 演示文字记录逐字段不变", snapshot(cur) == before) |
|||
check("T5 未创建账本表", not tool.table_exists(cur, tool.LEDGER_TABLE)) |
|||
|
|||
|
|||
def t06_ledger_lost_or_identity_changed(conn, cur): |
|||
ident = identity_of(cur) |
|||
# 6a 账本整表丢失后清理 → 拒绝 |
|||
reset_schema(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("DROP TABLE b01_demo_ledger"); conn.commit() |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6a 账本表丢失 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6a 本批数据未被删除", snapshot(cur) == before) |
|||
|
|||
# 6b 资源身份变化(企业改名)→ 拒绝清理 |
|||
reset_schema(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE companies SET name=%s WHERE name=%s", ("被改名企业", DEMO_NAMES[0])) |
|||
conn.commit() |
|||
b1 = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6b 企业身份变化 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6b 改名后数据未被删除", snapshot(cur) == b1) |
|||
|
|||
# 6c 账本 ID 指向异常(把某条 JOB 账本指向哨兵岗位)→ 拒绝 |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
sentinel_job = count(cur, "SELECT id FROM jobs WHERE title='哨兵岗位'") |
|||
cur.execute("UPDATE b01_demo_ledger SET resource_id=%s WHERE resource_type='JOB' LIMIT 1", |
|||
(sentinel_job,)); conn.commit() |
|||
b2 = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6c 账本 ID 指向异常 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6c 哨兵与业务数据均未被删除", snapshot(cur) == b2) |
|||
|
|||
|
|||
def t07_external_dependency_refused(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
# 新增非本批依赖:学生投递到本批岗位 |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13922223333','外部学生','STUDENT')") |
|||
stu = cur.lastrowid |
|||
job = count(cur, "SELECT j.id FROM jobs j JOIN companies c ON c.id=j.company_id " |
|||
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s", |
|||
(DEMO_TITLES[0][0], DEMO_TITLES[0][1])) |
|||
cur.execute("INSERT INTO applications(job_id, student_user_id) VALUES(%s,%s)", (job, stu)); conn.commit() |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T7 存在本批之外依赖 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T7 本批数据与外部依赖全部保留", snapshot(cur) == before) |
|||
|
|||
|
|||
def t08_midway_error_rolls_back(conn, cur): |
|||
reset_schema(cur) |
|||
ident = identity_of(cur) |
|||
calls = {"n": 0} |
|||
real = tool.insert_ledger |
|||
|
|||
def boom(*a, **k): |
|||
calls["n"] += 1 |
|||
if calls["n"] == 3: # 已在事务中间 |
|||
raise RuntimeError("injected failure") |
|||
return real(*a, **k) |
|||
|
|||
tool.insert_ledger = boom |
|||
try: |
|||
raised = False |
|||
try: |
|||
tool.apply_import(conn, cur, ident) |
|||
except Exception: |
|||
raised = True |
|||
check("T8 中途异常被抛出", raised and calls["n"] >= 3, calls) |
|||
finally: |
|||
tool.insert_ledger = real |
|||
check("T8 本批业务数据整体回滚(users)", count(cur, "SELECT COUNT(*) FROM users") == 0) |
|||
check("T8 本批业务数据整体回滚(companies)", count(cur, "SELECT COUNT(*) FROM companies") == 0) |
|||
check("T8 本批业务数据整体回滚(jobs)", count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
ls = ledger_state(cur, ident) |
|||
check("T8 归属记账一并回滚(账本 0 行)", ls is not None and ls["total"] == 0, ls) |
|||
|
|||
|
|||
def t09_dry_run_and_preview_zero_write(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur); conn.commit() |
|||
before = snapshot(cur) |
|||
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), |
|||
"b01_demo_display_data.py"), |
|||
"--host", HOST, "--port", str(PORT), "--database", DB, |
|||
"--user", APP_USER, "--password", APP_PASS] |
|||
r = _run(cmd) |
|||
cur2 = conn.cursor() |
|||
check("T9 导入 dry-run 退出码 0", r.returncode == 0, r.stdout[-200:]) |
|||
check("T9 导入 dry-run 零写入且不建账本表", |
|||
snapshot(cur2) == before and not tool.table_exists(cur2, tool.LEDGER_TABLE)) |
|||
# 导入后清理预览 |
|||
ident = identity_of(cur2) |
|||
tool.apply_import(conn, cur2, ident) |
|||
b2 = snapshot(cur2) |
|||
ls_before = ledger_state(cur2, ident) |
|||
r2 = _run(cmd + ["--cleanup"]) |
|||
check("T9 清理预览退出码 0", r2.returncode == 0, r2.stdout[-200:]) |
|||
check("T9 清理预览零写入", snapshot(cur2) == b2 and ledger_state(cur2, ident) == ls_before) |
|||
|
|||
|
|||
def t10_cleanup_only_batch_and_sentinels_intact(conn, cur): |
|||
reset_schema(cur) |
|||
sent = seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
sent_ids = {"users": [sent["user1"], sent["user2"]], "companies": [sent["company"]], |
|||
"jobs": [sent["job"]]} |
|||
sent_before = { |
|||
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]), |
|||
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]), |
|||
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]), |
|||
"user_roles": norm([r for r in table_rows(cur, "user_roles") |
|||
if r["user_id"] in sent_ids["users"]]), |
|||
} |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T10 正常清理无冲突", conflicts == [], conflicts) |
|||
check("T10 仅删除本批 12 岗位/3 企业/3 用户/18 账本", |
|||
removed == dict(jobs=12, companies=3, users=3, ledger=18), removed) |
|||
dc = demo_counts(cur) |
|||
check("T10 本批业务数据已清空", dc == dict(users=0, companies=0, jobs=0), dc) |
|||
check("T10 账本已清空", ledger_state(cur, ident)["total"] == 0) |
|||
sent_after = { |
|||
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]), |
|||
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]), |
|||
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]), |
|||
"user_roles": norm([r for r in table_rows(cur, "user_roles") |
|||
if r["user_id"] in sent_ids["users"]]), |
|||
} |
|||
check("T10 哨兵记录逐字段不变(users/companies/jobs/user_roles)", sent_after == sent_before) |
|||
|
|||
|
|||
def t11_second_cleanup_noop(conn, cur): |
|||
ident = identity_of(cur) |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T11 再次清理安全无操作", conflicts == [] and removed == dict(jobs=0, companies=0, |
|||
users=0, ledger=0), (removed, conflicts)) |
|||
check("T11 库内无变化", snapshot(cur) == before) |
|||
|
|||
|
|||
def t12_non_whitelist_target_refused_before_connect(conn, cur): |
|||
ok = True |
|||
for host, port, db in [("127.0.0.1", 3308, "mydreamjob_b01_demo"), |
|||
("127.0.0.1", 3308, "mydreamjob_b01_demo_test")]: |
|||
try: |
|||
tool.assert_safe_target(host, port, db) |
|||
except SystemExit: |
|||
ok = False |
|||
check("T12 白名单目标通过", ok) |
|||
for host, port, db, why in [ |
|||
("127.0.0.1", 3307, "mydreamjob_b01_demo", "非白名单端口"), |
|||
("10.0.0.9", 3308, "mydreamjob_b01_demo", "非回环 host"), |
|||
("127.0.0.1", 3308, "some_other_db", "非白名单库"), |
|||
("127.0.0.1", 3308, "mydreamjob_prod", "疑似线上库")]: |
|||
try: |
|||
tool.assert_safe_target(host, port, db) |
|||
check("T12 拒绝 %s" % why, False, "未拒绝") |
|||
except SystemExit as e: |
|||
check("T12 拒绝 %s" % why, "REFUSED" in str(e), str(e)[:60]) |
|||
# 进程级:错误目标必须在**建立连接前**立即拒绝(不等待连接超时) |
|||
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), |
|||
"b01_demo_display_data.py"), |
|||
"--host", "127.0.0.1", "--port", "3307", "--database", "mydreamjob_b01_demo", |
|||
"--user", "nobody", "--password", "nope"] |
|||
t0 = time.time() |
|||
r = _run(cmd) |
|||
elapsed = time.time() - t0 |
|||
check("T12 非白名单目标非零退出且快速拒绝", |
|||
r.returncode != 0 and "REFUSED" in (r.stderr + r.stdout) and elapsed < 10, |
|||
"rc=%s elapsed=%.1fs" % (r.returncode, elapsed)) |
|||
|
|||
|
|||
def assert_refusal_preserves_rows(conn, cur, ident, label): |
|||
before = snapshot(cur, ("users", "user_roles", "companies", "jobs", tool.LEDGER_TABLE)) |
|||
_, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check(label + " 清理拒绝", bool(conflicts), conflicts) |
|||
check(label + " 清理未改业务或账本", snapshot(cur, tuple(before)) == before) |
|||
_, conflicts = tool.apply_import(conn, cur, ident) |
|||
check(label + " 导入拒绝", bool(conflicts), conflicts) |
|||
check(label + " 导入未改业务或账本", snapshot(cur, tuple(before)) == before) |
|||
|
|||
|
|||
def t13_real_user_identity_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE users SET phone='18800009991' WHERE phone=%s", (DEMO_PHONES[0],)) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T13 真实用户身份改变") |
|||
|
|||
|
|||
def t14_job_parent_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
ledger = tool.read_ledger(cur, ident) |
|||
job = next(r for r in ledger if r['resource_type'] == 'JOB') |
|||
cur.execute("SELECT company_id FROM jobs WHERE id=%s", (job['resource_id'],)) |
|||
old = cur.fetchone()[0] |
|||
other = next(r['resource_id'] for r in ledger if r['resource_type'] == 'COMPANY' and r['resource_id'] != old) |
|||
cur.execute("UPDATE jobs SET company_id=%s WHERE id=%s", (other, job['resource_id'])) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T14 岗位父企业改变") |
|||
|
|||
|
|||
def t15_company_name_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE companies SET name='changed-identity' WHERE name=%s", (DEMO_NAMES[0],)) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T15 企业真实名字改变") |
|||
|
|||
|
|||
TESTS = [ |
|||
("T1", t01_first_import), |
|||
("T2", t02_repeat_import_idempotent), |
|||
("T3", t03_occupied_phone_refused), |
|||
("T4", t04_nonbatch_company_and_title_refused), |
|||
("T5", t05_demo_text_without_ledger_not_deleted), |
|||
("T6", t06_ledger_lost_or_identity_changed), |
|||
("T7", t07_external_dependency_refused), |
|||
("T8", t08_midway_error_rolls_back), |
|||
("T9", t09_dry_run_and_preview_zero_write), |
|||
("T10", t10_cleanup_only_batch_and_sentinels_intact), |
|||
("T11", t11_second_cleanup_noop), |
|||
("T12", t12_non_whitelist_target_refused_before_connect), |
|||
("T13", t13_real_user_identity_changed), |
|||
("T14", t14_job_parent_changed), |
|||
("T15", t15_company_name_changed), |
|||
] |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--keep-container", action="store_true") |
|||
parser.add_argument("--out") |
|||
args = parser.parse_args() |
|||
|
|||
try: |
|||
start_container() |
|||
except Exception as exc: |
|||
print("[ENV] 无法启动隔离 MySQL 容器:%s" % exc) |
|||
return 2 |
|||
try: |
|||
admin = root_conn() |
|||
with admin.cursor() as ac: |
|||
ac.execute("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'" % (APP_USER, APP_PASS)) |
|||
ac.execute("GRANT ALL PRIVILEGES ON `%s`.* TO '%s'@'%%'" % (DB, APP_USER)) |
|||
ac.execute("FLUSH PRIVILEGES") |
|||
admin.close() |
|||
|
|||
for name, fn in TESTS: |
|||
conn = root_conn(autocommit=False) |
|||
try: |
|||
with conn.cursor() as cur: |
|||
fn(conn, cur) |
|||
except Exception as exc: |
|||
import traceback |
|||
check("%s 执行异常" % name, False, traceback.format_exc()[-400:]) |
|||
finally: |
|||
try: |
|||
conn.rollback() |
|||
except Exception: |
|||
pass |
|||
conn.close() |
|||
finally: |
|||
if not args.keep_container: |
|||
stop_container() |
|||
|
|||
bad = [r for r in RESULTS if not r["ok"]] |
|||
print("\n== DEMO-TOOL SAFETY SUMMARY: total=%d fail=%d ==" % (len(RESULTS), len(bad))) |
|||
if args.out: |
|||
with open(args.out, "w", encoding="utf-8") as fh: |
|||
json.dump(RESULTS, fh, ensure_ascii=False, indent=1) |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,49 @@ |
|||
# 统一手机号注册与首次身份选择设计 |
|||
|
|||
## 范围与基线 |
|||
|
|||
本设计面向后端实施、独立复验和前端联调。采用一次性身份选择,不实现双身份切换、管理员公开注册、历史账号转身份或线上部署。 |
|||
开工分支 master,HEAD 8c9429108cbd5d85e5245f6c404b2172bf6dd7f3。暂存区空;已有 Phase B、验证码 P0、岗位等未提交变更及 290 个历史未跟踪条目须保留。 |
|||
|
|||
## 权威库存 |
|||
|
|||
- AuthService.mockLogin 对新手机号显式创建 STUDENT;密码登录只登录既有账号。 |
|||
- User 主角色为 VARCHAR(30),user_roles 为关联表,无数据库枚举限制。最新迁移 V46,无新增字段需求。 |
|||
- JwtAuthenticationFilter 只建立已认证主体,业务角色由 Service 判定;存在仅要求认证的业务接口,需要入口白名单。 |
|||
- AuthSessionService.createSession/revokeAllUserSessions 使用 REQUIRED;刷新持锁顺序 users → auth_tokens,身份选择沿用该顺序。 |
|||
- AuthTokenRepository 的 modifying 操作 flush 并清空 persistence context;必须先 flush 角色变化,再撤销/创建会话。 |
|||
- 企业创建资料约束复用 CompanyRegistrationRequest.CompanyDetails;企业注册开关只接受字面量 true。 |
|||
- 现有 /auth/company/register 保留原契约,不新增第二套角色或企业认证状态。 |
|||
|
|||
## 状态机和兼容 |
|||
|
|||
新手机号通过 login 用途短信验证 → UNASSIGNED 主角色、空业务 roles → 一次选择 STUDENT 或 COMPANY。 |
|||
COMPANY 选择同时创建 verified=false 的企业资料,后续沿用 Phase A 入驻与 Phase B 招聘认证门禁。 |
|||
旧三种角色不迁移。实体/数据库 STUDENT 默认值暂保留用于既有内部创建点兼容,统一注册路径显式赋值,不依赖默认授权。 |
|||
未知/矛盾主角色和 roles 组合不得通过身份选择;UNASSIGNED 即使被误写业务 roles 也应被公共角色 guard 拒绝。 |
|||
|
|||
## 接口 |
|||
|
|||
- /auth/login、/auth/password/login 和 /auth/me 增加 identitySelectionRequired(主角色派生),保留其他字段和成功码。 |
|||
- POST /auth/identity/select:identity 仅 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company;身份来自当前会话,客户端不能指定 userId/role/verified。 |
|||
- 首次选择返回强类型响应:userId、role、identitySelectionRequired=false、companyId、onboardingStatus、新 Token 对及 expiresIn。 |
|||
- 有效会话重复相同身份不修改资料或审计,返回状态和 reauthenticationRequired=true,不再次颁发凭证。失效旧会话返回 401,重新登录可恢复。 |
|||
- 已选其他身份 409;企业开关关闭 503;未选择身份访问受限业务 403;非法请求 400。 |
|||
|
|||
## 白名单 |
|||
|
|||
待选身份仅允许 GET /auth/me、POST /auth/identity/select、POST /auth/logout、POST /auth/password/set/change,及既有匿名认证入口 /auth/login、/auth/password/login、/auth/password/reset、/auth/refresh、/auth/sms/send、/auth/company/register。 |
|||
公开只读保持允许:GET /、/health(/子路径)、Swagger/OpenAPI、岗位公开列表/搜索/筛选/对比/评价、企业公开主页/岗位、岗位推荐分类字典。业务端口 actuator 路径保留原 404。 |
|||
其余已登录业务统一拒绝(含资料、认证、投递、候选人、Offer、面试、消息、测评、通知、导出和管理员)。方法与路径同时匹配,不用 /auth/** 放行。 |
|||
白名单检查由 IdentityAccessFilter 置于 JWT 验证之后的安全过滤链,在 MVC 路径/请求格式匹配之前执行,读取数据库当前身份;未知接口默认拒绝。通过禁用容器自动注册避免重复执行。公共角色 guard 同时拒绝 UNASSIGNED,避免服务调用用矛盾 roles 绕过。 |
|||
|
|||
## 原子事务与竞争 |
|||
|
|||
users 行锁 → 重新验证当前会话与账号注销状态 → 校验身份/资料/开关 → 更新角色并 flush → 建企业并 flush → 撤销全部旧会话 → 创建新会话 → 必需审计。任一失败回滚全部数据库更改,不返回 Token。 |
|||
验证码错误计数独立事务及 Redis 限流不纳入该回滚范围,不改变既有 P0。 |
|||
并发不同选择只允许一种提交,使用行锁后重新验证会话,防止认证过滤器校验与事务执行间的竞争。与刷新采用同一锁顺序;选择之前的会话在提交后失效。 |
|||
|
|||
## 验收 |
|||
|
|||
先保存生产修改前的失败证据,覆盖统一登录、身份选择、角色组合、企业开关、会话撤销、事务回滚和真实 MySQL 竞争。旧角色测试保留;仅更新明确依赖新手机号默认 STUDENT 的契约断言,不放宽授权断言。 |
|||
H2 定向、认证关联、真实 MySQL、全量 test/clean verify、静态与运行时 OpenAPI 对齐后进入独立复验。真实短信未接通时明确受控 Mock,不冒充短信送达。 |
|||
@ -0,0 +1,159 @@ |
|||
# 统一手机号账号注册与首次身份选择实施报告 |
|||
|
|||
## 当前状态 |
|||
|
|||
统一手机号账号注册与首次身份选择本地实施完成,待独立代码复验。 |
|||
最终全量验证与独立 JVM 最新 JAR 黑盒均通过。没有暂存、提交、推送或部署。 |
|||
|
|||
## 范围与基线 |
|||
|
|||
项目为职来后端(项目目录 `zhilai`)。分支 master;开工 HEAD 为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`。 |
|||
开工已存在 Phase B、验证码 P0、岗位等未提交改动,以及 290 个历史未跟踪条目。本功能叠加于工作区,不能把全部 diff 当成本轮改动。 |
|||
最新迁移 V46;角色字段是 VARCHAR,无需新增迁移。不实现双身份切换、历史账号转角色、公开管理员注册或 Phase C。 |
|||
|
|||
## 权威库存 |
|||
|
|||
原 `AuthService.mockLogin` 为新手机号自动创建 STUDENT;密码登录不负责注册。 |
|||
`users.role` 与 `user_roles` 都被生产代码读取;JWT 认证以服务端有效会话为准,业务角色由 Service 校验。 |
|||
`AuthSessionService` 的会话创建/撤销参与调用方事务;刷新按 users → auth_tokens 顺序加锁。 |
|||
撤销 Repository modifying 操作会清空 persistence context,身份更新必须先 flush。 |
|||
企业资料复用既有 `CompanyDetails` 的 Validation 约束,开关复用只接受字面量 true 的企业注册配置。 |
|||
|
|||
## 新注册与旧账号兼容 |
|||
|
|||
新手机号仍通过 `POST /auth/login` 验证 login 用途短信,创建 UNASSIGNED 主角色和空业务 roles。 |
|||
登录、密码登录和 `/auth/me` 都新增派生字段 `identitySelectionRequired`,成功业务码仍为 200;旧字段不删除。 |
|||
既有 STUDENT、COMPANY、ADMIN 不重置、不迁移。实体和数据库的 STUDENT 默认值保留以兼容其他明确的内部创建点,统一注册路径显式覆盖为 UNASSIGNED。 |
|||
原 `/auth/company/register` 保留为兼容入口,并不强迫旧客户端立即改造。 |
|||
|
|||
## 首次身份选择接口 |
|||
|
|||
`POST /auth/identity/select` 必须使用有效 Access Token;身份和 sessionId 取自当前已验证会话。 |
|||
|
|||
```json |
|||
{"identity":"STUDENT"} |
|||
``` |
|||
|
|||
```json |
|||
{"identity":"COMPANY","company":{"name":"示例企业","industry":"互联网"}} |
|||
``` |
|||
|
|||
identity 仅允许 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company。 |
|||
不接受客户端 userId、role、verified 或其他未知字段;嵌套企业资料也不允许非法授权字段。 |
|||
首次成功返回新 Token 对、userId、role、identitySelectionRequired=false;企业额外返回 companyId 和 UNVERIFIED。 |
|||
同身份重复请求只返回当前状态、reauthenticationRequired=true,不重复发 Token、修改资料或追加审计。 |
|||
原会话失效后重复使用旧 Token 返回 401;客户端应采用新凭证,丢失响应时通过原登录方式重新登录。 |
|||
已经选择另一种身份的账号返回 409,不能用该接口切换身份。 |
|||
|
|||
## 待选身份访问门禁 |
|||
|
|||
`IdentityAccessFilter` 只作用于 JWT 过滤器建立的已验证会话主体,在 JWT 之后、MVC 路由和参数解析之前执行。 |
|||
它读取数据库当前身份,使用精确的 HTTP 方法/路径白名单,未知业务入口默认拒绝。 |
|||
白名单包含身份查询/选择、登出、密码维护和原匿名认证入口,以及既有公开只读页面。 |
|||
企业资料、认证申请、测评、消息、投递、候选人、岗位写操作和管理员接口不对 UNASSIGNED 放行。 |
|||
容器自动注册被禁用,避免过滤器在 JWT 之前执行或重复执行。 |
|||
公共 `UserRoleGuard.requireRole` 额外拒绝 UNASSIGNED,即使 user_roles 被误写成业务角色也不能通过该 guard。 |
|||
旧 Controller 接线探针使用的非 JWT 测试主体不新增解析调用;生产仍只有 JWT 认证方式,未启用 Basic/Form 登录。 |
|||
|
|||
## 事务与锁顺序 |
|||
|
|||
1. Service 层再次验证 DTO。 |
|||
2. 锁定 users,重新核验 active、当前会话和注销状态。 |
|||
3. 确认主角色/roles 无矛盾、没有孤立企业资料;校验企业开关。 |
|||
4. 更新 users.role 与唯一业务 role,saveAndFlush。 |
|||
5. COMPANY 创建唯一 verified=false 企业资料并 flush。 |
|||
6. 撤销全部旧会话,创建带新角色的新会话。 |
|||
7. 同事务执行 MANDATORY 必需审计,固定事件 IDENTITY_SELECTED,明细只含结果。 |
|||
|
|||
任何异常回滚角色、企业和 Token 更改;不返回未提交的凭证。锁顺序与刷新一致,不增加跨请求缓存。 |
|||
注销 PENDING/COMPLETED 拒绝选择,取消后的 NONE 可继续;禁用账号在锁内再次拒绝。 |
|||
原验证码原子消费、独立错误计数事务和 Redis 限流不被修改,也不虚称这些 Redis 状态会自动回滚。 |
|||
|
|||
## 错误响应 |
|||
|
|||
新增唯一数字码:10401 身份待选/403,10402 身份冲突/409,10403 身份选择依赖不可用/503。 |
|||
非法 JSON/字段/参数为原 400;匿名、失效会话和非法 Token 为原 401;企业注册开关关闭沿用原 503。 |
|||
没有修改任何历史错误码数字;历史 9103 重码事实仍保留。 |
|||
历史映射快照资源未改,只在契约测试中单独登记三个新增映射和相应数量。 |
|||
|
|||
## 修复前证据 |
|||
|
|||
生产修改前新增 6 个 HTTP 测试:6 run / 6 failures / 0 errors / 0 skipped。 |
|||
明确复现:新账号实际为 STUDENT;待选业务请求实际 200;身份选择路径不存在。 |
|||
失败证据在忽略目录 `docs/acceptance/unified-identity/`,不会进入提交。 |
|||
|
|||
## 实施中发现与修正 |
|||
|
|||
- Jackson 全局忽略未知字段,初版 userId 注入请求被接受;改为新 DTO/企业嵌套 DTO 安全拒绝未知字段。 |
|||
- 初版 MVC 拦截器晚于 multipart/Content-Type 路由匹配;全端点测试定位到格式不符请求先出错,改为安全过滤链门禁。 |
|||
- 新过滤器初版增加历史 Controller 接线探针的 Accessor 调用;限定为 JWT 已验证会话主体后保留旧精确调用次数断言。 |
|||
- MySQL 回滚测试初版在事务代理外 stub MANDATORY 审计,导致测试装配失败;改为对代理内部 spy 注入故障,实际调用仍经过真实事务代理。 |
|||
- Repository 会将故障转换为 DataAccessException,回滚测试明确验证安全业务码 10403,不要求错误穿透为原异常类型。 |
|||
- 密码 HTTP 测试初版遗漏密码模块开关和 mock Redis 原子脚本返回值;补齐测试前置。真实 Redis 证据由最新 JAR 验收单独提供。 |
|||
- 新增接口使旧 OpenAPI 数量断言失败;只将数量改为 183/221,保留原简历安全字段和接口断言。 |
|||
- MySQL Service 并发只产生一份身份,但首次最新 JAR HTTP 并发出现 200/500:失败请求在锁内抛出 InvalidTokenException,新 Controller 未转换。补确定性测试先复现 1 run / 1 failure(expected 401 but was 500),再复用原认证安全响应返回 401;没有改变锁、角色或数据断言。 |
|||
- JAR 验收脚本初版把 `/auth/sms/send` 的查询参数放进 JSON body,实测缺少 phone 返回 400;按现有 RequestParam 契约修正脚本,未修改生产短信接口。Maven exec 参数需整体引号;RabbitMQ 改用本机已缓存的官方 3.13 management 镜像,未通过反复网络拉取干扰其他服务。 |
|||
|
|||
## 精确本轮文件范围 |
|||
|
|||
新增生产文件(4): |
|||
|
|||
- `auth/identity/IdentitySelectionRequest.java` |
|||
- `auth/identity/IdentitySelectionResponse.java` |
|||
- `auth/identity/IdentitySelectionService.java` |
|||
- `auth/identity/IdentityAccessFilter.java` |
|||
|
|||
修改生产文件(9):`AuthService`、`User`、`PasswordAuthenticationService`、`UserRoleGuard`、`ErrorCode`、`ErrorHttpStatusRegistry`、`CompanyRegistrationRequest`、`SecurityConfig`、`AuthController`。 |
|||
其中异常码文件已有 Phase B 改动,本轮只叠加 10401–10403;其他原有 P0/B/岗位文件不被本轮覆盖。 |
|||
|
|||
新增测试文件(4):`IdentityPolicyTest`、`UnifiedRegistrationIntegrationTest`、`IdentitySelectionMySqlTest`、`UnifiedIdentityJarAcceptanceProbe`。最后一项为手工最新 JAR 验收 main,不冒充 JUnit 数量。 |
|||
修改既有测试(8):`AuthServiceTest`、`CompanyControllerMvcTest`、`CompanyOnboardingControllerTest`、`GlobalExceptionHandlerStatusContractTest`、`ErrorHttpStatusRegistryTest`、`GlobalExceptionHandlerMappingParityTest`、`R01BIndependentHttpMappingParityTest`、`CompanyResumeDownloadIntegrationTest`。 |
|||
修改仅涉及新账号默认身份、新依赖装配、新枚举映射计数和新增接口计数;原角色、归属、隐私及历史映射断言保留。 |
|||
|
|||
新增设计与本报告;修改 `openapi-rc.json` 与 `verify_openapi.py`。没有修改迁移、依赖、短信服务商、Jenkins 或线上配置。 |
|||
|
|||
## 测试及真实基础设施 |
|||
|
|||
最终定向命令 `mvn -B -Dtest=UnifiedRegistrationIntegrationTest,IdentityPolicyTest,IdentitySelectionMySqlTest test`:55 / F=0 / E=0 / S=0。 |
|||
其中策略测试 27、HTTP 集成 13、真实 MySQL 15;HTTP 库存测试独立列出公开与账号操作清单,不调用生产白名单生成期望,196 个受限 operation 全部拒绝。 |
|||
首次全量 test 曾有 7 个失败(旧接口计数 + 旧接线精确次数),修正测试计数与过滤器主体边界后 `mvn test` 为 2952 / F=0 / E=0 / S=0。 |
|||
之后新增失效会话 HTTP 反证并修复 500,最终 `mvn -B clean verify`:2953 / F=0 / E=0 / S=0 / BUILD SUCCESS,耗时 13:13。 |
|||
独立读取 284 份 Surefire XML 汇总同为 2953 / 0 / 0 / 0。相对采用的 2889 基线,新增 55 个 JUnit 执行项,既有枚举参数化测试增加 9 项,数量自洽。 |
|||
真实 MySQL 15 项及既有 Testcontainers 测试均实际执行;没有 Docker 不可用静默跳过。 |
|||
MySQL/Testcontainers 不设置静默跳过 Docker 的开关。 |
|||
并发使用 CountDownLatch,不用 sleep 调节竞争。 |
|||
最新 JAR 验收使用隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3;所有夹具仅在隔离库,不使用真实手机号或线上密钥。 |
|||
短信 sender 使用受控 Mock,数据库获取测试验证码;不是实际运营商投递,也没有为前端增加 mockCode 旁路。 |
|||
|
|||
最新 JAR 三中间件黑盒场景包括:企业开关关闭安全拒绝;UNASSIGNED 空业务 roles;13 张表逐字段快照零写;求职者/企业身份选择;密码登录身份标记;全部旧 Access/Refresh 失效;同身份重试只读;跨身份冲突;未认证企业候选人访问被拒;真实 HTTP 双请求只一个 200、另一个 401;四个 RabbitMQ 队列消息数、ready、unacked 均为 0;精确删除三账号夹具及验证码,审计 append-only。 |
|||
首次以 Maven exec:java 同 JVM 运行时,场景已产生 66 checks / 0 failures,但 Testcontainers Ryuk 守护线程在插件清理阶段被中断,导致外层 Maven 返回非零;保留该失败事实,不写成 BUILD SUCCESS。 |
|||
最终改为独立 `java -cp` JVM 执行同一验收 main:再次 66 checks / 0 failures,进程正常退出 0,三容器按生命周期关闭,无插件线程清理异常。 |
|||
最终结果在被忽略的 `target/identity-probe-result.json`,日志在 `docs/acceptance/unified-identity/jar-probe-standalone.log`;不新增未跟踪 JSON 验收产物。 |
|||
被验收的最终 JAR SHA-256:`77d0125ba1d20007966be9fa7a7b3f82ef09fb5628b83bb5190262d318720cc9`。最终全量打包后仅重编译手工验收 main 的输出路径调整,没有再修改生产代码。 |
|||
|
|||
## OpenAPI 与前端切换 |
|||
|
|||
新增一个 path/operation,静态验证为 183 paths / 221 operations,`verify_openapi.py` All checks passed。 |
|||
最新 JAR `/v3/api-docs` 的完整 path/method operation 集合与静态文件完全一致,不只比较数量。 |
|||
前端登录成功后查看 identitySelectionRequired:true 进入身份选择页;false 根据旧 role 进入原工作台。 |
|||
首次选择成功必须同时替换 Access/Refresh Token,不能继续使用旧 Token。 |
|||
COMPANY 仍进入原 UNVERIFIED 入驻流程;不能直接进入已认证招聘能力。刷新后如需身份展示,继续查询 `/auth/me`。 |
|||
新账号已不自动成为求职者,因此部署必须与前端身份选择页协调,不能宣称旧前端零改动兼容。 |
|||
|
|||
## 敏感信息分类 |
|||
|
|||
本轮新增源码、测试与两份文档扫描:完整 JWT、字面 BCrypt 摘要、邮箱、PRIVATE KEY、AWS key、本机用户绝对路径均为 0 命中。 |
|||
手机号命中 22 行、15 个唯一值,全部来自独占的 `199000094xx/199000095xx/199000096xx` 假号段夹具;没有使用用户提供的真实手机号发送短信。 |
|||
手工验收 main 中的测试密码、JWT 测试密钥和 Testcontainers 口令为显式 test-only 常量,只在隔离库/容器使用,不是生产凭据。 |
|||
成功 JAR 日志通过自动断言确认不含三条测试手机号、测试明文密码、对应 BCrypt 摘要及指定签发 Token;拒绝响应不含异常类、SQL 连接串或存储字段。 |
|||
失败的 MockMvc 证据可能包含已失效的本地测试 Token,日志由既有 `**/*.log` 规则忽略,不进入提交;不把这类失败证据宣称为“所有日志零 Token”。 |
|||
历史启动日志中的本机目录与 Spring generated security password 警告仍属于既有环境输出,本轮未顺手重构该功能,也不声称这些观察项已消失。 |
|||
|
|||
## 边界与结束检查 |
|||
|
|||
最终分支 master,HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,暂存区空,`git diff --check` 为 0。 |
|||
V1–V46 与 pom.xml 无改动、无新迁移;WebMvcConfig 内容与 HEAD 一致,没有保留初版拦截器配置。 |
|||
历史 290 个未跟踪条目未删除,当前 300 = 历史 290 + 本轮 10 个新增文件。原有未提交 Phase B/P0/岗位改动保留,不把它们记成本轮产出。 |
|||
最新 JAR 两次启动按各自 Process 对象精确停止;最终无本轮 Java 残留,8080/8081 空闲。验收容器自动关闭,Docker 守护进程可保持运行。 |
|||
三账号及验证码夹具精确清理,清理过程中不删除审计行;审计随隔离容器自然生命周期结束,不触及线上审计数据。四个消息队列在关闭前 messages/ready/unacked 均为 0。 |
|||
未进入 Phase C;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。 |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import com.mydreamjob.security.CurrentUserAccessor; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.web.bind.annotation.*; |
|||
import java.time.LocalDateTime; |
|||
|
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
@Tag(name = "投递") |
|||
public class ApplicationAcceptanceController { |
|||
private final ApplicationAcceptanceService acceptance; |
|||
private final CurrentUserAccessor users; |
|||
private final RequestIdAccessor requestIds; |
|||
|
|||
public record AcceptanceView(Long applicationId, String status, LocalDateTime companyAcceptedAt, |
|||
boolean communicationAllowed) {} |
|||
|
|||
@Operation(summary = "企业接受投递并解锁沟通", description = "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。") |
|||
@ApiResponses({ |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="200",description="data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="401",description="未登录或会话不可用"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="403",description="角色、企业认证或投递归属不符"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="404",description="投递/企业不存在"), |
|||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="409",description="投递已拒绝或撤回,不能接受") |
|||
}) |
|||
@PostMapping("/applications/{id}/accept") |
|||
public ResponseEntity<ApiResponse<AcceptanceView>> accept(@PathVariable Long id, HttpServletRequest request) { |
|||
Application app = acceptance.accept(users.requireUserId(),id,AuditContext.from(request)); |
|||
AcceptanceView view = new AcceptanceView(app.getId(),app.getStatus(),app.getCompanyAcceptedAt(),true); |
|||
return ResponseEntity.ok(ApiResponse.ok(view,requestIds.get(request))); |
|||
} |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.company.VerifiedCompanyGuard; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobService; |
|||
import com.mydreamjob.notification.NotificationService; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
import java.util.Set; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class ApplicationAcceptanceService { |
|||
private final UserRoleGuard roles; |
|||
private final CompanyRepository companies; |
|||
private final ApplicationRepository applications; |
|||
private final JobService jobs; |
|||
private final ApplicationStatusHistoryRepository history; |
|||
private final AuditService audit; |
|||
private final NotificationService notifications; |
|||
private final Clock clock; |
|||
|
|||
/** 与撤回/状态推进使用相同投递行锁;重试只产生一次接受记录和通知。 */ |
|||
@Transactional |
|||
public Application accept(Long actorUserId, Long applicationId, AuditContext context) { |
|||
roles.requireCompany(actorUserId); |
|||
Company company = VerifiedCompanyGuard.requireVerified(companies.findByOwnerUserId(actorUserId).orElse(null)); |
|||
Application app = applications.findLockedById(applicationId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); |
|||
Job job = jobs.getJob(app.getJobId()); |
|||
if (!company.getId().equals(job.getCompanyId())) |
|||
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); |
|||
if (!Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) |
|||
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); |
|||
// 已录用仍可补记明确接受;已经接受时不覆盖原始时间/审计。
|
|||
if (app.getCompanyAcceptedAt() != null) return app; |
|||
app.setCompanyAcceptedAt(LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MICROS)); |
|||
app = applications.save(app); |
|||
history.save(ApplicationStatusHistory.builder().applicationId(app.getId()) |
|||
.operatorUserId(actorUserId).fromStatus(app.getStatus()).toStatus(app.getStatus()) |
|||
.reason("COMPANY_ACCEPTED: company accepted application; communication unlocked").build()); |
|||
audit.log(actorUserId,"ACCEPT_APPLICATION","APPLICATION",app.getId(), |
|||
"Company accepted application; communication unlocked",context); |
|||
notifications.publishEvent(app.getStudentUserId(),"APPLICATION_ACCEPTED","企业已接受投递", |
|||
"企业已接受你的投递,可以开始沟通","APPLICATION",app.getId(),"accept-application-"+app.getId()); |
|||
return app; |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; |
|||
|
|||
/** 沟通资格与双向评分解耦;终止投递优先拒绝,历史匹配/已录用保持兼容。 */ |
|||
public final class ApplicationCommunicationPolicy { |
|||
private ApplicationCommunicationPolicy() {} |
|||
|
|||
public static boolean allows(Application app, ApplicationMatchRecord legacyMatch) { |
|||
if (app == null || app.getStatus() == null |
|||
|| !java.util.Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) |
|||
return false; |
|||
return app.getCompanyAcceptedAt() != null || "HIRED".equals(app.getStatus()) |
|||
|| (legacyMatch != null && ApplicationMatchRecord.STATUS_MATCHED.equals(legacyMatch.getStatus())); |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* Persist an invalid-code attempt even when the surrounding login or registration rolls back. |
|||
* A conditional database update prevents concurrent guesses from losing increments. |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class SmsFailedAttemptRecorder { |
|||
private final SmsVerificationCodeRepository codes; |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public void record(Long codeId, LocalDateTime now) { |
|||
codes.incrementFailedAttemptsIfValid(codeId, now); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
|
|||
public record PasswordSetupRequest(@NotBlank @Size(max = 512) |
|||
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String passwordSetupToken, |
|||
@NotBlank @Size(max = 256) |
|||
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String newPassword) { |
|||
@Override public String toString() { return "PasswordSetupRequest[redacted]"; } |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
public record PasswordSetupResponse(boolean passwordConfigured, boolean reauthenticationRequired, |
|||
boolean identitySelectionRequired, String accessToken, String refreshToken, long expiresIn) { |
|||
@Override public String toString() { return "PasswordSetupResponse[redacted]"; } |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import com.mydreamjob.security.CurrentUserAccessor; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.security.authentication.InsufficientAuthenticationException; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.util.AntPathMatcher; |
|||
import org.springframework.web.filter.OncePerRequestFilter; |
|||
import jakarta.servlet.FilterChain; |
|||
import jakarta.servlet.ServletException; |
|||
import java.io.IOException; |
|||
import java.util.Set; |
|||
|
|||
/** 已认证但未选择身份的账号:默认拒绝业务,只允许精确的方法/路径白名单。 */ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class IdentityAccessFilter extends OncePerRequestFilter { |
|||
private static final Set<String> AUTH_POST = Set.of("/auth/identity/select", "/auth/logout", |
|||
"/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login", |
|||
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); |
|||
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", |
|||
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", |
|||
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs", |
|||
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**"); |
|||
private final UserRepository users; |
|||
private final CurrentUserAccessor currentUser; |
|||
private final ObjectMapper mapper; |
|||
private final RequestIdAccessor requestIds; |
|||
private final AntPathMatcher paths = new AntPathMatcher(); |
|||
|
|||
@Override |
|||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) |
|||
throws ServletException, IOException { |
|||
// 当前生产认证仅由 JwtAuthenticationFilter 建立;只使用其已校验的会话主体。
|
|||
// 匿名请求继续交给既有 Security 入口,测试注入的非 JWT 主体不新增解析行为。
|
|||
var authentication=org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); |
|||
if(authentication==null || !(authentication.getDetails() instanceof com.mydreamjob.auth.SessionAuthenticationDetails)) { |
|||
chain.doFilter(request,response); return; |
|||
} |
|||
Long id; |
|||
try { id = currentUser.requireUserId(); } |
|||
catch (InsufficientAuthenticationException e) { chain.doFilter(request,response); return; } |
|||
User user; |
|||
try { user = users.findById(id).orElse(null); } |
|||
catch (org.springframework.dao.DataAccessException e) { |
|||
reject(request,response,503,ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); return; |
|||
} |
|||
if (user == null || user.getRole() != User.Role.UNASSIGNED) {chain.doFilter(request,response); return;} |
|||
String uri = request.getRequestURI().substring(request.getContextPath().length()); |
|||
if (allowed(request.getMethod(), uri)) {chain.doFilter(request,response); return;} |
|||
reject(request,response,403,ErrorCode.IDENTITY_SELECTION_REQUIRED); |
|||
} |
|||
|
|||
private void reject(HttpServletRequest request, HttpServletResponse response, int status, ErrorCode error) throws IOException { |
|||
response.setStatus(status); |
|||
response.setContentType("application/json;charset=UTF-8"); |
|||
mapper.writeValue(response.getWriter(), ApiResponse.error(error.getCode(),error.getMessage(), requestIds.get(request))); |
|||
} |
|||
|
|||
boolean allowed(String method, String uri) { |
|||
if ("OPTIONS".equals(method) || paths.match("/actuator/**", uri)) return true; |
|||
if ("POST".equals(method)) return AUTH_POST.contains(uri); |
|||
if (!"GET".equals(method) && !"HEAD".equals(method)) return false; |
|||
// /companies/* 是公开企业 ID 路径,不能误把保留字 me 当作公开主页。
|
|||
if ("/companies/me".equals(uri) || uri.startsWith("/companies/me/")) return false; |
|||
return "/auth/me".equals(uri) || PUBLIC_GET.stream().anyMatch(pattern -> paths.match(pattern, uri)); |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.AssertTrue; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
@Getter |
|||
@Setter |
|||
public class IdentitySelectionRequest { |
|||
public enum Identity { STUDENT, COMPANY } |
|||
|
|||
@NotNull |
|||
private Identity identity; |
|||
@Valid |
|||
private CompanyDetails company; |
|||
|
|||
@com.fasterxml.jackson.annotation.JsonAnySetter |
|||
public void rejectUnknown(String name, Object value) { |
|||
throw new IllegalArgumentException("request contains unsupported fields"); |
|||
} |
|||
|
|||
@AssertTrue(message = "company details are required only for company identity") |
|||
@Schema(hidden = true) |
|||
public boolean isCompanyShapeValid() { |
|||
return identity == null || (identity == Identity.COMPANY ? company != null : company == null); |
|||
} |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
|
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public record IdentitySelectionResponse(Long userId, String role, boolean identitySelectionRequired, |
|||
Long companyId, String onboardingStatus, String accessToken, String refreshToken, |
|||
Long expiresIn, boolean reauthenticationRequired) { } |
|||
@ -0,0 +1,104 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.auth.company.CompanyRegistrationProperties; |
|||
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.company.CompanyOnboardingService; |
|||
import com.mydreamjob.deletion.DeletionStatus; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.deletion.AccountDeletionRepository; |
|||
import jakarta.validation.Validator; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import java.time.Clock; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.util.HashSet; |
|||
import java.util.Set; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class IdentitySelectionService { |
|||
private final UserRepository users; |
|||
private final CompanyRepository companies; |
|||
private final AuthSessionService sessions; |
|||
private final AuditService audit; |
|||
private final AccountDeletionRepository deletions; |
|||
private final CompanyRegistrationProperties properties; |
|||
private final CompanyOnboardingService onboarding; |
|||
private final Validator validator; |
|||
private final Clock clock; |
|||
|
|||
@Transactional |
|||
public IdentitySelectionResponse select(Long userId, String sessionId, IdentitySelectionRequest request, |
|||
AuditContext context) { |
|||
if (request == null || !validator.validate(request).isEmpty()) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
try { |
|||
// 与刷新/密码变更相同:users -> auth_tokens;锁内重新确认会话,关闭 TOCTOU。
|
|||
User user = users.findByIdForUpdate(userId).orElseThrow(InvalidTokenException::new); |
|||
Instant now = clock.instant(); |
|||
if (!Boolean.TRUE.equals(user.getActive()) || !sessions.isSessionActive(userId, sessionId, now)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
if (deletions.findByUserId(userId).filter(d -> d.getStatus() != DeletionStatus.NONE).isPresent()) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
User.Role target = request.getIdentity() == IdentitySelectionRequest.Identity.STUDENT |
|||
? User.Role.STUDENT : User.Role.COMPANY; |
|||
if (user.getRole() != User.Role.UNASSIGNED) { |
|||
if (user.getRole() != target || user.getRoles() == null || !user.getRoles().equals(Set.of(target))) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
Company company = target == User.Role.COMPANY ? companies.findByOwnerUserId(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT)) : null; |
|||
// 不重复更改资料/颁发会话/审计;客户端可使用已有凭证或重新登录。
|
|||
return response(user, company, null, true); |
|||
} |
|||
if (user.getRoles() == null || !user.getRoles().isEmpty() || companies.existsByOwnerUserId(userId)) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
if (target == User.Role.COMPANY && !properties.isEnabled()) { |
|||
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); |
|||
} |
|||
user.setRole(target); |
|||
user.setRoles(new HashSet<>(Set.of(target))); |
|||
users.saveAndFlush(user); |
|||
Company company = null; |
|||
if (target == User.Role.COMPANY) { |
|||
CompanyDetails d = request.getCompany(); |
|||
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName()) |
|||
.logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription()) |
|||
.website(d.getWebsite()).address(d.getAddress()).verified(false).build()); |
|||
} |
|||
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。
|
|||
sessions.revokeAllUserSessions(userId, "IDENTITY_SELECTED", now); |
|||
AuthSessionService.SessionTokens tokens = sessions.createSession(user, now, context); |
|||
audit.logRequired(userId, "IDENTITY_SELECTED", "USER", userId, |
|||
"{\"result\":\"SUCCESS\"}", context, LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE)); |
|||
return response(user, company, tokens, false); |
|||
} catch (DataIntegrityViolationException e) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} catch (DataAccessException e) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); |
|||
} |
|||
} |
|||
|
|||
private IdentitySelectionResponse response(User user, Company company, AuthSessionService.SessionTokens tokens, |
|||
boolean reauthenticate) { |
|||
return new IdentitySelectionResponse(user.getId(), user.getRole().name(), false, |
|||
company == null ? null : company.getId(), company == null ? null : |
|||
(tokens == null ? onboarding.get(user.getId()).onboardingStatus() : "UNVERIFIED"), |
|||
tokens == null ? null : tokens.accessToken(), tokens == null ? null : tokens.refreshToken(), |
|||
tokens == null ? null : AuthService.EXPIRES_IN_SECONDS, reauthenticate); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import jakarta.persistence.*; |
|||
import java.time.LocalDateTime; |
|||
import lombok.*; |
|||
|
|||
@Entity |
|||
@Table(name = "password_setup_challenges") |
|||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor |
|||
public class PasswordSetupChallenge { |
|||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; |
|||
@Column(nullable = false) private Long userId; |
|||
@Column(nullable = false, length = 36) private String sessionId; |
|||
@Column(nullable = false, unique = true, length = 64) private String tokenHash; |
|||
@Column(nullable = false) private LocalDateTime createdAt; |
|||
@Column(nullable = false) private LocalDateTime expiresAt; |
|||
private LocalDateTime consumedAt; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import org.springframework.data.jpa.repository.*; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
public interface PasswordSetupChallengeRepository extends JpaRepository<PasswordSetupChallenge, Long> { |
|||
@Modifying(flushAutomatically = true) |
|||
@Query(""" |
|||
update PasswordSetupChallenge c set c.consumedAt = :now |
|||
where c.userId = :user and c.sessionId = :session and c.tokenHash = :hash |
|||
and c.consumedAt is null and c.expiresAt > :now |
|||
""") |
|||
int consume(@Param("user") Long userId, @Param("session") String sessionId, |
|||
@Param("hash") String hash, @Param("now") LocalDateTime now); |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.auth.dto.PasswordSetupResponse; |
|||
import com.mydreamjob.audit.*; |
|||
import com.mydreamjob.common.exception.*; |
|||
import java.security.SecureRandom; |
|||
import java.time.*; |
|||
import java.util.Base64; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.security.crypto.password.PasswordEncoder; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
/** 短信登录后的首次密码设置;凭证只绑定当前会话,不用会话创建时间推断认证来源。 */ |
|||
@Service @RequiredArgsConstructor |
|||
public class PasswordSetupService { |
|||
private static final SecureRandom RANDOM = new SecureRandom(); |
|||
private final PasswordSetupChallengeRepository challenges; |
|||
private final AuthTokenHasher hasher; |
|||
private final AuthTokenRepository tokens; |
|||
private final PasswordAuthenticationService passwords; |
|||
private final PasswordRateLimiter limiter; |
|||
private final PasswordPolicy policy; |
|||
private final PasswordEncoder encoder; |
|||
private final UserRepository users; |
|||
private final AuthSessionService sessions; |
|||
private final AuditService audit; |
|||
private final Clock clock; |
|||
private final PasswordAuthenticationProperties properties; |
|||
|
|||
@Transactional |
|||
public String issue(User user, String sessionId, Instant now) { |
|||
if (!properties.isEnabled() || user.getPasswordHash() != null) return null; |
|||
byte[] random = new byte[32]; |
|||
RANDOM.nextBytes(random); |
|||
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(random); |
|||
challenges.save(PasswordSetupChallenge.builder().userId(user.getId()).sessionId(sessionId) |
|||
.tokenHash(hash(raw)).createdAt(AuthSessionService.toLocal(now)) |
|||
.expiresAt(AuthSessionService.toLocal(now.plusSeconds(600))).build()); |
|||
return raw; |
|||
} |
|||
|
|||
@Transactional |
|||
public PasswordSetupResponse setup(Long userId, String sessionId, String raw, String newPassword, AuditContext ctx) { |
|||
User user = passwords.lockedUsableUser(userId); |
|||
Instant now = clock.instant(); |
|||
boolean active = tokens.lockSession(userId, sessionId).stream().anyMatch(t -> |
|||
Boolean.FALSE.equals(t.getRevoked()) && t.getConsumedAt() == null |
|||
&& t.getExpiredAt().isAfter(AuthSessionService.toLocal(now))); |
|||
if (!active) |
|||
throw new InvalidTokenException(); |
|||
limiter.checkOperationAllowed(user.getPhone()); |
|||
if (user.getPasswordHash() != null) throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT); |
|||
policy.validateNewPassword(newPassword, user.getPhone(), null, encoder); |
|||
if (raw == null || raw.length() != 43 || challenges.consume(userId, sessionId, hash(raw), |
|||
AuthSessionService.toLocal(now)) != 1) |
|||
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "invalid password setup proof"); |
|||
user.setPasswordHash(encoder.encode(policy.normalize(newPassword))); |
|||
user.setPasswordChangedAt(AuthSessionService.toLocal(now)); |
|||
users.saveAndFlush(user); |
|||
sessions.revokeAllUserSessions(userId, "PASSWORD_SET", now); |
|||
var pair = sessions.createSession(user, now, ctx); |
|||
audit.log(userId, "PASSWORD_SET", "AUTH", null, "First password configured", ctx, |
|||
AuthSessionService.toLocal(now)); |
|||
audit.log(userId, "AUTH_SESSIONS_REVOKED", "AUTH", null, "PASSWORD_SET", ctx, |
|||
AuthSessionService.toLocal(now)); |
|||
return new PasswordSetupResponse(true, false, user.getRole() == User.Role.UNASSIGNED, |
|||
pair.accessToken(), pair.refreshToken(), AuthService.EXPIRES_IN_SECONDS); |
|||
} |
|||
|
|||
private String hash(String raw) { return hasher.hash("password-setup:" + raw); } |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.aliyun.dysmsapi20170525.Client; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse; |
|||
import com.aliyun.teautil.models.RuntimeOptions; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.slf4j.Logger; |
|||
import org.slf4j.LoggerFactory; |
|||
import java.util.Set; |
|||
|
|||
/** SDK 返回 OK 只代表网关受理,不代表手机已收到;禁止自动重试非幂等发送。 */ |
|||
public final class AliyunSmsCodeSender implements SmsCodeSender { |
|||
private static final Logger LOG = LoggerFactory.getLogger(AliyunSmsCodeSender.class); |
|||
private static final Set<String> PURPOSES = Set.of("login", "password-set", "password-reset", "company-register"); |
|||
private final Client client; |
|||
private final String signName; |
|||
private final String templateCode; |
|||
private final int connectTimeoutMs; |
|||
private final int readTimeoutMs; |
|||
|
|||
public AliyunSmsCodeSender(Client client, AliyunSmsProperties properties) { |
|||
properties.validateForSending(); |
|||
this.client = client; |
|||
this.signName = properties.getSignName(); |
|||
this.templateCode = properties.getTemplateCode(); |
|||
this.connectTimeoutMs = properties.getConnectTimeoutMs(); |
|||
this.readTimeoutMs = properties.getReadTimeoutMs(); |
|||
} |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
if (phone == null || !phone.matches("[0-9]{11}") || purpose == null || !PURPOSES.contains(purpose) |
|||
|| code == null || !code.matches("[0-9]{6}")) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid SMS request"); |
|||
} |
|||
SendSmsRequest request = new SendSmsRequest().setPhoneNumbers(phone).setSignName(signName) |
|||
.setTemplateCode(templateCode).setTemplateParam("{\"code\":\"" + code + "\"}"); |
|||
RuntimeOptions runtime = new RuntimeOptions().setAutoretry(false) |
|||
.setConnectTimeout(connectTimeoutMs).setReadTimeout(readTimeoutMs); |
|||
SendSmsResponse response; |
|||
try { |
|||
response = client.sendSmsWithOptions(request, runtime); |
|||
} catch (Exception ignored) { |
|||
if (ignored instanceof InterruptedException) Thread.currentThread().interrupt(); |
|||
// SDK 异常可能包含请求绑定值,既不透传原因也不输出堆栈。
|
|||
LOG.warn("Aliyun SMS gateway call failed"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
if (response == null || response.getBody() == null || response.getStatusCode() == null |
|||
|| response.getStatusCode() != 200 || !"OK".equals(response.getBody().getCode())) { |
|||
LOG.warn("Aliyun SMS gateway did not accept request"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
|
|||
/** 不生成 toString,避免配置对象输出凭据。只有显式 true 才启用。 */ |
|||
@Getter |
|||
@Setter |
|||
@ConfigurationProperties(prefix = "app.sms.aliyun") |
|||
public class AliyunSmsProperties { |
|||
private String enabled = "false"; |
|||
private String accessKeyId; |
|||
private String accessKeySecret; |
|||
private String signName; |
|||
private String templateCode; |
|||
private int connectTimeoutMs = 3000; |
|||
private int readTimeoutMs = 5000; |
|||
|
|||
public boolean explicitlyEnabled() { |
|||
return enabled != null && "true".equalsIgnoreCase(enabled.trim()); |
|||
} |
|||
|
|||
public void validateForSending() { |
|||
if (blank(accessKeyId) || blank(accessKeySecret) || blank(signName) || blank(templateCode) |
|||
|| !templateCode.matches("SMS_[A-Za-z0-9_]+") |
|||
|| connectTimeoutMs < 1000 || connectTimeoutMs > 30000 |
|||
|| readTimeoutMs < 1000 || readTimeoutMs > 30000) { |
|||
// 不使用包含 rejected value 的校验异常,避免绑定密钥进入启动日志。
|
|||
throw new IllegalStateException("SMS configuration incomplete or invalid"); |
|||
} |
|||
} |
|||
|
|||
private static boolean blank(String value) { return value == null || value.isBlank(); } |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.mydreamjob.common; |
|||
|
|||
/** 对外品牌统一入口;不改变包名、数据库、接口路径及部署标识。 */ |
|||
public final class ProductBrand { |
|||
public static final String DISPLAY_NAME = "轻松聘"; |
|||
public static final String API_TITLE = DISPLAY_NAME + " API"; |
|||
public static final String API_DESCRIPTION = DISPLAY_NAME + " - 大学生求职服务平台 API"; |
|||
|
|||
private ProductBrand() { } |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package com.mydreamjob.common.logging; |
|||
|
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
|
|||
/** 只对 AI 经历接口收紧日志元数据,其他接口保持现状。 */ |
|||
public final class SensitiveEndpointLogPolicy { |
|||
private static final String ROOT="/profile/me/work-experience-optimization"; |
|||
private SensitiveEndpointLogPolicy() {} |
|||
public static boolean isWorkExperience(HttpServletRequest request) { |
|||
// servletPath 通常已解码且去掉路径参数;原始 URI 作为前置过滤器和单测的兜底。
|
|||
if (matches(request.getServletPath())) return true; |
|||
String path=request.getRequestURI(); |
|||
String context=request.getContextPath(); |
|||
if(!context.isEmpty() && path.startsWith(context))path=path.substring(context.length()); |
|||
// 同时兼容过滤器单测不设置 contextPath 的标准 /api/v1 请求。
|
|||
if(path.startsWith("/api/v1/"))path=path.substring("/api/v1".length()); |
|||
try { path=org.springframework.web.util.UriUtils.decode(path,java.nio.charset.StandardCharsets.UTF_8); } |
|||
catch(IllegalArgumentException ignored) { /* 非法编码由既有 HTTP/安全链拒绝,不记录异常原文。 */ } |
|||
if(path.startsWith("/api/v1/"))path=path.substring("/api/v1".length()); |
|||
path=path.replaceAll(";[^/]*", ""); |
|||
return matches(path); |
|||
} |
|||
private static boolean matches(String path) { return path!=null&&(path.equals(ROOT)||path.startsWith(ROOT+"/")); } |
|||
public static String canonicalPath() { return ROOT; } |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
package com.mydreamjob.common.logging; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.LoggerContext; |
|||
import ch.qos.logback.classic.turbo.TurboFilter; |
|||
import ch.qos.logback.core.spi.FilterReply; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.slf4j.MDC; |
|||
import org.slf4j.Marker; |
|||
import org.springframework.beans.factory.DisposableBean; |
|||
import org.springframework.beans.factory.InitializingBean; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** AI 请求范围内禁止框架正文/异常诊断与模块对象日志;只保留独立请求元数据日志。 */ |
|||
@Component |
|||
public final class WorkExperienceLogPrivacyFilter extends TurboFilter implements InitializingBean, DisposableBean { |
|||
public static final String CONTEXT_KEY="workExperiencePrivacy"; |
|||
private LoggerContext context; |
|||
@Override public void afterPropertiesSet() { |
|||
if(!(LoggerFactory.getILoggerFactory() instanceof LoggerContext loggerContext)) |
|||
throw new IllegalStateException("AI privacy logging requires supported Logback configuration"); |
|||
context=loggerContext;start();context.addTurboFilter(this); |
|||
} |
|||
@Override public FilterReply decide(Marker marker, Logger logger, Level level, String format, |
|||
Object[] arguments, Throwable throwable) { |
|||
if("true".equals(MDC.get(CONTEXT_KEY))) { |
|||
String name=logger.getName(); |
|||
if(name.startsWith("org.springframework.web.")||name.startsWith("org.springframework.http.") |
|||
||name.startsWith("org.hibernate.") |
|||
||name.startsWith("org.springframework.security.") |
|||
||name.startsWith("com.mydreamjob.resume.optimization."))return FilterReply.DENY; |
|||
} |
|||
return FilterReply.NEUTRAL; |
|||
} |
|||
@Override public void destroy() { |
|||
if(context!=null)context.getTurboFilterList().remove(this); |
|||
stop(); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue