Browse Source

chore: initialize zhilai backend project

feat/message-recall
Zhang Shun 2 months ago
commit
b01616d9a7
  1. 38
      .gitignore
  2. 35
      README.md
  3. 8
      backend-infra/.env.example
  4. 5
      backend-infra/.gitignore
  5. 27
      backend-infra/README.md
  6. 61
      backend-infra/compose.yaml
  7. 73
      docs/ERD-v0.2-draft.md
  8. 135
      docs/ERD.md
  9. 71
      docs/V2__business_schema.sql.draft
  10. 178
      docs/acceptance-week1.md
  11. 180
      docs/api-v0.1.md
  12. 142
      docs/api-v0.2-draft.md
  13. 271
      docs/candidate-score-card-api.md
  14. 134
      docs/company-public-api.md
  15. 295
      docs/company-verification-api.md
  16. 183
      docs/frontend-integration.md
  17. 490
      docs/interview-api.md
  18. 124
      docs/job-review-api.md
  19. 149
      docs/job-search-api.md
  20. 186
      docs/match-api.md
  21. 309
      docs/offer-api.md
  22. 1
      docs/openapi-rc.json
  23. 213
      docs/resume-api.md
  24. 89
      docs/review-moderation-api.md
  25. 471
      docs/review-weight-api.md
  26. 174
      docs/smoke-test.sh
  27. 32
      docs/verify_openapi.py
  28. 56
      mydreamjob-server/.gitignore
  29. 215
      mydreamjob-server/README.md
  30. 131
      mydreamjob-server/docs/interview-api.md
  31. 169
      mydreamjob-server/pom.xml
  32. 11
      mydreamjob-server/src/main/java/com/mydreamjob/MyDreamjobApplication.java
  33. 52
      mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java
  34. 152
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  35. 29
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  36. 227
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  37. 32
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java
  38. 45
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistory.java
  39. 12
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java
  40. 10
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationCreateRequest.java
  41. 13
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationStatusRequest.java
  42. 32
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditContext.java
  43. 56
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java
  44. 22
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java
  45. 86
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
  46. 88
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  47. 52
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java
  48. 20
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  49. 57
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
  50. 84
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java
  51. 20
      mydreamjob-server/src/main/java/com/mydreamjob/auth/LoginRequest.java
  52. 56
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java
  53. 33
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  54. 178
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  55. 79
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  56. 14
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  57. 88
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
  58. 41
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversation.java
  59. 12
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversationRepository.java
  60. 80
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEvent.java
  61. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEventRepository.java
  62. 103
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecord.java
  63. 31
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  64. 193
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  65. 48
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRule.java
  66. 11
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRuleRepository.java
  67. 46
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
  68. 124
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  69. 42
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationScheduler.java
  70. 93
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationService.java
  71. 173
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriter.java
  72. 73
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java
  73. 104
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  74. 9
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchScoreField.java
  75. 48
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRule.java
  76. 11
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRuleRepository.java
  77. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/CompanyMatchRuleRequest.java
  78. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/ScoreRequest.java
  79. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/StudentMatchRuleRequest.java
  80. 96
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java
  81. 64
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardRepository.java
  82. 241
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java
  83. 28
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateFeedbackSummary.java
  84. 22
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateMatchSignals.java
  85. 23
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidatePercentileResponse.java
  86. 24
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreCardResponse.java
  87. 18
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendItem.java
  88. 17
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendResponse.java
  89. 9
      mydreamjob-server/src/main/java/com/mydreamjob/career/CareerService.java
  90. 82
      mydreamjob-server/src/main/java/com/mydreamjob/common/api/ApiResponse.java
  91. 33
      mydreamjob-server/src/main/java/com/mydreamjob/common/api/PageResponse.java
  92. 35
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/BusinessException.java
  93. 129
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  94. 20
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/FileUploadExceptionHandler.java
  95. 190
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  96. 37
      mydreamjob-server/src/main/java/com/mydreamjob/common/health/HealthController.java
  97. 66
      mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java
  98. 64
      mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestLoggingFilter.java
  99. 62
      mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java
  100. 111
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

38
.gitignore

@ -0,0 +1,38 @@
# Local agent/workbench state
.claude/
.workbuddy/
__pycache__/
xlsx-analysis/node_modules/
docx_qa/
# Java/Maven build and runtime artifacts
mydreamjob-server/target/
mydreamjob-server/logs/
mydreamjob-server/*.log
mydreamjob-server/*.pid
mydreamjob-server/resumes/
mydreamjob-server/resume-exports/
# Local secrets and environment files
**/.env
**/.env.*
!**/.env.example
mydreamjob-server/src/main/resources/application-dev.yml
mydreamjob-server/src/main/resources/application-prod.yml
# IDE and OS files
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
*.bak
# Local acceptance output
acceptance-*.log
acceptance-*.out.log
acceptance-*.err.log

35
README.md

@ -0,0 +1,35 @@
# 职来(mydreamjob)
本目录只包含职来项目,不包含青核校园项目。
## 目录结构
- `mydreamjob-server/`:Spring Boot 后端源码、测试和 Flyway 迁移
- `backend-infra/`:Docker Compose 基础设施(MySQL、Redis、RabbitMQ)
- `docs/`:OpenAPI、接口文档、验收脚本和数据模型文档
- `product-docs/`:产品方案、功能设计表、UI 规范和架构图
- `scripts/`:黑盒验收、调试、文档处理和环境脚本
- `xlsx-analysis/`:产品功能表分析工具
- `docx_qa/`:文档质量检查输出目录
## 后端开发
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn test
```
## 基础设施
```powershell
cd C:\Users\15642\Desktop\zhilai\backend-infra
docker compose --env-file .env up -d
```
## 相关项目边界
青核校园 React Native 项目位于:
`C:\Users\15642\Desktop\青核\collegeCli`
不要将青核项目代码提交到本仓库。

8
backend-infra/.env.example

@ -0,0 +1,8 @@
# Copy to .env and replace every placeholder locally. Never commit .env.
MYSQL_ROOT_PASSWORD=replace-with-local-secret
MYSQL_PASSWORD=replace-with-local-secret
MYSQL_USER=mydreamjob
RABBITMQ_USER=mydreamjob_dev
RABBITMQ_PASS=replace-with-local-secret
RABBITMQ_VHOST=/
JWT_SECRET=replace-with-a-random-secret-at-least-256-bits

5
backend-infra/.gitignore

@ -0,0 +1,5 @@
# backend-infra environment variables (contains secrets)
.env
# Docker volumes data
# (volumes are not in the filesystem, but good to be safe)

27
backend-infra/README.md

@ -0,0 +1,27 @@
# 职来本地后端依赖
此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。
## 启动
```powershell
docker compose up -d
```
## 连接信息
| 服务 | 地址 | 数据库/说明 |
|---|---|---|
| MySQL 8.4 | `127.0.0.1:3307` | 数据库:`mydreamjob`;账号密码见本地 `.env` |
| Redis 7.4 | `127.0.0.1:6379` | 启用 AOF 持久化 |
| RabbitMQ 3.13 | `127.0.0.1:5672` | 管理界面:`127.0.0.1:15672` (账号密码见本地 `.env`,禁止使用 guest) |
## 常用命令
```powershell
docker compose ps
docker compose logs -f
docker compose down
```
不要执行 `docker compose down -v`,除非确认需要删除本地数据库数据。

61
backend-infra/compose.yaml

@ -0,0 +1,61 @@
services:
mysql:
image: mysql:8.4
container_name: mydreamjob-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: mydreamjob
MYSQL_USER: mydreamjob
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
TZ: Asia/Shanghai
ports:
- "127.0.0.1:3307:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
redis:
image: redis:7.4-alpine
container_name: mydreamjob-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 12
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: mydreamjob-rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST}
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 15s
timeout: 10s
retries: 12
start_period: 30s
volumes:
mysql_data:
redis_data:
rabbitmq_data:

73
docs/ERD-v0.2-draft.md

@ -0,0 +1,73 @@
# 职来数据模型 v0.2(第 1 天草案)
本轮只新增业务表,不修改现有 `users`、`sms_verification_codes`、`audit_logs`、`auth_tokens` 表。
## companies
| 字段 | 类型 | 约束 |
| --- | --- | --- |
| `id` | BIGINT | PK、自增 |
| `owner_user_id` | BIGINT | FK `users.id`、唯一、非空 |
| `name` | VARCHAR(120) | 非空 |
| `logo_url` | VARCHAR(500) | 可空 |
| `description` | TEXT | 可空 |
| `industry` | VARCHAR(80) | 可空 |
| `website` | VARCHAR(255) | 可空 |
| `address` | VARCHAR(255) | 可空 |
| `verified` | TINYINT(1) | 默认 0 |
| `created_at` / `updated_at` | DATETIME | 非空 |
## student_profiles
| 字段 | 类型 | 约束 |
| --- | --- | --- |
| `id` | BIGINT | PK、自增 |
| `user_id` | BIGINT | FK `users.id`、唯一、非空 |
| `real_name` | VARCHAR(50) | 可空 |
| `bio` | TEXT | 可空 |
| `location` | VARCHAR(100) | 可空 |
| `education` | VARCHAR(100) | 可空 |
| `school` | VARCHAR(150) | 可空 |
| `graduation_year` | SMALLINT | 可空 |
| `resume_url` | VARCHAR(500) | 可空 |
| `skills_json` | JSON | 可空 |
| `created_at` / `updated_at` | DATETIME | 非空 |
## jobs
| 字段 | 类型 | 约束 |
| --- | --- | --- |
| `id` | BIGINT | PK、自增 |
| `company_id` | BIGINT | FK `companies.id`、非空 |
| `title` | VARCHAR(120) | 非空 |
| `description` | TEXT | 非空 |
| `requirements` | TEXT | 可空 |
| `city` | VARCHAR(80) | 非空 |
| `employment_type` | VARCHAR(30) | 非空 |
| `salary_min` / `salary_max` | INT | 可空,不能为负 |
| `status` | VARCHAR(20) | 默认 `DRAFT` |
| `published_at` | DATETIME | 可空 |
| `created_at` / `updated_at` | DATETIME | 非空 |
索引:`company_id,status`、`status,created_at`、`city,status`。
## applications
| 字段 | 类型 | 约束 |
| --- | --- | --- |
| `id` | BIGINT | PK、自增 |
| `job_id` | BIGINT | FK `jobs.id`、非空 |
| `student_user_id` | BIGINT | FK `users.id`、非空 |
| `cover_letter` | TEXT | 可空 |
| `status` | VARCHAR(20) | 默认 `SUBMITTED` |
| `created_at` / `updated_at` | DATETIME | 非空 |
唯一约束:`job_id,student_user_id`,防止重复投递。
## 第 1 天不实现的内容
- 聊天与消息表
- 评分、评价、证据上传
- AI 推荐
- 真实短信供应商接入
- 管理端审核页面

135
docs/ERD.md

@ -0,0 +1,135 @@
# 职来 (mydreamjob) 数据库 ERD 设计
> 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线
## 实体关系图 (ERD)
```
┌──────────────┐ ┌──────────────┐
│ users │1 *│ user_roles │
│──────────────│──────▶│──────────────│
│ id (PK) │ │ user_id (FK) │
│ phone │ │ role (Enum) │
│ email │ └──────────────┘
│ nickname │
│ avatar_url │ ┌──────────────┐ ┌──────────────┐
│ password_hash│ │sms_vrf_codes │ │ audit_logs │
│ role (Enum) │ │──────────────│ │──────────────│
│ is_active │ │ id (PK) │ │ id (PK) │
│ is_verified │ │ phone │ │ user_id (FK) │
│ created_at │ │ code │ │ action │
│ updated_at │ │ purpose │ │ entity_type │
└──────────────┘ │ attempts │ │ entity_id │
│ │ max_attempts │ │ details │
│1 │ expired_at │ │ ip_address │
│ │ used │ │ user_agent │
│ │ created_at │ │ request_id │
│ └──────────────┘ │ created_at │
│ └──────────────┘
│
│1 ┌──────────────┐
└───────────────▶│ auth_tokens │
│──────────────│
│ id (PK) │
│ user_id (FK) │
│ token_hash │
│ token_type │
│ expired_at │
│ revoked │
│ refresh_hash │
│ created_at │
└──────────────┘
```
## 实体说明
### 1. users (用户)
核心用户表,支持学生、企业、管理员三种角色。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| phone | VARCHAR(20) | UNIQUE, NOT NULL | 手机号(登录主键) |
| email | VARCHAR(100) | NULLABLE | 邮箱 |
| nickname | VARCHAR(50) | NULLABLE | 昵称 |
| avatar_url | VARCHAR(500) | NULLABLE | 头像URL |
| password_hash | VARCHAR(255) | NULLABLE | BCrypt 密码哈希 |
| role | VARCHAR(30) | NOT NULL | 主角色: STUDENT/COMPANY/ADMIN |
| is_active | TINYINT(1) | DEFAULT 1 | 是否启用 |
| is_verified | TINYINT(1) | DEFAULT 0 | 是否已认证 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 2. user_roles (用户角色关联)
多对多关系,支持用户拥有多个角色。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| user_id | BIGINT | FK → users.id, PK | 用户ID |
| role | VARCHAR(30) | Enum, PK | 角色: STUDENT/COMPANY/ADMIN |
### 3. sms_verification_codes (短信验证码)
短信验证码记录,用于登录/注册验证。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| phone | VARCHAR(20) | NOT NULL | 手机号 |
| code | VARCHAR(10) | NOT NULL | 验证码 |
| purpose | VARCHAR(50) | NOT NULL | 用途: login/register 等 |
| attempts | INT | DEFAULT 0 | 错误尝试次数 |
| max_attempts | INT | DEFAULT 5 | 最大尝试次数 |
| expired_at | DATETIME | NOT NULL | 过期时间 |
| used | TINYINT(1) | DEFAULT 0 | 是否已使用 |
| created_at | DATETIME | NOT NULL | 创建时间 |
### 4. audit_logs (审计日志)
系统操作审计记录,追踪关键业务行为。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| user_id | BIGINT | FK → users.id | 操作用户ID |
| action | VARCHAR(50) | NOT NULL | 操作类型: LOGIN/LOGOUT/SEND等 |
| entity_type | VARCHAR(50) | NULLABLE | 实体类型 |
| entity_id | BIGINT | NULLABLE | 实体ID |
| details | TEXT | NULLABLE | 详细描述 |
| ip_address | VARCHAR(45) | NULLABLE | 客户端IP |
| user_agent | VARCHAR(500) | NULLABLE | 浏览器UA |
| request_id | VARCHAR(32) | NULLABLE | 请求追踪ID |
| created_at | DATETIME | NOT NULL | 创建时间 |
### 5. auth_tokens (认证Token)
JWT Token 管理表,支持 Token 吊销和刷新。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AUTO_INCREMENT | 主键 |
| user_id | BIGINT | FK → users.id, NOT NULL | 用户ID |
| token_hash | VARCHAR(255) | NOT NULL | Token Hash (SHA-256) |
| token_type | VARCHAR(20) | DEFAULT 'ACCESS' | ACCESS/REFRESH |
| expired_at | DATETIME | NOT NULL | 过期时间 |
| revoked | TINYINT(1) | DEFAULT 0 | 是否已吊销 |
| refresh_token_hash | VARCHAR(255) | NULLABLE | 刷新Token Hash |
| created_at | DATETIME | NOT NULL | 创建时间 |
## 后续规划(Week 2+)
以下实体在 Week 1 不实现,仅列出设计意图:
| 实体 | 说明 | 关联 |
|------|------|------|
| companies (企业) | 企业信息、认证状态 | users.role=COMPANY |
| jobs (岗位) | 招聘信息、薪资、地点 | companies.id |
| applications (投递) | 投递记录、状态流转 | users.id, jobs.id |
| ratings (评分) | 企业评分、评价 | users.id(评分者), companies.id(被评) |
| evidence (证据) | 证明材料、截图 | users.id, ratings.id |
| conversations (会话) | 聊天会话、消息 | users.id, jobs.id |
## 索引策略
- `users.phone` / `users.email`: 唯一索引,用于登录查询
- `sms_verification_codes.phone, created_at`: 复合索引,用于查找最近验证码
- `audit_logs.user_id, created_at`: 复合索引,用于用户操作历史查询
- `audit_logs.created_at`: 单列索引,用于时间范围查询
- `auth_tokens.user_id, revoked`: 复合索引,用于用户Token管理

71
docs/V2__business_schema.sql.draft

@ -0,0 +1,71 @@
-- Flyway V2 draft: 企业、学生资料、岗位、投递
-- 设计确认后再移动到 src/main/resources/db/migration/ 并执行。
CREATE TABLE companies (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_user_id BIGINT NOT NULL,
name VARCHAR(120) NOT NULL,
logo_url VARCHAR(500),
description TEXT,
industry VARCHAR(80),
website VARCHAR(255),
address VARCHAR(255),
verified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_companies_owner UNIQUE (owner_user_id),
CONSTRAINT fk_companies_owner FOREIGN KEY (owner_user_id) REFERENCES users(id),
INDEX idx_companies_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE student_profiles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
real_name VARCHAR(50),
bio TEXT,
location VARCHAR(100),
education VARCHAR(100),
school VARCHAR(150),
graduation_year SMALLINT,
resume_url VARCHAR(500),
skills_json JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_student_profiles_user UNIQUE (user_id),
CONSTRAINT fk_student_profiles_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
company_id BIGINT NOT NULL,
title VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
requirements TEXT,
city VARCHAR(80) NOT NULL,
employment_type VARCHAR(30) NOT NULL,
salary_min INT,
salary_max INT,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
published_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_jobs_company FOREIGN KEY (company_id) REFERENCES companies(id),
INDEX idx_jobs_company_status (company_id, status),
INDEX idx_jobs_status_created (status, created_at),
INDEX idx_jobs_city_status (city, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE applications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
job_id BIGINT NOT NULL,
student_user_id BIGINT NOT NULL,
cover_letter TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_applications_job_student UNIQUE (job_id, student_user_id),
CONSTRAINT fk_applications_job FOREIGN KEY (job_id) REFERENCES jobs(id),
CONSTRAINT fk_applications_student FOREIGN KEY (student_user_id) REFERENCES users(id),
INDEX idx_applications_student_status (student_user_id, status),
INDEX idx_applications_job_status (job_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

178
docs/acceptance-week1.md

@ -0,0 +1,178 @@
# 职来 (mydreamjob) Week 1 验收文档
> 日期: 2026-08-04 | 范围: 第 1 周后端基线 | 状态: ✅ 通过
## 验收标准
### A. 工程与质量基线
| 验收项 | 状态 | 说明 |
|--------|------|------|
| Maven 项目结构 | ✅ | Java 17, Spring Boot 3.5.16 |
| 分层目录 | ✅ | config, common/api, common/exception, common/logging, common/health, auth, audit |
| 依赖配置 | ✅ | JPA, Redis, AMQP, Flyway, OpenAPI, JWT, Validation, Actuator |
| 统一响应格式 | ✅ | ApiResponse<T> 包装,code/message/data/requestId/timestamp |
| 全局异常处理 | ✅ | GlobalExceptionHandler |
| 请求 ID | ✅ | RequestIdFilter (X-Request-Id) |
| 结构化日志 | ✅ | 含 requestId 的日志模式 |
| 环境变量配置 | ✅ | application.yml 使用 ${ENV_VAR} 读取敏感配置 |
| 配置模板 | ✅ | application-dev.example.yml |
| .gitignore | ✅ | 排除 .env, target, IDE 文件 |
### B. 数据与安全基线
| 验收项 | 状态 | 说明 |
|--------|------|------|
| ERD 文档 | ✅ | docs/ERD.md |
| Flyway V1 迁移 | ✅ | base_schema.sql + seed_data.sql |
| JWT 鉴权 | ✅ | JwtTokenProvider, JwtAuthenticationFilter |
| 安全配置 | ✅ | SecurityConfig (CORS, CSRF 关闭, 状态less) |
| 角色枚举 | ✅ | STUDENT, COMPANY, ADMIN |
| 审计事件骨架 | ✅ | AuditService |
| Mock 验证码 | ✅ | SmsVerificationService (限流/锁定/过期) |
| Mock 登录 | ✅ | AuthController + AuthService |
### C. 本地基础设施
| 验收项 | 状态 | 说明 |
|--------|------|------|
| MySQL (3307) | ✅ | compose.yaml 配置 |
| Redis (6379) | ✅ | compose.yaml 配置 |
| RabbitMQ (5672/15672) | ✅ | compose.yaml 配置 + 管理界面 |
| 端口绑定 | ✅ | 所有端口绑定 127.0.0.1 |
### D. 文档
| 验收项 | 状态 | 说明 |
|--------|------|------|
| README.md | ✅ | 项目说明、启动步骤、环境变量 |
| docs/ERD.md | ✅ | 数据库实体关系设计 |
| docs/api-v0.1.md | ✅ | API 接口清单、错误码、请求/响应示例 |
| docs/acceptance-week1.md | ✅ | 本文件 |
## 测试报告
### mvn clean verify 结果
```
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
| 测试类 | 数量 | 状态 |
|--------|------|------|
| ApiResponseTest | 10 | ✅ |
| AuthServiceTest | 3 | ✅ |
| ErrorCodeTest | 2 | ✅ |
| JwtTokenProviderTest | 4 | ✅ |
| SmsVerificationServiceTest | 2 | ✅ |
### 接口实测结果
| 接口 | 方法 | 预期 | 实际 |
|------|------|------|------|
| /api/v1/health | GET | 200 OK | ✅ |
| /api/v1/info | GET | 200 OK | ✅ |
| /api/v1/auth/sms/send | POST | 200 OK | ✅ |
| /api/v1/auth/login | POST | 201 Created | ✅ |
| /api/v1/auth/me | GET | 403 (无token) / 200 (有token) | ✅ |
| /api/v1/v3/api-docs | GET | 200 OK | ✅ |
| /api/v1/swagger-ui/index.html | GET | 200 OK | ✅ |
| /actuator/health | GET | 200 OK | ✅ |
### 实测命令
```bash
# 健康检查
curl http://localhost:8080/api/v1/health
# 应用信息
curl http://localhost:8080/api/v1/info
# 发送验证码
curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login"
# Mock 登录(从日志获取验证码)
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}'
# 当前用户信息
curl http://localhost:8080/api/v1/auth/me \
-H "Authorization: Bearer <accessToken>"
# Swagger UI
http://localhost:8080/api/v1/swagger-ui/index.html
```
## 基础设施状态
| 服务 | 地址 | 状态 |
|------|------|------|
| MySQL 8.4 | 127.0.0.1:3307 | ✅ healthy |
| Redis 7.4 | 127.0.0.1:6379 | ✅ healthy |
| RabbitMQ 3.13 | 127.0.0.1:5672 | ✅ healthy |
| RabbitMQ Management | 127.0.0.1:15672 | ✅ healthy |
## 启动步骤
### 1. 启动基础设施
```bash
cd C:/Users/15642/Desktop/zhilai/backend-infra
docker compose up -d
```
### 2. 编译与测试
```bash
cd C:/Users/15642/Desktop/zhilai/mydreamjob-server
mvn clean verify
```
### 3. 启动应用
```bash
mvn spring-boot:run -Dspring-boot.run.profiles=dev \
-Dspring-boot.run.jvmArguments="\
-Dspring.datasource.password=<DB_PASSWORD> \
-Dspring.datasource.username=mydreamjob \
-Dspring.datasource.url=jdbc:mysql://127.0.0.1:3307/mydreamjob?... \
-Dapp.jwt.secret=<JWT_SECRET>"
```
## 测试账号
| 角色 | 手机号 | 用途 |
|------|--------|------|
| ADMIN | 13800000001 | 管理员测试 |
| STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 |
> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取
## 已知限制
1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信
2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架
3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+)
4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping)
5. **Swagger UI 路径**: 需访问 `/api/v1/swagger-ui/index.html`(含 context path)
6. **Testcontainers**: 未引入(Docker 环境限制),Spring Boot 上下文测试被 @Disabled
## 风险项
| 风险 | 等级 | 说明 |
|------|------|------|
| Docker Desktop 未启动 | 高 | 需先启动 Docker Desktop 才能运行 compose |
| MySQL 3306 端口冲突 | 中 | 本地已有 MySQL 服务占用 3306,compose 使用 3307 |
| Redis 未安装 | 中 | 需依赖 Docker 或手动安装 Redis |
| Spring Boot 3.5.16 新特性 | 低 | @MockBean 在 WebMvcTest 中与 SecurityFilterChain 有兼容性问题 |
## 待办事项 (Week 2+)
- [ ] 企业模块: companies 表及 CRUD
- [ ] 岗位模块: jobs 表、搜索、筛选
- [ ] 投递模块: applications 表、状态流转
- [ ] 聊天模块: conversations 表、WebSocket/STOMP
- [ ] 评分模块: ratings 表、证据上传
- [ ] Token 刷新接口
- [ ] 密码重置功能
- [ ] RabbitMQ 消费者实现
- [ ] Testcontainers 集成测试

180
docs/api-v0.1.md

@ -0,0 +1,180 @@
# mydreamjob API v0.1 接口清单
> 版本: 0.1.0 | 更新日期: 2026-08-03 | 基础路径: `http://localhost:8080/api/v1`
## 认证模块 `/api/v1/auth`
### 1. 发送短信验证码(Mock)
```
POST /api/v1/auth/sms/send
Query: phone=13800000001, purpose=login
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": { "message": "验证码已发送(Mock)" },
"requestId": "a1b2c3d4e5f6g7h8",
"timestamp": "2026-08-03T10:00:00Z"
}
```
**限制:**
- 60秒内同一手机号只能发送1次
- 5次错误后锁定5分钟
- Mock环境验证码打印到日志
### 2. Mock 登录
```
POST /api/v1/auth/login
Content-Type: application/json
```
**请求体:**
```json
{
"phone": "13800000001",
"code": "123456",
"purpose": "login"
}
```
**响应示例:**
```json
{
"code": 201,
"message": "created",
"data": {
"userId": 1,
"phone": "13800000001",
"nickname": "管理员",
"role": "ADMIN",
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc...",
"expiresIn": 86400
},
"requestId": "a1b2c3d4e5f6g7h8",
"timestamp": "2026-08-03T10:00:00Z"
}
```
### 3. 当前用户信息
```
GET /api/v1/auth/me
Authorization: Bearer <accessToken>
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"phone": "13800000001",
"email": "admin@mydreamjob.com",
"nickname": "管理员",
"role": "ADMIN",
"active": true,
"verified": true,
"createdAt": "2026-08-03T10:00:00Z"
},
"requestId": "a1b2c3d4e5f6g7h8",
"timestamp": "2026-08-03T10:00:00Z"
}
```
## 系统模块 `/api/v1`
### 4. 健康检查
```
GET /api/v1/health
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"status": "UP",
"components": {
"db": { "status": "UP", "detail": { "database": "MySQL" } },
"redis": { "status": "UP" },
"diskSpace": { "status": "UP" },
"rabbitmq": { "status": "UP" }
}
},
"requestId": "a1b2c3d4e5f6g7h8",
"timestamp": "2026-08-03T10:00:00Z"
}
```
### 5. 应用信息
```
GET /api/v1/info
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": {
"application": "mydreamjob-server",
"version": "0.0.1-SNAPSHOT",
"javaVersion": "17.0.18",
"uptimeSeconds": 120,
"springProfile": "dev"
},
"requestId": "a1b2c3d4e5f6g7h8",
"timestamp": "2026-08-03T10:00:00Z"
}
```
### 6. 详细健康检查
```
GET /api/v1/health/details
```
返回 Actuator 健康端点的完整信息,包括各依赖服务状态。
## 错误码说明
| 错误码 | 含义 | 场景 |
|--------|------|------|
| 1000 | success | 成功 |
| 1001 | bad request | 请求参数错误 |
| 1002 | internal server error | 服务器内部错误 |
| 2001 | unauthorized | 未认证 |
| 2002 | forbidden | 无权限 |
| 2003 | token expired | Token 已过期 |
| 2004 | token invalid | Token 无效 |
| 2005 | invalid credentials | 凭证错误 |
| 3001 | sms code sent | 验证码已发送 |
| 3002 | sms code expired | 验证码已过期 |
| 3003 | sms code invalid | 验证码错误 |
| 3004 | sms code attempts exceeded | 验证码尝试次数超限 |
| 3005 | sms rate limited | 短信发送频率限制 |
| 4001 | user not found | 用户不存在 |
| 4002 | user already exists | 用户已存在 |
| 9002 | rate limited | 系统限流 |
## Swagger/OpenAPI 文档
```
http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs
```
## 已知限制 (Week 1)
1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信
2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能
4. 无 Token 刷新接口(Week 2+)
5. 无密码修改/重置功能
6. RabbitMQ 未实现消息监听器(仅队列定义)
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

142
docs/api-v0.2-draft.md

