11 KiB
Offer 进阶模块接口说明
职来 (mydreamjob) · Offer 进阶能力 基础路径:
/api/v1· 除明确标注外全部接口需Authorization: Bearer <accessToken>在 Offer 管理基础模块 之上增量开发,未修改任何已验收接口的公共签名。
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(终态,仅企业可触发,通过通用状态接口):
SENT ──────────(企业 WITHDRAW)──> WITHDRAWN
NEGOTIATING ───(企业 WITHDRAW)──> WITHDRAWN
撤销规则:
- 可撤销:仅
SENT/NEGOTIATING(PATCH /offers/{id}/status传{"status":"WITHDRAWN"},仅企业角色); - 非法撤销:
DRAFT / ACCEPTED / REJECTED / EXPIRED→ 409INVALID_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 字符 |
{
"proposedSalaryMin": 18000,
"proposedSalaryMax": 25000,
"proposedOnboardingDate": "2026-10-01",
"reason": "参考行业水平与个人经验,期望更高薪资"
}
业务规则:
- 仅
SENT/NEGOTIATING可发起(否则 400OFFER_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):
{ "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):
{ "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):
{ "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):
{ "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 → 400OFFER_COMPARE_TOO_MANY(8115);ids存在重复 → 400BAD_REQUEST。
业务规则:
- 仅比较非 DRAFT 的 Offer(
DRAFT不计入); - 任一 Offer 不属于当前用户或未命中(如 DRAFT)→ 403
OFFER_COMPARE_NOT_OWNER(8116); - 返回 Offer 视图并附带
companyName。
响应(HTTP 200):
{ "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):
{ "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。