# V41 企业人才发现推荐——详细设计(编码前评审) > 状态:**已实现并通过自动化与真实 MySQL/Docker 黑盒验收**。自动化测试 1461/1461 通过,真实黑盒 `PASS=37 / FAIL=0`。本文是 V41 的唯一设计基线。 ## 1. 目标与边界 ### 1.1 目标 让已认证企业针对自己已发布的岗位,发现**主动开启人才发现**的学生候选人。系统用已批准的岗位画像和学生自愿提供的聚合信号做规则排序,返回数据最小化的候选人卡片。 这是一项“人才发现”能力,不替代投递、面试、Offer 或现有企业候选人看板: - 现有 `/company/candidates/**` 只展示已经投递的候选人; - V41 只展示尚未向当前企业投递、且显式同意被发现的候选人; - V41 不创建投递、不发送消息、不创建邀约,也不让企业获得联系方式或简历文件。 ### 1.2 确认范围 1. 学生人才发现默认关闭,学生可随时自行开启或关闭; 2. 只有 `verified=true` 的 COMPANY,才可对其自有、`PUBLISHED` 的岗位查询; 3. 仅已启用且 ADMIN 审核通过的 V39 岗位画像可以计算推荐; 4. 复用 V39 的规则信号权重:学生偏好 40%、RIASEC 聚合兴趣 35%、四维能力聚合 25%; 5. 已向**当前企业任一岗位**投递过的学生均从候选池排除,包含已撤回或已拒绝的历史投递,避免重复触达; 6. 公司只得到匹配档位与安全概述,不得到 RIASEC、四维能力、候选评分、答卷、答案、题目或原始权重; 7. 本版没有企业私信、邀约、收藏/淘汰反馈、企业端联系方式、学生被动通知、AI 解释或推荐历史快照。 ### 1.3 明确不做 - 不改变 V35/V36 的测评题库、提交、重测、有效期或审计语义; - 不改变 V38 的候选评分写入、排名和企业评分卡; - 不改变 V39 的“学生找岗位”推荐及其岗位画像审核流; - 不改变现有 `resumeVisibility` / `contactVisible` 的含义; - 不按性别、年龄、籍贯、手机号、邮箱、学校排名、姓名或头像作为排序信号; - 不生成“录用概率”“胜任结论”“适合/不适合”之类确定性结论。 ## 2. 现状核查与设计前提 | 项目 | 现状 | V41 处理 | |---|---|---| | 最新迁移 | V38 为企业评价官方回复 | V41 建议下一编号 `V39__enterprise_talent_discovery.sql` | | V39 岗位画像 | `job_recommendation_profiles`,仅 `enabled=true` 且 `APPROVED` 可用 | 作为岗位侧唯一的结构化推荐输入 | | 学生偏好 | `student_preferences` 含城市、行业、岗位类型、用工类型、远程与薪资期望 | 只用于内部偏好匹配,不原样返回企业 | | 兴趣测评 | V35 RIASEC 聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 | | 能力测评 | V36 完整且未过期的聚合结果 | 仅可作为内部聚合信号,绝不下发维度分 | | 简历隐私 | `resumeVisibility` 只控制简历可见性,默认 `PRIVATE` | 不复用为人才发现授权;V41 另设明确授权 | | 企业候选人看板 | 仅已投递的 `application` | V41 只发现未向本企业投递过的候选人 | `resumeVisibility` 与“是否允许被人才发现”是不同目的的授权:前者控制简历,后者控制是否进入企业发现候选池。两者不可混用或隐式推导。 ## 3. 角色、权限与隐私 ### 3.1 权限矩阵 | 能力 | STUDENT | COMPANY(未认证) | COMPANY(认证且岗位所属) | ADMIN | 未登录 | |---|---:|---:|---:|---:|---:| | 查询/更新本人人才发现开关 | ✓ | 403/2002 | 403/2002 | 403/2002 | 401 | | 查询岗位人才推荐 | 403/2002 | 403/2002 | ✓ | 403/2002 | 401 | 所有身份从 `SecurityContext.getAuthentication().getName()` 的 JWT subject 推导。请求中不得接受 `companyId`、`studentUserId`、`candidateId` 或任何越权身份参数。 ### 3.2 候选人可见性规则 一个学生进入某企业某岗位的候选池,必须同时满足: 1. 用户角色为 `STUDENT`,账号未进入注销终态; 2. 存在 `student_talent_discovery_preferences` 且 `enabled=true`;无记录一律视为关闭; 3. 存在可用于候选卡的基础学生档案; 4. 与当前公司不存在任何历史 `applications` 记录; 5. 当前 COMPANY 已认证,目标岗位归属该 COMPANY 且为 `PUBLISHED`; 6. 目标岗位有 `APPROVED + enabled=true` 的 V39 岗位画像。 关闭开关后,后续查询立即不再返回该学生;不删除其档案、投递、测评、评分或审计历史。V41 首版不保存企业查询历史与候选人曝光历史,避免把“被浏览”固化为可追踪个人行为数据。 ### 3.3 响应白名单 企业候选卡仅允许: ```json { "candidateKey": "tdc_opaque_token", "candidateLabel": "开放人才候选人", "city": "合肥", "education": "本科", "graduationYear": 2027, "skills": ["Java", "Spring Boot"], "fitBand": "HIGH", "summary": "岗位画像与候选人公开资料存在较高匹配度", "signalsAvailable": ["PREFERENCE", "RIASEC", "ABILITY"] } ``` `candidateKey` 是服务端签名的、与公司和岗位绑定的短期不透明标识,只用于前端列表稳定 key;它不等于 `studentUserId`,也不能在本版换取详情、联系方式或简历。 企业响应禁止出现: - `studentUserId`、用户 ID、简历 ID、手机号、邮箱、住址、简历 URL; - 姓名、头像、出生日期、性别、身份证明、学校排名; - 测评 attempt/result ID、维度分、原始分、题目、答案、答案键、解析、评分规则; - `candidate_dimension_scores` 明细、候选综合分、排名、百分位; - 画像原始权重、能力期望、内部规则版本、SQL/审计数据。 学生端开关响应只返回 `enabled`、`version`、`createdAt`、`updatedAt`;不返回企业查询过该学生的记录。 ## 4. 接口契约(提案) ### 4.1 学生人才发现偏好 ```http GET /profile/me/talent-discovery PUT /profile/me/talent-discovery ``` `GET` 无记录时返回默认值,不写库: ```json { "enabled": false, "version": 0 } ``` `PUT` 请求: ```json { "enabled": true, "expectedVersion": 0 } ``` - `enabled` 必填; - `expectedVersion` 可选,传入时必须与当前版本一致; - 首次创建仅接受 `expectedVersion` 缺失或 `0`; - 并发冲突复用现有乐观锁错误 `9201`,不新增无必要错误码; - 成功只写 `TALENT_DISCOVERY_PREFERENCE_UPDATED` 审计,details 仅有开关状态、版本、requestId 与时间。 ### 4.2 企业岗位人才推荐 ```http GET /companies/me/jobs/{jobId}/talent-recommendations?page=0&size=20 ``` 参数规范:`page` 从 0 起;`size` 为 1–50;`jobId > 0`。无候选人返回 HTTP 200: ```json { "mode": "NO_ELIGIBLE_CANDIDATE", "items": [], "total": 0, "page": 0, "size": 20, "totalPages": 0, "message": "暂未发现已授权且满足基础条件的候选人" } ``` 目标岗位缺少可用画像时也返回 HTTP 200,但明确: ```json { "mode": "UNAVAILABLE", "items": [], "message": "该岗位尚无审核通过的推荐画像" } ``` 不得把空候选池或不可用画像伪装成评分为 0 的推荐列表。 ### 4.3 预计 OpenAPI 新增 2 个唯一 path / 3 个 operation(学生 GET/PUT 共用同一 path): | 方法 | 路径 | 角色 | |---|---|---| | GET | `/profile/me/talent-discovery` | STUDENT | | PUT | `/profile/me/talent-discovery` | STUDENT | | GET | `/companies/me/jobs/{jobId}/talent-recommendations` | 已认证 COMPANY,且岗位归属本人 | 以当前 159 paths / 196 operations 为基线,预计更新到 **161 paths / 199 operations**。三个 operation 只形成两个唯一 URL path;实际以运行时 `/v3/api-docs` 和静态 `openapi-rc.json` 的对账结果为准。 ## 5. 数据模型与迁移 ### 5.1 新迁移 建议新增、且只新增: ```text V39__enterprise_talent_discovery.sql ``` 不修改 V1–V38 已执行迁移;不向 `candidate_dimension_scores` 写入任何数据。 ### 5.2 `student_talent_discovery_preferences` | 字段 | 类型/约束 | 含义 | |---|---|---| | `id` | BIGINT PK | 主键 | | `student_user_id` | BIGINT NOT NULL UNIQUE,FK `users(id)` | 仅本人一条授权记录 | | `enabled` | TINYINT NOT NULL DEFAULT 0 | 人才发现显式授权 | | `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁 | | `created_at` | DATETIME NOT NULL | 创建时间 | | `updated_at` | DATETIME NOT NULL | 最后更新 | 索引:`(enabled, student_user_id)`。关闭的记录不参与候选查询;不存在记录等效关闭。 不存:公司 ID、岗位 ID、企业浏览记录、联系方式、简历路径、测评细节、候选评分或算法分。 ## 6. 规则计算与数据一致性 ### 6.1 capturedNow 企业一次查询在服务起点生成唯一 `capturedNow`。同一次计算的能力有效期、候选过滤与输出必须复用该值,避免边界时刻同页结果不一致。 ### 6.2 信号选择 对每位候选人仅读取以下最小聚合数据: | 信号 | 有效条件 | 不可用处理 | |---|---|---| | 偏好 P | 可解析的城市、行业、岗位类型、用工类型、远程或薪资偏好 | 该信号不计入分母 | | 兴趣 I | 最新 `HOLLAND_RIASEC`、`COMPLETED`,按 `completed_at DESC, attempt_id DESC` 取一条,六维完整且 0–100 | 不计入分母 | | 能力 A | 最新 `WORKPLACE_ABILITY`、`COMPLETED`、`valid_until > capturedNow`,四维完整、每维 6 题、总计 24 题且 0–100 | 不计入分母 | 禁止取历史最高分。JSON 损坏、缺维、越界、空值、重复维或无效时间都只能令该信号不可用,不能造成 HTTP 500。 ### 6.3 分数与排序 仅当至少一项可用信号存在时,计算: ```text fitScore = (P × 40 + I × 35 + A × 25) / 可用信号权重之和 ``` 中间与最终结果使用 `BigDecimal`,最终保留两位、`HALF_UP`。`fitScore` 仅用于后端排序与映射档位,不对企业返回数值。 | 匹配档位 | 内部区间 | 企业展示 | |---|---:|---| | HIGH | ≥ 75 | 较高匹配度 | | MEDIUM | 60–74.99 | 中等匹配度 | | BASIC | < 60 | 基础匹配度 | 排序:`fitScore DESC` → 候选档案最后更新时间 DESC → `student_user_id DESC`。最后一项只用于稳定排序,不对外暴露。 若没有任何有效信号,候选人不进入 V41 首版列表;不使用学校、姓名、性别等字段做“兜底排序”。 ### 6.4 安全概述 `summary` 固定来自模板,只允许说明总体匹配等级。`signalsAvailable` 仅披露存在的类别 `PREFERENCE` / `RIASEC` / `ABILITY`,不披露任何单项高低、维度或权重。详细理由与可解释反馈明确留给后续“推荐理由和解释”模块评审。 ## 7. 查询、性能与并发 ### 7.1 查询策略 V41 第一版采用**实时计算、全局排序后分页**:不能先在数据库分页、只对当前页计算分数再排序,否则第 2 页候选人可能比第 1 页分数更高,分页结果会错误。 1. 先用一次批量资格查询读取全部候选 ID 与必要的基础档案字段:授权开启、`STUDENT`、档案存在、账号未处于注销限制状态、与当前公司不存在任何历史投递;历史投递通过 `NOT EXISTS` 或批量集合排除,不能只排除当前岗位,也不能按候选人逐条查询。 2. 按固定批次(建议每批 500 个 ID)批量读取学生偏好、最新可用 RIASEC、最新有效能力结果和展示档案;候选 ID 必须使用参数绑定,禁止拼接 SQL。 3. 服务层以内存 Map 汇总每个候选人的信号,计算所有候选人的 `fitScore`,再按 `fitScore DESC`、档案 `updatedAt DESC`、`studentUserId DESC` 做全局排序,最后执行 `page/size` 切片。 这是第一版实时方案;当候选池达到规模阈值,应升级为异步推荐索引/快照,但不能以页内排序牺牲当前版本的全局排序正确性。查询需预留真实 MySQL `EXPLAIN` 验收:`student_user_id`、`definition_id`、`completed_at`、`valid_until` 与授权开关索引均应参与过滤。 ### 7.2 并发 人才推荐列表是只读、实时计算,无持久化推荐记录,故无创建推荐的并发写入问题。学生开关通过 `@Version + expectedVersion` 控制并发,两个竞争更新只能有一个成功;另一个返回 `409/9201`。 查询过程中学生关闭开关的竞态允许下一次请求生效;同一已开始的只读事务可在当前页继续返回当时快照,不延迟或阻塞用户操作。 ## 8. 错误语义与审计 ### 8.1 错误 | 场景 | HTTP / 业务码 | |---|---| | 非认证 | 401 | | 角色不符 | 403 / 2002 | | 企业不存在 | 404 / 5001 | | 岗位不存在/非发布 | 复用既有岗位错误码 | | 岗位不属于当前企业 | 403 / 2002(不泄露额外信息) | | 企业未认证 | 403 / 2002 | | page/size/jobId 非法 | 400 / 1001 | | 学生开关版本冲突 | 409 / 9201 | 本版不新增 V41 专属错误码。`UNAVAILABLE` 和 `NO_ELIGIBLE_CANDIDATE` 是成功响应模式,不是错误。 ### 8.2 审计 仅学生开关写审计: ```json { "enabled": true, "version": 2, "requestId": "..." } ``` `GET` 推荐列表不写逐候选人或逐企业浏览审计,避免形成候选人追踪链路。不得审计候选人清单、matchScore、信号细节、学生 ID、联系方式、简历地址、答案、IP、User-Agent、token 或密码。 ## 9. 安全与公平性门禁 1. 学生显式同意是进入企业发现池的前置条件;默认关闭; 2. 企业必须认证、岗位必须属于自己且公开发布; 3. 推荐不是自动淘汰或录用依据,企业仍需在后续合法招聘流程中自行评估; 4. 不使用受保护特征或联系方式排序; 5. 任何测评聚合仅在学生自愿进入发现池后作为内部排序信号;企业侧不见测评详情; 6. 若后续加入解释、邀约、反馈或模型训练,必须单列模块并重新做隐私、风控和产品评审。 ## 10. 建议代码结构 ```text com.mydreamjob.talentdiscovery StudentTalentDiscoveryPreference StudentTalentDiscoveryPreferenceRepository TalentDiscoveryService TalentDiscoveryCandidateSource # 批量只读数据来源 TalentDiscoveryScoreCalculator # 纯函数,无数据库访问 TalentDiscoveryController dto/ ``` 允许复用 V39 的安全 JSON 解析与岗位画像校验,但不得复制 Controller 中的权限逻辑或把 V41 放入 V39 Controller 而扩大其职责。 ## 11. 测试与验收方案 ### 11.1 自动化测试 1. `TalentDiscoveryScoreCalculatorTest` - 三信号权重与缺项重归一化; - 最新低分优先于历史高分; - 能力恰在 `capturedNow` 过期; - 缺维、非法 JSON、越界均不 500; - 不使用姓名、性别、学校或联系方式作为输入。 2. `TalentDiscoveryServiceTest` - 默认关闭不入池;开启后入池;关闭后立即排除; - 当前企业任一历史投递均排除;其他企业投递不排除; - 未认证企业、跨企业岗位、无审核画像、未发布岗位; - 候选卡白名单;无数据模式正确;N+1 查询次数固定。 3. `TalentDiscoveryControllerTest` - STUDENT/COMPANY/ADMIN/未登录矩阵; - 分页校验;不泄露 `studentUserId`、联系方式、测评字段或分数; - 学生开关 PUT 乐观锁冲突。 4. `TalentDiscoveryPersistenceIntegrationTest` - V39 迁移、唯一约束、FK、默认关闭、`@Version` 冲突; - 批量查询与已投递排除在 H2 MySQL 模式工作。 ### 11.2 真实 MySQL/Docker 黑盒 1. V39 迁移成功且无影响 V1–V38; 2. 默认关闭、开启、关闭后的立即可见性变化; 3. 已认证/未认证企业、岗位所有权、PUBLISHED 与已审核画像门禁; 4. 同企业历史投递排除、其他企业投递不误排; 5. 最新低分优先、能力过期、无有效信号和无候选人模式; 6. 响应脱敏、审计不写候选清单; 7. `EXPLAIN` 与常数批量查询验证; 8. RabbitMQ 队列终态与 V39/V40 关键接口回归。 ## 12. 文档、OpenAPI 与发布门禁 编码后更新: - `docs/enterprise-talent-discovery-api.md`; - `docs/frontend-integration.md`; - `docs/backend-development-roadmap.md`; - `docs/openapi-rc.json`(V41 实际 161 paths / 199 operations,两个唯一 path 承载三个 operation); - `docs/verify_openapi.py`。 发布门禁已完成:专项与全量自动化测试通过(1461/1461),OpenAPI 静态/运行时均为 161 paths / 199 operations,真实 MySQL/Docker 黑盒 `PASS=37 / FAIL=0`。黑盒覆盖 V39 迁移、授权状态机、最新版测评选择、同企业历史投递排除、全局排序后分页、权限与脱敏、队列终态及 V39 回归。 ## 13. 编码前确认结论 负责人已确认本设计的业务范围。进入编码前还必须按本设计完成只读核查:最新 Flyway 为 V38、V39 岗位画像审核字段存在、认证企业判断可用、学生档案字段可满足候选卡白名单;如任一项与当前代码冲突,停止并报告,不自行改变接口、迁移号、隐私边界或排序口径。