@ -0,0 +1,142 @@
# 职来后端 API v0.2(第 1 天草案)
> 目标:形成“企业发布岗位—学生浏览—学生投递—企业处理”的最小业务闭环。
> 本文件是开发前契约,接口实现完成后再同步到 Apifox。
基础路径:`/api/v1`
## 角色约定
| 角色 | 权限 |
| --- | --- |
| STUDENT | 查看已发布岗位、维护自己的资料、投递岗位、查看自己的投递 |
| COMPANY | 维护企业资料、创建和管理自己公司的岗位、处理自己岗位的投递 |
| ADMIN | 预留后台管理权限,本轮不实现管理端页面 |
除岗位公开列表和详情外,本轮接口均要求 `Authorization: Bearer <accessToken>`。
## 个人资料
### `GET /profile/me`
返回当前学生的资料;没有资料时返回空对象,不自动创建业务字段。
### `PUT /profile/me`
请求示例:
```json
{
"realName": "张三",
"bio": "Java 后端开发",
"location": "杭州",
"education": "本科",
"school": "示例大学",
"graduationYear": 2027,
"resumeUrl": "https://cdn.example.com/resume/1.pdf",
"skills": ["Java", "Spring Boot", "MySQL"]
}
```
### `GET /companies/me`
返回当前企业账号绑定的企业资料。
### `PUT /companies/me`
请求字段:`name`、`logoUrl`、`description`、`industry`、`website`、`address`。
## 岗位
### `POST /jobs`
仅 `COMPANY` 可调用。
```json
{
"title": "Java 后端工程师",
"description": "负责服务端业务开发",
"requirements": "熟悉 Spring Boot",
"city": "杭州",
"employmentType": "FULL_TIME",
"salaryMin": 8000,
"salaryMax": 15000
}
```
新岗位默认状态为 `DRAFT`。
### `PUT /jobs/{jobId}`
仅岗位所属企业可修改草稿或已发布岗位。
### `PATCH /jobs/{jobId}/status`
请求示例:
```json
{ "status": "PUBLISHED" }
```
允许状态:`DRAFT`、`PUBLISHED`、`CLOSED`。
### `GET /jobs/{jobId}`
返回岗位详情。已关闭岗位仍允许查看详情,但不可新投递。
### `GET /jobs`
查询参数:
| 参数 | 说明 |
| --- | --- |
| `keyword` | 标题、描述关键词 |
| `city` | 工作城市 |
| `employmentType` | 用工类型 |
| `salaryMin` | 最低薪资 |
| `salaryMax` | 最高薪资 |
| `page` | 从 0 开始,默认 0 |
| `size` | 默认 20,最大 50 |
默认只返回 `PUBLISHED` 岗位,并按发布时间倒序。
## 投递
### `POST /jobs/{jobId}/applications`
仅 `STUDENT` 可调用。
```json
{ "coverLetter": "希望加入贵公司。" }
```
同一学生对同一岗位只能投递一次。
### `GET /applications/me`
学生查看自己的投递记录,支持 `status`、`page`、`size`。
### `GET /company/applications`
企业查看自己公司岗位收到的投递,支持 `jobId`、`status`、`page`、`size`。
### `PATCH /applications/{applicationId}/status`
仅岗位所属企业可调用。
```json
{ "status": "INTERVIEW" }
```
允许状态:`SUBMITTED`、`VIEWED`、`INTERVIEW`、`REJECTED`、`HIRED`。
## 统一错误验收
- 未登录:`401`
- 无角色权限:`403`
- 参数错误:`400`
- 重复投递:`409`
- 岗位不存在:`404`
- 非法状态流转:`409`
所有响应继续使用现有 `ApiResponse` 和 `PageResponse` 格式。

271
docs/candidate-score-card-api.md

@ -0,0 +1,271 @@
# 求职者评分卡 API
## 概述
求职者评价聚合与评分卡模块,提供求职者(STUDENT)查看自己的评价聚合数据、评分趋势和百分位排名。
## 权限
- 未登录:HTTP 401(code=401)
- STUDENT:HTTP 200
- COMPANY:HTTP 403(code=2002)
- ADMIN:HTTP 403(code=2002)
所有接口只允许当前登录的 STUDENT 查询自己的数据,不接受通过 URL 参数查询其他用户。
## 接口列表
### 1. GET /api/v1/profile/me/candidate-score-card
查询求职者评分卡。
**响应示例(有数据):**
```json
{
"code": 200,
"message": "ok",
"data": {
"candidateId": 10001,
"status": "OK",
"overallScore": null,
"sampleCount": 3,
"feedbackSummary": {
"totalCount": 3,
"averageRating": 4.33,
"ratingDistribution": {
"1": 0,
"2": 0,
"3": 1,
"4": 0,
"5": 2
},
"tags": [
{"name": "沟通良好", "count": 2}
]
},
"matchSignals": {
"candidateInterestScore": {
"average": 82.50,
"sampleCount": 2
},
"hrSuitabilityScore": {
"average": 76.00,
"sampleCount": 1
}
},
"unsupportedDimensions": [
"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"
],
"generatedAt": "2026-08-14T12:00:00Z"
}
}
```
**响应示例(无数据):**
```json
{
"code": 200,
"message": "ok",
"data": {
"candidateId": 10001,
"status": "INSUFFICIENT_DATA",
"overallScore": null,
"sampleCount": 0,
"feedbackSummary": {
"totalCount": 0,
"averageRating": null,
"ratingDistribution": {
"1": 0, "2": 0, "3": 0, "4": 0, "5": 0
},
"tags": []
},
"matchSignals": {
"candidateInterestScore": {"average": null, "sampleCount": 0},
"hrSuitabilityScore": {"average": null, "sampleCount": 0}
},
"unsupportedDimensions": [
"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"
],
"generatedAt": "2026-08-14T12:00:00Z"
}
}
```
**错误响应:**
- 未登录:401(code=401)
- 非 STUDENT:403(code=2002)
### 2. GET /api/v1/profile/me/candidate-score-trend
查询评分趋势。
**参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| months | int | 否 | 6 | 统计月数,范围 1-24 |
**响应示例:**
```json
{
"code": 200,
"message": "ok",
"data": {
"candidateId": 10001,
"months": 6,
"items": [
{
"month": "2026-03",
"feedbackCount": 2,
"averageRating": 4.50,
"ratingScore": 90.00
}
]
}
}
```
**错误响应:**
- 未登录:401(code=401)
- 非 STUDENT:403(code=2002)
- months < 1 或 > 24:400(code=1001)
**说明:**
- 趋势时间范围:`months=6` 表示当前月份和前 5 个自然月,起始时间使用当前月份的第一天
- 按 `candidate_feedback.created_at` 的年月分组,结果按 `yyyy-MM` 升序
- `ratingScore = averageRating / 5 * 100`,保留两位小数
- 无数据时返回 `items: []`(没有数据的月份不返回空月份条目)
### 3. GET /api/v1/profile/me/candidate-percentile
查询评分百分位。
**响应示例(有数据):**
```json
{
"code": 200,
"message": "ok",
"data": {
"candidateId": 10001,
"status": "OK",
"scoreType": "FEEDBACK_RATING",
"score": 86.67,
"percentile": 72.50,
"cohortSize": 38,
"sampleCount": 3,
"minimumSampleCount": 3
}
}
```
**响应示例(样本不足):**
```json
{
"code": 200,
"message": "ok",
"data": {
"candidateId": 10001,
"status": "INSUFFICIENT_DATA",
"scoreType": "FEEDBACK_RATING",
"score": null,
"percentile": null,
"cohortSize": 0,
"sampleCount": 1,
"minimumSampleCount": 3
}
}
```
**错误响应:**
- 未登录:401(code=401)
- 非 STUDENT:403(code=2002)
## 百分位计算公式
**cohort 定义:**
- `candidate_feedback` 按 `student_user_id` 分组
- `COUNT(*) >= 3` 的求职者进入 cohort
**score 计算:**
- `score = AVG(rating) / 5 * 100`
**百分位计算:**
- `percentile = COUNT(other.score < current.score) / (cohortSize - 1) * 100`
- 只统计严格低于当前分数的求职者
- 最高分为 100.00
- 最低分为 0.00
- 同分不互相计入
- 使用 BigDecimal,保留两位小数,HALF_UP
**INSUFFICIENT_DATA 条件:**
- 当前用户反馈数 < 3
- cohort 总人数 < 2
## 不支持的评分维度
当前评分卡仅覆盖基于反馈的评分聚合,以下 8 个维度尚未实现,始终返回为 `unsupportedDimensions`:
- EDUCATION(教育背景)
- INTERNSHIP(实习经历)
- ABILITY(能力评分)
- COMPLIANCE(合规性)
- STABILITY(稳定性)
- GROWTH(成长潜力)
- INTEGRITY(诚信评分)
- ACTIVITY(求职活跃度)
`overallScore` 在 8 个维度全部实现前固定为 `null`。
## 数据来源
| 数据 | 来源表 |
|------|--------|
| 反馈总数、平均评分、评分分布 | candidate_feedback |
| 标签聚合 | candidate_feedback.tags(JSON) |
| 求职者意愿分、HR 适配分 | application_match_records |
| 趋势 | candidate_feedback.created_at |
| 百分位 | candidate_feedback 全量聚合 |
## null 字段策略
项目全局配置 `spring.jackson.default-property-inclusion=non_null`,null 字段默认不序列化。
以下关键字段使用 `@JsonInclude(JsonInclude.Include.ALWAYS)` 标注,无数据时仍显式返回 `null`:
- `overallScore`(评分卡)
- `averageRating`(feedbackSummary)
- `candidateInterestScore.average`(匹配信号)
- `hrSuitabilityScore.average`(匹配信号)
- `score`(百分位)
- `percentile`(百分位)
## 查询次数
评分卡三个接口的实际 SQL 查询次数:
| 接口 | 查询次数 | 说明 |
|------|----------|------|
| GET /profile/me/candidate-score-card | 4 条 SQL | 1 反馈摘要(COUNT+AVG合并) + 1 评分分布 + 1 标签 + 2 匹配信号(candidateInterestScore + hrSuitabilityScore) |
| GET /profile/me/candidate-score-trend | 1 条 SQL | 按月聚合趋势 |
| GET /profile/me/candidate-percentile | 3 条 SQL | cohort 人数 + 当前分数 + 排名(仅当样本>=3 且 cohort>=2 时执行全部 3 条) |
所有查询均针对单个 `studentUserId`,无 N+1 问题。
## 数据脱敏
- 不返回 `candidate_feedback.content`
- 不返回 `companyUserId`
- 不返回 `applicationId`
- 不返回其他求职者的任何数据
- 不暴露内部审核字段

134
docs/company-public-api.md

@ -0,0 +1,134 @@
# 企业公开主页 API
## 接口列表
### 1. GET /api/v1/companies/{companyId}
查询企业公开详情。匿名可访问。
**仅返回 6 个公开字段:** id, name, logoUrl, description, industry, verified
**不返回:** ownerUserId, website, address, createdAt, updatedAt, 审核信息
#### 响应 200
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"name": "XX科技有限公司",
"logoUrl": "https://cdn.example.com/logo.png",
"description": "专注于企业级软件开发...",
"industry": "IT",
"verified": true
}
}
```
#### 错误响应
| 场景 | HTTP | code | message |
|------|------|------|---------|
| 企业不存在 | 404 | 5001 | company not found |
---
### 2. GET /api/v1/companies/{companyId}/jobs
查询企业公开岗位列表。匿名可访问。
仅返回该企业 `status=PUBLISHED` 的岗位。
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 校验 |
|------|------|------|--------|------|
| page | int | 否 | 0 | >= 0 |
| size | int | 否 | 20 | 1–50 |
| sort | String | 否 | latest | latest / salary / relevance |
#### 排序规则
| sort 值 | 排序 |
|---------|------|
| latest(默认) | createdAt DESC, id DESC |
| salary | salaryMax DESC, NULL 最后, id DESC |
| relevance | createdAt DESC, id DESC(本期按 latest 兜底) |
#### 响应 200
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"id": 1,
"title": "Java开发工程师",
"description": "...",
"requirements": "...",
"city": "北京",
"employmentType": "FULL_TIME",
"salaryMin": 15000,
"salaryMax": 25000,
"status": "PUBLISHED",
"publishedAt": "2026-08-01T10:00:00",
"createdAt": "2026-08-01T09:00:00",
"company": {
"id": 1,
"name": "XX科技有限公司",
"logoUrl": "https://cdn.example.com/logo.png",
"verified": true
}
}
],
"total": 5,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
#### 空结果(企业存在但无 PUBLISHED 岗位)
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [],
"total": 0,
"page": 0,
"size": 20,
"totalPages": 0
}
}
```
#### 错误响应
| 场景 | HTTP | code | message |
|------|------|------|---------|
| 企业不存在 | 404 | 5001 | company not found |
| page < 0 | 400 | 1001 | bad request |
| size < 1 或 > 50 | 400 | 1001 | bad request |
| sort 非法 | 400 | 1001 | bad request |
---
## 兼容性说明
| 端点 | 行为 |
|------|------|
| GET /companies/me | 完全不变(需登录) |
| POST /companies/me | 完全不变(需登录) |
| PUT /companies/me | 完全不变(需登录) |
| GET /companies/{companyId} | 新增,匿名可访问 |
| GET /companies/{companyId}/jobs | 新增,匿名可访问 |
| GET /jobs | 完全不变 |
| GET /jobs/search | 完全不变 |
| GET /jobs/public/{jobId} | 完全不变 |

295
docs/company-verification-api.md

@ -0,0 +1,295 @@
# 企业认证接口文档
## 概述
企业认证模块支持企业提交认证材料,管理员审核通过或拒绝。认证状态包括 PENDING、APPROVED、REJECTED、RESUBMITTED。
## 认证状态枚举
| 状态 | 说明 |
|------|------|
| PENDING | 待审核 |
| APPROVED | 已通过(终态) |
| REJECTED | 已拒绝 |
| RESUBMITTED | 重新提交(待审核) |
## 状态流转规则
```
PENDING ──> APPROVED (终态)
PENDING ──> REJECTED
REJECTED ──> RESUBMITTED ──> PENDING(重新进入审核)
```
APPROVED 为终态,不可再流转。
---
## 接口列表
### 1. 企业提交认证
**请求方法**: `POST /companies/me/verification`
**请求头**:
```
Authorization: Bearer {jwt}
Content-Type: application/json
```
**请求体**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| materialsJson | String | 是 | 认证材料 JSON(营业执照URL等) |
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": {
"id": 1,
"companyId": 10,
"status": "PENDING",
"submittedBy": 1,
"materialsJson": "{\"license\":\"url\"}",
"submittedAt": "2026-08-13T10:00:00",
"version": 0
}
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | 1001 | 缺少 materialsJson |
| 403 | 2002 | 非 COMPANY 角色 |
| 409 | 8502 | 已有待审核的认证申请 |
| 409 | 8503 | 已通过认证,不可重复提交 |
**权限规则**: 仅 COMPANY 角色可调用,企业身份从 JWT 获取。
---
### 2. 企业查询认证状态
**请求方法**: `GET /companies/me/verification`
**请求头**:
```
Authorization: Bearer {jwt}
```
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": {
"id": 1,
"companyId": 10,
"status": "APPROVED",
"submittedBy": 1,
"reviewerId": 100,
"decisionReason": "材料齐全",
"submittedAt": "2026-08-13T10:00:00",
"decidedAt": "2026-08-13T11:00:00",
"version": 1
}
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 403 | 2002 | 非 COMPANY 角色 |
| 404 | 8501 | 未提交过认证 |
**权限规则**: 仅 COMPANY 角色可调用,只能查看自己的认证。
---
### 3. 管理员查询认证列表
**请求方法**: `GET /admin/companies/verifications`
**请求头**:
```
Authorization: Bearer {jwt}
```
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | String | 否 | 按状态筛选 (PENDING/APPROVED/REJECTED/RESUBMITTED) |
| from | ISO DateTime | 否 | 提交时间起始 |
| to | ISO DateTime | 否 | 提交时间截止 |
| page | int | 否 | 页码(默认 0) |
| size | int | 否 | 每页条数(默认 20,最大 100) |
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": {
"items": [],
"total": 0,
"page": 0,
"size": 20,
"totalPages": 0
}
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 403 | 2002 | 非 ADMIN 角色 |
**权限规则**: 仅 ADMIN 角色可调用。
---
### 4. 管理员查看认证详情
**请求方法**: `GET /admin/companies/verifications/{id}`
**请求头**:
```
Authorization: Bearer {jwt}
```
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": {
"id": 1,
"companyId": 10,
"status": "PENDING",
"submittedBy": 1,
"materialsJson": "{\"license\":\"url\"}",
"submittedAt": "2026-08-13T10:00:00"
}
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 403 | 2002 | 非 ADMIN 角色 |
| 404 | 8501 | 认证记录不存在 |
**权限规则**: 仅 ADMIN 角色可调用。
---
### 5. 管理员审核决定
**请求方法**: `POST /admin/companies/verifications/{id}/decision`
**请求头**:
```
Authorization: Bearer {jwt}
Content-Type: application/json
```
**请求体**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| decision | String | 是 | APPROVED 或 REJECTED |
| reason | String | 条件必填 | REJECTED 时必须填写原因 |
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": {
"id": 1,
"companyId": 10,
"status": "APPROVED",
"reviewerId": 100,
"decisionReason": "材料齐全",
"decidedAt": "2026-08-13T11:00:00",
"version": 1
}
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | 1001 | decision 无效(非 APPROVED/REJECTED) |
| 400 | 8505 | REJECTED 时缺少原因 |
| 403 | 2002 | 非 ADMIN 角色 |
| 404 | 8501 | 认证记录不存在 |
| 409 | 8503 | 已通过,不可重复审核 |
| 409 | 8504 | 非法状态流转 |
**权限规则**: 仅 ADMIN 角色可调用。审查结果发送通知给企业。
**幂等规则**: 相同状态重复调用返回 200(幂等返回),但已 APPROVED 状态不可重复审核(返回 409)。
---
### 6. 审核历史
**请求方法**: `GET /admin/companies/verifications/history`
**请求头**:
```
Authorization: Bearer {jwt}
```
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| verificationId | Long | 是 | 认证记录 ID |
**成功响应** (200):
```json
{
"code": 1000,
"message": "success",
"data": [
{
"id": 1,
"fromStatus": null,
"toStatus": "PENDING",
"actorUserId": 1,
"reason": "企业提交认证材料",
"createdAt": "2026-08-13T10:00:00"
}
]
}
```
**错误响应**:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 403 | 2002 | 非 ADMIN 角色 |
**权限规则**: 仅 ADMIN 角色可调用。
---
## 权限规则总结
| 角色 | 接口 | 权限 |
|------|------|------|
| COMPANY | `/companies/me/verification` (POST/GET) | 允许 |
| STUDENT | `/companies/me/verification` (POST/GET) | 403 |
| ADMIN | `/admin/companies/verifications/**` | 允许 |
| COMPANY | `/admin/companies/verifications/**` | 403 |
| STUDENT | `/admin/companies/verifications/**` | 403 |
## 时间字段和时区
所有时间字段使用 ISO 8601 格式,时区为系统默认(Asia/Shanghai)。
## 审计日志
所有操作(提交、审核决定)均写入审计日志,包含操作用户、操作类型、IP 地址、User-Agent 等上下文信息。

183
docs/frontend-integration.md

