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.
 
 
 

15 KiB

Offer 管理模块接口说明

轻松聘 (mydreamjob) · Offer 管理基础模块 基础路径:/api/v1 · 除明确标注外全部接口需 Authorization: Bearer <accessToken>

1. 概述

Offer 用于管理企业向求职者发出的录用邀请,覆盖:创建草稿、发送、候选人接受/拒绝/进入洽谈、 到期自动失效、完整状态历史、到期提醒与手动提醒。本轮不包含双向匹配、公平性检测、SHAP、 AI 简历/面试、评分算法、数据看板等模块。

2. 状态机

状态统一命名(大小写敏感):

状态 含义 是否终态
DRAFT 草稿(企业已创建、未发送) 否
SENT 已发送(等待候选人响应) 否
NEGOTIATING 洽谈中 否
ACCEPTED 已接受 是
REJECTED 已拒绝 是
EXPIRED 已过期(定时任务触发) 是

状态流转:

DRAFT ──(企业 SEND)──> SENT ──(候选人)→ NEGOTIATING
                              │──(候选人)→ ACCEPTED
                              │──(候选人)→ REJECTED
                              └──(系统任务)→ EXPIRED
NEGOTIATING ──(候选人)→ ACCEPTED
              ──(候选人)→ REJECTED
              ──(系统任务)→ EXPIRED

禁止规则:

  • ACCEPTED 后再变更为其他状态;
  • REJECTED / EXPIRED 后接受;
  • DRAFT 直接跳转到 ACCEPTED/REJECTED/NEGOTIATING(非法流转,409);
  • 已发送(SENT 及以后)修改 Offer 核心字段(非 DRAFT 一律不允许 PUT,409);
  • 对已过期 Offer 进行接受/拒绝/洽谈/提醒(409 OFFER_EXPIRED)。
  • 对 DRAFT / ACCEPTED / REJECTED Offer 进行手动提醒(400 OFFER_NOT_REMINDABLE)。

到期策略:仅 SENT / NEGOTIATING 由定时任务自动置为 EXPIRED;DRAFT 不自动过期, 但到期后不可再 SEND(返回 409 OFFER_EXPIRED)。

3. 接口清单

3.1 创建 Offer

POST /offers

权限:企业账号(当前用户必须绑定企业,且岗位属于该企业)。

请求字段:

字段 必填 约束
applicationId 是 投递记录必须存在
title 是 ≤ 120 字符
salaryMin / salaryMax 否 ≥ 0;salaryMin ≤ salaryMax
salaryCurrency 否 ≤ 10 字符,默认 CNY
salaryPeriod 否 MONTH / YEAR / HOUR,默认 MONTH
workCity 是 ≤ 80 字符
employmentType 否 FULL_TIME / PART_TIME / INTERN / INTERNSHIP
probationMonths 否 ≥ 0,默认 0
benefits 否 ≤ 2000 字符
description 是 ≤ 5000 字符
expireAt 是 必须晚于当前时间

前置条件(任一不满足则失败):

  • 投递记录存在(404 APPLICATION_NOT_FOUND);
  • 投递的岗位属于当前企业(400 APPLICATION_OWNER_MISMATCH);
  • 投递状态允许发 Offer:SUBMITTED / VIEWED / INTERVIEW;REJECTED/HIRED/WITHDRAWN 不允许(400 OFFER_APPLICATION_NOT_ELIGIBLE);
  • 该投递没有有效 Offer(409 DUPLICATE_OFFER)。

默认状态 DRAFT;创建不发送任何通知。

响应(HTTP 200,业务 code=200):

{
  "code": 200, "message": "ok", "data": { "id": 1, "applicationId": 5, "jobId": 10,
  "companyId": 1, "candidateId": 100, "title": "后端工程师", "salaryMin": 10000,
  "salaryMax": 20000, "salaryCurrency": "CNY", "salaryPeriod": "MONTH", "workCity": "杭州",
  "employmentType": "FULL_TIME", "probationMonths": 3, "benefits": "五险一金",
  "description": "...", "expireAt": "2026-09-01T23:59:59", "status": "DRAFT",
  "version": 0, "createdBy": 10, "updatedBy": 10, "createdAt": "...", "updatedAt": "..." },
  "requestId": "...", "timestamp": "..." }

