轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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 → 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 字符
{
  "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):

{ "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 → 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):

{ "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。