@ -0,0 +1,183 @@
# 职来后端前端联调说明(RC)
## 1. API Base URL
| 环境 | Base URL |
|------|----------|
| 本地开发 | `http://127.0.0.1:8080/api/v1` |
| 生产(示例) | `https://api.mydreamjob.example.com/api/v1` |
所有接口统一前缀 `/api/v1`,OpenAPI 文档:`GET /api/v1/v3/api-docs`(Swagger UI:`/api/v1/swagger-ui/index.html`)。
## 2. 后端启动(Windows)
先启动基础设施容器:
```powershell
cd backend-infra
docker compose up -d
```
然后设置环境变量并启动:
```powershell
cd mydreamjob-server
$env:DB_URL = "jdbc:mysql://127.0.0.1:3307/mydreamjob?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai"
$env:DB_USERNAME = "mydreamjob"
$env:DB_PASSWORD = "<你的数据库密码>"
$env:RABBITMQ_USERNAME = "mydreamjob_dev"
$env:RABBITMQ_PASSWORD = "<你的 RabbitMQ 密码>"
$env:JWT_SECRET = "<至少256位的随机密钥>"
$env:SMS_MOCK_ENABLED = "true"
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。
## 3. Mock 验证码日志位置
开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中:
```
MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
```
- 运行 `mvn spring-boot:run` → 看终端输出
- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`)
- 每次发码后取**最新一条** `code=<6位数字>`
## 4. Apifox 导入 OpenAPI
1. 打开 Apifox → 新建项目
2. 左侧「导入数据」→ 选择「OpenAPI/Swagger」
3. 上传 [docs/openapi-rc.json](../docs/openapi-rc.json)(或填 URL `http://127.0.0.1:8080/api/v1/v3/api-docs`)
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。
```json
{
"code": 200,
"data": {
"userId": 2,
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"expiresIn": 86400,
"role": "STUDENT"
}
}
```
**后续请求**:所有需要认证的接口在请求头携带
```
Authorization: Bearer <accessToken>
```
## 3. 请求头
| Header | 必需 | 说明 |
|--------|------|------|
| `Authorization` | 认证接口 | `Bearer <token>` |
| `Content-Type` | POST/PUT/PATCH | `application/json` |
| `X-Request-Id` | 可选 | 全链路追踪 ID,审计日志会记录 |
| `User-Agent` | 可选 | 客户端标识,审计日志会记录 |
**注意**:`X-Request-Id` 和 `User-Agent` 会被写入 audit_logs,联调时建议带自定义值便于排查。
## 4. 分页格式
列表接口统一使用 `page`(从 0 起)和 `size` 参数,返回 `PageResponse` 结构:
```json
{
"code": 200,
"data": {
"items": [ ... ],
"total": 42,
"page": 0,
"size": 20,
"totalPages": 3
}
}
```
| 接口 | 默认 size | 最大 size |
|------|-----------|-----------|
| GET /jobs | 20 | 50 |
| GET /applications/me | 20 | 100 |
| GET /company/applications | 20 | 100 |
| GET /jobs/{id}/reviews | 20 | 50 |
| GET /conversations/{id}/messages | 20 | 50 |
| GET /notifications | 20 | 50 |
## 5. 统一响应与错误码
所有接口返回统一 `ApiResponse`:
```json
{ "code": 200, "message": "ok", "data": {...}, "requestId": "...", "timestamp": "..." }
```
| HTTP | 业务 code | 含义 |
|------|-----------|------|
| 401 | 2001 | 未登录 / token 失效 |
| 403 | 2002 | 无角色权限 / 越权 |
| 400 | 1001, 3002-3006, 8004... | 参数错误 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 9105 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 9101, 9103 | 重复 / 非法状态流转 |
| 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。
## 6. 测试账号
| 账号 | 角色 | 说明 |
|------|------|------|
| `13800000002` | STUDENT | 学生,可投递/匹配/查看通知 |
| `13800000003` | COMPANY | 企业,可创建岗位/查看投递/更新状态 |
| `13800000001` | ADMIN | 管理员(预留) |
**登录方式**(开发环境 Mock):
1. `POST /auth/sms/send?phone=<手机号>` 发送验证码
2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx`
3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token
## 7. 典型联调流程
```
学生端:
发码 → 登录 → GET /jobs → GET /jobs/{id}/match → POST /applications
→ GET /notifications → POST /notifications/{id}/read
企业端:
发码 → 登录 → POST /companies/me → POST /jobs → PATCH /jobs/{id}/status
→ GET /company/applications?jobId= → PATCH /applications/{id}/status
```
## 8. 投递接口与评价/对比补充
### 8.1 投递接口(正确写法)
创建投递为 **`POST /applications`**,请求体 `{"jobId": 123}`(不要写成 `POST /jobs/{jobId}/applications`):
```json
{ "jobId": 123 }
```
### 8.2 岗位八维评价
`POST /jobs/{jobId}/reviews` 可附加可选 `dimensionScores`(八个固定维度,值 1-5,可稀疏提交):
```json
{ "rating": 4, "content": "岗位信息真实", "tags": ["团队氛围"],
"dimensionScores": { "AUTHENTICITY": 5, "TEAM_ATMOSPHERE": 4 } }
```
- 不传 `dimensionScores` 时旧接口完全兼容;
- 评价列表/详情返回 `dimensionScores`;
- 汇总 `GET /jobs/{jobId}/reviews/summary` 返回 `dimensionSummary`(`INSUFFICIENT` sampleCount<3 / `SUFFICIENT` >=3);
- 详情见 `docs/job-review-api.md`。
### 8.3 岗位对比(公开)
`GET /jobs/compare?ids=1,2,3`(无需登录,2-3 个公开岗位),返回公开字段与公开评分。

490
docs/interview-api.md

@ -0,0 +1,490 @@
# 面试管理接口文档
## 概述
面试管理基础模块,支持企业创建面试、学生响应面试、企业取消/完成面试,以及面试状态时间线查看。
所有接口路径前缀: `/api/v1`
## 请求头
所有接口需要携带:
| 请求头 | 必填 | 说明 |
|--------|------|------|
| Authorization | 是 | `Bearer <JWT>` |
| Content-Type | 是 | `application/json` |
| X-Request-Id | 否 | 请求追踪 ID |
## 时间字段和时区
- 所有时间字段使用 ISO 8601 格式 (如 `2026-08-10T14:00:00`)
- 存储时区: UTC (由数据库层处理)
- API 层时区: 通过 `timezone` 字段指定,默认 `Asia/Shanghai`
- `startAt` 必须在未来时间
- `endAt` 必须晚于 `startAt`
- 面试时长不能超过 8 小时
## 状态枚举
```
SCHEDULED - 已安排 (企业创建后初始状态)
CONFIRMED - 已确认 (学生确认参加)
DECLINED - 已拒绝 (学生拒绝,终态)
RESCHEDULE_REQUESTED - 申请改期 (学生请求改期)
CANCELLED - 已取消 (企业取消,终态)
COMPLETED - 已完成 (企业标记完成,终态)
```
## 状态流转规则
```
SCHEDULED ──────────> CONFIRMED ──────────> COMPLETED (终态)
│ │
├────────────────────┼──> CANCELLED (终态)
│ │
├──> DECLINED (终态) │
│ │
└──> RESCHEDULE_REQUESTED ──> SCHEDULED (企业重新安排)
```
- SCHEDULED → CONFIRMED / DECLINED / RESCHEDULE_REQUESTED / CANCELLED
- RESCHEDULE_REQUESTED → SCHEDULED (企业重新安排)
- CONFIRMED → CANCELLED / COMPLETED
- DECLINED / CANCELLED / COMPLETED 为终态,不可再流转
- 非法状态流转返回 409
## 权限规则
| 操作 | 企业 (COMPANY) | 学生 (STUDENT) |
|------|---------------|---------------|
| 创建面试 | 仅本企业岗位投递 | 禁止 |
| 查看面试列表(投递维度) | 仅本企业 | 仅本人 |
| 查看面试详情 | 仅本企业 | 仅本人 |
| 修改面试 | 仅本企业 (SCHEDULED/CONFIRMED) | 禁止 |
| 取消面试 | 仅本企业 | 禁止 |
| 完成面试 | 仅本企业 (CONFIRMED 且已过 endAt) | 禁止 |
| 响应面试 | 禁止 | 仅本人 (SCHEDULED 状态) |
| 查看时间线 | 仅本企业 | 仅本人 |
- 非参与者访问任何接口返回 403
- 企业只能操作自己创建的面试
- 学生只能操作自己的面试
## 幂等规则
- 重复 CONFIRM (已 CONFIRMED): 返回 200,不产生状态变更
- 重复 DECLINE (已 DECLINED): 返回 200,不产生状态变更
- 重复 RESCHEDULE_REQUESTED (已 RESCHEDULE_REQUESTED): 返回 200,不产生状态变更
- 重复 CANCELLED (已 CANCELLED): 返回 200,不产生状态变更
- 重复 COMPLETED (已 COMPLETED): 返回 200,不产生状态变更
- 已处于终态的面试再次操作: 返回 409
- 并发更新: 乐观锁(version 字段)保护,不会覆盖状态历史
---
## 接口列表
### 1. 创建面试 (企业)
```
POST /api/v1/applications/{applicationId}/interviews
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| roundNo | Integer | 否 | 面试轮次,默认 1 |
| title | String | 是 | 面试标题,最大 200 字符 |
| interviewType | String | 是 | 面试类型: ONLINE / OFFLINE |
| startAt | DateTime | 是 | 面试开始时间 (ISO 8601) |
| endAt | DateTime | 是 | 面试结束时间 (ISO 8601) |
| timezone | String | 否 | 时区,默认 "Asia/Shanghai" |
| meetingUrl | String | 条件必填 | ONLINE 时必填,最大 500 字符 |
| address | String | 条件必填 | OFFLINE 时必填,最大 500 字符 |
| interviewerName | String | 否 | 面试官姓名,最大 100 字符 |
| notes | String | 否 | 备注 |
```json
{
"roundNo": 1,
"title": "技术面试",
"interviewType": "ONLINE",
"startAt": "2026-08-15T14:00:00",
"endAt": "2026-08-15T15:00:00",
"timezone": "Asia/Shanghai",
"meetingUrl": "https://meeting.example.com/123",
"interviewerName": "张经理",
"notes": "请准备项目经验介绍"
}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"applicationId": 1,
"companyUserId": 2,
"studentUserId": 3,
"roundNo": 1,
"title": "技术面试",
"interviewType": "ONLINE",
"startAt": "2026-08-15T14:00:00",
"endAt": "2026-08-15T15:00:00",
"timezone": "Asia/Shanghai",
"meetingUrl": "https://meeting.example.com/123",
"address": null,
"interviewerName": "张经理",
"notes": "请准备项目经验介绍",
"status": "SCHEDULED",
"version": 0,
"createdAt": "2026-08-13T10:00:00",
"updatedAt": "2026-08-13T10:00:00"
},
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | BAD_REQUEST | 缺少必填字段、参数校验失败 |
| 400 | INTERVIEW_TIME_INVALID | 结束时间早于开始时间 |
| 400 | INTERVIEW_START_TIME_PAST | 面试开始时间在过去 |
| 400 | INTERVIEW_DURATION_EXCEEDED | 面试时长超过 8 小时 |
| 400 | INTERVIEW_ONLINE_URL_REQUIRED | ONLINE 类型需要 meetingUrl |
| 400 | INTERVIEW_OFFLINE_ADDRESS_REQUIRED | OFFLINE 类型需要 address |
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | FORBIDDEN | 非企业用户 |
| 404 | APPLICATION_NOT_FOUND | 投递记录不存在 |
| 404 | COMPANY_NOT_FOUND | 企业信息不存在 |
---
### 2. 查询投递的面试列表
```
GET /api/v1/applications/{applicationId}/interviews
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": [ { ...interview... } ],
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非投递学生或岗位所属企业 |
| 404 | APPLICATION_NOT_FOUND | 投递记录不存在 |
---
### 3. 我的面试列表
```
GET /api/v1/interviews/me?status=SCHEDULED&from=2026-08-01T00:00:00&to=2026-08-31T23:59:59&page=0&size=20
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | String | 否 | 状态筛选 (SCHEDULED/CONFIRMED/DECLINED 等) |
| from | DateTime | 否 | 开始时间 >= from (ISO 8601) |
| to | DateTime | 否 | 开始时间 <= to (ISO 8601) |
| page | int | 否 | 页码,默认 0 |
| size | int | 否 | 每页大小,默认 20,最大 100 |
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": {
"items": [ { ...interview... } ],
"total": 50,
"page": 0,
"size": 20,
"totalPages": 3
},
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | FORBIDDEN | 非企业或学生角色 |
---
### 4. 面试详情
```
GET /api/v1/interviews/{interviewId}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": { ...interview... },
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非参与者 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
---
### 5. 修改面试 (企业)
```
PUT /api/v1/interviews/{interviewId}
```
**请求体:** 所有字段均为可选,传入的字段才会更新
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | String | 否 | 面试标题 |
| startAt | DateTime | 否 | 面试开始时间,需与 endAt 同时提供 |
| endAt | DateTime | 否 | 面试结束时间,需与 startAt 同时提供 |
| timezone | String | 否 | 时区 |
| meetingUrl | String | 否 | 在线会议链接 |
| address | String | 否 | 面试地址 |
| interviewerName | String | 否 | 面试官姓名 |
| notes | String | 否 | 备注 |
```json
{
"startAt": "2026-08-16T10:00:00",
"endAt": "2026-08-16T11:00:00",
"meetingUrl": "https://meeting.example.com/456"
}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": { ...interview... },
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | INTERVIEW_TIME_INVALID | 结束时间早于开始时间 |
| 400 | INTERVIEW_START_TIME_PAST | 面试开始时间在过去 |
| 400 | INTERVIEW_DURATION_EXCEEDED | 面试时长超过 8 小时 |
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非本企业面试 |
| 403 | FORBIDDEN | 非企业用户 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
| 409 | INVALID_INTERVIEW_STATUS_TRANSITION | 面试状态不允许修改 (非 SCHEDULED/CONFIRMED) |
---
### 6. 学生响应面试
```
POST /api/v1/interviews/{interviewId}/respond
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| action | String | 是 | CONFIRM / DECLINE / REQUEST_RESCHEDULE |
| reason | String | 否 | 原因说明 |
```json
{
"action": "CONFIRM",
"reason": "时间可以接受"
}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": { ...interview... },
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | BAD_REQUEST | action 缺失或值无效 |
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非本人面试 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
| 409 | INVALID_INTERVIEW_STATUS_TRANSITION | 面试状态不可响应 (非 SCHEDULED) |
---
### 7. 企业取消面试
```
POST /api/v1/interviews/{interviewId}/cancel
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| reason | String | 否 | 取消原因 |
```json
{
"reason": "岗位已招满"
}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": { ...interview... },
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非本企业面试 |
| 403 | FORBIDDEN | 非企业用户 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
| 409 | INVALID_INTERVIEW_STATUS_TRANSITION | 面试已是终态 |
---
### 8. 企业完成面试
```
POST /api/v1/interviews/{interviewId}/complete
```
**请求体 (可选):**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| notes | String | 否 | 完成备注 |
```json
{
"notes": "面试表现优秀"
}
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": { ...interview... },
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非本企业面试 |
| 403 | FORBIDDEN | 非企业用户 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
| 409 | INTERVIEW_COMPLETE_NOT_READY | 面试尚未到结束时间 |
| 409 | INVALID_INTERVIEW_STATUS_TRANSITION | 面试未确认 (非 CONFIRMED) |
---
### 9. 状态时间线
```
GET /api/v1/interviews/{interviewId}/timeline
```
**成功响应 (200):**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"id": 1,
"fromStatus": null,
"toStatus": "SCHEDULED",
"actorUserId": 2,
"reason": "Interview created",
"createdAt": "2026-08-13T10:00:00"
},
{
"id": 2,
"fromStatus": "SCHEDULED",
"toStatus": "CONFIRMED",
"actorUserId": 3,
"reason": "时间可以接受",
"createdAt": "2026-08-13T10:30:00"
}
],
"requestId": "..."
}
```
**错误:**
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 401 | UNAUTHORIZED | 未提供有效 JWT |
| 403 | INTERVIEW_ACCESS_DENIED | 非参与者 |
| 404 | INTERVIEW_NOT_FOUND | 面试不存在 |
---
## 错误码汇总
| 错误码 | HTTP | 说明 |
|--------|------|------|
| 8401 | 404 | INTERVIEW_NOT_FOUND - 面试不存在 |
| 8402 | 403 | INTERVIEW_ACCESS_DENIED - 无权访问面试 |
| 8403 | 409 | INVALID_INTERVIEW_STATUS_TRANSITION - 非法状态流转 |
| 8404 | 400 | INTERVIEW_TIME_INVALID - 时间区间无效 |
| 8405 | 400 | INTERVIEW_DURATION_EXCEEDED - 面试时长超过 8 小时 |
| 8406 | 400 | INTERVIEW_ONLINE_URL_REQUIRED - 在线面试需要会议链接 |
| 8407 | 400 | INTERVIEW_OFFLINE_ADDRESS_REQUIRED - 线下面试需要地址 |
| 8408 | 400 | INTERVIEW_START_TIME_PAST - 面试开始时间必须在未来 |
| 8409 | 409 | INTERVIEW_COMPLETE_NOT_READY - 面试未结束不能完成 |
| 8410 | 409 | 已响应,不可重复操作 |
## 通知事件
| 事件类型 | 触发方 | 接收方 |
|----------|--------|--------|
| INTERVIEW_INVITED | 企业创建面试 | 学生 |
| INTERVIEW_CONFIRMED | 学生确认 | 企业 |
| INTERVIEW_DECLINED | 学生拒绝 | 企业 |
| INTERVIEW_RESCHEDULE_REQUESTED | 学生申请改期 | 企业 |
| INTERVIEW_RESCHEDULED | 企业修改时间 | 学生 |
| INTERVIEW_CANCELLED | 企业取消 | 学生 |
| INTERVIEW_COMPLETED | 企业完成 | 学生 |

124
docs/job-review-api.md

@ -0,0 +1,124 @@
# 岗位评价与八维评分卡接口说明
> 职来 (mydreamjob) · 岗位评价 + 八维评分卡基础模块
> 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开
## 1. 概述
在原岗位评价(rating 1-5 + content + tags)基础上,新增**八个固定维度**的稀疏评分卡:
| 维度码 | 说明 |
|---|---|
| `AUTHENTICITY` | 岗位真实性 |
| `BENEFITS` | 基础保障 |
| `SALARY_TRANSPARENCY` | 薪资透明 |
| `JD_CLARITY` | JD 清晰度 |
| `GROWTH` | 成长空间 |
| `TEAM_ATMOSPHERE` | 团队氛围 |
| `PROMOTION` | 晋升机制 |
| `WORK_LIFE_BALANCE` | 工作与生活平衡(含加班强度/工作时间稳定性/弹性办公/休假执行/加班补偿) |
- `dimensionScores` **可选**、允许稀疏(可只提交部分维度),每项 1-5 整数;
- 未知维度 / 非 1-5 / null 分数 → 400;
- 旧客户端不传 `dimensionScores` 完全兼容,不报错;
- 同一学生同一岗位只能评价一次 → 409 `DUPLICATE_REVIEW`;越权修改/删除 → 403。
## 2. 接口
### 2.1 创建评价 `POST /jobs/{jobId}/reviews`
- 仅 STUDENT;企业创建 → 403;重复 → 409。
- 旧字段:`rating`(必填 1-5)、`content`(必填 ≤1000)、`tags`(可选 ≤10)。
- 新增可选 `dimensionScores`(Map,值 1-5):
```json
{ "rating": 4, "content": "岗位信息真实", "tags": ["团队氛围"],
"dimensionScores": { "AUTHENTICITY": 5, "BENEFITS": 4, "SALARY_TRANSPARENCY": 4,
"JD_CLARITY": 5, "GROWTH": 4, "TEAM_ATMOSPHERE": 5, "PROMOTION": 3, "WORK_LIFE_BALANCE": 4 } }
```
### 2.2 评价列表 `GET /jobs/{jobId}/reviews`(公开,分页)
每条返回 `id, jobId, studentUserId, rating, content, tags, status, createdAt, updatedAt, dimensionScores`。
无维度分时 `dimensionScores` 为空对象。
### 2.3 评价详情 `GET /jobs/{jobId}/reviews/{reviewId}`(公开)
返回单条评价,结构同列表项(含 `dimensionScores`)。
### 2.4 修改评价 `PUT /jobs/{jobId}/reviews/{reviewId}`
- 仅评价本人;他人 → 403;不存在 → 404。
- `dimensionScores` 可选:**只更新请求中实际提交的维度**(已有更新、新增插入);未提交维度保持原值;空对象不删除原有维度。
### 2.5 删除评价 `DELETE /jobs/{jobId}/reviews/{reviewId}`
- 仅评价本人;删除时**级联删除**该评价的维度评分(DB ON DELETE CASCADE),不留下孤儿维度记录。
### 2.6 评分汇总 `GET /jobs/{jobId}/reviews/summary`(公开)
保留原有 `jobId, totalReviews, averageRating, ratingDistribution, topTags`,新增 `dimensionSummary`:
```json
{ "dimensionSummary": {
"AUTHENTICITY": { "averageScore": 4.5, "sampleCount": 2, "dataState": "INSUFFICIENT" },
"BENEFITS": { "averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT" }
} }
```
- 只统计 `PUBLISHED` 评价中**实际存在**的维度分;不伪造、不补齐;
- 始终返回全部 8 个维度;
- `sampleCount < 3` → `INSUFFICIENT`;`>= 3` → `SUFFICIENT`;`sampleCount == 0` → `averageScore` 为 null;
- `averageScore` 保留 1 位小数,HALF_UP。
### 2.7 岗位对比 `GET /jobs/compare?ids=1,2,3`(公开,不要求登录)
- `ids` 为 2-3 个公开岗位,逗号分隔;少于 2 / 超过 3 / 缺失 / 重复 / 非法 → 400;
- 任一岗位不存在或非 PUBLISHED → 404;
- 每岗返回:`id, title, city, employmentType, salaryMin, salaryMax, benefits, description, requirements,
averageRating, totalReviews, ratingDistribution, tags, dimensionSummary`;
- 只返回公开岗位字段与公开评分,**不返回**企业内部字段、联系方式、审核字段或内部备注。
## 3. 权限
| 操作 | 学生本人 | 其他学生 | 企业 | 未登录 |
|---|---|---|---|---|
| 创建评价 | ✅ | 403(重复→409) | 403 | 401 |
| 修改/删除评价 | ✅ | 403 | 403 | 401 |
| 列表/详情/汇总/对比 | 公开 | 公开 | 公开 | ✅ |
身份一律来自 JWT,不信任请求体中的 userId/studentUserId;所有写操作记录 X-Request-Id(超长统一截断为 32 位)。
## 4. 数据库(V17)
`V17__job_review_dimensions.sql`:
- 表 `job_review_dimension_scores`:`review_id`(FK→job_reviews ON DELETE CASCADE)、`dimension_code`、`score`(CHECK 1-5)、`created_at/updated_at`;
- `UNIQUE(review_id, dimension_code)`(并发唯一性保护)、`INDEX(review_id)`;
- **不改** `job_reviews` 表结构,V1-V16 历史迁移不动。
## 5. Apifox 快速步骤
1. 学生投递可见岗位 → `POST /jobs/{jobId}/reviews` 带 `dimensionScores` → 200。
2. 同一学生重复 `POST` → 409。
3. `GET /jobs/{jobId}/reviews/{reviewId}` → 含 `dimensionScores`。
4. `PUT` 只改 `dimensionScores` 中部分维度 → 其余保持。
5. `GET /jobs/{jobId}/reviews/summary` → 看 `dimensionSummary`(INSUFFICIENT/SUFFICIENT)。
6. `GET /jobs/compare?ids=1,2` → 对比两个公开岗位(不登录)。
7. 企业账号 `POST /jobs/{jobId}/reviews` → 403。
## 6. 已知限制
1. `benefits` 字段:当前 `jobs` 表无该字段,对比接口返回 `benefits: null` 占位,待产品补充岗位福利后接入。
2. WORK_LIFE_BALANCE 的 5 个子项本轮归并为一个 1-5 分,不拆分存储。
3. 维度分仅学生本人创建评价时可录入;汇总为只读聚合。
4. OpenAPI 快照:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**56 路径**),现网可用 `GET /api/v1/v3/api-docs` 重新导出。
## 7. 评价审核与防刷(V18 起)
评价创建经确定性预审(非 AI,详见 `docs/review-moderation-api.md`):
- 无风险 → `moderationStatus = AUTO_APPROVED`,同步 200 且公开展示(行为与旧版一致);
- 命中规则(敏感词/联系方式/重复模式/高频/批量)→ `moderationStatus = PENDING`,保存成功但不公开,
作者响应含 `moderationStatus` 与安全原因码(不含敏感词原文);
- 公开列表/详情/汇总仅返回 `AUTO_APPROVED` 与 `APPROVED` 的评价;
- 管理员通过 `POST /admin/reviews/{reviewId}/decision` 审核(`APPROVED` 公开 / `REJECTED` 永不公开),
重复审核返回 409 `REVIEW_ALREADY_DECIDED`;待审列表与历史见 `GET /admin/reviews/pending`、`GET /admin/reviews/history`。
评价视图统一增加 `moderationStatus` 字段。

149
docs/job-search-api.md

@ -0,0 +1,149 @@
# 岗位搜索 API
## 接口列表
### 1. GET /api/v1/jobs/search
岗位高级搜索。仅返回 PUBLISHED 岗位,CLOSED 和 DRAFT 不出现在结果中。
**公开访问,无需认证。**
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| keyword | String | 否 | - | 搜索标题/描述/要求关键词 |
| city | String | 否 | - | 城市精确匹配 |
| employmentType | String | 否 | - | 用工类型精确匹配 |
| salaryMin | Integer | 否 | - | 最低薪资筛选(岗位 salaryMax >= salaryMin) |
| salaryMax | Integer | 否 | - | 最高薪资筛选(岗位 salaryMin <= salaryMax) |
| companyId | Long | 否 | - | 按公司筛选 |
| sort | String | 否 | latest | latest / salary / relevance |
| page | int | 否 | 0 | 页码,>= 0 |
| size | int | 否 | 20 | 每页条数,1-50 |
#### 排序规则
| sort 值 | 排序 |
|---------|------|
| latest(默认) | createdAt DESC, id DESC |
| salary | salaryMax DESC, id DESC |
| relevance | createdAt DESC, id DESC(本期按 latest 兜底) |
#### 薪资筛选规则
- salaryMin:仅返回 `job.salaryMax >= salaryMin` 的岗位
- salaryMax:仅返回 `job.salaryMin <= salaryMax` 的岗位
- salaryMin > salaryMax 返回 400 (code=1001)
- 薪资字段为 NULL 的岗位不参与薪资范围筛选
#### 错误码
| 场景 | HTTP | code |
|------|------|------|
| salaryMin > salaryMax | 400 | 1001 |
| sort 非法值 | 400 | 1001 |
| page < 0 | 400 | 1001 |
| size < 1 或 size > 50 | 400 | 1001 |
#### 响应 200
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [
{
"id": 1,
"title": "Java开发工程师",
"description": "负责后端服务开发...",
"requirements": "3年以上Java经验...",
"city": "北京",
"employmentType": "FULL_TIME",
"salaryMin": 15000,
"salaryMax": 25000,
"status": "PUBLISHED",
"publishedAt": "2026-08-01T10:00:00",
"createdAt": "2026-08-01T09:00:00",
"company": {
"id": 1,
"name": "XX科技有限公司",
"logoUrl": "https://cdn.example.com/logo.png",
"verified": true
}
}
],
"total": 100,
"page": 0,
"size": 20,
"totalPages": 5
}
}
```
---
### 2. GET /api/v1/jobs/filters
岗位筛选选项。数据仅来自 PUBLISHED 岗位。
**公开访问,无需认证。**
#### 响应 200
```json
{
"code": 200,
"message": "ok",
"data": {
"cities": ["北京", "上海", "深圳"],
"employmentTypes": ["FULL_TIME", "INTERN", "PART_TIME"],
"salaryRange": {
"min": 5000,
"max": 50000
}
}
}
```
#### 空数据
```json
{
"cities": [],
"employmentTypes": [],
"salaryRange": {
"min": null,
"max": null
}
}
```
#### 排序规则
- cities:按城市名自然排序(ASC),去重
- employmentTypes:按枚举值自然排序(ASC),去重
- salaryRange:min 取 MIN(salaryMin),max 取 MAX(salaryMax),排除 NULL
---
### 3. 兼容性说明
| 端点 | 行为 |
|------|------|
| GET /jobs | 完全不变。路径、参数、返回结构、状态码、权限语义均保持现有实现。 |
| GET /jobs/public/{jobId} | 完全不变。PUBLISHED 和 CLOSED 均可查看详情。 |
| GET /jobs/search | 新增。仅返回 PUBLISHED。 |
| GET /jobs/filters | 新增。仅基于 PUBLISHED。 |
---
## 公开 DTO 字段
搜索结果仅返回以下公开字段,不泄露:
- `ownerUserId`(企业关联用户)
- 企业联系方式(website、address)
- 企业内部时间戳(updatedAt)
- 审核信息(company_verifications.*)

186
docs/match-api.md

@ -0,0 +1,186 @@
# 双向匹配基础模块接口说明
> 职来 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述
企业与求职者围绕同一条投递 `application` 形成双向匹配,**不依赖 AI 或综合算法**:
- `candidateInterestScore`(0-100):求职者对岗位的**意愿分**(人工录入,来源 `MANUAL_INTEREST`),
**不是**教育/能力/诚信/算法综合分;未来算法服务可替换该分数来源。
- `hrSuitabilityScore`(0-100):企业对候选人的 **HR 适配分**(人工录入,来源 `HR_MANUAL`)。
- `companyRequiredCandidateScore`(0-100,默认 60):企业设置的"最低意愿分"阈值。
- `studentRequiredHrScore`(0-100,默认 60):求职者设置的"最低 HR 适配分"阈值。
**匹配成功条件**:`candidateInterestScore >= companyRequiredCandidateScore 且 hrSuitabilityScore >= studentRequiredHrScore`。
匹配成功(状态 `MATCHED`)后:写入 `matchedAt`;双方各收到一次 `MATCH_SUCCEEDED` 通知;
双方可基于该 application 创建/获取直聊会话;本期**不再允许修改双方评分**。
未达标时状态为 `NOT_MATCHED`,双方可重提/改分,每次修改后重新计算。**不做** 7 天冷却期等额外限制。
## 2. 状态机
| 状态 | 含义 |
|---|---|
| `PENDING_BOTH` | 双方都未评分 |
| `PENDING_HR` | 求职者已评分,等待企业 HR 分 |
| `PENDING_CANDIDATE` | 企业已评分,等待求职者意愿分 |
| `NOT_MATCHED` | 双方已有评分但未同时达到阈值 |
| `MATCHED` | 双方达到阈值(终态之一,评分锁定) |
| `CLOSED` | 投递已撤回/拒绝、岗位关闭等,不再允许匹配(终态) |
重算规则(每次评分提交后执行):
- 两侧评分缺失 → `PENDING_BOTH`;仅缺候选人分 → `PENDING_CANDIDATE`;仅缺 HR 分 → `PENDING_HR`;
- 两侧都有且同时达标 → `MATCHED`;否则 → `NOT_MATCHED`。
- 仅**有效投递状态**(`SUBMITTED`/`VIEWED`/`INTERVIEW`)可评分;`REJECTED`/`HIRED`/`WITHDRAWN`
或岗位 `CLOSED` 时评分为 `CLOSED` 并拒绝写入。
- 重复提交相同分数不产生重复事件/通知;并发评分经乐观锁 + `MATCH_SUCCEEDED` 事件唯一约束,
保证**仅一次**从非 MATCHED 进入 MATCHED。
## 3. 接口清单
### 3.1 企业匹配阈值
```
GET /companies/me/match-rule
PUT /companies/me/match-rule
```
- 角色:企业本人(COMPANY 且绑定企业);未配置读取返回默认 `60`;首次更新创建配置。
- PUT 请求:`{"requiredCandidateScore": 70}`(0-100,非法值 400)。
- 响应:`{"companyId": 1, "requiredCandidateScore": 70}`。
- 阈值更新**只影响后续重新计算**;匹配记录保留本次计算实际使用的阈值快照。
### 3.2 学生匹配阈值
```
GET /profile/me/match-rule
PUT /profile/me/match-rule
```
- 角色:学生本人;同上,PUT 字段为 `requiredHrScore`。
### 3.3 提交求职者岗位意愿分
```
PUT /applications/{id}/candidate-interest-score
```
- 角色:该投递的求职者本人(STUDENT);请求 `{"score": 80}`(0-100)。
- 前置:投递存在(404)、投递归属本人(403)、投递状态有效(否则 409 `MATCH_CLOSED`)、
匹配记录非 MATCHED/CLOSED(409 `MATCH_SCORES_LOCKED`/`MATCH_CLOSED`)。
- 成功 200,返回匹配记录视图(状态、双分、阈值快照、matchedAt)。
### 3.4 提交企业 HR 适配分
```
PUT /applications/{id}/hr-suitability-score
```
- 角色:岗位所属企业(COMPANY,经 `company_id` 归属校验);请求 `{"score": 80}`。
- 权限/校验同上;他人企业 403。
### 3.5 查询投递匹配状态
```
GET /applications/{id}/match
```
- 角色:该投递求职者或岗位所属企业;其他 403,不存在 404。
- 响应(脱敏,不含内部路径/事件 ID):
`applicationId, jobId, companyId, candidateId, status, candidateInterestScore,
hrSuitabilityScore, companyRequiredCandidateScore, studentRequiredHrScore,
candidateScoreSource, hrScoreSource, matchedAt, matchVersion`。
- 无记录返回 `PENDING_BOTH` + 当前阈值;投递终态/岗位关闭返回 `CLOSED`。
### 3.6 匹配后创建/获取沟通会话
```
POST /applications/{id}/conversation
```
- 角色:该投递求职者或岗位所属企业;`application` 必须为 `MATCHED`,否则 409 `APPLICATION_NOT_MATCHED`。
- 幂等:已有对应会话则返回原会话;否则创建 DIRECT 会话并记录 `application_match_conversations`;
并发冲突返回既有会话。
- 响应:会话视图 `{id, title, type, createdBy, createdAt, updatedAt}`。
## 4. 权限与越权矩阵
| 接口 | 该投递求职者 | 岗位所属企业 | 其他企业 | 其他求职者 | 未登录 |
|---|---|---|---|---|---|
| 企业阈值 GET/PUT | 403 | ✅ | 403 | 403 | 401 |
| 学生阈值 GET/PUT | ✅ | 403 | 403 | 403 | 401 |
| candidate-interest-score | ✅(本人) | 403 | 403 | 403 | 401 |
| hr-suitability-score | 403 | ✅(本人岗位) | 403 | 403 | 401 |
| 匹配状态查询 | ✅(本人) | ✅(本人岗位) | 403 | 403 | 401 |
| 匹配会话创建 | ✅(且 MATCHED) | ✅(且 MATCHED) | 403 | 403 | 401 |
原则:身份一律来自 JWT,不信任请求体/查询参数中的 userId/companyId/studentId;
401 未登录;403 存在但越权;404 资源不存在;分数/阈值非法值 400。
## 5. 防绕过:旧 `/conversations` 收紧
- 现有 `POST /conversations` 在**新建会话**时,若双方为"企业账号 + 求职者账号"组合,
必须存在一条同时归属双方且状态为 `MATCHED` 的投递,否则 **403 `MATCH_REQUIRED_FOR_CONVERSATION`**。
- 已有 DIRECT 会话:仅允许原成员继续访问,**不删除、不失效**(命中既有会话直接返回,不校验)。
- 学生-学生、企业-企业及其他组合:保持原语义。
- 无法判定身份时(上游已校验存在性)防御性放行。
## 6. 通知与 RabbitMQ(Outbox)
- 首次进入 `MATCHED` 时,求职者与企业各收到一次 `MATCH_SUCCEEDED` 通知。
- 通知内容含 `applicationId`、`jobId`、对方展示信息(企业名/岗位名、候选人姓名/昵称),
**不暴露手机号、JWT、内部存储路径**。
- 防重复三层保障:① `application_match_events` 对同一 application 的 `MATCH_SUCCEEDED` 事件唯一(dedup_key);
② 事件 `notification_status` 仅 `PENDING → SENT`,发送后不再重发;
③ 通知幂等键 `match-succeeded-{applicationId}-{candidate|company}`。
- 可靠发送:评分事务提交后 `afterCommit` 立即发送;失败保持 `PENDING`,由 `MatchNotificationScheduler`
(`app.match.notification.enabled`/`retry-ms`,默认 60s)重试;`sendMatchSucceeded` 为 `REQUIRES_NEW`
独立事务,重试/并发不产生重复通知。
- 复用现有 `NotificationService`(幂等键落库),不新增队列、不清空任何 RabbitMQ 队列;不影响 Offer 通知语义。
## 7. 数据库迁移(V16)
`src/main/resources/db/migration/V16__bidirectional_match.sql`(兼容 MySQL 8 / H2 MODE=MySQL)。
| 表 | 关键字段/约束 |
|---|---|
| `company_match_rules` | company_id 唯一 FK→companies;required_candidate_score 0-100 默认 60 |
| `student_match_rules` | student_user_id 唯一 FK→users;required_hr_score 0-100 默认 60 |
| `application_match_records` | application_id 唯一 FK→applications;双分可空(0-100 CHECK)、阈值快照、评分来源、status、matched_at、version 乐观锁;索引(company_id,status)/(candidate_id,status)/(status,matched_at)/(job_id) |
| `application_match_events` | application_id+dedup_key 唯一(保证 MATCH_SUCCEEDED 仅一次);event_type/old_status/new_status/old_score/new_score/score_field/notification_status/request_id;索引(application_id,created_at)/(event_type,notification_status) |
| `application_match_conversations` | application_id 唯一;conversation_id FK→conversations;解锁会话幂等 |
不改任何既有表;`/jobs/{jobId}/match` 岗位匹配诊断、`candidate_feedback`、`POST /conversations` 语义保持不变。
## 8. Apifox 测试步骤
1. 登录学生与企业账号(Mock 验证码)。
2. 企业 `PUT /companies/me/match-rule` 设阈值 70;学生 `PUT /profile/me/match-rule` 设 70。
3. 学生投递岗位,记录 applicationId。
4. 学生 `PUT /applications/{id}/candidate-interest-score` `{"score":80}` → 200,状态 `PENDING_HR`。
5. 企业 `PUT /applications/{id}/hr-suitability-score` `{"score":80}` → 200,状态 `MATCHED`,matchedAt 非空。
6. `GET /applications/{id}/match` 查看双方评分与快照。
7. 双方各收到 `MATCH_SUCCEEDED` 通知(通知列表可查)。
8. `POST /applications/{id}/conversation` → 创建会话;重复调用返回同一会话。
9. 再提分 → 409 `MATCH_SCORES_LOCKED`。
10. 未匹配投递 `POST /applications/{id}/conversation` → 409 `APPLICATION_NOT_MATCHED`。
11. 未匹配的企业+学生调旧 `POST /conversations` → 403 `MATCH_REQUIRED_FOR_CONVERSATION`。
## 9. 已知限制
1. 评分与阈值均为人工录入(来源 MANUAL_INTEREST/HR_MANUAL),非算法评分;算法服务接入时替换写入方并升级 `matchVersion`/评分来源。
2. `MATCHED` 后评分锁定;如需协商调整需先 CLOSED 或产品确认撤销规则。
3. 阈值更新只影响后续重算,匹配记录保留计算时点快照(可审计)。
4. 通知在评分事务提交后发送,极端情况下依赖调度任务补发(默认 60s 周期)。
5. OpenAPI 快照:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**55 路径**),现网可用 `GET /api/v1/v3/api-docs` 重新导出。
## 10. 错误码(新增 83xx)
| 错误码 | 枚举 | HTTP | 含义 |
|---|---|---|---|
| 8301 | `MATCH_CLOSED` | 409 | 投递终态/岗位关闭,匹配已关闭 |
| 8302 | `MATCH_SCORES_LOCKED` | 409 | 已 MATCHED,评分锁定 |
| 8303 | `MATCH_REQUIRED_FOR_CONVERSATION` | 403 | 企业-求职者建会话需匹配成功 |
| 8304 | `APPLICATION_NOT_MATCHED` | 409 | 投递未匹配,不可建会话 |