3.2 查询 Offer 列表

GET /offers?status=SENT&applicationId=5&page=0&size=20

权限:学生(查看自己收到的)或 企业(查看自己发出的)。

  • 学生视图:candidateId = 当前用户;
  • 企业视图:companyId = 当前用户绑定企业;
  • 仅返回当前用户有权限的数据,不可通过参数越权;
  • status、applicationId 可选筛选;
  • 分页:page(默认 0)、size(默认 20,最大 100),按 createdAt 倒序。

响应:

{ "code": 200, "data": { "items": [ ... ], "total": 3, "page": 0, "size": 20, "totalPages": 1 }, ... }

3.3 查看 Offer 详情

GET /offers/{id}

权限:目标候选人、所属企业。其他账号返回 403(FORBIDDEN,不泄露 Offer 是否存在以外的信息)。

3.4 修改 Offer

PUT /offers/{id}

权限:所属企业;仅 DRAFT 状态可修改。SENT 及以后返回 409 OFFER_NOT_EDITABLE。 字段均可选(null 表示不更新),约束与创建一致;salaryMin/salaryMax/expireAt 同样校验。 身份字段(applicationId/jobId/companyId/candidateId)不可修改。

3.5 状态变更

PATCH /offers/{id}/status

请求体:

{ "status": "SENT", "reason": "可选,≤255字符" }
  • 目标状态枚举:SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED;
  • 角色校验:DRAFT→SENT 仅企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 仅候选人; →EXPIRED 仅系统任务(API 调用一律拒绝);
  • 幂等:目标状态与当前一致时直接返回当前 Offer,不产生重复历史;
  • 并发保护:乐观锁(version),冲突时重读——已在目标状态视为幂等成功,否则 409;
  • 每次成功变更写入 offer_status_history(from/to/operator/operatorRole/reason/createdAt/requestId), 更新 updated_at、updated_by、version;
  • 非法流转返回 409 INVALID_OFFER_STATUS_TRANSITION;已过期 Offer 返回 409 OFFER_EXPIRED;
  • status 非法枚举返回 400(校验注解)。

3.6 状态时间线

GET /offers/{id}/timeline

权限:同详情。返回按时间升序的完整状态变更历史:

{ "code": 200, "data": [
  { "fromStatus": null, "toStatus": "DRAFT", "operator": 10, "operatorRole": "COMPANY",
    "reason": "Offer created", "createdAt": "...", "requestId": "..." },
  { "fromStatus": "DRAFT", "toStatus": "SENT", "operator": 10, "operatorRole": "COMPANY", ... }
] }

3.7 手动提醒

POST /offers/{id}/remind

权限:所属企业。仅 SENT / NEGOTIATING Offer 可提醒;过期返回 409 OFFER_EXPIRED, DRAFT / ACCEPTED / REJECTED 返回 400 OFFER_NOT_REMINDABLE。 同一 Offer 的 MANUAL 提醒只创建一次(幂等,重复请求返回已有提醒,不重复通知)。 提醒结果写入 offer_reminders,并通过现有通知模块向候选人推送。

4. 权限矩阵

操作 学生(候选人本人) 企业(岗位所属公司) 其他账号 系统任务
创建 Offer 403 ✅ 403 —
列表 / 详情 / 时间线 ✅(仅自己的) ✅(仅自己公司的) 403 —
修改草稿(PUT) 403 ✅ 403 —
DRAFT→SENT 403 ✅ 403 —
SENT→ACCEPTED/REJECTED/NEGOTIATING ✅(仅本人的) 403 403 —
NEGOTIATING→ACCEPTED/REJECTED ✅(仅本人的) 403 403 —
→EXPIRED 403 403 403 ✅
手动提醒(仅 SENT / NEGOTIATING) 403 ✅ 403 —

安全说明:

  • 所有身份均来自 JWT(auth.getName() = userId),绝不信任请求参数中的 companyId/candidateId;
  • 所有关键操作记录 operator(用户 id)+ operatorRole(COMPANY/CANDIDATE/SYSTEM)到历史表;
  • 不返回候选人联系方式、企业内注等敏感信息;错误响应不泄露堆栈与数据库结构。

