# Offer 进阶模块接口说明 > 轻松聘 (mydreamjob) · Offer 进阶能力 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` > 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。 ## 1. 概述 本模块在基础 Offer(创建/发送/接受/拒绝/洽谈/到期/提醒/时间线)之上,为招聘双方补充 **薪资洽谈(还价)**、**Offer 对比** 与 **企业 Offer 统计** 三组能力: | 能力 | 角色 | 说明 | |---|---|---| | 还价(Counter-Offer) | 候选人发起 · 企业接受/拒绝 | 候选人可对 `SENT/NEGOTIATING` 的 Offer 提出新薪资/入职日期;企业接受后更新 Offer 条款并成交 | | Offer 对比(Compare) | 候选人本人 | 一次性横向比较本人 2-5 个非 DRAFT 的 Offer | | Offer 统计(Statistics) | 企业 | 企业 Offer 状态分布、接受率、平均响应时间看板 | 同时将 **WITHDRAWN(撤销)** 纳入 Offer 状态机:`SENT/NEGOTIATING` 可由企业撤销, 撤销/过期时自动将 PENDING 还价置为 `EXPIRED` 并释放唯一还价槽位。 ## 2. 状态机增量:WITHDRAWN(撤销) 基础状态:`DRAFT / SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED`。 本模块新增 **`WITHDRAWN`(终态,仅企业可触发,通过通用状态接口)**: ```text SENT ──────────(企业 WITHDRAW)──> WITHDRAWN NEGOTIATING ───(企业 WITHDRAW)──> WITHDRAWN ``` 撤销规则: - **可撤销**:仅 `SENT` / `NEGOTIATING`(`PATCH /offers/{id}/status` 传 `{"status":"WITHDRAWN"}`,仅企业角色); - **非法撤销**:`DRAFT / ACCEPTED / REJECTED / EXPIRED` → 409 `INVALID_OFFER_STATUS_TRANSITION`(走状态机校验); - **副效应**:撤销/过期时,该 Offer 所有 PENDING 还价原子置为 `EXPIRED`、`active_key` 置 `NULL` 释放槽位;Offer 自身 `active_key` 置 `NULL` 释放"唯一有效 Offer"槽位; - **历史**:每次撤销写入 `offer_status_history`(from=`SENT/NEGOTIATING`, to=`WITHDRAWN`, operatorRole=`COMPANY`); - **通知**:复用 `NotificationService.publishEvent`,事件类型 `OFFER_WITHDRAWN`,幂等键 `offer-withdrawn-{offerId}`(沿用现有通知机制,未新建链路)。 ## 3. 数据库迁移(V26) 迁移文件:`src/main/resources/db/migration/V26__offer_advance.sql`(兼容 MySQL 8 / H2 MODE=MySQL,未改动 V1-V25)。 **`offers` 表新增字段**(统计与撤销沿用现有字段,无需新增,此处为校验/约束确认): - 无结构破坏:`status`、`active_key`、`expire_at` 等继续复用。 **新增 `offer_counter_offers` 表**(候选人还价记录): | 字段 | 说明 | |---|---| | `id` | 主键自增 | | `offer_id` | 所属 Offer(FK → offers.id) | | `student_id` | 发起还价的候选人(FK → users.id) | | `proposed_salary_min/max` | 提议薪资区间 | | `proposed_onboarding_date` | 提议入职日期 | | `reason` | 说明,≤ 2000 字符 | | `status` | `PENDING / ACCEPTED / REJECTED / EXPIRED`,默认 `PENDING` | | `active_key` | 可空判别列:`PENDING` 时为 `ACTIVE`;`ACCEPTED/REJECTED/EXPIRED` 时为 `NULL` | | `version` | 乐观锁 | | `created_at / updated_at` | 审计时间 | - **唯一约束** `uk_co_active UNIQUE(offer_id, active_key)`:保证同一 Offer 同时最多一个 PENDING 还价; - `active_key` 释放即"槽位释放",释放后可再次发起新还价。 ## 4. 接口清单(6 个 operation / 5 个 path) ### 4.1 发起还价 ``` POST /offers/{id}/counter-offers ``` 权限:**候选人本人**(`offer.candidateId == 当前用户`)。 请求体: | 字段 | 必填 | 约束 | |---|---|---| | `proposedSalaryMin` | 否 | ≥ 0 | | `proposedSalaryMax` | 否 | ≥ 0 | | `proposedOnboardingDate` | 否 | 必须是未来日期 | | `reason` | 否 | ≤ 2000 字符 | ```json { "proposedSalaryMin": 18000, "proposedSalaryMax": 25000, "proposedOnboardingDate": "2026-10-01", "reason": "参考行业水平与个人经验,期望更高薪资" } ``` 业务规则: - 仅 `SENT` / `NEGOTIATING` 可发起(否则 400 `OFFER_NOT_NEGOTIABLE`); - 同一 Offer 已有 PENDING 还价 → 409 `OFFER_COUNTER_OFFER_EXISTS`; - Offer 已过期 → 409 `OFFER_EXPIRED`; - 发起时若 Offer 为 `SENT`,**自动流转**为 `NEGOTIATING`(写历史 + `OFFER_NEGOTIATING` 通知); - 其他账号 → 403(`FORBIDDEN`);Offer 不存在 → 404(`OFFER_NOT_FOUND`)。 响应(HTTP 200): ```json { "code": 200, "message": "ok", "data": { "id": 11, "offerId": 1, "studentId": 100, "proposedSalaryMin": 18000, "proposedSalaryMax": 25000, "proposedOnboardingDate": "2026-10-01", "reason": "...", "status": "PENDING", "version": 0, "createdAt": "...", "updatedAt": "..." }, "requestId": "...", "timestamp": "..." } ``` ### 4.2 还价列表 ``` GET /offers/{id}/counter-offers ``` 权限:候选人本人(其 Offer)或该 Offer 所属企业。 - 按 `createdAt` 升序返回全部还价记录; - 其他账号 → 403;Offer 不存在 → 404。 响应(HTTP 200): ```json { "code": 200, "data": [ { "id": 11, "offerId": 1, "status": "PENDING", "proposedSalaryMin": 18000, ... } ], ... } ``` ### 4.3 接受还价(企业) ``` PATCH /offers/{id}/counter-offers/{coId}/accept ``` 权限:**该 Offer 所属企业**(`company.id == offer.companyId`)。 业务规则: - 还价不存在或不属于该 Offer → 404 `OFFER_COUNTER_OFFER_NOT_FOUND`(8110); - 还价非 PENDING → 409 `OFFER_COUNTER_OFFER_NOT_PENDING`(8111); - Offer 已撤销/过期 → 将还价置为 EXPIRED 并返回 409 `OFFER_COUNTER_OFFER_EXPIRED`(8113); - 接受后:以还价条款更新 Offer 的 `salaryMin / salaryMax / onboardingDate`,将 Offer 流转为 `ACCEPTED`,还价置 `ACCEPTED` 并释放 `active_key`; - 并发保护:乐观锁(`version`),冲突时重读,已在目标状态幂等成功。 响应(HTTP 200): ```json { "code": 200, "data": { "id": 11, "offerId": 1, "status": "ACCEPTED", ... }, ... } ``` ### 4.4 拒绝还价(企业) ``` PATCH /offers/{id}/counter-offers/{coId}/reject ``` 权限:**该 Offer 所属企业**。 业务规则: - 还价不存在/不属于该 Offer → 404 `OFFER_COUNTER_OFFER_NOT_FOUND`(8110); - 还价已拒绝 → 幂等返回(200); - 还价非 PENDING 且非 REJECTED → 409 `OFFER_COUNTER_OFFER_NOT_PENDING`(8111); - Offer 已撤销/过期 → 置 EXPIRED 并返回 409 `OFFER_COUNTER_OFFER_EXPIRED`(8113); - 拒绝后:还价置 `REJECTED`、释放 `active_key`,Offer 保持 `NEGOTIATING`。 响应(HTTP 200): ```json { "code": 200, "data": { "id": 11, "offerId": 1, "status": "REJECTED", ... }, ... } ``` ### 4.5 Offer 对比(候选人) ``` GET /offers/compare?ids=1,2,3 ``` 权限:**候选人本人**;比较的 Offer 必须全部属于当前用户。 查询参数: - `ids`(必填):逗号分隔的 Offer id,支持 **2-5 个**。 参数校验(**非法一律返回 400 / 1001,绝不返回 500**): - `ids` 缺失/空白、含空值(`1,,2`)、含非数字(`abc`)、含非正数(`1,-2`)→ 400; - `ids` 个数 < 2 或 > 5 → 400 `OFFER_COMPARE_TOO_MANY`(8115); - `ids` 存在重复 → 400 `BAD_REQUEST`。 业务规则: - 仅比较**非 DRAFT** 的 Offer(`DRAFT` 不计入); - 任一 Offer 不属于当前用户或未命中(如 DRAFT)→ 403 `OFFER_COMPARE_NOT_OWNER`(8116); - 返回 Offer 视图并附带 `companyName`。 响应(HTTP 200): ```json { "code": 200, "data": [ { "id": 1, "companyName": "示例科技", "title": "后端工程师", "status": "SENT", "salaryMin": 20000, "salaryMax": 30000, "onboardingDate": "2026-09-01", ... }, { "id": 2, "companyName": "示例科技", "status": "NEGOTIATING", ... } ], "requestId": "...", "timestamp": "..." } ``` ### 4.6 Offer 统计(企业) ``` GET /offers/statistics?months=6 ``` 权限:**企业账号**(当前用户绑定企业)。 查询参数: - `months`(可选,默认 `6`):统计回溯月数,1-24;越界按默认 6 处理。 返回: - `totalOffers`:期内 Offer 总数; - `byStatus`:各状态计数(含 DRAFT/SENT/NEGOTIATING/ACCEPTED/REJECTED/EXPIRED/WITHDRAWN,缺失补 0); - `acceptanceRate`:接受率 = `ACCEPTED / (ACCEPTED + REJECTED)`,无数据为 `null`; - `avgResponseHours`:`SENT→ACCEPTED/REJECTED` 平均响应小时(按状态历史计算),无数据为 `null`; - `periodMonths`:实际使用的月数。 响应(HTTP 200): ```json { "code": 200, "data": { "totalOffers": 10, "periodMonths": 6, "byStatus": { "DRAFT": 2, "SENT": 3, "NEGOTIATING": 0, "ACCEPTED": 4, "REJECTED": 1, "EXPIRED": 0, "WITHDRAWN": 0 }, "acceptanceRate": 0.8, "avgResponseHours": 26.5 }, "requestId": "...", "timestamp": "..." } ``` ## 5. 权限矩阵 | 接口 | 候选人本人 | 岗位所属企业 | 其他账号 | 系统 | |---|---|---|---|---| | 发起还价 POST counter-offers | ✅ | 403 | 403 | — | | 还价列表 GET counter-offers | ✅(仅本人的) | ✅(本企业) | 403 | — | | 接受还价 accept | 403 | ✅ | 403 | — | | 拒绝还价 reject | 403 | ✅ | 403 | — | | Offer 对比 compare | ✅(全部属于本人) | 403 | 403 | — | | Offer 统计 statistics | 403 | ✅ | 403 | — | | 撤销 WITHDRAWN | 403 | ✅(`SENT/NEGOTIATING`) | 403 | — | 所有身份一律来自 JWT,绝不信任请求参数中的 `companyId/candidateId`。 ## 6. 错误码(本模块相关) | 错误码 | 枚举 | HTTP | 含义 | |---|---|---|---| | 8109 | `OFFER_COUNTER_OFFER_EXISTS` | 409 | 该 Offer 已有 PENDING 还价 | | 8110 | `OFFER_COUNTER_OFFER_NOT_FOUND` | 404 | 还价不存在/不属于该 Offer | | 8111 | `OFFER_COUNTER_OFFER_NOT_PENDING` | 409 | 还价非 PENDING,无法接受/拒绝 | | 8112 | `OFFER_NOT_WITHDRAWABLE` | 400 | Offer 当前状态不可撤销(预留,实际经状态机返回 8103/409) | | 8113 | `OFFER_COUNTER_OFFER_EXPIRED` | 409 | Offer 已撤销/过期,还价失效 | | 8114 | `OFFER_NOT_NEGOTIABLE` | 400 | Offer 状态不允许发起还价 | | 8115 | `OFFER_COMPARE_TOO_MANY` | 400 | 对比数量需 2-5 个 | | 8116 | `OFFER_COMPARE_NOT_OWNER` | 403 | 存在非本人/非有效 Offer | 复用基础错误码:`OFFER_NOT_FOUND`(8101/404)、`OFFER_EXPIRED`(8104/409)、`INVALID_OFFER_STATUS_TRANSITION`(8103/409)、`FORBIDDEN`(2002/403)、`BAD_REQUEST`(1001/400)。 ## 7. OpenAPI 本模块新增 **6 个 operation / 5 个 path**(绝对路径数),OpenAPI 总路径数由 102 → **107**: - `/offers/{id}/counter-offers`(POST + GET) - `/offers/{id}/counter-offers/{coId}/accept` - `/offers/{id}/counter-offers/{coId}/reject` - `/offers/compare` - `/offers/statistics` > 注意:是 +5 个 path(其中 counter-offers 承载 2 个 operation),**不是 +6 个 path**。