309
docs/offer-api.md

@ -0,0 +1,309 @@
# Offer 管理模块接口说明
> 职来 (mydreamjob) · Offer 管理基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述
Offer 用于管理企业向求职者发出的录用邀请,覆盖:创建草稿、发送、候选人接受/拒绝/进入洽谈、
到期自动失效、完整状态历史、到期提醒与手动提醒。本轮**不包含**双向匹配、公平性检测、SHAP、
AI 简历/面试、评分算法、数据看板等模块。
## 2. 状态机
状态统一命名(大小写敏感):
| 状态 | 含义 | 是否终态 |
|---|---|---|
| `DRAFT` | 草稿(企业已创建、未发送) | 否 |
| `SENT` | 已发送(等待候选人响应) | 否 |
| `NEGOTIATING` | 洽谈中 | 否 |
| `ACCEPTED` | 已接受 | **是** |
| `REJECTED` | 已拒绝 | **是** |
| `EXPIRED` | 已过期(定时任务触发) | **是** |
状态流转:
```text
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`):
```json
{
"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` 倒序。
响应:
```json
{ "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
```
请求体:
```json
{ "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
```
权限:同详情。返回按时间升序的完整状态变更历史:
```json
{ "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 短信验证码,验证码见应用日志):
| 角色 | 手机号 | 用途 |
|---|---|---|
| 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 | 当前状态不允许手动提醒 |

1
docs/openapi-rc.json

File diff suppressed because one or more lines are too long

213
docs/resume-api.md

@ -0,0 +1,213 @@
# 简历版本管理与导出模块接口说明
> 职来 (mydreamjob) · 简历版本管理与导出基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述
学生在既有单简历(`/profile/me/resume`,上传即替换)之外,新增**多版本简历**管理:
创建/列表/详情/修改/删除/设默认,以及**异步导出任务**(复制该版本文件到导出目录并查询状态)。
本轮**不做** AI 简历生成/优化、简历查重、词云、匹配算法、SHAP、Offer 新功能。
与旧单简历 `student_profiles.resume_url` **完全独立**,不修改其原有语义。
## 2. 数据模型(Flyway V14 / V15)
**`resume_versions`**(简历版本)
- `id`、`user_id`(FK→users)、`name`(版本名,可空)、`version_no`(每用户递增:max+1)
- `file_storage_path`/`file_name`/`content_type`/`file_size`(复用 `ResumeStorageService` 存储)
- `default_key`:可空判别列,默认时 `'DEFAULT'` 否则 `NULL`;`UNIQUE(user_id, default_key)` 保证**每用户最多一个默认**(多个 NULL 不冲突)
- `status`(ACTIVE/DELETED)、`created_at/updated_at`
- `UNIQUE(user_id, version_no)`:并发创建时版本号唯一(DB 硬约束,Service 层独立事务重试)
**`resume_export_tasks`**(导出任务)
- `id`、`user_id`(FK→users)、`resume_version_id`(FK→resume_versions **ON DELETE SET NULL**)
- `resume_version_name`(版本名快照,删除版本后任务仍可读)
- **源文件快照**(V15 新增,创建任务时写入,worker 据此读取而非查当前版本):
`source_storage_path`、`source_file_name`、`source_content_type`、`source_file_size`
- `status`:`PENDING → RUNNING → SUCCESS / FAILED`
- `output_path`/`output_name`(成功时,`output_path` 仅内部使用**不对外返回**)、`error_message`(**失败必须记录原因**)
- `created_at/started_at/finished_at`
## 2.1 文件一致性保证
- **修改版本文件**:先校验并保存新文件 → 更新数据库 → 事务提交后再删除旧文件;数据库保存失败时清理刚保存的新文件,旧文件保持可用;若旧文件正被进行中的导出任务引用(快照),则不删除以保导出一致性。
- **删除版本**:先完成数据库删除,事务提交后再删除存储文件;数据库失败时不得删除原文件。
- **创建版本**:版本号并发重试最终失败或数据库写入失败时,清理已保存但未被引用的新文件,不留下孤儿文件。
## 3. 接口清单
### 3.1 创建简历版本
```
POST /profile/me/resume-versions (multipart/form-data)
```
权限:**学生本人**。
表单字段:
| 字段 | 必填 | 约束 |
|---|---|---|
| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 |
| `name` | 否 | ≤120 字符,可空 |
默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。
### 3.2 简历版本列表
```
GET /profile/me/resume-versions
```
权限:学生本人。返回本人 ACTIVE 版本,`version_no` 倒序。
响应**不包含** `userId`、`filePath` 等内部字段:
```json
{ "code": 200, "data": [ { "id": 1, "name": "秋招版", "versionNo": 2,
"fileName": "resume.pdf", "contentType": "application/pdf", "fileSize": 1048576,
"default": false, "status": "ACTIVE",
"createdAt": "...", "updatedAt": "..." } ] }
```
### 3.3 简历版本详情
```
GET /profile/me/resume-versions/{id}
```
权限:学生本人。他人 403(`FORBIDDEN`),不存在/已删除 404(`RESUME_VERSION_NOT_FOUND`)。
### 3.4 修改简历版本
```
PUT /profile/me/resume-versions/{id} (multipart/form-data)
```
权限:学生本人。可选字段(均不传则不更新):
- `file`:替换文件(校验同上,替换时删除旧文件);
- `name`:重命名。
### 3.5 删除简历版本
```
DELETE /profile/me/resume-versions/{id}
```
权限:学生本人。
- **默认版本不可删除**:必须先设置其他默认(409 `DEFAULT_RESUME_DELETE_NOT_ALLOWED`);
- **有 PENDING/RUNNING 导出任务不可删除**(409 `RESUME_EXPORT_IN_PROGRESS`);
- 其余:删除存储文件并移除记录(硬删除)。
### 3.6 设为默认简历
```
POST /profile/me/resume-versions/{id}/default
```
权限:学生本人。同事务内清除旧默认并设置新默认;已默认幂等;并发冲突 409 `DUPLICATE_DEFAULT_RESUME`。
同一用户最多一个默认(DB 唯一约束 `uk_resume_versions_default`)。
### 3.7 创建导出任务
```
POST /profile/me/resume-versions/{id}/export
```
权限:学生本人。创建 `PENDING` 任务,并将当前版本的**源文件快照**(`source_storage_path/file_name/content_type/file_size`)写入任务;事务提交后触发 `@Async` worker:
读取任务快照中的源文件 → 复制到独立导出目录 `./resume-exports/{userId}/{taskId}{ext}` → `SUCCESS`;
源文件缺失/IO 异常 → `FAILED` + `error_message`。返回任务视图。
> 由于 worker 从任务快照读取源文件,任务创建后即使立刻修改/替换简历版本,导出结果仍为创建任务时的原文件。
### 3.8 导出任务状态
```
GET /resume-export-tasks/{taskId}
```
权限:学生本人(task.userId = 当前用户,否则 403;不存在 404)。
响应**不包含** `userId`、`outputPath`、`source_*` 等内部字段:
```json
{ "code": 200, "data": { "id": 10, "resumeVersionId": 1, "resumeVersionName": "秋招版",
"status": "SUCCESS", "outputName": "resume.pdf", "downloadAvailable": true,
"errorMessage": null, "createdAt": "...", "startedAt": "...", "finishedAt": "..." } }
```
### 3.9 下载导出产物
```
GET /resume-export-tasks/{taskId}/download
```
权限:学生本人。
- 仅任务创建者本人且任务状态为 `SUCCESS` 可下载;
- 其他用户 403(`FORBIDDEN`);任务不存在 404(`RESUME_EXPORT_TASK_NOT_FOUND`);未完成/失败 409(`RESUME_EXPORT_NOT_READY`);
- 使用 `Content-Disposition: attachment`,文件名取任务保存的 `outputName`,Content-Type 取 `source_content_type`;
- 不返回任何 Windows/Linux 绝对路径、`file_storage_path`、`output_path`。
## 4. 权限矩阵
| 操作 | 本人(学生) | 其他学生 | 企业 | 未登录 |
|---|---|---|---|---|
| 版本 增删改查 / 设默认 / 创建导出 | ✅ | 403 | 403 | 401 |
| 导出任务查询 / 下载 | ✅(task.userId=本人) | 403 | 403 | 401 |
安全说明:
- 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**;
- 不能读取其他用户的简历(owner 校验);
- 不返回内部堆栈;错误响应不泄露数据库结构。
## 5. 异步导出设计
- 新增 `AsyncConfig`:`@EnableAsync` + 专用线程池 `resumeExportExecutor`(core 2 / max 4 / queue 100,可配 `app.async.resume-export.*`)。
- `ResumeExportService.createExportTask`:校验本人+版本 → 保存 `PENDING` → 通过 `TransactionSynchronizationManager` **afterCommit** 触发 `ResumeExportWorker.execute(taskId)`(保证 worker 在任务行提交后才读得到)。
- `ResumeExportWorker.execute`:`@Async + @Transactional`。`RUNNING` → **从任务快照读取源文件** → 复制到导出目录 → `SUCCESS`;任何异常 → `FAILED` + 原因;finally 置 `finished_at`。所有异常内部捕获,不产生未处理异步异常。**不按 resumeVersionId 查当前版本文件**。
- 导出目录:`app.resume.export-dir`(默认 `./resume-exports`),与简历原始存储 `./resumes` 分离。
## 6. 并发控制
- **每用户最多一个默认**:`UNIQUE(user_id, default_key)` DB 硬约束 + Service 事务内"清旧设新"。
- **版本号不重复**:`UNIQUE(user_id, version_no)` DB 硬约束;`ResumeVersionWriter` 用 `REQUIRES_NEW` 独立事务插入(唯一键冲突会标记事务 rollback-only,独立事务保证单次冲突只回滚自身,外层短退避后重试)。
- 并发测试:两版本并发设默认 → 最终恰一个默认;并发创建 → 版本号不重复。
## 7. 数据库迁移说明(V14)
文件:`src/main/resources/db/migration/V14__resume_versions.sql`(兼容 MySQL 8 / H2 MODE=MySQL)。
- `resume_versions`:如上,含 FK(user)、`uk_resume_versions_user_no`、`uk_resume_versions_default`、索引 `(user_id, created_at)`、`(status)`。
- `resume_export_tasks`:FK(user)、FK(version) **ON DELETE SET NULL**(删除版本不破坏任务历史)、索引 `(user_id, created_at)`、`(status)`、`(resume_version_id)`。
## 8. Apifox 测试步骤
前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。
1. 登录学生账号(Mock 验证码,见日志)。
2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。
3. 再次上传 → `versionNo=2`;设置 `name=社招版`。
4. `GET /profile/me/resume-versions` → 列表含两个版本。
5. `POST /profile/me/resume-versions/{id}/default` → `default=true`;再次设置另一个 → 默认切换。
6. `DELETE` 默认版本 → 409;先设其他默认后删除 → 200。
7. `POST /profile/me/resume-versions/{id}/export` → 200 `PENDING`;`GET /resume-export-tasks/{taskId}` 轮询至 `SUCCESS`;`GET /resume-export-tasks/{taskId}/download` 下载导出文件。
8. 越权:另一学生 token 访问详情/导出任务/下载 → 403;无 token → 401;未完成任务下载 → 409。
## 9. 已知限制
1. **导出产物为原文件副本**:不解析/渲染 PDF/DOC 内容(无渲染依赖),`outputName` 为原文件名。
2. **版本号冲突重试有上限**:极端并发(3 次重试仍冲突)返回 400,客户端可重试;DB 唯一约束保证不会产生重复版本号。
3. **`version_no` 不回收**:删除版本不回收编号(不影响功能)。
4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。
5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。
6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。
## 10. 错误码(新增 82xx)
| 错误码 | 枚举 | HTTP | 含义 |
|---|---|---|---|
| 8201 | `RESUME_VERSION_NOT_FOUND` | 404 | 简历版本不存在/已删除 |
| 8202 | `DEFAULT_RESUME_DELETE_NOT_ALLOWED` | 409 | 默认简历删除前需先设其他默认 |
| 8203 | `RESUME_EXPORT_TASK_NOT_FOUND` | 404 | 导出任务不存在 |
| 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 |
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 |

89
docs/review-moderation-api.md

@ -0,0 +1,89 @@
# 评价审核与防刷基础模块接口说明
> 职来 (mydreamjob) · 评价审核与防刷基础模块
> 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径
## 1. 概述
在岗位评价基础上加入**确定性规则预审(非 AI)**与**管理员审核**:
- 无风险评价 → `moderationStatus = AUTO_APPROVED`,行为与旧版一致(同步 200、公开展示);
- 命中规则 → 评价**仍保存成功**,`moderationStatus = PENDING`,**不进入**公开列表/详情/汇总;
- 管理员可 `APPROVED`(进入公开)或 `REJECTED`(永不公开);
- 公开查询只返回 `status=PUBLISHED` 且 `moderationStatus ∈ {AUTO_APPROVED, APPROVED}`。
审核状态值:`AUTO_APPROVED` / `PENDING` / `APPROVED` / `REJECTED`。
## 2. 防刷规则(确定性 MVP)
| 原因码 | 规则 | 权重 |
|---|---|---|
| `SENSITIVE_WORD` | 内容包含配置中的敏感词(`app.review-moderation.sensitive-words`) | +2 |
| `CONTACT_INFO` | 包含手机号 / QQ / 微信 / 外链 | +2 |
| `PATTERN_ANOMALY` | 连续重复字符或异常超长(≥800 字) | +1 |
| `RAPID_SUBMISSION` | 同一用户窗口期内重复提交(默认 60s 内 ≥3) | +1 |
| `DUPLICATE_CONTENT` | 同岗位窗口期内出现相同内容 SHA-256(默认 300s) | +2 |
| `ABUSIVE_BATCH` | 同一 IP/User-Agent 哈希窗口期批量(默认 300s 内 ≥5) | +2 |
- 敏感词来自配置项,不硬编码在 Controller;命中时**只返回原因码,不返回敏感词原文**;
- 原始 IP / User-Agent **不落明文**,仅存 SHA-256 哈希;
- 内容哈希只用于风险识别,不影响正常评价。
## 3. 公开查询兼容(不变路径)
- `GET /jobs/{jobId}/reviews` / `GET /jobs/{jobId}/reviews/{reviewId}` / `GET /jobs/{jobId}/reviews/summary`
仅返回/统计 `AUTO_APPROVED` 与 `APPROVED` 的评价;`PENDING`/`REJECTED` 不公开。
- 八维 `dimensionScores` 与 `dimensionSummary` 逻辑不变。
## 4. 审核管理接口(仅 ADMIN,身份来自 JWT)
### 4.1 待审列表 `GET /admin/reviews/pending`
- 仅 ADMIN(非 ADMIN → 403,未登录 → 401);分页;
- 可选筛选:`jobId`、`riskLevel`(LOW/MEDIUM/HIGH)、`createdFrom`、`createdTo`;
- 返回:`reviewId, jobId, studentUserId, rating, content, tags, dimensionScores,
moderationStatus, reasonCodes, riskLevel, moderationScore, createdAt`;
- **不返回**明文 IP / User-Agent / 内部哈希。
### 4.2 审核决策 `POST /admin/reviews/{reviewId}/decision`
```json
{ "decision": "APPROVED|REJECTED", "reason": "人工审核说明(可选)" }
```
- 仅 ADMIN;仅 `PENDING` 可审,已审核重复提交 → **409 `REVIEW_ALREADY_DECIDED`**;
- `APPROVED` → 进入公开列表/汇总;`REJECTED` → 永不公开;
- 决策写入 `review_moderation_history` 与 `audit_logs`;审核人从 JWT 获取。
### 4.3 审核历史 `GET /admin/reviews/history`
- 仅 ADMIN;分页;返回 `id, reviewId, action, actorUserId, reasonCodes, reason, createdAt`
(含自动预审标记与管理员决策)。
## 5. 权限
| 操作 | ADMIN | 学生本人 | 其他学生 | 企业 | 未登录 |
|---|---|---|---|---|---|
| 创建评价 | — | ✅ | 403(重复→409) | 403 | 401 |
| 评价列表/详情/汇总 | 公开 | 公开 | 公开 | 公开 | ✅ |
| 待审/决策/历史 | ✅ | 403 | 403 | 403 | 401 |
审核人/操作者一律来自 JWT,不信任请求体 userId。
## 6. 数据库(V18)
`V18__job_review_moderation.sql`:
- `job_reviews` 增加:`moderation_status`(默认 `AUTO_APPROVED`,历史数据自动生效)、`moderation_reason`(原因码 JSON)、`moderation_score`、`moderation_updated_at`、`risk_level`、`content_hash`、`ip_hash`、`ua_hash`(SHA-256,不存明文);`moderation_status` 建索引;
- 新表 `review_moderation_history`(自动预审标记 + 管理员决策,级联删除);
- 不改既有表结构,V1-V17 历史迁移不动。
## 7. 测试
覆盖:普通评价 AUTO_APPROVED 且公开、敏感词/联系方式 PENDING 且不公开、内容哈希风险、IP/UA 仅存哈希、审核通过后公开、审核拒绝不公开、非 ADMIN 403、未登录 401、重复审核 409、历史迁移默认 AUTO_APPROVED、维度仍可保存读取、并发重复仅一条成功、审核写入 audit_logs、既有 526 测试回归。
## 8. 已知限制
1. 规则为确定性 MVP,不声称 AI;权重/窗口为默认配置,可按运营调整。
2. `moderation_reason` 仅存原因码,敏感词原文不落库、不返回。
3. 已审核(`APPROVED/REJECTED`)评价如需复审需产品确认(当前不可重复决策)。
4. OpenAPI 快照:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**59 路径**),现网可用 `GET /api/v1/v3/api-docs` 重新导出。

471
docs/review-weight-api.md

@ -0,0 +1,471 @@
# 岗位评价个性化权重与权重模板 API
## 概述
本模块提供岗位评价维度的个性化权重配置和基于自定义权重的岗位评分计算。学生用户可以:
- 查看预定义的权重模板
- 配置个人权重偏好(模板或自定义)
- 基于个人权重计算岗位的个性化评分
## 接口列表
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/review-weight-templates` | 已登录 | 获取权重模板列表 |
| GET | `/profile/me/review-weights` | STUDENT | 获取当前学生的权重配置 |
| PUT | `/profile/me/review-weights` | STUDENT | 保存当前学生的权重配置 |
| GET | `/jobs/{jobId}/personalized-review-score` | STUDENT | 基于用户权重计算岗位个性化评分 |
### 权限矩阵
| 角色 | 模板列表 | 权重配置 | 个性化评分 |
|------|---------|---------|-----------|
| 未登录 | 401 | 401 | 401 |
| STUDENT | 200 | 200 | 200 |
| COMPANY | 200 | 403 | 403 |
| ADMIN | 200 | 403 | 403 |
---
## 接口详情
### 1. GET /review-weight-templates
获取所有预定义的权重模板。
**请求示例:**
```
GET /review-weight-templates
Authorization: Bearer <token>
```
**响应示例:**
```json
{
"code": 200,
"message": "success",
"data": [
{
"code": "BALANCED",
"name": "均衡权重",
"description": "八个维度平均分配",
"weights": {
"AUTHENTICITY": 12.50,
"BENEFITS": 12.50,
"SALARY_TRANSPARENCY": 12.50,
"JD_CLARITY": 12.50,
"GROWTH": 12.50,
"TEAM_ATMOSPHERE": 12.50,
"PROMOTION": 12.50,
"WORK_LIFE_BALANCE": 12.50
}
}
]
}
```
---
### 2. GET /profile/me/review-weights
获取当前学生的权重配置。未配置时返回系统默认配置(BALANCED 模板,source="SYSTEM_DEFAULT")。
**请求示例:**
```
GET /profile/me/review-weights
Authorization: Bearer <token>
```
**响应示例(未配置):**
```json
{
"code": 200,
"message": "success",
"data": {
"source": "SYSTEM_DEFAULT",
"templateCode": "BALANCED",
"configuredWeights": {
"AUTHENTICITY": 12.50,
"BENEFITS": 12.50,
"SALARY_TRANSPARENCY": 12.50,
"JD_CLARITY": 12.50,
"GROWTH": 12.50,
"TEAM_ATMOSPHERE": 12.50,
"PROMOTION": 12.50,
"WORK_LIFE_BALANCE": 12.50
},
"updatedAt": null
}
}
```
**响应示例(已配置模板):**
```json
{
"code": 200,
"message": "success",
"data": {
"source": "TEMPLATE",
"templateCode": "SALARY_FIRST",
"configuredWeights": {
"AUTHENTICITY": 15.00,
"BENEFITS": 20.00,
"SALARY_TRANSPARENCY": 30.00,
"JD_CLARITY": 10.00,
"GROWTH": 10.00,
"TEAM_ATMOSPHERE": 5.00,
"PROMOTION": 5.00,
"WORK_LIFE_BALANCE": 5.00
},
"updatedAt": "2026-08-14T10:00:00"
}
}
```
**响应示例(自定义配置):**
```json
{
"code": 200,
"message": "success",
"data": {
"source": "CUSTOM",
"templateCode": null,
"configuredWeights": {
"AUTHENTICITY": 15.00,
"BENEFITS": 15.00,
"SALARY_TRANSPARENCY": 25.00,
"JD_CLARITY": 10.00,
"GROWTH": 10.00,
"TEAM_ATMOSPHERE": 10.00,
"PROMOTION": 10.00,
"WORK_LIFE_BALANCE": 5.00
},
"updatedAt": "2026-08-14T10:00:00"
}
}
```
---
### 3. PUT /profile/me/review-weights
保存当前学生的权重配置。支持模板模式和自定义模式。
**请求体(模板模式):**
```json
{
"templateCode": "BALANCED"
}
```
**请求体(自定义模式):**
```json
{
"weights": {
"AUTHENTICITY": 15.00,
"BENEFITS": 15.00,
"SALARY_TRANSPARENCY": 25.00,
"JD_CLARITY": 10.00,
"GROWTH": 10.00,
"TEAM_ATMOSPHERE": 10.00,
"PROMOTION": 10.00,
"WORK_LIFE_BALANCE": 5.00
}
}
```
**请求校验规则:**
1. `templateCode` 和 `weights` 必须且只能提供一个
2. 模板模式:`templateCode` 必须是 `BALANCED`、`SALARY_FIRST`、`GROWTH_FIRST`、`STABILITY_FIRST` 之一
3. 自定义模式:
- 必须包含全部 8 个维度
- 每个权重值必须在 `[0.00, 100.00]` 范围内
- 每个权重值最多保留两位小数(如 `12.345` 会被拒绝,返回 400)
- 8 个权重值总和必须精确等于 `100.00`
4. 并发冲突:同一用户并发更新时返回 409(错误码 9106)
**错误响应示例:**
```json
{
"code": 1001,
"message": "bad request",
"requestId": "xxx",
"timestamp": "2026-08-14T10:00:00Z"
}
```
```json
{
"code": 9106,
"message": "concurrent weight update conflict",
"requestId": "xxx",
"timestamp": "2026-08-14T10:00:00Z"
}
```
---
### 4. GET /jobs/{jobId}/personalized-review-score
基于用户权重配置计算岗位的个性化评分。仅支持 `PUBLISHED` 状态的岗位。
**请求示例:**
```
GET /jobs/1/personalized-review-score
Authorization: Bearer <token>
```
**响应示例(正常计算):**
```json
{
"code": 200,
"message": "success",
"data": {
"jobId": 1,
"score": 78.50,
"status": "OK",
"source": "CUSTOM",
"templateCode": null,
"configuredWeights": {
"AUTHENTICITY": 15.00,
"BENEFITS": 15.00,
"SALARY_TRANSPARENCY": 25.00,
"JD_CLARITY": 10.00,
"GROWTH": 10.00,
"TEAM_ATMOSPHERE": 10.00,
"PROMOTION": 10.00,
"WORK_LIFE_BALANCE": 5.00
},
"effectiveWeights": {
"AUTHENTICITY": 25.00,
"BENEFITS": 25.00,
"SALARY_TRANSPARENCY": 33.33,
"GROWTH": 16.67
},
"dimensionScores": {
"AUTHENTICITY": 4.20,
"BENEFITS": 3.80,
"SALARY_TRANSPARENCY": 4.50,
"JD_CLARITY": null,
"GROWTH": 3.00,
"TEAM_ATMOSPHERE": null,
"PROMOTION": null,
"WORK_LIFE_BALANCE": null
},
"coveredWeight": 60.00,
"missingDimensions": ["JD_CLARITY", "TEAM_ATMOSPHERE", "PROMOTION", "WORK_LIFE_BALANCE"]
}
}
```
**响应示例(数据不足):**
```json
{
"code": 200,
"message": "success",
"data": {
"jobId": 1,
"score": null,
"status": "INSUFFICIENT_DATA",
"source": "SYSTEM_DEFAULT",
"templateCode": "BALANCED",
"configuredWeights": { ... },
"effectiveWeights": {},
"dimensionScores": {
"AUTHENTICITY": null,
"BENEFITS": null,
"SALARY_TRANSPARENCY": null,
"JD_CLARITY": null,
"GROWTH": null,
"TEAM_ATMOSPHERE": null,
"PROMOTION": null,
"WORK_LIFE_BALANCE": null
},
"coveredWeight": 0,
"missingDimensions": ["AUTHENTICITY", "BENEFITS", "SALARY_TRANSPARENCY", "JD_CLARITY", "GROWTH", "TEAM_ATMOSPHERE", "PROMOTION", "WORK_LIFE_BALANCE"]
}
}
```
**错误响应:**
- 岗位不存在或非 PUBLISHED 状态:404(code=7001)
- 非 STUDENT 角色:403(code=2002)
- 未登录:401(code=401)
---
## 权重模板
### 四套预定义模板
#### BALANCED(均衡权重)
| 维度 | 权重 |
|------|------|
| AUTHENTICITY | 12.50 |
| BENEFITS | 12.50 |
| SALARY_TRANSPARENCY | 12.50 |
| JD_CLARITY | 12.50 |
| GROWTH | 12.50 |
| TEAM_ATMOSPHERE | 12.50 |
| PROMOTION | 12.50 |
| WORK_LIFE_BALANCE | 12.50 |
| **合计** | **100.00** |
#### SALARY_FIRST(薪资优先)
| 维度 | 权重 |
|------|------|
| AUTHENTICITY | 15.00 |
| BENEFITS | 20.00 |
| SALARY_TRANSPARENCY | 30.00 |
| JD_CLARITY | 10.00 |
| GROWTH | 10.00 |
| TEAM_ATMOSPHERE | 5.00 |
| PROMOTION | 5.00 |
| WORK_LIFE_BALANCE | 5.00 |
| **合计** | **100.00** |
#### GROWTH_FIRST(成长优先)
| 维度 | 权重 |
|------|------|
| AUTHENTICITY | 10.00 |
| BENEFITS | 5.00 |
| SALARY_TRANSPARENCY | 5.00 |
| JD_CLARITY | 15.00 |
| GROWTH | 30.00 |
| TEAM_ATMOSPHERE | 10.00 |
| PROMOTION | 20.00 |
| WORK_LIFE_BALANCE | 5.00 |
| **合计** | **100.00** |
#### STABILITY_FIRST(稳定优先)
| 维度 | 权重 |
|------|------|
| AUTHENTICITY | 10.00 |
| BENEFITS | 25.00 |
| SALARY_TRANSPARENCY | 15.00 |
| JD_CLARITY | 5.00 |
| GROWTH | 5.00 |
| TEAM_ATMOSPHERE | 5.00 |
| PROMOTION | 5.00 |
| WORK_LIFE_BALANCE | 30.00 |
| **合计** | **100.00** |
---
## 八维评价维度
| 维度代码 | 说明 |
|---------|------|
| AUTHENTICITY | 信息真实性 |
| BENEFITS | 福利待遇 |
| SALARY_TRANSPARENCY | 薪资透明度 |
| JD_CLARITY | 职位描述清晰度 |
| GROWTH | 成长空间 |
| TEAM_ATMOSPHERE | 团队氛围 |
| PROMOTION | 晋升机制 |
| WORK_LIFE_BALANCE | 工作生活平衡 |
---
## 个性化评分计算规则
### 数据来源
使用专用聚合查询直接从 `job_review_dimension_scores` 表按 `jobId` 聚合,只统计状态为 `PUBLISHED` 且审核状态为 `AUTO_APPROVED` 或 `APPROVED` 的评价。不修改原有 `/jobs/{id}/reviews/summary` 接口的语义。
### 维度可用性判定
仅当某个维度同时满足以下条件时,该维度才参与计算:
- `sampleCount >= 3`
- `averageScore` 非空
`sampleCount` 为 1 或 2 的维度视为缺失维度。
### 计算步骤
1. **coveredWeight**:所有可用维度的 configuredWeight 之和
2. 若 `coveredWeight = 0`:
- `score = null`
- `status = INSUFFICIENT_DATA`
- `effectiveWeights = {}`
- `missingDimensions` = 完整八维
3. **effectiveWeight**(归一化):
```
effectiveWeight = configuredWeight / coveredWeight × 100
```
最后一项 effectiveWeight 用差额修正,确保总和严格等于 100.00
4. **单维百分制分数**:
```
dimensionScore100 = averageScore / 5 × 100
```
5. **最终分数**:
```
score = Σ(dimensionScore100 × effectiveWeight / 100)
```
### 精度要求
- 全程使用 `BigDecimal`
- 中间除法 `scale=8`、`HALF_UP`
- 最终 score、coveredWeight、effectiveWeight 保留两位 `HALF_UP`
---
## 并发冲突处理
当同一用户并发更新权重配置时,系统使用乐观锁(`@Version`)进行控制。
| 异常类型 | 错误码 | HTTP 状态码 |
|---------|--------|------------|
| `ObjectOptimisticLockingFailureException` | 9106 | 409 Conflict |
| `DataIntegrityViolationException`(UNIQUE 冲突) | 9106 | 409 Conflict |
任何异常发生时,整笔事务回滚,不会留下不完整的权重配置。
---
## 接入方式
### Apifox 导入
1. 打开 Apifox,选择"导入数据"
2. 选择 "OpenAPI/Swagger" 格式
3. 导入文件:`docs/openapi-rc.json`
4. 配置 Base URL:`http://localhost:8080`
### Base URL
- 本地开发:`http://localhost:8080`
- 测试环境:根据实际部署地址配置
---
## 不修改的既有接口
本模块不修改以下已有接口:
- `GET /jobs/{id}/reviews/summary` — 岗位评价汇总
- 所有 V1–V22 迁移和已验收接口