5. 通知

复用现有 NotificationService.publishEvent(...)(异步 RabbitMQ + 幂等键去重),未新建通知系统:

事件 触发 接收方 幂等键
创建 DRAFT 创建 无(不通知) —
OFFER_SENT DRAFT→SENT 候选人 offer-sent-{offerId}
OFFER_ACCEPTED / OFFER_REJECTED / OFFER_NEGOTIATING 候选人决策 企业 offer-accepted-{offerId} 等
OFFER_EXPIRED 到期失效 候选人 + 企业 offer-expired-{offerId}
OFFER_REMINDER 3 天 / 1 天 / 手动 候选人 offer-remind-{type}-{offerId}

6. 定时任务

复用项目已有的 Spring Scheduler(@EnableScheduling),未引入新中间件:

任务 周期(可配) 行为
到期失效 app.offer.scheduler.expiry-interval-ms(默认 60s) SENT/NEGOTIATING 且 expire_at < now → EXPIRED
到期提醒 app.offer.scheduler.reminder-interval-ms(默认 60s) 3 天 / 1 天前各发一次

可靠性设计:

  • 每个 Offer 在独立事务(REQUIRES_NEW)中处理,单个失败不破坏其他 Offer、不回滚已完成变更;
  • 幂等:只扫描 SENT/NEGOTIATING;(offer_id, reminder_type) 唯一索引 + exists 检查保证提醒只发一次;
  • 乐观锁(version)防止并发重复处理产生重复历史;
  • 通知发送失败不阻断状态变更。

7. 数据库迁移(V13)

迁移文件:src/main/resources/db/migration/V13__offers.sql(兼容 MySQL 8 / H2 MODE=MySQL)。

新增三张表:

offers(Offer 主表)

  • 字段:id, application_id, job_id, company_id, candidate_id, title, salary_min, salary_max, salary_currency, salary_period, work_city, employment_type, probation_months, benefits, description, expire_at, status, active_offer_key, version, created_by, updated_by, created_at, updated_at
  • 唯一有效 Offer 约束:uk_offers_active UNIQUE (application_id, active_offer_key)。 active_offer_key 为非终态时 ACTIVE、REJECTED/EXPIRED 时 NULL(MySQL 不支持 partial index, 用可空判别列表达"每投递最多一个有效 Offer";REJECTED/EXPIRED 释放槽位后允许重新发 Offer)。
  • 外键:application → applications、job → jobs、company → companies、candidate → users、created_by → users。
  • CHECK:salary_min/salary_max ≥ 0、salary_min ≤ salary_max、probation_months ≥ 0。
  • 索引:(company_id, status)、(candidate_id, status)、(status, expire_at)(任务扫描)、(application_id)、(created_at)。
  • 乐观锁:version BIGINT NOT NULL DEFAULT 0(@Version);审计时间:created_at/updated_at。

offer_status_history(状态历史)

  • 字段:id, offer_id, from_status, to_status, operator_id, operator_role, reason, request_id, created_at。
  • 索引:(offer_id, created_at)、(created_at)。request_id 透传请求链路 ID。

offer_reminders(提醒记录)

  • 字段:id, offer_id, reminder_type, remind_at, sent_at, status, created_at。
  • 唯一:uk_offer_reminders (offer_id, reminder_type)(保证同类型提醒只创建一次)。
  • 索引:(offer_id)、(status)、(remind_at)。

说明:offer_status_history.operator_id 未加外键,允许系统任务写入 0(SYSTEM)而不引用真实用户。

8. Apifox 测试步骤

前置:启动基础设施 → 启动应用(dev profile)→ Apifox 导入 docs/openapi-rc.json 生成接口。

现网 OpenAPI 亦可访问 GET /api/v1/v3/api-docs 导出后导入 Apifox。

测试账号(Mock 短信验证码:应用日志不再输出,本地按 docs/frontend-integration.md §3 的受控方式取得;生产禁止查表):

角色 手机号 用途
ADMIN 13800000001 管理员
STUDENT 13800000002 候选人
COMPANY 13800000003 企业