174
docs/smoke-test.sh

@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -u
# MyDreamJob backend RC smoke test.
# Required tools: curl, grep, sed, head, tail.
# Environment:
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SERVER_LOG Server log path used to read Mock SMS codes
BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}"
SERVER_LOG="${SERVER_LOG:-}"
PASS=0
FAIL=0
SKIP=0
if [ -n "${STUDENT_PHONE:-}" ]; then
PHONE="$STUDENT_PHONE"
else
# 139 + eight digits = 11 digits.
PHONE="139$(date +%s | tail -c 9)"
fi
say() { printf '%-58s' "$1"; }
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); }
resolve_log() {
case "$SERVER_LOG" in
[A-Za-z]:/*)
local drive rest
drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')"
rest="${SERVER_LOG:2}"
if [ -d /mnt/c ]; then
echo "/mnt/$drive$rest"
else
echo "/$drive$rest"
fi
;;
*) echo "$SERVER_LOG" ;;
esac
}
report() {
local response="$1"
local http_code body biz_code request_id
http_code="$(printf '%s\n' "$response" | tail -n 1)"
body="$(printf '%s\n' "$response" | sed '$d')"
biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 || true)"
request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)"
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
extract_token() {
printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}
extract_id() {
printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1
}
sms_code() {
local log_path
log_path="$(resolve_log)"
[ -n "$log_path" ] || return 1
[ -f "$log_path" ] || return 1
grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2
}
echo "=== MyDreamJob backend smoke test ==="
echo "BASE_URL=$BASE_URL"
echo "PHONE=$PHONE"
say "GET /health"
HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)"
report "$HEALTH"
if [ "$(printf '%s\n' "$HEALTH" | tail -n 1)" = "200" ]; then ok "health"; else fail "health"; fi
say "POST /auth/sms/send and POST /auth/login"
SMS=""
for attempt in 1 2 3; do
curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \
-H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true
sleep 2
SMS="$(sms_code || true)"
[ -n "$SMS" ] && break
done
if [ -z "$SMS" ]; then
fail "cannot read Mock SMS code; set SERVER_LOG to the server log path"
TOKEN=""
else
LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-login-001' \
-d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)"
report "$LOGIN"
TOKEN="$(extract_token "$LOGIN")"
if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi
fi
if [ -z "$TOKEN" ]; then
echo "PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
exit 1
fi
AUTH="Authorization: Bearer $TOKEN"
say "GET /jobs?size=5"
JOBS="$(curl -sS -w '\n%{http_code}' "$BASE_URL/jobs?size=5" -H "$AUTH" || true)"
report "$JOBS"
JOB_ID="$(extract_id "$JOBS")"
if printf '%s' "$JOBS" | grep -q 'items' && [ -n "$JOB_ID" ]; then ok "jobs list"; else fail "jobs list"; fi
say "GET /jobs/public/$JOB_ID"
PUBLIC="$(curl -sS -w '\n%{http_code}' "$BASE_URL/jobs/public/$JOB_ID" || true)"
report "$PUBLIC"
if printf '%s' "$PUBLIC" | grep -q 'title'; then ok "public job"; else fail "public job"; fi
say "GET /jobs/$JOB_ID/match"
MATCH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/jobs/$JOB_ID/match" -H "$AUTH" || true)"
report "$MATCH"
if printf '%s' "$MATCH" | grep -q 'rulesVersion'; then ok "match diagnose"; else fail "match diagnose"; fi
say "POST /applications"
APP="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/applications" \
-H "$AUTH" -H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-apply-001' -d "{\"jobId\":$JOB_ID}" || true)"
report "$APP"
APP_ID="$(extract_id "$APP")"
if [ -n "$APP_ID" ]; then ok "apply application $APP_ID"; else fail "apply application"; fi
say "PATCH /applications/$APP_ID/status"
if [ -z "${COMPANY_TOKEN:-}" ]; then
skip "COMPANY_TOKEN not provided"
else
STATUS="$(curl -sS -w '\n%{http_code}' -X PATCH "$BASE_URL/applications/$APP_ID/status" \
-H "Authorization: Bearer $COMPANY_TOKEN" -H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-status-001' -d '{"status":"VIEWED"}' || true)"
report "$STATUS"
if printf '%s' "$STATUS" | grep -q 'VIEWED'; then ok "company status VIEWED"; else fail "company status"; fi
fi
say "GET /notifications"
NOTIFICATIONS="$(curl -sS -w '\n%{http_code}' "$BASE_URL/notifications?size=5" -H "$AUTH" || true)"
report "$NOTIFICATIONS"
if printf '%s' "$NOTIFICATIONS" | grep -q 'items'; then ok "notifications list"; else fail "notifications list"; fi
say "GET /notifications/unread-count"
UNREAD="$(curl -sS -w '\n%{http_code}' "$BASE_URL/notifications/unread-count" -H "$AUTH" || true)"
report "$UNREAD"
if printf '%s' "$UNREAD" | grep -q 'unreadCount'; then ok "unread count"; else fail "unread count"; fi
NOTIFICATION_ID="$(extract_id "$NOTIFICATIONS")"
say "POST /notifications/$NOTIFICATION_ID/read"
if [ -z "$NOTIFICATION_ID" ]; then
skip "no notification available"
else
READ="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/notifications/$NOTIFICATION_ID/read" -H "$AUTH" || true)"
report "$READ"
if printf '%s' "$READ" | grep -q 'marked as read'; then ok "mark notification read"; else fail "mark notification read"; fi
fi
say "POST /applications/$APP_ID/withdraw"
WITHDRAW="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/applications/$APP_ID/withdraw" \
-H "$AUTH" -H 'X-Request-Id: smoke-withdraw-001' || true)"
report "$WITHDRAW"
if printf '%s' "$WITHDRAW" | grep -q 'WITHDRAWN'; then ok "withdraw application"; else fail "withdraw application"; fi
echo "============================================"
echo "PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
echo "============================================"
[ "$FAIL" -eq 0 ]

32
docs/verify_openapi.py

@ -0,0 +1,32 @@
import json
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 81, f"Expected 81 paths, got {len(d['paths'])}"
paths = d['paths']
required = [
'/review-weight-templates',
'/profile/me/review-weights',
'/jobs/{jobId}/personalized-review-score',
]
for path in required:
assert path in paths, f"Missing path: {path}"
print(f" Found: {path}")
assert 'get' in paths['/review-weight-templates'], "Missing GET /review-weight-templates"
assert 'get' in paths['/profile/me/review-weights'], "Missing GET /profile/me/review-weights"
assert 'put' in paths['/profile/me/review-weights'], "Missing PUT /profile/me/review-weights"
assert 'get' in paths['/jobs/{jobId}/personalized-review-score'], "Missing GET /jobs/{jobId}/personalized-review-score"
print(" 4 operations all present")
data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found")
print("Valid JSON: True")
print("All checks passed!")

56
mydreamjob-server/.gitignore

@ -0,0 +1,56 @@
# Build outputs
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
# IDE
.idea/
*.iml
*.iws
*.ipr
.vscode/
.settings/
.project
.classpath
.factorypath
# Logs
logs/
*.log
log/
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
# Application secrets
src/main/resources/application-dev.yml
src/main/resources/application-prod.yml
*.local.yml
# Compiled class files
*.class
# Temp files
*.tmp
*.temp
# Maven
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# Gradle
.gradle
/build

215
mydreamjob-server/README.md

@ -0,0 +1,215 @@
# 职来 (mydreamjob) 后端服务
> Spring Boot 3.2.5 + Java 17 | 职来大学生求职服务平台后端基线
## 项目结构
```
mydreamjob-server/
├── pom.xml # Maven 依赖配置
├── .gitignore # Git 忽略规则
└── src/
├── main/
│ ├── java/com/mydreamjob/
│ │ ├── MyDreamjobApplication.java
│ │ ├── config/ # 配置类
│ │ │ ├── SecurityConfig.java
│ │ │ ├── RedisConfig.java
│ │ │ ├── RabbitConfig.java
│ │ │ ├── WebMvcConfig.java
│ │ │ └── SchedulingConfig.java
│ │ ├── common/
│ │ │ ├── api/ # 统一响应格式
│ │ │ │ ├── ApiResponse.java
│ │ │ │ └── PageResponse.java
│ │ │ ├── exception/ # 异常处理
│ │ │ │ ├── BusinessException.java
│ │ │ │ ├── ErrorCode.java
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ ├── logging/ # 日志过滤
│ │ │ │ ├── RequestIdFilter.java
│ │ │ │ └── RequestLoggingFilter.java
│ │ │ └── health/ # 健康检查
│ │ │ └── HealthController.java
│ │ ├── auth/ # 认证模块
│ │ │ ├── User.java
│ │ │ ├── SmsVerificationCode.java
│ │ │ ├── AuthToken.java
│ │ │ ├── UserRepository.java
│ │ │ ├── SmsVerificationCodeRepository.java
│ │ │ ├── AuthTokenRepository.java
│ │ │ ├── JwtTokenProvider.java
│ │ │ ├── JwtAuthenticationFilter.java
│ │ │ ├── SmsVerificationService.java
│ │ │ └── AuthService.java
│ │ ├── audit/ # 审计模块
│ │ │ ├── AuditLog.java
│ │ │ ├── AuditLogRepository.java
│ │ │ └── AuditService.java
│ │ ├── controller/ # 控制器
│ │ │ ├── AuthController.java
│ │ │ ├── SystemController.java
│ │ │ └── ApiController.java
│ │ ├── job/ # 岗位模块(骨架)
│ │ ├── career/ # 投递模块(骨架)
│ │ ├── messaging/ # 聊天模块(骨架)
│ │ └── profile/ # 评分模块(骨架)
│ ├── resources/
│ │ ├── application.yml # 主配置
│ │ ├── application-dev.yml # 开发环境配置
│ │ ├── application-dev.example.yml # 配置模板
│ │ └── db/migration/
│ │ ├── V1__base_schema.sql # 基础表结构
│ │ └── V1_1__seed_data.sql # 种子数据
│ └── test/java/com/mydreamjob/
│ ├── MyDreamjobApplicationTest.java
│ ├── auth/
│ │ ├── SmsVerificationServiceTest.java
│ │ └── JwtTokenProviderTest.java
└── docs/ # 项目文档
├── ERD.md
├── api-v0.1.md
└── acceptance-week1.md
```
## 快速启动
### 1. 启动基础设施
```bash
cd backend-infra
docker compose up -d
```
### 2. 验证依赖服务
```bash
# MySQL (3307)
mysql -h 127.0.0.1 -P 3307 -u root -p"$MYSQL_ROOT_PASSWORD" -e 'SELECT 1'
# Redis (6379)
redis-cli -h 127.0.0.1 -p 6379 ping
# RabbitMQ 管理界面 (非 guest,使用环境变量账号)
curl http://127.0.0.1:15672/api/health/check-alive -u "$RABBITMQ_USERNAME:$RABBITMQ_PASSWORD"
```
### 3. 编译与测试
```bash
cd mydreamjob-server
mvn clean compile
mvn test
```
### 4. 启动应用
```bash
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
### 5. 验证接口
```bash
# 健康检查
curl http://localhost:8080/api/v1/health
# 发送验证码
curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login"
# 登录(从日志获取验证码)
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}'
# 查看用户信息
curl http://localhost:8080/api/v1/auth/me \
-H "Authorization: Bearer <accessToken>"
```
## 环境变量
> **生产安全要求**:`JWT_SECRET`、`DB_PASSWORD`、`RABBITMQ_USERNAME`、`RABBITMQ_PASSWORD` **必须**通过环境变量显式提供,禁止使用默认值或写入代码。缺失时应用启动失败。
| 变量 | 默认值 | 说明 |
|------|--------|------|
| SPRING_PROFILES_ACTIVE | dev | 激活的 Spring Profile |
| DB_URL | jdbc:mysql://127.0.0.1:3307/mydreamjob | 数据库连接 URL |
| DB_USERNAME | mydreamjob | 数据库用户名 |
| DB_PASSWORD | **必填** | 数据库密码(无默认值) |
| REDIS_HOST | 127.0.0.1 | Redis 主机 |
| REDIS_PORT | 6379 | Redis 端口 |
| REDIS_PASSWORD | (空) | Redis 密码 |
| RABBITMQ_HOST | 127.0.0.1 | RabbitMQ 主机 |
| RABBITMQ_PORT | 5672 | RabbitMQ 端口 |
| RABBITMQ_USERNAME | **必填** | RabbitMQ 用户名(禁止 guest) |
| RABBITMQ_PASSWORD | **必填** | RabbitMQ 密码(禁止 guest) |
| JWT_SECRET | **必填** | JWT 签名密钥(至少 256 位,无默认值) |
| JWT_EXPIRATION_MS | 86400000 | Access Token 过期时间 (毫秒) |
| JWT_REFRESH_EXPIRATION_MS | 604800000 | Refresh Token 过期时间 (毫秒) |
| SMS_MOCK_ENABLED | true | 是否启用 Mock 短信 |
| SMS_CODE_EXPIRE_SECONDS | 300 | 验证码有效期 (秒) |
| SMS_LOCKOUT_SECONDS | 300 | 锁定时间 (秒) |
### Windows 启动方式
```powershell
# PowerShell 设置环境变量(每次新终端需重设)
$env:DB_URL = "jdbc:mysql://127.0.0.1:3307/mydreamjob?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai"
$env:DB_USERNAME = "mydreamjob"
$env:DB_PASSWORD = "<你的数据库密码>"
$env:RABBITMQ_USERNAME = "mydreamjob_dev"
$env:RABBITMQ_PASSWORD = "<你的 RabbitMQ 密码>"
$env:JWT_SECRET = "<至少256位的随机密钥>"
$env:SMS_MOCK_ENABLED = "true"
# 启动
cd mydreamjob-server
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
Mock 验证码打印在服务日志中:`MOCK SMS CODE: phone=... code=<6位验证码>`
## 端口说明
| 服务 | 端口 | 说明 |
|------|------|------|
| 后端 API | 8080 | Spring Boot 应用 |
| MySQL | 3307 | Docker 容器 (127.0.0.1:3307:3306) |
| Redis | 6379 | Docker 容器 (127.0.0.1:6379:6379) |
| RabbitMQ | 5672 | Docker 容器 (127.0.0.1:5672:5672) |
| RabbitMQ 管理 | 15672 | Docker 容器 (127.0.0.1:15672:15672) |
| Swagger UI | 8080/swagger-ui.html | OpenAPI 文档 |
## 技术栈
- **Spring Boot 3.2.5** - 应用框架
- **Java 17** - 编程语言
- **Spring Security** - 安全框架
- **Spring Data JPA** - ORM
- **Spring Data Redis** - 缓存
- **Spring AMQP** - 消息队列
- **Flyway** - 数据库迁移
- **JWT (jjwt 0.12.5)** - 认证
- **SpringDoc OpenAPI** - API 文档
- **MySQL 8.4** - 数据库
- **Redis 7.4** - 缓存/验证码存储
- **RabbitMQ 3.13** - 消息队列
- **Lombok** - 代码简化
## 测试账号
| 角色 | 手机号 | 用途 |
|------|--------|------|
| ADMIN | 13800000001 | 管理员测试 |
| STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 |
> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取
## 文档
- [ERD 设计](docs/ERD.md)
- [API v0.1 接口清单](docs/api-v0.1.md)
- [Week 1 验收文档](docs/acceptance-week1.md)
## 已知限制
1. 短信验证码为 Mock 实现,仅打印到日志(`MOCK SMS CODE: ... code=<6位>`)
2. 生产环境配置需通过环境变量提供(JWT_SECRET / DB_PASSWORD / RabbitMQ 账号),不提交真实密钥
3. `career/CareerService.java` 为历史遗留空接口占位(投递功能已由 application 模块实现),待产品确认

131
mydreamjob-server/docs/interview-api.md

@ -0,0 +1,131 @@
# 面试管理接口文档
## 概述
面试管理基础模块,支持企业创建面试、学生响应面试、企业取消/完成面试,以及面试状态时间线查看。
## 状态机
```
SCHEDULED → CONFIRMED → COMPLETED (终态)
↓ ↓
↓→ DECLINED (终态)
↓→ RESCHEDULE_REQUESTED → SCHEDULED
↓→ CANCELLED (终态)
```
## 接口列表
### 1. 创建面试 (企业)
```
POST /api/v1/applications/{applicationId}/interviews
```
**请求体:**
```json
{
"roundNo": 1,
"title": "技术面试",
"interviewType": "ONLINE",
"startAt": "2026-08-10T14:00:00",
"endAt": "2026-08-10T15:00:00",
"timezone": "Asia/Shanghai",
"meetingUrl": "https://meeting.example.com/123",
"interviewerName": "张经理",
"notes": "请准备项目经验介绍"
}
```
### 2. 查询投递的面试列表
```
GET /api/v1/applications/{applicationId}/interviews
```
### 3. 我的面试列表
```
GET /api/v1/interviews/me?status=SCHEDULED&from=2026-08-01&to=2026-08-31&page=0&size=20
```
参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | String | 否 | 状态筛选 |
| from | DateTime | 否 | 开始时间 >= from |
| to | DateTime | 否 | 开始时间 <= to |
| page | int | 否 | 页码, 默认0 |
| size | int | 否 | 每页大小, 默认20, 最大100 |
### 4. 面试详情
```
GET /api/v1/interviews/{interviewId}
```
### 5. 修改面试 (企业)
```
PUT /api/v1/interviews/{interviewId}
```
### 6. 学生响应面试
```
POST /api/v1/interviews/{interviewId}/respond
```
**请求体:**
```json
{
"action": "CONFIRM",
"reason": "时间可以接受"
}
```
action 可选值: `CONFIRM`, `DECLINE`, `REQUEST_RESCHEDULE`
### 7. 企业取消面试
```
POST /api/v1/interviews/{interviewId}/cancel
```
### 8. 企业完成面试
```
POST /api/v1/interviews/{interviewId}/complete
```
### 9. 状态时间线
```
GET /api/v1/interviews/{interviewId}/timeline
```
## 权限矩阵
| 操作 | 企业 | 学生 |
|------|------|------|
| 创建面试 | 仅本企业岗位投递 | ❌ |
| 查看面试 | 仅本企业 | 仅本人 |
| 修改面试 | 仅本企业 | ❌ |
| 取消面试 | 仅本企业 | ❌ |
| 响应面试 | ❌ | 仅本人 |
| 完成面试 | 仅本企业 | ❌ |
## 错误码
| 错误码 | 说明 |
|--------|------|
| 8401 | 面试不存在 |
| 8402 | 无权访问面试 |
| 8403 | 非法状态流转 |
| 8404 | 时间区间无效 |
| 8405 | 面试时长超过8小时 |
| 8406 | 在线面试需要会议链接 |
| 8407 | 线下面试需要地址 |
| 8408 | 面试开始时间必须在未来 |
| 8409 | 面试未结束不能完成 |
| 8410 | 已响应,不可重复操作 |

169
mydreamjob-server/pom.xml

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.mydreamjob</groupId>
<artifactId>mydreamjob-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mydreamjob-server</name>
<description>职来 (mydreamjob) Backend - Week 1 Baseline</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
<springdoc.version>2.8.13</springdoc.version>
<testcontainers.version>1.21.3</testcontainers.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI / Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

11
mydreamjob-server/src/main/java/com/mydreamjob/MyDreamjobApplication.java

@ -0,0 +1,11 @@
package com.mydreamjob;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyDreamjobApplication {
public static void main(String[] args) {
SpringApplication.run(MyDreamjobApplication.class, args);
}
}

52
mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java

@ -0,0 +1,52 @@
package com.mydreamjob.application;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 投递实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "applications",
uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student",
columnNames = {"job_id", "student_user_id"}))
@EntityListeners(AuditingEntityListener.class)
public class Application {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "job_id", nullable = false)
private Long jobId;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "cover_letter", columnDefinition = "text")
private String coverLetter;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = "SUBMITTED";
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

152
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -0,0 +1,152 @@
package com.mydreamjob.application;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.company.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Tag(name = "投递", description = "学生投递、撤回、查询;企业查看和审核投递;时间线")
@RestController
@RequiredArgsConstructor
public class ApplicationController {
private final ApplicationService applicationService;
private final CompanyService companyService;
// ── 学生投递 ──────────────────────────────────────────────
@Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递")
@PostMapping("/applications")
public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Application app = applicationService.apply(userId, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
}
// ── 学生撤回投递 ──────────────────────────────────────────
@Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆")
@PostMapping("/applications/{id}/withdraw")
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Application app = applicationService.withdraw(userId, id, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
}
// ── 投递时间线 ────────────────────────────────────────────
@Operation(summary = "投递状态时间线", description = "学生本人或岗位所属企业可查看状态变更历史")
@GetMapping("/applications/{id}/timeline")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
List<Map<String, Object>> data = applicationService.getTimeline(id, userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
// ── 学生查看自己的投递 ────────────────────────────────────
@Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页")
@GetMapping("/applications/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList()));
data.put("total", result.getTotalElements());
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "投递详情", description = "查看自己的投递记录详情")
@GetMapping("/applications/{id}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getApplication(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Application app = applicationService.getApplication(id, userId);
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
}
// ── 企业查看岗位收到的投递 ────────────────────────────────
@Operation(summary = "查看岗位投递列表(企业)", description = "企业查看自己发布的某个岗位收到的投递,支持 status 筛选和分页")
@GetMapping("/company/applications")
public ResponseEntity<ApiResponse<Map<String, Object>>> listCompanyApplications(
@RequestParam Long jobId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
if (size > 100) size = 100;
Page<Application> result = applicationService.listCompanyApplications(
company.getId(), jobId, status, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList()));
data.put("total", result.getTotalElements());
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
// ── 企业更新投递状态 ──────────────────────────────────────
@Operation(summary = "更新投递状态(企业)", description = "SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED")
@PatchMapping("/applications/{id}/status")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateStatus(
@PathVariable Long id,
@Valid @RequestBody ApplicationStatusRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Application app = applicationService.updateStatus(company.getId(), id, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
}
// ── 辅助 ──────────────────────────────────────────────────
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new BusinessException(ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

29
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -0,0 +1,29 @@
package com.mydreamjob.application;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
@Repository
public interface ApplicationRepository extends JpaRepository<Application, Long> {
Optional<Application> findByJobIdAndStudentUserId(Long jobId, Long studentUserId);
List<Application> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId);
Page<Application> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId, Pageable pageable);
Page<Application> findByStudentUserIdAndStatusOrderByCreatedAtDesc(Long studentUserId, String status, Pageable pageable);
List<Application> findByJobIdOrderByCreatedAtDesc(Long jobId);
Page<Application> findByJobIdOrderByCreatedAtDesc(Long jobId, Pageable pageable);
Page<Application> findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable);
boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId);
}

227
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -0,0 +1,227 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.notification.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApplicationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final UserRepository userRepository;
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
// ── 学生端 ────────────────────────────────────────────────
@Transactional
public Application apply(Long studentUserId, ApplicationCreateRequest req) {
return apply(studentUserId, req, null);
}
@Transactional
public Application apply(Long studentUserId, ApplicationCreateRequest req,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Job job = jobService.getJob(req.getJobId());
if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
if (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE);
Application app = Application.builder()
.jobId(req.getJobId()).studentUserId(studentUserId)
.status(ApplicationStatus.SUBMITTED.name()).build();
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied");
auditService.log(studentUserId, "APPLY", "APPLICATION", app.getId(),
"Student applied to job " + req.getJobId(), ctx);
// 站内通知:投递成功
notificationService.publishEvent(studentUserId, "APPLICATION_APPLIED", "投递成功",
"你已成功投递岗位 #" + req.getJobId(),
"APPLICATION", app.getId(),
"apply-" + studentUserId + "-" + req.getJobId());
return app;
}
/** 学生撤回投递:仅 SUBMITTED/VIEWED 可撤回 */
@Transactional
public Application withdraw(Long studentUserId, Long applicationId) {
return withdraw(studentUserId, applicationId, null);
}
@Transactional
public Application withdraw(Long studentUserId, Long applicationId,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
String currentStatus = app.getStatus();
// 终态或已被撤回不可重复撤回
if ("WITHDRAWN".equals(currentStatus)
|| "REJECTED".equals(currentStatus)
|| "HIRED".equals(currentStatus))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
if (!"SUBMITTED".equals(currentStatus) && !"VIEWED".equals(currentStatus))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
app.setStatus("WITHDRAWN");
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, currentStatus, "WITHDRAWN", "Student withdrew");
auditService.log(studentUserId, "WITHDRAW", "APPLICATION", app.getId(),
"Student withdrew application", ctx);
// 站内通知:投递撤回
notificationService.publishEvent(studentUserId, "APPLICATION_WITHDRAWN", "投递已撤回",
"你的投递 #" + app.getId() + " 已撤回",
"APPLICATION", app.getId(),
"withdraw-" + studentUserId + "-" + app.getId());
return app;
}
/** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = job.getCompanyId().equals(userId);
if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
.stream().map(h -> {
Map<String, Object> v = new HashMap<>();
v.put("id", h.getId());
v.put("fromStatus", h.getFromStatus());
v.put("toStatus", h.getToStatus());
v.put("operatorUserId", h.getOperatorUserId());
v.put("reason", h.getReason());
v.put("createdAt", h.getCreatedAt());
return v;
}).collect(Collectors.toList());
}
public Page<Application> listStudentApplications(Long studentUserId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
PageRequest pr = PageRequest.of(page, size);
if (status != null && !status.isBlank())
return applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr);
return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr);
}
public Application getApplication(Long applicationId, Long studentUserId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
return app;
}
// ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) {
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId);
}
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
PageRequest pr = PageRequest.of(page, size);
if (status != null && !status.isBlank())
return applicationRepository.findByJobIdAndStatusOrderByCreatedAtDesc(jobId, status, pr);
return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId, pr);
}
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req) {
return updateStatus(companyId, applicationId, req, null);
}
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
ApplicationStatus current = ApplicationStatus.valueOf(app.getStatus());
ApplicationStatus target = ApplicationStatus.valueOf(req.getStatus());
if (!current.canTransitionTo(target))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
String from = app.getStatus();
app.setStatus(target.name());
app = applicationRepository.save(app);
recordHistory(app.getId(), companyId, from, target.name(),
"Company updated status to " + target.name());
auditService.log(companyId, "UPDATE_STATUS", "APPLICATION", app.getId(),
"Status changed: " + from + " → " + target.name(), ctx);
// 站内通知:投递状态变更 → 学生
notificationService.publishEvent(app.getStudentUserId(), "APPLICATION_STATUS",
"投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(),
"APPLICATION", app.getId(),
"status-" + companyId + "-" + app.getId() + "-" + target.name());
return app;
}
// ── 视图 ──────────────────────────────────────────────────
public Map<String, Object> toView(Application app) {
Map<String, Object> view = new HashMap<>();
view.put("id", app.getId());
view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus());
view.put("createdAt", app.getCreatedAt());
view.put("updatedAt", app.getUpdatedAt());
return view;
}
// ── 私有 ──────────────────────────────────────────────────
private void assertRole(Long userId, String expectedRoleName) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r.name().equals(expectedRoleName)))
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private void recordHistory(Long applicationId, Long operatorUserId,
String fromStatus, String toStatus, String reason) {
historyRepository.save(ApplicationStatusHistory.builder()
.applicationId(applicationId).operatorUserId(operatorUserId)
.fromStatus(fromStatus).toStatus(toStatus).reason(reason)
.build());
}
}

32
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java

@ -0,0 +1,32 @@
package com.mydreamjob.application;
/**
* 投递状态枚举
*
* SUBMITTED — 学生已投递
* VIEWED — 企业已查看
* INTERVIEW — 进入面试阶段
* REJECTED — 已拒绝
* HIRED — 已录用
*/
public enum ApplicationStatus {
SUBMITTED,
VIEWED,
INTERVIEW,
REJECTED,
HIRED,
WITHDRAWN; // 学生主动撤回
/**
* 校验状态流转是否合法
*/
public boolean canTransitionTo(ApplicationStatus target) {
if (target == null) return false;
return switch (this) {
case SUBMITTED -> target == VIEWED || target == WITHDRAWN;
case VIEWED -> target == INTERVIEW || target == WITHDRAWN;
case INTERVIEW -> target == REJECTED || target == HIRED;
case REJECTED, HIRED, WITHDRAWN -> false; // 终态不可再流转
};
}
}

45
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistory.java

@ -0,0 +1,45 @@
package com.mydreamjob.application;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "application_status_history")
public class ApplicationStatusHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "application_id", nullable = false)
private Long applicationId;
@Column(name = "operator_user_id", nullable = false)
private Long operatorUserId;
@Column(name = "from_status", length = 20)
private String fromStatus;
@Column(name = "to_status", nullable = false, length = 20)
private String toStatus;
@Column(name = "reason", length = 255)
private String reason;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

12
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.application;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ApplicationStatusHistoryRepository extends JpaRepository<ApplicationStatusHistory, Long> {
List<ApplicationStatusHistory> findByApplicationIdOrderByCreatedAtAsc(Long applicationId);
}

10
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationCreateRequest.java

@ -0,0 +1,10 @@
package com.mydreamjob.application.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ApplicationCreateRequest {
@NotNull(message = "jobId is required")
private Long jobId;
}

13
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationStatusRequest.java

@ -0,0 +1,13 @@
package com.mydreamjob.application.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class ApplicationStatusRequest {
@NotBlank(message = "status is required")
@Pattern(regexp = "^(VIEWED|INTERVIEW|REJECTED|HIRED)$",
message = "status must be VIEWED, INTERVIEW, REJECTED, or HIRED")
private String status;
}

32
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditContext.java

@ -0,0 +1,32 @@
package com.mydreamjob.audit;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 审计上下文:请求级元数据,贯穿 Controller → Service → AuditService。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditContext {
private String requestId;
private String ipAddress;
private String userAgent;
/**
* 从 HttpServletRequest 构建上下文
*/
public static AuditContext from(HttpServletRequest request) {
return AuditContext.builder()
.requestId(request.getHeader("X-Request-Id"))
.ipAddress(request.getRemoteAddr())
.userAgent(request.getHeader("User-Agent"))
.build();
}
}

56
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java

@ -0,0 +1,56 @@
package com.mydreamjob.audit;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 审计日志实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "audit_logs")
@EntityListeners(AuditingEntityListener.class)
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
@Column(name = "action", nullable = false, length = 50)
private String action;
@Column(name = "entity_type", length = 50)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "details", columnDefinition = "TEXT")
private String details;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "request_id", length = 32)
private String requestId;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

22
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java

@ -0,0 +1,22 @@
package com.mydreamjob.audit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
@Query("SELECT a FROM AuditLog a WHERE a.userId = :userId ORDER BY a.createdAt DESC")
List<AuditLog> findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable);
@Query("SELECT a FROM AuditLog a WHERE a.createdAt > :since ORDER BY a.createdAt DESC")
List<AuditLog> findByCreatedAtAfter(@Param("since") LocalDateTime since);
void deleteByCreatedAtBefore(LocalDateTime before);
}

86
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java

@ -0,0 +1,86 @@
package com.mydreamjob.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 审计日志服务:持久化到 audit_logs 表,写入失败不阻断主业务流程。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
/**
* 记录一个审计事件(兼容旧签名,上下文字段为 null 时跳过填充)
*/
public void log(Long userId, String action, String entityType, Long entityId,
String details, String ipAddress, String userAgent, String requestId) {
logWithContext(userId, action, entityType, entityId, details,
AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build());
}
/**
* 记录一个审计事件(统一上下文版本)
*/
public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context) {
logWithContext(userId, action, entityType, entityId, details, context);
}
private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx) {
try {
// 脱敏:details 中不得包含 code/password/token/secret 等关键词的值
String safeDetails = sanitize(details);
AuditLog auditLog = AuditLog.builder()
.userId(userId)
.action(action)
.entityType(entityType)
.entityId(entityId)
.details(safeDetails)
.ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now())
.build();
auditLogRepository.save(auditLog);
} catch (Exception e) {
// 审计持久化失败不能阻断主业务流程
log.error("Failed to persist audit log: action={}, userId={}, error={}",
action, userId, e.getMessage());
}
}
public void logLogin(Long userId, String ipAddress, String userAgent, String requestId) {
log(userId, "LOGIN", "USER", userId, "User logged in", ipAddress, userAgent, requestId);
}
public void logLogin(Long userId, AuditContext ctx) {
log(userId, "LOGIN", "USER", userId, "User logged in", ctx);
}
public void logLogout(Long userId, String ipAddress, String userAgent, String requestId) {
log(userId, "LOGOUT", "USER", userId, "User logged out", ipAddress, userAgent, requestId);
}
public void logLogout(Long userId, AuditContext ctx) {
log(userId, "LOGOUT", "USER", userId, "User logged out", ctx);
}
/**
* 脱敏:不记录可能的敏感字段值
*/
private String sanitize(String details) {
if (details == null) return null;
return details.replaceAll("(?i)code[=:]\\s*\\S+", "code=[REDACTED]")
.replaceAll("(?i)password[=:]\\s*\\S+", "password=[REDACTED]")
.replaceAll("(?i)token[=:]\\s*\\S+", "token=[REDACTED]")
.replaceAll("(?i)secret[=:]\\s*\\S+", "secret=[REDACTED]");
}
}

88
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java

@ -0,0 +1,88 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* Mock 认证服务 - 仅用于开发/测试环境登录原型
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService;
private final AuditService auditService;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户
*/
@Transactional
public Map<String, Object> mockLogin(String phone, String code, String purpose,
String requestId, String ipAddress, String userAgent) {
// 验证验证码(传入 purpose,确保验证码用途匹配)
smsVerificationService.verifyCode(phone, code, purpose);
// 查找或创建用户
Optional<User> userOpt = userRepository.findByPhone(phone);
User user;
if (userOpt.isEmpty()) {
user = User.builder()
.phone(phone)
.nickname("用户_" + phone.substring(7))
.role(User.Role.STUDENT)
.active(true)
.verified(true)
.build();
user = userRepository.save(user);
log.info("Auto-created user: id={}, phone={}", user.getId(), phone);
} else {
user = userOpt.get();
}
// 生成 token
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name()));
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone());
auditService.logLogin(user.getId(), ipAddress, userAgent, requestId);
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId());
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("expiresIn", 86400);
return result;
}
/**
* 获取当前用户信息
*/
public Map<String, Object> getCurrentUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
Map<String, Object> result = new HashMap<>();
result.put("id", user.getId());
result.put("phone", user.getPhone());
result.put("email", user.getEmail());
result.put("nickname", user.getNickname());
result.put("avatarUrl", user.getAvatarUrl());
result.put("role", user.getRole());
result.put("active", user.getActive());
result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt());
return result;
}
}

52
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java

@ -0,0 +1,52 @@
package com.mydreamjob.auth;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* JWT Token 实体(用于 token 吊销和刷新)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "auth_tokens")
@EntityListeners(AuditingEntityListener.class)
public class AuthToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "token_hash", nullable = false, length = 255)
private String tokenHash;
@Column(name = "token_type", nullable = false, length = 20)
@Builder.Default
private String tokenType = "ACCESS";
@Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt;
@Column(name = "revoked", nullable = false)
@Builder.Default
private Boolean revoked = false;
@Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

20
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java

@ -0,0 +1,20 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
List<AuthToken> findByUserIdAndRevokedFalse(Long userId);
Optional<AuthToken> findByTokenHash(String tokenHash);
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt);
}

57
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java

@ -0,0 +1,57 @@
package com.mydreamjob.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* JWT 认证过滤器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) {
try {
Long userId = tokenProvider.parseUserId(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId);
} catch (Exception e) {
log.warn("JWT authentication failed for token: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
@Nullable
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

84
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java

@ -0,0 +1,84 @@
package com.mydreamjob.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
/**
* JWT 工具类
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expirationMs;
private final long refreshExpirationMs;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs,
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
this.refreshExpirationMs = refreshExpirationMs;
}
public String generateAccessToken(Long userId, String phone, Map<String, Object> claims) {
return generateToken(userId, phone, claims, expirationMs);
}
public String generateRefreshToken(Long userId, String phone) {
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs);
}
private String generateToken(Long userId, String phone, Map<String, Object> claims, long expirationMs) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("phone", phone)
.claims(claims)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}
public Long parseUserId(String token) {
String subject = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
return Long.parseLong(subject);
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return true;
} catch (JwtException e) {
log.debug("JWT validation failed: {}", e.getMessage());
return false;
}
}
public boolean isTokenExpired(String token) {
try {
return Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token).getPayload().getExpiration().before(new Date());
} catch (JwtException e) {
return true;
}
}
}

20
mydreamjob-server/src/main/java/com/mydreamjob/auth/LoginRequest.java

@ -0,0 +1,20 @@
package com.mydreamjob.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/** SMS login request. */
@Data
public class LoginRequest {
@NotBlank(message = "phone is required")
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits")
private String phone;
@NotBlank(message = "code is required")
@Pattern(regexp = "[0-9]{6}", message = "code must contain exactly 6 digits")
private String code;
private String purpose = "login";
}

56
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java

@ -0,0 +1,56 @@
package com.mydreamjob.auth;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 短信验证码实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "sms_verification_codes")
@EntityListeners(AuditingEntityListener.class)
public class SmsVerificationCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "code", nullable = false, length = 10)
private String code;
@Column(name = "purpose", nullable = false, length = 50)
private String purpose;
@Column(name = "attempts", nullable = false)
@Builder.Default
private Integer attempts = 0;
@Column(name = "max_attempts", nullable = false)
@Builder.Default
private Integer maxAttempts = 5;
@Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt;
@Column(name = "used", nullable = false)
@Builder.Default
private Boolean used = false;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

33
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java

@ -0,0 +1,33 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerificationCode, Long> {
List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after);
/**
* 按手机号+用途查询,按创建时间降序返回(最新在前)
*/
List<SmsVerificationCode> findByPhoneAndPurposeOrderByCreatedAtDesc(String phone, String purpose);
/**
* 查询手机号+用途下最新的未使用验证码
*/
@Query("SELECT c FROM SmsVerificationCode c WHERE c.phone = :phone AND c.purpose = :purpose AND c.used = false ORDER BY c.createdAt DESC")
java.util.stream.Stream<SmsVerificationCode> streamLatestUnusedByPhoneAndPurpose(@Param("phone") String phone,
@Param("purpose") String purpose);
/**
* 按手机号+用途查询已使用的验证码(用于测试中取旧验证码)
*/
List<SmsVerificationCode> findByPhoneAndPurposeAndUsedTrueOrderByCreatedAtDesc(String phone, String purpose);
}

178
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -0,0 +1,178 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit;
/**
* Mock 短信验证码服务
* 不接入真实短信网关,验证码直接生成并存入 Redis
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsVerificationService {
private final SmsVerificationCodeRepository codeRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService;
@Value("${app.sms.mock.code-length:6}")
private int codeLength;
@Value("${app.sms.mock.expire-seconds:300}")
private int expireSeconds;
@Value("${app.sms.mock.max-attempts:5}")
private int maxAttempts;
@Value("${app.sms.mock.lockout-seconds:300}")
private int lockoutSeconds;
private static final String RATE_LIMIT_KEY_PREFIX = "sms:rate:";
private static final String LOCKOUT_KEY_PREFIX = "sms:lockout:";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}");
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
/**
* 发送验证码(Mock)
*/
@Transactional
public void sendCode(String phone, String purpose,
String requestId, String ipAddress, String userAgent) {
validatePhone(phone);
validatePurpose(purpose);
// 检查锁定
String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) {
log.warn("Phone {} is locked out", phone);
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 检查频率限制(60秒内最多1次)
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) {
log.warn("Phone {} rate limited", phone);
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 生成验证码
String code = generateCode();
LocalDateTime now = LocalDateTime.now();
// 保存验证码记录
SmsVerificationCode smsCode = SmsVerificationCode.builder()
.phone(phone)
.code(code)
.purpose(purpose)
.attempts(0)
.maxAttempts(maxAttempts)
.expiredAt(now.plusSeconds(expireSeconds))
.used(false)
.build();
codeRepository.save(smsCode);
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// Mock: 打印验证码到日志(不发送真实短信)
log.info("MOCK SMS CODE: phone={}, purpose={}, code={}", phone, purpose, code);
auditService.log(null, "SMS_SEND", "PHONE", null,
"Mock SMS sent to " + phone + " for " + purpose,
ipAddress, userAgent, requestId);
}
/**
* 验证验证码
* 按手机号+用途查询最新一条未使用记录,原子性标记已使用。
*/
@Transactional
public boolean verifyCode(String phone, String code, String purpose) {
validatePhone(phone);
validateCode(code);
validatePurpose(purpose);
String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) {
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 查询最新一条未使用的验证码(按 createdAt DESC,仅返回第一条)
java.util.Optional<SmsVerificationCode> opt =
codeRepository.streamLatestUnusedByPhoneAndPurpose(phone, purpose)
.filter(c -> !c.getUsed())
.findFirst();
if (opt.isEmpty()) {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
SmsVerificationCode smsCode = opt.get();
// 检查过期
LocalDateTime now = LocalDateTime.now();
if (smsCode.getExpiredAt().isBefore(now)) {
throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED);
}
// 检查尝试次数
if (smsCode.getAttempts() >= smsCode.getMaxAttempts()) {
redisTemplate.opsForValue().set(lockoutKey, "1", lockoutSeconds, TimeUnit.SECONDS);
throw new BusinessException(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED);
}
// 校验验证码值
if (!code.equals(smsCode.getCode())) {
smsCode.setAttempts(smsCode.getAttempts() + 1);
codeRepository.save(smsCode);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
// 原子性标记已使用并保存
smsCode.setUsed(true);
codeRepository.save(smsCode);
return true;
}
private String generateCode() {
StringBuilder sb = new StringBuilder(codeLength);
for (int i = 0; i < codeLength; i++) {
sb.append(SECURE_RANDOM.nextInt(10));
}
return sb.toString();
}
private void validatePhone(String phone) {
if (phone == null || !PHONE_PATTERN.matcher(phone).matches()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"phone must contain exactly 11 digits");
}
}
private void validateCode(String code) {
if (code == null || !CODE_PATTERN.matcher(code).matches()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"code must contain exactly 6 digits");
}
}
private void validatePurpose(String purpose) {
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"purpose is required");
}
}
}

79
mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java

@ -0,0 +1,79 @@
package com.mydreamjob.auth;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 用户实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "phone", nullable = false, unique = true, length = 20)
private String phone;
@Column(name = "email", length = 100)
private String email;
@Column(name = "nickname", length = 50)
private String nickname;
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 30)
@Builder.Default
private Role role = Role.STUDENT;
@ElementCollection(targetClass = Role.class)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
@Enumerated(EnumType.STRING)
@Builder.Default
private Set<Role> roles = Set.of(Role.STUDENT);
@Column(name = "is_active", nullable = false)
@Builder.Default
private Boolean active = true;
@Column(name = "is_verified", nullable = false)
@Builder.Default
private Boolean verified = false;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public enum Role {
STUDENT,
COMPANY,
ADMIN
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java

@ -0,0 +1,14 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone);
Optional<User> findByEmail(String email);
boolean existsByPhone(String phone);
boolean existsByEmail(String email);
}

88
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

@ -0,0 +1,88 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.bidirectionalmatch.dto.ScoreRequest;
import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 双向匹配评分与状态查询接口
*/
@Tag(name = "双向匹配", description = "求职者意愿分、企业 HR 适配分、匹配状态")
@RestController
@RequiredArgsConstructor
public class ApplicationMatchController {
private final BidirectionalMatchService matchService;
@Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改")
@PutMapping("/applications/{id}/candidate-interest-score")
public ResponseEntity<ApiResponse<Map<String, Object>>> submitCandidateInterestScore(
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
ApplicationMatchRecord record = matchService.submitCandidateInterestScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
}
@Operation(summary = "提交企业 HR 适配分", description = "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改")
@PutMapping("/applications/{id}/hr-suitability-score")
public ResponseEntity<ApiResponse<Map<String, Object>>> submitHrSuitabilityScore(
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
ApplicationMatchRecord record = matchService.submitHrSuitabilityScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
}
@Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID")
@GetMapping("/applications/{id}/match")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request)));
}
/** 评分接口返回记录视图(脱敏,不含内部字段) */
private Map<String, Object> toView(ApplicationMatchRecord record) {
Map<String, Object> v = new java.util.HashMap<>();
v.put("applicationId", record.getApplicationId());
v.put("jobId", record.getJobId());
v.put("companyId", record.getCompanyId());
v.put("candidateId", record.getCandidateId());
v.put("candidateInterestScore", record.getCandidateInterestScore());
v.put("hrSuitabilityScore", record.getHrSuitabilityScore());
v.put("companyRequiredCandidateScore", record.getCompanyRequiredCandidateScore());
v.put("studentRequiredHrScore", record.getStudentRequiredHrScore());
v.put("status", record.getStatus());
v.put("matchedAt", record.getMatchedAt());
v.put("matchVersion", ApplicationMatchRecord.MATCH_VERSION);
return v;
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

41
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversation.java

@ -0,0 +1,41 @@
package com.mydreamjob.bidirectionalmatch;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 匹配解锁会话关联:一条投递最多解锁一个会话(幂等复用),不直接改 conversations 表。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "application_match_conversations",
uniqueConstraints = @UniqueConstraint(name = "uk_app_match_conversations_app",
columnNames = {"application_id"}))
public class ApplicationMatchConversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "application_id", nullable = false)
private Long applicationId;
@Column(name = "conversation_id", nullable = false)
private Long conversationId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

12
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversationRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.bidirectionalmatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ApplicationMatchConversationRepository extends JpaRepository<ApplicationMatchConversation, Long> {
Optional<ApplicationMatchConversation> findByApplicationId(Long applicationId);
}

80
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEvent.java

@ -0,0 +1,80 @@
package com.mydreamjob.bidirectionalmatch;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 匹配事件:审计 + MATCH_SUCCEEDED 通知 Outbox。
*
* <p>{@code dedupKey}:MATCH_SUCCEEDED 事件为 {@code MATCH_SUCCEEDED}(同一 application 唯一,
* 防重复通知);其余审计事件为 NULL(允许多条)。
* {@code notificationStatus}:仅 MATCH_SUCCEEDED 使用 PENDING → SENT;其余为 NULL。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "application_match_events",
uniqueConstraints = @UniqueConstraint(name = "uk_app_match_events_dedup",
columnNames = {"application_id", "dedup_key"}))
public class ApplicationMatchEvent {
public static final String EVENT_MATCH_SUCCEEDED = "MATCH_SUCCEEDED";
public static final String EVENT_STATUS_CHANGED = "STATUS_CHANGED";
public static final String EVENT_SCORE_UPDATED = "SCORE_UPDATED";
public static final String EVENT_MATCH_CLOSED = "MATCH_CLOSED";
public static final String NOTIFY_PENDING = "PENDING";
public static final String NOTIFY_SENT = "SENT";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "application_id", nullable = false)
private Long applicationId;
@Column(name = "actor_user_id")
private Long actorUserId;
@Column(name = "event_type", nullable = false, length = 30)
private String eventType;
@Column(name = "dedup_key", length = 30)
private String dedupKey;
@Column(name = "old_status", length = 30)
private String oldStatus;
@Column(name = "new_status", length = 30)
private String newStatus;
@Column(name = "old_score")
private Integer oldScore;
@Column(name = "new_score")
private Integer newScore;
@Column(name = "score_field", length = 30)
private String scoreField;
@Column(name = "notification_status", length = 20)
private String notificationStatus;
@Column(name = "request_id", length = 32)
private String requestId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEventRepository.java

@ -0,0 +1,18 @@
package com.mydreamjob.bidirectionalmatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ApplicationMatchEventRepository extends JpaRepository<ApplicationMatchEvent, Long> {
Optional<ApplicationMatchEvent> findByApplicationIdAndEventTypeAndDedupKey(
Long applicationId, String eventType, String dedupKey);
/** 待发送的 MATCH_SUCCEEDED 通知(Outbox 轮询) */
List<ApplicationMatchEvent> findByEventTypeAndNotificationStatus(
String eventType, String notificationStatus);
}

103
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecord.java

@ -0,0 +1,103 @@
package com.mydreamjob.bidirectionalmatch;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 双向匹配记录:一条投递一条记录。
*
* <p>字段语义(本轮明确):
* <ul>
* <li>candidate_interest_score:求职者对岗位的"意愿分"(0-100,人工录入 MANUAL_INTEREST),
* 不是教育/能力/诚信/算法综合分;</li>
* <li>hr_suitability_score:企业对候选人的"HR 适配分"(0-100,人工录入 HR_MANUAL);</li>
* <li>company_required_candidate_score / student_required_hr_score:本次计算实际使用的阈值快照,
* 阈值更新只影响后续重算。</li>
* </ul>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "application_match_records",
uniqueConstraints = @UniqueConstraint(name = "uk_application_match_records_app",
columnNames = {"application_id"}))
@EntityListeners(AuditingEntityListener.class)
public class ApplicationMatchRecord {
public static final String STATUS_PENDING_BOTH = "PENDING_BOTH";
public static final String STATUS_PENDING_HR = "PENDING_HR";
public static final String STATUS_PENDING_CANDIDATE = "PENDING_CANDIDATE";
public static final String STATUS_NOT_MATCHED = "NOT_MATCHED";
public static final String STATUS_MATCHED = "MATCHED";
public static final String STATUS_CLOSED = "CLOSED";
public static final String SOURCE_MANUAL_INTEREST = "MANUAL_INTEREST";
public static final String SOURCE_HR_MANUAL = "HR_MANUAL";
public static final String MATCH_VERSION = "RULE_THRESHOLD_V1";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "application_id", nullable = false)
private Long applicationId;
@Column(name = "job_id", nullable = false)
private Long jobId;
@Column(name = "company_id", nullable = false)
private Long companyId;
@Column(name = "candidate_id", nullable = false)
private Long candidateId;
@Column(name = "candidate_interest_score")
private Integer candidateInterestScore;
@Column(name = "hr_suitability_score")
private Integer hrSuitabilityScore;
@Column(name = "company_required_candidate_score", nullable = false)
private Integer companyRequiredCandidateScore;
@Column(name = "student_required_hr_score", nullable = false)
private Integer studentRequiredHrScore;
@Column(name = "candidate_score_source", nullable = false, length = 30)
@Builder.Default
private String candidateScoreSource = SOURCE_MANUAL_INTEREST;
@Column(name = "hr_score_source", nullable = false, length = 30)
@Builder.Default
private String hrScoreSource = SOURCE_HR_MANUAL;
@Column(name = "status", nullable = false, length = 30)
@Builder.Default
private String status = STATUS_PENDING_BOTH;
@Column(name = "matched_at")
private LocalDateTime matchedAt;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java

@ -0,0 +1,31 @@
package com.mydreamjob.bidirectionalmatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ApplicationMatchRecordRepository extends JpaRepository<ApplicationMatchRecord, Long> {
Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId);
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */
@Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r "
+ "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED'")
boolean existsMatchedApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId);
/** 候选人所有匹配记录中 candidateInterestScore 的平均值和条数 */
@Query("SELECT COALESCE(AVG(r.candidateInterestScore), 0), COUNT(r) FROM ApplicationMatchRecord r "
+ "WHERE r.candidateId = :candidateId AND r.candidateInterestScore IS NOT NULL")
List<Object[]> avgAndCountCandidateInterestScore(@Param("candidateId") Long candidateId);
/** 候选人所有匹配记录中 hrSuitabilityScore 的平均值和条数 */
@Query("SELECT COALESCE(AVG(r.hrSuitabilityScore), 0), COUNT(r) FROM ApplicationMatchRecord r "
+ "WHERE r.candidateId = :candidateId AND r.hrSuitabilityScore IS NOT NULL")
List<Object[]> avgAndCountHrSuitabilityScore(@Param("candidateId") Long candidateId);
}

193
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java

@ -0,0 +1,193 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* 双向匹配基础流程(规则阈值版)。
*
* <p>匹配成功条件:candidateInterestScore >= companyRequiredCandidateScore
* 且 hrSuitabilityScore >= studentRequiredHrScore(均为 0-100,人工录入,可审计)。
* 状态机:PENDING_BOTH / PENDING_HR / PENDING_CANDIDATE / NOT_MATCHED / MATCHED / CLOSED。
* 并发:评分写入由 {@link MatchRecordWriter} 独立事务(REQUIRES_NEW)执行 + 事件表
* MATCH_SUCCEEDED 唯一约束,保证"仅一次进入 MATCHED"。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BidirectionalMatchService {
/** 允许评分的投递状态(复用现有 ApplicationStatus 语义) */
private static final Set<String> TERMINAL_APPLICATION_STATUSES =
Set.of("REJECTED", "HIRED", "WITHDRAWN");
private static final int MAX_CONFLICT_RETRY = 3;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final CompanyRepository companyRepository;
private final UserRepository userRepository;
private final ApplicationMatchRecordRepository recordRepository;
private final MatchRecordWriter matchRecordWriter;
private final AuditService auditService;
// ── 求职者:岗位意愿分 ────────────────────────────────────
@Transactional
public ApplicationMatchRecord submitCandidateInterestScore(
Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId);
assertRole(user, User.Role.STUDENT);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
Job job = jobService.getJob(app.getJobId());
assertNotClosed(app, job);
ApplicationMatchRecord record = applyScoreWithRetry(app, job, user.getId(), score,
MatchScoreField.CANDIDATE_INTEREST, ctx);
auditService.log(userId, "SUBMIT_CANDIDATE_INTEREST_SCORE", "APPLICATION", app.getId(),
"Candidate interest score=" + score + " -> status=" + record.getStatus(), ctx);
return record;
}
// ── 企业:HR 适配分 ───────────────────────────────────────
@Transactional
public ApplicationMatchRecord submitHrSuitabilityScore(
Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId);
assertRole(user, User.Role.COMPANY);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Job job = jobService.getJob(app.getJobId());
if (companyId == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
assertNotClosed(app, job);
ApplicationMatchRecord record = applyScoreWithRetry(app, job, user.getId(), score,
MatchScoreField.HR_SUITABILITY, ctx);
auditService.log(userId, "SUBMIT_HR_SUITABILITY_SCORE", "APPLICATION", app.getId(),
"HR suitability score=" + score + " -> status=" + record.getStatus(), ctx);
return record;
}
// ── 匹配状态查询(双方可读) ──────────────────────────────
public Map<String, Object> getMatchView(Long userId, Long applicationId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
assertParticipant(userId, app, job);
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
boolean closed = isClosed(app, job);
return toView(app, job, record, closed);
}
// ── 核心:评分 + 重算 + 事件(独立事务 + 重试) ────────────
private ApplicationMatchRecord applyScoreWithRetry(Application app, Job job, Long actorUserId,
int score, MatchScoreField field, AuditContext ctx) {
String requestId = ctx == null ? null : truncate(ctx.getRequestId());
for (int attempt = 0; attempt < MAX_CONFLICT_RETRY; attempt++) {
try {
return matchRecordWriter.applyScore(app, job, actorUserId, score, field, requestId);
} catch (OptimisticLockingFailureException | DataIntegrityViolationException e) {
// REQUIRES_NEW 已回滚;重读重试(并发评分只产生一次进入 MATCHED)
log.info("Match score conflict for application {}, retry {}/{}",
app.getId(), attempt + 1, MAX_CONFLICT_RETRY);
}
}
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED,
"match update conflicted, please retry");
}
// ── 关闭判定 / 参与者 ────────────────────────────────────
private void assertNotClosed(Application app, Job job) {
if (isClosed(app, job)) {
throw new BusinessException(ErrorCode.MATCH_CLOSED);
}
}
private boolean isClosed(Application app, Job job) {
return TERMINAL_APPLICATION_STATUSES.contains(app.getStatus())
|| "CLOSED".equals(job.getStatus());
}
private void assertParticipant(Long userId, Application app, Job job) {
if (app.getStudentUserId().equals(userId)) return;
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
if (companyId != null && companyId.equals(job.getCompanyId())) return;
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private User loadUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
private void assertRole(User user, User.Role role) {
boolean has = user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
if (!has) throw new BusinessException(ErrorCode.FORBIDDEN);
}
// ── 视图(脱敏) ──────────────────────────────────────────
public Map<String, Object> toView(Application app, Job job, ApplicationMatchRecord record, boolean closed) {
Map<String, Object> v = new HashMap<>();
v.put("applicationId", app.getId());
v.put("jobId", job.getId());
v.put("companyId", job.getCompanyId());
v.put("candidateId", app.getStudentUserId());
if (record != null) {
v.put("candidateInterestScore", record.getCandidateInterestScore());
v.put("hrSuitabilityScore", record.getHrSuitabilityScore());
v.put("companyRequiredCandidateScore", record.getCompanyRequiredCandidateScore());
v.put("studentRequiredHrScore", record.getStudentRequiredHrScore());
v.put("candidateScoreSource", record.getCandidateScoreSource());
v.put("hrScoreSource", record.getHrScoreSource());
v.put("matchedAt", record.getMatchedAt());
v.put("status", closed ? ApplicationMatchRecord.STATUS_CLOSED : record.getStatus());
} else {
v.put("candidateInterestScore", null);
v.put("hrSuitabilityScore", null);
v.put("companyRequiredCandidateScore", 60);
v.put("studentRequiredHrScore", 60);
v.put("candidateScoreSource", ApplicationMatchRecord.SOURCE_MANUAL_INTEREST);
v.put("hrScoreSource", ApplicationMatchRecord.SOURCE_HR_MANUAL);
v.put("matchedAt", null);
v.put("status", closed ? ApplicationMatchRecord.STATUS_CLOSED : ApplicationMatchRecord.STATUS_PENDING_BOTH);
}
v.put("matchVersion", ApplicationMatchRecord.MATCH_VERSION);
return v;
}
private String truncate(String s) {
if (s == null) return null;
return s.length() > 32 ? s.substring(0, 32) : s;
}
}