步骤(按依赖顺序):

  1. 登录企业:POST /auth/sms/send?phone=13800000003&purpose=login → POST /auth/login,取 accessToken(企业 token)。
  2. 登录学生:同上换 13800000002(学生 token)。
  3. 准备投递:企业创建岗位并发布 → 学生投递,记录 applicationId。
  4. 创建 Offer(企业):POST /offers,断言 200、status=DRAFT、时间线含 DRAFT 历史。
  5. 重复创建校验:同一 applicationId 再次 POST /offers → 409 DUPLICATE_OFFER。
  6. 修改草稿(企业):PUT /offers/{id} 修改薪资 → 200。
  7. 发送(企业):PATCH /offers/{id}/status {"status":"SENT"} → 200;学生应收到 OFFER_SENT 通知。
  8. 越权校验:学生 token 访问 PUT /offers/{id} → 403;另一学生 token 访问 GET /offers/{id} → 403。
  9. 已发送不可改:企业 PUT /offers/{id} → 409 OFFER_NOT_EDITABLE。
  10. 候选人响应(学生):PATCH /offers/{id}/status {"status":"ACCEPTED"} → 200;企业收到 OFFER_ACCEPTED 通知。
  11. 幂等校验:重复提交同状态 PATCH ... {"status":"ACCEPTED"} → 200,时间线不新增历史。
  12. 终态保护:再次 {"status":"REJECTED"} → 409 INVALID_OFFER_STATUS_TRANSITION。
  13. 非法流转:新建 Offer 草稿直接 {"status":"ACCEPTED"} → 409;{"status":"INVALID"} → 400。
  14. 提醒:企业对 SENT / NEGOTIATING Offer POST /offers/{id}/remind → 200;重复调用 → 幂等返回;DRAFT / ACCEPTED / REJECTED → 400 OFFER_NOT_REMINDABLE;过期 Offer → 409 OFFER_EXPIRED。
  15. 到期自动失效:将 Offer expireAt 设为过去(或等待定时任务)→ 状态自动变 EXPIRED,时间线含 SYSTEM 记录。

9. 已知限制

  1. 手动提醒限制:同一 Offer 的 MANUAL 提醒仅一次((offer_id, reminder_type) 唯一)。如需"多次提醒"需放开该约束并改为幂等窗口去重。
  2. DRAFT 不过期:草稿到期不自动失效,仅阻止后续 SEND。若产品要求草稿也自动清理,需在状态机中增加 DRAFT→EXPIRED。
  3. 重新发 Offer:REJECTED/EXPIRED 后允许对同一投递重新创建 Offer(符合"每投递最多一个有效 Offer")。
  4. 并发 API 冲突:极端并发下同一 Offer 重复流转会返回 409(幂等仅针对目标状态一致;并发冲突以乐观锁裁决)。
  5. 通知依赖 MQ:app.notification.mq-enabled=false 时通知消费者关闭,通知仅发布到 RabbitMQ(测试环境不落库)。
  6. OpenAPI 快照:docs/openapi-rc.json 由 H2 集成测试上下文导出(基础设施未启动时),server URL 为测试默认值;接入现网后可用 GET /api/v1/v3/api-docs 重新导出。
  7. 薪资隐私:Offer 薪资对候选人可见(Offer 本身就是录用条款)。企业间数据隔离由 companyId 判定,候选人数据隔离由 candidateId 判定。

10. 错误码(新增 81xx)

错误码 枚举 HTTP 含义
8101 OFFER_NOT_FOUND 404 Offer 不存在
8102 DUPLICATE_OFFER 409 该投递已有有效 Offer
8103 INVALID_OFFER_STATUS_TRANSITION 409 非法状态流转
8104 OFFER_EXPIRED 409 Offer 已过期
8105 OFFER_NOT_EDITABLE 409 非 DRAFT 不可修改
8106 OFFER_APPLICATION_NOT_ELIGIBLE 400 投递状态不允许发 Offer
8107 OFFER_REMINDER_DUPLICATE 409 提醒重复(预留)
8108 OFFER_NOT_REMINDABLE 400 当前状态不允许手动提醒