48
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRule.java

@ -0,0 +1,48 @@
package com.mydreamjob.bidirectionalmatch;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 企业匹配阈值:学生对岗位的"意愿分"须达到企业设定的最低值(默认 60)。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "company_match_rules",
uniqueConstraints = @UniqueConstraint(name = "uk_company_match_rules_company",
columnNames = {"company_id"}))
@EntityListeners(AuditingEntityListener.class)
public class CompanyMatchRule {
public static final int DEFAULT_REQUIRED_CANDIDATE_SCORE = 60;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "company_id", nullable = false)
private Long companyId;
@Column(name = "required_candidate_score", nullable = false)
@Builder.Default
private Integer requiredCandidateScore = DEFAULT_REQUIRED_CANDIDATE_SCORE;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRuleRepository.java

@ -0,0 +1,11 @@
package com.mydreamjob.bidirectionalmatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface CompanyMatchRuleRepository extends JpaRepository<CompanyMatchRule, Long> {
Optional<CompanyMatchRule> findByCompanyId(Long companyId);
}

46
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java

@ -0,0 +1,46 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 匹配后沟通解锁接口:基于 application 创建/获取直聊会话
*/
@Tag(name = "双向匹配沟通", description = "匹配成功后创建或获取直聊会话(幂等)")
@RestController
@RequiredArgsConstructor
public class MatchConversationController {
private final MatchConversationService matchConversationService;
@Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话")
@PostMapping("/applications/{id}/conversation")
public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Map<String, Object> conversation = matchConversationService.createOrGetMatchConversation(userId, id);
return ResponseEntity.ok(ApiResponse.ok(conversation, getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

124
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java

@ -0,0 +1,124 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation;
import com.mydreamjob.messaging.ConversationMember;
import com.mydreamjob.messaging.ConversationMemberRepository;
import com.mydreamjob.messaging.ConversationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 匹配后沟通解锁:仅 MATCHED 的双方可基于 application 创建/获取直聊会话。
*
* <p>幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话,
* 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MatchConversationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final CompanyRepository companyRepository;
private final ApplicationMatchRecordRepository recordRepository;
private final ApplicationMatchConversationRepository conversationLinkRepository;
private final ConversationRepository conversationRepository;
private final ConversationMemberRepository memberRepository;
@Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
Long companyId = job.getCompanyId();
Long candidateId = app.getStudentUserId();
// 参与者推导(身份一律来自 JWT):求职者 or 岗位所属企业
Long otherUserId;
if (candidateId.equals(userId)) {
otherUserId = companyRepository.findById(companyId)
.map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
otherUserId = candidateId;
}
// 必须已 MATCHED
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
}
// 幂等:已有关联 → 返回既有会话
ApplicationMatchConversation link = conversationLinkRepository.findByApplicationId(applicationId).orElse(null);
if (link != null) {
return toView(conversationRepository.findById(link.getConversationId())
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST)));
}
// 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致)
Conversation conversation = findOrCreateDirectConversation(userId, otherUserId);
try {
conversationLinkRepository.save(ApplicationMatchConversation.builder()
.applicationId(applicationId)
.conversationId(conversation.getId())
.build());
} catch (DataIntegrityViolationException e) {
// 并发:另一请求已建关联 → 返回既有会话
ApplicationMatchConversation existing = conversationLinkRepository
.findByApplicationId(applicationId).orElse(null);
if (existing != null) {
return toView(conversationRepository.findById(existing.getConversationId())
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST)));
}
throw e;
}
return toView(conversation);
}
private Conversation findOrCreateDirectConversation(Long userId, Long otherUserId) {
List<ConversationMember> my = memberRepository.findByUserId(userId);
for (ConversationMember m : my) {
if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherUserId)) {
Conversation existing = conversationRepository.findById(m.getConversationId()).orElse(null);
if (existing != null && "DIRECT".equals(existing.getType())) return existing;
}
}
Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build());
memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(userId).build());
memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(otherUserId).build());
return c;
}
private Map<String, Object> toView(Conversation c) {
Map<String, Object> v = new HashMap<>();
v.put("id", c.getId());
v.put("title", c.getTitle());
v.put("type", c.getType());
v.put("createdBy", c.getCreatedBy());
v.put("createdAt", c.getCreatedAt());
v.put("updatedAt", c.getUpdatedAt());
return v;
}
}

42
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationScheduler.java

@ -0,0 +1,42 @@
package com.mydreamjob.bidirectionalmatch;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 匹配成功通知重试调度:扫描 PENDING 的 MATCH_SUCCEEDED 事件并发送。
* 幂等:通知幂等键 + 事件 SENT 标记保证重试不重复发送。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.match.notification.enabled", havingValue = "true", matchIfMissing = true)
public class MatchNotificationScheduler {
private final ApplicationMatchEventRepository eventRepository;
private final MatchNotificationService matchNotificationService;
@Scheduled(fixedDelayString = "${app.match.notification.retry-ms:60000}")
public void retryPendingNotifications() {
try {
List<ApplicationMatchEvent> pending = eventRepository
.findByEventTypeAndNotificationStatus(ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED,
ApplicationMatchEvent.NOTIFY_PENDING);
for (ApplicationMatchEvent event : pending) {
try {
matchNotificationService.sendMatchSucceeded(event.getApplicationId());
} catch (Exception e) {
log.error("Match notification retry failed: applicationId={}, error={}",
event.getApplicationId(), e.getMessage());
}
}
} catch (Exception e) {
log.error("Match notification scheduler failed", e);
}
}
}

93
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationService.java

@ -0,0 +1,93 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 匹配成功通知(Outbox 发送)。
*
* <p>由 {@code application_match_events} 中 MATCH_SUCCEEDED(PENDING) 事件驱动;
* 通知通过 NotificationService.create 幂等键落库(同一 application 每方只产生一条),
* 事件标记 SENT;任何失败保持 PENDING,由调度任务重试。重试不会重复发通知。
* <b>REQUIRES_NEW</b>:afterCommit/调度器调用时若处于已完成的同步上下文,REQUIRED 会加入已完成事务
* 导致通知不落库;独立事务保证通知总能提交。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MatchNotificationService {
public static final String TYPE_MATCH_SUCCEEDED = "MATCH_SUCCEEDED";
private final ApplicationMatchEventRepository eventRepository;
private final ApplicationMatchRecordRepository recordRepository;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final CompanyRepository companyRepository;
private final StudentProfileRepository profileRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendMatchSucceeded(Long applicationId) {
ApplicationMatchEvent event = eventRepository
.findByApplicationIdAndEventTypeAndDedupKey(applicationId,
ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED,
ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)
.orElse(null);
if (event == null || ApplicationMatchEvent.NOTIFY_SENT.equals(event.getNotificationStatus())) {
return; // 无事件或已发送
}
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
Application app = applicationRepository.findById(applicationId).orElse(null);
if (record == null || app == null) return;
Job job = jobService.getJob(record.getJobId());
Company company = companyRepository.findById(record.getCompanyId()).orElse(null);
String jobTitle = job.getTitle() != null ? job.getTitle() : "岗位";
String companyName = company != null && company.getName() != null ? company.getName() : "企业";
String candidateName = resolveCandidateName(record.getCandidateId());
// 求职者
notificationService.create(record.getCandidateId(), TYPE_MATCH_SUCCEEDED,
"双向匹配成功",
"你与「" + companyName + "」的岗位「" + jobTitle + "」已达成双向匹配,可与企业直聊",
"MATCH", applicationId, "match-succeeded-" + applicationId + "-candidate");
// 企业
if (company != null) {
notificationService.create(company.getOwnerUserId(), TYPE_MATCH_SUCCEEDED,
"双向匹配成功",
"候选人「" + candidateName + "」与你发布的岗位「" + jobTitle + "」已达成双向匹配,可与其直聊",
"MATCH", applicationId, "match-succeeded-" + applicationId + "-company");
}
event.setNotificationStatus(ApplicationMatchEvent.NOTIFY_SENT);
eventRepository.save(event);
log.info("MATCH_SUCCEEDED notification sent: applicationId={}", applicationId);
}
private String resolveCandidateName(Long candidateId) {
StudentProfile profile = profileRepository.findByUserId(candidateId).orElse(null);
if (profile != null && profile.getRealName() != null && !profile.getRealName().isBlank()) {
return profile.getRealName();
}
User user = userRepository.findById(candidateId).orElse(null);
if (user != null && user.getNickname() != null && !user.getNickname().isBlank()) {
return user.getNickname();
}
return "候选人";
}
}

173
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriter.java

@ -0,0 +1,173 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.application.Application;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.job.Job;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
/**
* 匹配记录写入工作单元:每次评分在独立事务(REQUIRES_NEW)中执行。
*
* <p>原因:并发首评时两条 INSERT 撞唯一键会把同一事务标记 rollback-only;
* 独立事务保证单次冲突只回滚自身,外层可安全重试,且"仅一次进入 MATCHED"由
* MATCH_SUCCEEDED 事件唯一约束兜底。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MatchRecordWriter {
private final ApplicationMatchRecordRepository recordRepository;
private final ApplicationMatchEventRepository eventRepository;
private final CompanyMatchRuleRepository companyRuleRepository;
private final StudentMatchRuleRepository studentRuleRepository;
private final MatchNotificationService matchNotificationService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ApplicationMatchRecord applyScore(Application app, Job job, Long actorUserId,
int score, MatchScoreField field, String requestId) {
ApplicationMatchRecord record = loadOrCreate(app, job);
if (ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) {
throw new BusinessException(ErrorCode.MATCH_SCORES_LOCKED);
}
if (ApplicationMatchRecord.STATUS_CLOSED.equals(record.getStatus())) {
throw new BusinessException(ErrorCode.MATCH_CLOSED);
}
// 阈值快照:本次计算实际使用的阈值(缺失规则用默认 60)
int companyThreshold = resolveCompanyThreshold(job.getCompanyId());
int studentThreshold = resolveStudentThreshold(app.getStudentUserId());
record.setCompanyRequiredCandidateScore(companyThreshold);
record.setStudentRequiredHrScore(studentThreshold);
Integer oldScore = field == MatchScoreField.CANDIDATE_INTEREST
? record.getCandidateInterestScore() : record.getHrSuitabilityScore();
String oldStatus = record.getStatus();
if (field == MatchScoreField.CANDIDATE_INTEREST) {
record.setCandidateInterestScore(score);
record.setCandidateScoreSource(ApplicationMatchRecord.SOURCE_MANUAL_INTEREST);
} else {
record.setHrSuitabilityScore(score);
record.setHrScoreSource(ApplicationMatchRecord.SOURCE_HR_MANUAL);
}
String newStatus = computeStatus(record);
record.setStatus(newStatus);
boolean enteredMatched = ApplicationMatchRecord.STATUS_MATCHED.equals(newStatus)
&& !ApplicationMatchRecord.STATUS_MATCHED.equals(oldStatus);
if (enteredMatched) {
record.setMatchedAt(LocalDateTime.now());
}
record = recordRepository.saveAndFlush(record);
recordEvents(record, oldStatus, newStatus, oldScore, score, field, actorUserId, requestId);
if (enteredMatched) {
registerMatchSucceededSend(record.getApplicationId());
}
return record;
}
private ApplicationMatchRecord loadOrCreate(Application app, Job job) {
return recordRepository.findByApplicationId(app.getId())
.orElseGet(() -> ApplicationMatchRecord.builder()
.applicationId(app.getId())
.jobId(job.getId())
.companyId(job.getCompanyId())
.candidateId(app.getStudentUserId())
.companyRequiredCandidateScore(CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE)
.studentRequiredHrScore(StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE)
.status(ApplicationMatchRecord.STATUS_PENDING_BOTH)
.build());
}
private String computeStatus(ApplicationMatchRecord record) {
Integer candidate = record.getCandidateInterestScore();
Integer hr = record.getHrSuitabilityScore();
if (candidate == null && hr == null) return ApplicationMatchRecord.STATUS_PENDING_BOTH;
if (candidate == null) return ApplicationMatchRecord.STATUS_PENDING_CANDIDATE;
if (hr == null) return ApplicationMatchRecord.STATUS_PENDING_HR;
boolean candidateOk = candidate >= record.getCompanyRequiredCandidateScore();
boolean hrOk = hr >= record.getStudentRequiredHrScore();
return candidateOk && hrOk ? ApplicationMatchRecord.STATUS_MATCHED
: ApplicationMatchRecord.STATUS_NOT_MATCHED;
}
private void recordEvents(ApplicationMatchRecord record, String oldStatus, String newStatus,
Integer oldScore, int newScore, MatchScoreField field,
Long actorUserId, String requestId) {
if (oldStatus == null || !oldStatus.equals(newStatus)) {
if (ApplicationMatchRecord.STATUS_MATCHED.equals(newStatus)) {
try {
eventRepository.save(ApplicationMatchEvent.builder()
.applicationId(record.getApplicationId())
.actorUserId(actorUserId)
.eventType(ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)
.dedupKey(ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)
.oldStatus(oldStatus).newStatus(newStatus)
.notificationStatus(ApplicationMatchEvent.NOTIFY_PENDING)
.requestId(requestId)
.build());
} catch (DataIntegrityViolationException e) {
// 并发:另一侧已写入 → 幂等跳过
log.info("MATCH_SUCCEEDED event already exists: applicationId={}", record.getApplicationId());
}
} else {
eventRepository.save(ApplicationMatchEvent.builder()
.applicationId(record.getApplicationId())
.actorUserId(actorUserId)
.eventType(ApplicationMatchEvent.EVENT_STATUS_CHANGED)
.oldStatus(oldStatus).newStatus(newStatus)
.requestId(requestId)
.build());
}
}
if (oldScore == null || !oldScore.equals(newScore)) {
eventRepository.save(ApplicationMatchEvent.builder()
.applicationId(record.getApplicationId())
.actorUserId(actorUserId)
.eventType(ApplicationMatchEvent.EVENT_SCORE_UPDATED)
.oldScore(oldScore).newScore(newScore)
.scoreField(field.name())
.requestId(requestId)
.build());
}
}
/** 在本次 REQUIRES_NEW 事务提交后发送通知(REQUIRES_NEW 保证重试/回滚时不会误发) */
private void registerMatchSucceededSend(Long applicationId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
matchNotificationService.sendMatchSucceeded(applicationId);
} catch (Exception e) {
log.warn("Match notification immediate send failed, will retry: applicationId={}, error={}",
applicationId, e.getMessage());
}
}
});
}
}
private int resolveCompanyThreshold(Long companyId) {
return companyRuleRepository.findByCompanyId(companyId)
.map(CompanyMatchRule::getRequiredCandidateScore)
.orElse(CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE);
}
private int resolveStudentThreshold(Long studentUserId) {
return studentRuleRepository.findByStudentUserId(studentUserId)
.map(StudentMatchRule::getRequiredHrScore)
.orElse(StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE);
}
}

73
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java

@ -0,0 +1,73 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.bidirectionalmatch.dto.CompanyMatchRuleRequest;
import com.mydreamjob.bidirectionalmatch.dto.StudentMatchRuleRequest;
import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 匹配阈值配置接口(企业 requiredCandidateScore / 学生 requiredHrScore)
*/
@Tag(name = "双向匹配阈值", description = "企业与求职者的匹配阈值配置")
@RestController
@RequiredArgsConstructor
public class MatchRuleController {
private final MatchRuleService matchRuleService;
@Operation(summary = "查询企业匹配阈值", description = "未配置返回默认 60")
@GetMapping("/companies/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyRule(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getCompanyRule(userId), getRequestId(request)));
}
@Operation(summary = "更新企业匹配阈值", description = "requiredCandidateScore 0-100;首次更新创建配置")
@PutMapping("/companies/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCompanyRule(
@Valid @RequestBody CompanyMatchRuleRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
matchRuleService.updateCompanyRule(userId, req.getRequiredCandidateScore()),
getRequestId(request)));
}
@Operation(summary = "查询学生匹配阈值", description = "未配置返回默认 60")
@GetMapping("/profile/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> getStudentRule(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchRuleService.getStudentRule(userId), getRequestId(request)));
}
@Operation(summary = "更新学生匹配阈值", description = "requiredHrScore 0-100;首次更新创建配置")
@PutMapping("/profile/me/match-rule")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateStudentRule(
@Valid @RequestBody StudentMatchRuleRequest req, HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
matchRuleService.updateStudentRule(userId, req.getRequiredHrScore()),
getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

104
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java

@ -0,0 +1,104 @@
package com.mydreamjob.bidirectionalmatch;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
/**
* 匹配阈值配置:企业 requiredCandidateScore / 学生 requiredHrScore。
* 规则配置不存在时读取返回默认 60;首次更新时创建。
*/
@Service
@RequiredArgsConstructor
public class MatchRuleService {
private final CompanyMatchRuleRepository companyRuleRepository;
private final StudentMatchRuleRepository studentRuleRepository;
private final CompanyRepository companyRepository;
private final UserRepository userRepository;
// ── 企业 ──────────────────────────────────────────────────
@Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
return companyView(company.getId(), required);
}
@Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
rule.setRequiredCandidateScore(requiredCandidateScore);
rule = companyRuleRepository.save(rule);
return companyView(company.getId(), rule.getRequiredCandidateScore());
}
// ── 学生 ──────────────────────────────────────────────────
@Transactional(readOnly = true)
public Map<String, Object> getStudentRule(Long userId) {
requireStudent(userId);
StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId).orElse(null);
int required = rule != null ? rule.getRequiredHrScore()
: StudentMatchRule.DEFAULT_REQUIRED_HR_SCORE;
return studentView(userId, required);
}
@Transactional
public Map<String, Object> updateStudentRule(Long userId, int requiredHrScore) {
requireStudent(userId);
StudentMatchRule rule = studentRuleRepository.findByStudentUserId(userId)
.orElseGet(() -> StudentMatchRule.builder().studentUserId(userId).build());
rule.setRequiredHrScore(requiredHrScore);
rule = studentRuleRepository.save(rule);
return studentView(userId, rule.getRequiredHrScore());
}
// ── 视图 / 私有 ───────────────────────────────────────────
private Map<String, Object> companyView(Long companyId, int required) {
Map<String, Object> v = new HashMap<>();
v.put("companyId", companyId);
v.put("requiredCandidateScore", required);
return v;
}
private Map<String, Object> studentView(Long studentUserId, int required) {
Map<String, Object> v = new HashMap<>();
v.put("studentUserId", studentUserId);
v.put("requiredHrScore", required);
return v;
}
private Company requireCompany(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isCompany = user.getRoles() != null
&& user.getRoles().stream().anyMatch(r -> r == User.Role.COMPANY);
if (!isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
}
private void requireStudent(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles() != null
&& user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN);
}
}

9
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchScoreField.java

@ -0,0 +1,9 @@
package com.mydreamjob.bidirectionalmatch;
/**
* 评分字段类型:求职者意愿分 / 企业 HR 适配分
*/
public enum MatchScoreField {
CANDIDATE_INTEREST,
HR_SUITABILITY
}

48
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRule.java

@ -0,0 +1,48 @@
package com.mydreamjob.bidirectionalmatch;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 学生匹配阈值:HR 对候选人的"适配分"须达到学生设定的最低值(默认 60)。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student_match_rules",
uniqueConstraints = @UniqueConstraint(name = "uk_student_match_rules_student",
columnNames = {"student_user_id"}))
@EntityListeners(AuditingEntityListener.class)
public class StudentMatchRule {
public static final int DEFAULT_REQUIRED_HR_SCORE = 60;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "required_hr_score", nullable = false)
@Builder.Default
private Integer requiredHrScore = DEFAULT_REQUIRED_HR_SCORE;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRuleRepository.java

@ -0,0 +1,11 @@
package com.mydreamjob.bidirectionalmatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface StudentMatchRuleRepository extends JpaRepository<StudentMatchRule, Long> {
Optional<StudentMatchRule> findByStudentUserId(Long studentUserId);
}

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/CompanyMatchRuleRequest.java

@ -0,0 +1,18 @@
package com.mydreamjob.bidirectionalmatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 企业匹配阈值更新请求
*/
@Data
public class CompanyMatchRuleRequest {
@NotNull(message = "requiredCandidateScore is required")
@Min(value = 0, message = "requiredCandidateScore must be between 0 and 100")
@Max(value = 100, message = "requiredCandidateScore must be between 0 and 100")
private Integer requiredCandidateScore;
}

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/ScoreRequest.java

@ -0,0 +1,18 @@
package com.mydreamjob.bidirectionalmatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 评分写入请求(求职者意愿分 / HR 适配分,0-100)
*/
@Data
public class ScoreRequest {
@NotNull(message = "score is required")
@Min(value = 0, message = "score must be between 0 and 100")
@Max(value = 100, message = "score must be between 0 and 100")
private Integer score;
}

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/StudentMatchRuleRequest.java

@ -0,0 +1,18 @@
package com.mydreamjob.bidirectionalmatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 学生匹配阈值更新请求
*/
@Data
public class StudentMatchRuleRequest {
@NotNull(message = "requiredHrScore is required")
@Min(value = 0, message = "requiredHrScore must be between 0 and 100")
@Max(value = 100, message = "requiredHrScore must be between 0 and 100")
private Integer requiredHrScore;
}

96
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java

@ -0,0 +1,96 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.candidatescore.dto.CandidatePercentileResponse;
import com.mydreamjob.candidatescore.dto.CandidateScoreCardResponse;
import com.mydreamjob.candidatescore.dto.CandidateScoreTrendResponse;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@Tag(name = "求职者评分卡", description = "求职者评价聚合与评分卡")
@RestController
@RequiredArgsConstructor
public class CandidateScoreCardController {
private final CandidateScoreCardService scoreCardService;
private final CompanyService companyService;
@Operation(summary = "查询求职者评分卡", description = "仅 STUDENT;返回自己的评价聚合卡")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class)))
})
@GetMapping("/profile/me/candidate-score-card")
public ResponseEntity<ApiResponse<CandidateScoreCardResponse>> getScoreCard(
HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
CandidateScoreCardResponse card = scoreCardService.getScoreCard(userId);
return ResponseEntity.ok(ApiResponse.ok(card, getRequestId(request)));
}
@Operation(summary = "查询评分趋势", description = "仅 STUDENT;返回最近月份的反馈趋势")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class)))
})
@GetMapping("/profile/me/candidate-score-trend")
public ResponseEntity<ApiResponse<CandidateScoreTrendResponse>> getTrend(
@Parameter(description = "统计月数,默认 6,范围 1-24")
@RequestParam(defaultValue = "6") int months,
HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
CandidateScoreTrendResponse trend = scoreCardService.getTrend(userId, months);
return ResponseEntity.ok(ApiResponse.ok(trend, getRequestId(request)));
}
@Operation(summary = "查询百分位", description = "仅 STUDENT;返回自己在候选人中的评分百分位")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色",
content = @Content(schema = @Schema(implementation = com.mydreamjob.common.api.ApiResponse.class)))
})
@GetMapping("/profile/me/candidate-percentile")
public ResponseEntity<ApiResponse<CandidatePercentileResponse>> getPercentile(
HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
CandidatePercentileResponse percentile = scoreCardService.getPercentile(userId);
return ResponseEntity.ok(ApiResponse.ok(percentile, getRequestId(request)));
}
private Long resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

64
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardRepository.java

@ -0,0 +1,64 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.feedback.CandidateFeedback;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CandidateScoreCardRepository extends JpaRepository<CandidateFeedback, Long> {
/** 反馈总数和平均评分(合并查询,减少 SQL 次数) */
@Query("SELECT COUNT(f), COALESCE(AVG(f.rating), 0) FROM CandidateFeedback f WHERE f.studentUserId = :studentUserId")
List<Object[]> countAndAvgRating(@Param("studentUserId") Long studentUserId);
/** 评分分布:每组 rating 的计数 */
@Query("SELECT f.rating, COUNT(f) FROM CandidateFeedback f WHERE f.studentUserId = :studentUserId GROUP BY f.rating ORDER BY f.rating")
List<Object[]> ratingDistribution(@Param("studentUserId") Long studentUserId);
/** 当前学生的所有 tags(JSON 字符串列表,Java 层聚合) */
@Query("SELECT f.tags FROM CandidateFeedback f WHERE f.studentUserId = :studentUserId AND f.tags IS NOT NULL")
List<String> findTagsByStudentUserId(@Param("studentUserId") Long studentUserId);
/** 趋势:按年月分组,按 cutoffDate 过滤,按年月升序 */
@Query("SELECT FUNCTION('YEAR', f.createdAt), FUNCTION('MONTH', f.createdAt), " +
"COUNT(f), AVG(f.rating) " +
"FROM CandidateFeedback f " +
"WHERE f.studentUserId = :studentUserId AND f.createdAt >= :cutoffDate " +
"GROUP BY FUNCTION('YEAR', f.createdAt), FUNCTION('MONTH', f.createdAt) " +
"ORDER BY FUNCTION('YEAR', f.createdAt), FUNCTION('MONTH', f.createdAt)")
List<Object[]> trendByMonth(@Param("studentUserId") Long studentUserId,
@Param("cutoffDate") LocalDateTime cutoffDate);
/** 百分位:cohort 总人数(>= 3 条反馈的求职者数) */
@Query(value = "SELECT COUNT(*) FROM (" +
"SELECT cf.student_user_id FROM candidate_feedback cf " +
"GROUP BY cf.student_user_id HAVING COUNT(*) >= 3" +
") cohort", nativeQuery = true)
Long countCohortSize();
/** 百分位:当前求职者的平均分 (×20 = rating/5*100) */
@Query(value = "SELECT COALESCE(AVG(cf.rating) * 20.0, 0) " +
"FROM candidate_feedback cf WHERE cf.student_user_id = :studentUserId",
nativeQuery = true)
Double getStudentScore(@Param("studentUserId") Long studentUserId);
/** 百分位:分数严格低于当前求职者的 cohort 人数 */
@Query(value = "SELECT COUNT(*) FROM (" +
"SELECT cf.student_user_id FROM candidate_feedback cf " +
"GROUP BY cf.student_user_id HAVING COUNT(*) >= 3 " +
"AND AVG(cf.rating) < (" +
" SELECT AVG(cf2.rating) FROM candidate_feedback cf2 WHERE cf2.student_user_id = :studentUserId" +
")" +
") lower_cohort", nativeQuery = true)
Long countLowerScores(@Param("studentUserId") Long studentUserId);
/** 百分位:当前求职者的反馈条数 */
@Query(value = "SELECT COUNT(*) FROM candidate_feedback cf WHERE cf.student_user_id = :studentUserId",
nativeQuery = true)
Long countStudentFeedbacks(@Param("studentUserId") Long studentUserId);
}

241
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java

@ -0,0 +1,241 @@
package com.mydreamjob.candidatescore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository;
import com.mydreamjob.candidatescore.dto.*;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class CandidateScoreCardService {
private static final List<String> UNSUPPORTED_DIMENSIONS = List.of(
"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"
);
private static final int MINIMUM_SAMPLE_COUNT = 3;
private static final ObjectMapper mapper = new ObjectMapper();
private final CandidateScoreCardRepository scoreCardRepository;
private final ApplicationMatchRecordRepository matchRecordRepository;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
// ── 评分卡(共 4 条 SQL:1 反馈摘要 + 1 评分分布 + 1 标签 + 2 匹配信号) ──
public CandidateScoreCardResponse getScoreCard(Long studentUserId) {
// 1. 反馈摘要(COUNT + AVG 合并为 1 条 SQL)
List<Object[]> summaryList = scoreCardRepository.countAndAvgRating(studentUserId);
Object[] summary = summaryList.isEmpty() ? new Object[]{0L, 0.0} : summaryList.get(0);
long feedbackCount = ((Number) summary[0]).longValue();
double avgRating = ((Number) summary[1]).doubleValue();
// 2. 评分分布
Map<Integer, Integer> ratingDist = new LinkedHashMap<>();
for (int i = 1; i <= 5; i++) ratingDist.put(i, 0);
List<Object[]> distRows = scoreCardRepository.ratingDistribution(studentUserId);
for (Object[] row : distRows) {
int rating = ((Number) row[0]).intValue();
int count = ((Number) row[1]).intValue();
ratingDist.put(rating, count);
}
// 3. 标签聚合(使用 JdbcTemplate 避免 H2 JSON 类型兼容问题)
List<String> tagJsons = jdbcTemplate.queryForList(
"SELECT CAST(cf.tags AS VARCHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?",
String.class, studentUserId);
Map<String, Integer> tagCounts = aggregateTags(tagJsons);
List<CandidateFeedbackSummary.TagCount> tagList = tagCounts.entrySet().stream()
.map(e -> CandidateFeedbackSummary.TagCount.builder()
.name(e.getKey()).count(e.getValue()).build())
.sorted((a, b) -> Integer.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
CandidateFeedbackSummary feedbackSummary = CandidateFeedbackSummary.builder()
.totalCount((int) feedbackCount)
.averageRating(feedbackCount > 0
? BigDecimal.valueOf(avgRating).setScale(2, RoundingMode.HALF_UP)
: null)
.ratingDistribution(ratingDist)
.tags(tagList)
.build();
// 4-5. 匹配信号(candidateInterestScore + hrSuitabilityScore,各 1 条 SQL)
List<Object[]> interestList = matchRecordRepository.avgAndCountCandidateInterestScore(studentUserId);
Object[] interestData = interestList.isEmpty() ? new Object[]{0.0, 0L} : interestList.get(0);
double avgInterest = ((Number) interestData[0]).doubleValue();
long interestCount = ((Number) interestData[1]).longValue();
List<Object[]> hrList = matchRecordRepository.avgAndCountHrSuitabilityScore(studentUserId);
Object[] hrData = hrList.isEmpty() ? new Object[]{0.0, 0L} : hrList.get(0);
double avgHr = ((Number) hrData[0]).doubleValue();
long hrCount = ((Number) hrData[1]).longValue();
CandidateMatchSignals.MatchSignal interestSignal = CandidateMatchSignals.MatchSignal.builder()
.average(interestCount > 0
? BigDecimal.valueOf(avgInterest).setScale(2, RoundingMode.HALF_UP)
: null)
.sampleCount((int) interestCount)
.build();
CandidateMatchSignals.MatchSignal hrSignal = CandidateMatchSignals.MatchSignal.builder()
.average(hrCount > 0
? BigDecimal.valueOf(avgHr).setScale(2, RoundingMode.HALF_UP)
: null)
.sampleCount((int) hrCount)
.build();
CandidateMatchSignals matchSignals = CandidateMatchSignals.builder()
.candidateInterestScore(interestSignal)
.hrSuitabilityScore(hrSignal)
.build();
// 状态判定
boolean hasData = feedbackCount > 0 || interestCount > 0 || hrCount > 0;
String status = hasData ? "OK" : "INSUFFICIENT_DATA";
return CandidateScoreCardResponse.builder()
.candidateId(studentUserId)
.status(status)
.overallScore(null)
.sampleCount((int) feedbackCount)
.feedbackSummary(feedbackSummary)
.matchSignals(matchSignals)
.unsupportedDimensions(UNSUPPORTED_DIMENSIONS)
.generatedAt(Instant.now())
.build();
}
// ── 趋势(1 条 SQL:按月聚合) ──────────────────────────
public CandidateScoreTrendResponse getTrend(Long studentUserId, int months) {
if (months < 1 || months > 24) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"months must be between 1 and 24");
}
// 最近 N 个自然月:从 (当前月 - (months-1)) 的第一天开始
LocalDate startOfMonth = LocalDate.now().withDayOfMonth(1).minusMonths(months - 1);
LocalDateTime cutoffDate = startOfMonth.atStartOfDay();
List<Object[]> rows = scoreCardRepository.trendByMonth(studentUserId, cutoffDate);
List<CandidateScoreTrendItem> items = new ArrayList<>();
for (Object[] row : rows) {
int year = ((Number) row[0]).intValue();
int month = ((Number) row[1]).intValue();
int count = ((Number) row[2]).intValue();
double avgRating = ((Number) row[3]).doubleValue();
String monthStr = String.format("%d-%02d", year, month);
BigDecimal ratingScore = BigDecimal.valueOf(avgRating / 5.0 * 100)
.setScale(2, RoundingMode.HALF_UP);
items.add(CandidateScoreTrendItem.builder()
.month(monthStr)
.feedbackCount(count)
.averageRating(BigDecimal.valueOf(avgRating).setScale(2, RoundingMode.HALF_UP))
.ratingScore(ratingScore)
.build());
}
return CandidateScoreTrendResponse.builder()
.candidateId(studentUserId)
.months(months)
.items(items)
.build();
}
// ── 百分位(3 条原生 SQL:cohort 人数 + 当前分数 + 排名) ──
public CandidatePercentileResponse getPercentile(Long studentUserId) {
long sampleCount = scoreCardRepository.countStudentFeedbacks(studentUserId);
// 当前用户反馈数 < 3 → INSUFFICIENT_DATA
if (sampleCount < MINIMUM_SAMPLE_COUNT) {
return insufficientData(studentUserId, (int) sampleCount);
}
Long cohortSize = scoreCardRepository.countCohortSize();
// cohort < 2 → INSUFFICIENT_DATA
if (cohortSize == null || cohortSize < 2) {
return insufficientData(studentUserId, (int) sampleCount);
}
Double myScore = scoreCardRepository.getStudentScore(studentUserId);
// 当前用户不在 cohort 中(理论上不会发生,但防御性检查)
if (myScore == null || myScore == 0.0) {
return insufficientData(studentUserId, (int) sampleCount);
}
Long countLower = scoreCardRepository.countLowerScores(studentUserId);
BigDecimal score = BigDecimal.valueOf(myScore).setScale(2, RoundingMode.HALF_UP);
// 百分位 = countLower / (cohortSize - 1) * 100
BigDecimal percentile = BigDecimal.valueOf(countLower == null ? 0 : countLower)
.divide(BigDecimal.valueOf(cohortSize - 1), 10, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
return CandidatePercentileResponse.builder()
.candidateId(studentUserId)
.status("OK")
.scoreType("FEEDBACK_RATING")
.score(score)
.percentile(percentile)
.cohortSize(cohortSize.intValue())
.sampleCount((int) sampleCount)
.minimumSampleCount(MINIMUM_SAMPLE_COUNT)
.build();
}
private CandidatePercentileResponse insufficientData(Long studentUserId, int sampleCount) {
return CandidatePercentileResponse.builder()
.candidateId(studentUserId)
.status("INSUFFICIENT_DATA")
.scoreType("FEEDBACK_RATING")
.score(null)
.percentile(null)
.cohortSize(0)
.sampleCount(sampleCount)
.minimumSampleCount(MINIMUM_SAMPLE_COUNT)
.build();
}
// ── 标签聚合 ──────────────────────────────────────────
private Map<String, Integer> aggregateTags(List<String> tagJsons) {
Map<String, Integer> counts = new HashMap<>();
for (String json : tagJsons) {
if (json == null || json.isBlank()) continue;
try {
List<String> tags = mapper.readValue(json, new TypeReference<List<String>>() {});
for (String tag : tags) {
if (tag != null && !tag.isBlank()) {
counts.merge(tag.trim(), 1, Integer::sum);
}
}
} catch (JsonProcessingException e) {
log.warn("Failed to parse tags JSON: {}", json, e);
}
}
return counts;
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateFeedbackSummary.java

@ -0,0 +1,28 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class CandidateFeedbackSummary {
private int totalCount;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal averageRating;
private Map<Integer, Integer> ratingDistribution;
private List<TagCount> tags;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class TagCount {
private String name;
private int count;
}
}

22
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateMatchSignals.java

@ -0,0 +1,22 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CandidateMatchSignals {
private MatchSignal candidateInterestScore;
private MatchSignal hrSuitabilityScore;
@Data
@Builder
public static class MatchSignal {
@JsonInclude(JsonInclude.Include.ALWAYS)
private java.math.BigDecimal average;
private int sampleCount;
}
}

23
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidatePercentileResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class CandidatePercentileResponse {
private Long candidateId;
private String status;
private String scoreType;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal percentile;
private int cohortSize;
private int sampleCount;
private int minimumSampleCount;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreCardResponse.java

@ -0,0 +1,24 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
@Data
@Builder
public class CandidateScoreCardResponse {
private Long candidateId;
private String status;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
private int sampleCount;
private CandidateFeedbackSummary feedbackSummary;
private CandidateMatchSignals matchSignals;
private List<String> unsupportedDimensions;
private Instant generatedAt;
}

18
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendItem.java

@ -0,0 +1,18 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CandidateScoreTrendItem {
private String month;
private int feedbackCount;
private BigDecimal averageRating;
private BigDecimal ratingScore;
}

17
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendResponse.java

@ -0,0 +1,17 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CandidateScoreTrendResponse {
private Long candidateId;
private int months;
private List<CandidateScoreTrendItem> items;
}

9
mydreamjob-server/src/main/java/com/mydreamjob/career/CareerService.java

@ -0,0 +1,9 @@
package com.mydreamjob.career;
/**
* 投递模块 - 接口定义(Week 1 仅骨架,不实现业务逻辑)
* TODO: Week 2+ 实现投递 CRUD、状态流转、审核等功能
*/
public interface CareerService {
// CareerController, CareerRepository, Career 实体 待 Week 2+ 实现
}

82
mydreamjob-server/src/main/java/com/mydreamjob/common/api/ApiResponse.java

@ -0,0 +1,82 @@
package com.mydreamjob.common.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.io.Serializable;
import java.time.Instant;
/**
* 统一 API 响应包装
*
* <p>规范:
* <ul>
* <li>{@code code} — 业务状态码(200=成功,4xx=客户端错误,5xx=服务端错误)</li>
* <li>{@code message} — 成功时固定为 {@code "ok"},错误时为具体描述</li>
* <li>{@code data} — 响应体,成功时携带,失败时为 {@code null}</li>
* <li>{@code requestId} — 链路追踪 ID,由 {@link com.mydreamjob.common.logging.RequestIdFilter} 注入</li>
* <li>{@code timestamp} — 响应时间</li>
* </ul>
*
* @param <T> 响应体类型
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> implements Serializable {
private static final long serialVersionUID = 1L;
private int code;
private String message;
private T data;
private String requestId;
private Instant timestamp;
private ApiResponse(int code, String message, T data, String requestId) {
this.code = code;
this.message = message;
this.data = data;
this.requestId = requestId;
this.timestamp = Instant.now();
}
public static <T> ApiResponse<T> ok(T data, String requestId) {
return new ApiResponse<>(200, "ok", data, requestId);
}
public static <T> ApiResponse<T> created(T data, String requestId) {
return new ApiResponse<>(201, "ok", data, requestId);
}
public static <T> ApiResponse<T> error(int code, String message, String requestId) {
return new ApiResponse<>(code, message, null, requestId);
}
public static <T> ApiResponse<T> badRequest(String message, String requestId) {
return error(400, message, requestId);
}
public static <T> ApiResponse<T> unauthorized(String message, String requestId) {
return error(401, message, requestId);
}
public static <T> ApiResponse<T> forbidden(String message, String requestId) {
return error(403, message, requestId);
}
public static <T> ApiResponse<T> notFound(String message, String requestId) {
return error(404, message, requestId);
}
public static <T> ApiResponse<T> conflict(String message, String requestId) {
return error(409, message, requestId);
}
public static <T> ApiResponse<T> tooManyRequests(String message, String requestId) {
return error(429, message, requestId);
}
public static <T> ApiResponse<T> internalError(String requestId) {
return error(500, "internal server error", requestId);
}
}

33
mydreamjob-server/src/main/java/com/mydreamjob/common/api/PageResponse.java

@ -0,0 +1,33 @@
package com.mydreamjob.common.api;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 分页响应包装
*
* @param <T> 元素类型
*/
@Data
public class PageResponse<T> implements Serializable {
private static final long serialVersionUID = 1L;
private List<T> items;
private long total;
private int page;
private int size;
private int totalPages;
public static <T> PageResponse<T> of(List<T> items, long total, int page, int size) {
PageResponse<T> response = new PageResponse<>();
response.setItems(items);
response.setTotal(total);
response.setPage(page);
response.setSize(size);
response.setTotalPages((int) Math.ceil((double) total / size));
return response;
}
}

35
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/BusinessException.java

@ -0,0 +1,35 @@
package com.mydreamjob.common.exception;
import lombok.Getter;
/**
* 业务异常基类,携带错误码
*/
@Getter
public class BusinessException extends RuntimeException {
private final int errorCode;
private final String errorMessage;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode.getCode();
this.errorMessage = errorCode.getMessage();
}
public BusinessException(ErrorCode errorCode, String detail) {
super(detail);
this.errorCode = errorCode.getCode();
this.errorMessage = detail;
}
public BusinessException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode.getCode();
this.errorMessage = errorCode.getMessage();
}
public int getErrorCodeInt() {
return errorCode;
}
}

129
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -0,0 +1,129 @@
package com.mydreamjob.common.exception;
/**
* 业务错误码枚举
*/
public enum ErrorCode {
// 通用 1xxx
SUCCESS(1000, "success"),
BAD_REQUEST(1001, "bad request"),
INTERNAL_ERROR(1002, "internal server error"),
// 认证鉴权 2xxx
UNAUTHORIZED(2001, "unauthorized"),
FORBIDDEN(2002, "forbidden"),
TOKEN_EXPIRED(2003, "token expired"),
TOKEN_INVALID(2004, "token invalid"),
CREDENTIALS_INVALID(2005, "invalid credentials"),
// 验证码 3xxx
SMS_CODE_SENT(3001, "sms code sent"),
SMS_CODE_EXPIRED(3002, "sms code expired"),
SMS_CODE_INVALID(3003, "sms code invalid"),
SMS_CODE_ATTEMPTS_EXCEEDED(3004, "sms code attempts exceeded"),
SMS_RATE_LIMITED(3005, "sms rate limited"),
SMS_SEND_FAILED(3006, "sms send failed"),
// 用户 4xxx
USER_NOT_FOUND(4001, "user not found"),
USER_ALREADY_EXISTS(4002, "user already exists"),
USER_DISABLED(4003, "user disabled"),
// 企业 5xxx
COMPANY_NOT_FOUND(5001, "company not found"),
COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"),
// 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"),
// 岗位 7xxx
JOB_NOT_FOUND(7001, "job not found"),
JOB_OWNER_MISMATCH(7002, "job owner mismatch"),
INVALID_JOB_STATUS_TRANSITION(7003, "invalid job status transition"),
// 投递 8xxx
APPLICATION_DUPLICATE(8001, "duplicate application"),
APPLICATION_NOT_FOUND(8002, "application not found"),
INVALID_APPLICATION_STATUS_TRANSITION(8003, "invalid application status transition"),
APPLICATION_OWNER_MISMATCH(8004, "application owner mismatch"),
// Offer 81xx
OFFER_NOT_FOUND(8101, "offer not found"),
DUPLICATE_OFFER(8102, "duplicate active offer for application"),
INVALID_OFFER_STATUS_TRANSITION(8103, "invalid offer status transition"),
OFFER_EXPIRED(8104, "offer expired"),
OFFER_NOT_EDITABLE(8105, "offer is not in draft status"),
OFFER_APPLICATION_NOT_ELIGIBLE(8106, "application is not eligible for offer"),
OFFER_REMINDER_DUPLICATE(8107, "duplicate offer reminder"),
OFFER_NOT_REMINDABLE(8108, "offer is not eligible for manual reminder"),
// 简历版本 82xx
RESUME_VERSION_NOT_FOUND(8201, "resume version not found"),
DEFAULT_RESUME_DELETE_NOT_ALLOWED(8202, "cannot delete default resume before setting another default"),
RESUME_EXPORT_TASK_NOT_FOUND(8203, "resume export task not found"),
RESUME_EXPORT_IN_PROGRESS(8204, "resume export task in progress"),
DUPLICATE_DEFAULT_RESUME(8205, "duplicate default resume"),
RESUME_EXPORT_NOT_READY(8206, "resume export task is not ready for download"),
// 双向匹配 83xx
MATCH_CLOSED(8301, "match is closed"),
MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"),
MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"),
APPLICATION_NOT_MATCHED(8304, "application is not matched"),
// 评价 91xx
DUPLICATE_REVIEW(9101, "duplicate review"),
REVIEW_NOT_FOUND(9102, "review not found"),
REVIEW_ALREADY_DECIDED(9103, "review already decided"),
// 候选人反馈 9103-9104
DUPLICATE_FEEDBACK(9103, "duplicate feedback"),
FEEDBACK_NOT_FOUND(9104, "feedback not found"),
// 通知 9105
NOTIFICATION_NOT_FOUND(9105, "notification not found"),
// 评价权重 9106
REVIEW_WEIGHT_CONCURRENT_UPDATE(9106, "concurrent weight update conflict"),
// 面试 84xx
INTERVIEW_NOT_FOUND(8401, "interview not found"),
INTERVIEW_ACCESS_DENIED(8402, "interview access denied"),
INVALID_INTERVIEW_STATUS_TRANSITION(8403, "invalid interview status transition"),
INTERVIEW_TIME_INVALID(8404, "interview start time must be before end time and in the future"),
INTERVIEW_DURATION_EXCEEDED(8405, "interview duration must not exceed 8 hours"),
INTERVIEW_ONLINE_URL_REQUIRED(8406, "meeting URL is required for online interviews"),
INTERVIEW_OFFLINE_ADDRESS_REQUIRED(8407, "address is required for offline interviews"),
INTERVIEW_START_TIME_PAST(8408, "interview start time must be in the future"),
INTERVIEW_COMPLETE_NOT_READY(8409, "interview cannot be completed before its end time"),
INTERVIEW_ALREADY_RESPONDED(8410, "interview has already been responded to"),
// 企业认证 85xx
VERIFICATION_NOT_FOUND(8501, "verification not found"),
VERIFICATION_ALREADY_PENDING(8502, "verification already pending"),
VERIFICATION_ALREADY_APPROVED(8503, "verification already approved"),
INVALID_VERIFICATION_STATUS_TRANSITION(8504, "invalid verification status transition"),
VERIFICATION_REJECT_REASON_REQUIRED(8505, "reject reason is required"),
// 系统 9xxx
SERVICE_UNAVAILABLE(9001, "service unavailable"),
RATE_LIMITED(9002, "rate limited");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}

20
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/FileUploadExceptionHandler.java

@ -0,0 +1,20 @@
package com.mydreamjob.common.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class FileUploadExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<com.mydreamjob.common.api.ApiResponse<Void>> handleMaxSize(
MaxUploadSizeExceededException ex) {
return ResponseEntity
.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(com.mydreamjob.common.api.ApiResponse.error(
413, "file size exceeds maximum allowed (10MB)", null));
}
}

190
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -0,0 +1,190 @@
package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.HashMap;
import java.util.Map;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) {
log.warn("Constraint violation: path={}, message={}", request.getRequestURI(), ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.badRequest(ex.getMessage(), getRequestId(request)));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(
BusinessException e, HttpServletRequest request) {
int errorCode = e.getErrorCode();
log.warn("BusinessException: code={}, message={}, path={}",
errorCode, e.getMessage(), request.getRequestURI());
HttpStatus status = resolveHttpStatus(errorCode);
return ResponseEntity
.status(status)
.body(ApiResponse.error(errorCode, e.getMessage(), getRequestId(request)));
}
/**
* 按业务错误码段映射 HTTP 状态码(与 API v0.2 草案一致):
* - FORBIDDEN(2002) → 403
* - 不存在类(5001/6001/7001/8002/4001/9102) → 404
* - 冲突/非法流转类(5003/7003/8001/8003/9101) → 409
* - 其余 → 400
*/
private HttpStatus resolveHttpStatus(int errorCode) {
// 权限 → 403
if (errorCode == ErrorCode.FORBIDDEN.getCode()) return HttpStatus.FORBIDDEN;
// 面试访问/越权 → 403
if (errorCode == ErrorCode.INTERVIEW_ACCESS_DENIED.getCode()
|| errorCode == ErrorCode.APPLICATION_OWNER_MISMATCH.getCode())
return HttpStatus.FORBIDDEN;
// 不存在 → 404
if (errorCode == ErrorCode.COMPANY_NOT_FOUND.getCode()
|| errorCode == ErrorCode.PROFILE_NOT_FOUND.getCode()
|| errorCode == ErrorCode.JOB_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.OFFER_NOT_FOUND.getCode()
|| errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.USER_NOT_FOUND.getCode()
|| errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode()
|| errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
|| errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.DUPLICATE_REVIEW.getCode()
|| errorCode == ErrorCode.DUPLICATE_FEEDBACK.getCode()
|| errorCode == ErrorCode.DUPLICATE_OFFER.getCode()
|| errorCode == ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.OFFER_EXPIRED.getCode()
|| errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode()
|| errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode()
|| errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode()
|| errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode()
|| errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode()
|| errorCode == ErrorCode.RESUME_EXPORT_NOT_READY.getCode()
|| errorCode == ErrorCode.MATCH_CLOSED.getCode()
|| errorCode == ErrorCode.MATCH_SCORES_LOCKED.getCode()
|| errorCode == ErrorCode.APPLICATION_NOT_MATCHED.getCode()
|| errorCode == ErrorCode.REVIEW_ALREADY_DECIDED.getCode()
// 面试冲突 → 409
|| errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode()
|| errorCode == ErrorCode.INTERVIEW_ALREADY_RESPONDED.getCode())
return HttpStatus.CONFLICT;
// 越权 → 403(新增:企业-求职者建会话需匹配)
if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode())
return HttpStatus.FORBIDDEN;
// 认证不存在 → 404
if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 认证冲突 → 409
if (errorCode == ErrorCode.VERIFICATION_ALREADY_PENDING.getCode()
|| errorCode == ErrorCode.VERIFICATION_ALREADY_APPROVED.getCode()
|| errorCode == ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION.getCode())
return HttpStatus.CONFLICT;
// 权重并发冲突 → 409
if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode())
return HttpStatus.CONFLICT;
return HttpStatus.BAD_REQUEST;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException ex, HttpServletRequest request) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
log.warn("Validation failed: path={}, errors={}", request.getRequestURI(), errors);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.badRequest("validation failed: " + errors, getRequestId(request)));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(
HttpMessageNotReadableException ex, HttpServletRequest request) {
log.warn("Message not readable: path={}", request.getRequestURI(), ex);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.badRequest("request body is invalid", getRequestId(request)));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParam(
MissingServletRequestParameterException ex, HttpServletRequest request) {
log.warn("Missing parameter: {} path={}", ex.getParameterName(), request.getRequestURI());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.badRequest("missing parameter: " + ex.getParameterName(), getRequestId(request)));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(
MethodArgumentTypeMismatchException ex, HttpServletRequest request) {
log.warn("Type mismatch: {} path={}", ex.getName(), request.getRequestURI());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), "bad request", getRequestId(request)));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthentication(
AuthenticationException ex, HttpServletRequest request) {
log.warn("Authentication failed: path={}, message={}", request.getRequestURI(), ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.unauthorized(ex.getMessage(), getRequestId(request)));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(
AccessDeniedException ex, HttpServletRequest request) {
log.warn("Access denied: path={}", request.getRequestURI());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.forbidden("access denied", getRequestId(request)));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(
Exception ex, HttpServletRequest request) {
log.error("Unexpected error: path={}", request.getRequestURI(), ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.internalError(getRequestId(request)));
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

37
mydreamjob-server/src/main/java/com/mydreamjob/common/health/HealthController.java

@ -0,0 +1,37 @@
package com.mydreamjob.common.health;
import com.mydreamjob.common.api.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 应用健康检查控制器(扩展 Actuator 的健康信息)
*/
@RestController
@RequestMapping("/health")
public class HealthController {
/**
* 自定义健康检查端点
*/
@GetMapping("/details")
public ApiResponse<Map<String, Object>> healthDetails() {
Map<String, Object> details = new LinkedHashMap<>();
details.put("status", "UP");
details.put("application", Map.of(
"name", "mydreamjob-server",
"version", "0.0.1-SNAPSHOT",
"javaVersion", System.getProperty("java.version"),
"uptimeSeconds", getUptimeSeconds()
));
return ApiResponse.ok(details, null);
}
private long getUptimeSeconds() {
return (System.currentTimeMillis() - java.lang.management.ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
}
}

66
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java

@ -0,0 +1,66 @@
package com.mydreamjob.common.logging;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.UUID;
/**
* 请求 ID 过滤器:为每个请求生成/传递唯一 ID,便于日志追踪
*/
@Component
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
private static final String REQUEST_ID_ATTR = "requestId";
private static final int MAX_REQUEST_ID_LENGTH = 32;
@Override
protected void doFilterInternal(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response,
jakarta.servlet.FilterChain filterChain) throws IOException, jakarta.servlet.ServletException {
String requestId = normalizeRequestId(request.getHeader(REQUEST_ID_HEADER));
MDC.put(REQUEST_ID_ATTR, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
try {
// Controllers and AuditContext read the request header directly. Wrap the
// request so they receive the same bounded value that is written to MDC
// and the response header; otherwise a client-supplied UUID can overflow
// audit_logs.request_id (VARCHAR(32)).
HttpServletRequest normalizedRequest = new HttpServletRequestWrapper(request) {
@Override
public String getHeader(String name) {
if (REQUEST_ID_HEADER.equalsIgnoreCase(name)) {
return requestId;
}
return super.getHeader(name);
}
@Override
public java.util.Enumeration<String> getHeaders(String name) {
if (REQUEST_ID_HEADER.equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(requestId));
}
return super.getHeaders(name);
}
};
filterChain.doFilter(normalizedRequest, response);
} finally {
MDC.remove(REQUEST_ID_ATTR);
}
}
static String normalizeRequestId(String candidate) {
String value = candidate == null ? "" : candidate.trim();
if (value.isEmpty()) {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
return value.length() <= MAX_REQUEST_ID_LENGTH
? value
: value.substring(0, MAX_REQUEST_ID_LENGTH);
}
}

64
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestLoggingFilter.java

@ -0,0 +1,64 @@
package com.mydreamjob.common.logging;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* 请求日志过滤器:记录请求方法、路径、耗时
*/
@Slf4j
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final String START_TIME_ATTR = "startTime";
private static final String WRAPPER_ATTR = "wrappedResponse";
@Override
protected void doFilterInternal(HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response,
jakarta.servlet.FilterChain filterChain) throws IOException, jakarta.servlet.ServletException {
long startTime = System.currentTimeMillis();
request.setAttribute(START_TIME_ATTR, startTime);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
request.setAttribute(WRAPPER_ATTR, wrappedResponse);
try {
filterChain.doFilter(request, wrappedResponse);
} finally {
long duration = System.currentTimeMillis() - startTime;
String requestId = request.getHeader("X-Request-Id");
Map<String, Object> logEntry = new HashMap<>();
logEntry.put("method", request.getMethod());
logEntry.put("path", request.getRequestURI());
logEntry.put("status", response.getStatus());
logEntry.put("durationMs", duration);
logEntry.put("requestId", requestId);
logEntry.put("remoteAddr", request.getRemoteAddr());
if (duration > 1000) {
log.warn("SLOW REQUEST: {}", objectMapperToString(logEntry));
} else {
log.debug("REQUEST: {}", objectMapperToString(logEntry));
}
wrappedResponse.copyBodyToResponse();
}
}
private String objectMapperToString(Object obj) {
try {
return new ObjectMapper().writeValueAsString(obj);
} catch (Exception e) {
return obj.toString();
}
}
}

62
mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java

@ -0,0 +1,62 @@
package com.mydreamjob.company;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 企业实体
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "companies")
@EntityListeners(AuditingEntityListener.class)
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "owner_user_id", nullable = false, unique = true)
private Long ownerUserId;
@Column(name = "name", nullable = false, length = 120)
private String name;
@Column(name = "logo_url", length = 500)
private String logoUrl;
@Column(columnDefinition = "text")
private String description;
@Column(name = "industry", length = 80)
private String industry;
@Column(name = "website", length = 255)
private String website;
@Column(name = "address", length = 255)
private String address;
@Column(name = "verified", nullable = false)
@Builder.Default
private Boolean verified = false;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

111
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -0,0 +1,111 @@
package com.mydreamjob.company;
import com.mydreamjob.company.dto.CompanyCreateRequest;
import com.mydreamjob.company.dto.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.job.dto.JobSearchItem;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "企业", description = "企业资料创建、查询与更新")
@RestController
@RequestMapping("/companies")
@RequiredArgsConstructor
public class CompanyController {
private final CompanyService companyService;
// ── 创建 ──────────────────────────────────────────────────
@Operation(summary = "创建企业资料", description = "仅 COMPANY 角色可调用;已存在时返回 409")
@PostMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> createMyCompany(
@Valid @RequestBody CompanyCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Company company = companyService.createCompany(userId, req);
return ResponseEntity.ok(ApiResponse.ok(companyService.toView(company), getRequestId(request)));
}
// ── 查询 ──────────────────────────────────────────────────
@Operation(summary = "查询当前企业资料", description = "返回当前登录用户绑定的企业资料,未绑定企业返回 404")
@GetMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMyCompany(HttpServletRequest request) {
Long userId = resolveUserId(request);
Map<String, Object> view = companyService.toView(companyService.findCompanyByOwnerUserId(userId));
if (view == null) {
return ResponseEntity.status(404).body(
ApiResponse.error(404, "company not found", getRequestId(request)));
}
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
}
// ── 更新 ──────────────────────────────────────────────────
@Operation(summary = "更新企业资料", description = "仅更新传入的非 null 字段;未绑定企业时返回 404")
@PutMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateMyCompany(
@Valid @RequestBody CompanyUpdateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Map<String, Object> view = companyService.toView(companyService.updateCompany(userId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
}
// ── 公开端:企业详情 ────────────────────────────────────────
@Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回 6 个公开字段")
@GetMapping("/{companyId}")
public ResponseEntity<ApiResponse<CompanyPublicView>> getPublicCompany(
@PathVariable Long companyId,
HttpServletRequest request) {
CompanyPublicView view = companyService.getPublicCompany(companyId);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
}
// ── 公开端:企业岗位列表 ────────────────────────────────────
@Operation(summary = "查询企业公开岗位列表", description = "匿名可访问,仅返回 PUBLISHED 岗位")
@GetMapping("/{companyId}/jobs")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs(
@PathVariable Long companyId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "latest") String sort,
HttpServletRequest request) {
Page<JobSearchItem> result = companyService.getCompanyJobs(companyId, sort, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent());
data.put("total", result.getTotalElements());
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save