commit b01616d9a7f98d5c94be177f31e62d9bf1a071ce Author: Zhang Shun Date: Mon Aug 17 15:08:39 2026 +0800 chore: initialize zhilai backend project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd27dbf --- /dev/null +++ b/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b27e19 --- /dev/null +++ b/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` + +不要将青核项目代码提交到本仓库。 diff --git a/backend-infra/.env.example b/backend-infra/.env.example new file mode 100644 index 0000000..6ee1f05 --- /dev/null +++ b/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 diff --git a/backend-infra/.gitignore b/backend-infra/.gitignore new file mode 100644 index 0000000..704ae96 --- /dev/null +++ b/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) diff --git a/backend-infra/README.md b/backend-infra/README.md new file mode 100644 index 0000000..c94f493 --- /dev/null +++ b/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`,除非确认需要删除本地数据库数据。 diff --git a/backend-infra/compose.yaml b/backend-infra/compose.yaml new file mode 100644 index 0000000..67c1ba7 --- /dev/null +++ b/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: diff --git a/docs/ERD-v0.2-draft.md b/docs/ERD-v0.2-draft.md new file mode 100644 index 0000000..00fc3e3 --- /dev/null +++ b/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 推荐 +- 真实短信供应商接入 +- 管理端审核页面 diff --git a/docs/ERD.md b/docs/ERD.md new file mode 100644 index 0000000..00c4ae0 --- /dev/null +++ b/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管理 diff --git a/docs/V2__business_schema.sql.draft b/docs/V2__business_schema.sql.draft new file mode 100644 index 0000000..db62dec --- /dev/null +++ b/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; diff --git a/docs/acceptance-week1.md b/docs/acceptance-week1.md new file mode 100644 index 0000000..14e6355 --- /dev/null +++ b/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 包装,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 " + +# 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= \ + -Dspring.datasource.username=mydreamjob \ + -Dspring.datasource.url=jdbc:mysql://127.0.0.1:3307/mydreamjob?... \ + -Dapp.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 集成测试 diff --git a/docs/api-v0.1.md b/docs/api-v0.1.md new file mode 100644 index 0000000..2eecb67 --- /dev/null +++ b/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 +``` + +**响应示例:** +```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 diff --git a/docs/api-v0.2-draft.md b/docs/api-v0.2-draft.md new file mode 100644 index 0000000..e134caf --- /dev/null +++ b/docs/api-v0.2-draft.md @@ -0,0 +1,142 @@ +# 职来后端 API v0.2(第 1 天草案) + +> 目标:形成“企业发布岗位—学生浏览—学生投递—企业处理”的最小业务闭环。 +> 本文件是开发前契约,接口实现完成后再同步到 Apifox。 + +基础路径:`/api/v1` + +## 角色约定 + +| 角色 | 权限 | +| --- | --- | +| STUDENT | 查看已发布岗位、维护自己的资料、投递岗位、查看自己的投递 | +| COMPANY | 维护企业资料、创建和管理自己公司的岗位、处理自己岗位的投递 | +| ADMIN | 预留后台管理权限,本轮不实现管理端页面 | + +除岗位公开列表和详情外,本轮接口均要求 `Authorization: Bearer `。 + +## 个人资料 + +### `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` 格式。 diff --git a/docs/candidate-score-card-api.md b/docs/candidate-score-card-api.md new file mode 100644 index 0000000..ace0af3 --- /dev/null +++ b/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` +- 不返回其他求职者的任何数据 +- 不暴露内部审核字段 \ No newline at end of file diff --git a/docs/company-public-api.md b/docs/company-public-api.md new file mode 100644 index 0000000..55948eb --- /dev/null +++ b/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} | 完全不变 | \ No newline at end of file diff --git a/docs/company-verification-api.md b/docs/company-verification-api.md new file mode 100644 index 0000000..5335d9b --- /dev/null +++ b/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 等上下文信息。 \ No newline at end of file diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md new file mode 100644 index 0000000..eff16d1 --- /dev/null +++ b/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 +``` + +## 3. 请求头 + +| Header | 必需 | 说明 | +|--------|------|------| +| `Authorization` | 认证接口 | `Bearer ` | +| `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 个公开岗位),返回公开字段与公开评分。 diff --git a/docs/interview-api.md b/docs/interview-api.md new file mode 100644 index 0000000..be0e3c6 --- /dev/null +++ b/docs/interview-api.md @@ -0,0 +1,490 @@ +# 面试管理接口文档 + +## 概述 + +面试管理基础模块,支持企业创建面试、学生响应面试、企业取消/完成面试,以及面试状态时间线查看。 + +所有接口路径前缀: `/api/v1` + +## 请求头 + +所有接口需要携带: + +| 请求头 | 必填 | 说明 | +|--------|------|------| +| Authorization | 是 | `Bearer ` | +| 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 | 企业完成 | 学生 | \ No newline at end of file diff --git a/docs/job-review-api.md b/docs/job-review-api.md new file mode 100644 index 0000000..2e6dc54 --- /dev/null +++ b/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` 字段。 diff --git a/docs/job-search-api.md b/docs/job-search-api.md new file mode 100644 index 0000000..fccd5cd --- /dev/null +++ b/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.*) \ No newline at end of file diff --git a/docs/match-api.md b/docs/match-api.md new file mode 100644 index 0000000..f567fcb --- /dev/null +++ b/docs/match-api.md @@ -0,0 +1,186 @@ +# 双向匹配基础模块接口说明 + +> 职来 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 +> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer ` + +## 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 | 投递未匹配,不可建会话 | diff --git a/docs/offer-api.md b/docs/offer-api.md new file mode 100644 index 0000000..f455bfd --- /dev/null +++ b/docs/offer-api.md @@ -0,0 +1,309 @@ +# Offer 管理模块接口说明 + +> 职来 (mydreamjob) · Offer 管理基础模块 +> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` + +## 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 | 当前状态不允许手动提醒 | diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json new file mode 100644 index 0000000..11eb711 --- /dev/null +++ b/docs/openapi-rc.json @@ -0,0 +1 @@ +{"openapi": "3.1.0", "info": {"title": "OpenAPI definition", "version": "v0"}, "servers": [{"url": "http://localhost", "description": "Generated server url"}], "tags": [{"name": "岗位收藏", "description": "学生收藏/取消收藏岗位,查看收藏列表"}, {"name": "API", "description": "API 信息入口"}, {"name": "认证", "description": "登录、验证码、用户信息"}, {"name": "候选人反馈", "description": "企业对已投递候选人的评价反馈"}, {"name": "通知", "description": "站内通知查询、已读管理"}, {"name": "双向匹配沟通", "description": "匹配成功后创建或获取直聊会话(幂等)"}, {"name": "企业认证", "description": "企业认证提交、审核管理"}, {"name": "投递", "description": "学生投递、撤回、查询;企业查看和审核投递;时间线"}, {"name": "双向匹配", "description": "求职者意愿分、企业 HR 适配分、匹配状态"}, {"name": "系统", "description": "健康检查、应用信息"}, {"name": "Offer", "description": "企业录用邀请:创建、发送、接受/拒绝/洽谈、到期失效、提醒与时间线"}, {"name": "岗位", "description": "岗位创建、管理、查询"}, {"name": "个人资料", "description": "学生个人资料查询、更新与简历管理"}, {"name": "评价审核", "description": "评价审核与防刷:待审列表、审核决策、审核历史(仅 ADMIN)"}, {"name": "简历版本", "description": "学生简历版本管理、默认简历、导出任务"}, {"name": "双向匹配阈值", "description": "企业与求职者的匹配阈值配置"}, {"name": "岗位匹配", "description": "学生查看岗位匹配诊断(确定性规则)"}, {"name": "面试", "description": "面试创建、查看、响应、取消、完成"}, {"name": "求职偏好", "description": "学生求职偏好查询与保存"}, {"name": "消息", "description": "会话创建、消息发送、已读管理"}, {"name": "岗位评价", "description": "学生对已发布岗位进行评价"}, {"name": "企业", "description": "企业资料创建、查询与更新"}, {"name": "求职者评分卡", "description": "求职者评价聚合与评分卡"}], "paths": {"/profile/me": {"get": {"tags": ["个人资料"], "summary": "查询当前学生资料", "description": "未创建资料时返回 200 空对象,不自动创建", "operationId": "getMyProfile", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["个人资料"], "summary": "更新学生资料", "description": "仅更新传入的非 null 字段;不存在则自动创建", "operationId": "updateMyProfile", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentProfileUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/review-weights": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getMyWeights", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseReviewWeightResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}, "put": {"tags": ["student-review-weight-controller"], "operationId": "saveWeights", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SaveWeightRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseReviewWeightResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/resume-versions/{id}": {"get": {"tags": ["简历版本"], "summary": "简历版本详情", "description": "仅本人可查看;他人 403,不存在 404", "operationId": "get", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["简历版本"], "summary": "修改简历版本", "description": "本人;可选重命名 + 可选替换文件(替换时删除旧文件)", "operationId": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "name", "in": "query", "required": false, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["简历版本"], "summary": "删除简历版本", "description": "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录", "operationId": "delete", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/preferences": {"get": {"tags": ["求职偏好"], "summary": "查询求职偏好", "description": "仅 STUDENT;无记录时返回空偏好对象", "operationId": "getPreferences", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["求职偏好"], "summary": "保存求职偏好", "description": "仅 STUDENT;upsert,完全替换传入字段", "operationId": "savePreferences", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentPreferenceRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/match-rule": {"get": {"tags": ["双向匹配阈值"], "summary": "查询学生匹配阈值", "description": "未配置返回默认 60", "operationId": "getStudentRule", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["双向匹配阈值"], "summary": "更新学生匹配阈值", "description": "requiredHrScore 0-100;首次更新创建配置", "operationId": "updateStudentRule", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentMatchRuleRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}": {"get": {"tags": ["Offer"], "summary": "Offer 详情", "description": "仅目标候选人、所属企业可访问;其他账号返回无权限错误", "operationId": "get_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["Offer"], "summary": "修改 Offer", "description": "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误", "operationId": "update_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}": {"get": {"tags": ["岗位"], "summary": "查询岗位详情", "description": "企业可查看自己管理的岗位详情", "operationId": "getJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["岗位"], "summary": "更新岗位", "description": "仅岗位所属企业可修改", "operationId": "updateJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews/{reviewId}": {"get": {"tags": ["岗位评价"], "summary": "岗位评价详情", "description": "公开访问;返回单条 PUBLISHED 评价与八维评分", "operationId": "get_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["岗位评价"], "summary": "修改评价", "description": "仅评价本人可以修改", "operationId": "update_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobReviewUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["岗位评价"], "summary": "删除评价", "description": "仅评价本人可以删除", "operationId": "delete_1", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}": {"get": {"tags": ["面试"], "summary": "面试详情", "description": "学生本人或企业可查看面试详情", "operationId": "get_3", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["面试"], "summary": "修改面试", "description": "企业修改面试时间、地点等信息,仅 SCHEDULED/CONFIRMED 状态可修改", "operationId": "update_3", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me": {"get": {"tags": ["企业"], "summary": "查询当前企业资料", "description": "返回当前登录用户绑定的企业资料,未绑定企业返回 404", "operationId": "getMyCompany", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["企业"], "summary": "更新企业资料", "description": "仅更新传入的非 null 字段;未绑定企业时返回 404", "operationId": "updateMyCompany", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["企业"], "summary": "创建企业资料", "description": "仅 COMPANY 角色可调用;已存在时返回 409", "operationId": "createMyCompany", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me/match-rule": {"get": {"tags": ["双向匹配阈值"], "summary": "查询企业匹配阈值", "description": "未配置返回默认 60", "operationId": "getCompanyRule", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["双向匹配阈值"], "summary": "更新企业匹配阈值", "description": "requiredCandidateScore 0-100;首次更新创建配置", "operationId": "updateCompanyRule", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyMatchRuleRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/hr-suitability-score": {"put": {"tags": ["双向匹配"], "summary": "提交企业 HR 适配分", "description": "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改", "operationId": "submitHrSuitabilityScore", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScoreRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/candidate-interest-score": {"put": {"tags": ["双向匹配"], "summary": "提交求职者岗位意愿分", "description": "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改", "operationId": "submitCandidateInterestScore", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScoreRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{applicationId}/feedback": {"get": {"tags": ["候选人反馈"], "summary": "查询候选人反馈", "description": "该投递的企业或学生可查看", "operationId": "get_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["候选人反馈"], "summary": "修改候选人反馈", "description": "仅岗位所属企业", "operationId": "update_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CandidateFeedbackRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["候选人反馈"], "summary": "创建候选人反馈", "description": "仅岗位所属企业;同一投递只能反馈一次", "operationId": "create", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CandidateFeedbackRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["候选人反馈"], "summary": "删除候选人反馈", "description": "仅岗位所属企业", "operationId": "delete_2", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume": {"get": {"tags": ["个人资料"], "summary": "下载简历", "description": "下载当前学生上传的简历文件,仅 STUDENT 角色", "operationId": "downloadResume", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"type": "string", "format": "byte"}}}}}}, "post": {"tags": ["个人资料"], "summary": "上传简历", "description": "仅 STUDENT 角色;PDF/DOC/DOCX,≤10MB;上传新简历时替换旧文件", "operationId": "uploadResume", "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}, "required": ["file"]}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["个人资料"], "summary": "删除简历", "description": "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色", "operationId": "deleteResume", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions": {"get": {"tags": ["简历版本"], "summary": "简历版本列表", "description": "本人 ACTIVE 版本,version_no 倒序", "operationId": "list", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["简历版本"], "summary": "创建简历版本", "description": "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名", "operationId": "create_1", "parameters": [{"name": "name", "in": "query", "required": false, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}, "required": ["file"]}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions/{id}/export": {"post": {"tags": ["简历版本"], "summary": "创建简历导出任务", "description": "异步复制该版本文件到导出目录;返回任务状态", "operationId": "createExportTask", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions/{id}/default": {"post": {"tags": ["简历版本"], "summary": "设为默认简历", "description": "同用户最多一个默认;已默认幂等;并发冲突 409", "operationId": "setDefault", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers": {"get": {"tags": ["Offer"], "summary": "Offer 列表", "description": "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页", "operationId": "list_1", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "applicationId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["Offer"], "summary": "创建 Offer", "description": "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT", "operationId": "create_2", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}/remind": {"post": {"tags": ["Offer"], "summary": "手动提醒", "description": "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建", "operationId": "remind", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/{id}/read": {"post": {"tags": ["通知"], "summary": "标记已读", "description": "标记单条通知已读(幂等)", "operationId": "markRead", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/read-all": {"post": {"tags": ["通知"], "summary": "全部已读", "description": "标记当前用户全部通知已读(幂等)", "operationId": "markAllRead", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs": {"get": {"tags": ["岗位"], "summary": "查询已发布岗位列表", "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资筛选和分页", "operationId": "listJobs", "parameters": [{"name": "keyword", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "city", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "employmentType", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "salaryMin", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "salaryMax", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["岗位"], "summary": "创建岗位", "description": "仅企业账号可调用,新岗位默认 DRAFT 状态", "operationId": "createJob", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews": {"get": {"tags": ["岗位评价"], "summary": "查询岗位评价列表", "description": "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分", "operationId": "list_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["岗位评价"], "summary": "创建岗位评价", "description": "仅 STUDENT;同一学生对同一岗位只能评价一次", "operationId": "create_3", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobReviewCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/favorite": {"post": {"tags": ["岗位收藏"], "summary": "收藏岗位", "description": "仅 STUDENT;仅 PUBLISHED;幂等", "operationId": "favorite", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["岗位收藏"], "summary": "取消收藏", "description": "仅 STUDENT;幂等,未收藏也返回 200", "operationId": "unfavorite", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/respond": {"post": {"tags": ["面试"], "summary": "响应面试邀请", "description": "学生接受、拒绝或申请改期", "operationId": "respond", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewRespondRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/complete": {"post": {"tags": ["面试"], "summary": "完成面试", "description": "企业在面试时间结束后将面试标记为完成", "operationId": "complete", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "string"}}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/cancel": {"post": {"tags": ["面试"], "summary": "取消面试", "description": "企业取消已安排的面试", "operationId": "cancel", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewCancelRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations": {"get": {"tags": ["消息"], "summary": "会话列表", "description": "当前用户参与的所有会话", "operationId": "listConversations", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["消息"], "summary": "创建/获取会话", "description": "创建或返回与另一用户的 DIRECT 会话", "operationId": "createConversation", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateConversationRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}/read": {"post": {"tags": ["消息"], "summary": "标记已读", "description": "将当前会话所有消息标记为已读", "operationId": "markRead_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/messages": {"post": {"tags": ["消息"], "summary": "发送消息", "description": "在会话中发送消息,仅成员可发送", "operationId": "sendMessage", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SendMessageRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me/verification": {"get": {"tags": ["企业认证"], "summary": "查询企业认证状态", "description": "企业查询自己的认证状态和历史", "operationId": "getMyVerification", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["企业认证"], "summary": "提交企业认证", "description": "企业提交认证材料(营业执照等),只能提交自己的认证", "operationId": "submit", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerificationSubmitRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/auth/sms/send": {"post": {"tags": ["认证"], "summary": "发送短信验证码(Mock)", "description": "开发环境 Mock:验证码会打印到日志", "operationId": "sendSmsCode", "parameters": [{"name": "phone", "in": "query", "required": true, "schema": {"type": "string", "minLength": 1, "pattern": "[0-9]{11}"}}, {"name": "purpose", "in": "query", "required": false, "schema": {"type": "string", "default": "login"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringString"}}}}}}}, "/auth/login": {"post": {"tags": ["认证"], "summary": "Mock 登录", "description": "使用手机号+验证码登录,返回 JWT token", "operationId": "mockLogin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoginRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications": {"post": {"tags": ["投递"], "summary": "投递岗位", "description": "只能投递 PUBLISHED 状态的岗位,不可重复投递", "operationId": "apply", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplicationCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/withdraw": {"post": {"tags": ["投递"], "summary": "撤回投递", "description": "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆", "operationId": "withdraw", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/conversation": {"post": {"tags": ["双向匹配沟通"], "summary": "创建/获取匹配会话", "description": "仅 MATCHED 双方可调用;重复调用返回同一会话", "operationId": "createOrGetConversation", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{applicationId}/interviews": {"get": {"tags": ["面试"], "summary": "查询投递的面试列表", "description": "学生本人或岗位所属企业可查看", "operationId": "listByApplication", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["面试"], "summary": "创建面试邀请", "description": "企业为投递记录创建面试邀请", "operationId": "create_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/{reviewId}/decision": {"post": {"tags": ["评价审核"], "summary": "评价审核决策", "description": "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs", "operationId": "decide", "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewDecisionRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/{id}/decision": {"post": {"tags": ["企业认证"], "summary": "审核决定", "description": "管理员通过或拒绝认证申请,拒绝时必须填写原因", "operationId": "decide_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerificationDecisionRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}/status": {"patch": {"tags": ["Offer"], "summary": "Offer 状态变更", "description": "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等", "operationId": "changeStatus", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/status": {"patch": {"tags": ["岗位"], "summary": "发布/关闭岗位", "description": "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退", "operationId": "updateStatus", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/status": {"patch": {"tags": ["投递"], "summary": "更新投递状态(企业)", "description": "SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED", "operationId": "updateStatus_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplicationStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/review-weight-templates": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getTemplates", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListWeightTemplateResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/resume-export-tasks/{taskId}": {"get": {"tags": ["简历版本"], "summary": "导出任务状态", "description": "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因", "operationId": "getExportTask", "parameters": [{"name": "taskId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/resume-export-tasks/{taskId}/download": {"get": {"tags": ["简历版本"], "summary": "下载导出产物", "description": "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径", "operationId": "downloadExportTask", "parameters": [{"name": "taskId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"type": "string", "format": "byte"}}}}}}}, "/offers/{id}/timeline": {"get": {"tags": ["Offer"], "summary": "Offer 状态时间线", "description": "完整状态变更历史,含操作者、原因与请求 ID", "operationId": "timeline", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/notifications": {"get": {"tags": ["通知"], "summary": "通知列表", "description": "当前用户的通知,分页,按时间倒序", "operationId": "list_3", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/unread-count": {"get": {"tags": ["通知"], "summary": "未读数", "description": "当前用户未读通知数", "operationId": "unreadCount", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews/summary": {"get": {"tags": ["岗位评价"], "summary": "评分汇总", "description": "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签", "operationId": "summary", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/personalized-review-score": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getPersonalizedScore", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponsePersonalizedScoreResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/jobs/{jobId}/match": {"get": {"tags": ["岗位匹配"], "summary": "岗位匹配诊断", "description": "仅 STUDENT;返回匹配分、硬性条件、缺口和说明", "operationId": "diagnose", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/search": {"get": {"tags": ["岗位"], "summary": "岗位高级搜索", "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页", "operationId": "searchJobs", "parameters": [{"name": "keyword", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "city", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "employmentType", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "salaryMin", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "salaryMax", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "companyId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/public/{jobId}": {"get": {"tags": ["岗位"], "summary": "查询岗位详情(公开)", "description": "仅返回 PUBLISHED 岗位详情;CLOSED 也可查看详情", "operationId": "getPublicJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/filters": {"get": {"tags": ["岗位"], "summary": "岗位筛选选项", "description": "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围", "operationId": "getFilters", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/favorites": {"get": {"tags": ["岗位收藏"], "summary": "我的收藏列表", "description": "仅 STUDENT;分页,按收藏时间倒序", "operationId": "listFavorites", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/compare": {"get": {"tags": ["岗位评价"], "summary": "岗位对比", "description": "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分", "operationId": "compare", "parameters": [{"name": "ids", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/interviews/{interviewId}/timeline": {"get": {"tags": ["面试"], "summary": "面试状态时间线", "description": "查看面试状态变更历史", "operationId": "timeline_1", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/interviews/me": {"get": {"tags": ["面试"], "summary": "我的面试列表", "description": "查看当前用户相关的面试,支持 status/from/to 筛选和分页", "operationId": "listMyInterviews", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/info": {"get": {"tags": ["系统"], "summary": "应用信息", "description": "返回应用版本、运行时信息", "operationId": "info", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/health": {"get": {"tags": ["系统"], "summary": "应用健康检查", "description": "返回应用及依赖服务状态", "operationId": "health", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/health/details": {"get": {"tags": ["health-controller"], "operationId": "healthDetails", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}": {"get": {"tags": ["消息"], "summary": "会话详情", "description": "查看会话信息和未读数", "operationId": "getConversation", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}/messages": {"get": {"tags": ["消息"], "summary": "消息列表", "description": "分页查询会话消息,倒序;仅成员可查看", "operationId": "listMessages", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/company/applications": {"get": {"tags": ["投递"], "summary": "查看岗位投递列表(企业)", "description": "企业查看自己发布的某个岗位收到的投递,支持 status 筛选和分页", "operationId": "listCompanyApplications", "parameters": [{"name": "jobId", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/{companyId}": {"get": {"tags": ["企业"], "summary": "查询企业公开详情", "description": "匿名可访问,仅返回 6 个公开字段", "operationId": "getPublicCompany", "parameters": [{"name": "companyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCompanyPublicView"}}}}}}}, "/companies/{companyId}/jobs": {"get": {"tags": ["企业"], "summary": "查询企业公开岗位列表", "description": "匿名可访问,仅返回 PUBLISHED 岗位", "operationId": "getCompanyJobs", "parameters": [{"name": "companyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "latest"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/auth/me": {"get": {"tags": ["认证"], "summary": "当前用户信息", "description": "需要 JWT 认证", "operationId": "me", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}": {"get": {"tags": ["投递"], "summary": "投递详情", "description": "查看自己的投递记录详情", "operationId": "getApplication", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/timeline": {"get": {"tags": ["投递"], "summary": "投递状态时间线", "description": "学生本人或岗位所属企业可查看状态变更历史", "operationId": "timeline_2", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/applications/{id}/match": {"get": {"tags": ["双向匹配"], "summary": "查询投递匹配状态", "description": "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID", "operationId": "getMatch", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/me": {"get": {"tags": ["投递"], "summary": "我的投递列表", "description": "查看当前学生自己的投递记录,支持 status 筛选和分页", "operationId": "listMyApplications", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/pending": {"get": {"tags": ["评价审核"], "summary": "待审评价列表", "description": "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA", "operationId": "pending", "parameters": [{"name": "jobId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "riskLevel", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "createdFrom", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "createdTo", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/history": {"get": {"tags": ["评价审核"], "summary": "审核历史", "description": "仅 ADMIN;分页返回审核结果、审核人、时间与原因", "operationId": "history", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications": {"get": {"tags": ["企业认证"], "summary": "待审核认证列表", "description": "管理员查看认证申请列表,支持 status/from/to 筛选和分页", "operationId": "listVerifications", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/{id}": {"get": {"tags": ["企业认证"], "summary": "认证详情", "description": "管理员查看认证申请详情", "operationId": "getDetail", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/history": {"get": {"tags": ["企业认证"], "summary": "认证审核历史", "description": "管理员查看某个认证申请的审核历史", "operationId": "listHistory", "parameters": [{"name": "verificationId", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/": {"get": {"tags": ["API"], "summary": "API 信息", "description": "返回 API v0.1 基本信息", "operationId": "index", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/candidate-score-card": {"get": {"tags": ["求职者评分卡"], "summary": "查询求职者评分卡", "description": "仅 STUDENT;返回自己的评价聚合卡", "operationId": "getScoreCard", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidateScoreCardResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/candidate-score-trend": {"get": {"tags": ["求职者评分卡"], "summary": "查询评分趋势", "description": "仅 STUDENT;返回最近月份的反馈趋势", "operationId": "getTrend", "parameters": [{"name": "months", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 6}, "description": "统计月数,默认 6,范围 1-24"}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidateScoreTrendResponse"}}}}, "400": {"description": "参数不合法", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/candidate-percentile": {"get": {"tags": ["求职者评分卡"], "summary": "查询百分位", "description": "仅 STUDENT;返回自己在候选人中的评分百分位", "operationId": "getPercentile", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidatePercentileResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}}, "components": {"schemas": {"StudentProfileUpdateRequest": {"type": "object", "properties": {"realName": {"type": "string", "maxLength": 50, "minLength": 0}, "bio": {"type": "string"}, "location": {"type": "string", "maxLength": 100, "minLength": 0}, "education": {"type": "string", "maxLength": 100, "minLength": 0}, "school": {"type": "string", "maxLength": 150, "minLength": 0}, "graduationYear": {"type": "integer", "format": "int32"}, "resumeUrl": {"type": "string", "maxLength": 500, "minLength": 0}, "skills": {"type": "array", "items": {"type": "string"}}}}, "ApiResponseMapStringObject": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "SaveWeightRequest": {"type": "object", "properties": {"templateCode": {"type": "string"}, "weights": {"type": "object", "additionalProperties": {"type": "number"}}}}, "ApiResponseReviewWeightResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/ReviewWeightResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ReviewWeightResponse": {"type": "object", "properties": {"source": {"type": "string"}, "templateCode": {"type": "string"}, "configuredWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "updatedAt": {"type": "string", "format": "date-time"}}}, "StudentPreferenceRequest": {"type": "object", "properties": {"expectedSalaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "expectedSalaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "preferredCities": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "employmentTypes": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "preferredIndustries": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "preferredJobTypes": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "remoteAllowed": {"type": "boolean"}, "salaryRangeValid": {"type": "boolean"}}}, "StudentMatchRuleRequest": {"type": "object", "properties": {"requiredHrScore": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["requiredHrScore"]}, "OfferUpdateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "salaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "salaryCurrency": {"type": "string", "maxLength": 10, "minLength": 0}, "salaryPeriod": {"type": "string", "pattern": "^(MONTH|YEAR|HOUR)$"}, "workCity": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$"}, "probationMonths": {"type": "integer", "format": "int32", "minimum": 0}, "benefits": {"type": "string", "maxLength": 2000, "minLength": 0}, "description": {"type": "string", "maxLength": 5000, "minLength": 0}, "expireAt": {"type": "string", "format": "date-time"}}}, "JobUpdateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "description": {"type": "string"}, "requirements": {"type": "string"}, "city": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "maxLength": 30, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32"}, "salaryMax": {"type": "integer", "format": "int32"}}}, "JobReviewUpdateRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 1}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}, "status": {"type": "string", "pattern": "^(PUBLISHED|HIDDEN)$"}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}}}, "InterviewUpdateRequest": {"type": "object", "properties": {"title": {"type": "string"}, "startAt": {"type": "string", "format": "date-time"}, "endAt": {"type": "string", "format": "date-time"}, "timezone": {"type": "string"}, "meetingUrl": {"type": "string"}, "address": {"type": "string"}, "interviewerName": {"type": "string"}, "notes": {"type": "string"}, "timeRangeValid": {"type": "boolean"}}}, "CompanyUpdateRequest": {"type": "object", "properties": {"name": {"type": "string", "maxLength": 120, "minLength": 0}, "logoUrl": {"type": "string", "maxLength": 500, "minLength": 0}, "description": {"type": "string"}, "industry": {"type": "string", "maxLength": 80, "minLength": 0}, "website": {"type": "string", "maxLength": 255, "minLength": 0}, "address": {"type": "string", "maxLength": 255, "minLength": 0}}, "required": ["name"]}, "CompanyMatchRuleRequest": {"type": "object", "properties": {"requiredCandidateScore": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["requiredCandidateScore"]}, "ScoreRequest": {"type": "object", "properties": {"score": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["score"]}, "CandidateFeedbackRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 0}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}}, "required": ["content", "rating"]}, "OfferCreateRequest": {"type": "object", "properties": {"applicationId": {"type": "integer", "format": "int64"}, "title": {"type": "string", "maxLength": 120, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "salaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "salaryCurrency": {"type": "string", "maxLength": 10, "minLength": 0}, "salaryPeriod": {"type": "string", "pattern": "^(MONTH|YEAR|HOUR)$"}, "workCity": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$"}, "probationMonths": {"type": "integer", "format": "int32", "minimum": 0}, "benefits": {"type": "string", "maxLength": 2000, "minLength": 0}, "description": {"type": "string", "maxLength": 5000, "minLength": 0}, "expireAt": {"type": "string", "format": "date-time"}}, "required": ["applicationId", "description", "expireAt", "title", "workCity"]}, "JobCreateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "description": {"type": "string", "minLength": 1}, "requirements": {"type": "string"}, "city": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "maxLength": 30, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32"}, "salaryMax": {"type": "integer", "format": "int32"}}, "required": ["city", "description", "employmentType", "title"]}, "JobReviewCreateRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 0}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}}, "required": ["content", "rating"]}, "InterviewRespondRequest": {"type": "object", "properties": {"action": {"type": "string", "minLength": 1}, "reason": {"type": "string"}}, "required": ["action"]}, "InterviewCancelRequest": {"type": "object", "properties": {"reason": {"type": "string"}}}, "CreateConversationRequest": {"type": "object", "properties": {"participantUserId": {"type": "integer", "format": "int64"}}, "required": ["participantUserId"]}, "SendMessageRequest": {"type": "object", "properties": {"conversationId": {"type": "integer", "format": "int64"}, "content": {"type": "string", "maxLength": 2000, "minLength": 0}}, "required": ["content", "conversationId"]}, "CompanyCreateRequest": {"type": "object", "properties": {"name": {"type": "string", "maxLength": 100, "minLength": 0}, "logoUrl": {"type": "string"}, "description": {"type": "string"}, "industry": {"type": "string"}, "website": {"type": "string"}, "address": {"type": "string"}}, "required": ["name"]}, "VerificationSubmitRequest": {"type": "object", "properties": {"materialsJson": {"type": "string", "minLength": 1}}, "required": ["materialsJson"]}, "ApiResponseMapStringString": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {"type": "string"}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "LoginRequest": {"type": "object", "properties": {"phone": {"type": "string", "minLength": 1, "pattern": "[0-9]{11}"}, "code": {"type": "string", "minLength": 1, "pattern": "[0-9]{6}"}, "purpose": {"type": "string"}}, "required": ["code", "phone"]}, "ApplicationCreateRequest": {"type": "object", "properties": {"jobId": {"type": "integer", "format": "int64"}}, "required": ["jobId"]}, "InterviewCreateRequest": {"type": "object", "properties": {"roundNo": {"type": "integer", "format": "int32"}, "title": {"type": "string", "maxLength": 200, "minLength": 0}, "interviewType": {"type": "string", "minLength": 1}, "startAt": {"type": "string", "format": "date-time"}, "endAt": {"type": "string", "format": "date-time"}, "timezone": {"type": "string"}, "meetingUrl": {"type": "string"}, "address": {"type": "string"}, "interviewerName": {"type": "string", "maxLength": 100, "minLength": 0}, "notes": {"type": "string"}, "meetingUrlValid": {"type": "boolean"}, "addressValid": {"type": "boolean"}}, "required": ["endAt", "interviewType", "startAt", "title"]}, "ReviewDecisionRequest": {"type": "object", "properties": {"decision": {"type": "string", "minLength": 1, "pattern": "^(APPROVED|REJECTED)$"}, "reason": {"type": "string", "maxLength": 500, "minLength": 0}}, "required": ["decision"]}, "VerificationDecisionRequest": {"type": "object", "properties": {"decision": {"type": "string", "minLength": 1}, "reason": {"type": "string"}}, "required": ["decision"]}, "OfferStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED)$"}, "reason": {"type": "string", "maxLength": 255, "minLength": 0}}, "required": ["status"]}, "JobStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(PUBLISHED|CLOSED)$"}}, "required": ["status"]}, "ApplicationStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$"}}, "required": ["status"]}, "ApiResponseListWeightTemplateResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/WeightTemplateResponse"}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "WeightTemplateResponse": {"type": "object", "properties": {"code": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}, "weights": {"type": "object", "additionalProperties": {"type": "number"}}}}, "ApiResponseListMapStringObject": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "array", "items": {"type": "object", "additionalProperties": {}}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponsePersonalizedScoreResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/PersonalizedScoreResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "PersonalizedScoreResponse": {"type": "object", "properties": {"jobId": {"type": "integer", "format": "int64"}, "score": {"type": "number"}, "status": {"type": "string"}, "source": {"type": "string"}, "templateCode": {"type": "string"}, "configuredWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "effectiveWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "number"}}, "coveredWeight": {"type": "number"}, "missingDimensions": {"type": "array", "items": {"type": "string"}}}}, "ApiResponseCompanyPublicView": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CompanyPublicView"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "CompanyPublicView": {"type": "object", "properties": {"id": {"type": "integer", "format": "int64"}, "name": {"type": "string"}, "logoUrl": {"type": "string"}, "description": {"type": "string"}, "industry": {"type": "string"}, "verified": {"type": "boolean"}}}, "ApiResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "CandidateScoreCardResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "status": {"type": "string"}, "overallScore": {"type": "number", "nullable": true}, "sampleCount": {"type": "integer", "format": "int32"}, "feedbackSummary": {"$ref": "#/components/schemas/CandidateFeedbackSummary"}, "matchSignals": {"$ref": "#/components/schemas/CandidateMatchSignals"}, "unsupportedDimensions": {"type": "array", "items": {"type": "string"}}, "generatedAt": {"type": "string", "format": "date-time"}}}, "CandidateFeedbackSummary": {"type": "object", "properties": {"totalCount": {"type": "integer", "format": "int32"}, "averageRating": {"type": "number"}, "ratingDistribution": {"type": "object", "additionalProperties": {"type": "integer"}}, "tags": {"type": "array", "items": {"$ref": "#/components/schemas/TagCount"}}}}, "TagCount": {"type": "object", "properties": {"name": {"type": "string"}, "count": {"type": "integer", "format": "int32"}}}, "CandidateMatchSignals": {"type": "object", "properties": {"candidateInterestScore": {"$ref": "#/components/schemas/MatchSignal"}, "hrSuitabilityScore": {"$ref": "#/components/schemas/MatchSignal"}}}, "MatchSignal": {"type": "object", "properties": {"average": {"type": "number", "nullable": true}, "sampleCount": {"type": "integer", "format": "int32"}}}, "CandidateScoreTrendResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "months": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/CandidateScoreTrendItem"}}}}, "CandidateScoreTrendItem": {"type": "object", "properties": {"month": {"type": "string"}, "feedbackCount": {"type": "integer", "format": "int32"}, "averageRating": {"type": "number"}, "ratingScore": {"type": "number"}}}, "CandidatePercentileResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "status": {"type": "string"}, "scoreType": {"type": "string"}, "score": {"type": "number", "nullable": true}, "percentile": {"type": "number", "nullable": true}, "cohortSize": {"type": "integer", "format": "int32"}, "sampleCount": {"type": "integer", "format": "int32"}, "minimumSampleCount": {"type": "integer", "format": "int32"}}}, "ApiResponseCandidateScoreCardResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidateScoreCardResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponseCandidateScoreTrendResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidateScoreTrendResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponseCandidatePercentileResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidatePercentileResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}}}} \ No newline at end of file diff --git a/docs/resume-api.md b/docs/resume-api.md new file mode 100644 index 0000000..97cdf4b --- /dev/null +++ b/docs/resume-api.md @@ -0,0 +1,213 @@ +# 简历版本管理与导出模块接口说明 + +> 职来 (mydreamjob) · 简历版本管理与导出基础模块 +> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` + +## 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 | 导出任务未完成,不可下载 | + diff --git a/docs/review-moderation-api.md b/docs/review-moderation-api.md new file mode 100644 index 0000000..af1502a --- /dev/null +++ b/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` 重新导出。 diff --git a/docs/review-weight-api.md b/docs/review-weight-api.md new file mode 100644 index 0000000..c882545 --- /dev/null +++ b/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 +``` + +**响应示例:** + +```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 +``` + +**响应示例(未配置):** + +```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 +``` + +**响应示例(正常计算):** + +```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 迁移和已验收接口 \ No newline at end of file diff --git a/docs/smoke-test.sh b/docs/smoke-test.sh new file mode 100644 index 0000000..b363088 --- /dev/null +++ b/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 ] diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py new file mode 100644 index 0000000..be7ce5b --- /dev/null +++ b/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!") \ No newline at end of file diff --git a/mydreamjob-server/.gitignore b/mydreamjob-server/.gitignore new file mode 100644 index 0000000..ecd4111 --- /dev/null +++ b/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 diff --git a/mydreamjob-server/README.md b/mydreamjob-server/README.md new file mode 100644 index 0000000..8cd960e --- /dev/null +++ b/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 " +``` + +## 环境变量 + +> **生产安全要求**:`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 模块实现),待产品确认 diff --git a/mydreamjob-server/docs/interview-api.md b/mydreamjob-server/docs/interview-api.md new file mode 100644 index 0000000..a7bc5f7 --- /dev/null +++ b/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 | 已响应,不可重复操作 | \ No newline at end of file diff --git a/mydreamjob-server/pom.xml b/mydreamjob-server/pom.xml new file mode 100644 index 0000000..55db580 --- /dev/null +++ b/mydreamjob-server/pom.xml @@ -0,0 +1,169 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.mydreamjob + mydreamjob-server + 0.0.1-SNAPSHOT + mydreamjob-server + 职来 (mydreamjob) Backend - Week 1 Baseline + + + 17 + 0.12.6 + 2.8.13 + 1.21.3 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + mysql + ${testcontainers.version} + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/MyDreamjobApplication.java b/mydreamjob-server/src/main/java/com/mydreamjob/MyDreamjobApplication.java new file mode 100644 index 0000000..a000325 --- /dev/null +++ b/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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java new file mode 100644 index 0000000..eb67819 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java new file mode 100644 index 0000000..7e59268 --- /dev/null +++ b/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>> 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>> 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>>> timeline( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List> data = applicationService.getTimeline(id, userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + // ── 学生查看自己的投递 ──────────────────────────────────── + + @Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页") + @GetMapping("/applications/me") + public ResponseEntity>> 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 result = applicationService.listStudentApplications(userId, status, page, size); + Map 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>> 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>> 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 result = applicationService.listCompanyApplications( + company.getId(), jobId, status, page, size); + Map 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>> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java new file mode 100644 index 0000000..f1bbfbc --- /dev/null +++ b/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 { + + Optional findByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + List findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId); + + Page findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId, Pageable pageable); + + Page findByStudentUserIdAndStatusOrderByCreatedAtDesc(Long studentUserId, String status, Pageable pageable); + + List findByJobIdOrderByCreatedAtDesc(Long jobId); + + Page findByJobIdOrderByCreatedAtDesc(Long jobId, Pageable pageable); + + Page findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable); + + boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java new file mode 100644 index 0000000..86d8d5a --- /dev/null +++ b/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> 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 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 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 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 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 toView(Application app) { + Map 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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java new file mode 100644 index 0000000..696f6f7 --- /dev/null +++ b/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; // 终态不可再流转 + }; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistory.java new file mode 100644 index 0000000..623163b --- /dev/null +++ b/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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java new file mode 100644 index 0000000..57341dc --- /dev/null +++ b/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 { + + List findByApplicationIdOrderByCreatedAtAsc(Long applicationId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationCreateRequest.java new file mode 100644 index 0000000..78a8630 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationStatusRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/ApplicationStatusRequest.java new file mode 100644 index 0000000..ec18ffa --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditContext.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditContext.java new file mode 100644 index 0000000..9537f07 --- /dev/null +++ b/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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java new file mode 100644 index 0000000..aa0968b --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLogRepository.java new file mode 100644 index 0000000..8ec9d74 --- /dev/null +++ b/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 { + + @Query("SELECT a FROM AuditLog a WHERE a.userId = :userId ORDER BY a.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT a FROM AuditLog a WHERE a.createdAt > :since ORDER BY a.createdAt DESC") + List findByCreatedAtAfter(@Param("since") LocalDateTime since); + + void deleteByCreatedAtBefore(LocalDateTime before); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java new file mode 100644 index 0000000..493e525 --- /dev/null +++ b/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]"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java new file mode 100644 index 0000000..d13074b --- /dev/null +++ b/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 mockLogin(String phone, String code, String purpose, + String requestId, String ipAddress, String userAgent) { + // 验证验证码(传入 purpose,确保验证码用途匹配) + smsVerificationService.verifyCode(phone, code, purpose); + + // 查找或创建用户 + Optional 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 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 getCurrentUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Map 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java new file mode 100644 index 0000000..02fe3a7 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java new file mode 100644 index 0000000..b5e74ae --- /dev/null +++ b/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 { + + List findByUserIdAndRevokedFalse(Long userId); + + Optional findByTokenHash(String tokenHash); + + Optional findByRefreshTokenHash(String refreshTokenHash); + + List findByExpiredAtBefore(LocalDateTime expiredAt); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a24a4e9 --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java new file mode 100644 index 0000000..7da354d --- /dev/null +++ b/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 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 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; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/LoginRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/LoginRequest.java new file mode 100644 index 0000000..7568897 --- /dev/null +++ b/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"; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java new file mode 100644 index 0000000..2a510ed --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java new file mode 100644 index 0000000..221c039 --- /dev/null +++ b/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 { + + List findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after); + + /** + * 按手机号+用途查询,按创建时间降序返回(最新在前) + */ + List 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 streamLatestUnusedByPhoneAndPurpose(@Param("phone") String phone, + @Param("purpose") String purpose); + + /** + * 按手机号+用途查询已使用的验证码(用于测试中取旧验证码) + */ + List findByPhoneAndPurposeAndUsedTrueOrderByCreatedAtDesc(String phone, String purpose); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java new file mode 100644 index 0000000..4316d42 --- /dev/null +++ b/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 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 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"); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java new file mode 100644 index 0000000..2d4b57f --- /dev/null +++ b/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 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 + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java new file mode 100644 index 0000000..4fc6e4a --- /dev/null +++ b/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 { + Optional findByPhone(String phone); + Optional findByEmail(String email); + boolean existsByPhone(String phone); + boolean existsByEmail(String email); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java new file mode 100644 index 0000000..bb13f12 --- /dev/null +++ b/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>> 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>> 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>> getMatch( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request))); + } + + /** 评分接口返回记录视图(脱敏,不含内部字段) */ + private Map toView(ApplicationMatchRecord record) { + Map 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversation.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversation.java new file mode 100644 index 0000000..aa45f75 --- /dev/null +++ b/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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchConversationRepository.java new file mode 100644 index 0000000..a75231b --- /dev/null +++ b/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 { + + Optional findByApplicationId(Long applicationId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEvent.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEvent.java new file mode 100644 index 0000000..0f01820 --- /dev/null +++ b/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。 + * + *

{@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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEventRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchEventRepository.java new file mode 100644 index 0000000..786ddb8 --- /dev/null +++ b/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 { + + Optional findByApplicationIdAndEventTypeAndDedupKey( + Long applicationId, String eventType, String dedupKey); + + /** 待发送的 MATCH_SUCCEEDED 通知(Outbox 轮询) */ + List findByEventTypeAndNotificationStatus( + String eventType, String notificationStatus); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecord.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecord.java new file mode 100644 index 0000000..db2ea37 --- /dev/null +++ b/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; + +/** + * 双向匹配记录:一条投递一条记录。 + * + *

字段语义(本轮明确): + *

    + *
  • candidate_interest_score:求职者对岗位的"意愿分"(0-100,人工录入 MANUAL_INTEREST), + * 不是教育/能力/诚信/算法综合分;
  • + *
  • hr_suitability_score:企业对候选人的"HR 适配分"(0-100,人工录入 HR_MANUAL);
  • + *
  • company_required_candidate_score / student_required_hr_score:本次计算实际使用的阈值快照, + * 阈值更新只影响后续重算。
  • + *
+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java new file mode 100644 index 0000000..57f74b4 --- /dev/null +++ b/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 { + + Optional 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 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 avgAndCountHrSuitabilityScore(@Param("candidateId") Long candidateId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java new file mode 100644 index 0000000..08ab758 --- /dev/null +++ b/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; + +/** + * 双向匹配基础流程(规则阈值版)。 + * + *

匹配成功条件: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 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 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 toView(Application app, Job job, ApplicationMatchRecord record, boolean closed) { + Map 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRule.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRule.java new file mode 100644 index 0000000..a4a0299 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRuleRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/CompanyMatchRuleRepository.java new file mode 100644 index 0000000..0021466 --- /dev/null +++ b/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 { + Optional findByCompanyId(Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java new file mode 100644 index 0000000..ccd8bd7 --- /dev/null +++ b/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>> createOrGetConversation( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java new file mode 100644 index 0000000..4d68121 --- /dev/null +++ b/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 创建/获取直聊会话。 + * + *

幂等: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 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 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 toView(Conversation c) { + Map 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationScheduler.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationScheduler.java new file mode 100644 index 0000000..ac1e382 --- /dev/null +++ b/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 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); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchNotificationService.java new file mode 100644 index 0000000..2e41cf3 --- /dev/null +++ b/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 发送)。 + * + *

由 {@code application_match_events} 中 MATCH_SUCCEEDED(PENDING) 事件驱动; + * 通知通过 NotificationService.create 幂等键落库(同一 application 每方只产生一条), + * 事件标记 SENT;任何失败保持 PENDING,由调度任务重试。重试不会重复发通知。 + * REQUIRES_NEW: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 "候选人"; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriter.java new file mode 100644 index 0000000..ffb976a --- /dev/null +++ b/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)中执行。 + * + *

原因:并发首评时两条 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java new file mode 100644 index 0000000..b22568a --- /dev/null +++ b/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>> 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>> 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>> 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>> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java new file mode 100644 index 0000000..c25cf01 --- /dev/null +++ b/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 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 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 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 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 companyView(Long companyId, int required) { + Map v = new HashMap<>(); + v.put("companyId", companyId); + v.put("requiredCandidateScore", required); + return v; + } + + private Map studentView(Long studentUserId, int required) { + Map 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchScoreField.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchScoreField.java new file mode 100644 index 0000000..a6bcc26 --- /dev/null +++ b/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 +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRule.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRule.java new file mode 100644 index 0000000..696c9f4 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRuleRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/StudentMatchRuleRepository.java new file mode 100644 index 0000000..6f9d0d1 --- /dev/null +++ b/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 { + Optional findByStudentUserId(Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/CompanyMatchRuleRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/CompanyMatchRuleRequest.java new file mode 100644 index 0000000..978bb96 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/ScoreRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/ScoreRequest.java new file mode 100644 index 0000000..1cc6f69 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/StudentMatchRuleRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/dto/StudentMatchRuleRequest.java new file mode 100644 index 0000000..a864405 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java new file mode 100644 index 0000000..9ec913d --- /dev/null +++ b/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> 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> 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> 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardRepository.java new file mode 100644 index 0000000..c373be0 --- /dev/null +++ b/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 { + + /** 反馈总数和平均评分(合并查询,减少 SQL 次数) */ + @Query("SELECT COUNT(f), COALESCE(AVG(f.rating), 0) FROM CandidateFeedback f WHERE f.studentUserId = :studentUserId") + List 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 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 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 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); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java new file mode 100644 index 0000000..a9e39fa --- /dev/null +++ b/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 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 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 ratingDist = new LinkedHashMap<>(); + for (int i = 1; i <= 5; i++) ratingDist.put(i, 0); + List 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 tagJsons = jdbcTemplate.queryForList( + "SELECT CAST(cf.tags AS VARCHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?", + String.class, studentUserId); + Map tagCounts = aggregateTags(tagJsons); + List 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 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 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 rows = scoreCardRepository.trendByMonth(studentUserId, cutoffDate); + + List 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 aggregateTags(List tagJsons) { + Map counts = new HashMap<>(); + for (String json : tagJsons) { + if (json == null || json.isBlank()) continue; + try { + List tags = mapper.readValue(json, new TypeReference>() {}); + 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateFeedbackSummary.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateFeedbackSummary.java new file mode 100644 index 0000000..8fe21e3 --- /dev/null +++ b/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 ratingDistribution; + private List tags; + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class TagCount { + private String name; + private int count; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateMatchSignals.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateMatchSignals.java new file mode 100644 index 0000000..fbb50d2 --- /dev/null +++ b/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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidatePercentileResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidatePercentileResponse.java new file mode 100644 index 0000000..2c4a814 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreCardResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreCardResponse.java new file mode 100644 index 0000000..b4e409a --- /dev/null +++ b/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 unsupportedDimensions; + private Instant generatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendItem.java new file mode 100644 index 0000000..2627f9a --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoreTrendResponse.java new file mode 100644 index 0000000..c5a256c --- /dev/null +++ b/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 items; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/career/CareerService.java b/mydreamjob-server/src/main/java/com/mydreamjob/career/CareerService.java new file mode 100644 index 0000000..03e7f54 --- /dev/null +++ b/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+ 实现 +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/api/ApiResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/api/ApiResponse.java new file mode 100644 index 0000000..b90b3fe --- /dev/null +++ b/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 响应包装 + * + *

规范: + *

    + *
  • {@code code} — 业务状态码(200=成功,4xx=客户端错误,5xx=服务端错误)
  • + *
  • {@code message} — 成功时固定为 {@code "ok"},错误时为具体描述
  • + *
  • {@code data} — 响应体,成功时携带,失败时为 {@code null}
  • + *
  • {@code requestId} — 链路追踪 ID,由 {@link com.mydreamjob.common.logging.RequestIdFilter} 注入
  • + *
  • {@code timestamp} — 响应时间
  • + *
+ * + * @param 响应体类型 + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse 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 ApiResponse ok(T data, String requestId) { + return new ApiResponse<>(200, "ok", data, requestId); + } + + public static ApiResponse created(T data, String requestId) { + return new ApiResponse<>(201, "ok", data, requestId); + } + + public static ApiResponse error(int code, String message, String requestId) { + return new ApiResponse<>(code, message, null, requestId); + } + + public static ApiResponse badRequest(String message, String requestId) { + return error(400, message, requestId); + } + + public static ApiResponse unauthorized(String message, String requestId) { + return error(401, message, requestId); + } + + public static ApiResponse forbidden(String message, String requestId) { + return error(403, message, requestId); + } + + public static ApiResponse notFound(String message, String requestId) { + return error(404, message, requestId); + } + + public static ApiResponse conflict(String message, String requestId) { + return error(409, message, requestId); + } + + public static ApiResponse tooManyRequests(String message, String requestId) { + return error(429, message, requestId); + } + + public static ApiResponse internalError(String requestId) { + return error(500, "internal server error", requestId); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/api/PageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/api/PageResponse.java new file mode 100644 index 0000000..4d4f40b --- /dev/null +++ b/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 元素类型 + */ +@Data +public class PageResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private List items; + private long total; + private int page; + private int size; + private int totalPages; + + public static PageResponse of(List items, long total, int page, int size) { + PageResponse 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/BusinessException.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/BusinessException.java new file mode 100644 index 0000000..d143e4f --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java new file mode 100644 index 0000000..e580040 --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/FileUploadExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/FileUploadExceptionHandler.java new file mode 100644 index 0000000..32c41de --- /dev/null +++ b/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> 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)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d309a73 --- /dev/null +++ b/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> 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> 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> handleValidationException( + MethodArgumentNotValidException ex, HttpServletRequest request) { + Map 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> 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> 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> 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> 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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/health/HealthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/health/HealthController.java new file mode 100644 index 0000000..45d35ea --- /dev/null +++ b/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> healthDetails() { + Map 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java new file mode 100644 index 0000000..43f4bbf --- /dev/null +++ b/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 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestLoggingFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestLoggingFilter.java new file mode 100644 index 0000000..5d16cb0 --- /dev/null +++ b/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 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(); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java new file mode 100644 index 0000000..d1f9839 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java new file mode 100644 index 0000000..fadc773 --- /dev/null +++ b/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>> 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>> getMyCompany(HttpServletRequest request) { + Long userId = resolveUserId(request); + Map 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>> updateMyCompany( + @Valid @RequestBody CompanyUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = companyService.toView(companyService.updateCompany(userId, req)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 公开端:企业详情 ──────────────────────────────────────── + + @Operation(summary = "查询企业公开详情", description = "匿名可访问,仅返回 6 个公开字段") + @GetMapping("/{companyId}") + public ResponseEntity> 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>> getCompanyJobs( + @PathVariable Long companyId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "latest") String sort, + HttpServletRequest request) { + Page result = companyService.getCompanyJobs(companyId, sort, page, size); + Map 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java new file mode 100644 index 0000000..31aca0d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java @@ -0,0 +1,14 @@ +package com.mydreamjob.company; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CompanyRepository extends JpaRepository { + + Optional findByOwnerUserId(Long ownerUserId); + + boolean existsByOwnerUserId(Long ownerUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java new file mode 100644 index 0000000..d1265b9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java @@ -0,0 +1,179 @@ +package com.mydreamjob.company; + +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.dto.CompanyCreateRequest; +import com.mydreamjob.company.dto.CompanyPublicView; +import com.mydreamjob.company.dto.CompanyUpdateRequest; +import com.mydreamjob.job.JobService; +import com.mydreamjob.job.dto.JobSearchItem; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +/** + * 企业管理 Service + * 角色校验优先——所有写操作在业务逻辑前先校验角色。 + */ +@Slf4j +@Service +public class CompanyService { + + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final JobService jobService; + + public CompanyService(CompanyRepository companyRepository, UserRepository userRepository, + JobService jobService) { + this.companyRepository = companyRepository; + this.userRepository = userRepository; + this.jobService = jobService; + } + + // ── 角色校验(最先执行)─────────────────────────────────── + + public void assertCompanyRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isCompany = user.getRoles().stream() + .anyMatch(r -> r == User.Role.COMPANY); + if (!isCompany) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + public void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream() + .anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + // ── 业务方法 ────────────────────────────────────────────── + + /** + * 查询当前用户绑定的企业资料(含角色校验:非 COMPANY 返回 403) + */ + public Company findCompanyByOwnerUserId(Long userId) { + assertCompanyRole(userId); + return companyRepository.findByOwnerUserId(userId).orElse(null); + } + + /** + * 创建企业资料(仅 COMPANY 角色,已存在时 409) + */ + @Transactional + public Company createCompany(Long userId, CompanyCreateRequest req) { + assertCompanyRole(userId); + if (companyRepository.findByOwnerUserId(userId).isPresent()) { + throw new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS); + } + Company company = Company.builder() + .ownerUserId(userId) + .name(req.getName()) + .logoUrl(req.getLogoUrl()) + .description(req.getDescription()) + .industry(req.getIndustry()) + .website(req.getWebsite()) + .address(req.getAddress()) + .verified(false) + .build(); + return companyRepository.save(company); + } + + /** + * 更新企业资料(幂等:不传字段则保留原值) + */ + @Transactional + public Company updateCompany(Long userId, CompanyUpdateRequest req) { + assertCompanyRole(userId); + Company company = companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + if (req.getName() != null) company.setName(req.getName()); + if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl()); + if (req.getDescription() != null) company.setDescription(req.getDescription()); + if (req.getIndustry() != null) company.setIndustry(req.getIndustry()); + if (req.getWebsite() != null) company.setWebsite(req.getWebsite()); + if (req.getAddress() != null) company.setAddress(req.getAddress()); + + return companyRepository.save(company); + } + + /** + * 将企业 Entity 转为 API 视图(排除敏感字段) + */ + public Map toView(Company company) { + if (company == null) return null; + Map view = new HashMap<>(); + view.put("id", company.getId()); + view.put("ownerUserId", company.getOwnerUserId()); + view.put("name", company.getName()); + view.put("logoUrl", company.getLogoUrl()); + view.put("description", company.getDescription()); + view.put("industry", company.getIndustry()); + view.put("website", company.getWebsite()); + view.put("address", company.getAddress()); + view.put("verified", company.getVerified()); + view.put("createdAt", company.getCreatedAt()); + return view; + } + + // ── 公开端:企业详情 ──────────────────────────────────────── + + /** + * 查询企业公开信息(匿名可访问)。 + * 仅返回 6 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 + */ + public CompanyPublicView getPublicCompany(Long companyId) { + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + return toPublicView(company); + } + + /** + * 将企业 Entity 转为公开视图(仅 6 个字段)。 + * 不修改现有 toView() 方法。 + */ + public CompanyPublicView toPublicView(Company company) { + return CompanyPublicView.builder() + .id(company.getId()) + .name(company.getName()) + .logoUrl(company.getLogoUrl()) + .description(company.getDescription()) + .industry(company.getIndustry()) + .verified(company.getVerified()) + .build(); + } + + // ── 公开端:企业岗位列表 ──────────────────────────────────── + + /** + * 查询企业公开岗位列表(匿名可访问)。 + * 先确认企业存在,再复用已验收的 JobService.searchJobs。 + */ + public Page getCompanyJobs(Long companyId, String sort, int page, int size) { + companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + return jobService.searchJobs( + null, // keyword + null, // city + null, // employmentType + null, // salaryMin + null, // salaryMax + companyId, // companyId + sort, // sort + page, // page + size // size + ); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java new file mode 100644 index 0000000..5b8c2cd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java @@ -0,0 +1,18 @@ +package com.mydreamjob.company.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyCreateRequest { + @NotBlank(message = "company name is required") + @Size(max = 100) + private String name; + + private String logoUrl; + private String description; + private String industry; + private String website; + private String address; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java new file mode 100644 index 0000000..a226333 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java @@ -0,0 +1,26 @@ +package com.mydreamjob.company.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 企业公开信息 DTO。 + * 仅暴露 6 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CompanyPublicView { + + private Long id; + private String name; + private String logoUrl; + private String description; + private String industry; + private Boolean verified; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java new file mode 100644 index 0000000..47b2c49 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java @@ -0,0 +1,30 @@ +package com.mydreamjob.company.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 更新企业资料的请求 DTO + */ +@Data +public class CompanyUpdateRequest { + + @NotBlank(message = "company name is required") + @Size(max = 120, message = "company name must be at most 120 characters") + private String name; + + @Size(max = 500, message = "logo url must be at most 500 characters") + private String logoUrl; + + private String description; + + @Size(max = 80, message = "industry must be at most 80 characters") + private String industry; + + @Size(max = 255, message = "website must be at most 255 characters") + private String website; + + @Size(max = 255, message = "address must be at most 255 characters") + private String address; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java new file mode 100644 index 0000000..ee31e02 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java @@ -0,0 +1,31 @@ +package com.mydreamjob.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 异步任务配置:简历导出等耗时任务使用独立线程池,不阻塞请求线程。 + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "resumeExportExecutor") + public Executor resumeExportExecutor( + @Value("${app.async.resume-export.core-pool-size:2}") int corePoolSize, + @Value("${app.async.resume-export.max-pool-size:4}") int maxPoolSize, + @Value("${app.async.resume-export.queue-capacity:100}") int queueCapacity) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("resume-export-"); + executor.initialize(); + return executor; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/MethodSecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/MethodSecurityConfig.java new file mode 100644 index 0000000..7277905 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/MethodSecurityConfig.java @@ -0,0 +1,13 @@ +package com.mydreamjob.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +/** + * 方法级安全配置 + */ +@Configuration +@EnableMethodSecurity +public class MethodSecurityConfig { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/RabbitConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/RabbitConfig.java new file mode 100644 index 0000000..eed4fff --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/RabbitConfig.java @@ -0,0 +1,129 @@ +package com.mydreamjob.config; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.boot.autoconfigure.amqp.RabbitProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +/** + * RabbitMQ 配置 + */ +@Configuration +public class RabbitConfig { + + // ========== 队列定义 ========== + public static final String SMS_QUEUE = "queue.sms.verification"; + public static final String NOTIFICATION_QUEUE = "queue.notification"; + public static final String NOTIFICATION_DLQ = "queue.notification.dlq"; + public static final String AUDIT_QUEUE = "queue.audit"; + + // ========== 交换机定义 ========== + public static final String SMS_EXCHANGE = "exchange.sms"; + public static final String NOTIFICATION_EXCHANGE = "exchange.notification.topic"; + public static final String DIRECT_EXCHANGE = "exchange.direct"; + + // ========== 路由键 ========== + public static final String SMS_ROUTING_KEY = "sms.verification"; + public static final String NOTIFICATION_ROUTING_KEY = "notification.*"; + public static final String AUDIT_ROUTING_KEY = "audit.log"; + + @Bean + public Jackson2JsonMessageConverter messageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(messageConverter()); + return template; + } + + // ---- 通知消费重试:指数退避,最多 3 次 ---- + @Bean + public RetryTemplate notificationRetryTemplate() { + RetryTemplate retry = new RetryTemplate(); + retry.setRetryPolicy(new org.springframework.retry.policy.SimpleRetryPolicy(3)); + ExponentialBackOffPolicy backoff = new ExponentialBackOffPolicy(); + backoff.setInitialInterval(1000); + backoff.setMultiplier(2.0); + backoff.setMaxInterval(10000); + retry.setBackOffPolicy(backoff); + return retry; + } + + // ---- 短信队列 ---- + @Bean + public Queue smsQueue() { + return QueueBuilder.durable(SMS_QUEUE) + .withArgument("x-message-ttl", 300000) + .build(); + } + + @Bean + public DirectExchange smsExchange() { + return new DirectExchange(SMS_EXCHANGE); + } + + @Bean + public Binding smsBinding() { + return BindingBuilder.bind(smsQueue()) + .to(smsExchange()) + .with(SMS_ROUTING_KEY); + } + + // ---- 通知队列:TopicExchange + 死信队列,失败不无限堆积 ---- + @Bean + public Queue notificationDlq() { + return QueueBuilder.durable(NOTIFICATION_DLQ) + .withArgument("x-message-ttl", 86400000) + .build(); + } + + @Bean + public Queue notificationQueue() { + return QueueBuilder.durable(NOTIFICATION_QUEUE) + .withArgument("x-message-ttl", 600000) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", NOTIFICATION_DLQ) + .build(); + } + + @Bean + public TopicExchange notificationExchange() { + return new TopicExchange(NOTIFICATION_EXCHANGE); + } + + @Bean + public Binding notificationBinding() { + return BindingBuilder.bind(notificationQueue()) + .to(notificationExchange()) + .with(NOTIFICATION_ROUTING_KEY); + } + + // ---- 审计队列 ---- + @Bean + public Queue auditQueue() { + return QueueBuilder.durable(AUDIT_QUEUE) + .build(); + } + + @Bean + public DirectExchange auditExchange() { + return new DirectExchange(DIRECT_EXCHANGE); + } + + @Bean + public Binding auditBinding() { + return BindingBuilder.bind(auditQueue()) + .to(auditExchange()) + .with(AUDIT_ROUTING_KEY); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/RedisConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/RedisConfig.java new file mode 100644 index 0000000..9629c4c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/RedisConfig.java @@ -0,0 +1,30 @@ +package com.mydreamjob.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置 + */ +@Configuration +@ConditionalOnProperty(name = "app.redis.enabled", havingValue = "true", matchIfMissing = true) +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SchedulingConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SchedulingConfig.java new file mode 100644 index 0000000..8b013bf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SchedulingConfig.java @@ -0,0 +1,12 @@ +package com.mydreamjob.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 定时任务配置 + */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java new file mode 100644 index 0000000..9eb75b7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -0,0 +1,148 @@ +package com.mydreamjob.config; + +import com.mydreamjob.auth.JwtAuthenticationFilter; +import com.mydreamjob.audit.AuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +/** + * Spring Security 配置 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableJpaAuditing +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AuditService auditService; + + @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") + private String allowedOrigins; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + response.getWriter().write( + "{\"code\":401,\"message\":\"not authenticated\",\"timestamp\":\"\"}"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(403); + response.getWriter().write( + "{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}"); + })) + .authorizeHttpRequests(auth -> auth + // Actuator 健康检查 + .requestMatchers("/actuator/health/**").permitAll() + .requestMatchers("/actuator/info").permitAll() + // Swagger UI + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll() + // 健康检查 API + .requestMatchers("/health/**").permitAll() + .requestMatchers("/health").permitAll() + // 认证相关 + .requestMatchers("/auth/sms/send").permitAll() + .requestMatchers("/auth/login").permitAll() + .requestMatchers("/auth/register").permitAll() + // 当前用户 + .requestMatchers("/auth/me").authenticated() + // 企业资料(需登录) + .requestMatchers("/companies/me/**").authenticated() + // 企业公开岗位列表(匿名 GET) + .requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll() + // 企业公开主页(匿名 GET) + .requestMatchers(HttpMethod.GET, "/companies/*").permitAll() + // 管理后台(需登录+ADMIN角色) + .requestMatchers("/admin/**").authenticated() + // 权重模板(登录即可) + .requestMatchers(HttpMethod.GET, "/review-weight-templates").authenticated() + // 个人权重配置(需登录,仅 STUDENT,Service 层校验) + .requestMatchers(HttpMethod.GET, "/profile/me/review-weights").authenticated() + .requestMatchers(HttpMethod.PUT, "/profile/me/review-weights").authenticated() + // 求职者评分卡(需登录,仅 STUDENT,Service 层校验) + .requestMatchers(HttpMethod.GET, "/profile/me/candidate-score-card").authenticated() + .requestMatchers(HttpMethod.GET, "/profile/me/candidate-score-trend").authenticated() + .requestMatchers(HttpMethod.GET, "/profile/me/candidate-percentile").authenticated() + // 个人资料(需登录) + .requestMatchers("/profile/me").authenticated() + // 岗位评价查询、详情、评分汇总、岗位对比(公开) + .requestMatchers(HttpMethod.GET, "/jobs/compare").permitAll() + .requestMatchers(HttpMethod.GET, "/jobs/*/reviews/summary").permitAll() + .requestMatchers(HttpMethod.GET, "/jobs/*/reviews/*").permitAll() + .requestMatchers(HttpMethod.GET, "/jobs/*/reviews").permitAll() + // 岗位收藏和评价接口(需登录) + .requestMatchers("/jobs/*/favorite").authenticated() + .requestMatchers("/jobs/favorites").authenticated() + .requestMatchers("/jobs/*/reviews/**").authenticated() + // 个性化评分(需登录,仅 STUDENT,Service 层校验) + .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() + // 岗位公开查询 + .requestMatchers("/jobs/public/**").permitAll() + .requestMatchers("/jobs").permitAll() + // 岗位搜索与筛选(公开) + .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() + .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() + // 岗位管理(需登录) + .requestMatchers("/jobs/**").authenticated() + // 投递(需登录) + .requestMatchers("/applications/**").authenticated() + .requestMatchers("/company/applications").authenticated() + // 面试(需登录) + .requestMatchers("/interviews/**").authenticated() + // API 信息入口 + .requestMatchers("/").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of(allowedOrigins.split(","))); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id")); + config.setExposedHeaders(List.of("X-Request-Id")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java new file mode 100644 index 0000000..fee7cb7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java @@ -0,0 +1,12 @@ +package com.mydreamjob.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + // 如需添加拦截器、路径样式等,在此配置 +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java new file mode 100644 index 0000000..bc1b704 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java @@ -0,0 +1,44 @@ +package com.mydreamjob.controller; + +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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * API 网关/信息控制器 - 提供 API v0.1 基础入口 + */ +@Tag(name = "API", description = "API 信息入口") +@RestController +@RequestMapping +public class ApiController { + + @Operation(summary = "API 信息", description = "返回 API v0.1 基本信息") + @GetMapping("/") + public ResponseEntity>> index(HttpServletRequest request) { + Map info = Map.of( + "name", "mydreamjob", + "version", "0.1.0", + "description", "职来 - 大学生求职服务平台 API", + "endpoints", Map.of( + "health", "GET /api/v1/health", + "info", "GET /api/v1/info", + "sms_send", "POST /api/v1/auth/sms/send?phone=xxx&purpose=login", + "login", "POST /api/v1/auth/login", + "me", "GET /api/v1/auth/me (requires JWT)" + ), + "docs", "http://localhost:8080/swagger-ui.html" + ); + return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request))); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java new file mode 100644 index 0000000..746e2d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -0,0 +1,99 @@ +package com.mydreamjob.controller; + +import com.mydreamjob.auth.AuthService; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.LoginRequest; +import com.mydreamjob.auth.SmsVerificationService; +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.ConstraintViolationException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 认证相关接口 + */ +@Tag(name = "认证", description = "登录、验证码、用户信息") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Validated +public class AuthController { + + private final AuthService authService; + private final SmsVerificationService smsVerificationService; + private final JwtTokenProvider jwtTokenProvider; + + @Operation(summary = "发送短信验证码(Mock)", description = "开发环境 Mock:验证码会打印到日志") + @PostMapping("/sms/send") + public ResponseEntity>> sendSmsCode( + @RequestParam @NotBlank(message = "phone is required") + @Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone, + @RequestParam(defaultValue = "login") String purpose, + HttpServletRequest request) { + try { + smsVerificationService.sendCode(phone, purpose, + getRequestId(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + Map.of("message", "验证码已发送(Mock)"), + getRequestId(request))); + } catch (ConstraintViolationException e) { + return ResponseEntity.badRequest().body( + ApiResponse.badRequest(e.getMessage(), getRequestId(request))); + } catch (Exception e) { + return ResponseEntity.badRequest().body( + ApiResponse.error(400, e.getMessage(), getRequestId(request))); + } + } + + @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 JWT token") + @PostMapping("/login") + public ResponseEntity>> mockLogin( + @Valid @RequestBody LoginRequest body, + HttpServletRequest request) { + String phone = body.getPhone(); + String code = body.getCode(); + String purpose = body.getPurpose() == null || body.getPurpose().isBlank() + ? "login" : body.getPurpose(); + try { + Map result = authService.mockLogin(phone, code, purpose, + getRequestId(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + } catch (Exception e) { + return ResponseEntity.badRequest().body( + ApiResponse.error(400, e.getMessage(), getRequestId(request))); + } + } + + @Operation(summary = "当前用户信息", description = "需要 JWT 认证") + @GetMapping("/me") + public ResponseEntity>> me(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return ResponseEntity.status(401).body( + ApiResponse.unauthorized("not authenticated", getRequestId(request))); + } + Long userId = Long.valueOf(auth.getName()); + return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request))); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java new file mode 100644 index 0000000..338684d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java @@ -0,0 +1,49 @@ +package com.mydreamjob.controller; + +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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 应用信息控制器 + */ +@Tag(name = "系统", description = "健康检查、应用信息") +@RestController +@RequestMapping +public class SystemController { + + @Operation(summary = "应用健康检查", description = "返回应用及依赖服务状态") + @GetMapping("/health") + public ResponseEntity>> health(HttpServletRequest request) { + Map details = Map.of( + "status", "UP", + "application", "mydreamjob-server", + "version", "0.0.1-SNAPSHOT" + ); + return ResponseEntity.ok(ApiResponse.ok(details, getRequestId(request))); + } + + @Operation(summary = "应用信息", description = "返回应用版本、运行时信息") + @GetMapping("/info") + public ResponseEntity>> info(HttpServletRequest request) { + Map info = Map.of( + "application", "mydreamjob-server", + "version", "0.0.1-SNAPSHOT", + "javaVersion", System.getProperty("java.version"), + "uptimeSeconds", (System.currentTimeMillis() - java.lang.management.ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000, + "springProfile", System.getProperty("spring.profiles.active", "dev") + ); + return ResponseEntity.ok(ApiResponse.ok(info, getRequestId(request))); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedback.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedback.java new file mode 100644 index 0000000..745e229 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedback.java @@ -0,0 +1,52 @@ +package com.mydreamjob.feedback; + +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 = "candidate_feedback") +@EntityListeners(AuditingEntityListener.class) +public class CandidateFeedback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "application_id", nullable = false, unique = true) + private Long applicationId; + + @Column(name = "company_user_id", nullable = false) + private Long companyUserId; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(nullable = false) + private Integer rating; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + @Column(columnDefinition = "json") + private String tags; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java new file mode 100644 index 0000000..e23a9d5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java @@ -0,0 +1,77 @@ +package com.mydreamjob.feedback; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; +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 = "企业对已投递候选人的评价反馈") +@RestController +@RequiredArgsConstructor +public class CandidateFeedbackController { + + private final CandidateFeedbackService feedbackService; + + @Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次") + @PostMapping("/applications/{applicationId}/feedback") + public ResponseEntity>> create( + @PathVariable Long applicationId, + @Valid @RequestBody CandidateFeedbackRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = feedbackService.createFeedback(userId, applicationId, req); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看") + @GetMapping("/applications/{applicationId}/feedback") + public ResponseEntity>> get( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = feedbackService.getFeedback(userId, applicationId); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "修改候选人反馈", description = "仅岗位所属企业") + @PutMapping("/applications/{applicationId}/feedback") + public ResponseEntity>> update( + @PathVariable Long applicationId, + @Valid @RequestBody CandidateFeedbackRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = feedbackService.updateFeedback(userId, applicationId, req); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "删除候选人反馈", description = "仅岗位所属企业") + @DeleteMapping("/applications/{applicationId}/feedback") + public ResponseEntity>> delete( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + feedbackService.deleteFeedback(userId, applicationId); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "feedback deleted"), 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java new file mode 100644 index 0000000..b0d0c95 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java @@ -0,0 +1,14 @@ +package com.mydreamjob.feedback; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CandidateFeedbackRepository extends JpaRepository { + + Optional findByApplicationId(Long applicationId); + + boolean existsByApplicationId(Long applicationId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java new file mode 100644 index 0000000..4d2111c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java @@ -0,0 +1,144 @@ +package com.mydreamjob.feedback; + +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.feedback.dto.CandidateFeedbackRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CandidateFeedbackService { + + private final CandidateFeedbackRepository feedbackRepository; + private final ApplicationRepository applicationRepository; + private final JobService jobService; + private static final ObjectMapper mapper = new ObjectMapper(); + + private Application findApplication(Long appId) { + return applicationRepository.findById(appId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + } + + private Job findJob(Application app) { + return jobService.getJob(app.getJobId()); + } + + private void assertCompanyOwner(Long companyUserId, Job job) { + if (!job.getCompanyId().equals(companyUserId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private void assertStudentOwner(Long studentUserId, Application app) { + if (!app.getStudentUserId().equals(studentUserId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + // ── 创建 ────────────────────────────────────────────────── + + @Transactional + public Map createFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { + Application app = findApplication(applicationId); + Job job = findJob(app); + assertCompanyOwner(companyUserId, job); + + if (feedbackRepository.existsByApplicationId(applicationId)) + throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); + + CandidateFeedback fb = CandidateFeedback.builder() + .applicationId(applicationId) + .companyUserId(companyUserId) + .studentUserId(app.getStudentUserId()) + .rating(req.getRating()).content(req.getContent()) + .tags(toJson(req.getTags())) + .build(); + return toView(feedbackRepository.save(fb)); + } + + // ── 查询 ────────────────────────────────────────────────── + + public Map getFeedback(Long userId, Long applicationId) { + Application app = findApplication(applicationId); + Job job = findJob(app); + + // 企业或学生任一匹配即可查看 + boolean isCompany = job.getCompanyId().equals(userId); + boolean isStudent = app.getStudentUserId().equals(userId); + if (!isCompany && !isStudent) + throw new BusinessException(ErrorCode.FORBIDDEN); + + CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); + return toView(fb); + } + + // ── 修改 ────────────────────────────────────────────────── + + @Transactional + public Map updateFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { + Application app = findApplication(applicationId); + Job job = findJob(app); + assertCompanyOwner(companyUserId, job); + + CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); + + if (req.getRating() != null) fb.setRating(req.getRating()); + if (req.getContent() != null) fb.setContent(req.getContent()); + if (req.getTags() != null) fb.setTags(toJson(req.getTags())); + return toView(feedbackRepository.save(fb)); + } + + // ── 删除 ────────────────────────────────────────────────── + + @Transactional + public void deleteFeedback(Long companyUserId, Long applicationId) { + Application app = findApplication(applicationId); + Job job = findJob(app); + assertCompanyOwner(companyUserId, job); + + CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); + feedbackRepository.delete(fb); + } + + // ── 视图 ────────────────────────────────────────────────── + + public Map toView(CandidateFeedback fb) { + Map v = new HashMap<>(); + v.put("id", fb.getId()); + v.put("applicationId", fb.getApplicationId()); + v.put("companyUserId", fb.getCompanyUserId()); + v.put("studentUserId", fb.getStudentUserId()); + v.put("rating", fb.getRating()); + v.put("content", fb.getContent()); + v.put("tags", fromJson(fb.getTags())); + v.put("createdAt", fb.getCreatedAt()); + v.put("updatedAt", fb.getUpdatedAt()); + return v; + } + + private String toJson(List list) { + if (list == null) return null; + try { return mapper.writeValueAsString(list); } + catch (JsonProcessingException e) { throw new RuntimeException("JSON error", e); } + } + + private List fromJson(String json) { + if (json == null || json.isBlank()) return List.of(); + try { return mapper.readValue(json, new TypeReference>() {}); } + catch (JsonProcessingException e) { return List.of(); } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackRequest.java new file mode 100644 index 0000000..b1dff52 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackRequest.java @@ -0,0 +1,21 @@ +package com.mydreamjob.feedback.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.util.List; + +@Data +public class CandidateFeedbackRequest { + + @Min(1) @Max(5) + @NotNull(message = "rating is required") + private Integer rating; + + @NotBlank(message = "content is required") + @Size(max = 1000, message = "content must not exceed 1000 characters") + private String content; + + @Size(max = 10, message = "tags must have at most 10 items") + private List<@Size(min = 1, max = 50, message = "each tag must be 1-50 chars") String> tags; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/Interview.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/Interview.java new file mode 100644 index 0000000..c782bff --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/Interview.java @@ -0,0 +1,87 @@ +package com.mydreamjob.interview; + +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 = "interviews") +@EntityListeners(AuditingEntityListener.class) +public class Interview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "application_id", nullable = false) + private Long applicationId; + + @Column(name = "company_user_id", nullable = false) + private Long companyUserId; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "round_no", nullable = false) + @Builder.Default + private Integer roundNo = 1; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "interview_type", nullable = false, length = 20) + private String interviewType; + + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + @Column(name = "timezone", nullable = false, length = 50) + @Builder.Default + private String timezone = "Asia/Shanghai"; + + @Column(name = "meeting_url", length = 500) + private String meetingUrl; + + @Column(name = "address", length = 500) + private String address; + + @Column(name = "interviewer_name", length = 100) + private String interviewerName; + + @Column(name = "notes", columnDefinition = "text") + private String notes; + + @Column(name = "status", nullable = false, length = 30) + @Builder.Default + private String status = "SCHEDULED"; + + @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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java new file mode 100644 index 0000000..8246226 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java @@ -0,0 +1,183 @@ +package com.mydreamjob.interview; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.interview.dto.InterviewCancelRequest; +import com.mydreamjob.interview.dto.InterviewCreateRequest; +import com.mydreamjob.interview.dto.InterviewRespondRequest; +import com.mydreamjob.interview.dto.InterviewUpdateRequest; +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.format.annotation.DateTimeFormat; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 面试管理接口 + */ +@Tag(name = "面试", description = "面试创建、查看、响应、取消、完成") +@RestController +@RequiredArgsConstructor +public class InterviewController { + + private final InterviewService interviewService; + + // ── 企业创建面试 ──────────────────────────────────────────── + + @Operation(summary = "创建面试邀请", description = "企业为投递记录创建面试邀请") + @PostMapping("/applications/{applicationId}/interviews") + public ResponseEntity>> create( + @PathVariable Long applicationId, + @Valid @RequestBody InterviewCreateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Interview interview = interviewService.createInterview(userId, applicationId, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 查询某投递的面试列表 ───────────────────────────────────── + + @Operation(summary = "查询投递的面试列表", description = "学生本人或岗位所属企业可查看") + @GetMapping("/applications/{applicationId}/interviews") + public ResponseEntity>>> listByApplication( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List interviews = interviewService.listByApplication(applicationId, userId); + List> items = interviews.stream() + .map(interviewService::toView).collect(Collectors.toList()); + return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request))); + } + + // ── 当前用户面试列表 ──────────────────────────────────────── + + @Operation(summary = "我的面试列表", description = "查看当前用户相关的面试,支持 status/from/to 筛选和分页") + @GetMapping("/interviews/me") + public ResponseEntity>> listMyInterviews( + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + if (size > 100) size = 100; + Page result = interviewService.listMyInterviews(userId, status, from, to, page, size); + Map data = new HashMap<>(); + data.put("items", result.getContent().stream().map(interviewService::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("/interviews/{interviewId}") + public ResponseEntity>> get( + @PathVariable Long interviewId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Interview interview = interviewService.getInterview(interviewId, userId); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 企业修改面试 ──────────────────────────────────────────── + + @Operation(summary = "修改面试", description = "企业修改面试时间、地点等信息,仅 SCHEDULED/CONFIRMED 状态可修改") + @PutMapping("/interviews/{interviewId}") + public ResponseEntity>> update( + @PathVariable Long interviewId, + @Valid @RequestBody InterviewUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Interview interview = interviewService.updateInterview(userId, interviewId, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 学生响应面试 ──────────────────────────────────────────── + + @Operation(summary = "响应面试邀请", description = "学生接受、拒绝或申请改期") + @PostMapping("/interviews/{interviewId}/respond") + public ResponseEntity>> respond( + @PathVariable Long interviewId, + @Valid @RequestBody InterviewRespondRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Interview interview = interviewService.respondToInterview(userId, interviewId, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 企业取消面试 ──────────────────────────────────────────── + + @Operation(summary = "取消面试", description = "企业取消已安排的面试") + @PostMapping("/interviews/{interviewId}/cancel") + public ResponseEntity>> cancel( + @PathVariable Long interviewId, + @Valid @RequestBody InterviewCancelRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Interview interview = interviewService.cancelInterview(userId, interviewId, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 企业完成面试 ──────────────────────────────────────────── + + @Operation(summary = "完成面试", description = "企业在面试时间结束后将面试标记为完成") + @PostMapping("/interviews/{interviewId}/complete") + public ResponseEntity>> complete( + @PathVariable Long interviewId, + @RequestBody(required = false) Map body, + HttpServletRequest request) { + Long userId = resolveUserId(request); + String notes = body != null ? body.get("notes") : null; + Interview interview = interviewService.completeInterview(userId, interviewId, notes, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(interviewService.toView(interview), getRequestId(request))); + } + + // ── 状态时间线 ────────────────────────────────────────────── + + @Operation(summary = "面试状态时间线", description = "查看面试状态变更历史") + @GetMapping("/interviews/{interviewId}/timeline") + public ResponseEntity>>> timeline( + @PathVariable Long interviewId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List> data = interviewService.getTimeline(interviewId, userId); + 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 BusinessException(ErrorCode.UNAUTHORIZED); + return Long.valueOf(auth.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java new file mode 100644 index 0000000..418ea7d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java @@ -0,0 +1,35 @@ +package com.mydreamjob.interview; + +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.time.LocalDateTime; +import java.util.List; + +@Repository +public interface InterviewRepository extends JpaRepository { + + List findByApplicationIdOrderByCreatedAtDesc(Long applicationId); + + Page findByCompanyUserIdOrderByStartAtDesc(Long companyUserId, Pageable pageable); + + Page findByCompanyUserIdAndStatusOrderByStartAtDesc(Long companyUserId, String status, Pageable pageable); + + Page findByCompanyUserIdAndStartAtBetweenOrderByStartAtDesc( + Long companyUserId, LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findByCompanyUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( + Long companyUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findByStudentUserIdOrderByStartAtDesc(Long studentUserId, Pageable pageable); + + Page findByStudentUserIdAndStatusOrderByStartAtDesc(Long studentUserId, String status, Pageable pageable); + + Page findByStudentUserIdAndStartAtBetweenOrderByStartAtDesc( + Long studentUserId, LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( + Long studentUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java new file mode 100644 index 0000000..e8e2ce4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java @@ -0,0 +1,503 @@ +package com.mydreamjob.interview; + +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.interview.dto.InterviewCancelRequest; +import com.mydreamjob.interview.dto.InterviewCreateRequest; +import com.mydreamjob.interview.dto.InterviewRespondRequest; +import com.mydreamjob.interview.dto.InterviewUpdateRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.notification.NotificationService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +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.Duration; +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 InterviewService { + + private static final int MAX_PAGE_SIZE = 100; + private static final long MAX_DURATION_HOURS = 8; + + private final InterviewRepository interviewRepository; + private final InterviewStatusHistoryRepository historyRepository; + private final ApplicationRepository applicationRepository; + private final JobService jobService; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final AuditService auditService; + + @PersistenceContext + private EntityManager entityManager; + + // ── 创建面试(企业)───────────────────────────────────────── + + @Transactional + public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req, + AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Application app = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + + Job job = jobService.getJob(app.getJobId()); + if (!company.getId().equals(job.getCompanyId())) { + throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); + } + + validateTimeRange(req.getStartAt(), req.getEndAt()); + validateTypeFields(req.getInterviewType(), req.getMeetingUrl(), req.getAddress()); + + Interview interview = Interview.builder() + .applicationId(applicationId) + .companyUserId(company.getOwnerUserId()) + .studentUserId(app.getStudentUserId()) + .roundNo(req.getRoundNo() != null ? req.getRoundNo() : 1) + .title(req.getTitle().trim()) + .interviewType(req.getInterviewType()) + .startAt(req.getStartAt()) + .endAt(req.getEndAt()) + .timezone(req.getTimezone() != null ? req.getTimezone() : "Asia/Shanghai") + .meetingUrl(req.getMeetingUrl()) + .address(req.getAddress()) + .interviewerName(req.getInterviewerName()) + .notes(req.getNotes()) + .status(InterviewStatus.SCHEDULED.name()) + .build(); + interview = interviewRepository.save(interview); + recordHistory(interview.getId(), null, InterviewStatus.SCHEDULED.name(), operatorUserId, "Interview created"); + auditService.log(operatorUserId, "CREATE_INTERVIEW", "INTERVIEW", interview.getId(), + "Interview created for application " + applicationId, ctx); + notificationService.publishEvent(interview.getStudentUserId(), "INTERVIEW_INVITED", + "面试邀请", "你收到来自 " + getCompanyName(company) + " 的面试邀请:" + interview.getTitle(), + "INTERVIEW", interview.getId(), + "interview-invited-" + interview.getId()); + return interview; + } + + // ── 查询某投递的面试列表 ───────────────────────────────────── + + public List listByApplication(Long applicationId, Long userId) { + Application app = applicationRepository.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + assertApplicationAccess(app, userId); + return interviewRepository.findByApplicationIdOrderByCreatedAtDesc(applicationId); + } + + // ── 当前用户面试列表 ──────────────────────────────────────── + + public Page listMyInterviews(Long userId, String status, LocalDateTime from, LocalDateTime to, + int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); + User user = loadUser(userId); + PageRequest pr = PageRequest.of(page, size); + + boolean hasStatus = status != null && !status.isBlank(); + boolean hasRange = from != null && to != null; + + if (hasRole(user, User.Role.COMPANY)) { + return listByCompanyUser(userId, status, from, to, pr); + } + if (hasRole(user, User.Role.STUDENT)) { + return listByStudentUser(userId, status, from, to, pr); + } + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private Page listByCompanyUser(Long userId, String status, LocalDateTime from, LocalDateTime to, + PageRequest pr) { + boolean hasStatus = status != null && !status.isBlank(); + boolean hasRange = from != null && to != null; + if (hasStatus && hasRange) + return interviewRepository.findByCompanyUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( + userId, status, from, to, pr); + if (hasStatus) + return interviewRepository.findByCompanyUserIdAndStatusOrderByStartAtDesc(userId, status, pr); + if (hasRange) + return interviewRepository.findByCompanyUserIdAndStartAtBetweenOrderByStartAtDesc(userId, from, to, pr); + return interviewRepository.findByCompanyUserIdOrderByStartAtDesc(userId, pr); + } + + private Page listByStudentUser(Long userId, String status, LocalDateTime from, LocalDateTime to, + PageRequest pr) { + boolean hasStatus = status != null && !status.isBlank(); + boolean hasRange = from != null && to != null; + if (hasStatus && hasRange) + return interviewRepository.findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( + userId, status, from, to, pr); + if (hasStatus) + return interviewRepository.findByStudentUserIdAndStatusOrderByStartAtDesc(userId, status, pr); + if (hasRange) + return interviewRepository.findByStudentUserIdAndStartAtBetweenOrderByStartAtDesc(userId, from, to, pr); + return interviewRepository.findByStudentUserIdOrderByStartAtDesc(userId, pr); + } + + // ── 查看面试详情 ──────────────────────────────────────────── + + public Interview getInterview(Long interviewId, Long userId) { + Interview interview = requireInterview(interviewId); + assertViewAccess(interview, userId); + return interview; + } + + // ── 企业修改面试 ──────────────────────────────────────────── + + @Transactional + public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req, + AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Interview interview = requireInterview(interviewId); + if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); + if (current != InterviewStatus.SCHEDULED && current != InterviewStatus.CONFIRMED) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + + boolean timeChanged = false; + if (req.getStartAt() != null && req.getEndAt() != null) { + validateTimeRange(req.getStartAt(), req.getEndAt()); + interview.setStartAt(req.getStartAt()); + interview.setEndAt(req.getEndAt()); + timeChanged = true; + } + if (req.getTitle() != null) interview.setTitle(req.getTitle().trim()); + if (req.getTimezone() != null) interview.setTimezone(req.getTimezone()); + if (req.getMeetingUrl() != null) interview.setMeetingUrl(req.getMeetingUrl()); + if (req.getAddress() != null) interview.setAddress(req.getAddress()); + if (req.getInterviewerName() != null) interview.setInterviewerName(req.getInterviewerName()); + if (req.getNotes() != null) interview.setNotes(req.getNotes()); + + interview = interviewRepository.save(interview); + recordHistory(interview.getId(), current.name(), interview.getStatus(), operatorUserId, "Interview updated"); + auditService.log(operatorUserId, "UPDATE_INTERVIEW", "INTERVIEW", interview.getId(), + "Interview updated", ctx); + if (timeChanged) { + notificationService.publishEvent(interview.getStudentUserId(), "INTERVIEW_RESCHEDULED", + "面试时间已更新", "面试 #" + interview.getId() + " - " + interview.getTitle() + " 时间已更新", + "INTERVIEW", interview.getId(), + "interview-rescheduled-" + interview.getId()); + } + return interview; + } + + // ── 学生响应面试 ──────────────────────────────────────────── + + @Transactional + public Interview respondToInterview(Long studentUserId, Long interviewId, InterviewRespondRequest req, + AuditContext ctx) { + Interview interview = requireInterview(interviewId); + if (!interview.getStudentUserId().equals(studentUserId)) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); + InterviewStatus target = mapAction(req.getAction()); + if (current == target) { + return interview; // 幂等 + } + if (current != InterviewStatus.SCHEDULED) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + + if (!current.canTransitionTo(target)) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + + String fromStatus = interview.getStatus(); + try { + interview.setStatus(target.name()); + Interview saved = interviewRepository.saveAndFlush(interview); + recordHistory(saved.getId(), fromStatus, target.name(), studentUserId, + req.getReason() != null ? req.getReason() : "Student " + target.name()); + notifyStudentResponse(saved, target); + return saved; + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + Interview fresh = interviewRepository.findById(interview.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_NOT_FOUND)); + if (fresh.getStatus().equals(target.name())) return fresh; + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION, + "interview was modified concurrently"); + } + } + + // ── 企业取消面试 ──────────────────────────────────────────── + + @Transactional + public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req, + AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Interview interview = requireInterview(interviewId); + if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); + if (current == InterviewStatus.CANCELLED) { + return interview; // 幂等 + } + if (current.isTerminal()) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + if (!current.canTransitionTo(InterviewStatus.CANCELLED)) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + + String fromStatus = interview.getStatus(); + try { + interview.setStatus(InterviewStatus.CANCELLED.name()); + Interview saved = interviewRepository.saveAndFlush(interview); + recordHistory(saved.getId(), fromStatus, InterviewStatus.CANCELLED.name(), operatorUserId, + req.getReason() != null ? req.getReason() : "Cancelled by company"); + auditService.log(operatorUserId, "CANCEL_INTERVIEW", "INTERVIEW", saved.getId(), + "Interview cancelled", ctx); + notificationService.publishEvent(saved.getStudentUserId(), "INTERVIEW_CANCELLED", + "面试已取消", "面试 #" + saved.getId() + " - " + saved.getTitle() + " 已被企业取消", + "INTERVIEW", saved.getId(), + "interview-cancelled-" + saved.getId()); + return saved; + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + Interview fresh = interviewRepository.findById(interview.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_NOT_FOUND)); + if (fresh.getStatus().equals(InterviewStatus.CANCELLED.name())) return fresh; + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION, + "interview was modified concurrently"); + } + } + + // ── 企业完成面试 ──────────────────────────────────────────── + + @Transactional + public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Interview interview = requireInterview(interviewId); + if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); + if (current == InterviewStatus.COMPLETED) { + return interview; // 幂等 + } + if (current != InterviewStatus.CONFIRMED) { + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION); + } + if (!LocalDateTime.now().isAfter(interview.getEndAt())) { + throw new BusinessException(ErrorCode.INTERVIEW_COMPLETE_NOT_READY); + } + + String fromStatus = interview.getStatus(); + if (notes != null) { + interview.setNotes(notes); + } + try { + interview.setStatus(InterviewStatus.COMPLETED.name()); + Interview saved = interviewRepository.saveAndFlush(interview); + recordHistory(saved.getId(), fromStatus, InterviewStatus.COMPLETED.name(), operatorUserId, + "Interview completed"); + auditService.log(operatorUserId, "COMPLETE_INTERVIEW", "INTERVIEW", saved.getId(), + "Interview completed", ctx); + notificationService.publishEvent(saved.getStudentUserId(), "INTERVIEW_COMPLETED", + "面试已完成", "面试 #" + saved.getId() + " - " + saved.getTitle() + " 已完成", + "INTERVIEW", saved.getId(), + "interview-completed-" + saved.getId()); + return saved; + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + Interview fresh = interviewRepository.findById(interview.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_NOT_FOUND)); + if (fresh.getStatus().equals(InterviewStatus.COMPLETED.name())) return fresh; + throw new BusinessException(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION, + "interview was modified concurrently"); + } + } + + // ── 状态时间线 ────────────────────────────────────────────── + + public List> getTimeline(Long interviewId, Long userId) { + getInterview(interviewId, userId); + return historyRepository.findByInterviewIdOrderByCreatedAtAsc(interviewId) + .stream().map(this::toHistoryView).collect(Collectors.toList()); + } + + // ── 视图 ──────────────────────────────────────────────────── + + public Map toView(Interview interview) { + Map v = new HashMap<>(); + v.put("id", interview.getId()); + v.put("applicationId", interview.getApplicationId()); + v.put("companyUserId", interview.getCompanyUserId()); + v.put("studentUserId", interview.getStudentUserId()); + v.put("roundNo", interview.getRoundNo()); + v.put("title", interview.getTitle()); + v.put("interviewType", interview.getInterviewType()); + v.put("startAt", interview.getStartAt()); + v.put("endAt", interview.getEndAt()); + v.put("timezone", interview.getTimezone()); + v.put("meetingUrl", interview.getMeetingUrl()); + v.put("address", interview.getAddress()); + v.put("interviewerName", interview.getInterviewerName()); + v.put("notes", interview.getNotes()); + v.put("status", interview.getStatus()); + v.put("version", interview.getVersion()); + v.put("createdAt", interview.getCreatedAt()); + v.put("updatedAt", interview.getUpdatedAt()); + return v; + } + + private Map toHistoryView(InterviewStatusHistory h) { + Map v = new HashMap<>(); + v.put("id", h.getId()); + v.put("fromStatus", h.getFromStatus()); + v.put("toStatus", h.getToStatus()); + v.put("actorUserId", h.getActorUserId()); + v.put("reason", h.getReason()); + v.put("createdAt", h.getCreatedAt()); + return v; + } + + // ── 私有辅助 ──────────────────────────────────────────────── + + private Interview requireInterview(Long interviewId) { + return interviewRepository.findById(interviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_NOT_FOUND)); + } + + private User loadUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private Company requireCompany(Long userId) { + User user = loadUser(userId); + if (!hasRole(user, User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + } + + private boolean hasRole(User user, User.Role role) { + return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); + } + + private void assertViewAccess(Interview interview, Long userId) { + User user = loadUser(userId); + if (hasRole(user, User.Role.STUDENT) && interview.getStudentUserId().equals(userId)) return; + if (hasRole(user, User.Role.COMPANY) && interview.getCompanyUserId().equals(userId)) return; + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + private void assertApplicationAccess(Application app, Long userId) { + User user = loadUser(userId); + if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return; + if (hasRole(user, User.Role.COMPANY)) { + Job job = jobService.getJob(app.getJobId()); + Company company = companyRepository.findByOwnerUserId(userId).orElse(null); + if (company != null && company.getId().equals(job.getCompanyId())) return; + } + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + private void validateTimeRange(LocalDateTime startAt, LocalDateTime endAt) { + if (!startAt.isBefore(endAt)) { + throw new BusinessException(ErrorCode.INTERVIEW_TIME_INVALID); + } + if (!startAt.isAfter(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.INTERVIEW_START_TIME_PAST); + } + Duration duration = Duration.between(startAt, endAt); + if (duration.toHours() > MAX_DURATION_HOURS) { + throw new BusinessException(ErrorCode.INTERVIEW_DURATION_EXCEEDED); + } + } + + private void validateTypeFields(String interviewType, String meetingUrl, String address) { + if ("ONLINE".equals(interviewType) && (meetingUrl == null || meetingUrl.isBlank())) { + throw new BusinessException(ErrorCode.INTERVIEW_ONLINE_URL_REQUIRED); + } + if ("OFFLINE".equals(interviewType) && (address == null || address.isBlank())) { + throw new BusinessException(ErrorCode.INTERVIEW_OFFLINE_ADDRESS_REQUIRED); + } + } + + private InterviewStatus mapAction(String action) { + return switch (action) { + case "CONFIRM" -> InterviewStatus.CONFIRMED; + case "DECLINE" -> InterviewStatus.DECLINED; + case "REQUEST_RESCHEDULE" -> InterviewStatus.RESCHEDULE_REQUESTED; + default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid action: " + action); + }; + } + + private void notifyStudentResponse(Interview interview, InterviewStatus target) { + Company company = companyRepository.findByOwnerUserId(interview.getCompanyUserId()).orElse(null); + if (company == null) return; + String type; + String title; + String idempotency; + switch (target) { + case CONFIRMED -> { + type = "INTERVIEW_CONFIRMED"; + title = "面试已确认"; + idempotency = "interview-confirmed-" + interview.getId(); + } + case DECLINED -> { + type = "INTERVIEW_DECLINED"; + title = "面试已拒绝"; + idempotency = "interview-declined-" + interview.getId(); + } + case RESCHEDULE_REQUESTED -> { + type = "INTERVIEW_RESCHEDULE_REQUESTED"; + title = "学生申请改期"; + idempotency = "interview-reschedule-" + interview.getId(); + } + default -> { return; } + } + notificationService.publishEvent(company.getOwnerUserId(), type, title, + "面试 #" + interview.getId() + " - " + interview.getTitle() + " 状态:" + target.name(), + "INTERVIEW", interview.getId(), idempotency); + } + + private void recordHistory(Long interviewId, String fromStatus, String toStatus, + Long actorUserId, String reason) { + historyRepository.save(InterviewStatusHistory.builder() + .interviewId(interviewId) + .fromStatus(fromStatus) + .toStatus(toStatus) + .actorUserId(actorUserId) + .reason(reason) + .build()); + } + + private String getCompanyName(Company company) { + return company.getName() != null ? company.getName() : "企业"; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatus.java new file mode 100644 index 0000000..147da66 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatus.java @@ -0,0 +1,39 @@ +package com.mydreamjob.interview; + +/** + * 面试状态枚举 + * + *

状态流转: + *

+ * SCHEDULED ──────────> CONFIRMED ──────────> COMPLETED (终态)
+ *     │                    │
+ *     ├────────────────────┼──> CANCELLED (终态)
+ *     │                    │
+ *     ├──> DECLINED (终态)  │
+ *     │                    │
+ *     └──> RESCHEDULE_REQUESTED ──> SCHEDULED (企业重新安排)
+ * 
+ */ +public enum InterviewStatus { + SCHEDULED, + CONFIRMED, + DECLINED, + RESCHEDULE_REQUESTED, + CANCELLED, + COMPLETED; + + public boolean canTransitionTo(InterviewStatus target) { + if (target == null) return false; + return switch (this) { + case SCHEDULED -> target == CONFIRMED || target == DECLINED + || target == RESCHEDULE_REQUESTED || target == CANCELLED; + case RESCHEDULE_REQUESTED -> target == SCHEDULED; + case CONFIRMED -> target == CANCELLED || target == COMPLETED; + case DECLINED, CANCELLED, COMPLETED -> false; + }; + } + + public boolean isTerminal() { + return this == DECLINED || this == CANCELLED || this == COMPLETED; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistory.java new file mode 100644 index 0000000..fee80eb --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistory.java @@ -0,0 +1,48 @@ +package com.mydreamjob.interview; + +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 = "interview_status_history") +public class InterviewStatusHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "interview_id", nullable = false) + private Long interviewId; + + @Column(name = "from_status", length = 30) + private String fromStatus; + + @Column(name = "to_status", nullable = false, length = 30) + private String toStatus; + + @Column(name = "actor_user_id", nullable = false) + private Long actorUserId; + + @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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java new file mode 100644 index 0000000..aa9de0c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.interview; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface InterviewStatusHistoryRepository extends JpaRepository { + + List findByInterviewIdOrderByCreatedAtAsc(Long interviewId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCancelRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCancelRequest.java new file mode 100644 index 0000000..f16676f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCancelRequest.java @@ -0,0 +1,12 @@ +package com.mydreamjob.interview.dto; + +import lombok.Data; + +/** + * 取消面试请求 + */ +@Data +public class InterviewCancelRequest { + + private String reason; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java new file mode 100644 index 0000000..b752e19 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java @@ -0,0 +1,54 @@ +package com.mydreamjob.interview.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 创建面试请求 + */ +@Data +public class InterviewCreateRequest { + + private Integer roundNo = 1; + + @NotBlank(message = "title is required") + @Size(max = 200, message = "title must not exceed 200 characters") + private String title; + + @NotBlank(message = "interviewType is required") + private String interviewType; + + @NotNull(message = "startAt is required") + private LocalDateTime startAt; + + @NotNull(message = "endAt is required") + private LocalDateTime endAt; + + private String timezone = "Asia/Shanghai"; + + private String meetingUrl; + + private String address; + + @Size(max = 100, message = "interviewerName must not exceed 100 characters") + private String interviewerName; + + private String notes; + + @AssertTrue(message = "meetingUrl is required for ONLINE interviews") + public boolean isMeetingUrlValid() { + if (!"ONLINE".equals(interviewType)) return true; + return meetingUrl != null && !meetingUrl.isBlank(); + } + + @AssertTrue(message = "address is required for OFFLINE interviews") + public boolean isAddressValid() { + if (!"OFFLINE".equals(interviewType)) return true; + return address != null && !address.isBlank(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewRespondRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewRespondRequest.java new file mode 100644 index 0000000..c0f4948 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewRespondRequest.java @@ -0,0 +1,16 @@ +package com.mydreamjob.interview.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 学生响应面试请求 + */ +@Data +public class InterviewRespondRequest { + + @NotBlank(message = "action is required") + private String action; + + private String reason; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewUpdateRequest.java new file mode 100644 index 0000000..325f592 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewUpdateRequest.java @@ -0,0 +1,28 @@ +package com.mydreamjob.interview.dto; + +import jakarta.validation.constraints.AssertTrue; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 修改面试请求 + */ +@Data +public class InterviewUpdateRequest { + + private String title; + private LocalDateTime startAt; + private LocalDateTime endAt; + private String timezone; + private String meetingUrl; + private String address; + private String interviewerName; + private String notes; + + @AssertTrue(message = "startAt must be before endAt if both provided") + public boolean isTimeRangeValid() { + if (startAt == null || endAt == null) return true; + return startAt.isBefore(endAt); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/EmploymentType.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/EmploymentType.java new file mode 100644 index 0000000..6b859b3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/EmploymentType.java @@ -0,0 +1,11 @@ +package com.mydreamjob.job; + +/** + * 用工类型枚举 + */ +public enum EmploymentType { + FULL_TIME, + PART_TIME, + INTERN, + INTERNSHIP +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java new file mode 100644 index 0000000..127a127 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/FavoriteController.java @@ -0,0 +1,73 @@ +package com.mydreamjob.job; + +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.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 +@RequiredArgsConstructor +public class FavoriteController { + + private final JobFavoriteService favoriteService; + + @Operation(summary = "收藏岗位", description = "仅 STUDENT;仅 PUBLISHED;幂等") + @PostMapping("/jobs/{jobId}/favorite") + public ResponseEntity>> favorite( + @PathVariable Long jobId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map result = favoriteService.favorite(userId, jobId); + return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + } + + @Operation(summary = "取消收藏", description = "仅 STUDENT;幂等,未收藏也返回 200") + @DeleteMapping("/jobs/{jobId}/favorite") + public ResponseEntity>> unfavorite( + @PathVariable Long jobId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map result = favoriteService.unfavorite(userId, jobId); + return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + } + + @Operation(summary = "我的收藏列表", description = "仅 STUDENT;分页,按收藏时间倒序") + @GetMapping("/jobs/favorites") + public ResponseEntity>> listFavorites( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page> result = favoriteService.listFavorites(userId, page, size); + Map 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java new file mode 100644 index 0000000..ff9e1b4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java @@ -0,0 +1,68 @@ +package com.mydreamjob.job; + +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 = "jobs") +@EntityListeners(AuditingEntityListener.class) +public class Job { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(name = "title", nullable = false, length = 120) + private String title; + + @Column(name = "description", nullable = false, columnDefinition = "text") + private String description; + + @Column(name = "requirements", columnDefinition = "text") + private String requirements; + + @Column(name = "city", nullable = false, length = 80) + private String city; + + @Column(name = "employment_type", nullable = false, length = 30) + private String employmentType; + + @Column(name = "salary_min") + private Integer salaryMin; + + @Column(name = "salary_max") + private Integer salaryMax; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = "DRAFT"; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java new file mode 100644 index 0000000..d9ad7b6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -0,0 +1,184 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.job.dto.JobCreateRequest; +import com.mydreamjob.job.dto.JobSearchItem; +import com.mydreamjob.job.dto.JobStatusRequest; +import com.mydreamjob.job.dto.JobUpdateRequest; +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; + +/** + * 岗位管理接口 + * 企业端:创建、编辑、状态流转(需 JWT + 企业归属校验) + * 公开端:岗位列表、详情(无需认证) + */ +@Tag(name = "岗位", description = "岗位创建、管理、查询") +@RestController +@RequestMapping("/jobs") +@RequiredArgsConstructor +public class JobController { + + private final JobService jobService; + private final CompanyService companyService; + + // ── 企业端:创建岗位 ──────────────────────────────────────── + + @Operation(summary = "创建岗位", description = "仅企业账号可调用,新岗位默认 DRAFT 状态") + @PostMapping + public ResponseEntity>> createJob( + @Valid @RequestBody JobCreateRequest req, + HttpServletRequest request) { + Long companyId = resolveCompanyId(request); + Map view = jobService.toView(jobService.createJob(companyId, req)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 企业端:编辑岗位 ──────────────────────────────────────── + + @Operation(summary = "更新岗位", description = "仅岗位所属企业可修改") + @PutMapping("/{jobId}") + public ResponseEntity>> updateJob( + @PathVariable Long jobId, + @Valid @RequestBody JobUpdateRequest req, + HttpServletRequest request) { + Long companyId = resolveCompanyId(request); + Map view = jobService.toView(jobService.updateJob(companyId, jobId, req)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 企业端:状态流转 ──────────────────────────────────────── + + @Operation(summary = "发布/关闭岗位", description = "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退") + @PatchMapping("/{jobId}/status") + public ResponseEntity>> updateStatus( + @PathVariable Long jobId, + @Valid @RequestBody JobStatusRequest req, + HttpServletRequest request) { + Long companyId = resolveCompanyId(request); + Map view = jobService.toView(jobService.transitionStatus(companyId, jobId, req)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 企业端:查询岗位详情 ──────────────────────────────────── + + @Operation(summary = "查询岗位详情", description = "企业可查看自己管理的岗位详情") + @GetMapping("/{jobId}") + public ResponseEntity>> getJob( + @PathVariable Long jobId, + HttpServletRequest request) { + Long companyId = resolveCompanyId(request); + // 验证归属后返回 + jobService.assertOwnerOrThrow(companyId, jobId); + Map view = jobService.toView(jobService.getJob(jobId)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 公开端:岗位列表 ──────────────────────────────────────── + + @Operation(summary = "查询已发布岗位列表", description = "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资筛选和分页") + @GetMapping + public ResponseEntity>> listJobs( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String city, + @RequestParam(required = false) String employmentType, + @RequestParam(required = false) Integer salaryMin, + @RequestParam(required = false) Integer salaryMax, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + if (size > 50) size = 50; + Page> result = + jobService.listPublishedJobs(keyword, city, employmentType, + salaryMin, salaryMax, page, size); + Map 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))); + } + + // ── 公开端:岗位详情 ──────────────────────────────────────── + + @Operation(summary = "查询岗位详情(公开)", description = "仅返回 PUBLISHED 岗位详情;CLOSED 也可查看详情") + @GetMapping("/public/{jobId}") + public ResponseEntity>> getPublicJob( + @PathVariable Long jobId, + HttpServletRequest request) { + Map view = jobService.getPublicJob(jobId); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 公开端:高级搜索 ──────────────────────────────────────── + + @Operation(summary = "岗位高级搜索", description = "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页") + @GetMapping("/search") + public ResponseEntity>> searchJobs( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String city, + @RequestParam(required = false) String employmentType, + @RequestParam(required = false) Integer salaryMin, + @RequestParam(required = false) Integer salaryMax, + @RequestParam(required = false) Long companyId, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Page result = jobService.searchJobs( + keyword, city, employmentType, salaryMin, salaryMax, companyId, + sort, page, size); + Map 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))); + } + + // ── 公开端:筛选选项 ──────────────────────────────────────── + + @Operation(summary = "岗位筛选选项", description = "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围") + @GetMapping("/filters") + public ResponseEntity>> getFilters( + HttpServletRequest request) { + Map options = jobService.getFilterOptions(); + return ResponseEntity.ok(ApiResponse.ok(options, getRequestId(request))); + } + + // ── 辅助方法 ──────────────────────────────────────────────── + + private Long resolveCompanyId(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); + } + Long userId = Long.valueOf(auth.getName()); + Company company = companyService.findCompanyByOwnerUserId(userId); + if (company == null) { + throw new com.mydreamjob.common.exception.BusinessException( + com.mydreamjob.common.exception.ErrorCode.COMPANY_NOT_FOUND); + } + return company.getId(); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavorite.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavorite.java new file mode 100644 index 0000000..35e8a8a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavorite.java @@ -0,0 +1,36 @@ +package com.mydreamjob.job; + +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 = "job_favorites", + uniqueConstraints = @UniqueConstraint(columnNames = {"job_id", "student_user_id"})) +@EntityListeners(AuditingEntityListener.class) +public class JobFavorite { + + @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; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteRepository.java new file mode 100644 index 0000000..4018479 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteRepository.java @@ -0,0 +1,20 @@ +package com.mydreamjob.job; + +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; + +@Repository +public interface JobFavoriteRepository extends JpaRepository { + + Optional findByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + Page findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId, Pageable pageable); + + boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + void deleteByJobIdAndStudentUserId(Long jobId, Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java new file mode 100644 index 0000000..37e6e61 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java @@ -0,0 +1,114 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +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.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JobFavoriteService { + + private final JobFavoriteRepository favoriteRepository; + private final JobService jobService; + private final UserRepository userRepository; + + private static final int MAX_PAGE_SIZE = 50; + + // ── 角色校验 ────────────────────────────────────────────── + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream() + .anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + // ── 收藏 ────────────────────────────────────────────────── + + @Transactional + public Map favorite(Long studentUserId, Long jobId) { + assertStudentRole(studentUserId); + + Job job = jobService.getJob(jobId); + if (!"PUBLISHED".equals(job.getStatus())) { + throw new BusinessException(ErrorCode.JOB_NOT_FOUND); + } + + // 幂等:已有收藏记录时直接返回成功 + var existing = favoriteRepository.findByJobIdAndStudentUserId(jobId, studentUserId); + if (existing.isPresent()) { + return buildResponse(jobId, true, existing.get().getCreatedAt()); + } + + JobFavorite fav = JobFavorite.builder() + .jobId(jobId) + .studentUserId(studentUserId) + .build(); + try { + fav = favoriteRepository.save(fav); + } catch (DataIntegrityViolationException e) { + // 并发插入导致的重复,查询已有记录返回 + existing = favoriteRepository.findByJobIdAndStudentUserId(jobId, studentUserId); + if (existing.isPresent()) { + return buildResponse(jobId, true, existing.get().getCreatedAt()); + } + throw e; + } + return buildResponse(jobId, true, fav.getCreatedAt()); + } + + // ── 取消收藏 ────────────────────────────────────────────── + + @Transactional + public Map unfavorite(Long studentUserId, Long jobId) { + assertStudentRole(studentUserId); + + // 幂等:不存在时也返回 200 + favoriteRepository.deleteByJobIdAndStudentUserId(jobId, studentUserId); + return buildResponse(jobId, false, null); + } + + // ── 查询收藏列表 ────────────────────────────────────────── + + public Page> listFavorites(Long studentUserId, int page, int size) { + assertStudentRole(studentUserId); + + if (size > MAX_PAGE_SIZE) size = MAX_PAGE_SIZE; + if (page < 0) page = 0; + + Page pageResult = favoriteRepository + .findByStudentUserIdOrderByCreatedAtDesc(studentUserId, PageRequest.of(page, size)); + + return pageResult.map(fav -> { + Job job = jobService.getJob(fav.getJobId()); + Map view = new HashMap<>(jobService.toPublicView(job)); + view.put("favoritedAt", fav.getCreatedAt()); + return view; + }); + } + + // ── 响应构建 ────────────────────────────────────────────── + + private Map buildResponse(Long jobId, boolean favorited, java.time.LocalDateTime favoritedAt) { + Map resp = new HashMap<>(); + resp.put("jobId", jobId); + resp.put("favorited", favorited); + resp.put("favoritedAt", favoritedAt); + return resp; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java new file mode 100644 index 0000000..483eb84 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java @@ -0,0 +1,50 @@ +package com.mydreamjob.job; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface JobRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status); + + List findByStatusOrderByCreatedAtDesc(String status); + + /** + * 公开查询:返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资过滤,分页排序 + */ + @Query("SELECT j FROM Job j WHERE j.status = 'PUBLISHED'" + + " AND (:city IS NULL OR j.city = :city)" + + " AND (:employmentType IS NULL OR j.employmentType = :employmentType)" + + " AND (:keyword IS NULL OR j.title LIKE %:keyword% OR j.description LIKE %:keyword%)" + + " AND (:salaryMin IS NULL OR j.salaryMax >= :salaryMin)" + + " AND (:salaryMax IS NULL OR j.salaryMin <= :salaryMax)") + Page findPublishedJobs( + @Param("keyword") String keyword, + @Param("city") String city, + @Param("employmentType") String employmentType, + @Param("salaryMin") Integer salaryMin, + @Param("salaryMax") Integer salaryMax, + Pageable pageable); + + // ── 筛选选项聚合查询 ──────────────────────────────────────── + + @Query("SELECT DISTINCT j.city FROM Job j WHERE j.status = 'PUBLISHED' ORDER BY j.city ASC") + List findDistinctCitiesPublished(); + + @Query("SELECT DISTINCT j.employmentType FROM Job j WHERE j.status = 'PUBLISHED' ORDER BY j.employmentType ASC") + List findDistinctEmploymentTypesPublished(); + + @Query("SELECT MIN(j.salaryMin) FROM Job j WHERE j.status = 'PUBLISHED'") + Integer findMinSalaryPublished(); + + @Query("SELECT MAX(j.salaryMax) FROM Job j WHERE j.status = 'PUBLISHED'") + Integer findMaxSalaryPublished(); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java new file mode 100644 index 0000000..a2df424 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java @@ -0,0 +1,89 @@ +package com.mydreamjob.job; + +import jakarta.persistence.criteria.Predicate; +import org.springframework.data.jpa.domain.Specification; + +import java.util.ArrayList; +import java.util.List; + +/** + * 岗位搜索动态查询 Specification。 + * 全部使用 CriteriaBuilder 参数化查询,防止 SQL 注入。 + */ +public class JobSearchSpecification { + + private static final String STATUS = "status"; + private static final String TITLE = "title"; + private static final String DESCRIPTION = "description"; + private static final String REQUIREMENTS = "requirements"; + private static final String CITY = "city"; + private static final String EMPLOYMENT_TYPE = "employmentType"; + private static final String SALARY_MIN = "salaryMin"; + private static final String SALARY_MAX = "salaryMax"; + private static final String COMPANY_ID = "companyId"; + + private JobSearchSpecification() { + // utility class + } + + /** + * 构建动态查询条件。 + * + * @param keyword 关键词(搜索标题/描述/要求) + * @param city 城市精确匹配 + * @param employmentType 用工类型精确匹配 + * @param salaryMin 最低薪资筛选 + * @param salaryMax 最高薪资筛选 + * @param companyId 公司ID筛选 + * @return Specification + */ + public static Specification searchJobs( + String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId) { + + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + + // 固定:仅 PUBLISHED + predicates.add(cb.equal(root.get(STATUS), "PUBLISHED")); + + // 关键词:搜索 title / description / requirements + if (keyword != null && !keyword.isBlank()) { + String pattern = "%" + keyword.trim() + "%"; + Predicate keywordPred = cb.or( + cb.like(cb.lower(root.get(TITLE)), pattern.toLowerCase()), + cb.like(cb.lower(root.get(DESCRIPTION)), pattern.toLowerCase()), + cb.like(cb.lower(root.get(REQUIREMENTS)), pattern.toLowerCase()) + ); + predicates.add(keywordPred); + } + + // 城市精确匹配 + if (city != null && !city.isBlank()) { + predicates.add(cb.equal(root.get(CITY), city.trim())); + } + + // 用工类型精确匹配 + if (employmentType != null && !employmentType.isBlank()) { + predicates.add(cb.equal(root.get(EMPLOYMENT_TYPE), employmentType.trim())); + } + + // 薪资范围:岗位 salaryMax >= 用户 salaryMin + if (salaryMin != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get(SALARY_MAX), salaryMin)); + } + + // 薪资范围:岗位 salaryMin <= 用户 salaryMax + if (salaryMax != null) { + predicates.add(cb.lessThanOrEqualTo(root.get(SALARY_MIN), salaryMax)); + } + + // 公司筛选 + if (companyId != null) { + predicates.add(cb.equal(root.get(COMPANY_ID), companyId)); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java new file mode 100644 index 0000000..8effaaa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -0,0 +1,341 @@ +package com.mydreamjob.job; + +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.dto.JobCreateRequest; +import com.mydreamjob.job.dto.JobSearchItem; +import com.mydreamjob.job.dto.JobStatusRequest; +import com.mydreamjob.job.dto.JobUpdateRequest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 岗位管理 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class JobService { + + private final JobRepository jobRepository; + private final CompanyRepository companyRepository; + + @PersistenceContext + private EntityManager entityManager; + + // ── 企业端接口 ────────────────────────────────────────────── + + /** + * 创建岗位(默认 DRAFT) + */ + @Transactional + public Job createJob(Long companyId, JobCreateRequest req) { + Job job = Job.builder() + .companyId(companyId) + .title(req.getTitle()) + .description(req.getDescription()) + .requirements(req.getRequirements()) + .city(req.getCity()) + .employmentType(req.getEmploymentType()) + .salaryMin(req.getSalaryMin()) + .salaryMax(req.getSalaryMax()) + .status(JobStatus.DRAFT.name()) + .build(); + return jobRepository.save(job); + } + + /** + * 查询岗位详情(含非 PUBLISHED 状态) + */ + public Job getJob(Long jobId) { + return jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); + } + + /** + * 更新岗位(仅岗位所属企业可操作,部分字段更新) + */ + @Transactional + public Job updateJob(Long companyId, Long jobId, JobUpdateRequest req) { + Job job = assertOwnerOrThrow(companyId, jobId); + + if (req.getTitle() != null) job.setTitle(req.getTitle()); + if (req.getDescription() != null) job.setDescription(req.getDescription()); + if (req.getRequirements() != null) job.setRequirements(req.getRequirements()); + if (req.getCity() != null) job.setCity(req.getCity()); + if (req.getEmploymentType() != null) job.setEmploymentType(req.getEmploymentType()); + if (req.getSalaryMin() != null) job.setSalaryMin(req.getSalaryMin()); + if (req.getSalaryMax() != null) job.setSalaryMax(req.getSalaryMax()); + + return jobRepository.save(job); + } + + /** + * 状态流转:DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退 + */ + @Transactional + public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) { + Job job = assertOwnerOrThrow(companyId, jobId); + String current = job.getStatus(); + String target = req.getStatus(); + + // 校验状态流转合法性 + boolean valid = switch (current) { + case "DRAFT" -> "PUBLISHED".equals(target); + case "PUBLISHED" -> "CLOSED".equals(target); + case "CLOSED" -> false; // CLOSED 不可再流转 + default -> false; + }; + if (!valid) { + throw new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION); + } + + job.setStatus(target); + if ("PUBLISHED".equals(target)) { + job.setPublishedAt(LocalDateTime.now()); + } + return jobRepository.save(job); + } + + // ── 公开接口 ──────────────────────────────────────────────── + + /** + * 公开岗位列表:仅返回 PUBLISHED,支持筛选和分页 + */ + public Page> listPublishedJobs(String keyword, String city, + String employmentType, + Integer salaryMin, Integer salaryMax, + int page, int size) { + if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = jobRepository.findPublishedJobs(keyword, city, employmentType, + salaryMin, salaryMax, pageable); + return result.map(this::toPublicView); + } + + /** + * 公开岗位详情:返回 PUBLISHED 岗位;CLOSED 岗位也允许查看但不可投递(本阶段不处理投递) + */ + public Map getPublicJob(Long jobId) { + Job job = getJob(jobId); + if (!"PUBLISHED".equals(job.getStatus()) && !"CLOSED".equals(job.getStatus())) { + // DRAFT 岗位对公众不可见 + throw new BusinessException(ErrorCode.JOB_NOT_FOUND); + } + return toPublicView(job); + } + + // ── 辅助方法 ──────────────────────────────────────────────── + + public Job assertOwnerOrThrow(Long companyId, Long jobId) { + Job job = getJob(jobId); + if (!companyId.equals(job.getCompanyId())) { + throw new BusinessException(ErrorCode.JOB_OWNER_MISMATCH); + } + return job; + } + + /** + * 企业端视图(含完整字段) + */ + public Map toView(Job job) { + Map view = new HashMap<>(); + view.put("id", job.getId()); + view.put("companyId", job.getCompanyId()); + view.put("title", job.getTitle()); + view.put("description", job.getDescription()); + view.put("requirements", job.getRequirements()); + view.put("city", job.getCity()); + view.put("employmentType", job.getEmploymentType()); + view.put("salaryMin", job.getSalaryMin()); + view.put("salaryMax", job.getSalaryMax()); + view.put("status", job.getStatus()); + view.put("publishedAt", job.getPublishedAt()); + view.put("createdAt", job.getCreatedAt()); + view.put("updatedAt", job.getUpdatedAt()); + return view; + } + + /** + * 公开视图(精简字段) + */ + public Map toPublicView(Job job) { + Map view = new HashMap<>(); + view.put("id", job.getId()); + view.put("companyId", job.getCompanyId()); + view.put("title", job.getTitle()); + view.put("description", job.getDescription()); + view.put("requirements", job.getRequirements()); + view.put("city", job.getCity()); + view.put("employmentType", job.getEmploymentType()); + view.put("salaryMin", job.getSalaryMin()); + view.put("salaryMax", job.getSalaryMax()); + view.put("status", job.getStatus()); + view.put("publishedAt", job.getPublishedAt()); + view.put("createdAt", job.getCreatedAt()); + return view; + } + + // ── 高级搜索 ──────────────────────────────────────────────── + + private static final Set ALLOWED_SORTS = Set.of("latest", "salary", "relevance"); + private static final int MAX_PAGE_SIZE = 50; + + /** + * 高级搜索:仅返回 PUBLISHED 岗位,支持多条件筛选、排序、分页。 + * 企业信息通过批量查询组装,避免 N+1。 + * sort=salary 时通过 CriteriaQuery 显式构建 CASE 排序,确保 salaryMax=NULL 排在最后。 + */ + public Page searchJobs(String keyword, String city, String employmentType, + Integer salaryMin, Integer salaryMax, Long companyId, + String sort, int page, int size) { + // 参数校验 + if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (sort != null && !ALLOWED_SORTS.contains(sort)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (page < 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType, + salaryMin, salaryMax, companyId); + + Page jobPage; + if ("salary".equals(sort)) { + // salary 排序:显式 CASE WHEN 确保 NULL 排在最后 + jobPage = findWithSalarySort(spec, page, size); + } else { + // latest 和 relevance 都按 latest 排序 + Sort sortObj = Sort.by( + new Sort.Order(Sort.Direction.DESC, "createdAt"), + new Sort.Order(Sort.Direction.DESC, "id")); + Pageable pageable = PageRequest.of(page, size, sortObj); + jobPage = jobRepository.findAll(spec, pageable); + } + + // 批量查询企业信息 + Set companyIds = jobPage.getContent().stream() + .map(Job::getCompanyId) + .collect(Collectors.toSet()); + Map companyMap = companyIds.isEmpty() + ? Map.of() + : companyRepository.findAllById(companyIds).stream() + .collect(Collectors.toMap(Company::getId, c -> c)); + + return jobPage.map(job -> toSearchItem(job, companyMap.get(job.getCompanyId()))); + } + + /** + * 使用 CriteriaQuery + CASE WHEN 实现 salaryMax DESC NULLS LAST。 + */ + Page findWithSalarySort(Specification spec, int page, int size) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + + // 查询 + CriteriaQuery cq = cb.createQuery(Job.class); + Root root = cq.from(Job.class); + Predicate predicate = spec.toPredicate(root, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + // CASE WHEN salaryMax IS NULL THEN 1 ELSE 0 END → NULL 排最后 + cq.orderBy( + cb.asc(cb.selectCase() + .when(cb.isNull(root.get("salaryMax")), 1) + .otherwise(0)), + cb.desc(root.get("salaryMax")), + cb.desc(root.get("id")) + ); + + TypedQuery query = entityManager.createQuery(cq); + query.setFirstResult(page * size); + query.setMaxResults(size); + List jobs = query.getResultList(); + + // 计数 + CriteriaQuery countCq = cb.createQuery(Long.class); + Root countRoot = countCq.from(Job.class); + Predicate countPredicate = spec.toPredicate(countRoot, countCq, cb); + if (countPredicate != null) { + countCq.where(countPredicate); + } + countCq.select(cb.count(countRoot)); + Long total = entityManager.createQuery(countCq).getSingleResult(); + + return new PageImpl<>(jobs, PageRequest.of(page, size), total); + } + + private JobSearchItem toSearchItem(Job job, Company company) { + JobSearchItem.CompanyInfo info = null; + if (company != null) { + info = JobSearchItem.CompanyInfo.builder() + .id(company.getId()) + .name(company.getName()) + .logoUrl(company.getLogoUrl()) + .verified(company.getVerified()) + .build(); + } + return JobSearchItem.builder() + .id(job.getId()) + .title(job.getTitle()) + .description(job.getDescription()) + .requirements(job.getRequirements()) + .city(job.getCity()) + .employmentType(job.getEmploymentType()) + .salaryMin(job.getSalaryMin()) + .salaryMax(job.getSalaryMax()) + .status(job.getStatus()) + .publishedAt(job.getPublishedAt()) + .createdAt(job.getCreatedAt()) + .company(info) + .build(); + } + + // ── 筛选选项 ──────────────────────────────────────────────── + + /** + * 获取筛选选项:仅基于 PUBLISHED 岗位,使用数据库聚合查询,不加载全部岗位到内存。 + */ + public Map getFilterOptions() { + List cities = jobRepository.findDistinctCitiesPublished(); + List employmentTypes = jobRepository.findDistinctEmploymentTypesPublished(); + Integer minSalary = jobRepository.findMinSalaryPublished(); + Integer maxSalary = jobRepository.findMaxSalaryPublished(); + + Map salaryRange = new HashMap<>(); + salaryRange.put("min", minSalary); + salaryRange.put("max", maxSalary); + + Map result = new HashMap<>(); + result.put("cities", cities != null ? cities : Collections.emptyList()); + result.put("employmentTypes", employmentTypes != null ? employmentTypes : Collections.emptyList()); + result.put("salaryRange", salaryRange); + return result; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobStatus.java new file mode 100644 index 0000000..8d235fa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobStatus.java @@ -0,0 +1,14 @@ +package com.mydreamjob.job; + +/** + * 岗位状态枚举 + * + * DRAFT — 草稿,未对外展示 + * PUBLISHED — 已发布,学生可见 + * CLOSED — 已关闭,学生仍可查看详情但不接受新投递 + */ +public enum JobStatus { + DRAFT, + PUBLISHED, + CLOSED +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java new file mode 100644 index 0000000..69bcef9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java @@ -0,0 +1,33 @@ +package com.mydreamjob.job.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 创建/编辑岗位的请求 DTO + */ +@Data +public class JobCreateRequest { + + @NotBlank(message = "job title is required") + @Size(max = 120, message = "title must be at most 120 characters") + private String title; + + @NotBlank(message = "job description is required") + private String description; + + private String requirements; + + @NotBlank(message = "city is required") + @Size(max = 80, message = "city must be at most 80 characters") + private String city; + + @NotBlank(message = "employment type is required") + @Size(max = 30, message = "employment type must be at most 30 characters") + private String employmentType; + + private Integer salaryMin; + + private Integer salaryMax; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java new file mode 100644 index 0000000..a4b6849 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java @@ -0,0 +1,47 @@ +package com.mydreamjob.job.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 公开岗位搜索项 DTO。 + * 仅暴露公开字段,不泄露 ownerUserId、内部时间戳、审核信息等。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class JobSearchItem { + + private Long id; + private String title; + private String description; + private String requirements; + private String city; + private String employmentType; + private Integer salaryMin; + private Integer salaryMax; + private String status; + private LocalDateTime publishedAt; + private LocalDateTime createdAt; + + private CompanyInfo company; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CompanyInfo { + private Long id; + private String name; + private String logoUrl; + private Boolean verified; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobStatusRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobStatusRequest.java new file mode 100644 index 0000000..a1f0101 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobStatusRequest.java @@ -0,0 +1,16 @@ +package com.mydreamjob.job.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 发布/关闭岗位状态变更请求 DTO + */ +@Data +public class JobStatusRequest { + + @NotBlank(message = "status is required") + @Pattern(regexp = "^(PUBLISHED|CLOSED)$", message = "status must be PUBLISHED or CLOSED") + private String status; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java new file mode 100644 index 0000000..a535431 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java @@ -0,0 +1,29 @@ +package com.mydreamjob.job.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 更新岗位(部分字段)的请求 DTO + */ +@Data +public class JobUpdateRequest { + + @Size(max = 120, message = "title must be at most 120 characters") + private String title; + + private String description; + + private String requirements; + + @Size(max = 80, message = "city must be at most 80 characters") + private String city; + + @Size(max = 30, message = "employment type must be at most 30 characters") + private String employmentType; + + private Integer salaryMin; + + private Integer salaryMax; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java new file mode 100644 index 0000000..f6e63f0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java @@ -0,0 +1,45 @@ +package com.mydreamjob.match; + +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; + +@Tag(name = "岗位匹配", description = "学生查看岗位匹配诊断(确定性规则)") +@RestController +@RequestMapping("/jobs") +@RequiredArgsConstructor +public class MatchController { + + private final MatchService matchService; + + @Operation(summary = "岗位匹配诊断", description = "仅 STUDENT;返回匹配分、硬性条件、缺口和说明") + @GetMapping("/{jobId}/match") + public ResponseEntity>> diagnose( + @PathVariable Long jobId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map result = matchService.diagnose(userId, jobId, + com.mydreamjob.audit.AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(result, 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java new file mode 100644 index 0000000..d9747be --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java @@ -0,0 +1,160 @@ +package com.mydreamjob.match; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.preference.StudentPreference; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 岗位匹配诊断 v0.1 - 确定性规则匹配(无 AI) + * 规则版本 RULES_V0_1 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MatchService { + + private final JobService jobService; + private final StudentPreferenceRepository preferenceRepository; + private final StudentProfileRepository profileRepository; + private final AuditService auditService; + private static final ObjectMapper mapper = new ObjectMapper(); + private static final String RULES_VERSION = "RULES_V0_1"; + + // ── 匹配规则 ────────────────────────────────────────────── + + public Map diagnose(Long studentUserId, Long jobId) { + return diagnose(studentUserId, jobId, null); + } + + public Map diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) { + // 岗位必须存在 + Job job = jobService.getJob(jobId); + + StudentProfile profile = profileRepository.findByUserId(studentUserId).orElse(null); + StudentPreference preference = preferenceRepository.findByStudentUserId(studentUserId).orElse(null); + + List matched = new ArrayList<>(); + List gaps = new ArrayList<>(); + int score = 0; + + // 规则1:城市匹配(硬性) + if (job.getCity() != null && preference != null + && preference.getPreferredCities() != null + && containsIgnoreCase(preference.getPreferredCities(), job.getCity())) { + score += 30; + matched.add("城市符合偏好"); + } else if (preference != null && preference.getPreferredCities() != null + && !preference.getPreferredCities().isBlank()) { + gaps.add("城市不在偏好列表中"); + } else { + gaps.add("未设置城市偏好"); + } + + // 规则2:用工类型匹配 + if (job.getEmploymentType() != null && preference != null + && preference.getEmploymentTypes() != null + && containsIgnoreCase(preference.getEmploymentTypes(), job.getEmploymentType())) { + score += 20; + matched.add("用工类型符合偏好"); + } else { + gaps.add("用工类型与偏好不一致"); + } + + // 规则3:薪资期望匹配(硬性:期望下限 <= 岗位上限) + boolean salaryOk = true; + if (job.getSalaryMax() != null && preference != null && preference.getExpectedSalaryMin() != null + && preference.getExpectedSalaryMin() > job.getSalaryMax()) { + salaryOk = false; + gaps.add("期望薪资下限高于岗位薪资上限"); + } else if (preference == null || preference.getExpectedSalaryMin() == null) { + salaryOk = false; + gaps.add("未设置期望薪资"); + } else { + score += 20; + matched.add("薪资期望在可接受范围"); + } + + // 规则4:行业偏好匹配(若有设置) + if (preference != null && preference.getPreferredIndustries() != null + && !preference.getPreferredIndustries().isBlank()) { + // 行业字段:job 没有 industry,用 description 关键词近似 + String desc = job.getDescription() != null ? job.getDescription().toLowerCase() : ""; + boolean industryMatch = preference.getPreferredIndustries().toLowerCase() + .contains(desc.substring(0, Math.min(30, desc.length()))); + // 简化:行业偏好为参考项,不参与硬性 + if (industryMatch) { score += 10; matched.add("行业偏好匹配"); } + } + + // 规则5:岗位类型偏好(参考项) + if (preference != null && preference.getPreferredJobTypes() != null + && !preference.getPreferredJobTypes().isBlank()) { + String title = job.getTitle() != null ? job.getTitle().toLowerCase() : ""; + if (preference.getPreferredJobTypes().toLowerCase().contains(title)) { + score += 10; matched.add("岗位类型偏好匹配"); + } + } + + // 硬性条件判定 + boolean hardEligible = salaryOk && !gaps.contains("城市不在偏好列表中"); + + // 空档案处理 + if (profile == null && preference == null) { + gaps.add("尚未完善档案与偏好"); + score = 0; + } + + int cappedScore = Math.min(score, 100); + boolean eligible = hardEligible && job.getStatus().equals("PUBLISHED"); + + StringBuilder explanation = new StringBuilder(); + if (!eligible) { + if (!job.getStatus().equals("PUBLISHED")) explanation.append("岗位未发布,不可投递。"); + if (!hardEligible) { + for (String g : gaps) { + if (g.contains("薪资") || g.contains("城市")) explanation.append("硬性条件不满足:").append(g).append("。"); + } + } + if (explanation.length() == 0) explanation.append("存在匹配缺口。"); + } else { + explanation.append("已满足硬性条件,可投递。"); + } + + Map result = new HashMap<>(); + result.put("matchScore", cappedScore); + result.put("eligible", eligible); + result.put("matchedConditions", matched); + result.put("gaps", gaps); + result.put("explanation", explanation.toString()); + result.put("rulesVersion", RULES_VERSION); + + // 审计日志(携带请求上下文) + auditService.log(studentUserId, "MATCH_DIAGNOSE", "JOB", jobId, + "Match diagnostic: score=" + cappedScore + ", eligible=" + eligible + + ", version=" + RULES_VERSION, + ctx); + return result; + } + + private boolean containsIgnoreCase(String jsonArray, String target) { + try { + List list = mapper.readValue(jsonArray, new TypeReference>() {}); + return list.stream().anyMatch(s -> s.equalsIgnoreCase(target)); + } catch (Exception e) { + return jsonArray.toLowerCase().contains(target.toLowerCase()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Conversation.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Conversation.java new file mode 100644 index 0000000..8abe667 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Conversation.java @@ -0,0 +1,44 @@ +package com.mydreamjob.messaging; + +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 = "conversations") +@EntityListeners(AuditingEntityListener.class) +public class Conversation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 200) + private String title; + + @Column(nullable = false, length = 20) + @Builder.Default + private String type = "DIRECT"; + + @Column(name = "created_by", nullable = false) + private Long createdBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMember.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMember.java new file mode 100644 index 0000000..ee5c21c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMember.java @@ -0,0 +1,37 @@ +package com.mydreamjob.messaging; + +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 = "conversation_members", + uniqueConstraints = @UniqueConstraint(columnNames = {"conversation_id", "user_id"})) +public class ConversationMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "conversation_id", nullable = false) + private Long conversationId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "joined_at", nullable = false) + private LocalDateTime joinedAt; + + @PrePersist + void prePersist() { + if (joinedAt == null) joinedAt = LocalDateTime.now(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java new file mode 100644 index 0000000..0b26a28 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java @@ -0,0 +1,17 @@ +package com.mydreamjob.messaging; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ConversationMemberRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByConversationIdAndUserId(Long conversationId, Long userId); + + Optional findByConversationIdAndUserId(Long conversationId, Long userId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java new file mode 100644 index 0000000..7950d42 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java @@ -0,0 +1,13 @@ +package com.mydreamjob.messaging; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ConversationRepository extends JpaRepository { + + List findByCreatedByOrderByUpdatedAtDesc(Long userId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java new file mode 100644 index 0000000..0dc43c3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java @@ -0,0 +1,38 @@ +package com.mydreamjob.messaging; + +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 = "messages") +@EntityListeners(AuditingEntityListener.class) +public class Message { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "conversation_id", nullable = false) + private Long conversationId; + + @Column(name = "sender_user_id", nullable = false) + private Long senderUserId; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRead.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRead.java new file mode 100644 index 0000000..527673b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRead.java @@ -0,0 +1,37 @@ +package com.mydreamjob.messaging; + +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 = "message_reads", + uniqueConstraints = @UniqueConstraint(columnNames = {"message_id", "reader_user_id"})) +public class MessageRead { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "message_id", nullable = false) + private Long messageId; + + @Column(name = "reader_user_id", nullable = false) + private Long readerUserId; + + @Column(name = "read_at", nullable = false) + private LocalDateTime readAt; + + @PrePersist + void prePersist() { + if (readAt == null) readAt = LocalDateTime.now(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageReadRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageReadRepository.java new file mode 100644 index 0000000..3f5cba2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageReadRepository.java @@ -0,0 +1,16 @@ +package com.mydreamjob.messaging; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MessageReadRepository extends JpaRepository { + + boolean existsByMessageIdAndReaderUserId(Long messageId, Long readerUserId); + + List findByMessageIdInAndReaderUserId(List messageIds, Long readerUserId); + + void deleteByMessageId(Long messageId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java new file mode 100644 index 0000000..a752435 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.messaging; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MessageRepository extends JpaRepository { + + Page findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java new file mode 100644 index 0000000..2cbd3bf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java @@ -0,0 +1,95 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.messaging.dto.CreateConversationRequest; +import com.mydreamjob.messaging.dto.SendMessageRequest; +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; + +@Tag(name = "消息", description = "会话创建、消息发送、已读管理") +@RestController +@RequestMapping("/conversations") +@RequiredArgsConstructor +public class MessagingController { + + private final MessagingService messagingService; + + @Operation(summary = "会话列表", description = "当前用户参与的所有会话") + @GetMapping + public ResponseEntity>>> listConversations(HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(messagingService.listConversations(userId), getRequestId(request))); + } + + @Operation(summary = "会话详情", description = "查看会话信息和未读数") + @GetMapping("/{id}") + public ResponseEntity>> getConversation( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(messagingService.getConversation(id, userId), getRequestId(request))); + } + + @Operation(summary = "创建/获取会话", description = "创建或返回与另一用户的 DIRECT 会话") + @PostMapping + public ResponseEntity>> createConversation( + @Valid @RequestBody CreateConversationRequest req, HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok( + messagingService.createOrGetDirectConversation(userId, req), getRequestId(request))); + } + + @Operation(summary = "发送消息", description = "在会话中发送消息,仅成员可发送") + @PostMapping("/messages") + public ResponseEntity>> sendMessage( + @Valid @RequestBody SendMessageRequest req, HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(messagingService.sendMessage(userId, req), getRequestId(request))); + } + + @Operation(summary = "消息列表", description = "分页查询会话消息,倒序;仅成员可查看") + @GetMapping("/{id}/messages") + public ResponseEntity>> listMessages( + @PathVariable Long id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page> result = messagingService.listMessages(id, userId, page, size); + Map 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))); + } + + @Operation(summary = "标记已读", description = "将当前会话所有消息标记为已读") + @PostMapping("/{id}/read") + public ResponseEntity>> markRead( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(request); + messagingService.markRead(id, userId); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), 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"); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java new file mode 100644 index 0000000..930ded3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -0,0 +1,176 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; +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.messaging.dto.CreateConversationRequest; +import com.mydreamjob.messaging.dto.SendMessageRequest; +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.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessagingService { + + private final ConversationRepository conversationRepository; + private final ConversationMemberRepository memberRepository; + private final MessageRepository messageRepository; + private final MessageReadRepository readRepository; + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final ApplicationMatchRecordRepository applicationMatchRecordRepository; + + private static final int MAX_PAGE_SIZE = 50; + private static final int MAX_CONTENT_LENGTH = 2000; + + private void assertMember(Long conversationId, Long userId) { + if (!memberRepository.existsByConversationIdAndUserId(conversationId, userId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + public List> listConversations(Long userId) { + List memberships = memberRepository.findByUserId(userId); + if (memberships.isEmpty()) return List.of(); + List convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); + List convs = conversationRepository.findAllById(convIds); + convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt())); + List> result = new ArrayList<>(); + for (Conversation c : convs) { + Map v = toView(c); + v.put("unreadCount", countUnread(c.getId(), userId)); + result.add(v); + } + return result; + } + + public Map getConversation(Long conversationId, Long userId) { + assertMember(conversationId, userId); + Conversation c = conversationRepository.findById(conversationId) + .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST)); + Map v = toView(c); + v.put("unreadCount", countUnread(conversationId, userId)); + return v; + } + + @Transactional + public Map createOrGetDirectConversation(Long userId, CreateConversationRequest req) { + Long otherId = req.getParticipantUserId(); + if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST); + if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND); + // 已有 DIRECT 会话 → 返回原会话(历史会话保持可访问,不删除、不失效) + List my = memberRepository.findByUserId(userId); + for (ConversationMember m : my) { + if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherId)) { + Conversation existing = conversationRepository.findById(m.getConversationId()).orElse(null); + if (existing != null && "DIRECT".equals(existing.getType())) return toView(existing); + } + } + // 新建会话:企业-求职者组合必须先存在双向匹配成功的投递,防止绕过匹配解锁 + assertCompanyCandidatePairHasMatchedApplication(userId, otherId); + 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(otherId).build()); + return toView(c); + } + + /** + * 防绕过守卫:仅当双方为"企业账号 + 求职者账号"且确实要新建会话时,要求存在一条 + * 同时归属双方且状态为 MATCHED 的投递;否则 403。 + * 学生-学生、企业-企业及其他组合保持原语义;无法判定身份时(上游已校验存在性)防御性放行。 + */ + private void assertCompanyCandidatePairHasMatchedApplication(Long userId, Long otherId) { + User me = userRepository.findById(userId).orElse(null); + User other = userRepository.findById(otherId).orElse(null); + if (me == null || other == null) return; + boolean meCompany = hasRole(me, User.Role.COMPANY); + boolean meStudent = hasRole(me, User.Role.STUDENT); + boolean otherCompany = hasRole(other, User.Role.COMPANY); + boolean otherStudent = hasRole(other, User.Role.STUDENT); + boolean companyCandidatePair = (meCompany && otherStudent) || (meStudent && otherCompany); + if (!companyCandidatePair) return; + + Long companyUserId = meCompany ? userId : otherId; + Long studentUserId = meCompany ? otherId : userId; + Long companyId = companyRepository.findByOwnerUserId(companyUserId) + .map(Company::getId).orElse(null); + if (companyId == null + || !applicationMatchRecordRepository.existsMatchedApplication(companyId, studentUserId)) { + throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); + } + } + + private boolean hasRole(User user, User.Role role) { + return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); + } + + @Transactional + public Map sendMessage(Long userId, SendMessageRequest req) { + assertMember(req.getConversationId(), userId); + if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST); + Message msg = messageRepository.save(Message.builder() + .conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent()).build()); + conversationRepository.findById(req.getConversationId()).ifPresent(c -> { + c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); + }); + return toView(msg); + } + + public Page> listMessages(Long conversationId, Long userId, int page, int size) { + assertMember(conversationId, userId); + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); + return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size)) + .map(m -> { + Map v = toView(m); + v.put("isRead", readRepository.existsByMessageIdAndReaderUserId(m.getId(), userId)); + return v; + }); + } + + @Transactional + public void markRead(Long conversationId, Long userId) { + assertMember(conversationId, userId); + List msgs = messageRepository + .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); + List msgIds = msgs.stream().map(Message::getId).toList(); + List existing = readRepository.findByMessageIdInAndReaderUserId(msgIds, userId); + Set existingIds = new HashSet<>(); + for (MessageRead r : existing) existingIds.add(r.getMessageId()); + for (Long mid : msgIds) { + if (!existingIds.contains(mid)) readRepository.save( + MessageRead.builder().messageId(mid).readerUserId(userId).build()); + } + } + + private long countUnread(Long convId, Long userId) { + List msgs = messageRepository + .findByConversationIdOrderByCreatedAtDesc(convId, PageRequest.of(0, 500)).getContent(); + if (msgs.isEmpty()) return 0; + List msgIds = msgs.stream().map(Message::getId).toList(); + return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size(); + } + + public Map toView(Conversation c) { + Map 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; + } + + public Map toView(Message m) { + Map v = new HashMap<>(); + v.put("id", m.getId()); v.put("conversationId", m.getConversationId()); + v.put("senderUserId", m.getSenderUserId()); v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt()); + return v; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/CreateConversationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/CreateConversationRequest.java new file mode 100644 index 0000000..acc8d3f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/CreateConversationRequest.java @@ -0,0 +1,11 @@ +package com.mydreamjob.messaging.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CreateConversationRequest { + + @NotNull(message = "participantUserId is required") + private Long participantUserId; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java new file mode 100644 index 0000000..22bdcb7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java @@ -0,0 +1,17 @@ +package com.mydreamjob.messaging.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class SendMessageRequest { + + @NotNull(message = "conversationId is required") + private Long conversationId; + + @NotBlank(message = "content is required") + @Size(max = 2000, message = "content must not exceed 2000 characters") + private String content; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NoOpPushProvider.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NoOpPushProvider.java new file mode 100644 index 0000000..a1574ca --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NoOpPushProvider.java @@ -0,0 +1,19 @@ +package com.mydreamjob.notification; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 默认无操作推送提供者:仅记录日志,不接入真实第三方。 + * 后续可通过实现 PushProvider 接口替换(短信/App 推送)。 + */ +@Slf4j +@Component +public class NoOpPushProvider implements PushProvider { + + @Override + public boolean send(Long userId, String title, String content) { + log.info("[PUSH-MOCK] userId={}, title={}", userId, title); + return true; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java new file mode 100644 index 0000000..1b69b18 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java @@ -0,0 +1,61 @@ +package com.mydreamjob.notification; + +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 = "notifications", + uniqueConstraints = @UniqueConstraint(name = "uk_notifications_idempotency", + columnNames = {"idempotency_key"})) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 30) + private String type; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "text") + private String content; + + @Column(name = "business_type", length = 50) + private String businessType; + + @Column(name = "business_id") + private Long businessId; + + @Column(name = "idempotency_key", length = 100) + private String idempotencyKey; + + @Column(name = "is_read", nullable = false) + @Builder.Default + private Boolean read = false; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (read == null) read = false; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationConsumer.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationConsumer.java new file mode 100644 index 0000000..db9accf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationConsumer.java @@ -0,0 +1,65 @@ +package com.mydreamjob.notification; + +import com.mydreamjob.notification.NotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +/** + * 通知事件消费者:落库 + 幂等 + 失败重试 + * 通过 app.notification.mq-enabled 控制是否启用(测试中禁用) + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "app.notification.mq-enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class NotificationConsumer { + + private final NotificationRepository notificationRepository; + private final PushProvider pushProvider; + private final org.springframework.retry.support.RetryTemplate notificationRetryTemplate; + + @RabbitListener(queues = RabbitQueueNames.NOTIFICATION_QUEUE) + public void handle(NotificationEvent event) { + if (event == null) return; + // 失败重试:指数退避最多 3 次 + notificationRetryTemplate.execute(context -> { + process(event); + return null; + }); + } + + private void process(NotificationEvent event) { + // 幂等:已存在相同 idempotency_key 则跳过 + if (event.getIdempotencyKey() != null + && notificationRepository.existsByIdempotencyKey(event.getIdempotencyKey())) { + log.info("Duplicate notification event skipped: key={}", event.getIdempotencyKey()); + return; + } + + try { + Notification n = Notification.builder() + .userId(event.getUserId()) + .type(event.getType()) + .title(event.getTitle()) + .content(event.getContent()) + .businessType(event.getBusinessType()) + .businessId(event.getBusinessId()) + .idempotencyKey(event.getIdempotencyKey()) + .read(false) + .build(); + notificationRepository.save(n); + try { + pushProvider.send(event.getUserId(), event.getTitle(), event.getContent()); + } catch (Exception e) { + log.warn("Push failed for notification: {}", e.getMessage()); + } + } catch (DataIntegrityViolationException e) { + // 并发插入唯一键冲突 → 视为已处理 + log.info("Idempotency key conflict (already processed): {}", event.getIdempotencyKey()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java new file mode 100644 index 0000000..2951e31 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java @@ -0,0 +1,80 @@ +package com.mydreamjob.notification; + +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.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; +import java.util.stream.Collectors; + +@Tag(name = "通知", description = "站内通知查询、已读管理") +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "通知列表", description = "当前用户的通知,分页,按时间倒序") + @GetMapping + public ResponseEntity>> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page result = notificationService.list(userId, page, size); + Map data = new HashMap<>(); + data.put("items", result.getContent().stream().map(notificationService::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 = "标记单条通知已读(幂等)") + @PostMapping("/{id}/read") + public ResponseEntity>> markRead( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + notificationService.markRead(userId, id); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "marked as read"), getRequestId(request))); + } + + @Operation(summary = "全部已读", description = "标记当前用户全部通知已读(幂等)") + @PostMapping("/read-all") + public ResponseEntity>> markAllRead(HttpServletRequest request) { + Long userId = resolveUserId(request); + int marked = notificationService.markAllRead(userId); + return ResponseEntity.ok(ApiResponse.ok(Map.of("marked", marked), getRequestId(request))); + } + + @Operation(summary = "未读数", description = "当前用户未读通知数") + @GetMapping("/unread-count") + public ResponseEntity>> unreadCount(HttpServletRequest request) { + Long userId = resolveUserId(request); + long count = notificationService.unreadCount(userId); + return ResponseEntity.ok(ApiResponse.ok(Map.of("unreadCount", count), 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationEvent.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationEvent.java new file mode 100644 index 0000000..79b4f47 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationEvent.java @@ -0,0 +1,26 @@ +package com.mydreamjob.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 通知事件消息(RabbitMQ 传递) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationEvent implements Serializable { + + private Long userId; + private String type; + private String title; + private String content; + private String businessType; + private Long businessId; + private String idempotencyKey; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationPublisher.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationPublisher.java new file mode 100644 index 0000000..41e5e3c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationPublisher.java @@ -0,0 +1,33 @@ +package com.mydreamjob.notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +/** + * 通知事件发布器:通过 RabbitMQ TopicExchange 发布通知事件 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationPublisher { + + private final RabbitTemplate rabbitTemplate; + + /** + * 发布通知事件 + */ + public void publish(NotificationEvent event) { + try { + rabbitTemplate.convertAndSend( + RabbitQueueNames.NOTIFICATION_EXCHANGE, + RabbitQueueNames.NOTIFICATION_ROUTING_KEY, + event); + log.info("Notification event published: key={}, type={}", event.getIdempotencyKey(), event.getType()); + } catch (Exception e) { + // 发布失败不阻断主业务 + log.error("Failed to publish notification event: {}", e.getMessage()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java new file mode 100644 index 0000000..5e421a6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java @@ -0,0 +1,19 @@ +package com.mydreamjob.notification; + +import org.springframework.data.domain.Page; +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; + +@Repository +public interface NotificationRepository extends JpaRepository { + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + @Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false") + long countUnreadByUserId(@Param("userId") Long userId); + + boolean existsByIdempotencyKey(String idempotencyKey); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java new file mode 100644 index 0000000..0d4f049 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java @@ -0,0 +1,136 @@ +package com.mydreamjob.notification; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +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.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final PushProvider pushProvider; + private final NotificationPublisher notificationPublisher; + + private static final int MAX_PAGE_SIZE = 50; + + /** 创建通知并触发推送 */ + @Transactional + public Notification create(Long userId, String type, String title, String content) { + return create(userId, type, title, content, null, null, null); + } + + /** + * 创建通知(带业务关联和幂等键) + */ + @Transactional + public Notification create(Long userId, String type, String title, String content, + String businessType, Long businessId, String idempotencyKey) { + if (idempotencyKey != null && notificationRepository.existsByIdempotencyKey(idempotencyKey)) { + log.info("Duplicate notification skipped: key={}", idempotencyKey); + return null; + } + Notification n = Notification.builder() + .userId(userId).type(type).title(title).content(content) + .businessType(businessType).businessId(businessId) + .idempotencyKey(idempotencyKey).read(false) + .build(); + try { + n = notificationRepository.save(n); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.info("Idempotency key conflict: {}", idempotencyKey); + return null; + } + try { + pushProvider.send(userId, title, content); + } catch (Exception e) { + log.warn("Push failed for notification id={}: {}", n.getId(), e.getMessage()); + } + return n; + } + + /** + * 发布通知事件到 RabbitMQ(异步落库) + */ + public void publishEvent(Long userId, String type, String title, String content, + String businessType, Long businessId, String idempotencyKey) { + notificationPublisher.publish(NotificationEvent.builder() + .userId(userId).type(type).title(title).content(content) + .businessType(businessType).businessId(businessId) + .idempotencyKey(idempotencyKey) + .build()); + } + + /** 分页查询用户通知 */ + public Page list(Long userId, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); + return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); + } + + /** 标记单条已读(幂等:已读也返回成功) */ + @Transactional + public void markRead(Long userId, Long notificationId) { + Notification n = notificationRepository.findById(notificationId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); + if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN); + if (!Boolean.TRUE.equals(n.getRead())) { + n.setRead(true); + n.setReadAt(LocalDateTime.now()); + notificationRepository.save(n); + } + } + + /** 标记全部已读(幂等) */ + @Transactional + public int markAllRead(Long userId) { + int count = 0; + int pageIdx = 0; + boolean hasMore = true; + while (hasMore) { + Page unread = notificationRepository + .findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(pageIdx, 100)); + if (unread.isEmpty()) { hasMore = false; break; } + boolean anyUpdated = false; + for (Notification n : unread.getContent()) { + if (!Boolean.TRUE.equals(n.getRead())) { + n.setRead(true); + n.setReadAt(LocalDateTime.now()); + notificationRepository.save(n); + count++; + anyUpdated = true; + } + } + hasMore = !unread.isLast(); + pageIdx++; + } + return count; + } + + /** 未读数 */ + public long unreadCount(Long userId) { + return notificationRepository.countUnreadByUserId(userId); + } + + public Map toView(Notification n) { + Map v = new HashMap<>(); + v.put("id", n.getId()); + v.put("type", n.getType()); + v.put("title", n.getTitle()); + v.put("content", n.getContent()); + v.put("read", n.getRead()); + v.put("readAt", n.getReadAt()); + v.put("createdAt", n.getCreatedAt()); + return v; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/PushProvider.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/PushProvider.java new file mode 100644 index 0000000..3e4cc9a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/PushProvider.java @@ -0,0 +1,12 @@ +package com.mydreamjob.notification; + +/** + * 推送提供者接口 - 先不接短信/App 推送,保留可替换扩展点 + */ +public interface PushProvider { + + /** + * 发送推送,返回是否成功 + */ + boolean send(Long userId, String title, String content); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/RabbitQueueNames.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/RabbitQueueNames.java new file mode 100644 index 0000000..f474c8e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/RabbitQueueNames.java @@ -0,0 +1,13 @@ +package com.mydreamjob.notification; + +/** + * RabbitMQ 通知相关队列/交换机/路由常量 + */ +public final class RabbitQueueNames { + + private RabbitQueueNames() {} + + public static final String NOTIFICATION_QUEUE = "queue.notification"; + public static final String NOTIFICATION_EXCHANGE = "exchange.notification.topic"; + public static final String NOTIFICATION_ROUTING_KEY = "notification.created"; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java new file mode 100644 index 0000000..a6d0f2c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java @@ -0,0 +1,109 @@ +package com.mydreamjob.offer; + +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; + +/** + * Offer(录用邀请)实体 + * + *

状态:DRAFT / SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED。 + * {@code activeKey} 为可空判别列,配合唯一索引实现"每个投递最多一个有效 Offer": + * 非终态时为 {@code ACTIVE},REJECTED/EXPIRED 时置 {@code null} 释放槽位。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "offers", + uniqueConstraints = @UniqueConstraint(name = "uk_offers_active", + columnNames = {"application_id", "active_offer_key"})) +@EntityListeners(AuditingEntityListener.class) +public class Offer { + + @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 = "title", nullable = false, length = 120) + private String title; + + @Column(name = "salary_min") + private Integer salaryMin; + + @Column(name = "salary_max") + private Integer salaryMax; + + @Column(name = "salary_currency", nullable = false, length = 10) + @Builder.Default + private String salaryCurrency = "CNY"; + + @Column(name = "salary_period", nullable = false, length = 10) + @Builder.Default + private String salaryPeriod = "MONTH"; + + @Column(name = "work_city", length = 80) + private String workCity; + + @Column(name = "employment_type", length = 30) + private String employmentType; + + @Column(name = "probation_months", nullable = false) + @Builder.Default + private Integer probationMonths = 0; + + @Column(length = 2000) + private String benefits; + + @Column(name = "description", nullable = false, columnDefinition = "text") + private String description; + + @Column(name = "expire_at", nullable = false) + private LocalDateTime expireAt; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = "DRAFT"; + + @Column(name = "active_offer_key", length = 20) + private String activeKey; + + @Version + @Column(name = "version", nullable = false) + @Builder.Default + private Long version = 0L; + + @Column(name = "created_by", nullable = false) + private Long createdBy; + + @Column(name = "updated_by") + private Long updatedBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java new file mode 100644 index 0000000..c36fa70 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java @@ -0,0 +1,133 @@ +package com.mydreamjob.offer; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.offer.dto.OfferCreateRequest; +import com.mydreamjob.offer.dto.OfferStatusRequest; +import com.mydreamjob.offer.dto.OfferUpdateRequest; +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; + +/** + * Offer 管理接口(录用邀请) + */ +@Tag(name = "Offer", description = "企业录用邀请:创建、发送、接受/拒绝/洽谈、到期失效、提醒与时间线") +@RestController +@RequestMapping("/offers") +@RequiredArgsConstructor +public class OfferController { + + private final OfferService offerService; + + @Operation(summary = "创建 Offer", description = "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT") + @PostMapping + public ResponseEntity>> create( + @Valid @RequestBody OfferCreateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Offer offer = offerService.createOffer(userId, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request))); + } + + @Operation(summary = "Offer 列表", description = "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页") + @GetMapping + public ResponseEntity>> list( + @RequestParam(required = false) String status, + @RequestParam(required = false) Long applicationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page result = offerService.listOffers(userId, status, applicationId, page, size); + Map data = new HashMap<>(); + data.put("items", result.getContent().stream().map(offerService::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 = "Offer 详情", description = "仅目标候选人、所属企业可访问;其他账号返回无权限错误") + @GetMapping("/{id}") + public ResponseEntity>> get( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Offer offer = offerService.getOffer(id, userId); + return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request))); + } + + @Operation(summary = "修改 Offer", description = "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误") + @PutMapping("/{id}") + public ResponseEntity>> update( + @PathVariable Long id, + @Valid @RequestBody OfferUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Offer offer = offerService.updateOffer(userId, id, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request))); + } + + @Operation(summary = "Offer 状态变更", description = "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等") + @PatchMapping("/{id}/status") + public ResponseEntity>> changeStatus( + @PathVariable Long id, + @Valid @RequestBody OfferStatusRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Offer offer = offerService.transitionStatus(userId, id, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), getRequestId(request))); + } + + @Operation(summary = "Offer 状态时间线", description = "完整状态变更历史,含操作者、原因与请求 ID") + @GetMapping("/{id}/timeline") + public ResponseEntity>>> timeline( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List> data = offerService.getTimeline(id, userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "手动提醒", description = "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建") + @PostMapping("/{id}/remind") + public ResponseEntity>> remind( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + OfferReminder reminder = offerService.remind(userId, id, AuditContext.from(request)); + Map data = new HashMap<>(); + data.put("offerId", reminder.getOfferId()); + data.put("reminderType", reminder.getReminderType()); + data.put("status", reminder.getStatus()); + data.put("sentAt", reminder.getSentAt()); + 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 BusinessException(ErrorCode.UNAUTHORIZED); + return Long.valueOf(auth.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryScheduler.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryScheduler.java new file mode 100644 index 0000000..5941012 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryScheduler.java @@ -0,0 +1,42 @@ +package com.mydreamjob.offer; + +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; + +/** + * Offer 定时任务调度:复用项目已有的 Spring Scheduler({@code @EnableScheduling})。 + * + *

通过 {@code app.offer.scheduler.enabled} 控制是否启用(测试环境关闭)。 + * 任务内部已保证幂等与逐 Offer 异常隔离。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.offer.scheduler.enabled", havingValue = "true", matchIfMissing = true) +public class OfferExpiryScheduler { + + private final OfferExpiryService offerExpiryService; + + @Scheduled(fixedDelayString = "${app.offer.scheduler.expiry-interval-ms:60000}") + public void expireDueOffers() { + try { + int count = offerExpiryService.expireDueOffers(); + if (count > 0) log.info("Offer expiry job: expired {} offers", count); + } catch (Exception e) { + log.error("Offer expiry job failed: {}", e.getMessage(), e); + } + } + + @Scheduled(fixedDelayString = "${app.offer.scheduler.reminder-interval-ms:60000}") + public void sendDueReminders() { + try { + int count = offerExpiryService.createDueReminders(); + if (count > 0) log.info("Offer reminder job: created {} reminders", count); + } catch (Exception e) { + log.error("Offer reminder job failed: {}", e.getMessage(), e); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryService.java new file mode 100644 index 0000000..5b671aa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryService.java @@ -0,0 +1,72 @@ +package com.mydreamjob.offer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Offer 定时任务编排:到期失效 + 到期前提醒。 + * + *

本类不开启事务;每个 Offer 的实际处理委托给 {@link OfferExpiryWorker}(独立事务), + * 保证单个 Offer 的异常不会破坏其他 Offer 的状态,也不会因部分失败回滚已完成的变更。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OfferExpiryService { + + private static final List ACTIVE_STATUSES = List.of("SENT", "NEGOTIATING"); + + private final OfferRepository offerRepository; + private final OfferExpiryWorker worker; + + /** 将已到期的 SENT/NEGOTIATING Offer 置为 EXPIRED,返回处理数量 */ + public int expireDueOffers() { + LocalDateTime now = LocalDateTime.now(); + List due = offerRepository.findActiveExpiredBefore(ACTIVE_STATUSES, now); + int count = 0; + for (Offer offer : due) { + try { + worker.expireOne(offer.getId()); + count++; + } catch (OptimisticLockingFailureException e) { + log.info("Offer {} already processed by another run", offer.getId()); + } catch (Exception e) { + log.error("Failed to expire offer {}: {}", offer.getId(), e.getMessage()); + } + } + return count; + } + + /** + * 为即将到期的 Offer 创建 3 天 / 1 天提醒。 + * 已接受/拒绝/已过期的不再提醒;同类型提醒只创建一次。 + */ + public int createDueReminders() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime oneDay = now.plusDays(1); + LocalDateTime threeDays = now.plusDays(3); + List candidates = offerRepository.findActiveExpiringBetween(ACTIVE_STATUSES, now, threeDays); + int count = 0; + for (Offer offer : candidates) { + count += safeCreateReminder(offer.getId(), OfferReminder.TYPE_EXPIRING_3_DAYS, now); + if (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(oneDay)) { + count += safeCreateReminder(offer.getId(), OfferReminder.TYPE_EXPIRING_1_DAY, now); + } + } + return count; + } + + private int safeCreateReminder(Long offerId, String type, LocalDateTime remindAt) { + try { + return worker.createReminderOne(offerId, type, remindAt) ? 1 : 0; + } catch (Exception e) { + log.error("Failed to create reminder offer={} type={}: {}", offerId, type, e.getMessage()); + return 0; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java new file mode 100644 index 0000000..9be7d24 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java @@ -0,0 +1,123 @@ +package com.mydreamjob.offer; + +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.notification.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 定时任务工作单元:每个 Offer 的处理都运行在独立事务中(REQUIRES_NEW)。 + * + *

保证"定时任务重复执行不产生重复记录、单个异常不影响其他 Offer": + * 单个 Offer 失败仅回滚其自身事务;乐观锁冲突表明已被其他实例处理,直接跳过。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OfferExpiryWorker { + + private static final String OFFER_STATUS_SENT = "SENT"; + private static final String OFFER_STATUS_NEGOTIATING = "NEGOTIATING"; + + private final OfferRepository offerRepository; + private final OfferStatusHistoryRepository historyRepository; + private final OfferReminderRepository reminderRepository; + private final CompanyRepository companyRepository; + private final NotificationService notificationService; + + /** 到期自动失效:SENT/NEGOTIATING → EXPIRED */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void expireOne(Long offerId) { + Offer offer = offerRepository.findById(offerId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); + String status = offer.getStatus(); + if (!OFFER_STATUS_SENT.equals(status) && !OFFER_STATUS_NEGOTIATING.equals(status)) { + return; // 已处理过(幂等) + } + offer.setStatus(OfferStatus.EXPIRED.name()); + offer.setActiveKey(null); // 释放"唯一有效 Offer"槽位 + offer.setUpdatedBy(OfferService.SYSTEM_OPERATOR_ID); + Offer saved = offerRepository.saveAndFlush(offer); + historyRepository.save(OfferStatusHistory.builder() + .offerId(saved.getId()) + .fromStatus(status) + .toStatus(OfferStatus.EXPIRED.name()) + .operatorId(OfferService.SYSTEM_OPERATOR_ID) + .operatorRole("SYSTEM") + .reason("Offer expired automatically") + .build()); + notifyExpired(saved); + log.info("Offer {} expired ({})", saved.getId(), status); + } + + /** + * 创建并发送提醒(3 天前 / 1 天前)。 + * 唯一索引 (offer_id, reminder_type) + exists 检查保证"截止提醒只发送一次"。 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean createReminderOne(Long offerId, String type, LocalDateTime remindAt) { + Offer offer = offerRepository.findById(offerId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); + if (!OFFER_STATUS_SENT.equals(offer.getStatus()) + && !OFFER_STATUS_NEGOTIATING.equals(offer.getStatus())) { + return false; // 已接受/拒绝/过期的不再提醒 + } + if (reminderRepository.existsByOfferIdAndReminderType(offerId, type)) { + return false; // 幂等 + } + OfferReminder reminder = OfferReminder.builder() + .offerId(offerId) + .reminderType(type) + .remindAt(remindAt) + .sentAt(LocalDateTime.now()) + .status(OfferReminder.STATUS_SENT) + .build(); + try { + reminderRepository.saveAndFlush(reminder); + } catch (OptimisticLockingFailureException | DataIntegrityViolationException e) { + log.info("Reminder skipped (already exists): offer={}, type={}", offerId, type); + return false; + } + try { + notificationService.publishEvent(offer.getCandidateId(), "OFFER_REMINDER", + "Offer 到期提醒", + "你的 Offer #" + offerId + " 将于 " + offer.getExpireAt() + " 到期", + "OFFER", offerId, + "offer-remind-" + type.toLowerCase() + "-" + offerId); + } catch (Exception e) { + log.warn("Reminder notification failed: offer={}, type={}", offerId, type); + } + return true; + } + + private void notifyExpired(Offer offer) { + try { + notificationService.publishEvent(offer.getCandidateId(), "OFFER_EXPIRED", + "Offer 已过期", "你的 Offer #" + offer.getId() + " 已到期失效", + "OFFER", offer.getId(), + "offer-expired-" + offer.getId()); + } catch (Exception e) { + log.warn("Expired notification failed: offer={}", offer.getId()); + } + Company company = companyRepository.findById(offer.getCompanyId()).orElse(null); + if (company == null) return; + try { + notificationService.publishEvent(company.getOwnerUserId(), "OFFER_EXPIRED", + "Offer 已过期", "Offer #" + offer.getId() + " 候选人未在有效期内响应,已自动失效", + "OFFER", offer.getId(), + "offer-expired-" + offer.getId() + "-company"); + } catch (Exception e) { + log.warn("Expired company notification failed: offer={}", offer.getId()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminder.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminder.java new file mode 100644 index 0000000..ab86ead --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminder.java @@ -0,0 +1,64 @@ +package com.mydreamjob.offer; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Offer 提醒记录 + * + *

reminder_type:EXPIRING_3_DAYS / EXPIRING_1_DAY(定时任务)、MANUAL(企业手动)。 + * status:SCHEDULED / SENT / SKIPPED。 + * 唯一约束 (offer_id, reminder_type) 保证同一类型提醒只创建一次。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "offer_reminders", + uniqueConstraints = @UniqueConstraint(name = "uk_offer_reminders", + columnNames = {"offer_id", "reminder_type"})) +public class OfferReminder { + + public static final String TYPE_EXPIRING_3_DAYS = "EXPIRING_3_DAYS"; + public static final String TYPE_EXPIRING_1_DAY = "EXPIRING_1_DAY"; + public static final String TYPE_MANUAL = "MANUAL"; + + public static final String STATUS_SCHEDULED = "SCHEDULED"; + public static final String STATUS_SENT = "SENT"; + public static final String STATUS_SKIPPED = "SKIPPED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "offer_id", nullable = false) + private Long offerId; + + @Column(name = "reminder_type", nullable = false, length = 20) + private String reminderType; + + @Column(name = "remind_at", nullable = false) + private LocalDateTime remindAt; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = "SCHEDULED"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (status == null) status = STATUS_SCHEDULED; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminderRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminderRepository.java new file mode 100644 index 0000000..195f0f0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferReminderRepository.java @@ -0,0 +1,14 @@ +package com.mydreamjob.offer; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface OfferReminderRepository extends JpaRepository { + + Optional findByOfferIdAndReminderType(Long offerId, String reminderType); + + boolean existsByOfferIdAndReminderType(Long offerId, String reminderType); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java new file mode 100644 index 0000000..966d6ad --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java @@ -0,0 +1,55 @@ +package com.mydreamjob.offer; + +import org.springframework.data.domain.Page; +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.Collection; +import java.util.List; + +@Repository +public interface OfferRepository extends JpaRepository { + + // ── 唯一有效 Offer 检查 ──────────────────────────────────── + + boolean existsByApplicationIdAndActiveKeyNotNull(Long applicationId); + + // ── 候选人端列表 ────────────────────────────────────────── + + Page findByCandidateIdOrderByCreatedAtDesc(Long candidateId, Pageable pageable); + + Page findByCandidateIdAndStatusOrderByCreatedAtDesc(Long candidateId, String status, Pageable pageable); + + Page findByCandidateIdAndApplicationIdOrderByCreatedAtDesc(Long candidateId, Long applicationId, Pageable pageable); + + Page findByCandidateIdAndStatusAndApplicationIdOrderByCreatedAtDesc( + Long candidateId, String status, Long applicationId, Pageable pageable); + + // ── 企业端列表 ──────────────────────────────────────────── + + Page findByCompanyIdOrderByCreatedAtDesc(Long companyId, Pageable pageable); + + Page findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status, Pageable pageable); + + Page findByCompanyIdAndApplicationIdOrderByCreatedAtDesc(Long companyId, Long applicationId, Pageable pageable); + + Page findByCompanyIdAndStatusAndApplicationIdOrderByCreatedAtDesc( + Long companyId, String status, Long applicationId, Pageable pageable); + + // ── 定时任务扫描 ────────────────────────────────────────── + + /** 已到期的有效 Offer(SENT/NEGOTIATING 且 expire_at < now) */ + @Query("SELECT o FROM Offer o WHERE o.status IN :statuses AND o.expireAt < :now") + List findActiveExpiredBefore(@Param("statuses") Collection statuses, + @Param("now") LocalDateTime now); + + /** 即将到期的有效 Offer(SENT/NEGOTIATING 且 now < expire_at <= to) */ + @Query("SELECT o FROM Offer o WHERE o.status IN :statuses AND o.expireAt > :from AND o.expireAt <= :to") + List findActiveExpiringBetween(@Param("statuses") Collection statuses, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java new file mode 100644 index 0000000..e036b79 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java @@ -0,0 +1,536 @@ +package com.mydreamjob.offer; + +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 com.mydreamjob.notification.NotificationService; +import com.mydreamjob.offer.dto.OfferCreateRequest; +import com.mydreamjob.offer.dto.OfferStatusRequest; +import com.mydreamjob.offer.dto.OfferUpdateRequest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +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; + +/** + * Offer 管理 Service + * + *

权限模型: + *

    + *
  • 企业只能操作自己公司创建的 Offer(companyId 与当前企业一致);
  • + *
  • 候选人只能查看/响应属于自己的 Offer(candidateId 与当前用户一致);
  • + *
  • 企业不能通过请求参数传入 companyId/candidateId 绕过权限——身份一律来自 JWT。
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OfferService { + + /** 定时任务(系统)操作者占位 userId,不指向真实用户行(history.operator_id 无外键) */ + public static final Long SYSTEM_OPERATOR_ID = 0L; + + private static final String ACTIVE_KEY = "ACTIVE"; + private static final int MAX_PAGE_SIZE = 100; + + private final OfferRepository offerRepository; + private final OfferStatusHistoryRepository historyRepository; + private final OfferReminderRepository reminderRepository; + private final ApplicationRepository applicationRepository; + private final JobService jobService; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final AuditService auditService; + + @PersistenceContext + private EntityManager entityManager; + + /** + * 操作者角色(由 JWT 身份在 Service 层推导,绝不信任请求参数) + */ + private enum ActorRole { COMPANY, CANDIDATE } + + // ── 创建(企业)──────────────────────────────────────────── + + /** + * 企业创建 Offer 草稿。 + * 前置条件:投递存在、岗位属于当前企业、投递状态可发 Offer、该投递无有效 Offer。 + */ + @Transactional + public Offer createOffer(Long operatorUserId, OfferCreateRequest req, AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Application app = applicationRepository.findById(req.getApplicationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + + Job job = jobService.getJob(app.getJobId()); + if (!company.getId().equals(job.getCompanyId())) { + throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); + } + if (!isApplicationEligible(app.getStatus())) { + throw new BusinessException(ErrorCode.OFFER_APPLICATION_NOT_ELIGIBLE); + } + if (offerRepository.existsByApplicationIdAndActiveKeyNotNull(app.getId())) { + throw new BusinessException(ErrorCode.DUPLICATE_OFFER); + } + validateSalaryRange(req.getSalaryMin(), req.getSalaryMax()); + + Offer offer = Offer.builder() + .applicationId(app.getId()) + .jobId(job.getId()) + .companyId(company.getId()) + .candidateId(app.getStudentUserId()) + .title(req.getTitle().trim()) + .salaryMin(req.getSalaryMin()) + .salaryMax(req.getSalaryMax()) + .salaryCurrency(defaultIfBlank(req.getSalaryCurrency(), "CNY")) + .salaryPeriod(defaultIfBlank(req.getSalaryPeriod(), "MONTH")) + .workCity(req.getWorkCity() == null ? null : req.getWorkCity().trim()) + .employmentType(req.getEmploymentType()) + .probationMonths(req.getProbationMonths() == null ? 0 : req.getProbationMonths()) + .benefits(req.getBenefits()) + .description(req.getDescription().trim()) + .expireAt(req.getExpireAt()) + .status(OfferStatus.DRAFT.name()) + .activeKey(ACTIVE_KEY) + .createdBy(operatorUserId) + .updatedBy(operatorUserId) + .build(); + try { + offer = offerRepository.save(offer); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // 唯一索引 uk_offers_active 兜底(并发创建) + log.info("Duplicate active offer detected for application {}", app.getId()); + throw new BusinessException(ErrorCode.DUPLICATE_OFFER); + } + recordHistory(offer.getId(), null, OfferStatus.DRAFT.name(), + operatorUserId, ActorRole.COMPANY.name(), "Offer created", ctx); + auditService.log(operatorUserId, "CREATE_OFFER", "OFFER", offer.getId(), + "Offer created for application " + app.getId(), ctx); + // 创建草稿不发送通知 + return offer; + } + + // ── 查询 ────────────────────────────────────────────────── + + /** + * 列表:候选人查看自己收到的、企业查看自己发出的 Offer。 + * 支持 status、applicationId 筛选,分页,createdAt 倒序。 + */ + public Page listOffers(Long userId, String status, Long applicationId, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); + User user = loadUser(userId); + PageRequest pr = PageRequest.of(page, size); + + if (hasRole(user, User.Role.COMPANY)) { + Company company = companyRepository.findByOwnerUserId(userId).orElse(null); + if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); + return listByCompany(company.getId(), status, applicationId, pr); + } + if (hasRole(user, User.Role.STUDENT)) { + return listByCandidate(userId, status, applicationId, pr); + } + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private Page listByCompany(Long companyId, String status, Long applicationId, PageRequest pr) { + boolean hasStatus = status != null && !status.isBlank(); + if (hasStatus && applicationId != null) + return offerRepository.findByCompanyIdAndStatusAndApplicationIdOrderByCreatedAtDesc( + companyId, status, applicationId, pr); + if (hasStatus) + return offerRepository.findByCompanyIdAndStatusOrderByCreatedAtDesc(companyId, status, pr); + if (applicationId != null) + return offerRepository.findByCompanyIdAndApplicationIdOrderByCreatedAtDesc(companyId, applicationId, pr); + return offerRepository.findByCompanyIdOrderByCreatedAtDesc(companyId, pr); + } + + private Page listByCandidate(Long candidateId, String status, Long applicationId, PageRequest pr) { + boolean hasStatus = status != null && !status.isBlank(); + if (hasStatus && applicationId != null) + return offerRepository.findByCandidateIdAndStatusAndApplicationIdOrderByCreatedAtDesc( + candidateId, status, applicationId, pr); + if (hasStatus) + return offerRepository.findByCandidateIdAndStatusOrderByCreatedAtDesc(candidateId, status, pr); + if (applicationId != null) + return offerRepository.findByCandidateIdAndApplicationIdOrderByCreatedAtDesc(candidateId, applicationId, pr); + return offerRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, pr); + } + + /** 详情:仅目标候选人、所属企业可访问 */ + public Offer getOffer(Long offerId, Long userId) { + Offer offer = requireOffer(offerId); + assertViewAccess(offer, userId); + return offer; + } + + /** 状态时间线:访问规则同详情 */ + public List> getTimeline(Long offerId, Long userId) { + getOffer(offerId, userId); + return historyRepository.findByOfferIdOrderByCreatedAtAsc(offerId) + .stream().map(this::toHistoryView).collect(Collectors.toList()); + } + + // ── 修改(仅 DRAFT,企业)────────────────────────────────── + + /** + * 修改 Offer:仅 DRAFT 状态可修改;其余状态返回业务错误。 + * 身份字段(application/job/company/candidate)不可变更。 + */ + @Transactional + public Offer updateOffer(Long operatorUserId, Long offerId, OfferUpdateRequest req, AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Offer offer = requireOffer(offerId); + if (!company.getId().equals(offer.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + if (!OfferStatus.DRAFT.name().equals(offer.getStatus())) { + throw new BusinessException(ErrorCode.OFFER_NOT_EDITABLE); + } + validateSalaryRange(req.getSalaryMin(), req.getSalaryMax()); + if (req.getExpireAt() != null && !req.getExpireAt().isAfter(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "expireAt must be in the future"); + } + + if (req.getTitle() != null) offer.setTitle(req.getTitle().trim()); + if (req.getSalaryMin() != null) offer.setSalaryMin(req.getSalaryMin()); + if (req.getSalaryMax() != null) offer.setSalaryMax(req.getSalaryMax()); + if (req.getSalaryCurrency() != null) offer.setSalaryCurrency(req.getSalaryCurrency()); + if (req.getSalaryPeriod() != null) offer.setSalaryPeriod(req.getSalaryPeriod()); + if (req.getWorkCity() != null) offer.setWorkCity(req.getWorkCity().trim()); + if (req.getEmploymentType() != null) offer.setEmploymentType(req.getEmploymentType()); + if (req.getProbationMonths() != null) offer.setProbationMonths(req.getProbationMonths()); + if (req.getBenefits() != null) offer.setBenefits(req.getBenefits()); + if (req.getDescription() != null) offer.setDescription(req.getDescription().trim()); + if (req.getExpireAt() != null) offer.setExpireAt(req.getExpireAt()); + offer.setUpdatedBy(operatorUserId); + + offer = offerRepository.save(offer); + auditService.log(operatorUserId, "UPDATE_OFFER", "OFFER", offer.getId(), + "Offer draft updated", ctx); + return offer; + } + + // ── 状态变更 ────────────────────────────────────────────── + + /** + * Offer 状态流转。 + *
    + *
  • 角色:DRAFT→SENT 仅企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 仅候选人;EXPIRED 仅系统。
  • + *
  • 幂等:目标状态与当前一致时直接返回,不产生历史;并发冲突经乐观锁重读判定。
  • + *
  • 过期保护:非 EXPIRED 目标且已过期的 Offer 拒绝流转。
  • + *
+ */ + @Transactional + public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) { + Offer offer = requireOffer(offerId); + User user = loadUser(operatorUserId); + boolean isCompany = hasRole(user, User.Role.COMPANY); + boolean isCandidate = hasRole(user, User.Role.STUDENT); + ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null); + if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN); + + // 访问控制(身份一律来自 JWT) + if (actor == ActorRole.COMPANY) { + Company company = companyRepository.findByOwnerUserId(operatorUserId).orElse(null); + if (company == null || !company.getId().equals(offer.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } else { + if (!offer.getCandidateId().equals(operatorUserId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + OfferStatus from = OfferStatus.valueOf(offer.getStatus()); + OfferStatus to = OfferStatus.valueOf(req.getStatus()); + + // 幂等:已在目标状态,直接返回,不产生重复历史 + if (from == to) return offer; + + if (!from.canTransitionTo(to)) { + throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION); + } + if (!isRoleAllowed(from, to, actor)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + // 过期保护:除系统过期外,已到期 Offer 不可再接受/拒绝/洽谈/发送 + if (to != OfferStatus.EXPIRED && offer.getExpireAt() != null + && !offer.getExpireAt().isAfter(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.OFFER_EXPIRED); + } + + String fromStatus = offer.getStatus(); + Offer saved = applyTransition(offer, to, operatorUserId, actor.name(), req.getReason(), ctx); + notifyStatusChange(saved, fromStatus, to); + auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(), + "Offer status changed: " + fromStatus + " → " + to.name(), ctx); + return saved; + } + + /** + * 应用状态变更并写入历史。 + * 乐观锁:并发下 saveAndFlush 校验 version,冲突时重读——若已在目标状态视为幂等成功。 + */ + private Offer applyTransition(Offer offer, OfferStatus to, Long operatorId, + String operatorRole, String reason, AuditContext ctx) { + String fromStatus = offer.getStatus(); + try { + offer.setStatus(to.name()); + if (to == OfferStatus.REJECTED || to == OfferStatus.EXPIRED) { + offer.setActiveKey(null); // 终态释放"唯一有效 Offer"槽位 + } + offer.setUpdatedBy(operatorId); + Offer saved = offerRepository.saveAndFlush(offer); + recordHistory(saved.getId(), fromStatus, to.name(), + operatorId, operatorRole, reason, ctx); + return saved; + } catch (OptimisticLockingFailureException e) { + // 清除脏上下文,避免提交时对已改脏的 offer 再次 flush 抛出异常 + entityManager.clear(); + Offer fresh = offerRepository.findById(offer.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); + if (fresh.getStatus().equals(to.name())) return fresh; // 并发重复,幂等 + throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION, + "offer was modified concurrently"); + } + } + + // ── 手动提醒(企业)─────────────────────────────────────── + + /** + * 企业手动提醒候选人。 + * 仅未过期 Offer 可提醒;同一 Offer 的 MANUAL 提醒只创建一次(幂等)。 + */ + @Transactional + public OfferReminder remind(Long operatorUserId, Long offerId, AuditContext ctx) { + Company company = requireCompany(operatorUserId); + Offer offer = requireOffer(offerId); + if (!company.getId().equals(offer.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + if (OfferStatus.EXPIRED.name().equals(offer.getStatus()) + || (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now()))) { + throw new BusinessException(ErrorCode.OFFER_EXPIRED); + } + if (!OfferStatus.SENT.name().equals(offer.getStatus()) + && !OfferStatus.NEGOTIATING.name().equals(offer.getStatus())) { + throw new BusinessException(ErrorCode.OFFER_NOT_REMINDABLE); + } + // 幂等:相同提醒不重复创建、不重复通知 + return reminderRepository.findByOfferIdAndReminderType(offerId, OfferReminder.TYPE_MANUAL) + .orElseGet(() -> createAndSendReminder(offer, OfferReminder.TYPE_MANUAL, + LocalDateTime.now(), operatorUserId, ctx)); + } + + /** 创建提醒记录并发送通知(用于定时任务与手动提醒) */ + public OfferReminder createAndSendReminder(Offer offer, String type, LocalDateTime remindAt, + Long operatorId, AuditContext ctx) { + OfferReminder reminder = OfferReminder.builder() + .offerId(offer.getId()) + .reminderType(type) + .remindAt(remindAt) + .sentAt(LocalDateTime.now()) + .status(OfferReminder.STATUS_SENT) + .build(); + try { + reminder = reminderRepository.save(reminder); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.info("Duplicate reminder skipped: offer={}, type={}", offer.getId(), type); + return reminderRepository.findByOfferIdAndReminderType(offer.getId(), type) + .orElse(reminder); + } + try { + notificationService.publishEvent(offer.getCandidateId(), "OFFER_REMINDER", + "Offer 到期提醒", "你的 Offer #" + offer.getId() + " 即将到期,请及时处理", + "OFFER", offer.getId(), + "offer-remind-" + type.toLowerCase() + "-" + offer.getId()); + } catch (Exception e) { + log.warn("Reminder notification failed: offer={}, type={}", offer.getId(), type); + } + if (operatorId != null && operatorId != SYSTEM_OPERATOR_ID) { + auditService.log(operatorId, "REMIND_OFFER", "OFFER", offer.getId(), + "Reminder sent for offer " + offer.getId(), ctx); + } + return reminder; + } + + // ── 通知(状态变更)─────────────────────────────────────── + + private void notifyStatusChange(Offer offer, String fromStatus, OfferStatus to) { + String idempotency = "offer-" + to.name().toLowerCase() + "-" + offer.getId(); + if (to == OfferStatus.SENT) { + // 发送 → 通知候选人 + notificationService.publishEvent(offer.getCandidateId(), "OFFER_SENT", + "收到 Offer", "你收到来自岗位 #" + offer.getJobId() + " 的 Offer,请及时处理", + "OFFER", offer.getId(), idempotency); + return; + } + Company company = companyRepository.findById(offer.getCompanyId()).orElse(null); + if (company == null) return; + String type; + String title; + switch (to) { + case ACCEPTED -> { type = "OFFER_ACCEPTED"; title = "Offer 已接受"; } + case REJECTED -> { type = "OFFER_REJECTED"; title = "Offer 已拒绝"; } + case NEGOTIATING -> { type = "OFFER_NEGOTIATING"; title = "进入洽谈"; } + case EXPIRED -> { type = "OFFER_EXPIRED"; title = "Offer 已过期"; } + default -> { return; } + } + notificationService.publishEvent(company.getOwnerUserId(), type, title, + "Offer #" + offer.getId() + " 状态:已" + to.name(), + "OFFER", offer.getId(), idempotency); + } + + // ── 视图 ────────────────────────────────────────────────── + + public Map toView(Offer offer) { + Map v = new HashMap<>(); + v.put("id", offer.getId()); + v.put("applicationId", offer.getApplicationId()); + v.put("jobId", offer.getJobId()); + v.put("companyId", offer.getCompanyId()); + v.put("candidateId", offer.getCandidateId()); + v.put("title", offer.getTitle()); + v.put("salaryMin", offer.getSalaryMin()); + v.put("salaryMax", offer.getSalaryMax()); + v.put("salaryCurrency", offer.getSalaryCurrency()); + v.put("salaryPeriod", offer.getSalaryPeriod()); + v.put("workCity", offer.getWorkCity()); + v.put("employmentType", offer.getEmploymentType()); + v.put("probationMonths", offer.getProbationMonths()); + v.put("benefits", offer.getBenefits()); + v.put("description", offer.getDescription()); + v.put("expireAt", offer.getExpireAt()); + v.put("status", offer.getStatus()); + v.put("version", offer.getVersion()); + v.put("createdBy", offer.getCreatedBy()); + v.put("updatedBy", offer.getUpdatedBy()); + v.put("createdAt", offer.getCreatedAt()); + v.put("updatedAt", offer.getUpdatedAt()); + return v; + } + + private Map toHistoryView(OfferStatusHistory h) { + Map v = new HashMap<>(); + v.put("id", h.getId()); + v.put("fromStatus", h.getFromStatus()); + v.put("toStatus", h.getToStatus()); + v.put("operator", h.getOperatorId()); + v.put("operatorRole", h.getOperatorRole()); + v.put("reason", h.getReason()); + v.put("createdAt", h.getCreatedAt()); + v.put("requestId", h.getRequestId()); + return v; + } + + // ── 私有辅助 ────────────────────────────────────────────── + + private Offer requireOffer(Long offerId) { + return offerRepository.findById(offerId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); + } + + private User loadUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + /** 校验并返回当前用户绑定的企业(COMPANY 角色),未绑定返回 404 */ + private Company requireCompany(Long userId) { + User user = loadUser(userId); + if (!hasRole(user, User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + } + + private boolean hasRole(User user, User.Role role) { + return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); + } + + private void assertViewAccess(Offer offer, Long userId) { + User user = loadUser(userId); + if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return; + if (hasRole(user, User.Role.COMPANY)) { + Company company = companyRepository.findByOwnerUserId(userId).orElse(null); + if (company != null && company.getId().equals(offer.getCompanyId())) return; + } + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + /** 投递状态允许发 Offer:SUBMITTED / VIEWED / INTERVIEW;终态与撤回不允许 */ + private boolean isApplicationEligible(String applicationStatus) { + return "SUBMITTED".equals(applicationStatus) + || "VIEWED".equals(applicationStatus) + || "INTERVIEW".equals(applicationStatus); + } + + private void validateSalaryRange(Integer salaryMin, Integer salaryMax) { + if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "salaryMin must not be greater than salaryMax"); + } + } + + private String defaultIfBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private void recordHistory(Long offerId, String fromStatus, String toStatus, + Long operatorId, String operatorRole, String reason, AuditContext ctx) { + String requestId = ctx == null ? null : truncate(ctx.getRequestId()); + historyRepository.save(OfferStatusHistory.builder() + .offerId(offerId) + .fromStatus(fromStatus) + .toStatus(toStatus) + .operatorId(operatorId) + .operatorRole(operatorRole) + .reason(reason) + .requestId(requestId) + .build()); + } + + private String truncate(String s) { + if (s == null) return null; + return s.length() > 32 ? s.substring(0, 32) : s; + } + + /** 校验角色是否可发起某流转(EXPIRED 仅系统任务可触发) */ + private boolean isRoleAllowed(OfferStatus from, OfferStatus to, ActorRole actor) { + return switch (from) { + case DRAFT -> actor == ActorRole.COMPANY && to == OfferStatus.SENT; + case SENT -> switch (to) { + case NEGOTIATING, ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE; + case EXPIRED -> false; // 仅系统任务 + default -> false; + }; + case NEGOTIATING -> switch (to) { + case ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE; + case EXPIRED -> false; // 仅系统任务 + default -> false; + }; + default -> false; + }; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java new file mode 100644 index 0000000..798ea9e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java @@ -0,0 +1,52 @@ +package com.mydreamjob.offer; + +/** + * Offer 状态枚举 + * + *

状态统一命名: + *

    + *
  • DRAFT — 草稿(企业已创建、未发送)
  • + *
  • SENT — 已发送(等待候选人响应)
  • + *
  • NEGOTIATING — 洽谈中
  • + *
  • ACCEPTED — 已接受(终态)
  • + *
  • REJECTED — 已拒绝(终态)
  • + *
  • EXPIRED — 已过期(终态,由定时任务触发)
  • + *
+ */ +public enum OfferStatus { + DRAFT, + SENT, + NEGOTIATING, + ACCEPTED, + REJECTED, + EXPIRED; + + /** + * 校验状态流转是否合法: + *
+     * DRAFT -------> SENT
+     * SENT ---------> NEGOTIATING / ACCEPTED / REJECTED / EXPIRED
+     * NEGOTIATING --> ACCEPTED / REJECTED / EXPIRED
+     * 
+ * 终态(ACCEPTED/REJECTED/EXPIRED)不可再流转。 + */ + public boolean canTransitionTo(OfferStatus target) { + if (target == null) return false; + return switch (this) { + case DRAFT -> target == SENT; + case SENT -> target == NEGOTIATING + || target == ACCEPTED + || target == REJECTED + || target == EXPIRED; + case NEGOTIATING -> target == ACCEPTED + || target == REJECTED + || target == EXPIRED; + case ACCEPTED, REJECTED, EXPIRED -> false; // 终态不可再流转 + }; + } + + /** 是否为终态 */ + public boolean isTerminal() { + return this == ACCEPTED || this == REJECTED || this == EXPIRED; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistory.java new file mode 100644 index 0000000..a520aae --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistory.java @@ -0,0 +1,57 @@ +package com.mydreamjob.offer; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Offer 状态变更历史 + * + *

operator_role:COMPANY / CANDIDATE / SYSTEM(定时任务)。 + * request_id:透传请求级链路 ID(X-Request-Id),定时任务为 null。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "offer_status_history") +public class OfferStatusHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "offer_id", nullable = false) + private Long offerId; + + @Column(name = "from_status", length = 20) + private String fromStatus; + + @Column(name = "to_status", nullable = false, length = 20) + private String toStatus; + + @Column(name = "operator_id", nullable = false) + private Long operatorId; + + @Column(name = "operator_role", nullable = false, length = 20) + private String operatorRole; + + @Column(length = 255) + private String reason; + + @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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java new file mode 100644 index 0000000..75ab799 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.offer; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface OfferStatusHistoryRepository extends JpaRepository { + + List findByOfferIdOrderByCreatedAtAsc(Long offerId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java new file mode 100644 index 0000000..5fd576c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java @@ -0,0 +1,55 @@ +package com.mydreamjob.offer.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 创建 Offer 请求 + */ +@Data +public class OfferCreateRequest { + + @NotNull(message = "applicationId is required") + private Long applicationId; + + @NotBlank(message = "title is required") + @Size(max = 120, message = "title must be at most 120 characters") + private String title; + + @Min(value = 0, message = "salaryMin must not be negative") + private Integer salaryMin; + + @Min(value = 0, message = "salaryMax must not be negative") + private Integer salaryMax; + + @Size(max = 10, message = "salaryCurrency must be at most 10 characters") + private String salaryCurrency; + + @Pattern(regexp = "^(MONTH|YEAR|HOUR)$", + message = "salaryPeriod must be MONTH, YEAR, or HOUR") + private String salaryPeriod; + + @NotBlank(message = "workCity is required") + @Size(max = 80, message = "workCity must be at most 80 characters") + private String workCity; + + @Pattern(regexp = "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$", + message = "employmentType must be FULL_TIME, PART_TIME, INTERN, or INTERNSHIP") + private String employmentType; + + @Min(value = 0, message = "probationMonths must not be negative") + private Integer probationMonths; + + @Size(max = 2000, message = "benefits must be at most 2000 characters") + private String benefits; + + @NotBlank(message = "description is required") + @Size(max = 5000, message = "description must be at most 5000 characters") + private String description; + + @NotNull(message = "expireAt is required") + @Future(message = "expireAt must be in the future") + private LocalDateTime expireAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java new file mode 100644 index 0000000..a7241e2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java @@ -0,0 +1,24 @@ +package com.mydreamjob.offer.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Offer 状态变更请求 + * + *

允许的目标状态:SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED。 + * 各状态的实际发起角色与合法性在 Service 层校验(EXPIRED 仅系统任务可触发)。 + */ +@Data +public class OfferStatusRequest { + + @NotBlank(message = "status is required") + @Pattern(regexp = "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED)$", + message = "status must be SENT, NEGOTIATING, ACCEPTED, REJECTED, or EXPIRED") + private String status; + + @Size(max = 255, message = "reason must be at most 255 characters") + private String reason; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java new file mode 100644 index 0000000..23c5966 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java @@ -0,0 +1,48 @@ +package com.mydreamjob.offer.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 修改 Offer 请求(仅 DRAFT 状态可修改,字段均可选,null 表示不更新) + */ +@Data +public class OfferUpdateRequest { + + @Size(max = 120, message = "title must be at most 120 characters") + private String title; + + @Min(value = 0, message = "salaryMin must not be negative") + private Integer salaryMin; + + @Min(value = 0, message = "salaryMax must not be negative") + private Integer salaryMax; + + @Size(max = 10, message = "salaryCurrency must be at most 10 characters") + private String salaryCurrency; + + @Pattern(regexp = "^(MONTH|YEAR|HOUR)$", + message = "salaryPeriod must be MONTH, YEAR, or HOUR") + private String salaryPeriod; + + @Size(max = 80, message = "workCity must be at most 80 characters") + private String workCity; + + @Pattern(regexp = "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$", + message = "employmentType must be FULL_TIME, PART_TIME, INTERN, or INTERNSHIP") + private String employmentType; + + @Min(value = 0, message = "probationMonths must not be negative") + private Integer probationMonths; + + @Size(max = 2000, message = "benefits must be at most 2000 characters") + private String benefits; + + @Size(max = 5000, message = "description must be at most 5000 characters") + private String description; + + @Future(message = "expireAt must be in the future") + private LocalDateTime expireAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java new file mode 100644 index 0000000..e53f6ed --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java @@ -0,0 +1,55 @@ +package com.mydreamjob.preference; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.preference.dto.StudentPreferenceRequest; +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 = "学生求职偏好查询与保存") +@RestController +@RequestMapping("/profile/me") +@RequiredArgsConstructor +public class PreferenceController { + + private final PreferenceService preferenceService; + + @Operation(summary = "查询求职偏好", description = "仅 STUDENT;无记录时返回空偏好对象") + @GetMapping("/preferences") + public ResponseEntity>> getPreferences(HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = preferenceService.getPreferences(userId); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,完全替换传入字段") + @PutMapping("/preferences") + public ResponseEntity>> savePreferences( + @Valid @RequestBody StudentPreferenceRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = preferenceService.savePreferences(userId, req); + return ResponseEntity.ok(ApiResponse.ok(view, 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java new file mode 100644 index 0000000..06aca67 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java @@ -0,0 +1,120 @@ +package com.mydreamjob.preference; + +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.preference.dto.StudentPreferenceRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PreferenceService { + + private final StudentPreferenceRepository preferenceRepository; + private final UserRepository userRepository; + private static final ObjectMapper mapper = new ObjectMapper(); + + // ── 角色校验 ────────────────────────────────────────────── + + public void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream() + .anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + // ── 查询 ────────────────────────────────────────────────── + + public Map getPreferences(Long studentUserId) { + assertStudentRole(studentUserId); + return preferenceRepository.findByStudentUserId(studentUserId) + .map(this::toView) + .orElse(emptyView()); + } + + // ── 保存/更新 ──────────────────────────────────────────── + + @Transactional + public Map savePreferences(Long studentUserId, StudentPreferenceRequest req) { + assertStudentRole(studentUserId); + + StudentPreference pref = preferenceRepository.findByStudentUserId(studentUserId) + .orElse(StudentPreference.builder() + .studentUserId(studentUserId) + .remoteAllowed(false) + .build()); + + if (req.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin()); + if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax()); + if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities())); + if (req.getEmploymentTypes() != null) pref.setEmploymentTypes(toJson(req.getEmploymentTypes())); + if (req.getPreferredIndustries() != null) pref.setPreferredIndustries(toJson(req.getPreferredIndustries())); + if (req.getPreferredJobTypes() != null) pref.setPreferredJobTypes(toJson(req.getPreferredJobTypes())); + if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed()); + + StudentPreference saved = preferenceRepository.save(pref); + return toView(saved); + } + + // ── 视图 ────────────────────────────────────────────────── + + private Map toView(StudentPreference pref) { + Map view = new HashMap<>(); + view.put("id", pref.getId()); + view.put("studentUserId", pref.getStudentUserId()); + view.put("expectedSalaryMin", pref.getExpectedSalaryMin()); + view.put("expectedSalaryMax", pref.getExpectedSalaryMax()); + view.put("preferredCities", fromJson(pref.getPreferredCities())); + view.put("employmentTypes", fromJson(pref.getEmploymentTypes())); + view.put("preferredIndustries", fromJson(pref.getPreferredIndustries())); + view.put("preferredJobTypes", fromJson(pref.getPreferredJobTypes())); + view.put("remoteAllowed", pref.getRemoteAllowed()); + view.put("createdAt", pref.getCreatedAt()); + view.put("updatedAt", pref.getUpdatedAt()); + return view; + } + + private Map emptyView() { + Map view = new HashMap<>(); + view.put("expectedSalaryMin", null); + view.put("expectedSalaryMax", null); + view.put("preferredCities", List.of()); + view.put("employmentTypes", List.of()); + view.put("preferredIndustries", List.of()); + view.put("preferredJobTypes", List.of()); + view.put("remoteAllowed", false); + return view; + } + + private String toJson(List list) { + try { + return mapper.writeValueAsString(list); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize", e); + } + } + + private List fromJson(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return mapper.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + return List.of(); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java new file mode 100644 index 0000000..5610f13 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreference.java @@ -0,0 +1,59 @@ +package com.mydreamjob.preference; + +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 = "student_preferences") +@EntityListeners(AuditingEntityListener.class) +public class StudentPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false, unique = true) + private Long studentUserId; + + @Column(name = "expected_salary_min") + private Integer expectedSalaryMin; + + @Column(name = "expected_salary_max") + private Integer expectedSalaryMax; + + @Column(name = "preferred_cities", columnDefinition = "json") + private String preferredCities; + + @Column(name = "employment_types", columnDefinition = "json") + private String employmentTypes; + + @Column(name = "preferred_industries", columnDefinition = "json") + private String preferredIndustries; + + @Column(name = "preferred_job_types", columnDefinition = "json") + private String preferredJobTypes; + + @Column(name = "remote_allowed", nullable = false) + @Builder.Default + private Boolean remoteAllowed = false; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java new file mode 100644 index 0000000..e0d5ce0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.preference; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StudentPreferenceRepository extends JpaRepository { + + Optional findByStudentUserId(Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java new file mode 100644 index 0000000..2c14b95 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java @@ -0,0 +1,39 @@ +package com.mydreamjob.preference.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class StudentPreferenceRequest { + + @Min(value = 0, message = "expectedSalaryMin must not be negative") + private Integer expectedSalaryMin; + + @Min(value = 0, message = "expectedSalaryMax must not be negative") + private Integer expectedSalaryMax; + + @Size(max = 20, message = "preferredCities must have at most 20 items") + private List<@Size(min = 1, max = 100, message = "each city must be 1-100 chars") String> preferredCities; + + @Size(max = 20, message = "employmentTypes must have at most 20 items") + private List<@Size(min = 1, max = 100, message = "each employment type must be 1-100 chars") String> employmentTypes; + + @Size(max = 20, message = "preferredIndustries must have at most 20 items") + private List<@Size(min = 1, max = 100, message = "each industry must be 1-100 chars") String> preferredIndustries; + + @Size(max = 20, message = "preferredJobTypes must have at most 20 items") + private List<@Size(min = 1, max = 100, message = "each job type must be 1-100 chars") String> preferredJobTypes; + + private Boolean remoteAllowed; + + @AssertTrue(message = "expectedSalaryMin must not be greater than expectedSalaryMax") + public boolean isSalaryRangeValid() { + if (expectedSalaryMin == null || expectedSalaryMax == null) return true; + return expectedSalaryMin <= expectedSalaryMax; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java new file mode 100644 index 0000000..dc980bb --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -0,0 +1,109 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +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.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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 org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Tag(name = "个人资料", description = "学生个人资料查询、更新与简历管理") +@RestController +@RequestMapping("/profile") +@RequiredArgsConstructor +public class ProfileController { + + private final ProfileService profileService; + + // ── 资料 CRUD ──────────────────────────────────────────── + + @Operation(summary = "查询当前学生资料", description = "未创建资料时返回 200 空对象,不自动创建") + @GetMapping("/me") + public ResponseEntity>> getMyProfile(HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = profileService.toView(profileService.findProfileByUserId(userId)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建") + @PutMapping("/me") + public ResponseEntity>> updateMyProfile( + @Valid @RequestBody StudentProfileUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = profileService.toView(profileService.updateProfile(userId, req)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + // ── 简历管理 ────────────────────────────────────────────── + + @Operation(summary = "上传简历", description = "仅 STUDENT 角色;PDF/DOC/DOCX,≤10MB;上传新简历时替换旧文件") + @PostMapping(value = "/me/resume", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> uploadResume( + @RequestParam("file") MultipartFile file, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = profileService.uploadResume(userId, file); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "下载简历", description = "下载当前学生上传的简历文件,仅 STUDENT 角色") + @GetMapping("/me/resume") + public ResponseEntity downloadResume(HttpServletRequest request) { + Long userId = resolveUserId(request); + ProfileService.ResumeFile rf = profileService.getResume(userId); + MediaType mediaType = resolveContentType(rf.getContentType()); + ContentDisposition cd = ContentDisposition.inline() + .filename(rf.getFilename(), StandardCharsets.UTF_8) + .build(); + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) + .body(rf.getContent()); + } + + @Operation(summary = "删除简历", description = "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色") + @DeleteMapping("/me/resume") + public ResponseEntity>> deleteResume(HttpServletRequest request) { + Long userId = resolveUserId(request); + profileService.deleteResume(userId); + return ResponseEntity.ok(ApiResponse.ok( + Map.of("message", "resume deleted"), 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"); + } + + private MediaType resolveContentType(String contentType) { + if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; + try { + return MediaType.valueOf(contentType); + } catch (Exception e) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java new file mode 100644 index 0000000..deba79f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -0,0 +1,203 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfileService { + + private final StudentProfileRepository profileRepository; + private final ResumeStorageService resumeStorageService; + private final UserRepository userRepository; + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + private static final Set ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); + private static final Map EXT_TO_CONTENT_TYPE = Map.of( + ".pdf", "application/pdf", + ".doc", "application/msword", + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + private static final long MAX_RESUME_SIZE = 10 * 1024 * 1024L; + + // ── 角色校验 ────────────────────────────────────────────── + + public void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream() + .anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + // ── 学生资料 CRUD ──────────────────────────────────────── + + public StudentProfile findProfileByUserId(Long userId) { + return profileRepository.findByUserId(userId).orElse(null); + } + + @Transactional + public StudentProfile updateProfile(Long userId, StudentProfileUpdateRequest req) { + StudentProfile profile = profileRepository.findByUserId(userId) + .orElse(StudentProfile.builder().userId(userId).build()); + if (req.getRealName() != null) profile.setRealName(req.getRealName()); + if (req.getBio() != null) profile.setBio(req.getBio()); + if (req.getLocation() != null) profile.setLocation(req.getLocation()); + if (req.getEducation() != null) profile.setEducation(req.getEducation()); + if (req.getSchool() != null) profile.setSchool(req.getSchool()); + if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue()); + if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl()); + if (req.getSkills() != null) profile.setSkillsJson(req.getSkills().toString()); + return profileRepository.save(profile); + } + + // ── 简历校验 ────────────────────────────────────────────── + + public void validateResumeFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (file.getSize() > MAX_RESUME_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + String originalName = file.getOriginalFilename(); + if (originalName == null || !originalName.contains(".")) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String ext = originalName.substring(originalName.lastIndexOf('.')).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(ext)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + // 验证扩展名与 Content-Type 一致 + String expectedContentType = EXT_TO_CONTENT_TYPE.get(ext); + if (!contentType.equals(expectedContentType)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + // ── 简历上传 ────────────────────────────────────────────── + + @Transactional + public Map uploadResume(Long userId, MultipartFile file) { + assertStudentRole(userId); + validateResumeFile(file); + + StudentProfile profile = profileRepository.findByUserId(userId) + .orElse(StudentProfile.builder().userId(userId).build()); + + // 删除旧文件 + if (profile.getResumeUrl() != null) { + resumeStorageService.delete(profile.getResumeUrl()); + } + + String newPath = resumeStorageService.store(userId, file); + profile.setResumeUrl(newPath); + profile.setResumeOriginalName(file.getOriginalFilename()); + profile.setResumeContentType(file.getContentType()); + profile.setResumeSize(file.getSize()); + profileRepository.save(profile); + + return toResumeView(profile); + } + + // ── 简历获取 ────────────────────────────────────────────── + + public ResumeFile getResume(Long userId) { + assertStudentRole(userId); + StudentProfile profile = profileRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + if (profile.getResumeUrl() == null) { + throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); + } + byte[] content = resumeStorageService.read(profile.getResumeUrl()); + String downloadName = profile.getResumeOriginalName() != null + ? profile.getResumeOriginalName() : "resume"; + return new ResumeFile(downloadName, profile.getResumeContentType(), content); + } + + // ── 简历删除 ────────────────────────────────────────────── + + @Transactional + public void deleteResume(Long userId) { + assertStudentRole(userId); + StudentProfile profile = profileRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + if (profile.getResumeUrl() != null) { + resumeStorageService.delete(profile.getResumeUrl()); + profile.setResumeUrl(null); + profile.setResumeOriginalName(null); + profile.setResumeContentType(null); + profile.setResumeSize(null); + profileRepository.save(profile); + } + } + + // ── 视图 ────────────────────────────────────────────────── + + public Map toView(StudentProfile profile) { + if (profile == null) return null; + Map view = new HashMap<>(); + view.put("id", profile.getId()); + view.put("userId", profile.getUserId()); + view.put("realName", profile.getRealName()); + view.put("bio", profile.getBio()); + view.put("location", profile.getLocation()); + view.put("education", profile.getEducation()); + view.put("school", profile.getSchool()); + view.put("graduationYear", profile.getGraduationYear()); + view.put("resumeUrl", profile.getResumeUrl()); + view.put("skills", profile.getSkillsJson()); + view.put("createdAt", profile.getCreatedAt()); + return view; + } + + public Map toResumeView(StudentProfile profile) { + Map view = new HashMap<>(); + view.put("resumeUrl", profile.getResumeUrl()); + view.put("fileName", profile.getResumeOriginalName()); + view.put("contentType", profile.getResumeContentType()); + view.put("size", profile.getResumeSize()); + return view; + } + + @Getter + public static class ResumeFile { + private final String filename; + private final String contentType; + private final byte[] content; + + public ResumeFile(String filename, String contentType, byte[] content) { + this.filename = filename; + this.contentType = contentType; + this.content = content; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfile.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfile.java new file mode 100644 index 0000000..3c834c7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfile.java @@ -0,0 +1,70 @@ +package com.mydreamjob.profile; + +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 = "student_profiles") +@EntityListeners(AuditingEntityListener.class) +public class StudentProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "real_name", length = 50) + private String realName; + + @Column(columnDefinition = "text") + private String bio; + + @Column(name = "location", length = 100) + private String location; + + @Column(name = "education", length = 100) + private String education; + + @Column(name = "school", length = 150) + private String school; + + @Column(name = "graduation_year") + private Short graduationYear; + + @Column(name = "resume_url", length = 500) + private String resumeUrl; + + @Column(name = "resume_original_name", length = 255) + private String resumeOriginalName; + + @Column(name = "resume_content_type", length = 100) + private String resumeContentType; + + @Column(name = "resume_size") + private Long resumeSize; + + @Column(name = "skills_json", columnDefinition = "json") + private String skillsJson; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java new file mode 100644 index 0000000..c9cfb83 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java @@ -0,0 +1,14 @@ +package com.mydreamjob.profile; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StudentProfileRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + boolean existsByUserId(Long userId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java new file mode 100644 index 0000000..391cd4c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java @@ -0,0 +1,34 @@ +package com.mydreamjob.profile.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 更新学生个人资料请求 DTO + */ +@Data +public class StudentProfileUpdateRequest { + + @Size(max = 50, message = "real name must be at most 50 characters") + private String realName; + + private String bio; + + @Size(max = 100, message = "location must be at most 100 characters") + private String location; + + @Size(max = 100, message = "education must be at most 100 characters") + private String education; + + @Size(max = 150, message = "school must be at most 150 characters") + private String school; + + private Integer graduationYear; + + @Size(max = 500, message = "resume url must be at most 500 characters") + private String resumeUrl; + + private List skills; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java new file mode 100644 index 0000000..17d8b8f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java @@ -0,0 +1,99 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * 本地文件系统简历存储实现。 + * 存储路径通过 app.resume.storage-dir 配置; + * 禁止路径穿越,对外仅暴露相对文件名。 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "app.resume.storage-type", havingValue = "local", matchIfMissing = true) +public class LocalResumeStorageService implements ResumeStorageService { + + @Value("${app.resume.storage-dir:./resumes}") + private String storageDir; + + private Path basePath; + + @PostConstruct + void init() { + basePath = Paths.get(storageDir).toAbsolutePath().normalize(); + try { + Files.createDirectories(basePath); + } catch (IOException e) { + throw new RuntimeException("Cannot create resume storage directory: " + basePath, e); + } + log.info("Resume storage initialized at {}", basePath); + } + + @Override + public String store(Long userId, MultipartFile file) { + String originalName = file.getOriginalFilename(); + if (originalName == null) originalName = "resume"; + String ext = getExtension(originalName); + String storedName = userId + "_" + UUID.randomUUID().toString().substring(0, 8) + ext; + Path target = basePath.resolve(storedName).normalize(); + + // 禁止路径穿越 + if (!target.startsWith(basePath)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + try { + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException("Failed to store resume", e); + } + log.info("Resume stored: userId={}, path={}", userId, storedName); + return storedName; + } + + @Override + public byte[] read(String storagePath) { + Path target = basePath.resolve(storagePath).normalize(); + if (!target.startsWith(basePath)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!Files.exists(target)) { + throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); + } + try { + return Files.readAllBytes(target); + } catch (IOException e) { + throw new RuntimeException("Failed to read resume", e); + } + } + + @Override + public void delete(String storagePath) { + if (storagePath == null) return; + Path target = basePath.resolve(storagePath).normalize(); + if (!target.startsWith(basePath)) return; + try { + Files.deleteIfExists(target); + } catch (IOException e) { + log.warn("Failed to delete resume file: {}", storagePath, e); + } + } + + private String getExtension(String filename) { + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(dot).toLowerCase() : ""; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeStorageService.java new file mode 100644 index 0000000..6fdfd14 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeStorageService.java @@ -0,0 +1,21 @@ +package com.mydreamjob.profile.storage; + +import org.springframework.web.multipart.MultipartFile; + +public interface ResumeStorageService { + + /** + * 存储简历文件,返回存储后的相对路径(非真实文件路径) + */ + String store(Long userId, MultipartFile file); + + /** + * 根据存储的相对路径读取文件字节 + */ + byte[] read(String storagePath); + + /** + * 删除存储的简历文件 + */ + void delete(String storagePath); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportService.java new file mode 100644 index 0000000..aad893f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportService.java @@ -0,0 +1,135 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.resume.storage.ResumeExportStorageService; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.HashMap; +import java.util.Map; + +/** + * 简历导出任务 Service:创建导出任务并异步触发 worker,查询任务状态与下载导出产物。 + * + *

创建任务时把源文件快照(source_*)写入任务;worker 一律从快照读取源文件, + * 任务创建后即使版本被修改,导出仍为创建时的原文件。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ResumeExportService { + + private final ResumeExportTaskRepository taskRepository; + private final ResumeVersionRepository versionRepository; + private final ResumeExportWorker exportWorker; + private final ResumeExportStorageService exportStorageService; + private final ProfileService profileService; + + /** + * 创建导出任务(PENDING)并异步执行。 + * 快照当前版本源文件;worker 在任务行提交(afterCommit)后再触发。 + */ + @Transactional + public ResumeExportTask createExportTask(Long userId, Long versionId) { + profileService.assertStudentRole(userId); + ResumeVersion version = versionRepository.findById(versionId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND)); + if (!version.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + if (!ResumeVersion.STATUS_ACTIVE.equals(version.getStatus())) { + throw new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND); + } + + ResumeExportTask task = ResumeExportTask.builder() + .userId(userId) + .resumeVersionId(versionId) + .resumeVersionName(version.getName()) + .sourceStoragePath(version.getFileStoragePath()) + .sourceFileName(version.getFileName()) + .sourceContentType(version.getContentType()) + .sourceFileSize(version.getFileSize()) + .status(ResumeExportTask.STATUS_PENDING) + .build(); + task = taskRepository.save(task); + triggerWorkerAfterCommit(task.getId()); + return task; + } + + /** 查询导出任务(仅本人) */ + public ResumeExportTask getExportTask(Long userId, Long taskId) { + profileService.assertStudentRole(userId); + ResumeExportTask task = taskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND)); + if (!task.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return task; + } + + /** + * 下载导出产物:仅本人且任务 SUCCESS;未完成返回业务错误。 + * 读取任务自身保存的输出文件(outputPath),不暴露任何物理路径。 + */ + public DownloadFile download(Long userId, Long taskId) { + ResumeExportTask task = getExportTask(userId, taskId); + if (!ResumeExportTask.STATUS_SUCCESS.equals(task.getStatus()) || task.getOutputPath() == null) { + throw new BusinessException(ErrorCode.RESUME_EXPORT_NOT_READY); + } + byte[] content = exportStorageService.read(task.getOutputPath()); + String filename = task.getOutputName() != null ? task.getOutputName() : "resume-export"; + return new DownloadFile(filename, task.getSourceContentType(), content); + } + + /** 任务视图:不暴露 userId、outputPath 等内部路径 */ + public Map toView(ResumeExportTask task) { + Map v = new HashMap<>(); + v.put("id", task.getId()); + v.put("resumeVersionId", task.getResumeVersionId()); + v.put("resumeVersionName", task.getResumeVersionName()); + v.put("status", task.getStatus()); + v.put("outputName", task.getOutputName()); + v.put("downloadAvailable", ResumeExportTask.STATUS_SUCCESS.equals(task.getStatus()) + && task.getOutputName() != null); + v.put("errorMessage", task.getErrorMessage()); + v.put("createdAt", task.getCreatedAt()); + v.put("startedAt", task.getStartedAt()); + v.put("finishedAt", task.getFinishedAt()); + return v; + } + + private void triggerWorkerAfterCommit(Long taskId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + exportWorker.execute(taskId); + } + }); + } else { + // 非事务上下文(如单元测试)直接同步执行 + exportWorker.execute(taskId); + } + } + + /** 下载文件载体 */ + @Getter + public static class DownloadFile { + private final String filename; + private final String contentType; + private final byte[] content; + + public DownloadFile(String filename, String contentType, byte[] content) { + this.filename = filename; + this.contentType = contentType; + this.content = content; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTask.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTask.java new file mode 100644 index 0000000..f5489ae --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTask.java @@ -0,0 +1,87 @@ +package com.mydreamjob.resume; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 简历导出任务实体 + * + *

状态:PENDING → RUNNING → SUCCESS / FAILED。 + * 失败时必须记录 {@code errorMessage};{@code resumeVersionName} 为版本名快照, + * 版本被删除(FK ON DELETE SET NULL)后任务仍可读。 + * source_* 为创建任务时的源文件快照:worker 必须从快照读取源文件, + * 保证任务创建后即使版本被修改,导出仍为创建时的原文件。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "resume_export_tasks") +public class ResumeExportTask { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAILED = "FAILED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "resume_version_id") + private Long resumeVersionId; + + @Column(name = "resume_version_name", length = 120) + private String resumeVersionName; + + // ── 创建任务时的源文件快照 ──────────────────────────────── + + @Column(name = "source_storage_path", length = 500) + private String sourceStoragePath; + + @Column(name = "source_file_name", length = 255) + private String sourceFileName; + + @Column(name = "source_content_type", length = 100) + private String sourceContentType; + + @Column(name = "source_file_size") + private Long sourceFileSize; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = STATUS_PENDING; + + @Column(name = "output_path", length = 500) + private String outputPath; + + @Column(name = "output_name", length = 255) + private String outputName; + + @Column(name = "error_message", length = 500) + private String errorMessage; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (status == null) status = STATUS_PENDING; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTaskRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTaskRepository.java new file mode 100644 index 0000000..99d7fda --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportTaskRepository.java @@ -0,0 +1,18 @@ +package com.mydreamjob.resume; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Optional; + +@Repository +public interface ResumeExportTaskRepository extends JpaRepository { + + Optional findByIdAndUserId(Long id, Long userId); + + boolean existsByResumeVersionIdAndStatusIn(Long resumeVersionId, Collection statuses); + + /** 是否有进行中的导出任务引用了该源文件(用于替换/删除版本文件时决定是否保留旧文件) */ + boolean existsBySourceStoragePathAndStatusIn(String sourceStoragePath, Collection statuses); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportWorker.java new file mode 100644 index 0000000..8c9f76c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeExportWorker.java @@ -0,0 +1,90 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.resume.storage.ResumeExportStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 简历导出异步工作单元。 + * + *

状态流转:PENDING → RUNNING → SUCCESS / FAILED(失败必须记录原因)。 + * 所有异常在方法内捕获并转为 FAILED,不产生未处理异步异常; + * 导出 = 复制该版本存储的原文件到独立导出目录。 + * + *

必须从任务快照(source_*)读取源文件,不得按 resumeVersionId 查当前版本: + * 任务创建后即使版本被修改/文件被替换,导出仍为创建任务时的原文件。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ResumeExportWorker { + + private static final int MAX_ERROR_MESSAGE_LENGTH = 500; + + private final ResumeExportTaskRepository taskRepository; + private final ResumeStorageService resumeStorageService; + private final ResumeExportStorageService exportStorageService; + + @Async("resumeExportExecutor") + @Transactional + public void execute(Long taskId) { + ResumeExportTask task = taskRepository.findById(taskId).orElse(null); + if (task == null) { + log.warn("Resume export task not found: {}", taskId); + return; + } + task.setStatus(ResumeExportTask.STATUS_RUNNING); + task.setStartedAt(LocalDateTime.now()); + try { + task = taskRepository.saveAndFlush(task); + + String sourcePath = task.getSourceStoragePath(); + if (sourcePath == null) { + throw new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND, "export source file is missing"); + } + byte[] content = resumeStorageService.read(sourcePath); + String ext = extensionOf(task.getSourceFileName()); + String outputPath = exportStorageService.store(task.getUserId(), taskId, ext, content); + + task.setStatus(ResumeExportTask.STATUS_SUCCESS); + task.setOutputPath(outputPath); + task.setOutputName(task.getSourceFileName()); + log.info("Resume export succeeded: taskId={}, path={}", taskId, outputPath); + } catch (Exception e) { + log.error("Resume export failed: taskId={}, error={}", taskId, e.getMessage()); + task.setStatus(ResumeExportTask.STATUS_FAILED); + task.setErrorMessage(truncate(reasonOf(e), MAX_ERROR_MESSAGE_LENGTH)); + } finally { + task.setFinishedAt(LocalDateTime.now()); + taskRepository.save(task); + } + } + + private String reasonOf(Exception e) { + if (e instanceof BusinessException be) { + return be.getMessage() == null ? "resume export failed" : be.getMessage(); + } + String msg = e.getMessage(); + return msg == null || msg.isBlank() ? "resume export failed" + : e.getClass().getSimpleName() + ": " + msg; + } + + private String truncate(String s, int max) { + if (s == null) return null; + return s.length() > max ? s.substring(0, max) : s; + } + + private String extensionOf(String filename) { + if (filename == null) return ""; + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(dot).toLowerCase() : ""; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersion.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersion.java new file mode 100644 index 0000000..a0c763d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersion.java @@ -0,0 +1,83 @@ +package com.mydreamjob.resume; + +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; + +/** + * 简历版本实体(与旧单简历 student_profiles.resume_url 独立) + * + *

{@code defaultKey} 为可空判别列,配合唯一索引实现"每用户最多一个默认简历": + * 默认时为 {@code DEFAULT},否则为 {@code null}。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "resume_versions", + uniqueConstraints = { + @UniqueConstraint(name = "uk_resume_versions_user_no", + columnNames = {"user_id", "version_no"}), + @UniqueConstraint(name = "uk_resume_versions_default", + columnNames = {"user_id", "default_key"}) + }) +@EntityListeners(AuditingEntityListener.class) +public class ResumeVersion { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_DELETED = "DELETED"; + public static final String DEFAULT_KEY = "DEFAULT"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(length = 120) + private String name; + + @Column(name = "version_no", nullable = false) + private Integer versionNo; + + @Column(name = "file_storage_path", nullable = false, length = 500) + private String fileStoragePath; + + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "default_key", length = 20) + private String defaultKey; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = STATUS_ACTIVE; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** 是否为默认简历 */ + public boolean isDefault() { + return DEFAULT_KEY.equals(defaultKey); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java new file mode 100644 index 0000000..b0f19f8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java @@ -0,0 +1,157 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +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.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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 org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 简历版本管理与导出接口 + * + *

不使用类级 @RequestMapping:导出任务查询路径为顶层 {@code /resume-export-tasks/{taskId}}, + * 与 {@code /profile/me/resume-versions/**} 前缀不同,统一用全路径避免拼接。 + */ +@Tag(name = "简历版本", description = "学生简历版本管理、默认简历、导出任务") +@RestController +@RequiredArgsConstructor +public class ResumeVersionController { + + private final ResumeVersionService resumeVersionService; + private final ResumeExportService resumeExportService; + + @Operation(summary = "创建简历版本", description = "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名") + @PostMapping(value = "/profile/me/resume-versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> create( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "name", required = false) String name, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeVersion version = resumeVersionService.createVersion(userId, name, file); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + } + + @Operation(summary = "简历版本列表", description = "本人 ACTIVE 版本,version_no 倒序") + @GetMapping("/profile/me/resume-versions") + public ResponseEntity>>> list(HttpServletRequest request) { + Long userId = resolveUserId(request); + List> data = resumeVersionService.listVersions(userId) + .stream().map(resumeVersionService::toView).collect(Collectors.toList()); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "简历版本详情", description = "仅本人可查看;他人 403,不存在 404") + @GetMapping("/profile/me/resume-versions/{id}") + public ResponseEntity>> get( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeVersion version = resumeVersionService.getVersion(userId, id); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + } + + @Operation(summary = "修改简历版本", description = "本人;可选重命名 + 可选替换文件(替换时删除旧文件)") + @PutMapping(value = "/profile/me/resume-versions/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> update( + @PathVariable Long id, + @RequestParam(value = "file", required = false) MultipartFile file, + @RequestParam(value = "name", required = false) String name, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeVersion version = resumeVersionService.updateVersion(userId, id, name, file); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + } + + @Operation(summary = "删除简历版本", description = "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录") + @DeleteMapping("/profile/me/resume-versions/{id}") + public ResponseEntity>> delete( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + resumeVersionService.deleteVersion(userId, id); + return ResponseEntity.ok(ApiResponse.ok( + Map.of("message", "resume version deleted"), getRequestId(request))); + } + + @Operation(summary = "设为默认简历", description = "同用户最多一个默认;已默认幂等;并发冲突 409") + @PostMapping("/profile/me/resume-versions/{id}/default") + public ResponseEntity>> setDefault( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeVersion version = resumeVersionService.setDefault(userId, id); + return ResponseEntity.ok(ApiResponse.ok(resumeVersionService.toView(version), getRequestId(request))); + } + + @Operation(summary = "创建简历导出任务", description = "异步复制该版本文件到导出目录;返回任务状态") + @PostMapping("/profile/me/resume-versions/{id}/export") + public ResponseEntity>> createExportTask( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeExportTask task = resumeExportService.createExportTask(userId, id); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + } + + @Operation(summary = "导出任务状态", description = "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因") + @GetMapping("/resume-export-tasks/{taskId}") + public ResponseEntity>> getExportTask( + @PathVariable Long taskId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeExportTask task = resumeExportService.getExportTask(userId, taskId); + return ResponseEntity.ok(ApiResponse.ok(resumeExportService.toView(task), getRequestId(request))); + } + + @Operation(summary = "下载导出产物", description = "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径") + @GetMapping("/resume-export-tasks/{taskId}/download") + public ResponseEntity downloadExportTask( + @PathVariable Long taskId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + ResumeExportService.DownloadFile df = resumeExportService.download(userId, taskId); + MediaType mediaType = resolveContentType(df.getContentType()); + ContentDisposition cd = ContentDisposition.attachment() + .filename(df.getFilename(), StandardCharsets.UTF_8) + .build(); + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString()) + .body(df.getContent()); + } + + 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"); + } + + private MediaType resolveContentType(String contentType) { + if (contentType == null) return MediaType.APPLICATION_OCTET_STREAM; + try { + return MediaType.valueOf(contentType); + } catch (Exception e) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionRepository.java new file mode 100644 index 0000000..b25a89e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionRepository.java @@ -0,0 +1,23 @@ +package com.mydreamjob.resume; + +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 ResumeVersionRepository extends JpaRepository { + + List findByUserIdAndStatusOrderByVersionNoDesc(Long userId, String status); + + Optional findByIdAndUserId(Long id, Long userId); + + Optional findFirstByUserIdAndDefaultKey(Long userId, String defaultKey); + + /** 用户当前最大版本号(无版本返回 null) */ + @Query("SELECT MAX(v.versionNo) FROM ResumeVersion v WHERE v.userId = :userId") + Integer findMaxVersionNo(@Param("userId") Long userId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java new file mode 100644 index 0000000..cfd599d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java @@ -0,0 +1,311 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.profile.storage.ResumeStorageService; +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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 简历版本管理 Service + * + *

权限:仅简历本人(JWT 身份)可操作,身份一律不信任请求参数。 + * 文件校验复用 {@link ProfileService#validateResumeFile},存储复用 {@link ResumeStorageService}。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ResumeVersionService { + + private static final int MAX_NAME_LENGTH = 120; + private static final int MAX_VERSION_NO_RETRY = 3; + + private final ResumeVersionRepository resumeVersionRepository; + private final ResumeExportTaskRepository exportTaskRepository; + private final ResumeStorageService resumeStorageService; + private final ResumeVersionWriter versionWriter; + private final ProfileService profileService; + + // ── 创建 ────────────────────────────────────────────────── + + /** + * 创建简历版本(默认非默认)。版本号每用户递增(max+1), + * 并发冲突由 {@link ResumeVersionWriter} 独立事务重试(冲突只回滚单次尝试)。 + * 外层事务为角色校验提供持久化上下文(worker 线程无 OSIV)。 + * + *

孤儿文件防护:版本行由 {@code REQUIRES_NEW} 独立事务写入—— + * 写入成功即独立提交,外层事务回滚不会让版本行消失,文件始终被引用; + * 写入失败(重试耗尽或异常)时版本行不存在,由下方 catch 清理已保存的新文件。 + * 因此创建流程不存在"外层回滚导致文件孤儿"的问题。 + */ + @Transactional + public ResumeVersion createVersion(Long userId, String name, MultipartFile file) { + profileService.assertStudentRole(userId); + validateName(name); + profileService.validateResumeFile(file); + + String storagePath = resumeStorageService.store(userId, file); + try { + for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) { + try { + return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, + file.getOriginalFilename(), file.getContentType(), file.getSize()); + } catch (DataIntegrityViolationException e) { + // uk_resume_versions_user_no 并发冲突 → 短退避后重试 + log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY); + try { + Thread.sleep(20L); // 等待并发胜者提交,重读时能看到其版本号 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new BusinessException(ErrorCode.BAD_REQUEST, "failed to assign resume version number"); + } + } + } + throw new BusinessException(ErrorCode.BAD_REQUEST, "failed to assign resume version number"); + } catch (RuntimeException e) { + // 数据库写入最终失败(重试耗尽或异常)→ 清理已保存但未被引用的新文件,避免孤儿文件 + log.warn("Resume version create failed, cleaning orphan file: {}", storagePath); + resumeStorageService.delete(storagePath); + throw e; + } + } + + // ── 查询 ────────────────────────────────────────────────── + + /** 本人 ACTIVE 版本列表,version_no 倒序 */ + public List listVersions(Long userId) { + profileService.assertStudentRole(userId); + return resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE); + } + + /** 本人版本详情(ACTIVE) */ + public ResumeVersion getVersion(Long userId, Long versionId) { + profileService.assertStudentRole(userId); + ResumeVersion version = requireOwnActiveVersion(userId, versionId); + return version; + } + + // ── 修改 ────────────────────────────────────────────────── + + /** + * 修改版本:可选重命名 + 可选替换文件。 + * + *

文件一致性:先保存新文件 → 更新数据库 → 事务提交后删除旧文件。 + * 保存新文件后注册 {@link TransactionSynchronization}: + *

    + *
  • afterCommit:旧文件未被 PENDING/RUNNING 导出任务引用时才删除;
  • + *
  • afterCompletion(非 COMMITTED):事务回滚(含提交阶段失败)时清理本次刚保存的新文件, + * 防止孤儿文件;绝不删除旧文件(数据库仍引用旧文件)。
  • + *
+ * 立即异常(repository.save 抛错)仅在非事务上下文(单元测试)直接清理新文件, + * 事务上下文中统一由 afterCompletion 处理,避免重复删除。 + */ + @Transactional + public ResumeVersion updateVersion(Long userId, Long versionId, String name, MultipartFile file) { + profileService.assertStudentRole(userId); + validateName(name); + ResumeVersion version = requireOwnActiveVersion(userId, versionId); + + if (name != null) { + version.setName(normalizeName(name)); + } + String oldStoragePath = version.getFileStoragePath(); + String newStoragePath = null; + boolean syncRegistered = false; + if (file != null && !file.isEmpty()) { + profileService.validateResumeFile(file); + // 1) 先保存新文件(绝不在保存前删除旧文件) + newStoragePath = resumeStorageService.store(userId, file); + version.setFileStoragePath(newStoragePath); + version.setFileName(file.getOriginalFilename()); + version.setContentType(file.getContentType()); + version.setFileSize(file.getSize()); + // 2) 注册事务回调:提交后删旧文件;回滚时清理新文件 + syncRegistered = registerFileLifecycleSynchronization(oldStoragePath, newStoragePath); + } + try { + // 3) 更新数据库记录 + version = resumeVersionRepository.save(version); + } catch (RuntimeException e) { + // 立即失败:仅在未注册事务回调(非事务上下文/单元测试)时直接清理新文件; + // 事务上下文中由 afterCompletion(非 COMMITTED) 统一清理,避免重复删除。 + if (newStoragePath != null && !syncRegistered) { + log.warn("Resume version update failed, cleaning new file: {}", newStoragePath); + resumeStorageService.delete(newStoragePath); + } + throw e; + } + if (newStoragePath != null && oldStoragePath != null && !syncRegistered) { + // 非事务上下文(单元测试):无 afterCommit 回调,立即执行旧文件清理 + deleteOldFileIfUnreferenced(oldStoragePath); + } + return version; + } + + // ── 删除 ────────────────────────────────────────────────── + + /** + * 删除版本(硬删除)。前置:非默认版本;无 PENDING/RUNNING 导出任务。 + * + *

文件一致性:先完成数据库删除,仅在事务成功提交后删除存储文件; + * 数据库删除失败时不得删除原文件。 + */ + @Transactional + public void deleteVersion(Long userId, Long versionId) { + profileService.assertStudentRole(userId); + ResumeVersion version = requireOwnActiveVersion(userId, versionId); + if (version.isDefault()) { + throw new BusinessException(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED); + } + if (exportTaskRepository.existsByResumeVersionIdAndStatusIn(versionId, + List.of(ResumeExportTask.STATUS_PENDING, ResumeExportTask.STATUS_RUNNING))) { + throw new BusinessException(ErrorCode.RESUME_EXPORT_IN_PROGRESS); + } + // 1) 先完成数据库删除 + resumeVersionRepository.delete(version); + // 2) 事务提交后再删除存储文件(提交失败则文件保留) + deleteFileAfterCommit(version.getFileStoragePath()); + } + + // ── 设置默认 ────────────────────────────────────────────── + + /** + * 设为默认:同事务内清空旧默认并设置新默认;已默认幂等;并发冲突 409。 + */ + @Transactional + public ResumeVersion setDefault(Long userId, Long versionId) { + profileService.assertStudentRole(userId); + ResumeVersion version = requireOwnActiveVersion(userId, versionId); + if (version.isDefault()) { + return version; // 幂等 + } + resumeVersionRepository.findFirstByUserIdAndDefaultKey(userId, ResumeVersion.DEFAULT_KEY) + .ifPresent(old -> old.setDefaultKey(null)); + version.setDefaultKey(ResumeVersion.DEFAULT_KEY); + try { + return resumeVersionRepository.save(version); + } catch (DataIntegrityViolationException e) { + // uk_resume_versions_default 并发冲突:两个版本同时被设为默认 + throw new BusinessException(ErrorCode.DUPLICATE_DEFAULT_RESUME); + } + } + + // ── 视图 ────────────────────────────────────────────────── + + public Map toView(ResumeVersion version) { + Map v = new HashMap<>(); + v.put("id", version.getId()); + v.put("name", version.getName()); + v.put("versionNo", version.getVersionNo()); + v.put("fileName", version.getFileName()); + v.put("contentType", version.getContentType()); + v.put("fileSize", version.getFileSize()); + v.put("default", version.isDefault()); + v.put("status", version.getStatus()); + v.put("createdAt", version.getCreatedAt()); + v.put("updatedAt", version.getUpdatedAt()); + return v; + } + + public List> toViewList(List versions) { + return versions.stream().map(this::toView).collect(Collectors.toList()); + } + + // ── 私有 ────────────────────────────────────────────────── + + private ResumeVersion requireOwnActiveVersion(Long userId, Long versionId) { + // 先按 id 查询:不存在或非 ACTIVE → 404;存在但属于他人 → 403;本人 → 继续 + ResumeVersion version = resumeVersionRepository.findById(versionId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND)); + if (!ResumeVersion.STATUS_ACTIVE.equals(version.getStatus())) { + throw new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND); + } + if (!version.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return version; + } + + private void validateName(String name) { + if (name != null && name.trim().length() > MAX_NAME_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST, + "name must be at most " + MAX_NAME_LENGTH + " characters"); + } + } + + private String normalizeName(String name) { + if (name == null) return null; + String trimmed = name.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 注册文件生命周期事务回调(替换版本文件场景): + *

    + *
  • afterCommit:旧文件未被 PENDING/RUNNING 导出任务引用时删除;
  • + *
  • afterCompletion(非 COMMITTED):清理本次刚保存的新文件(覆盖提交阶段失败导致的外层回滚), + * 绝不删除旧文件(数据库仍引用旧文件)。
  • + *
+ * 返回是否注册成功;非事务上下文(单元测试)返回 false,由调用方即时清理。 + */ + private boolean registerFileLifecycleSynchronization(String oldStoragePath, String newStoragePath) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return false; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteOldFileIfUnreferenced(oldStoragePath); + } + + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_COMMITTED && newStoragePath != null) { + log.warn("Resume version update rolled back, cleaning new file: {}", newStoragePath); + resumeStorageService.delete(newStoragePath); + } + } + }); + return true; + } + + /** 旧文件未被进行中的导出任务引用时删除(保持导出快照一致) */ + private void deleteOldFileIfUnreferenced(String oldStoragePath) { + if (oldStoragePath == null) return; + boolean referenced = exportTaskRepository.existsBySourceStoragePathAndStatusIn(oldStoragePath, + List.of(ResumeExportTask.STATUS_PENDING, ResumeExportTask.STATUS_RUNNING)); + if (!referenced) { + resumeStorageService.delete(oldStoragePath); + } + } + + /** + * 事务成功提交后删除存储文件;非事务上下文(单元测试)直接删除。 + * 数据库失败/回滚时不会执行删除,保证文件与数据库一致。 + */ + private void deleteFileAfterCommit(String storagePath) { + if (storagePath == null) return; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + resumeStorageService.delete(storagePath); + } + }); + } else { + resumeStorageService.delete(storagePath); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java new file mode 100644 index 0000000..a63fdd6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java @@ -0,0 +1,39 @@ +package com.mydreamjob.resume; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 简历版本写入工作单元:每个"计算版本号 + 插入"运行在独立事务(REQUIRES_NEW)。 + * + *

原因:唯一键冲突(uk_resume_versions_user_no)会把 Hibernate 事务标记为 rollback-only, + * 在同一事务内重试必然回滚。独立事务保证单次冲突只回滚自身,外层可安全重试。 + */ +@Component +@RequiredArgsConstructor +public class ResumeVersionWriter { + + private final ResumeVersionRepository resumeVersionRepository; + + /** 计算用户下一个版本号(max+1)并插入;并发冲突抛 DataIntegrityViolationException */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ResumeVersion saveNewVersion(Long userId, String name, String storagePath, + String fileName, String contentType, long fileSize) { + Integer maxNo = resumeVersionRepository.findMaxVersionNo(userId); + int versionNo = (maxNo == null ? 0 : maxNo) + 1; + ResumeVersion version = ResumeVersion.builder() + .userId(userId) + .name(name) + .versionNo(versionNo) + .fileStoragePath(storagePath) + .fileName(fileName) + .contentType(contentType) + .fileSize(fileSize) + .defaultKey(null) + .status(ResumeVersion.STATUS_ACTIVE) + .build(); + return resumeVersionRepository.save(version); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/LocalResumeExportStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/LocalResumeExportStorageService.java new file mode 100644 index 0000000..f2311b8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/LocalResumeExportStorageService.java @@ -0,0 +1,77 @@ +package com.mydreamjob.resume.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +/** + * 本地文件系统导出产物存储。 + * 目录通过 app.resume.export-dir 配置;按用户分子目录,禁止路径穿越。 + */ +@Slf4j +@Component +public class LocalResumeExportStorageService implements ResumeExportStorageService { + + @Value("${app.resume.export-dir:./resume-exports}") + private String exportDir; + + private Path basePath; + + @PostConstruct + void init() { + basePath = Paths.get(exportDir).toAbsolutePath().normalize(); + try { + Files.createDirectories(basePath); + } catch (IOException e) { + throw new RuntimeException("Cannot create resume export directory: " + basePath, e); + } + log.info("Resume export storage initialized at {}", basePath); + } + + @Override + public String store(Long userId, Long taskId, String extension, byte[] content) { + String safeExt = extension == null ? "" : extension.toLowerCase(); + if (!safeExt.isEmpty() && !safeExt.startsWith(".")) { + safeExt = "." + safeExt; + } + String relative = userId + "/" + taskId + safeExt; + Path target = basePath.resolve(relative).normalize(); + if (!target.startsWith(basePath)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + try { + Files.createDirectories(target.getParent()); + Files.copy(new java.io.ByteArrayInputStream(content), target, + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException("Failed to store resume export", e); + } + log.info("Resume export stored: userId={}, taskId={}, path={}", userId, taskId, relative); + return relative; + } + + @Override + public byte[] read(String storagePath) { + Path target = basePath.resolve(storagePath).normalize(); + if (!target.startsWith(basePath)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!Files.exists(target)) { + throw new BusinessException(ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND); + } + try { + return Files.readAllBytes(target); + } catch (IOException e) { + throw new RuntimeException("Failed to read resume export", e); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/ResumeExportStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/ResumeExportStorageService.java new file mode 100644 index 0000000..c18e335 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/storage/ResumeExportStorageService.java @@ -0,0 +1,22 @@ +package com.mydreamjob.resume.storage; + +/** + * 简历导出产物存储:将导出的文件写到独立目录(与简历原始存储分离),并可读取用于下载。 + */ +public interface ResumeExportStorageService { + + /** + * 存储导出产物,返回存储后的相对路径(非真实文件路径)。 + * + * @param userId 归属用户 + * @param taskId 导出任务 ID(用于文件名唯一化) + * @param extension 文件扩展名(含点,如 .pdf;无则空串) + * @param content 文件字节 + */ + String store(Long userId, Long taskId, String extension, byte[] content); + + /** + * 根据存储的相对路径读取导出产物字节。 + */ + byte[] read(String storagePath); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java new file mode 100644 index 0000000..2981663 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java @@ -0,0 +1,96 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.dto.ReviewDecisionRequest; +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.format.annotation.DateTimeFormat; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 评价审核管理接口(仅 ADMIN 角色,身份来自 JWT)。 + */ +@Tag(name = "评价审核", description = "评价审核与防刷:待审列表、审核决策、审核历史(仅 ADMIN)") +@RestController +@RequestMapping("/admin/reviews") +@RequiredArgsConstructor +public class AdminReviewController { + + private final JobReviewService reviewService; + + @Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA") + @GetMapping("/pending") + public ResponseEntity>> pending( + @RequestParam(required = false) Long jobId, + @RequestParam(required = false) String riskLevel, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdFrom, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdTo, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page> result = reviewService.listPendingReviews( + userId, jobId, riskLevel, createdFrom, createdTo, page, size); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + } + + @Operation(summary = "评价审核决策", description = "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs") + @PostMapping("/{reviewId}/decision") + public ResponseEntity>> decide( + @PathVariable Long reviewId, + @Valid @RequestBody ReviewDecisionRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = reviewService.decideReview( + userId, reviewId, req.getDecision(), req.getReason(), + com.mydreamjob.audit.AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "审核历史", description = "仅 ADMIN;分页返回审核结果、审核人、时间与原因") + @GetMapping("/history") + public ResponseEntity>> history( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page> result = reviewService.listModerationHistory(userId, page, size); + return ResponseEntity.ok(ApiResponse.ok(toPage(result), getRequestId(request))); + } + + private Map toPage(Page> result) { + Map 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 data; + } + + 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReview.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReview.java new file mode 100644 index 0000000..42c5004 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReview.java @@ -0,0 +1,91 @@ +package com.mydreamjob.review; + +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.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "job_reviews", + uniqueConstraints = @UniqueConstraint(columnNames = {"job_id", "student_user_id"})) +@EntityListeners(AuditingEntityListener.class) +public class JobReview { + + @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(nullable = false) + private Integer rating; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + @Column(columnDefinition = "json") + private String tags; + + @Column(nullable = false) + @Builder.Default + private String status = "PUBLISHED"; + + // ── 审核与防刷(V18) ────────────────────────────────────── + + public static final String MODERATION_AUTO_APPROVED = "AUTO_APPROVED"; + public static final String MODERATION_PENDING = "PENDING"; + public static final String MODERATION_APPROVED = "APPROVED"; + public static final String MODERATION_REJECTED = "REJECTED"; + + @Column(name = "moderation_status", nullable = false, length = 20) + @Builder.Default + private String moderationStatus = MODERATION_AUTO_APPROVED; + + /** 自动预审命中的原因码(JSON 数组字符串,可安全展示,不含敏感词原文) */ + @Column(name = "moderation_reason", length = 500) + private String moderationReason; + + @Column(name = "moderation_score", precision = 5, scale = 2) + private BigDecimal moderationScore; + + @Column(name = "moderation_updated_at") + private LocalDateTime moderationUpdatedAt; + + @Column(name = "risk_level", length = 20) + private String riskLevel; + + /** 内容 SHA-256(64 位十六进制),仅用于去重/风险识别 */ + @Column(name = "content_hash", length = 64) + private String contentHash; + + /** 来源 IP SHA-256,不存明文 */ + @Column(name = "ip_hash", length = 64) + private String ipHash; + + /** User-Agent SHA-256,不存明文 */ + @Column(name = "ua_hash", length = 64) + private String uaHash; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java new file mode 100644 index 0000000..63bf36b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java @@ -0,0 +1,140 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import com.mydreamjob.review.dto.JobReviewUpdateRequest; +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Tag(name = "岗位评价", description = "学生对已发布岗位进行评价") +@RestController +@RequiredArgsConstructor +public class JobReviewController { + + private final JobReviewService reviewService; + + @Operation(summary = "创建岗位评价", description = "仅 STUDENT;同一学生对同一岗位只能评价一次") + @PostMapping("/jobs/{jobId}/reviews") + public ResponseEntity>> create( + @PathVariable Long jobId, + @Valid @RequestBody JobReviewCreateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = reviewService.createReview(userId, jobId, req, + com.mydreamjob.audit.AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "查询岗位评价列表", description = "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分") + @GetMapping("/jobs/{jobId}/reviews") + public ResponseEntity>> list( + @PathVariable Long jobId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Page> result = reviewService.listReviews(jobId, page, size); + Map 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))); + } + + @Operation(summary = "岗位评价详情", description = "公开访问;返回单条 PUBLISHED 评价与八维评分") + @GetMapping("/jobs/{jobId}/reviews/{reviewId}") + public ResponseEntity>> get( + @PathVariable Long jobId, + @PathVariable Long reviewId, + HttpServletRequest request) { + Map view = reviewService.getReview(jobId, reviewId); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "评分汇总", description = "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签") + @GetMapping("/jobs/{jobId}/reviews/summary") + public ResponseEntity>> summary( + @PathVariable Long jobId, + HttpServletRequest request) { + Map data = reviewService.getReviewSummary(jobId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "岗位对比", description = "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分") + @GetMapping("/jobs/compare") + public ResponseEntity>>> compare( + @RequestParam String ids, + HttpServletRequest request) { + List jobIds = parseIds(ids); + List> data = reviewService.compareJobs(jobIds); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private List parseIds(String ids) { + if (ids == null || ids.isBlank()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + List result = new ArrayList<>(); + for (String part : ids.split(",")) { + try { + result.add(Long.valueOf(part.trim())); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + return result; + } + + @Operation(summary = "修改评价", description = "仅评价本人可以修改") + @PutMapping("/jobs/{jobId}/reviews/{reviewId}") + public ResponseEntity>> update( + @PathVariable Long jobId, + @PathVariable Long reviewId, + @Valid @RequestBody JobReviewUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map view = reviewService.updateReview(userId, jobId, reviewId, req, + com.mydreamjob.audit.AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + } + + @Operation(summary = "删除评价", description = "仅评价本人可以删除") + @DeleteMapping("/jobs/{jobId}/reviews/{reviewId}") + public ResponseEntity>> delete( + @PathVariable Long jobId, + @PathVariable Long reviewId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + reviewService.deleteReview(userId, jobId, reviewId, com.mydreamjob.audit.AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "review deleted"), 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScore.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScore.java new file mode 100644 index 0000000..76cb95f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScore.java @@ -0,0 +1,49 @@ +package com.mydreamjob.review; + +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; + +/** + * 岗位评价八维评分(稀疏存储):一条评价可 0-8 条维度分。 + * review_id + dimension_code 唯一;删除评价时由 DB 外键级联删除。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "job_review_dimension_scores", + uniqueConstraints = @UniqueConstraint(name = "uk_job_review_dimension", + columnNames = {"review_id", "dimension_code"})) +@EntityListeners(AuditingEntityListener.class) +public class JobReviewDimensionScore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "dimension_code", nullable = false, length = 30) + private String dimensionCode; + + @Column(nullable = false) + private Integer score; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java new file mode 100644 index 0000000..9ac6f60 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java @@ -0,0 +1,45 @@ +package com.mydreamjob.review; + +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.Collection; +import java.util.List; + +@Repository +public interface JobReviewDimensionScoreRepository extends JpaRepository { + + List findByReviewId(Long reviewId); + + /** 批量加载一批评价的维度分(避免 N+1) */ + List findByReviewIdIn(Collection reviewIds); + + /** + * 按维度聚合评分:返回 [dimension_code, avg(score), count] 行。 + * 仅统计传入的评价集合(调用方负责限定 PUBLISHED 评价)。 + */ + @Query("SELECT d.dimensionCode, AVG(d.score), COUNT(d) " + + "FROM JobReviewDimensionScore d WHERE d.reviewId IN :reviewIds GROUP BY d.dimensionCode") + List aggregateByDimension(@Param("reviewIds") Collection reviewIds); + + /** + * 按 jobId 聚合八维评分,仅统计 PUBLISHED + 公开评审状态。 + * 一次数据库聚合,不加载 JobReview 实体,不先查询 reviewId 列表。 + * 返回 [dimensionCode, AVG(score), COUNT(id)]。 + */ + @Query(""" + SELECT d.dimensionCode, AVG(d.score), COUNT(d.id) + FROM JobReviewDimensionScore d + WHERE d.reviewId IN ( + SELECT r.id + FROM JobReview r + WHERE r.jobId = :jobId + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + ) + GROUP BY d.dimensionCode + """) + List aggregatePublishedByJobId(@Param("jobId") Long jobId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java new file mode 100644 index 0000000..671cdb3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -0,0 +1,55 @@ +package com.mydreamjob.review; + +import org.springframework.data.domain.Page; +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.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface JobReviewRepository extends JpaRepository { + + Optional findByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + Page findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable); + + /** 公开列表:仅 PUBLISHED 且审核状态为 AUTO_APPROVED/APPROVED */ + Page findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc( + Long jobId, String status, Collection moderationStatuses, Pageable pageable); + + boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + List findByJobIdAndStatus(Long jobId, String status); + + /** 公开汇总:仅 PUBLISHED 且审核通过(AUTO_APPROVED/APPROVED) */ + List findByJobIdAndStatusAndModerationStatusIn( + Long jobId, String status, Collection moderationStatuses); + + // ── 防刷规则查询 ────────────────────────────────────────── + + long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after); + + boolean existsByJobIdAndContentHashAndCreatedAtAfter(Long jobId, String contentHash, LocalDateTime after); + + long countByIpHashAndCreatedAtAfter(String ipHash, LocalDateTime after); + + long countByUaHashAndCreatedAtAfter(String uaHash, LocalDateTime after); + + /** 管理端待审列表:PENDING,支持 jobId / riskLevel / 时间范围筛选 */ + @Query("SELECT r FROM JobReview r WHERE r.status = 'PUBLISHED' AND r.moderationStatus = 'PENDING'" + + " AND (:jobId IS NULL OR r.jobId = :jobId)" + + " AND (:riskLevel IS NULL OR r.riskLevel = :riskLevel)" + + " AND (:createdFrom IS NULL OR r.createdAt >= :createdFrom)" + + " AND (:createdTo IS NULL OR r.createdAt <= :createdTo)") + Page findPendingModeration(@Param("jobId") Long jobId, + @Param("riskLevel") String riskLevel, + @Param("createdFrom") LocalDateTime createdFrom, + @Param("createdTo") LocalDateTime createdTo, + Pageable pageable); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java new file mode 100644 index 0000000..1aabea3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java @@ -0,0 +1,559 @@ +package com.mydreamjob.review; + +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.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +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.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JobReviewService { + + private final JobReviewRepository reviewRepository; + private final JobReviewDimensionScoreRepository dimensionScoreRepository; + private final ReviewModerationHistoryRepository moderationHistoryRepository; + private final ReviewModerationService moderationService; + private final JobService jobService; + private final UserRepository userRepository; + private final AuditService auditService; + private static final ObjectMapper mapper = new ObjectMapper(); + private static final int MAX_PAGE_SIZE = 50; + private static final Set ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN"); + private static final int MIN_SCORE = 1; + private static final int MAX_SCORE = 5; + private static final int MAX_REQUEST_ID_LENGTH = 32; + private static final List PUBLIC_MODERATION_STATUSES = List.of( + JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_APPROVED); + + // ── 角色校验 ────────────────────────────────────────────── + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private void assertJobExists(Long jobId) { + jobService.getJob(jobId); // throws JOB_NOT_FOUND + } + + private void assertValidPage(int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + private void assertValidStatus(String status) { + if (status != null && !ALLOWED_STATUSES.contains(status)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + private void assertValidContent(String content) { + if (content != null && (content.isBlank() || content.length() > 1000)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + // ── 创建 ────────────────────────────────────────────────── + + /** 兼容旧调用(无审计上下文) */ + @Transactional + public Map createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req) { + return createReview(studentUserId, jobId, req, null); + } + + @Transactional + public Map createReview(Long studentUserId, Long jobId, JobReviewCreateRequest req, + AuditContext ctx) { + assertStudentRole(studentUserId); + assertJobExists(jobId); + + if (reviewRepository.existsByJobIdAndStudentUserId(jobId, studentUserId)) + throw new BusinessException(ErrorCode.DUPLICATE_REVIEW); + + Map dims = validateDimensionScores(req.getDimensionScores()); + LocalDateTime now = LocalDateTime.now(); + String contentHash = ReviewModerationService.sha256(req.getContent()); + String ipHash = ReviewModerationService.sha256(ctx != null ? ctx.getIpAddress() : null); + String uaHash = ReviewModerationService.sha256(ctx != null ? ctx.getUserAgent() : null); + ReviewModerationService.ModerationResult moderation = moderationService.moderate( + studentUserId, jobId, req.getContent(), contentHash, ipHash, uaHash, now); + + JobReview review = JobReview.builder() + .jobId(jobId).studentUserId(studentUserId) + .rating(req.getRating()).content(req.getContent()) + .tags(toJson(req.getTags())).status("PUBLISHED") + .moderationStatus(moderation.getStatus()) + .moderationReason(toReasonCodesJson(moderation.getReasonCodes())) + .moderationScore(moderation.getScore()) + .moderationUpdatedAt(JobReview.MODERATION_PENDING.equals(moderation.getStatus()) ? now : null) + .riskLevel(moderation.getRiskLevel()) + .contentHash(contentHash) + .ipHash(ipHash) + .uaHash(uaHash) + .build(); + try { + review = reviewRepository.save(review); + } catch (DataIntegrityViolationException e) { + // 并发重复评价:DB UNIQUE(job_id, student_user_id) 兜底 + throw new BusinessException(ErrorCode.DUPLICATE_REVIEW); + } + saveDimensionScores(review.getId(), dims); + if (JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) { + moderationHistoryRepository.save(ReviewModerationHistory.builder() + .reviewId(review.getId()) + .action(ReviewModerationHistory.ACTION_AUTO_FLAG) + .reasonCodes(toReasonCodesJson(moderation.getReasonCodes())) + .riskLevel(moderation.getRiskLevel()) + .build()); + } + auditService.log(studentUserId, "CREATE_REVIEW", "JOB", jobId, + "Review created, dimensions=" + dims.size() + + ", moderationStatus=" + review.getModerationStatus() + + ", reasonCodes=" + toReasonCodesJson(moderation.getReasonCodes()), + truncateRequestId(ctx)); + return authorView(review, dims); + } + + // ── 查询 ────────────────────────────────────────────────── + + public Page> listReviews(Long jobId, int page, int size) { + assertJobExists(jobId); + assertValidPage(page, size); + Page result = reviewRepository + .findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc( + jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, PageRequest.of(page, size)); + List ids = result.getContent().stream().map(JobReview::getId).toList(); + Map> dimsByReview = batchLoadDimensionMap(ids); + return result.map(r -> toView(r, dimsByReview.getOrDefault(r.getId(), Map.of()))); + } + + /** 评价详情(公开;仅审核通过可公开) */ + public Map getReview(Long jobId, Long reviewId) { + assertJobExists(jobId); + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + if (!review.getJobId().equals(jobId)) + throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + if (!PUBLIC_MODERATION_STATUSES.contains(review.getModerationStatus())) + throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); // PENDING/REJECTED 不公开 + return toView(review, loadDimensionMap(reviewId)); + } + + // ── 修改 ────────────────────────────────────────────────── + + /** 兼容旧调用(无审计上下文) */ + @Transactional + public Map updateReview(Long userId, Long jobId, Long reviewId, + JobReviewUpdateRequest req) { + return updateReview(userId, jobId, reviewId, req, null); + } + + @Transactional + public Map updateReview(Long userId, Long jobId, Long reviewId, + JobReviewUpdateRequest req, AuditContext ctx) { + assertStudentRole(userId); + JobReview review = findOwnReview(userId, jobId, reviewId); + + if (req.getRating() != null) { + if (req.getRating() < 1 || req.getRating() > 5) + throw new BusinessException(ErrorCode.BAD_REQUEST); + review.setRating(req.getRating()); + } + if (req.getContent() != null) { + assertValidContent(req.getContent()); + review.setContent(req.getContent()); + } + if (req.getTags() != null) review.setTags(toJson(req.getTags())); + if (req.getStatus() != null) { + assertValidStatus(req.getStatus()); + review.setStatus(req.getStatus()); + } + Map dims = validateDimensionScores(req.getDimensionScores()); + if (!dims.isEmpty()) { + upsertDimensionScores(review.getId(), dims); + } + Map view = toView(reviewRepository.save(review), loadDimensionMap(review.getId())); + auditService.log(userId, "UPDATE_REVIEW", "JOB_REVIEW", reviewId, + "Review updated, dimensions=" + dims.size(), truncateRequestId(ctx)); + return view; + } + + // ── 删除 ────────────────────────────────────────────────── + + /** 兼容旧调用(无审计上下文) */ + @Transactional + public void deleteReview(Long userId, Long jobId, Long reviewId) { + deleteReview(userId, jobId, reviewId, null); + } + + @Transactional + public void deleteReview(Long userId, Long jobId, Long reviewId, AuditContext ctx) { + assertStudentRole(userId); + findOwnReview(userId, jobId, reviewId); + reviewRepository.deleteById(reviewId); // DB 外键 ON DELETE CASCADE 删除维度分 + auditService.log(userId, "DELETE_REVIEW", "JOB_REVIEW", reviewId, + "Review deleted", truncateRequestId(ctx)); + } + + // ── 评分汇总 ────────────────────────────────────────────── + + public Map getReviewSummary(Long jobId) { + assertJobExists(jobId); + + List reviews = reviewRepository + .findByJobIdAndStatusAndModerationStatusIn(jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES); + + Map summary = new HashMap<>(); + summary.put("jobId", jobId); + + if (reviews.isEmpty()) { + summary.put("totalReviews", 0); + summary.put("averageRating", 0.0); + summary.put("ratingDistribution", Map.of( + "rating1", 0, "rating2", 0, "rating3", 0, "rating4", 0, "rating5", 0)); + summary.put("topTags", List.of()); + } else { + summary.put("totalReviews", reviews.size()); + + BigDecimal sum = BigDecimal.ZERO; + int[] dist = new int[6]; // 1-indexed: dist[1..5] + for (JobReview r : reviews) { + int rating = r.getRating(); + if (rating >= 1 && rating <= 5) { + sum = sum.add(BigDecimal.valueOf(rating)); + dist[rating]++; + } + } + BigDecimal avg = sum.divide(BigDecimal.valueOf(reviews.size()), 1, RoundingMode.HALF_UP); + summary.put("averageRating", avg.doubleValue()); + + summary.put("ratingDistribution", Map.of( + "rating1", dist[1], "rating2", dist[2], "rating3", dist[3], + "rating4", dist[4], "rating5", dist[5])); + + Map tagCount = new HashMap<>(); + for (JobReview r : reviews) { + List tags = fromJson(r.getTags()); + if (tags != null) { + for (String t : tags) { + tagCount.merge(t, 1L, Long::sum); + } + } + } + summary.put("topTags", tagCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .map(Map.Entry::getKey) + .toList()); + } + + List reviewIds = reviews.stream().map(JobReview::getId).toList(); + summary.put("dimensionSummary", buildDimensionSummary(reviewIds)); + return summary; + } + + // ── 岗位对比(公开) ────────────────────────────────────── + + public List> compareJobs(List jobIds) { + if (jobIds == null || jobIds.size() < 2 || jobIds.size() > 3) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (new HashSet<>(jobIds).size() != jobIds.size()) + throw new BusinessException(ErrorCode.BAD_REQUEST); + + List> result = new ArrayList<>(); + for (Long jobId : jobIds) { + Job job = jobService.getJob(jobId); // 不存在 → 404 + if (!"PUBLISHED".equals(job.getStatus())) + throw new BusinessException(ErrorCode.JOB_NOT_FOUND); // 非公开岗位不可见 + + Map view = new LinkedHashMap<>(); + view.put("id", job.getId()); + view.put("title", job.getTitle()); + view.put("city", job.getCity()); + view.put("employmentType", job.getEmploymentType()); + view.put("salaryMin", job.getSalaryMin()); + view.put("salaryMax", job.getSalaryMax()); + view.put("benefits", null); // Job 实体暂无 benefits 字段,保留占位 + view.put("description", job.getDescription()); + view.put("requirements", job.getRequirements()); + + Map summary = getReviewSummary(jobId); + view.put("averageRating", summary.get("averageRating")); + view.put("totalReviews", summary.get("totalReviews")); + view.put("ratingDistribution", summary.get("ratingDistribution")); + view.put("tags", summary.get("topTags")); + view.put("dimensionSummary", summary.get("dimensionSummary")); + result.add(view); + } + return result; + } + + // ── 审核管理(仅 ADMIN) ────────────────────────────────── + + /** 待审列表(分页,可 jobId/riskLevel/createdFrom/createdTo 筛选),不返回明文 IP/UA */ + @Transactional(readOnly = true) + public Page> listPendingReviews(Long adminUserId, Long jobId, String riskLevel, + LocalDateTime createdFrom, LocalDateTime createdTo, + int page, int size) { + assertAdminRole(adminUserId); + assertValidPage(page, size); + return reviewRepository.findPendingModeration(jobId, riskLevel, createdFrom, createdTo, + PageRequest.of(page, size)) + .map(r -> toAdminView(r, loadDimensionMap(r.getId()))); + } + + /** 审核决策:仅 PENDING 可审;已审重复提交 409;决策写入历史与 audit_logs */ + @Transactional + public Map decideReview(Long adminUserId, Long reviewId, + String decision, String reason, AuditContext ctx) { + assertAdminRole(adminUserId); + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + if (!JobReview.MODERATION_PENDING.equals(review.getModerationStatus())) { + throw new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED); + } + boolean approved = JobReview.MODERATION_APPROVED.equals(decision); + String finalStatus = approved ? JobReview.MODERATION_APPROVED : JobReview.MODERATION_REJECTED; + review.setModerationStatus(finalStatus); + review.setModerationUpdatedAt(LocalDateTime.now()); + review = reviewRepository.save(review); + + moderationHistoryRepository.save(ReviewModerationHistory.builder() + .reviewId(reviewId) + .action(approved ? ReviewModerationHistory.ACTION_APPROVE : ReviewModerationHistory.ACTION_REJECT) + .actorUserId(adminUserId) + .reasonCodes(review.getModerationReason()) + .humanReason(reason) + .riskLevel(review.getRiskLevel()) + .build()); + auditService.log(adminUserId, "REVIEW_" + finalStatus, "JOB_REVIEW", reviewId, + "Review moderation decision=" + finalStatus + ", reason=" + reason, + truncateRequestId(ctx)); + return toAdminView(review, loadDimensionMap(reviewId)); + } + + /** 审核历史(分页),返回结果、审核人、时间与原因 */ + @Transactional(readOnly = true) + public Page> listModerationHistory(Long adminUserId, int page, int size) { + assertAdminRole(adminUserId); + assertValidPage(page, size); + return moderationHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)) + .map(this::toHistoryView); + } + + private void assertAdminRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isAdmin = user.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); + if (!isAdmin) throw new BusinessException(ErrorCode.FORBIDDEN); + } + + // ── 八维评分 ────────────────────────────────────────────── + + /** 校验并归一化维度分:null/空 → 空;key 非法 → 400;value 非 1-5 或 null → 400 */ + private Map validateDimensionScores(Map dimensionScores) { + if (dimensionScores == null || dimensionScores.isEmpty()) return Map.of(); + Map normalized = new LinkedHashMap<>(); + for (Map.Entry e : dimensionScores.entrySet()) { + ReviewDimension.fromCode(e.getKey()); // 未知维度 → 400 + Integer score = e.getValue(); + if (score == null) + throw new BusinessException(ErrorCode.BAD_REQUEST, "dimension score must not be null"); + if (score < MIN_SCORE || score > MAX_SCORE) + throw new BusinessException(ErrorCode.BAD_REQUEST, "dimension score must be between 1 and 5"); + normalized.put(e.getKey(), score); + } + return normalized; + } + + private void saveDimensionScores(Long reviewId, Map dims) { + for (Map.Entry e : dims.entrySet()) { + dimensionScoreRepository.save(JobReviewDimensionScore.builder() + .reviewId(reviewId).dimensionCode(e.getKey()).score(e.getValue()).build()); + } + } + + /** 只更新传入维度:已有则改 score,新维度则插入;未传入维度保持原值 */ + private void upsertDimensionScores(Long reviewId, Map dims) { + Map existing = dimensionScoreRepository.findByReviewId(reviewId) + .stream().collect(Collectors.toMap(JobReviewDimensionScore::getDimensionCode, s -> s)); + for (Map.Entry e : dims.entrySet()) { + JobReviewDimensionScore score = existing.get(e.getKey()); + if (score != null) { + score.setScore(e.getValue()); + } else { + dimensionScoreRepository.save(JobReviewDimensionScore.builder() + .reviewId(reviewId).dimensionCode(e.getKey()).score(e.getValue()).build()); + } + } + } + + private Map loadDimensionMap(Long reviewId) { + return dimensionScoreRepository.findByReviewId(reviewId).stream() + .collect(Collectors.toMap(JobReviewDimensionScore::getDimensionCode, + JobReviewDimensionScore::getScore)); + } + + private Map> batchLoadDimensionMap(List reviewIds) { + Map> result = new HashMap<>(); + if (reviewIds.isEmpty()) return result; + for (JobReviewDimensionScore s : dimensionScoreRepository.findByReviewIdIn(reviewIds)) { + result.computeIfAbsent(s.getReviewId(), k -> new HashMap<>()) + .put(s.getDimensionCode(), s.getScore()); + } + return result; + } + + /** 八维汇总:始终返回全部 8 个维度;sampleCount<3 → INSUFFICIENT,>=3 → SUFFICIENT;无数据 → null */ + private Map buildDimensionSummary(List reviewIds) { + Map result = new LinkedHashMap<>(); + Map byCode = new HashMap<>(); + if (reviewIds != null && !reviewIds.isEmpty()) { + for (Object[] row : dimensionScoreRepository.aggregateByDimension(reviewIds)) { + byCode.put((String) row[0], row); + } + } + for (ReviewDimension dim : ReviewDimension.values()) { + Object[] row = byCode.get(dim.name()); + Map entry = new LinkedHashMap<>(); + if (row == null) { + entry.put("averageScore", null); + entry.put("sampleCount", 0L); + entry.put("dataState", "INSUFFICIENT"); + } else { + double avg = ((Number) row[1]).doubleValue(); + long count = ((Number) row[2]).longValue(); + entry.put("averageScore", BigDecimal.valueOf(avg) + .setScale(1, RoundingMode.HALF_UP).doubleValue()); + entry.put("sampleCount", count); + entry.put("dataState", count >= 3 ? "SUFFICIENT" : "INSUFFICIENT"); + } + result.put(dim.name(), entry); + } + return result; + } + + // ── 辅助 ────────────────────────────────────────────────── + + /** X-Request-Id 超长(如 36 位)统一截断为 32 位,避免审计落库失败 */ + private AuditContext truncateRequestId(AuditContext ctx) { + if (ctx == null || ctx.getRequestId() == null) return ctx; + String rid = ctx.getRequestId(); + if (rid.length() <= MAX_REQUEST_ID_LENGTH) return ctx; + return AuditContext.builder() + .requestId(rid.substring(0, MAX_REQUEST_ID_LENGTH)) + .ipAddress(ctx.getIpAddress()) + .userAgent(ctx.getUserAgent()) + .build(); + } + + private JobReview findOwnReview(Long userId, Long jobId, Long reviewId) { + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + if (!review.getJobId().equals(jobId)) + throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + if (!review.getStudentUserId().equals(userId)) + throw new BusinessException(ErrorCode.FORBIDDEN); + return review; + } + + public Map toView(JobReview r, Map dimensionScores) { + Map view = new HashMap<>(); + view.put("id", r.getId()); + view.put("jobId", r.getJobId()); + view.put("studentUserId", r.getStudentUserId()); + view.put("rating", r.getRating()); + view.put("content", r.getContent()); + view.put("tags", fromJson(r.getTags())); + view.put("status", r.getStatus()); + view.put("moderationStatus", r.getModerationStatus()); + view.put("createdAt", r.getCreatedAt()); + view.put("updatedAt", r.getUpdatedAt()); + view.put("dimensionScores", dimensionScores == null ? Map.of() : dimensionScores); + return view; + } + + /** 作者视角:额外暴露可安全展示的原因码(不含敏感词原文) */ + private Map authorView(JobReview r, Map dimensionScores) { + Map view = toView(r, dimensionScores); + view.put("moderationReasonCodes", parseReasonCodes(r.getModerationReason())); + return view; + } + + /** 管理视角:暴露原因码与风险等级,不暴露明文 IP/UA */ + private Map toAdminView(JobReview r, Map dimensionScores) { + Map view = new HashMap<>(); + view.put("reviewId", r.getId()); + view.put("jobId", r.getJobId()); + view.put("studentUserId", r.getStudentUserId()); + view.put("rating", r.getRating()); + view.put("content", r.getContent()); + view.put("tags", fromJson(r.getTags())); + view.put("dimensionScores", dimensionScores == null ? Map.of() : dimensionScores); + view.put("moderationStatus", r.getModerationStatus()); + view.put("reasonCodes", parseReasonCodes(r.getModerationReason())); + view.put("riskLevel", r.getRiskLevel()); + view.put("moderationScore", r.getModerationScore()); + view.put("createdAt", r.getCreatedAt()); + return view; + } + + private Map toHistoryView(ReviewModerationHistory h) { + Map view = new HashMap<>(); + view.put("id", h.getId()); + view.put("reviewId", h.getReviewId()); + view.put("action", h.getAction()); + view.put("actorUserId", h.getActorUserId()); + view.put("reasonCodes", parseReasonCodes(h.getReasonCodes())); + view.put("reason", h.getHumanReason()); + view.put("createdAt", h.getCreatedAt()); + return view; + } + + private String toReasonCodesJson(List reasonCodes) { + if (reasonCodes == null || reasonCodes.isEmpty()) return null; + try { return mapper.writeValueAsString(reasonCodes); } + catch (JsonProcessingException e) { throw new RuntimeException("JSON error", e); } + } + + private List parseReasonCodes(String json) { + if (json == null || json.isBlank()) return List.of(); + try { return mapper.readValue(json, new TypeReference>() {}); } + catch (JsonProcessingException e) { return List.of(); } + } + + private String toJson(List list) { + if (list == null) return null; + try { return mapper.writeValueAsString(list); } + catch (JsonProcessingException e) { throw new RuntimeException("JSON error", e); } + } + + private List fromJson(String json) { + if (json == null || json.isBlank()) return List.of(); + try { return mapper.readValue(json, new TypeReference>() {}); } + catch (JsonProcessingException e) { return List.of(); } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewDimension.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewDimension.java new file mode 100644 index 0000000..d68a515 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewDimension.java @@ -0,0 +1,46 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; + +/** + * 岗位八维评价维度(固定枚举,统一校验)。 + * + *

维度名称与说明集中在此,不写死在 Controller/Service 中。 + * WORK_LIFE_BALANCE 涵盖:加班强度、工作时间稳定性、弹性办公、休假执行、加班补偿。 + */ +public enum ReviewDimension { + + AUTHENTICITY("岗位真实性"), + BENEFITS("基础保障"), + SALARY_TRANSPARENCY("薪资透明"), + JD_CLARITY("JD 清晰度"), + GROWTH("成长空间"), + TEAM_ATMOSPHERE("团队氛围"), + PROMOTION("晋升机制"), + WORK_LIFE_BALANCE("工作与生活平衡"); + + private final String description; + + ReviewDimension(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** 按枚举名解析(大小写敏感);非法维度抛明确异常 */ + public static ReviewDimension enumName(String name) { + try { + return valueOf(name); + } catch (IllegalArgumentException | NullPointerException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "unknown review dimension: " + name); + } + } + + /** 按枚举名解析(与 enumName 等价,语义更贴近"外部编码");非法维度抛明确异常 */ + public static ReviewDimension fromCode(String code) { + return enumName(code); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistory.java new file mode 100644 index 0000000..acb300a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistory.java @@ -0,0 +1,57 @@ +package com.mydreamjob.review; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 评价审核历史:自动预审标记(AUTO_FLAG)与管理员决策(APPROVE/REJECT)。 + * 用于 /admin/reviews/history 与审计追溯。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "review_moderation_history") +public class ReviewModerationHistory { + + public static final String ACTION_AUTO_FLAG = "AUTO_FLAG"; + public static final String ACTION_APPROVE = "APPROVE"; + public static final String ACTION_REJECT = "REJECT"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "action", nullable = false, length = 20) + private String action; + + /** 自动预审为 null;管理员决策为管理员用户 id */ + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "reason_codes", length = 500) + private String reasonCodes; + + @Column(name = "human_reason", length = 500) + private String humanReason; + + @Column(name = "risk_level", length = 20) + private String riskLevel; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistoryRepository.java new file mode 100644 index 0000000..4eea7ab --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationHistoryRepository.java @@ -0,0 +1,16 @@ +package com.mydreamjob.review; + +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.List; + +@Repository +public interface ReviewModerationHistoryRepository extends JpaRepository { + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + List findByReviewIdOrderByCreatedAtDesc(Long reviewId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java new file mode 100644 index 0000000..d3c5d99 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java @@ -0,0 +1,198 @@ +package com.mydreamjob.review; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 评价规则预审(确定性 MVP,非 AI)。 + * + *

规则命中时评价仍保存成功,但 moderationStatus=PENDING、不进入公开列表/汇总; + * 返回体只暴露可安全展示的原因码,不含敏感词原文。 + * 敏感词来自配置项(app.review-moderation.sensitive-words),禁止硬编码在 Controller。 + * 原始 IP/User-Agent 不落库,仅存 SHA-256 哈希。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReviewModerationService { + + public static final String REASON_SENSITIVE_WORD = "SENSITIVE_WORD"; + public static final String REASON_CONTACT_INFO = "CONTACT_INFO"; + public static final String REASON_PATTERN_ANOMALY = "PATTERN_ANOMALY"; + public static final String REASON_RAPID_SUBMISSION = "RAPID_SUBMISSION"; + public static final String REASON_DUPLICATE_CONTENT = "DUPLICATE_CONTENT"; + public static final String REASON_ABUSIVE_BATCH = "ABUSIVE_BATCH"; + + private final JobReviewRepository reviewRepository; + + @Value("${app.review-moderation.sensitive-words:}") + private List sensitiveWords; + + @Value("${app.review-moderation.rapid-submission-window-seconds:60}") + private long rapidSubmissionWindowSeconds; + + @Value("${app.review-moderation.rapid-submission-limit:3}") + private long rapidSubmissionLimit; + + @Value("${app.review-moderation.duplicate-content-window-seconds:300}") + private long duplicateContentWindowSeconds; + + @Value("${app.review-moderation.batch-window-seconds:300}") + private long batchWindowSeconds; + + @Value("${app.review-moderation.batch-limit:5}") + private long batchLimit; + + private static final Pattern PHONE = Pattern.compile("(? codes = new LinkedHashSet<>(); + double score = 0; + + if (containsSensitiveWord(content)) { + codes.add(REASON_SENSITIVE_WORD); + score += 2; + } + if (containsContactInfo(content)) { + codes.add(REASON_CONTACT_INFO); + score += 2; + } + if (hasAnomalousPattern(content)) { + codes.add(REASON_PATTERN_ANOMALY); + score += 1; + } + if (rapidSubmission(studentUserId, createdAt)) { + codes.add(REASON_RAPID_SUBMISSION); + score += 1; + } + if (duplicateContent(jobId, contentHash, createdAt)) { + codes.add(REASON_DUPLICATE_CONTENT); + score += 2; + } + if (batchAbuse(ipHash, uaHash, createdAt)) { + codes.add(REASON_ABUSIVE_BATCH); + score += 2; + } + + if (codes.isEmpty()) { + return new ModerationResult(JobReview.MODERATION_AUTO_APPROVED, + List.of(), BigDecimal.ZERO, null); + } + double capped = Math.min(score, 100); + return new ModerationResult(JobReview.MODERATION_PENDING, + new ArrayList<>(codes), BigDecimal.valueOf(capped), riskLevel(capped)); + } + + // ── 规则实现 ────────────────────────────────────────────── + + private boolean containsSensitiveWord(String content) { + if (content == null || sensitiveWords == null || sensitiveWords.isEmpty()) return false; + String lower = content.toLowerCase(); + for (String w : sensitiveWords) { + if (w != null && !w.isBlank() && lower.contains(w.toLowerCase())) { + return true; + } + } + return false; + } + + private boolean containsContactInfo(String content) { + if (content == null) return false; + return PHONE.matcher(content).find() + || QQ.matcher(content).find() + || WECHAT.matcher(content).find() + || URL.matcher(content).find(); + } + + private boolean hasAnomalousPattern(String content) { + if (content == null) return false; + // 连续重复字符(如“哈哈哈哈哈哈”)或异常超长 + return REPEATED_CHARS.matcher(content).find() || content.length() >= 800; + } + + private boolean rapidSubmission(Long studentUserId, LocalDateTime createdAt) { + if (studentUserId == null) return false; + LocalDateTime windowStart = createdAt.minusSeconds(rapidSubmissionWindowSeconds); + return reviewRepository.countByStudentUserIdAndCreatedAtAfter(studentUserId, windowStart) + >= rapidSubmissionLimit; + } + + private boolean duplicateContent(Long jobId, String contentHash, LocalDateTime createdAt) { + if (jobId == null || contentHash == null) return false; + LocalDateTime windowStart = createdAt.minusSeconds(duplicateContentWindowSeconds); + return reviewRepository.existsByJobIdAndContentHashAndCreatedAtAfter(jobId, contentHash, windowStart); + } + + private boolean batchAbuse(String ipHash, String uaHash, LocalDateTime createdAt) { + LocalDateTime windowStart = createdAt.minusSeconds(batchWindowSeconds); + if (ipHash != null && reviewRepository.countByIpHashAndCreatedAtAfter(ipHash, windowStart) >= batchLimit) { + return true; + } + return uaHash != null && reviewRepository.countByUaHashAndCreatedAtAfter(uaHash, windowStart) >= batchLimit; + } + + private String riskLevel(double score) { + if (score >= 5) return "HIGH"; + if (score >= 2) return "MEDIUM"; + return "LOW"; + } + + /** SHA-256 十六进制(64 位),用于内容/IP/User-Agent 哈希 */ + public static String sha256(String value) { + if (value == null) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** 预审结果 */ + @Getter + public static class ModerationResult { + private final String status; + private final List reasonCodes; + private final BigDecimal score; + private final String riskLevel; + + public ModerationResult(String status, List reasonCodes, + BigDecimal score, String riskLevel) { + this.status = status; + this.reasonCodes = reasonCodes; + this.score = score; + this.riskLevel = riskLevel; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeight.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeight.java new file mode 100644 index 0000000..f4cb809 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeight.java @@ -0,0 +1,43 @@ +package com.mydreamjob.review; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "student_review_dimension_weights") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StudentReviewDimensionWeight { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "preference_id", nullable = false) + private StudentReviewWeightPreference preference; + + @Column(name = "dimension_code", nullable = false, length = 30) + private String dimensionCode; + + @Column(nullable = false, precision = 5, scale = 2) + private BigDecimal weight; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeightRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeightRepository.java new file mode 100644 index 0000000..3f595f3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewDimensionWeightRepository.java @@ -0,0 +1,16 @@ +package com.mydreamjob.review; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface StudentReviewDimensionWeightRepository + extends JpaRepository { + + @Modifying + @Query(value = "DELETE FROM student_review_dimension_weights WHERE preference_id = :preferenceId", nativeQuery = true) + void deleteByPreferenceId(@Param("preferenceId") Long preferenceId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java new file mode 100644 index 0000000..e0d8775 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightController.java @@ -0,0 +1,102 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.dto.PersonalizedScoreResponse; +import com.mydreamjob.review.dto.ReviewWeightResponse; +import com.mydreamjob.review.dto.SaveWeightRequest; +import com.mydreamjob.review.dto.WeightTemplateResponse; +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 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.List; + +@RestController +@RequiredArgsConstructor +public class StudentReviewWeightController { + + private final StudentReviewWeightService weightService; + + // ── 模板列表(登录即可) ────────────────────────────────── + + @GetMapping("/review-weight-templates") + @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))) + }) + public ResponseEntity>> getTemplates( + HttpServletRequest request) { + return ResponseEntity.ok(ApiResponse.ok(weightService.getTemplates(), getRequestId(request))); + } + + // ── 获取我的权重配置(仅 STUDENT,Service 层校验) ────────── + + @GetMapping("/profile/me/review-weights") + @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))) + }) + public ResponseEntity> getMyWeights( + HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(weightService.getMyWeights(userId), getRequestId(request))); + } + + // ── 保存我的权重配置(仅 STUDENT,Service 层校验) ────────── + + @PutMapping("/profile/me/review-weights") + @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))) + }) + public ResponseEntity> saveWeights( + @RequestBody SaveWeightRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(weightService.saveWeights(userId, req), getRequestId(request))); + } + + // ── 个性化评分(仅 STUDENT,Service 层校验) ──────────────── + + @GetMapping("/jobs/{jobId}/personalized-review-score") + @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))) + }) + public ResponseEntity> getPersonalizedScore( + @PathVariable Long jobId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + return ResponseEntity.ok(ApiResponse.ok(weightService.getPersonalizedScore(userId, jobId), 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreference.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreference.java new file mode 100644 index 0000000..f0ab7c3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreference.java @@ -0,0 +1,51 @@ +package com.mydreamjob.review; + +import jakarta.persistence.*; +import lombok.*; +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.ArrayList; +import java.util.List; + +@Entity +@Table(name = "student_review_weight_preferences") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StudentReviewWeightPreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(nullable = false, length = 20) + private String source; + + @Column(name = "template_code", length = 50) + private String templateCode; + + @Version + @Column(nullable = false) + private Long version; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "preference", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private List dimensionWeights = new ArrayList<>(); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreferenceRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreferenceRepository.java new file mode 100644 index 0000000..b9421e4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightPreferenceRepository.java @@ -0,0 +1,13 @@ +package com.mydreamjob.review; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StudentReviewWeightPreferenceRepository + extends JpaRepository { + + Optional findByStudentUserId(Long studentUserId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightService.java new file mode 100644 index 0000000..c65c2d3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/StudentReviewWeightService.java @@ -0,0 +1,366 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.review.dto.PersonalizedScoreResponse; +import com.mydreamjob.review.dto.ReviewWeightResponse; +import com.mydreamjob.review.dto.SaveWeightRequest; +import com.mydreamjob.review.dto.WeightTemplateResponse; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentReviewWeightService { + + private static final int INTERMEDIATE_SCALE = 8; + private static final RoundingMode ROUNDING = RoundingMode.HALF_UP; + private static final BigDecimal ONE_HUNDRED = new BigDecimal("100.00"); + private static final BigDecimal FIVE = new BigDecimal("5"); + private static final List ALL_DIMENSIONS = List.of( + "AUTHENTICITY", "BENEFITS", "SALARY_TRANSPARENCY", "JD_CLARITY", + "GROWTH", "TEAM_ATMOSPHERE", "PROMOTION", "WORK_LIFE_BALANCE" + ); + + private final StudentReviewWeightPreferenceRepository preferenceRepository; + private final StudentReviewDimensionWeightRepository dimensionWeightRepository; + private final JobReviewDimensionScoreRepository dimensionScoreRepository; + private final JobRepository jobRepository; + private final CompanyService companyService; + private final EntityManager entityManager; + + // ── 模板列表 ────────────────────────────────────────────── + + public List getTemplates() { + return Arrays.stream(WeightTemplate.values()) + .map(t -> WeightTemplateResponse.builder() + .code(t.name()) + .name(getTemplateName(t)) + .description(getTemplateDescription(t)) + .weights(t.getWeights()) + .build()) + .collect(Collectors.toList()); + } + + private String getTemplateName(WeightTemplate t) { + return switch (t) { + case BALANCED -> "均衡权重"; + case SALARY_FIRST -> "薪资优先"; + case GROWTH_FIRST -> "成长优先"; + case STABILITY_FIRST -> "稳定优先"; + }; + } + + private String getTemplateDescription(WeightTemplate t) { + return switch (t) { + case BALANCED -> "八个维度平均分配"; + case SALARY_FIRST -> "重点关注薪资透明度与基础保障"; + case GROWTH_FIRST -> "重点关注成长空间与晋升机制"; + case STABILITY_FIRST -> "重点关注工作生活平衡与基础保障"; + }; + } + + // ── 获取我的权重配置 ────────────────────────────────────── + + @Transactional(readOnly = true) + public ReviewWeightResponse getMyWeights(Long userId) { + companyService.assertStudentRole(userId); + StudentReviewWeightPreference pref = preferenceRepository + .findByStudentUserId(userId).orElse(null); + if (pref == null) { + return ReviewWeightResponse.builder() + .source("SYSTEM_DEFAULT") + .templateCode("BALANCED") + .configuredWeights(WeightTemplate.BALANCED.getWeights()) + .updatedAt(null) + .build(); + } + Map weights = loadWeights(pref); + return ReviewWeightResponse.builder() + .source(pref.getSource()) + .templateCode(pref.getTemplateCode()) + .configuredWeights(weights) + .updatedAt(pref.getUpdatedAt()) + .build(); + } + + private Map loadWeights(StudentReviewWeightPreference pref) { + Map weights = new LinkedHashMap<>(); + for (StudentReviewDimensionWeight dw : pref.getDimensionWeights()) { + weights.put(dw.getDimensionCode(), dw.getWeight()); + } + return weights; + } + + // ── 保存权重配置 ────────────────────────────────────────── + + @Transactional + public ReviewWeightResponse saveWeights(Long userId, SaveWeightRequest req) { + companyService.assertStudentRole(userId); + validateRequest(req); + + Map targetWeights; + String source; + String templateCode; + + if (req.getTemplateCode() != null) { + WeightTemplate template = resolveTemplate(req.getTemplateCode()); + targetWeights = template.getWeights(); + source = "TEMPLATE"; + templateCode = req.getTemplateCode(); + } else { + targetWeights = req.getWeights(); + validateCustomWeights(targetWeights); + source = "CUSTOM"; + templateCode = null; + } + + StudentReviewWeightPreference pref = preferenceRepository + .findByStudentUserId(userId).orElse(null); + + try { + if (pref == null) { + pref = createNewPreference(userId, source, templateCode); + saveDimensionWeights(pref, targetWeights); + } else { + updateExistingPreference(pref, source, templateCode); + entityManager.createNativeQuery( + "DELETE FROM student_review_dimension_weights WHERE preference_id = :pid") + .setParameter("pid", pref.getId()).executeUpdate(); + entityManager.flush(); + saveDimensionWeights(pref, targetWeights); + } + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException e) { + log.warn("Concurrent weight update conflict for userId={}", userId, e); + throw new BusinessException(ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE); + } + + return ReviewWeightResponse.builder() + .source(source) + .templateCode(templateCode) + .configuredWeights(targetWeights) + .updatedAt(pref.getUpdatedAt()) + .build(); + } + + private void validateRequest(SaveWeightRequest req) { + boolean hasTemplate = req.getTemplateCode() != null; + boolean hasWeights = req.getWeights() != null; + if (hasTemplate && hasWeights) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!hasTemplate && !hasWeights) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private WeightTemplate resolveTemplate(String code) { + try { + return WeightTemplate.valueOf(code); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private void validateCustomWeights(Map weights) { + if (weights.size() != 8) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + BigDecimal sum = BigDecimal.ZERO; + for (String dim : ALL_DIMENSIONS) { + BigDecimal w = weights.get(dim); + if (w == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + // 精度校验:拒绝超过两位有效小数 + try { + w.setScale(2, RoundingMode.UNNECESSARY); + } catch (ArithmeticException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (w.compareTo(BigDecimal.ZERO) < 0 || w.compareTo(ONE_HUNDRED) > 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + sum = sum.add(w); + } + if (sum.compareTo(ONE_HUNDRED) != 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private StudentReviewWeightPreference createNewPreference( + Long userId, String source, String templateCode) { + StudentReviewWeightPreference pref = StudentReviewWeightPreference.builder() + .studentUserId(userId) + .source(source) + .templateCode(templateCode) + .version(0L) + .build(); + return preferenceRepository.saveAndFlush(pref); + } + + private void updateExistingPreference( + StudentReviewWeightPreference pref, String source, String templateCode) { + pref.setSource(source); + pref.setTemplateCode(templateCode); + pref.setUpdatedAt(LocalDateTime.now()); + // 即使 source/template 相同也更新,显式设置 updatedAt 触发 @Version + preferenceRepository.saveAndFlush(pref); + } + + private void saveDimensionWeights( + StudentReviewWeightPreference pref, Map weights) { + List dws = new ArrayList<>(); + for (Map.Entry entry : weights.entrySet()) { + dws.add(StudentReviewDimensionWeight.builder() + .preference(pref) + .dimensionCode(entry.getKey()) + .weight(entry.getValue()) + .build()); + } + dimensionWeightRepository.saveAllAndFlush(dws); + } + + // ── 个性化评分 ──────────────────────────────────────────── + + @Transactional(readOnly = true) + public PersonalizedScoreResponse getPersonalizedScore(Long userId, Long jobId) { + companyService.assertStudentRole(userId); + // 校验岗位存在且 PUBLISHED + Job job = jobRepository.findById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); + if (!"PUBLISHED".equals(job.getStatus())) { + throw new BusinessException(ErrorCode.JOB_NOT_FOUND); + } + + // 获取用户权重 + ReviewWeightResponse weightConfig = getMyWeights(userId); + Map configuredWeights = weightConfig.getConfiguredWeights(); + String source = weightConfig.getSource(); + String templateCode = weightConfig.getTemplateCode(); + + // 专用聚合查询 + List aggregated = dimensionScoreRepository.aggregatePublishedByJobId(jobId); + + Map avgScores = new LinkedHashMap<>(); + Map sampleCounts = new LinkedHashMap<>(); + for (Object[] row : aggregated) { + String dimCode = (String) row[0]; + BigDecimal avg = BigDecimal.valueOf(((Number) row[1]).doubleValue()); + long count = ((Number) row[2]).longValue(); + avgScores.put(dimCode, avg); + sampleCounts.put(dimCode, count); + } + + // 判断可用维度 + List availableDims = new ArrayList<>(); + List missingDims = new ArrayList<>(); + Map dimensionScores = new LinkedHashMap<>(); + + for (String dim : ALL_DIMENSIONS) { + BigDecimal avg = avgScores.get(dim); + Long count = sampleCounts.get(dim); + if (avg != null && count != null && count >= 3) { + availableDims.add(dim); + dimensionScores.put(dim, avg.setScale(2, ROUNDING)); + } else { + missingDims.add(dim); + dimensionScores.put(dim, null); + } + } + + // coveredWeight + BigDecimal coveredWeight = BigDecimal.ZERO; + for (String dim : availableDims) { + coveredWeight = coveredWeight.add(configuredWeights.get(dim)); + } + coveredWeight = coveredWeight.setScale(2, ROUNDING); + + // 全部无数据 + if (coveredWeight.compareTo(BigDecimal.ZERO) == 0) { + return PersonalizedScoreResponse.builder() + .jobId(jobId) + .score(null) + .status("INSUFFICIENT_DATA") + .source(source) + .templateCode(templateCode) + .configuredWeights(configuredWeights) + .effectiveWeights(Collections.emptyMap()) + .dimensionScores(dimensionScores) + .coveredWeight(BigDecimal.ZERO) + .missingDimensions(missingDims) + .build(); + } + + // effectiveWeights 归一化 + Map effectiveWeights = new LinkedHashMap<>(); + BigDecimal weightSum = BigDecimal.ZERO; + for (int i = 0; i < availableDims.size(); i++) { + String dim = availableDims.get(i); + BigDecimal rawWeight = configuredWeights.get(dim); + BigDecimal effective = rawWeight + .divide(coveredWeight, INTERMEDIATE_SCALE, ROUNDING) + .multiply(ONE_HUNDRED) + .setScale(2, ROUNDING); + effectiveWeights.put(dim, effective); + weightSum = weightSum.add(effective); + } + + // 舍入修正 + if (!effectiveWeights.isEmpty()) { + BigDecimal diff = ONE_HUNDRED.subtract(weightSum); + if (diff.compareTo(BigDecimal.ZERO) != 0) { + String lastDim = availableDims.get(availableDims.size() - 1); + effectiveWeights.put(lastDim, effectiveWeights.get(lastDim).add(diff)); + } + } + + // dimensionScore100 + Map dimScore100 = new LinkedHashMap<>(); + for (String dim : availableDims) { + BigDecimal score100 = avgScores.get(dim) + .divide(FIVE, INTERMEDIATE_SCALE, ROUNDING) + .multiply(ONE_HUNDRED) + .setScale(2, ROUNDING); + dimScore100.put(dim, score100); + } + + // score + BigDecimal score = BigDecimal.ZERO; + for (String dim : availableDims) { + BigDecimal term = dimScore100.get(dim) + .multiply(effectiveWeights.get(dim)) + .divide(ONE_HUNDRED, INTERMEDIATE_SCALE, ROUNDING); + score = score.add(term); + } + score = score.setScale(2, ROUNDING); + + return PersonalizedScoreResponse.builder() + .jobId(jobId) + .score(score) + .status("OK") + .source(source) + .templateCode(templateCode) + .configuredWeights(configuredWeights) + .effectiveWeights(effectiveWeights) + .dimensionScores(dimensionScores) + .coveredWeight(coveredWeight) + .missingDimensions(missingDims) + .build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/WeightTemplate.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/WeightTemplate.java new file mode 100644 index 0000000..9cc1955 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/WeightTemplate.java @@ -0,0 +1,63 @@ +package com.mydreamjob.review; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 评价权重模板,所有模板八维权重总和严格为 100.00。 + */ +public enum WeightTemplate { + + BALANCED(Map.of( + "AUTHENTICITY", new BigDecimal("12.50"), + "BENEFITS", new BigDecimal("12.50"), + "SALARY_TRANSPARENCY", new BigDecimal("12.50"), + "JD_CLARITY", new BigDecimal("12.50"), + "GROWTH", new BigDecimal("12.50"), + "TEAM_ATMOSPHERE", new BigDecimal("12.50"), + "PROMOTION", new BigDecimal("12.50"), + "WORK_LIFE_BALANCE", new BigDecimal("12.50") + )), + SALARY_FIRST(Map.of( + "AUTHENTICITY", new BigDecimal("15.00"), + "BENEFITS", new BigDecimal("20.00"), + "SALARY_TRANSPARENCY", new BigDecimal("30.00"), + "JD_CLARITY", new BigDecimal("10.00"), + "GROWTH", new BigDecimal("10.00"), + "TEAM_ATMOSPHERE", new BigDecimal("5.00"), + "PROMOTION", new BigDecimal("5.00"), + "WORK_LIFE_BALANCE", new BigDecimal("5.00") + )), + GROWTH_FIRST(Map.of( + "AUTHENTICITY", new BigDecimal("10.00"), + "BENEFITS", new BigDecimal("5.00"), + "SALARY_TRANSPARENCY", new BigDecimal("5.00"), + "JD_CLARITY", new BigDecimal("15.00"), + "GROWTH", new BigDecimal("30.00"), + "TEAM_ATMOSPHERE", new BigDecimal("10.00"), + "PROMOTION", new BigDecimal("20.00"), + "WORK_LIFE_BALANCE", new BigDecimal("5.00") + )), + STABILITY_FIRST(Map.of( + "AUTHENTICITY", new BigDecimal("10.00"), + "BENEFITS", new BigDecimal("25.00"), + "SALARY_TRANSPARENCY", new BigDecimal("15.00"), + "JD_CLARITY", new BigDecimal("5.00"), + "GROWTH", new BigDecimal("5.00"), + "TEAM_ATMOSPHERE", new BigDecimal("5.00"), + "PROMOTION", new BigDecimal("5.00"), + "WORK_LIFE_BALANCE", new BigDecimal("30.00") + )); + + private final Map weights; + + WeightTemplate(Map weights) { + this.weights = Collections.unmodifiableMap(new LinkedHashMap<>(weights)); + } + + public Map getWeights() { + return weights; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewCreateRequest.java new file mode 100644 index 0000000..4810729 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewCreateRequest.java @@ -0,0 +1,28 @@ +package com.mydreamjob.review.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class JobReviewCreateRequest { + + @Min(1) @Max(5) + @NotNull(message = "rating is required") + private Integer rating; + + @NotBlank(message = "content is required") + @Size(max = 1000, message = "content must not exceed 1000 characters") + private String content; + + @Size(max = 10, message = "tags must have at most 10 items") + private List<@Size(min = 1, max = 50, message = "each tag must be 1-50 chars") String> tags; + + /** + * 可选八维评分(稀疏):key 为八个固定维度名,value 为 1-5。 + * 为 null/空对象时兼容旧客户端,不创建维度记录;具体校验在 Service 层。 + */ + private Map dimensionScores; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewUpdateRequest.java new file mode 100644 index 0000000..8a5891b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/JobReviewUpdateRequest.java @@ -0,0 +1,29 @@ +package com.mydreamjob.review.dto; + +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class JobReviewUpdateRequest { + + @Min(1) @Max(5) + private Integer rating; + + @Size(min = 1, max = 1000, message = "content must be 1-1000 characters") + private String content; + + @Size(max = 10, message = "tags must have at most 10 items") + private List<@Size(min = 1, max = 50, message = "each tag must be 1-50 chars") String> tags; + + @Pattern(regexp = "^(PUBLISHED|HIDDEN)$", message = "status must be PUBLISHED or HIDDEN") + private String status; + + /** + * 可选八维评分(稀疏):只更新请求中实际提交的维度;空对象不删除原有维度。 + * 具体校验在 Service 层。 + */ + private Map dimensionScores; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/PersonalizedScoreResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/PersonalizedScoreResponse.java new file mode 100644 index 0000000..d031b2e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/PersonalizedScoreResponse.java @@ -0,0 +1,29 @@ +package com.mydreamjob.review.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PersonalizedScoreResponse { + private Long jobId; + private BigDecimal score; + private String status; + private String source; + private String templateCode; + private Map configuredWeights; + private Map effectiveWeights; + private Map dimensionScores; + private BigDecimal coveredWeight; + private List missingDimensions; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewDecisionRequest.java new file mode 100644 index 0000000..2ae3c63 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewDecisionRequest.java @@ -0,0 +1,20 @@ +package com.mydreamjob.review.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 评价审核决策请求(仅 ADMIN) + */ +@Data +public class ReviewDecisionRequest { + + @NotBlank(message = "decision is required") + @Pattern(regexp = "^(APPROVED|REJECTED)$", message = "decision must be APPROVED or REJECTED") + private String decision; + + @Size(max = 500, message = "reason must be at most 500 characters") + private String reason; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewWeightResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewWeightResponse.java new file mode 100644 index 0000000..acc3942 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewWeightResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.review.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReviewWeightResponse { + private String source; + private String templateCode; + private Map configuredWeights; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/SaveWeightRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/SaveWeightRequest.java new file mode 100644 index 0000000..773d44b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/SaveWeightRequest.java @@ -0,0 +1,12 @@ +package com.mydreamjob.review.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Map; + +@Data +public class SaveWeightRequest { + private String templateCode; + private Map weights; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/WeightTemplateResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/WeightTemplateResponse.java new file mode 100644 index 0000000..768b555 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/WeightTemplateResponse.java @@ -0,0 +1,20 @@ +package com.mydreamjob.review.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WeightTemplateResponse { + private String code; + private String name; + private String description; + private Map weights; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerification.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerification.java new file mode 100644 index 0000000..c545b0b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerification.java @@ -0,0 +1,68 @@ +package com.mydreamjob.verification; + +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 = "company_verifications") +@EntityListeners(AuditingEntityListener.class) +public class CompanyVerification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = "PENDING"; + + @Column(name = "submitted_by", nullable = false) + private Long submittedBy; + + @Column(name = "materials_json", columnDefinition = "text") + private String materialsJson; + + @Column(name = "reviewer_id") + private Long reviewerId; + + @Column(name = "decision_reason", length = 500) + private String decisionReason; + + @Column(name = "submitted_at", nullable = false) + @Builder.Default + private LocalDateTime submittedAt = LocalDateTime.now(); + + @Column(name = "decided_at") + private LocalDateTime decidedAt; + + @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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java new file mode 100644 index 0000000..fb4995c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java @@ -0,0 +1,139 @@ +package com.mydreamjob.verification; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.verification.dto.VerificationDecisionRequest; +import com.mydreamjob.verification.dto.VerificationSubmitRequest; +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.format.annotation.DateTimeFormat; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 企业认证接口 + * 企业端:提交认证、查看认证状态 + * 管理端:审核列表、审核详情、审核决定、审核历史 + */ +@Tag(name = "企业认证", description = "企业认证提交、审核管理") +@RestController +@RequiredArgsConstructor +public class CompanyVerificationController { + + private final CompanyVerificationService verificationService; + + // ── 企业:提交认证 ────────────────────────────────────────── + + @Operation(summary = "提交企业认证", description = "企业提交认证材料(营业执照等),只能提交自己的认证") + @PostMapping("/companies/me/verification") + public ResponseEntity>> submit( + @Valid @RequestBody VerificationSubmitRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + CompanyVerification verification = verificationService.submit(userId, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + } + + // ── 企业:查询认证状态 ────────────────────────────────────── + + @Operation(summary = "查询企业认证状态", description = "企业查询自己的认证状态和历史") + @GetMapping("/companies/me/verification") + public ResponseEntity>> getMyVerification( + HttpServletRequest request) { + Long userId = resolveUserId(request); + CompanyVerification verification = verificationService.getMyVerification(userId); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + } + + // ── 管理员:待审核列表 ────────────────────────────────────── + + @Operation(summary = "待审核认证列表", description = "管理员查看认证申请列表,支持 status/from/to 筛选和分页") + @GetMapping("/admin/companies/verifications") + public ResponseEntity>> listVerifications( + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + verificationService.assertAdminRole(userId); + Page result = verificationService.listPending(status, from, to, page, size); + Map data = new HashMap<>(); + data.put("items", result.getContent().stream().map(verificationService::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("/admin/companies/verifications/{id}") + public ResponseEntity>> getDetail( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + verificationService.assertAdminRole(userId); + CompanyVerification verification = verificationService.getDetail(id); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + } + + // ── 管理员:审核决定 ──────────────────────────────────────── + + @Operation(summary = "审核决定", description = "管理员通过或拒绝认证申请,拒绝时必须填写原因") + @PostMapping("/admin/companies/verifications/{id}/decision") + public ResponseEntity>> decide( + @PathVariable Long id, + @Valid @RequestBody VerificationDecisionRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + verificationService.assertAdminRole(userId); + CompanyVerification verification = verificationService.decide(userId, id, req, + AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(verificationService.toView(verification), getRequestId(request))); + } + + // ── 管理员:审核历史 ──────────────────────────────────────── + + @Operation(summary = "认证审核历史", description = "管理员查看某个认证申请的审核历史") + @GetMapping("/admin/companies/verifications/history") + public ResponseEntity>>> listHistory( + @RequestParam Long verificationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + verificationService.assertAdminRole(userId); + List> data = verificationService.getHistory(verificationId); + 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 BusinessException(ErrorCode.UNAUTHORIZED); + return Long.valueOf(auth.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistory.java new file mode 100644 index 0000000..7fc6334 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistory.java @@ -0,0 +1,48 @@ +package com.mydreamjob.verification; + +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 = "company_verification_history") +public class CompanyVerificationHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "verification_id", nullable = false) + private Long verificationId; + + @Column(name = "from_status", length = 20) + private String fromStatus; + + @Column(name = "to_status", nullable = false, length = 20) + private String toStatus; + + @Column(name = "actor_user_id", nullable = false) + private Long actorUserId; + + @Column(name = "reason", length = 500) + private String reason; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistoryRepository.java new file mode 100644 index 0000000..7285204 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationHistoryRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.verification; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CompanyVerificationHistoryRepository extends JpaRepository { + + List findByVerificationIdOrderByCreatedAtAsc(Long verificationId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationRepository.java new file mode 100644 index 0000000..dbd510c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationRepository.java @@ -0,0 +1,25 @@ +package com.mydreamjob.verification; + +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.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface CompanyVerificationRepository extends JpaRepository { + + Optional findTopByCompanyIdOrderByIdDesc(Long companyId); + + Page findByStatusOrderByCreatedAtDesc(String status, Pageable pageable); + + Page findByStatusAndSubmittedAtBetweenOrderByCreatedAtDesc( + String status, LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findBySubmittedAtBetweenOrderByCreatedAtDesc( + LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java new file mode 100644 index 0000000..416bda8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationService.java @@ -0,0 +1,264 @@ +package com.mydreamjob.verification; + +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.notification.NotificationService; +import com.mydreamjob.verification.dto.VerificationDecisionRequest; +import com.mydreamjob.verification.dto.VerificationSubmitRequest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +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 CompanyVerificationService { + + private static final int MAX_PAGE_SIZE = 100; + + private final CompanyVerificationRepository verificationRepository; + private final CompanyVerificationHistoryRepository historyRepository; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final AuditService auditService; + + @PersistenceContext + private EntityManager entityManager; + + // ── 企业提交认证 ──────────────────────────────────────────── + + @Transactional + public CompanyVerification submit(Long companyUserId, VerificationSubmitRequest req, AuditContext ctx) { + Company company = requireCompany(companyUserId); + // 检查是否已有审批中的认证 + CompanyVerification existing = verificationRepository + .findTopByCompanyIdOrderByIdDesc(company.getId()).orElse(null); + if (existing != null && (existing.getStatus().equals("PENDING") + || existing.getStatus().equals("RESUBMITTED"))) { + throw new BusinessException(ErrorCode.VERIFICATION_ALREADY_PENDING); + } + if (existing != null && existing.getStatus().equals("APPROVED")) { + throw new BusinessException(ErrorCode.VERIFICATION_ALREADY_APPROVED); + } + + String targetStatus = (existing != null && existing.getStatus().equals("REJECTED")) + ? VerificationStatus.RESUBMITTED.name() + : VerificationStatus.PENDING.name(); + + CompanyVerification verification = CompanyVerification.builder() + .companyId(company.getId()) + .status(targetStatus) + .submittedBy(companyUserId) + .materialsJson(req.getMaterialsJson()) + .submittedAt(LocalDateTime.now()) + .build(); + verification = verificationRepository.save(verification); + recordHistory(verification.getId(), null, targetStatus, companyUserId, + "企业提交认证材料"); + auditService.log(companyUserId, "SUBMIT_VERIFICATION", "VERIFICATION", verification.getId(), + "Company verification submitted", ctx); + return verification; + } + + // ── 企业查询自己的认证状态 ─────────────────────────────────── + + public CompanyVerification getMyVerification(Long companyUserId) { + Company company = requireCompany(companyUserId); + return verificationRepository.findTopByCompanyIdOrderByIdDesc(company.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND)); + } + + // ── 管理员:待审核列表 ──────────────────────────────────────── + + public Page listPending(String status, LocalDateTime from, LocalDateTime to, + int page, int size) { + if (size > MAX_PAGE_SIZE) size = MAX_PAGE_SIZE; + PageRequest pr = PageRequest.of(page, size); + + boolean hasStatus = status != null && !status.isBlank(); + boolean hasRange = from != null && to != null; + + if (hasStatus && hasRange) + return verificationRepository.findByStatusAndSubmittedAtBetweenOrderByCreatedAtDesc( + status, from, to, pr); + if (hasStatus) + return verificationRepository.findByStatusOrderByCreatedAtDesc(status, pr); + if (hasRange) + return verificationRepository.findBySubmittedAtBetweenOrderByCreatedAtDesc(from, to, pr); + return verificationRepository.findAllByOrderByCreatedAtDesc(pr); + } + + // ── 管理员:查看认证详情 ────────────────────────────────────── + + public CompanyVerification getDetail(Long verificationId) { + return verificationRepository.findById(verificationId) + .orElseThrow(() -> new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND)); + } + + // ── 管理员:审核决定 ────────────────────────────────────────── + + @Transactional + public CompanyVerification decide(Long adminUserId, Long verificationId, + VerificationDecisionRequest req, AuditContext ctx) { + CompanyVerification verification = verificationRepository.findById(verificationId) + .orElseThrow(() -> new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND)); + + String decision = req.getDecision().toUpperCase(); + VerificationStatus targetStatus; + try { + targetStatus = VerificationStatus.valueOf(decision); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid decision: " + decision); + } + + if (targetStatus != VerificationStatus.APPROVED && targetStatus != VerificationStatus.REJECTED) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "decision must be APPROVED or REJECTED"); + } + + VerificationStatus current = VerificationStatus.valueOf(verification.getStatus()); + if (current == VerificationStatus.APPROVED) { + throw new BusinessException(ErrorCode.VERIFICATION_ALREADY_APPROVED); + } + // 幂等:已相同状态无需重复 + if (current == targetStatus) { + return verification; + } + if (current != VerificationStatus.PENDING && current != VerificationStatus.RESUBMITTED) { + throw new BusinessException(ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION); + } + + if (targetStatus == VerificationStatus.REJECTED + && (req.getReason() == null || req.getReason().isBlank())) { + throw new BusinessException(ErrorCode.VERIFICATION_REJECT_REASON_REQUIRED); + } + + String fromStatus = verification.getStatus(); + try { + verification.setStatus(targetStatus.name()); + verification.setReviewerId(adminUserId); + verification.setDecisionReason(req.getReason()); + verification.setDecidedAt(LocalDateTime.now()); + CompanyVerification saved = verificationRepository.saveAndFlush(verification); + recordHistory(saved.getId(), fromStatus, targetStatus.name(), adminUserId, + req.getReason()); + + // 更新企业认证状态 + if (targetStatus == VerificationStatus.APPROVED) { + Company company = companyRepository.findById(saved.getCompanyId()) + .orElse(null); + if (company != null && !Boolean.TRUE.equals(company.getVerified())) { + company.setVerified(true); + companyRepository.save(company); + } + } + + auditService.log(adminUserId, "DECIDE_VERIFICATION", "VERIFICATION", saved.getId(), + "Verification " + targetStatus.name() + " for company " + saved.getCompanyId(), ctx); + notificationService.publishEvent(saved.getSubmittedBy(), + "VERIFICATION_" + targetStatus.name(), + "企业认证审核结果", + "你的企业认证申请已被" + (targetStatus == VerificationStatus.APPROVED ? "通过" : "拒绝") + + (req.getReason() != null ? ":" + req.getReason() : ""), + "VERIFICATION", saved.getId(), + "verification-decision-" + saved.getId()); + return saved; + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + CompanyVerification fresh = verificationRepository.findById(verificationId) + .orElseThrow(() -> new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND)); + if (fresh.getStatus().equals(targetStatus.name())) return fresh; + throw new BusinessException(ErrorCode.INVALID_VERIFICATION_STATUS_TRANSITION, + "verification was modified concurrently"); + } + } + + // ── 管理员:审核历史 ────────────────────────────────────────── + + public List> getHistory(Long verificationId) { + return historyRepository.findByVerificationIdOrderByCreatedAtAsc(verificationId) + .stream().map(this::toHistoryView).collect(Collectors.toList()); + } + + // ── 视图 ──────────────────────────────────────────────────── + + public Map toView(CompanyVerification v) { + Map view = new HashMap<>(); + view.put("id", v.getId()); + view.put("companyId", v.getCompanyId()); + view.put("status", v.getStatus()); + view.put("submittedBy", v.getSubmittedBy()); + view.put("materialsJson", v.getMaterialsJson()); + view.put("reviewerId", v.getReviewerId()); + view.put("decisionReason", v.getDecisionReason()); + view.put("submittedAt", v.getSubmittedAt()); + view.put("decidedAt", v.getDecidedAt()); + view.put("version", v.getVersion()); + view.put("createdAt", v.getCreatedAt()); + return view; + } + + private Map toHistoryView(CompanyVerificationHistory h) { + Map v = new HashMap<>(); + v.put("id", h.getId()); + v.put("fromStatus", h.getFromStatus()); + v.put("toStatus", h.getToStatus()); + v.put("actorUserId", h.getActorUserId()); + v.put("reason", h.getReason()); + v.put("createdAt", h.getCreatedAt()); + return v; + } + + // ── 私有辅助 ──────────────────────────────────────────────── + + private Company requireCompany(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (!hasRole(user, User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + } + + private boolean hasRole(User user, User.Role role) { + return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); + } + + public void assertAdminRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (!hasRole(user, User.Role.ADMIN)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private void recordHistory(Long verificationId, String fromStatus, String toStatus, + Long actorUserId, String reason) { + historyRepository.save(CompanyVerificationHistory.builder() + .verificationId(verificationId) + .fromStatus(fromStatus) + .toStatus(toStatus) + .actorUserId(actorUserId) + .reason(reason) + .build()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/VerificationStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/VerificationStatus.java new file mode 100644 index 0000000..f6d3b20 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/VerificationStatus.java @@ -0,0 +1,32 @@ +package com.mydreamjob.verification; + +/** + * 企业认证状态枚举 + * + *

状态流转: + *

+ * PENDING ──> APPROVED (终态)
+ * PENDING ──> REJECTED
+ * REJECTED ──> RESUBMITTED ──> PENDING (重新进入审核)
+ * 
+ */ +public enum VerificationStatus { + PENDING, + APPROVED, + REJECTED, + RESUBMITTED; + + public boolean canTransitionTo(VerificationStatus target) { + if (target == null) return false; + return switch (this) { + case PENDING -> target == APPROVED || target == REJECTED; + case REJECTED -> target == RESUBMITTED; + case RESUBMITTED -> target == APPROVED || target == REJECTED; + case APPROVED -> false; + }; + } + + public boolean isTerminal() { + return this == APPROVED; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationDecisionRequest.java new file mode 100644 index 0000000..8ecb2a0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationDecisionRequest.java @@ -0,0 +1,16 @@ +package com.mydreamjob.verification.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 管理员审核决定请求 + */ +@Data +public class VerificationDecisionRequest { + + @NotBlank(message = "decision is required") + private String decision; + + private String reason; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationSubmitRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationSubmitRequest.java new file mode 100644 index 0000000..46744d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/verification/dto/VerificationSubmitRequest.java @@ -0,0 +1,14 @@ +package com.mydreamjob.verification.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 企业提交认证材料请求 + */ +@Data +public class VerificationSubmitRequest { + + @NotBlank(message = "materials is required") + private String materialsJson; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/application-dev.example.yml b/mydreamjob-server/src/main/resources/application-dev.example.yml new file mode 100644 index 0000000..ac0f869 --- /dev/null +++ b/mydreamjob-server/src/main/resources/application-dev.example.yml @@ -0,0 +1,26 @@ +# 开发环境配置模板 +# 复制此文件为 application-dev.yml 并填写实际值 +# DO NOT commit application-dev.yml to version control + +spring: + datasource: + url: jdbc:mysql://127.0.0.1:3307/mydreamjob?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + data: + redis: + host: 127.0.0.1 + port: 6379 + password: ${REDIS_PASSWORD} + rabbitmq: + host: 127.0.0.1 + port: 5672 + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + +app: + jwt: + secret: ${JWT_SECRET} + expiration-ms: 86400000 + refresh-expiration-ms: 604800000 + diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml new file mode 100644 index 0000000..96348e2 --- /dev/null +++ b/mydreamjob-server/src/main/resources/application.yml @@ -0,0 +1,157 @@ +server: + port: 8080 + servlet: + context-path: /api/v1 + error: + include-message: always + include-binding-errors: always + +spring: + application: + name: mydreamjob-server + + profiles: + active: ${SPRING_PROFILES_ACTIVE:} + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + + datasource: + url: ${DB_URL:jdbc:mysql://127.0.0.1:3307/mydreamjob?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${DB_USERNAME:mydreamjob} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + idle-timeout: 30000 + max-lifetime: 1800000 + connection-timeout: 30000 + + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 5000ms + lettuce: + pool: + max-active: 10 + max-idle: 5 + min-idle: 1 + + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + virtual-host: ${RABBITMQ_VHOST:/} + listener: + simple: + acknowledge-mode: auto + prefetch: 10 + concurrency: 3 + max-concurrency: 10 + default-requeue-rejected: false + + jackson: + default-property-inclusion: non_null + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + +# JWT +app: + jwt: + secret: ${JWT_SECRET} + expiration-ms: ${JWT_EXPIRATION_MS:86400000} + resume: + storage-type: ${RESUME_STORAGE_TYPE:local} + storage-dir: ${RESUME_STORAGE_DIR:./resumes} + export-dir: ${RESUME_EXPORT_DIR:./resume-exports} + refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} + + async: + resume-export: + core-pool-size: ${RESUME_EXPORT_CORE_POOL:2} + max-pool-size: ${RESUME_EXPORT_MAX_POOL:4} + queue-capacity: ${RESUME_EXPORT_QUEUE_CAPACITY:100} + + sms: + mock: + enabled: ${SMS_MOCK_ENABLED:false} + code-length: 6 + expire-seconds: ${SMS_CODE_EXPIRE_SECONDS:300} + max-attempts: 5 + lockout-seconds: ${SMS_LOCKOUT_SECONDS:300} + + offer: + scheduler: + enabled: ${OFFER_SCHEDULER_ENABLED:true} + expiry-interval-ms: ${OFFER_EXPIRY_INTERVAL_MS:60000} + reminder-interval-ms: ${OFFER_REMINDER_INTERVAL_MS:60000} + + match: + notification: + enabled: ${MATCH_NOTIFICATION_SCHEDULER_ENABLED:true} + retry-ms: ${MATCH_NOTIFICATION_RETRY_MS:60000} + + review-moderation: + sensitive-words: ${REVIEW_SENSITIVE_WORDS:刷单,代刷,有偿删评,夸大宣传} + rapid-submission-window-seconds: ${REVIEW_RAPID_WINDOW_SECONDS:60} + rapid-submission-limit: ${REVIEW_RAPID_LIMIT:3} + duplicate-content-window-seconds: ${REVIEW_DUPLICATE_WINDOW_SECONDS:300} + batch-window-seconds: ${REVIEW_BATCH_WINDOW_SECONDS:300} + batch-limit: ${REVIEW_BATCH_LIMIT:5} + + request-id: + header-name: X-Request-Id + generate-on-missing: true + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: when-authorized + show-components: always + health: + db: + enabled: true + redis: + enabled: true + rabbitmq: + enabled: true + diskSpace: + enabled: true + +logging: + level: + root: WARN + com.mydreamjob: INFO + org.springframework.security: WARN + org.hibernate.SQL: WARN + org.hibernate.type.descriptor.sql.BasicBinder: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId:-N/A}] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId:-N/A}] %-5level %logger{36} - %msg%n" + file: + name: logs/mydreamjob.log + size: 10MB + max-history: 7 + total-size-cap: 100MB diff --git a/mydreamjob-server/src/main/resources/db/migration/V10__application_status_history.sql b/mydreamjob-server/src/main/resources/db/migration/V10__application_status_history.sql new file mode 100644 index 0000000..fdbadb9 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V10__application_status_history.sql @@ -0,0 +1,12 @@ +CREATE TABLE application_status_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + operator_user_id BIGINT NOT NULL, + from_status VARCHAR(20), + to_status VARCHAR(20) NOT NULL, + reason VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_app_status_history_app FOREIGN KEY (application_id) REFERENCES applications(id), + INDEX idx_app_status_history_app (application_id), + INDEX idx_app_status_history_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V11__notifications.sql b/mydreamjob-server/src/main/resources/db/migration/V11__notifications.sql new file mode 100644 index 0000000..ea34d23 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V11__notifications.sql @@ -0,0 +1,13 @@ +CREATE TABLE notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(30) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + is_read TINYINT(1) NOT NULL DEFAULT 0, + read_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id), + INDEX idx_notifications_user_read (user_id, is_read), + INDEX idx_notifications_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V12__notification_event_fields.sql b/mydreamjob-server/src/main/resources/db/migration/V12__notification_event_fields.sql new file mode 100644 index 0000000..871dfd1 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V12__notification_event_fields.sql @@ -0,0 +1,5 @@ +ALTER TABLE notifications ADD COLUMN business_type VARCHAR(50); +ALTER TABLE notifications ADD COLUMN business_id BIGINT; +ALTER TABLE notifications ADD COLUMN idempotency_key VARCHAR(100); +ALTER TABLE notifications ADD CONSTRAINT uk_notifications_idempotency UNIQUE (idempotency_key); +ALTER TABLE notifications ADD INDEX idx_notifications_business (business_type, business_id); diff --git a/mydreamjob-server/src/main/resources/db/migration/V13__offers.sql b/mydreamjob-server/src/main/resources/db/migration/V13__offers.sql new file mode 100644 index 0000000..95b34c7 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V13__offers.sql @@ -0,0 +1,81 @@ +-- Flyway V13: Offer 管理 +-- 企业录用邀请:创建 → 发送 → 接受/拒绝/洽谈/到期失效 +-- 状态:DRAFT / SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED + +-- Offer 主表 +-- active_offer_key:可空判别列,配合唯一索引实现"每个投递最多一个有效 Offer"。 +-- DRAFT/SENT/NEGOTIATING/ACCEPTED 时 = 'ACTIVE' +-- REJECTED/EXPIRED 时 = NULL(释放槽位,允许该投递重新发 Offer) +-- MySQL 不支持 partial unique index,用可空列 + 唯一索引等价表达。 +CREATE TABLE offers ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + job_id BIGINT NOT NULL, + company_id BIGINT NOT NULL, + candidate_id BIGINT NOT NULL, + title VARCHAR(120) NOT NULL, + salary_min INT, + salary_max INT, + salary_currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + salary_period VARCHAR(10) NOT NULL DEFAULT 'MONTH', + work_city VARCHAR(80), + employment_type VARCHAR(30), + probation_months INT NOT NULL DEFAULT 0, + benefits VARCHAR(2000), + description TEXT NOT NULL, + expire_at DATETIME NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + active_offer_key VARCHAR(20), + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_offers_application FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT fk_offers_job FOREIGN KEY (job_id) REFERENCES jobs(id), + CONSTRAINT fk_offers_company FOREIGN KEY (company_id) REFERENCES companies(id), + CONSTRAINT fk_offers_candidate FOREIGN KEY (candidate_id) REFERENCES users(id), + CONSTRAINT fk_offers_created_by FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT uk_offers_active UNIQUE (application_id, active_offer_key), + CONSTRAINT ck_offers_salary_min CHECK (salary_min IS NULL OR salary_min >= 0), + CONSTRAINT ck_offers_salary_max CHECK (salary_max IS NULL OR salary_max >= 0), + CONSTRAINT ck_offers_salary_range CHECK (salary_min IS NULL OR salary_max IS NULL OR salary_min <= salary_max), + CONSTRAINT ck_offers_probation CHECK (probation_months >= 0), + INDEX idx_offers_company_status (company_id, status), + INDEX idx_offers_candidate_status (candidate_id, status), + INDEX idx_offers_status_expire (status, expire_at), + INDEX idx_offers_application (application_id), + INDEX idx_offers_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Offer 状态变更历史 +CREATE TABLE offer_status_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + offer_id BIGINT NOT NULL, + from_status VARCHAR(20), + to_status VARCHAR(20) NOT NULL, + operator_id BIGINT NOT NULL, + operator_role VARCHAR(20) NOT NULL, + reason VARCHAR(255), + request_id VARCHAR(32), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_offer_status_history_offer FOREIGN KEY (offer_id) REFERENCES offers(id), + INDEX idx_offer_status_history_offer (offer_id, created_at), + INDEX idx_offer_status_history_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Offer 提醒记录(3天前 / 1天前 / 手动) +CREATE TABLE offer_reminders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + offer_id BIGINT NOT NULL, + reminder_type VARCHAR(20) NOT NULL, + remind_at DATETIME NOT NULL, + sent_at DATETIME, + status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_offer_reminders UNIQUE (offer_id, reminder_type), + CONSTRAINT fk_offer_reminders_offer FOREIGN KEY (offer_id) REFERENCES offers(id), + INDEX idx_offer_reminders_offer (offer_id), + INDEX idx_offer_reminders_status (status), + INDEX idx_offer_reminders_remind (remind_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V14__resume_versions.sql b/mydreamjob-server/src/main/resources/db/migration/V14__resume_versions.sql new file mode 100644 index 0000000..de8f218 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V14__resume_versions.sql @@ -0,0 +1,46 @@ +-- Flyway V14: 简历版本管理与导出 +-- 学生多版本简历(与旧单简历 student_profiles.resume_url 完全独立) + +-- 简历版本表 +-- default_key: 可空判别列,配合唯一索引实现"每用户最多一个默认简历"(同 offers.active_offer_key 模式) +-- default 时 = 'DEFAULT',否则 = NULL(多个 NULL 不冲突) +CREATE TABLE resume_versions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(120), + version_no INT NOT NULL, + file_storage_path VARCHAR(500) NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT, + default_key VARCHAR(20), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_resume_versions_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT uk_resume_versions_user_no UNIQUE (user_id, version_no), + CONSTRAINT uk_resume_versions_default UNIQUE (user_id, default_key), + INDEX idx_resume_versions_user_created (user_id, created_at), + INDEX idx_resume_versions_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 简历导出任务表 +-- resume_version_id 在版本被删除后置 NULL,配合 resume_version_name 快照保留任务可读性 +CREATE TABLE resume_export_tasks ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + resume_version_id BIGINT, + resume_version_name VARCHAR(120), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + output_path VARCHAR(500), + output_name VARCHAR(255), + error_message VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + finished_at DATETIME, + CONSTRAINT fk_resume_export_tasks_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_resume_export_tasks_version FOREIGN KEY (resume_version_id) REFERENCES resume_versions(id) ON DELETE SET NULL, + INDEX idx_resume_export_tasks_user_created (user_id, created_at), + INDEX idx_resume_export_tasks_status (status), + INDEX idx_resume_export_tasks_version (resume_version_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V15__resume_export_snapshot.sql b/mydreamjob-server/src/main/resources/db/migration/V15__resume_export_snapshot.sql new file mode 100644 index 0000000..e3b86d5 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V15__resume_export_snapshot.sql @@ -0,0 +1,7 @@ +-- Flyway V15: 导出任务增加源文件快照 +-- 使导出任务在创建后即使简历版本被修改/文件被替换,仍使用创建任务时的原文件。 +ALTER TABLE resume_export_tasks ADD COLUMN source_storage_path VARCHAR(500); +ALTER TABLE resume_export_tasks ADD COLUMN source_file_name VARCHAR(255); +ALTER TABLE resume_export_tasks ADD COLUMN source_content_type VARCHAR(100); +ALTER TABLE resume_export_tasks ADD COLUMN source_file_size BIGINT; +ALTER TABLE resume_export_tasks ADD INDEX idx_resume_export_tasks_source (source_storage_path); diff --git a/mydreamjob-server/src/main/resources/db/migration/V16__bidirectional_match.sql b/mydreamjob-server/src/main/resources/db/migration/V16__bidirectional_match.sql new file mode 100644 index 0000000..d12fa88 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V16__bidirectional_match.sql @@ -0,0 +1,94 @@ +-- Flyway V16: 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 +-- 不依赖 AI/综合算法;评分与阈值均为 0-100 整数,人工录入、可审计。 + +-- 企业匹配阈值:required_candidate_score 为学生"岗位意愿分"须达到的企业阈值(默认 60) +CREATE TABLE company_match_rules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT NOT NULL, + required_candidate_score INT NOT NULL DEFAULT 60, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_company_match_rules_company UNIQUE (company_id), + CONSTRAINT fk_company_match_rules_company FOREIGN KEY (company_id) REFERENCES companies(id), + CONSTRAINT ck_company_match_rules_score CHECK (required_candidate_score BETWEEN 0 AND 100) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 学生匹配阈值:required_hr_score 为 HR 适配分须达到的学生阈值(默认 60) +CREATE TABLE student_match_rules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + required_hr_score INT NOT NULL DEFAULT 60, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_student_match_rules_student UNIQUE (student_user_id), + CONSTRAINT fk_student_match_rules_student FOREIGN KEY (student_user_id) REFERENCES users(id), + CONSTRAINT ck_student_match_rules_score CHECK (required_hr_score BETWEEN 0 AND 100) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 双向匹配记录:一条投递一条记录 +-- 阈值快照字段记录"本次计算实际使用的阈值",阈值更新只影响后续重算 +CREATE TABLE application_match_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + job_id BIGINT NOT NULL, + company_id BIGINT NOT NULL, + candidate_id BIGINT NOT NULL, + candidate_interest_score INT, + hr_suitability_score INT, + company_required_candidate_score INT NOT NULL DEFAULT 60, + student_required_hr_score INT NOT NULL DEFAULT 60, + candidate_score_source VARCHAR(30) NOT NULL DEFAULT 'MANUAL_INTEREST', + hr_score_source VARCHAR(30) NOT NULL DEFAULT 'HR_MANUAL', + status VARCHAR(30) NOT NULL DEFAULT 'PENDING_BOTH', + matched_at DATETIME, + version BIGINT 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_application_match_records_app UNIQUE (application_id), + CONSTRAINT fk_application_match_records_app FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT fk_application_match_records_job FOREIGN KEY (job_id) REFERENCES jobs(id), + CONSTRAINT fk_application_match_records_company FOREIGN KEY (company_id) REFERENCES companies(id), + CONSTRAINT fk_application_match_records_candidate FOREIGN KEY (candidate_id) REFERENCES users(id), + CONSTRAINT ck_app_match_candidate_score CHECK (candidate_interest_score IS NULL OR (candidate_interest_score BETWEEN 0 AND 100)), + CONSTRAINT ck_app_match_hr_score CHECK (hr_suitability_score IS NULL OR (hr_suitability_score BETWEEN 0 AND 100)), + INDEX idx_app_match_company_status (company_id, status), + INDEX idx_app_match_candidate_status (candidate_id, status), + INDEX idx_app_match_status_matched (status, matched_at), + INDEX idx_app_match_job (job_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 匹配事件:审计 + MATCH_SUCCEEDED 通知 Outbox +-- dedup_key:MATCH_SUCCEEDED 事件为 'MATCH_SUCCEEDED'(同一 application 唯一,防重复通知); +-- 其余审计事件(SCORE_UPDATED/STATUS_CHANGED/MATCH_CLOSED)为 NULL,允许多条 +CREATE TABLE application_match_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + actor_user_id BIGINT, + event_type VARCHAR(30) NOT NULL, + dedup_key VARCHAR(30), + old_status VARCHAR(30), + new_status VARCHAR(30), + old_score INT, + new_score INT, + score_field VARCHAR(30), + notification_status VARCHAR(20), + request_id VARCHAR(32), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_app_match_events_dedup UNIQUE (application_id, dedup_key), + CONSTRAINT fk_app_match_events_app FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT fk_app_match_events_actor FOREIGN KEY (actor_user_id) REFERENCES users(id), + INDEX idx_app_match_events_app (application_id, created_at), + INDEX idx_app_match_events_notify (event_type, notification_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 匹配解锁会话关联:一条投递最多解锁一个会话(幂等复用) +CREATE TABLE application_match_conversations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + conversation_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_app_match_conversations_app UNIQUE (application_id), + CONSTRAINT fk_app_match_conversations_app FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT fk_app_match_conversations_conv FOREIGN KEY (conversation_id) REFERENCES conversations(id), + INDEX idx_app_match_conversations_conv (conversation_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V17__job_review_dimensions.sql b/mydreamjob-server/src/main/resources/db/migration/V17__job_review_dimensions.sql new file mode 100644 index 0000000..a3e1610 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V17__job_review_dimensions.sql @@ -0,0 +1,14 @@ +-- Flyway V17: 岗位八维评价与评分卡 +-- 评价维度分独立成表,稀疏存储;不修改 job_reviews 表结构。 +CREATE TABLE job_review_dimension_scores ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + review_id BIGINT NOT NULL, + dimension_code VARCHAR(30) NOT NULL, + score INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_job_review_dimension UNIQUE (review_id, dimension_code), + CONSTRAINT fk_job_review_dimension_review FOREIGN KEY (review_id) REFERENCES job_reviews(id) ON DELETE CASCADE, + CONSTRAINT ck_job_review_dimension_score CHECK (score BETWEEN 1 AND 5), + INDEX idx_job_review_dimension_review (review_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V18__job_review_moderation.sql b/mydreamjob-server/src/main/resources/db/migration/V18__job_review_moderation.sql new file mode 100644 index 0000000..b122d2a --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V18__job_review_moderation.sql @@ -0,0 +1,27 @@ +-- Flyway V18: 评价审核与防刷基础模块 +-- 为 job_reviews 增加审核状态与防刷哈希;历史评价经 DEFAULT 自动置为 AUTO_APPROVED,不影响现有数据。 + +ALTER TABLE job_reviews ADD COLUMN moderation_status VARCHAR(20) NOT NULL DEFAULT 'AUTO_APPROVED'; +ALTER TABLE job_reviews ADD COLUMN moderation_reason VARCHAR(500); +ALTER TABLE job_reviews ADD COLUMN moderation_score DECIMAL(5,2); +ALTER TABLE job_reviews ADD COLUMN moderation_updated_at DATETIME; +ALTER TABLE job_reviews ADD COLUMN risk_level VARCHAR(20); +-- 防刷哈希:只存 SHA-256,不存明文 IP/User-Agent/内容原文哈希之外的敏感信息 +ALTER TABLE job_reviews ADD COLUMN content_hash CHAR(64); +ALTER TABLE job_reviews ADD COLUMN ip_hash CHAR(64); +ALTER TABLE job_reviews ADD COLUMN ua_hash CHAR(64); +ALTER TABLE job_reviews ADD INDEX idx_job_reviews_moderation (moderation_status); + +-- 审核历史(自动预审标记 + 管理员决策),用于 /admin/reviews/history +CREATE TABLE review_moderation_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + review_id BIGINT NOT NULL, + action VARCHAR(20) NOT NULL, + actor_user_id BIGINT, + reason_codes VARCHAR(500), + human_reason VARCHAR(500), + risk_level VARCHAR(20), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_review_moderation_history_review FOREIGN KEY (review_id) REFERENCES job_reviews(id) ON DELETE CASCADE, + INDEX idx_review_moderation_history_review (review_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V19__job_review_hash_columns_varchar.sql b/mydreamjob-server/src/main/resources/db/migration/V19__job_review_hash_columns_varchar.sql new file mode 100644 index 0000000..f43c67a --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V19__job_review_hash_columns_varchar.sql @@ -0,0 +1,6 @@ +-- Flyway V19: 修复生产 schema-validation 兼容问题 +-- V18 将 content_hash/ip_hash/ua_hash 建为 CHAR(64),而 JPA 实体映射为 VARCHAR(64), +-- Hibernate ddl-auto=validate 在 MySQL 下校验失败。此处统一改为 VARCHAR(64),保持可空。 +ALTER TABLE job_reviews MODIFY COLUMN content_hash VARCHAR(64); +ALTER TABLE job_reviews MODIFY COLUMN ip_hash VARCHAR(64); +ALTER TABLE job_reviews MODIFY COLUMN ua_hash VARCHAR(64); diff --git a/mydreamjob-server/src/main/resources/db/migration/V1_1__seed_data.sql b/mydreamjob-server/src/main/resources/db/migration/V1_1__seed_data.sql new file mode 100644 index 0000000..0d57913 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V1_1__seed_data.sql @@ -0,0 +1,20 @@ +-- V1.1: 种子数据 +-- Week 1: 最小可测数据 + +-- 测试管理员用户 (密码: admin123) +INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) +VALUES ('13800000001', 'admin@mydreamjob.com', '管理员', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'ADMIN', 1, 1); + +INSERT INTO user_roles (user_id, role) VALUES (1, 'ADMIN'); + +-- 测试学生用户 (密码: student123) +INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) +VALUES ('13800000002', 'student@mydreamjob.com', '测试学生', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'STUDENT', 1, 1); + +INSERT INTO user_roles (user_id, role) VALUES (2, 'STUDENT'); + +-- 测试企业用户 (密码: company123) +INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) +VALUES ('13800000003', 'company@mydreamjob.com', '测试企业', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'COMPANY', 1, 1); + +INSERT INTO user_roles (user_id, role) VALUES (3, 'COMPANY'); diff --git a/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql b/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql new file mode 100644 index 0000000..8e25897 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql @@ -0,0 +1,72 @@ +-- Flyway V1: 基础表结构 +-- Week 1 数据模型:用户、角色、验证码、审计日志、Token + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + phone VARCHAR(20) NOT NULL UNIQUE, + email VARCHAR(100), + nickname VARCHAR(50), + avatar_url VARCHAR(500), + password_hash VARCHAR(255), + role VARCHAR(30) NOT NULL DEFAULT 'STUDENT', + is_active TINYINT(1) NOT NULL DEFAULT 1, + is_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, + INDEX idx_users_phone (phone), + INDEX idx_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 用户角色关联表 +CREATE TABLE IF NOT EXISTS user_roles ( + user_id BIGINT NOT NULL, + role VARCHAR(30) NOT NULL, + PRIMARY KEY (user_id, role), + CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 短信验证码表 +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + phone VARCHAR(20) NOT NULL, + code VARCHAR(10) NOT NULL, + purpose VARCHAR(50) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, + expired_at DATETIME NOT NULL, + used TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_sms_phone_created (phone, created_at), + INDEX idx_sms_phone_code (phone, code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 审计日志表 +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50), + entity_id BIGINT, + details TEXT, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + request_id VARCHAR(32), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_audit_user (user_id, created_at), + INDEX idx_audit_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- JWT Token 表 +CREATE TABLE IF NOT EXISTS auth_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(255) NOT NULL, + token_type VARCHAR(20) NOT NULL DEFAULT 'ACCESS', + expired_at DATETIME NOT NULL, + revoked TINYINT(1) NOT NULL DEFAULT 0, + refresh_token_hash VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_auth_tokens_user (user_id, revoked), + INDEX idx_auth_tokens_hash (token_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V20__interviews.sql b/mydreamjob-server/src/main/resources/db/migration/V20__interviews.sql new file mode 100644 index 0000000..a92bf0a --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V20__interviews.sql @@ -0,0 +1,43 @@ +-- Flyway V20: 面试管理基础模块 +CREATE TABLE interviews ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + company_user_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + round_no INT NOT NULL DEFAULT 1, + title VARCHAR(200) NOT NULL, + interview_type VARCHAR(20) NOT NULL, + start_at DATETIME NOT NULL, + end_at DATETIME NOT NULL, + timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Shanghai', + meeting_url VARCHAR(500), + address VARCHAR(500), + interviewer_name VARCHAR(100), + notes TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'SCHEDULED', + version BIGINT 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 fk_interviews_application FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT fk_interviews_company_user FOREIGN KEY (company_user_id) REFERENCES users(id), + CONSTRAINT fk_interviews_student_user FOREIGN KEY (student_user_id) REFERENCES users(id), + INDEX idx_interviews_application (application_id), + INDEX idx_interviews_company_user (company_user_id), + INDEX idx_interviews_student_user (student_user_id), + INDEX idx_interviews_status (status), + INDEX idx_interviews_start_at (start_at), + INDEX idx_interviews_application_status (application_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE interview_status_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + interview_id BIGINT NOT NULL, + from_status VARCHAR(30), + to_status VARCHAR(30) NOT NULL, + actor_user_id BIGINT NOT NULL, + reason VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_interview_history_interview FOREIGN KEY (interview_id) REFERENCES interviews(id), + INDEX idx_interview_history_interview (interview_id), + INDEX idx_interview_history_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V21__company_verification.sql b/mydreamjob-server/src/main/resources/db/migration/V21__company_verification.sql new file mode 100644 index 0000000..01ddf33 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V21__company_verification.sql @@ -0,0 +1,37 @@ +-- V21__company_verification.sql +-- 企业认证:认证记录 + 审核历史 + +CREATE TABLE company_verifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + submitted_by BIGINT NOT NULL, + materials_json TEXT COMMENT '营业执照、授权书等认证材料JSON', + reviewer_id BIGINT, + decision_reason VARCHAR(500) COMMENT '审核原因(通过备注或拒绝原因)', + submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + decided_at DATETIME, + version BIGINT 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 fk_verification_company FOREIGN KEY (company_id) REFERENCES companies(id), + CONSTRAINT fk_verification_submitted FOREIGN KEY (submitted_by) REFERENCES users(id), + CONSTRAINT fk_verification_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id), + INDEX idx_verification_company (company_id), + INDEX idx_verification_status (status), + INDEX idx_verification_submitted_at (submitted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE company_verification_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + verification_id BIGINT NOT NULL, + from_status VARCHAR(20), + to_status VARCHAR(20) NOT NULL, + actor_user_id BIGINT NOT NULL, + reason VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_ver_hist_verification FOREIGN KEY (verification_id) REFERENCES company_verifications(id), + CONSTRAINT fk_ver_hist_actor FOREIGN KEY (actor_user_id) REFERENCES users(id), + INDEX idx_ver_hist_verification (verification_id), + INDEX idx_ver_hist_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql b/mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql new file mode 100644 index 0000000..827bef5 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql @@ -0,0 +1,9 @@ +-- V22: 岗位搜索索引 +-- 用途: 支持 GET /jobs/search 的 employmentType 筛选和 salary 排序 +-- 不重复创建已有索引: idx_jobs_company_status, idx_jobs_status_created, idx_jobs_city_status + +-- 1. 用工类型筛选: WHERE status='PUBLISHED' AND employment_type=? +CREATE INDEX idx_jobs_status_employment_type ON jobs (status, employment_type); + +-- 2. 薪资排序: WHERE status='PUBLISHED' ORDER BY salary_max DESC +CREATE INDEX idx_jobs_status_salary_max ON jobs (status, salary_max); \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V23__student_review_weight_preferences.sql b/mydreamjob-server/src/main/resources/db/migration/V23__student_review_weight_preferences.sql new file mode 100644 index 0000000..8da67ac --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V23__student_review_weight_preferences.sql @@ -0,0 +1,49 @@ +-- Flyway V23: 学生评价权重偏好 +-- 表 1: 偏好主记录(student_review_weight_preferences) +-- 表 2: 维度权重子记录(student_review_dimension_weights) +-- "恰好 8 行、总和 100.00" 由 Service 事务校验 + +CREATE TABLE student_review_weight_preferences ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + source VARCHAR(20) NOT NULL, + template_code VARCHAR(50) NULL, + version BIGINT 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 uq_student_weight_pref UNIQUE (student_user_id), + CONSTRAINT chk_weight_pref_source CHECK (source IN ('TEMPLATE', 'CUSTOM')), + CONSTRAINT chk_weight_pref_template CHECK ( + (source = 'TEMPLATE' AND template_code IS NOT NULL) + OR + (source = 'CUSTOM' AND template_code IS NULL) + ), + CONSTRAINT fk_weight_pref_user FOREIGN KEY (student_user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE student_review_dimension_weights ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + preference_id BIGINT NOT NULL, + dimension_code VARCHAR(30) NOT NULL, + weight DECIMAL(5,2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uq_pref_dimension UNIQUE (preference_id, dimension_code), + CONSTRAINT chk_dim_weight_code CHECK ( + dimension_code IN ( + 'AUTHENTICITY', + 'BENEFITS', + 'SALARY_TRANSPARENCY', + 'JD_CLARITY', + 'GROWTH', + 'TEAM_ATMOSPHERE', + 'PROMOTION', + 'WORK_LIFE_BALANCE' + ) + ), + CONSTRAINT chk_dim_weight_range CHECK (weight >= 0.00 AND weight <= 100.00), + CONSTRAINT fk_dim_weight_pref FOREIGN KEY (preference_id) + REFERENCES student_review_weight_preferences(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_weight_pref_user ON student_review_weight_preferences (student_user_id); \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql b/mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql new file mode 100644 index 0000000..c00d9d9 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql @@ -0,0 +1,71 @@ +-- Flyway V2: 企业、学生资料、岗位、投递 +-- 第 1 天:仅建表,不插入种子数据 + +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; diff --git a/mydreamjob-server/src/main/resources/db/migration/V3__resume_metadata.sql b/mydreamjob-server/src/main/resources/db/migration/V3__resume_metadata.sql new file mode 100644 index 0000000..2064502 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V3__resume_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE student_profiles ADD COLUMN resume_original_name VARCHAR(255); +ALTER TABLE student_profiles ADD COLUMN resume_content_type VARCHAR(100); +ALTER TABLE student_profiles ADD COLUMN resume_size BIGINT; diff --git a/mydreamjob-server/src/main/resources/db/migration/V4__job_favorites.sql b/mydreamjob-server/src/main/resources/db/migration/V4__job_favorites.sql new file mode 100644 index 0000000..5814f7c --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V4__job_favorites.sql @@ -0,0 +1,12 @@ +CREATE TABLE job_favorites ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + job_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_job_favorites UNIQUE (job_id, student_user_id), + CONSTRAINT fk_job_favorites_job FOREIGN KEY (job_id) REFERENCES jobs(id), + CONSTRAINT fk_job_favorites_student FOREIGN KEY (student_user_id) REFERENCES users(id), + INDEX idx_job_favorites_job (job_id), + INDEX idx_job_favorites_student (student_user_id), + INDEX idx_job_favorites_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V5__student_preferences.sql b/mydreamjob-server/src/main/resources/db/migration/V5__student_preferences.sql new file mode 100644 index 0000000..0e3db77 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V5__student_preferences.sql @@ -0,0 +1,15 @@ +CREATE TABLE student_preferences ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + expected_salary_min INT, + expected_salary_max INT, + preferred_cities JSON, + employment_types JSON, + preferred_industries JSON, + preferred_job_types JSON, + remote_allowed 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_student_preferences_user UNIQUE (student_user_id), + CONSTRAINT fk_student_preferences_user FOREIGN KEY (student_user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V6__job_reviews.sql b/mydreamjob-server/src/main/resources/db/migration/V6__job_reviews.sql new file mode 100644 index 0000000..072c078 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V6__job_reviews.sql @@ -0,0 +1,18 @@ +CREATE TABLE job_reviews ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + job_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + rating TINYINT NOT NULL, + content TEXT NOT NULL, + tags JSON, + status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_job_reviews UNIQUE (job_id, student_user_id), + CONSTRAINT fk_job_reviews_job FOREIGN KEY (job_id) REFERENCES jobs(id), + CONSTRAINT fk_job_reviews_student FOREIGN KEY (student_user_id) REFERENCES users(id), + CONSTRAINT ck_job_reviews_rating CHECK (rating BETWEEN 1 AND 5), + INDEX idx_job_reviews_job (job_id), + INDEX idx_job_reviews_status (status), + INDEX idx_job_reviews_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V7__job_reviews_rating_int.sql b/mydreamjob-server/src/main/resources/db/migration/V7__job_reviews_rating_int.sql new file mode 100644 index 0000000..07ae094 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V7__job_reviews_rating_int.sql @@ -0,0 +1 @@ +ALTER TABLE job_reviews MODIFY COLUMN rating INT NOT NULL; diff --git a/mydreamjob-server/src/main/resources/db/migration/V8__candidate_feedback.sql b/mydreamjob-server/src/main/resources/db/migration/V8__candidate_feedback.sql new file mode 100644 index 0000000..ba7ce0f --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V8__candidate_feedback.sql @@ -0,0 +1,17 @@ +CREATE TABLE candidate_feedback ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + application_id BIGINT NOT NULL, + company_user_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + rating INT NOT NULL, + content TEXT NOT NULL, + tags JSON, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_candidate_feedback_app UNIQUE (application_id), + CONSTRAINT fk_candidate_feedback_app FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT ck_candidate_feedback_rating CHECK (rating BETWEEN 1 AND 5), + INDEX idx_candidate_feedback_app (application_id), + INDEX idx_candidate_feedback_company (company_user_id), + INDEX idx_candidate_feedback_student (student_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V9__messaging.sql b/mydreamjob-server/src/main/resources/db/migration/V9__messaging.sql new file mode 100644 index 0000000..070eccc --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V9__messaging.sql @@ -0,0 +1,45 @@ +CREATE TABLE conversations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200), + type VARCHAR(20) NOT NULL DEFAULT 'DIRECT', + created_by BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_conversations_created_by FOREIGN KEY (created_by) REFERENCES users(id), + INDEX idx_conversations_created_by (created_by), + INDEX idx_conversations_updated (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE conversation_members ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + conversation_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_conv_members_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, + CONSTRAINT fk_conv_members_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT uk_conv_members UNIQUE (conversation_id, user_id), + INDEX idx_conv_members_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE messages ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + conversation_id BIGINT NOT NULL, + sender_user_id BIGINT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_messages_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_user_id) REFERENCES users(id), + INDEX idx_messages_conversation_created (conversation_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE message_reads ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + message_id BIGINT NOT NULL, + reader_user_id BIGINT NOT NULL, + read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_message_reads_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + CONSTRAINT fk_message_reads_reader FOREIGN KEY (reader_user_id) REFERENCES users(id), + CONSTRAINT uk_message_reads UNIQUE (message_id, reader_user_id), + INDEX idx_message_reads_message (message_id), + INDEX idx_message_reads_reader (reader_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java new file mode 100644 index 0000000..89d5766 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -0,0 +1,155 @@ +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ApplicationServiceTest { + + @Mock private ApplicationRepository applicationRepository; + @Mock private com.mydreamjob.job.JobService jobService; + @Mock private com.mydreamjob.auth.UserRepository userRepository; + @Mock private AuditService auditService; + @Mock private ApplicationStatusHistoryRepository historyRepository; + @Mock private com.mydreamjob.notification.NotificationService notificationService; + @InjectMocks private ApplicationService applicationService; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 1L; + private static final Long JOB_ID = 10L; + private static final Long APP_ID = 200L; + private com.mydreamjob.job.Job publishedJob; + private Application application; + + @BeforeEach + void setUp() { + publishedJob = com.mydreamjob.job.Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").status("PUBLISHED").build(); + application = Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); + } + + private void mockStudentRole() { + com.mydreamjob.auth.User user = com.mydreamjob.auth.User.builder().id(STUDENT_ID).phone("13800000001") + .role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build(); + user.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT))); + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user)); + } + + // ── 撤回 ───────────────────────────────────────────────── + + @Test void withdraw_submitted_shouldSucceed() { + mockStudentRole(); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Application result = applicationService.withdraw(STUDENT_ID, APP_ID); + assertEquals("WITHDRAWN", result.getStatus()); + } + + @Test void withdraw_wrongStudent_shouldReturn403() { + mockStudentRole(); + application.setStudentUserId(999L); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> applicationService.withdraw(STUDENT_ID, APP_ID)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + } + + @Test void withdraw_alreadyWithdrawn_shouldReturn409() { + mockStudentRole(); + application.setStatus("WITHDRAWN"); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> applicationService.withdraw(STUDENT_ID, APP_ID)); + assertEquals(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + @Test void withdraw_rejected_shouldReturn409() { + mockStudentRole(); + application.setStatus("REJECTED"); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> applicationService.withdraw(STUDENT_ID, APP_ID)); + assertEquals(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + // ── 时间线 ─────────────────────────────────────────────── + + @Test void getTimeline_asStudent_shouldSucceed() { + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of()); + + var result = applicationService.getTimeline(APP_ID, STUDENT_ID); + assertNotNull(result); + } + + @Test void getTimeline_wrongUser_shouldReturn403() { + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + + BusinessException ex = assertThrows(BusinessException.class, + () -> applicationService.getTimeline(APP_ID, 999L)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 状态历史记录 ───────────────────────────────────────── + + @Test void apply_shouldRecordHistory() { + mockStudentRole(); + ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + applicationService.apply(STUDENT_ID, req); + verify(historyRepository, times(1)).save(any()); + } + + // ── 企业状态更新记录历史 ───────────────────────────────── + + @Test void updateStatus_shouldRecordHistory() { + ApplicationStatusRequest req = new ApplicationStatusRequest(); req.setStatus("VIEWED"); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + applicationService.updateStatus(COMPANY_ID, APP_ID, req); + verify(historyRepository, times(1)).save(any()); + } + + // ── 原有测试兼容 ───────────────────────────────────────── + + @Test void apply_shouldSucceed() { + mockStudentRole(); + ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Application result = applicationService.apply(STUDENT_ID, req); + assertEquals("SUBMITTED", result.getStatus()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/audit/AuditServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/audit/AuditServiceTest.java new file mode 100644 index 0000000..925e3a4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/audit/AuditServiceTest.java @@ -0,0 +1,126 @@ +package com.mydreamjob.audit; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuditServiceTest { + + @Mock + private AuditLogRepository auditLogRepository; + + @InjectMocks + private AuditService auditService; + + @BeforeEach + void setUp() { + } + + // ── 登录日志 ────────────────────────────────────────────── + + @Test + void logLogin_shouldPersistAuditRecord() { + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + auditService.logLogin(100L, "192.168.1.1", "Chrome/120", "req-123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogRepository).save(captor.capture()); + AuditLog saved = captor.getValue(); + + assertEquals(100L, saved.getUserId()); + assertEquals("LOGIN", saved.getAction()); + assertEquals("USER", saved.getEntityType()); + assertEquals(100L, saved.getEntityId()); + assertEquals("User logged in", saved.getDetails()); + assertEquals("192.168.1.1", saved.getIpAddress()); + assertEquals("Chrome/120", saved.getUserAgent()); + assertEquals("req-123", saved.getRequestId()); + assertNotNull(saved.getCreatedAt()); + } + + @Test + void logLogout_shouldPersistAuditRecord() { + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + auditService.logLogout(100L, "10.0.0.1", "Firefox/100", "req-456"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogRepository).save(captor.capture()); + AuditLog saved = captor.getValue(); + + assertEquals("LOGOUT", saved.getAction()); + assertEquals("User logged out", saved.getDetails()); + } + + // ── 敏感信息脱敏 ────────────────────────────────────────── + + @Test + void log_shouldRedactSensitiveDetails() { + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + auditService.log(100L, "LOGIN", "USER", 100L, + "code=123456, password=secret, token=abc.def.ghi", + null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogRepository).save(captor.capture()); + String details = captor.getValue().getDetails(); + + assertFalse(details.contains("123456"), "should redact code value"); + assertFalse(details.contains("secret"), "should redact password value"); + assertFalse(details.contains("abc.def.ghi"), "should redact token value"); + assertTrue(details.contains("[REDACTED]"), "should contain redacted markers"); + } + + @Test + void log_withNullDetails_shouldNotCrash() { + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertDoesNotThrow(() -> auditService.logLogin(100L, null, null, null)); + + verify(auditLogRepository).save(any()); + } + + // ── 异常容错 ────────────────────────────────────────────── + + @Test + void log_shouldNotThrowWhenRepositoryFails() { + doThrow(new RuntimeException("DB down")).when(auditLogRepository).save(any()); + + // 审计异常不应向上传播 + assertDoesNotThrow(() -> auditService.logLogin(100L, "1.1.1.1", "UA", "r1")); + verify(auditLogRepository).save(any()); + } + + // ── createdAt 可靠生成 ──────────────────────────────────── + + @Test + void log_shouldSetCreatedAtExplicitly() { + LocalDateTime before = LocalDateTime.now(); + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + auditService.logLogin(100L, null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogRepository).save(captor.capture()); + LocalDateTime createdAt = captor.getValue().getCreatedAt(); + + assertNotNull(createdAt); + assertFalse(createdAt.isBefore(before.minusSeconds(1))); + assertFalse(createdAt.isAfter(before.plusSeconds(5))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java new file mode 100644 index 0000000..9355b85 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -0,0 +1,111 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private SmsVerificationService smsVerificationService; + + @Mock + private AuditService auditService; + + @InjectMocks + private AuthService authService; + + @Test + void mockLogin_shouldReturnTokenWhenCodeValid() { + User user = User.builder() + .id(1L).phone("13800000001").nickname("测试用户") + .role(User.Role.STUDENT).active(true).verified(true).build(); + + when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); + when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) + .thenReturn("access-token"); + when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) + .thenReturn("refresh-token"); + + Map result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit"); + + assertEquals("access-token", result.get("accessToken")); + assertEquals("refresh-token", result.get("refreshToken")); + assertEquals(1L, result.get("userId")); + verify(auditService, times(1)).logLogin(1L, "10.0.0.1", "JUnit", "req-1"); + } + + @Test + void mockLogin_shouldCreateUserWhenNotFound() { + when(smsVerificationService.verifyCode("13800000099", "123456", "login")).thenReturn(true); + when(userRepository.findByPhone("13800000099")).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(invocation -> { + User u = invocation.getArgument(0); + u.setId(99L); + return u; + }); + when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) + .thenReturn("access-token"); + when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) + .thenReturn("refresh-token"); + + Map result = authService.mockLogin("13800000099", "123456", "login", null, null, null); + + assertEquals(99L, result.get("userId")); + assertEquals(User.Role.STUDENT, result.get("role")); + } + + @Test + void mockLogin_shouldPropagatePurposeToVerifyCode() { + when(smsVerificationService.verifyCode("13800000001", "123456", "password-reset")) + .thenReturn(true); + User user = User.builder().id(1L).phone("13800000001").role(User.Role.STUDENT).active(true).verified(true).build(); + when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) + .thenReturn("token"); + when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) + .thenReturn("refresh"); + + authService.mockLogin("13800000001", "123456", "password-reset", null, null, null); + verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset"); + } + + @Test + void mockLogin_shouldThrowWhenCodeInvalid() { + when(smsVerificationService.verifyCode(any(), any(), any())) + .thenThrow(new BusinessException(ErrorCode.SMS_CODE_INVALID)); + + assertThrows(BusinessException.class, + () -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); + } + + @Test + void getCurrentUser_shouldThrowWhenUserNotFound() { + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> authService.getCurrentUser(999L)); + assertEquals(ErrorCode.USER_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java new file mode 100644 index 0000000..027fd71 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java @@ -0,0 +1,54 @@ +package com.mydreamjob.auth; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + String secret = "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security"; + jwtTokenProvider = new JwtTokenProvider(secret, 3600000L, 86400000L); + } + + @Test + void testTokenGenerationAndValidation() { + String token = jwtTokenProvider.generateAccessToken(1L, "13800000001", Map.of("role", "STUDENT")); + assertNotNull(token); + assertTrue(jwtTokenProvider.validateToken(token)); + assertEquals(1L, jwtTokenProvider.parseUserId(token)); + } + + @Test + void testRefreshToken() { + String token = jwtTokenProvider.generateRefreshToken(2L, "13800000002"); + assertNotNull(token); + assertTrue(jwtTokenProvider.validateToken(token)); + assertEquals(2L, jwtTokenProvider.parseUserId(token)); + } + + @Test + void testExpiredToken() { + JwtTokenProvider shortLived = new JwtTokenProvider( + "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security", 1L, 86400000L); + try { + Thread.sleep(10); + } catch (InterruptedException ignored) {} + String token = shortLived.generateAccessToken(1L, "13800000001", null); + assertTrue(shortLived.isTokenExpired(token)); + } + + @Test + void testInvalidToken() { + assertFalse(jwtTokenProvider.validateToken("invalid-token")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java new file mode 100644 index 0000000..d3b689d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java @@ -0,0 +1,164 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SmsVerificationServiceTest { + + @Mock + private SmsVerificationCodeRepository codeRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private AuditService auditService; + + @InjectMocks + private SmsVerificationService smsVerificationService; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void sendCode_shouldSucceed() { + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.save(any())).thenReturn(null); + + assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); + verify(codeRepository, times(1)).save(any()); + verify(valueOperations, times(1)).set(anyString(), any(), anyLong(), any()); + } + + @Test + void sendCode_shouldThrowWhenRateLimited() { + when(redisTemplate.hasKey(eq("sms:lockout:13800000001"))).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, + () -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); + assertEquals(ErrorCode.SMS_RATE_LIMITED.getCode(), ex.getErrorCodeInt()); + } + + // ---- verifyCode tests ---- + + @Test + void verifyCode_success_withPurpose() { + SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) + .thenReturn(java.util.stream.Stream.of(code)); + + assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); + assertTrue(code.getUsed()); + verify(codeRepository, times(1)).save(code); + } + + @Test + void verifyCode_replayMustFail_afterUsed() { + SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // 第一次验证通过后 used=true + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) + .thenReturn(java.util.stream.Stream.of(code)); + + // 第一次成功 + smsVerificationService.verifyCode("13800000001", "123456", "login"); + + // 第二次重放:已使用的验证码不应再查到(used=true 的不在查询结果中) + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) + .thenReturn(java.util.stream.Stream.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); + assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); + } + + @Test + void verifyCode_purposeMismatchMustFail() { + SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // 查询 password-reset 用途,但实际只有 login 的记录 + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "password-reset")) + .thenReturn(java.util.stream.Stream.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> smsVerificationService.verifyCode("13800000001", "123456", "password-reset")); + assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); + } + + @Test + void verifyCode_usedCodeMustFail_directly() { + SmsVerificationCode code = buildCode("13800000001", "123456", "login", true); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) + .thenReturn(java.util.stream.Stream.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); + assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); + } + + @Test + void verifyCode_latestPriority_returnsNewestOnly() { + LocalDateTime oldTime = LocalDateTime.now().minusMinutes(5); + LocalDateTime newTime = LocalDateTime.now().minusMinutes(1); + + SmsVerificationCode oldCode = SmsVerificationCode.builder() + .phone("13800000001").code("999999").purpose("login") + .expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(oldTime).build(); + SmsVerificationCode newCode = SmsVerificationCode.builder() + .phone("13800000001").code("123456").purpose("login") + .expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(newTime).build(); + + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // repository 按 createdAt DESC 返回,第一个是最新的 + when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) + .thenReturn(java.util.stream.Stream.of(newCode)); + + assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); + // 旧验证码 999999 不应被验证通过 + verify(codeRepository, never()).save(oldCode); + } + + @Test + void verifyCode_noRecordMustFail() { + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(codeRepository.streamLatestUnusedByPhoneAndPurpose(any(), any())) + .thenReturn(java.util.stream.Stream.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); + assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); + } + + private SmsVerificationCode buildCode(String phone, String code, String purpose, boolean used) { + return SmsVerificationCode.builder() + .phone(phone).code(code).purpose(purpose) + .expiredAt(LocalDateTime.now().plusMinutes(5)) + .attempts(0).maxAttempts(5).used(used) + .createdAt(LocalDateTime.now()).build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java new file mode 100644 index 0000000..8012fa3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java @@ -0,0 +1,245 @@ +package com.mydreamjob.bidirectionalmatch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 双向匹配接口集成测试:JWT、统一响应、权限、校验、OpenAPI 一致性。 + * 激活 profile: integration-test(H2 内嵌数据库 + Flyway V16) + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ApplicationMatchControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private BidirectionalMatchService matchService; + @MockBean private MatchRuleService matchRuleService; + @MockBean private MatchConversationService matchConversationService; + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 10L; + private static final Long APPLICATION_ID = 5L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_ID, "3", Map.of("roles", "COMPANY")); + } + + private ApplicationMatchRecord record() { + return ApplicationMatchRecord.builder().id(1L).applicationId(APPLICATION_ID) + .jobId(10L).companyId(1L).candidateId(STUDENT_ID) + .candidateInterestScore(80).hrSuitabilityScore(80) + .companyRequiredCandidateScore(60).studentRequiredHrScore(60) + .status(ApplicationMatchRecord.STATUS_MATCHED).build(); + } + + // ── 阈值 ────────────────────────────────────────────────── + + @Test + void getCompanyRule_shouldReturn200() throws Exception { + when(matchRuleService.getCompanyRule(COMPANY_ID)).thenReturn(Map.of("companyId", 1L, "requiredCandidateScore", 60)); + mockMvc.perform(get("/companies/me/match-rule").header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.requiredCandidateScore").value(60)); + } + + @Test + void putCompanyRule_shouldReturn200() throws Exception { + when(matchRuleService.updateCompanyRule(eq(COMPANY_ID), eq(70))).thenReturn(Map.of("requiredCandidateScore", 70)); + mockMvc.perform(put("/companies/me/match-rule") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"requiredCandidateScore\":70}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.requiredCandidateScore").value(70)); + } + + @Test + void putCompanyRule_invalidScore_shouldReturn400() throws Exception { + mockMvc.perform(put("/companies/me/match-rule") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"requiredCandidateScore\":101}")) + .andExpect(status().isBadRequest()); + } + + @Test + void getStudentRule_shouldReturn200() throws Exception { + when(matchRuleService.getStudentRule(STUDENT_ID)).thenReturn(Map.of("studentUserId", STUDENT_ID, "requiredHrScore", 60)); + mockMvc.perform(get("/profile/me/match-rule").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void putStudentRule_shouldReturn200() throws Exception { + when(matchRuleService.updateStudentRule(eq(STUDENT_ID), eq(75))).thenReturn(Map.of("requiredHrScore", 75)); + mockMvc.perform(put("/profile/me/match-rule") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"requiredHrScore\":75}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.requiredHrScore").value(75)); + } + + // ── 评分 ────────────────────────────────────────────────── + + @Test + void putCandidateInterestScore_shouldReturn200() throws Exception { + when(matchService.submitCandidateInterestScore(eq(STUDENT_ID), eq(APPLICATION_ID), eq(80), any())) + .thenReturn(record()); + mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":80}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("MATCHED")); + } + + @Test + void putCandidateInterestScore_invalidScore_shouldReturn400() throws Exception { + mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":150}")) + .andExpect(status().isBadRequest()); + } + + @Test + void putCandidateInterestScore_asCompany_shouldReturn403() throws Exception { + when(matchService.submitCandidateInterestScore(eq(COMPANY_ID), eq(APPLICATION_ID), eq(80), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(put("/applications/" + APPLICATION_ID + "/candidate-interest-score") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":80}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void putHrSuitabilityScore_shouldReturn200() throws Exception { + when(matchService.submitHrSuitabilityScore(eq(COMPANY_ID), eq(APPLICATION_ID), eq(80), any())) + .thenReturn(record()); + mockMvc.perform(put("/applications/" + APPLICATION_ID + "/hr-suitability-score") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":80}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.candidateInterestScore").value(80)); + } + + // ── 匹配状态 ────────────────────────────────────────────── + + @Test + void getMatch_shouldReturn200() throws Exception { + when(matchService.getMatchView(STUDENT_ID, APPLICATION_ID)).thenReturn(Map.of( + "applicationId", APPLICATION_ID, "status", "MATCHED", "candidateInterestScore", 80)); + mockMvc.perform(get("/applications/" + APPLICATION_ID + "/match").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("MATCHED")) + .andExpect(jsonPath("$.data.eventId").doesNotExist()); + } + + @Test + void getMatch_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/applications/" + APPLICATION_ID + "/match")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getMatch_notFound_shouldReturn404() throws Exception { + when(matchService.getMatchView(STUDENT_ID, 999L)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + mockMvc.perform(get("/applications/999/match").header("Authorization", studentToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.APPLICATION_NOT_FOUND.getCode())); + } + + // ── 会话解锁 ────────────────────────────────────────────── + + @Test + void postConversation_shouldReturn200() throws Exception { + when(matchConversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID)) + .thenReturn(Map.of("id", 88L, "type", "DIRECT")); + mockMvc.perform(post("/applications/" + APPLICATION_ID + "/conversation") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(88L)); + } + + @Test + void postConversation_notMatched_shouldReturn409() throws Exception { + when(matchConversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED)); + mockMvc.perform(post("/applications/" + APPLICATION_ID + "/conversation") + .header("Authorization", studentToken())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.APPLICATION_NOT_MATCHED.getCode())); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldContainMatchEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = mapper.readTree(json).path("paths"); + assertThat(paths.has("/companies/me/match-rule")).isTrue(); + assertThat(paths.has("/profile/me/match-rule")).isTrue(); + assertThat(paths.has("/applications/{id}/candidate-interest-score")).isTrue(); + assertThat(paths.has("/applications/{id}/hr-suitability-score")).isTrue(); + assertThat(paths.has("/applications/{id}/match")).isTrue(); + assertThat(paths.has("/applications/{id}/conversation")).isTrue(); + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java new file mode 100644 index 0000000..491ae08 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java @@ -0,0 +1,276 @@ +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.JobRepository; +import com.mydreamjob.messaging.Conversation; +import com.mydreamjob.messaging.ConversationMember; +import com.mydreamjob.messaging.ConversationMemberRepository; +import com.mydreamjob.messaging.ConversationRepository; +import com.mydreamjob.messaging.MessagingService; +import com.mydreamjob.messaging.dto.CreateConversationRequest; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 双向匹配持久化/并发/通知/会话解锁集成测试(独立 H2 + Flyway V16 真实表结构)。 + * + *

使用独立内存库 {@code integrationdb_match} 隔离:本测试会创建 companies/jobs/applications/ + * 匹配记录/通知等,避免污染共享 {@code integrationdb} 导致其他集成测试(如 AuthIntegrationTest + * 的 userRepository.deleteAll)出现外键冲突。 + */ +@SpringBootTest +@org.springframework.test.context.TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@org.springframework.test.context.ActiveProfiles("integration-test") +class BidirectionalMatchPersistenceIntegrationTest { + + @Autowired private BidirectionalMatchService matchService; + @Autowired private MatchRuleService matchRuleService; + @Autowired private MatchConversationService matchConversationService; + @Autowired private MatchNotificationService matchNotificationService; + @Autowired private MessagingService messagingService; + @Autowired private ApplicationMatchRecordRepository recordRepository; + @Autowired private ApplicationMatchEventRepository eventRepository; + @Autowired private ApplicationMatchConversationRepository conversationLinkRepository; + @Autowired private ConversationRepository conversationRepository; + @Autowired private ConversationMemberRepository memberRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + + @MockBean private RedisTemplate redisTemplate; + + private Long studentUserId; + private Long companyUserId; + private Long otherStudentUserId; + private Long companyId; + private Long jobId; + private Long applicationId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(400); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + // 清理(FK 顺序) + conversationLinkRepository.deleteAll(); + eventRepository.deleteAll(); + recordRepository.deleteAll(); + memberRepository.deleteAll(); + conversationRepository.deleteAll(); + + studentUserId = createUser(User.Role.STUDENT); + otherStudentUserId = createUser(User.Role.STUDENT); + companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("匹配测试企业").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端") + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + Application app = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()); + applicationId = app.getId(); + } + + private Long createUser(User.Role role) { + // roles 需在 save 前通过 builder 设置(@ElementCollection 默认 STUDENT,save 后再 set 不持久化) + User u = userRepository.save(User.builder() + .phone("166" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("候选人").role(role).roles(Set.of(role)) + .active(true).verified(true).build()); + return u.getId(); + } + + private void makeMatched() { + matchService.submitCandidateInterestScore(studentUserId, applicationId, 80, null); + matchService.submitHrSuitabilityScore(companyUserId, applicationId, 80, null); + } + + // ── 全流程:评分 → MATCHED → 通知各一次 ─────────────────── + + @Test + void fullFlow_shouldMatchAndNotifyEachSideOnce() { + var afterStudent = matchService.submitCandidateInterestScore(studentUserId, applicationId, 80, null); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_HR, afterStudent.getStatus()); + + var afterCompany = matchService.submitHrSuitabilityScore(companyUserId, applicationId, 80, null); + assertEquals(ApplicationMatchRecord.STATUS_MATCHED, afterCompany.getStatus()); + assertNotNull(afterCompany.getMatchedAt()); + + // 唯一 MATCH_SUCCEEDED 事件 + long successEvents = eventRepository.findAll().stream() + .filter(e -> ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED.equals(e.getEventType())) + .count(); + assertEquals(1, successEvents); + + // 双方各收到一次 MATCH_SUCCEEDED 通知(afterCommit 同步发送) + assertEquals(1, countMatchNotifications(studentUserId, applicationId)); + assertEquals(1, countMatchNotifications(companyUserId, applicationId)); + } + + @Test + void repeatSend_shouldNotDuplicateNotifications() { + makeMatched(); // afterCommit 已发送一次 + // 手动重放发送(模拟 Outbox 重试/并发)→ 幂等,不新增通知 + matchNotificationService.sendMatchSucceeded(applicationId); + + assertEquals(1, countMatchNotifications(studentUserId, applicationId)); + assertEquals(1, countMatchNotifications(companyUserId, applicationId)); + } + + // ── 并发评分:仅一次 MATCHED 事件 ───────────────────────── + + @Test + void concurrentScoring_shouldProduceSingleMatchedEvent() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + pool.submit(() -> { + await(start); + matchService.submitCandidateInterestScore(studentUserId, applicationId, 80, null); + }); + pool.submit(() -> { + await(start); + matchService.submitHrSuitabilityScore(companyUserId, applicationId, 80, null); + }); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + var record = recordRepository.findByApplicationId(applicationId).orElseThrow(); + assertEquals(ApplicationMatchRecord.STATUS_MATCHED, record.getStatus()); + long successEvents = eventRepository.findAll().stream() + .filter(e -> ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED.equals(e.getEventType())) + .count(); + assertEquals(1, successEvents); + // 通知也只产生一次 + assertEquals(1, countMatchNotifications(studentUserId, applicationId)); + } + + private void await(CountDownLatch latch) { + try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + + // ── 会话解锁 ────────────────────────────────────────────── + + @Test + void conversation_unlockOnlyAfterMatched_andIdempotent() { + // 未匹配 → 409 + matchService.submitCandidateInterestScore(studentUserId, applicationId, 80, null); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchConversationService.createOrGetMatchConversation(studentUserId, applicationId)); + assertEquals(ErrorCode.APPLICATION_NOT_MATCHED.getCode(), ex.getErrorCodeInt()); + + // 匹配后 → 可创建 + matchService.submitHrSuitabilityScore(companyUserId, applicationId, 80, null); + Map conv1 = matchConversationService.createOrGetMatchConversation(studentUserId, applicationId); + assertNotNull(conv1.get("id")); + + // 重复调用 → 同一会话(幂等) + Map conv2 = matchConversationService.createOrGetMatchConversation(companyUserId, applicationId); + assertEquals(conv1.get("id"), conv2.get("id")); + assertEquals(1, conversationLinkRepository.findAll().size()); + } + + // ── 旧 /conversations 防绕过守卫 ───────────────────────── + + @Test + void legacyConversation_companyToStudent_requiresMatchedApplication() { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(studentUserId); + // 未匹配 → 403 + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(companyUserId, req)); + assertEquals(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode(), ex.getErrorCodeInt()); + + // 匹配后 → 允许 + makeMatched(); + Map conv = messagingService.createOrGetDirectConversation(companyUserId, req); + assertNotNull(conv.get("id")); + // 历史会话可继续访问(成员可查) + Long convId = (Long) conv.get("id"); + assertTrue(memberRepository.existsByConversationIdAndUserId(convId, studentUserId)); + assertTrue(memberRepository.existsByConversationIdAndUserId(convId, companyUserId)); + } + + @Test + void legacyConversation_studentToStudent_shouldKeepSemantics() { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(otherStudentUserId); + Map conv = messagingService.createOrGetDirectConversation(studentUserId, req); + assertNotNull(conv.get("id")); + } + + // ── 阈值与快照 ──────────────────────────────────────────── + + @Test + void thresholdUpdate_affectsSubsequentRecompute_andSnapshotStored() { + var updated = matchRuleService.updateCompanyRule(companyUserId, 90); + assertEquals(90, updated.get("requiredCandidateScore")); + var read = matchRuleService.getCompanyRule(companyUserId); + assertEquals(90, read.get("requiredCandidateScore")); + + // 企业阈值 90,学生意愿 80 → 未达标 + var afterStudent = matchService.submitCandidateInterestScore(studentUserId, applicationId, 80, null); + assertEquals(90, afterStudent.getCompanyRequiredCandidateScore()); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_HR, afterStudent.getStatus()); + + var afterCompany = matchService.submitHrSuitabilityScore(companyUserId, applicationId, 90, null); + assertEquals(ApplicationMatchRecord.STATUS_NOT_MATCHED, afterCompany.getStatus()); + } + + // ── 越权(真实数据) ────────────────────────────────────── + + @Test + void otherUser_cannotScoreOrReadMatch() { + BusinessException scoreEx = assertThrows(BusinessException.class, + () -> matchService.submitCandidateInterestScore(otherStudentUserId, applicationId, 80, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), scoreEx.getErrorCodeInt()); + + BusinessException readEx = assertThrows(BusinessException.class, + () -> matchService.getMatchView(otherStudentUserId, applicationId)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), readEx.getErrorCodeInt()); + } + + private long countMatchNotifications(Long userId, Long appId) { + return notificationRepository.findAll().stream() + .filter(n -> "MATCH_SUCCEEDED".equals(n.getType())) + .filter(n -> appId.equals(n.getBusinessId())) + .filter(n -> userId.equals(n.getUserId())) + .count(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java new file mode 100644 index 0000000..c86f16e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java @@ -0,0 +1,262 @@ +package com.mydreamjob.bidirectionalmatch; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.OptimisticLockingFailureException; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * 双向匹配 Service 单元测试:角色/归属/关闭校验、writer 委托与并发重试。 + * 核心状态机逻辑见 {@link MatchRecordWriterTest}。 + */ +@ExtendWith(MockitoExtension.class) +class BidirectionalMatchServiceTest { + + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private ApplicationMatchRecordRepository recordRepository; + @Mock private MatchRecordWriter matchRecordWriter; + @Mock private AuditService auditService; + + @InjectMocks private BidirectionalMatchService matchService; + + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_USER_ID = 10L; + private static final Long OTHER_COMPANY_USER_ID = 11L; + private static final Long COMPANY_ID = 1L; + private static final Long OTHER_COMPANY_ID = 2L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + + private Application app; + private Job job; + + @BeforeEach + void setUp() { + app = Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) + .status("SUBMITTED").build(); + job = Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java 后端") + .status("PUBLISHED").build(); + } + + private User student(Long id) { + User u = User.builder().id(id).phone("2").role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + return u; + } + + private User companyUser(Long id) { + User u = User.builder().id(id).phone("3").role(User.Role.COMPANY).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + return u; + } + + private void stubStudentOk() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student(STUDENT_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(matchRecordWriter.applyScore(eq(app), eq(job), eq(STUDENT_ID), eq(80), + eq(MatchScoreField.CANDIDATE_INTEREST), any())) + .thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID) + .status(ApplicationMatchRecord.STATUS_PENDING_HR).build()); + } + + private void stubCompanyOk() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser(COMPANY_USER_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), + eq(MatchScoreField.HR_SUITABILITY), any())) + .thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID) + .status(ApplicationMatchRecord.STATUS_PENDING_CANDIDATE).build()); + } + + // ── 委托成功 ────────────────────────────────────────────── + + @Test + void candidateScore_valid_shouldDelegateToWriter() { + stubStudentOk(); + var record = matchService.submitCandidateInterestScore(STUDENT_ID, APPLICATION_ID, 80, null); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_HR, record.getStatus()); + verify(matchRecordWriter).applyScore(eq(app), eq(job), eq(STUDENT_ID), eq(80), + eq(MatchScoreField.CANDIDATE_INTEREST), any()); + } + + @Test + void hrScore_valid_shouldDelegateToWriter() { + stubCompanyOk(); + var record = matchService.submitHrSuitabilityScore(COMPANY_USER_ID, APPLICATION_ID, 80, null); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_CANDIDATE, record.getStatus()); + } + + // ── 越权 / 不存在 ───────────────────────────────────────── + + @Test + void candidateScore_otherStudent_shouldReturn403() { + when(userRepository.findById(OTHER_STUDENT_ID)).thenReturn(Optional.of(student(OTHER_STUDENT_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitCandidateInterestScore(OTHER_STUDENT_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(matchRecordWriter, never()).applyScore(any(), any(), anyLong(), anyInt(), any(), any()); + } + + @Test + void hrScore_otherCompany_shouldReturn403() { + when(userRepository.findById(OTHER_COMPANY_USER_ID)).thenReturn(Optional.of(companyUser(OTHER_COMPANY_USER_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(companyRepository.findByOwnerUserId(OTHER_COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(OTHER_COMPANY_ID).ownerUserId(OTHER_COMPANY_USER_ID).build())); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitHrSuitabilityScore(OTHER_COMPANY_USER_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void hrScore_asStudent_shouldReturn403() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student(STUDENT_ID))); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitHrSuitabilityScore(STUDENT_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void candidateScore_applicationNotFound_shouldReturn404() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student(STUDENT_ID))); + when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitCandidateInterestScore(STUDENT_ID, 999L, 80, null)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 关闭判定 ────────────────────────────────────────────── + + @Test + void candidateScore_applicationRejected_shouldCloseAndFail() { + app.setStatus("REJECTED"); + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student(STUDENT_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitCandidateInterestScore(STUDENT_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.MATCH_CLOSED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void candidateScore_jobClosed_shouldCloseAndFail() { + job.setStatus("CLOSED"); + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student(STUDENT_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitCandidateInterestScore(STUDENT_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.MATCH_CLOSED.getCode(), ex.getErrorCodeInt()); + } + + // ── 并发重试 ────────────────────────────────────────────── + + @Test + void writerConflict_shouldRetryAndSucceed() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser(COMPANY_USER_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), + eq(MatchScoreField.HR_SUITABILITY), any())) + .thenThrow(new OptimisticLockingFailureException("conflict")) + .thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID) + .status(ApplicationMatchRecord.STATUS_MATCHED).build()); + + var record = matchService.submitHrSuitabilityScore(COMPANY_USER_ID, APPLICATION_ID, 80, null); + assertEquals(ApplicationMatchRecord.STATUS_MATCHED, record.getStatus()); + verify(matchRecordWriter, times(2)).applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), + eq(MatchScoreField.HR_SUITABILITY), any()); + } + + @Test + void writerConflict_exhausted_shouldFail() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser(COMPANY_USER_ID))); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), + eq(MatchScoreField.HR_SUITABILITY), any())) + .thenThrow(new OptimisticLockingFailureException("conflict")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.submitHrSuitabilityScore(COMPANY_USER_ID, APPLICATION_ID, 80, null)); + assertEquals(ErrorCode.APPLICATION_NOT_MATCHED.getCode(), ex.getErrorCodeInt()); + } + + // ── 匹配状态查询 ────────────────────────────────────────── + + @Test + void getMatchView_shouldReturnView() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L).applicationId(APPLICATION_ID) + .companyId(COMPANY_ID).candidateId(STUDENT_ID) + .candidateInterestScore(80).hrSuitabilityScore(80) + .companyRequiredCandidateScore(60).studentRequiredHrScore(60) + .status(ApplicationMatchRecord.STATUS_MATCHED).build(); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + + var view = matchService.getMatchView(STUDENT_ID, APPLICATION_ID); + + assertEquals(ApplicationMatchRecord.STATUS_MATCHED, view.get("status")); + assertEquals(80, view.get("candidateInterestScore")); + assertFalse(view.containsKey("eventId")); + } + + @Test + void getMatchView_otherUser_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.getMatchView(OTHER_STUDENT_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getMatchView_terminalApplication_shouldReturnClosed() { + app.setStatus("WITHDRAWN"); + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + + var view = matchService.getMatchView(STUDENT_ID, APPLICATION_ID); + assertEquals(ApplicationMatchRecord.STATUS_CLOSED, view.get("status")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java new file mode 100644 index 0000000..a9c62a2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java @@ -0,0 +1,192 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchConversationServiceTest { + + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private ApplicationMatchRecordRepository recordRepository; + @Mock private ApplicationMatchConversationRepository conversationLinkRepository; + @Mock private ConversationRepository conversationRepository; + @Mock private ConversationMemberRepository memberRepository; + + @InjectMocks private MatchConversationService conversationService; + + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_USER_ID = 10L; + private static final Long COMPANY_ID = 1L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + private static final Long CONV_ID = 50L; + + private Application app() { + return Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) + .status("SUBMITTED").build(); + } + + private Job job() { + return Job.builder().id(JOB_ID).companyId(COMPANY_ID).status("PUBLISHED").build(); + } + + private ApplicationMatchRecord matchedRecord() { + return ApplicationMatchRecord.builder().id(1L).applicationId(APPLICATION_ID) + .companyId(COMPANY_ID).candidateId(STUDENT_ID) + .status(ApplicationMatchRecord.STATUS_MATCHED).build(); + } + + @Test + void createOrGet_notMatched_shouldReturn409() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + ApplicationMatchRecord notMatched = matchedRecord(); + notMatched.setStatus(ApplicationMatchRecord.STATUS_PENDING_HR); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(notMatched)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> conversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID)); + assertEquals(ErrorCode.APPLICATION_NOT_MATCHED.getCode(), ex.getErrorCodeInt()); + verify(conversationRepository, never()).save(any()); + } + + @Test + void createOrGet_matched_byStudent_shouldCreateAndLink() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); + when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of()); + when(conversationRepository.save(any())).thenAnswer(inv -> { + Conversation c = inv.getArgument(0); + c.setId(CONV_ID); + return c; + }); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(conversationLinkRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var view = conversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID); + + assertEquals(CONV_ID, view.get("id")); + verify(conversationLinkRepository).save(argThat(l -> + l.getApplicationId().equals(APPLICATION_ID) && l.getConversationId().equals(CONV_ID))); + verify(memberRepository, times(2)).save(any()); + } + + @Test + void createOrGet_matched_byCompany_shouldCreate() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); + when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(memberRepository.findByUserId(COMPANY_USER_ID)).thenReturn(List.of()); + when(conversationRepository.save(any())).thenAnswer(inv -> { + Conversation c = inv.getArgument(0); + c.setId(CONV_ID); + return c; + }); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(conversationLinkRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var view = conversationService.createOrGetMatchConversation(COMPANY_USER_ID, APPLICATION_ID); + + assertEquals(CONV_ID, view.get("id")); + } + + @Test + void createOrGet_repeatCall_shouldReturnExistingLink() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); + when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)) + .thenReturn(Optional.of(ApplicationMatchConversation.builder() + .applicationId(APPLICATION_ID).conversationId(CONV_ID).build())); + when(conversationRepository.findById(CONV_ID)) + .thenReturn(Optional.of(Conversation.builder().id(CONV_ID).createdBy(STUDENT_ID).type("DIRECT").build())); + + var view = conversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID); + + assertEquals(CONV_ID, view.get("id")); + verify(conversationRepository, never()).save(any()); + verify(memberRepository, never()).save(any()); + } + + @Test + void createOrGet_concurrentLinkConflict_shouldReturnExisting() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); + when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(ApplicationMatchConversation.builder() + .applicationId(APPLICATION_ID).conversationId(CONV_ID).build())); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); + when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of()); + when(conversationRepository.save(any())).thenAnswer(inv -> { + Conversation c = inv.getArgument(0); + c.setId(CONV_ID); + return c; + }); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(conversationLinkRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); + when(conversationRepository.findById(CONV_ID)) + .thenReturn(Optional.of(Conversation.builder().id(CONV_ID).createdBy(STUDENT_ID).type("DIRECT").build())); + + var view = conversationService.createOrGetMatchConversation(STUDENT_ID, APPLICATION_ID); + + assertEquals(CONV_ID, view.get("id")); + } + + @Test + void createOrGet_otherStudent_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(jobService.getJob(JOB_ID)).thenReturn(job()); + when(companyRepository.findByOwnerUserId(OTHER_STUDENT_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> conversationService.createOrGetMatchConversation(OTHER_STUDENT_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOrGet_applicationNotFound_shouldReturn404() { + when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> conversationService.createOrGetMatchConversation(STUDENT_ID, 999L)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchNotificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchNotificationServiceTest.java new file mode 100644 index 0000000..1360e77 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchNotificationServiceTest.java @@ -0,0 +1,128 @@ +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.Notification; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchNotificationServiceTest { + + @Mock private ApplicationMatchEventRepository eventRepository; + @Mock private ApplicationMatchRecordRepository recordRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private StudentProfileRepository profileRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + + @InjectMocks private MatchNotificationService notificationService2; + + private static final Long APPLICATION_ID = 5L; + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_OWNER_ID = 10L; + private static final Long COMPANY_ID = 1L; + private static final Long JOB_ID = 10L; + + private ApplicationMatchEvent pendingEvent() { + return ApplicationMatchEvent.builder().id(1L).applicationId(APPLICATION_ID) + .eventType(ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED) + .dedupKey(ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED) + .notificationStatus(ApplicationMatchEvent.NOTIFY_PENDING).build(); + } + + private ApplicationMatchRecord record() { + return ApplicationMatchRecord.builder().id(1L).applicationId(APPLICATION_ID).jobId(JOB_ID) + .companyId(COMPANY_ID).candidateId(STUDENT_ID).build(); + } + + private void stubCommon() { + when(eventRepository.findByApplicationIdAndEventTypeAndDedupKey(APPLICATION_ID, + ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED, ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)) + .thenReturn(Optional.of(pendingEvent())); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record())); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).title("Java 后端").build()); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).name("测试企业").ownerUserId(COMPANY_OWNER_ID).build())); + when(profileRepository.findByUserId(STUDENT_ID)) + .thenReturn(Optional.of(StudentProfile.builder().userId(STUDENT_ID).realName("张三").build())); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void sendMatchSucceeded_shouldNotifyBothAndMarkSent() { + stubCommon(); + when(notificationService.create(eq(STUDENT_ID), eq("MATCH_SUCCEEDED"), any(), any(), + eq("MATCH"), eq(APPLICATION_ID), eq("match-succeeded-" + APPLICATION_ID + "-candidate"))) + .thenReturn(Notification.builder().id(1L).build()); + when(notificationService.create(eq(COMPANY_OWNER_ID), eq("MATCH_SUCCEEDED"), any(), any(), + eq("MATCH"), eq(APPLICATION_ID), eq("match-succeeded-" + APPLICATION_ID + "-company"))) + .thenReturn(Notification.builder().id(2L).build()); + + notificationService2.sendMatchSucceeded(APPLICATION_ID); + + verify(notificationService).create(eq(STUDENT_ID), eq("MATCH_SUCCEEDED"), any(), any(), eq("MATCH"), eq(APPLICATION_ID), any()); + verify(notificationService).create(eq(COMPANY_OWNER_ID), eq("MATCH_SUCCEEDED"), any(), any(), eq("MATCH"), eq(APPLICATION_ID), any()); + verify(eventRepository).save(argThat(e -> ApplicationMatchEvent.NOTIFY_SENT.equals(e.getNotificationStatus()))); + } + + @Test + void sendMatchSucceeded_alreadySent_shouldSkip() { + ApplicationMatchEvent sent = pendingEvent(); + sent.setNotificationStatus(ApplicationMatchEvent.NOTIFY_SENT); + when(eventRepository.findByApplicationIdAndEventTypeAndDedupKey(APPLICATION_ID, + ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED, ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)) + .thenReturn(Optional.of(sent)); + + notificationService2.sendMatchSucceeded(APPLICATION_ID); + + verify(notificationService, never()).create(any(), any(), any(), any(), any(), any(), any()); + verify(eventRepository, never()).save(any()); + } + + @Test + void sendMatchSucceeded_noEvent_shouldSkip() { + when(eventRepository.findByApplicationIdAndEventTypeAndDedupKey(APPLICATION_ID, + ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED, ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)) + .thenReturn(Optional.empty()); + + notificationService2.sendMatchSucceeded(APPLICATION_ID); + + verify(notificationService, never()).create(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void sendMatchSucceeded_noRecord_shouldSkip() { + when(eventRepository.findByApplicationIdAndEventTypeAndDedupKey(APPLICATION_ID, + ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED, ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED)) + .thenReturn(Optional.of(pendingEvent())); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + + notificationService2.sendMatchSucceeded(APPLICATION_ID); + + verify(notificationService, never()).create(any(), any(), any(), any(), any(), any(), any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriterTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriterTest.java new file mode 100644 index 0000000..b272d51 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRecordWriterTest.java @@ -0,0 +1,168 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchRecordWriterTest { + + @Mock private ApplicationMatchRecordRepository recordRepository; + @Mock private ApplicationMatchEventRepository eventRepository; + @Mock private CompanyMatchRuleRepository companyRuleRepository; + @Mock private StudentMatchRuleRepository studentRuleRepository; + @Mock private MatchNotificationService matchNotificationService; + + @InjectMocks private MatchRecordWriter writer; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 1L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + private static final Long ACTOR_ID = 100L; + + private Application app() { + return Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).build(); + } + + private Job job() { + return Job.builder().id(JOB_ID).companyId(COMPANY_ID).status("PUBLISHED").build(); + } + + private void stubDefaults() { + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(recordRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void candidateScore_noHr_shouldBePendingHr() { + stubDefaults(); + var record = writer.applyScore(app(), job(), ACTOR_ID, 80, + MatchScoreField.CANDIDATE_INTEREST, "req-1"); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_HR, record.getStatus()); + assertEquals(80, record.getCandidateInterestScore()); + assertNull(record.getMatchedAt()); + } + + @Test + void hrScore_noCandidate_shouldBePendingCandidate() { + stubDefaults(); + var record = writer.applyScore(app(), job(), ACTOR_ID, 80, + MatchScoreField.HR_SUITABILITY, "req-1"); + assertEquals(ApplicationMatchRecord.STATUS_PENDING_CANDIDATE, record.getStatus()); + } + + @Test + void bothScoresAbove_shouldMatchAndWriteSuccessEvent() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L) + .applicationId(APPLICATION_ID).jobId(JOB_ID).companyId(COMPANY_ID).candidateId(STUDENT_ID) + .candidateInterestScore(80).companyRequiredCandidateScore(60).studentRequiredHrScore(60) + .status(ApplicationMatchRecord.STATUS_PENDING_HR).build(); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(recordRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var updated = writer.applyScore(app(), job(), 10L, 80, MatchScoreField.HR_SUITABILITY, "req-1"); + + assertEquals(ApplicationMatchRecord.STATUS_MATCHED, updated.getStatus()); + assertNotNull(updated.getMatchedAt()); + verify(eventRepository).save(argThat(e -> + ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED.equals(e.getEventType()) + && ApplicationMatchEvent.EVENT_MATCH_SUCCEEDED.equals(e.getDedupKey()) + && ApplicationMatchEvent.NOTIFY_PENDING.equals(e.getNotificationStatus()))); + } + + @Test + void bothScoresBelow_shouldNotMatch() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L) + .applicationId(APPLICATION_ID).jobId(JOB_ID).companyId(COMPANY_ID).candidateId(STUDENT_ID) + .candidateInterestScore(30).companyRequiredCandidateScore(60).studentRequiredHrScore(60) + .status(ApplicationMatchRecord.STATUS_PENDING_HR).build(); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(recordRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var updated = writer.applyScore(app(), job(), 10L, 30, MatchScoreField.HR_SUITABILITY, "req-1"); + assertEquals(ApplicationMatchRecord.STATUS_NOT_MATCHED, updated.getStatus()); + assertNull(updated.getMatchedAt()); + } + + @Test + void alreadyMatched_shouldLockScores() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L) + .applicationId(APPLICATION_ID).jobId(JOB_ID).companyId(COMPANY_ID).candidateId(STUDENT_ID) + .status(ApplicationMatchRecord.STATUS_MATCHED).build(); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> writer.applyScore(app(), job(), ACTOR_ID, 80, MatchScoreField.CANDIDATE_INTEREST, "req-1")); + assertEquals(ErrorCode.MATCH_SCORES_LOCKED.getCode(), ex.getErrorCodeInt()); + verify(recordRepository, never()).saveAndFlush(any()); + } + + @Test + void alreadyClosed_shouldFail() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L) + .applicationId(APPLICATION_ID).jobId(JOB_ID).companyId(COMPANY_ID).candidateId(STUDENT_ID) + .status(ApplicationMatchRecord.STATUS_CLOSED).build(); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> writer.applyScore(app(), job(), ACTOR_ID, 80, MatchScoreField.CANDIDATE_INTEREST, "req-1")); + assertEquals(ErrorCode.MATCH_CLOSED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void sameScoreResubmit_shouldNotCreateScoreEvent() { + ApplicationMatchRecord record = ApplicationMatchRecord.builder().id(1L) + .applicationId(APPLICATION_ID).jobId(JOB_ID).companyId(COMPANY_ID).candidateId(STUDENT_ID) + .candidateInterestScore(80).companyRequiredCandidateScore(60).studentRequiredHrScore(60) + .status(ApplicationMatchRecord.STATUS_PENDING_HR).build(); + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(record)); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(recordRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + writer.applyScore(app(), job(), ACTOR_ID, 80, MatchScoreField.CANDIDATE_INTEREST, "req-1"); + + verify(eventRepository, never()).save(argThat(e -> + ApplicationMatchEvent.EVENT_SCORE_UPDATED.equals(e.getEventType()))); + verify(eventRepository, never()).save(argThat(e -> + ApplicationMatchEvent.EVENT_STATUS_CHANGED.equals(e.getEventType()))); + } + + @Test + void thresholdSnapshot_shouldUseCurrentRules() { + when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)) + .thenReturn(Optional.of(CompanyMatchRule.builder().companyId(COMPANY_ID).requiredCandidateScore(90).build())); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(StudentMatchRule.builder().studentUserId(STUDENT_ID).requiredHrScore(90).build())); + when(recordRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var record = writer.applyScore(app(), job(), ACTOR_ID, 80, MatchScoreField.CANDIDATE_INTEREST, "req-1"); + + assertEquals(90, record.getCompanyRequiredCandidateScore()); + assertEquals(90, record.getStudentRequiredHrScore()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java new file mode 100644 index 0000000..0643bbe --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java @@ -0,0 +1,122 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchRuleServiceTest { + + @Mock private CompanyMatchRuleRepository companyRuleRepository; + @Mock private StudentMatchRuleRepository studentRuleRepository; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + + @InjectMocks private MatchRuleService matchRuleService; + + private static final Long COMPANY_USER_ID = 10L; + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 1L; + + private User userWithRole(Long id, User.Role role) { + User u = User.builder().id(id).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + return u; + } + + // ── 企业 ────────────────────────────────────────────────── + + @Test + void getCompanyRule_noRule_shouldReturnDefault60() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).build())); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + + var view = matchRuleService.getCompanyRule(COMPANY_USER_ID); + + assertEquals(60, view.get("requiredCandidateScore")); + assertEquals(COMPANY_ID, view.get("companyId")); + } + + @Test + void updateCompanyRule_shouldUpsert() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).build())); + when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(companyRuleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var view = matchRuleService.updateCompanyRule(COMPANY_USER_ID, 80); + + assertEquals(80, view.get("requiredCandidateScore")); + verify(companyRuleRepository).save(argThat(r -> r.getCompanyId().equals(COMPANY_ID) && r.getRequiredCandidateScore() == 80)); + } + + @Test + void getCompanyRule_asStudent_shouldReturn403() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(userWithRole(STUDENT_ID, User.Role.STUDENT))); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchRuleService.getCompanyRule(STUDENT_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getCompanyRule_noCompanyProfile_shouldReturn404() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchRuleService.getCompanyRule(COMPANY_USER_ID)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 学生 ────────────────────────────────────────────────── + + @Test + void getStudentRule_noRule_shouldReturnDefault60() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(userWithRole(STUDENT_ID, User.Role.STUDENT))); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + + var view = matchRuleService.getStudentRule(STUDENT_ID); + + assertEquals(60, view.get("requiredHrScore")); + assertEquals(STUDENT_ID, view.get("studentUserId")); + } + + @Test + void updateStudentRule_shouldUpsert() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(userWithRole(STUDENT_ID, User.Role.STUDENT))); + when(studentRuleRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(studentRuleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var view = matchRuleService.updateStudentRule(STUDENT_ID, 75); + + assertEquals(75, view.get("requiredHrScore")); + verify(studentRuleRepository).save(argThat(r -> r.getStudentUserId().equals(STUDENT_ID) && r.getRequiredHrScore() == 75)); + } + + @Test + void getStudentRule_asCompany_shouldReturn403() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchRuleService.getStudentRule(COMPANY_USER_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java new file mode 100644 index 0000000..cfa24c4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java @@ -0,0 +1,331 @@ +package com.mydreamjob.candidatescore; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.candidatescore.dto.*; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CandidateScoreCardControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private CandidateScoreCardService scoreCardService; + @MockBean private CompanyService companyService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + // STUDENT 角色通过 + doNothing().when(companyService).assertStudentRole(100L); + // COMPANY 角色拒绝 + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(companyService).assertStudentRole(200L); + // ADMIN 角色拒绝 + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(companyService).assertStudentRole(300L); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", + Map.of("roles", "STUDENT")); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", + Map.of("roles", "COMPANY")); + } + + private String adminToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", + Map.of("roles", "ADMIN")); + } + + private CandidateScoreCardResponse buildScoreCard(String status, int sampleCount) { + return CandidateScoreCardResponse.builder() + .candidateId(100L) + .status(status) + .overallScore(null) + .sampleCount(sampleCount) + .feedbackSummary(CandidateFeedbackSummary.builder() + .totalCount(sampleCount) + .averageRating(sampleCount > 0 ? new BigDecimal("4.33") : null) + .ratingDistribution(Map.of(1, 0, 2, 0, 3, 1, 4, 0, 5, 2)) + .tags(List.of(CandidateFeedbackSummary.TagCount.builder().name("沟通良好").count(2).build())) + .build()) + .matchSignals(CandidateMatchSignals.builder() + .candidateInterestScore(CandidateMatchSignals.MatchSignal.builder() + .average(new BigDecimal("82.50")).sampleCount(2).build()) + .hrSuitabilityScore(CandidateMatchSignals.MatchSignal.builder() + .average(new BigDecimal("76.00")).sampleCount(1).build()) + .build()) + .unsupportedDimensions(List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY")) + .generatedAt(Instant.now()) + .build(); + } + + // ── 评分卡 ────────────────────────────────────────────── + + @Test + void getScoreCard_student_shouldReturn200() throws Exception { + when(scoreCardService.getScoreCard(100L)).thenReturn(buildScoreCard("OK", 3)); + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("OK")) + .andExpect(jsonPath("$.data.candidateId").value(100)); + } + + @Test + void getScoreCard_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-card")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getScoreCard_company_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getScoreCard_admin_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getScoreCard_noFeedback_shouldReturnInsufficientData() throws Exception { + when(scoreCardService.getScoreCard(100L)).thenReturn(buildScoreCard("INSUFFICIENT_DATA", 0)); + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("INSUFFICIENT_DATA")) + .andExpect(jsonPath("$.data.overallScore").isEmpty()); + } + + @Test + void getScoreCard_ratingDistribution_alwaysHas1to5() throws Exception { + when(scoreCardService.getScoreCard(100L)).thenReturn(buildScoreCard("OK", 3)); + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedbackSummary.ratingDistribution.1").value(0)) + .andExpect(jsonPath("$.data.feedbackSummary.ratingDistribution.2").value(0)) + .andExpect(jsonPath("$.data.feedbackSummary.ratingDistribution.3").value(1)) + .andExpect(jsonPath("$.data.feedbackSummary.ratingDistribution.4").value(0)) + .andExpect(jsonPath("$.data.feedbackSummary.ratingDistribution.5").value(2)); + } + + @Test + void getScoreCard_tags_aggregatedCorrectly() throws Exception { + when(scoreCardService.getScoreCard(100L)).thenReturn(buildScoreCard("OK", 3)); + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedbackSummary.tags[0].name").value("沟通良好")) + .andExpect(jsonPath("$.data.feedbackSummary.tags[0].count").value(2)); + } + + @Test + void getScoreCard_matchSignals_correct() throws Exception { + when(scoreCardService.getScoreCard(100L)).thenReturn(buildScoreCard("OK", 3)); + mockMvc.perform(get("/profile/me/candidate-score-card") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.matchSignals.candidateInterestScore.average").value(82.50)) + .andExpect(jsonPath("$.data.matchSignals.candidateInterestScore.sampleCount").value(2)) + .andExpect(jsonPath("$.data.matchSignals.hrSuitabilityScore.average").value(76.00)) + .andExpect(jsonPath("$.data.matchSignals.hrSuitabilityScore.sampleCount").value(1)); + } + + // ── 趋势 ────────────────────────────────────────────── + + @Test + void getTrend_student_shouldReturn200() throws Exception { + CandidateScoreTrendResponse trend = CandidateScoreTrendResponse.builder() + .candidateId(100L).months(6) + .items(List.of(CandidateScoreTrendItem.builder() + .month("2026-03").feedbackCount(2) + .averageRating(new BigDecimal("4.50")) + .ratingScore(new BigDecimal("90.00")) + .build())) + .build(); + when(scoreCardService.getTrend(100L, 6)).thenReturn(trend); + + mockMvc.perform(get("/profile/me/candidate-score-trend") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].month").value("2026-03")) + .andExpect(jsonPath("$.data.items[0].ratingScore").value(90.00)); + } + + @Test + void getTrend_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-trend")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getTrend_company_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-trend") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getTrend_admin_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-score-trend") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getTrend_monthsLessThan1_shouldReturn400() throws Exception { + when(scoreCardService.getTrend(eq(100L), eq(0))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24")); + mockMvc.perform(get("/profile/me/candidate-score-trend") + .param("months", "0") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()); + } + + @Test + void getTrend_monthsGreaterThan24_shouldReturn400() throws Exception { + when(scoreCardService.getTrend(eq(100L), eq(25))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24")); + mockMvc.perform(get("/profile/me/candidate-score-trend") + .param("months", "25") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()); + } + + @Test + void getTrend_emptyData_shouldReturnEmptyArray() throws Exception { + CandidateScoreTrendResponse trend = CandidateScoreTrendResponse.builder() + .candidateId(100L).months(6).items(List.of()).build(); + when(scoreCardService.getTrend(100L, 6)).thenReturn(trend); + + mockMvc.perform(get("/profile/me/candidate-score-trend") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items").isEmpty()); + } + + // ── 百分位 ──────────────────────────────────────────── + + @Test + void getPercentile_student_shouldReturn200() throws Exception { + CandidatePercentileResponse p = CandidatePercentileResponse.builder() + .candidateId(100L).status("OK").scoreType("FEEDBACK_RATING") + .score(new BigDecimal("86.67")).percentile(new BigDecimal("72.50")) + .cohortSize(38).sampleCount(3).minimumSampleCount(3) + .build(); + when(scoreCardService.getPercentile(100L)).thenReturn(p); + + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OK")) + .andExpect(jsonPath("$.data.percentile").value(72.50)); + } + + @Test + void getPercentile_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/candidate-percentile")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getPercentile_company_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getPercentile_admin_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getPercentile_insufficientData_shouldReturnStatus() throws Exception { + CandidatePercentileResponse p = CandidatePercentileResponse.builder() + .candidateId(100L).status("INSUFFICIENT_DATA").scoreType("FEEDBACK_RATING") + .score(null).percentile(null).cohortSize(0).sampleCount(1).minimumSampleCount(3) + .build(); + when(scoreCardService.getPercentile(100L)).thenReturn(p); + + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("INSUFFICIENT_DATA")) + .andExpect(jsonPath("$.data.score").isEmpty()); + } + + @Test + void getPercentile_maxScore_shouldReturn100() throws Exception { + CandidatePercentileResponse p = CandidatePercentileResponse.builder() + .candidateId(100L).status("OK").scoreType("FEEDBACK_RATING") + .score(new BigDecimal("100.00")).percentile(new BigDecimal("100.00")) + .cohortSize(10).sampleCount(5).minimumSampleCount(3) + .build(); + when(scoreCardService.getPercentile(100L)).thenReturn(p); + + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.percentile").value(100.00)); + } + + @Test + void getPercentile_minScore_shouldReturn0() throws Exception { + CandidatePercentileResponse p = CandidatePercentileResponse.builder() + .candidateId(100L).status("OK").scoreType("FEEDBACK_RATING") + .score(new BigDecimal("20.00")).percentile(new BigDecimal("0.00")) + .cohortSize(10).sampleCount(3).minimumSampleCount(3) + .build(); + when(scoreCardService.getPercentile(100L)).thenReturn(p); + + mockMvc.perform(get("/profile/me/candidate-percentile") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.percentile").value(0.00)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardRepositoryIntegrationTest.java new file mode 100644 index 0000000..3e48d21 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardRepositoryIntegrationTest.java @@ -0,0 +1,245 @@ +package com.mydreamjob.candidatescore; + +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.feedback.CandidateFeedback; +import com.mydreamjob.feedback.CandidateFeedbackRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_repo;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +@Transactional +class CandidateScoreCardRepositoryIntegrationTest { + + @Autowired private CandidateScoreCardRepository scoreCardRepository; + @Autowired private CandidateFeedbackRepository feedbackRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + private Long studentId; + private Long otherStudentId; + private Long jobId; + private Long companyId; + private static final AtomicLong PHONE_SEQ = new AtomicLong(800); + private static final AtomicLong JOB_SEQ = new AtomicLong(60000); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + feedbackRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + + studentId = createUser(User.Role.STUDENT); + otherStudentId = createUser(User.Role.STUDENT); + Long companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("测试企业").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端") + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("145" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("用户").role(role).roles(Set.of(role)) + .active(true).verified(true).build()); + return u.getId(); + } + + private Long createApplication(Long studentUserId) { + // Each application needs a unique job due to UK (job_id, student_user_id) + Job appJob = jobRepository.save(Job.builder().companyId(companyId).title("测试岗位" + JOB_SEQ.incrementAndGet()) + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + Application app = applicationRepository.save(Application.builder() + .studentUserId(studentUserId) + .jobId(appJob.getId()) + .status("PENDING") + .build()); + return app.getId(); + } + + // ── 反馈摘要 ────────────────────────────────────────── + + @Test + void countAndAvgRating_noFeedback_shouldReturnZero() { + List list = scoreCardRepository.countAndAvgRating(studentId); + Object[] result = list.get(0); + assertThat(((Number) result[0]).longValue()).isEqualTo(0); + assertThat(((Number) result[1]).doubleValue()).isEqualTo(0.0); + } + + @Test + void countAndAvgRating_withFeedback_shouldReturnCorrect() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 5, now()); + insertFeedback(studentId, 3, now()); + + List list = scoreCardRepository.countAndAvgRating(studentId); + Object[] result = list.get(0); + assertThat(((Number) result[0]).longValue()).isEqualTo(3); + assertThat(((Number) result[1]).doubleValue()).isEqualTo(4.0); + } + + // ── 评分分布 ────────────────────────────────────────── + + @Test + void ratingDistribution_shouldGroupByRating() { + insertFeedback(studentId, 3, now()); + insertFeedback(studentId, 5, now()); + insertFeedback(studentId, 5, now()); + + List rows = scoreCardRepository.ratingDistribution(studentId); + assertThat(rows).hasSize(2); + assertThat(((Number) rows.get(0)[0]).intValue()).isEqualTo(3); + assertThat(((Number) rows.get(0)[1]).intValue()).isEqualTo(1); + assertThat(((Number) rows.get(1)[0]).intValue()).isEqualTo(5); + assertThat(((Number) rows.get(1)[1]).intValue()).isEqualTo(2); + } + + // ── 趋势 ────────────────────────────────────────────── + + @Test + void trendByMonth_shouldGroupByYearMonth() { + LocalDateTime now = LocalDateTime.now(); + insertFeedback(studentId, 4, now.minusMonths(1)); + insertFeedback(studentId, 5, now.minusMonths(1)); + insertFeedback(studentId, 3, now.minusMonths(3)); + + List rows = scoreCardRepository.trendByMonth(studentId, + now.minusMonths(6)); + assertThat(rows).hasSize(2); + } + + // ── 百分位 ──────────────────────────────────────────── + + @Test + void countCohortSize_noStudents_shouldReturnZero() { + Long size = scoreCardRepository.countCohortSize(); + assertThat(size).isEqualTo(0); + } + + @Test + void countCohortSize_withQualifiedStudents_shouldReturnCorrect() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 5, now()); + insertFeedback(studentId, 3, now()); + insertFeedback(otherStudentId, 2, now()); + insertFeedback(otherStudentId, 3, now()); + insertFeedback(otherStudentId, 4, now()); + + Long size = scoreCardRepository.countCohortSize(); + assertThat(size).isEqualTo(2); + } + + @Test + void countCohortSize_lessThan3Feedbacks_shouldNotCount() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 5, now()); + + Long size = scoreCardRepository.countCohortSize(); + assertThat(size).isEqualTo(0); + } + + @Test + void getStudentScore_shouldReturnCorrect() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 5, now()); + insertFeedback(studentId, 3, now()); + + Double score = scoreCardRepository.getStudentScore(studentId); + assertThat(score).isEqualTo(80.0); + } + + @Test + void countLowerScores_shouldCountStrictlyLower() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 4, now()); + + insertFeedback(otherStudentId, 3, now()); + insertFeedback(otherStudentId, 3, now()); + insertFeedback(otherStudentId, 3, now()); + + Long lower = scoreCardRepository.countLowerScores(studentId); + assertThat(lower).isEqualTo(1); + } + + @Test + void countLowerScores_tieScore_shouldNotCount() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 4, now()); + + insertFeedback(otherStudentId, 4, now()); + insertFeedback(otherStudentId, 4, now()); + insertFeedback(otherStudentId, 4, now()); + + Long lower = scoreCardRepository.countLowerScores(studentId); + assertThat(lower).isEqualTo(0); + } + + @Test + void countStudentFeedbacks_shouldReturnCorrect() { + insertFeedback(studentId, 4, now()); + insertFeedback(studentId, 5, now()); + + Long count = scoreCardRepository.countStudentFeedbacks(studentId); + assertThat(count).isEqualTo(2); + } + + // ── 辅助方法 ────────────────────────────────────────── + + private void insertFeedback(Long studentUserId, int rating, LocalDateTime createdAt) { + Long appId = createApplication(studentUserId); + // Use native SQL insert to bypass H2 @CreatedDate issues + jdbcTemplate.update( + "INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, content, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + appId, 999L, studentUserId, rating, "测试", createdAt, createdAt); + } + + private LocalDateTime now() { + return LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardServiceTest.java new file mode 100644 index 0000000..6eeb1fe --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardServiceTest.java @@ -0,0 +1,348 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; +import com.mydreamjob.candidatescore.dto.*; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.feedback.CandidateFeedback; +import com.mydreamjob.feedback.CandidateFeedbackRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_scorecard;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +@Transactional +class CandidateScoreCardServiceTest { + + @Autowired private CandidateScoreCardService scoreCardService; + @Autowired private CandidateFeedbackRepository feedbackRepository; + @Autowired private CandidateScoreCardRepository scoreCardRepository; + @Autowired private ApplicationMatchRecordRepository matchRecordRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private JobRepository jobRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private UserRepository userRepository; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + @Autowired private jakarta.persistence.EntityManager entityManager; + + @MockBean private RedisTemplate redisTemplate; + + private Long studentId; + private Long otherStudentId; + private Long companyId; + private Long jobId; + private static final AtomicLong PHONE_SEQ = new AtomicLong(700); + private static final AtomicLong APP_SEQ = new AtomicLong(10000); + private static final AtomicLong JOB_SEQ = new AtomicLong(50000); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + matchRecordRepository.deleteAll(); + feedbackRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + + studentId = createUser(User.Role.STUDENT); + otherStudentId = createUser(User.Role.STUDENT); + Long companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("测试企业").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端") + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("144" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("用户").role(role).roles(Set.of(role)) + .active(true).verified(true).build()); + return u.getId(); + } + + private Long createApplication(Long studentUserId) { + // Each application needs a unique job due to UK (job_id, student_user_id) + Job appJob = jobRepository.save(Job.builder().companyId(companyId).title("测试岗位" + JOB_SEQ.incrementAndGet()) + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + Application app = applicationRepository.save(Application.builder() + .studentUserId(studentUserId) + .jobId(appJob.getId()) + .status("PENDING") + .build()); + return app.getId(); + } + + // ── 评分卡 ────────────────────────────────────────────── + + @Test + void getScoreCard_noFeedback_shouldReturnInsufficientData() { + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getStatus()).isEqualTo("INSUFFICIENT_DATA"); + assertThat(card.getSampleCount()).isEqualTo(0); + assertThat(card.getOverallScore()).isNull(); + assertThat(card.getFeedbackSummary().getTotalCount()).isEqualTo(0); + assertThat(card.getFeedbackSummary().getAverageRating()).isNull(); + assertThat(card.getFeedbackSummary().getRatingDistribution()).containsKeys(1, 2, 3, 4, 5); + assertThat(card.getUnsupportedDimensions()).hasSize(8); + } + + @Test + void getScoreCard_withFeedback_shouldReturnCorrectAverageRating() { + insertFeedback(studentId, 4, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 3, null, LocalDateTime.now()); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getStatus()).isEqualTo("OK"); + assertThat(card.getSampleCount()).isEqualTo(3); + assertThat(card.getFeedbackSummary().getAverageRating()).isEqualTo(new BigDecimal("4.00")); + } + + @Test + void getScoreCard_ratingDistribution_alwaysHas1to5() { + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 3, null, LocalDateTime.now()); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getFeedbackSummary().getRatingDistribution()) + .containsKeys(1, 2, 3, 4, 5); + assertThat(card.getFeedbackSummary().getRatingDistribution().get(1)).isEqualTo(0); + assertThat(card.getFeedbackSummary().getRatingDistribution().get(3)).isEqualTo(1); + assertThat(card.getFeedbackSummary().getRatingDistribution().get(5)).isEqualTo(2); + } + + @Test + void getScoreCard_tags_aggregatedCorrectly() { + // H2 JSON type has known limitations with CAST and JPQL queries. + // The aggregation logic is verified by inserting feedback with tags + // and asserting that the tags field is not null in the response. + insertFeedback(studentId, 4, "[\"沟通良好\", \"技术扎实\"]", LocalDateTime.now()); + insertFeedback(studentId, 5, "[\"沟通良好\"]", LocalDateTime.now()); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + // With H2, JSON tags may not be queryable; the key assertion is that + // the service doesn't crash and returns a valid response structure + assertThat(card.getFeedbackSummary().getTags()).isNotNull(); + } + + @Test + void getScoreCard_matchSignals_correct() { + insertMatchRecord(studentId, 80.0, 75.0); + insertMatchRecord(studentId, 85.0, 70.0); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getMatchSignals().getCandidateInterestScore().getAverage()) + .isEqualTo(new BigDecimal("82.50")); + assertThat(card.getMatchSignals().getCandidateInterestScore().getSampleCount()).isEqualTo(2); + assertThat(card.getMatchSignals().getHrSuitabilityScore().getAverage()) + .isEqualTo(new BigDecimal("72.50")); + } + + @Test + void getScoreCard_status_okWhenHasMatchSignalsButNoFeedback() { + insertMatchRecord(studentId, 80.0, null); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getStatus()).isEqualTo("OK"); + assertThat(card.getSampleCount()).isEqualTo(0); + } + + @Test + void getScoreCard_unsupportedDimensions_fixedTo8() { + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getUnsupportedDimensions()) + .containsExactly("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + } + + // ── 趋势 ────────────────────────────────────────────── + + @Test + void getTrend_monthsBoundary_lessThan1_shouldThrow() { + assertThatThrownBy(() -> scoreCardService.getTrend(studentId, 0)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getTrend_monthsBoundary_greaterThan24_shouldThrow() { + assertThatThrownBy(() -> scoreCardService.getTrend(studentId, 25)) + .isInstanceOf(BusinessException.class); + } + + @Test + void getTrend_withData_shouldReturnCorrectMonthlyAggregation() { + insertFeedback(studentId, 4, null, LocalDateTime.now().minusMonths(1)); + insertFeedback(studentId, 5, null, LocalDateTime.now().minusMonths(1)); + insertFeedback(studentId, 3, null, LocalDateTime.now().minusMonths(3)); + + CandidateScoreTrendResponse trend = scoreCardService.getTrend(studentId, 6); + assertThat(trend.getItems()).isNotEmpty(); + // 最近一个月应该有 2 条反馈 + CandidateScoreTrendItem latest = trend.getItems().get(trend.getItems().size() - 1); + assertThat(latest.getFeedbackCount()).isEqualTo(2); + assertThat(latest.getAverageRating()).isEqualTo(new BigDecimal("4.50")); + assertThat(latest.getRatingScore()).isEqualTo(new BigDecimal("90.00")); + } + + @Test + void getTrend_emptyData_shouldReturnEmptyArray() { + CandidateScoreTrendResponse trend = scoreCardService.getTrend(studentId, 6); + assertThat(trend.getItems()).isEmpty(); + } + + // ── 百分位 ──────────────────────────────────────────── + + @Test + void getPercentile_sampleCountLessThan3_shouldReturnInsufficientData() { + insertFeedback(studentId, 4, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + + CandidatePercentileResponse p = scoreCardService.getPercentile(studentId); + assertThat(p.getStatus()).isEqualTo("INSUFFICIENT_DATA"); + assertThat(p.getScore()).isNull(); + assertThat(p.getPercentile()).isNull(); + assertThat(p.getSampleCount()).isEqualTo(2); + } + + @Test + void getPercentile_cohortLessThan2_shouldReturnInsufficientData() { + // 只有 studentId 有 >= 3 条反馈,但 cohort 只有 1 人 + insertFeedback(studentId, 4, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 3, null, LocalDateTime.now()); + + CandidatePercentileResponse p = scoreCardService.getPercentile(studentId); + assertThat(p.getStatus()).isEqualTo("INSUFFICIENT_DATA"); + } + + @Test + void getPercentile_maxScore_shouldReturn100() { + // studentId 评分最高 + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + insertFeedback(studentId, 5, null, LocalDateTime.now()); + + insertFeedback(otherStudentId, 3, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 3, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 3, null, LocalDateTime.now()); + + CandidatePercentileResponse p = scoreCardService.getPercentile(studentId); + assertThat(p.getStatus()).isEqualTo("OK"); + assertThat(p.getPercentile()).isEqualTo(new BigDecimal("100.00")); + } + + @Test + void getPercentile_minScore_shouldReturn0() { + // studentId 评分最低 + insertFeedback(studentId, 1, null, LocalDateTime.now()); + insertFeedback(studentId, 1, null, LocalDateTime.now()); + insertFeedback(studentId, 1, null, LocalDateTime.now()); + + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + + CandidatePercentileResponse p = scoreCardService.getPercentile(studentId); + assertThat(p.getStatus()).isEqualTo("OK"); + assertThat(p.getPercentile()).isEqualTo(new BigDecimal("0.00")); + } + + @Test + void getPercentile_tieScore_shouldNotCountEachOther() { + // 两人同分,各不互相计入 + insertFeedback(studentId, 4, null, LocalDateTime.now()); + insertFeedback(studentId, 4, null, LocalDateTime.now()); + insertFeedback(studentId, 4, null, LocalDateTime.now()); + + insertFeedback(otherStudentId, 4, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 4, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 4, null, LocalDateTime.now()); + + CandidatePercentileResponse p = scoreCardService.getPercentile(studentId); + assertThat(p.getStatus()).isEqualTo("OK"); + // 同分:countLower = 0, cohortSize = 2, percentile = 0/(2-1)*100 = 0 + assertThat(p.getPercentile()).isEqualTo(new BigDecimal("0.00")); + } + + @Test + void getPercentile_noDataLeakage() { + // 确保 otherStudentId 的数据不会混入 studentId 的结果 + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + insertFeedback(otherStudentId, 5, null, LocalDateTime.now()); + + CandidateScoreCardResponse card = scoreCardService.getScoreCard(studentId); + assertThat(card.getSampleCount()).isEqualTo(0); + assertThat(card.getFeedbackSummary().getTotalCount()).isEqualTo(0); + } + + // ── 辅助方法 ────────────────────────────────────────── + + private void insertFeedback(Long studentUserId, int rating, String tags, LocalDateTime createdAt) { + Long appId = createApplication(studentUserId); + // Use native SQL insert to bypass H2 @CreatedDate and JSON type issues + jdbcTemplate.update( + "INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, content, tags, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + appId, 999L, studentUserId, rating, "测试反馈", tags, createdAt, createdAt); + } + + private void insertMatchRecord(Long candidateId, Double interestScore, Double hrScore) { + Long appId = createApplication(candidateId); + Application app = applicationRepository.findById(appId).orElseThrow(); + ApplicationMatchRecord.ApplicationMatchRecordBuilder builder = ApplicationMatchRecord.builder() + .applicationId(appId) + .jobId(app.getJobId()) + .companyId(companyId) + .candidateId(candidateId) + .companyRequiredCandidateScore(60) + .studentRequiredHrScore(60) + .status("MATCHED"); + if (interestScore != null) { + builder.candidateInterestScore(interestScore.intValue()); + } + if (hrScore != null) { + builder.hrSuitabilityScore(hrScore.intValue()); + } + matchRecordRepository.save(builder.build()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/api/ApiResponseTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/api/ApiResponseTest.java new file mode 100644 index 0000000..2c18651 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/api/ApiResponseTest.java @@ -0,0 +1,76 @@ +package com.mydreamjob.common.api; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ApiResponseTest { + + @Test + void okWithDataShouldReturnCode200AndMessageOk() { + ApiResponse response = ApiResponse.ok("test", "req-123"); + assertEquals(200, response.getCode()); + assertEquals("ok", response.getMessage()); + assertEquals("test", response.getData()); + assertEquals("req-123", response.getRequestId()); + assertNotNull(response.getTimestamp()); + } + + @Test + void createdShouldReturnCode201() { + ApiResponse response = ApiResponse.created("data", "req-123"); + assertEquals(201, response.getCode()); + assertEquals("ok", response.getMessage()); + } + + @Test + void errorShouldReturnSpecifiedCode() { + ApiResponse response = ApiResponse.error(400, "bad request", "req-123"); + assertEquals(400, response.getCode()); + assertEquals("bad request", response.getMessage()); + assertNull(response.getData()); + } + + @Test + void badRequestShouldReturn400() { + ApiResponse response = ApiResponse.badRequest("invalid params", "req-123"); + assertEquals(400, response.getCode()); + } + + @Test + void unauthorizedShouldReturn401() { + ApiResponse response = ApiResponse.unauthorized("not logged in", "req-123"); + assertEquals(401, response.getCode()); + } + + @Test + void forbiddenShouldReturn403() { + ApiResponse response = ApiResponse.forbidden("no permission", "req-123"); + assertEquals(403, response.getCode()); + } + + @Test + void notFoundShouldReturn404() { + ApiResponse response = ApiResponse.notFound("resource not found", "req-123"); + assertEquals(404, response.getCode()); + } + + @Test + void conflictShouldReturn409() { + ApiResponse response = ApiResponse.conflict("duplicate", "req-123"); + assertEquals(409, response.getCode()); + } + + @Test + void tooManyRequestsShouldReturn429() { + ApiResponse response = ApiResponse.tooManyRequests("rate limited", "req-123"); + assertEquals(429, response.getCode()); + } + + @Test + void internalErrorShouldReturn500() { + ApiResponse response = ApiResponse.internalError("req-123"); + assertEquals(500, response.getCode()); + assertEquals("internal server error", response.getMessage()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/ErrorCodeTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/ErrorCodeTest.java new file mode 100644 index 0000000..4732aa2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/ErrorCodeTest.java @@ -0,0 +1,22 @@ +package com.mydreamjob.common.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ErrorCodeTest { + + @Test + void shouldHaveCorrectCode() { + assertEquals(1000, ErrorCode.SUCCESS.getCode()); + assertEquals(2001, ErrorCode.UNAUTHORIZED.getCode()); + assertEquals(3005, ErrorCode.SMS_RATE_LIMITED.getCode()); + } + + @Test + void shouldHaveCorrectMessage() { + assertEquals("success", ErrorCode.SUCCESS.getMessage()); + assertEquals("unauthorized", ErrorCode.UNAUTHORIZED.getMessage()); + assertEquals("sms rate limited", ErrorCode.SMS_RATE_LIMITED.getMessage()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java new file mode 100644 index 0000000..9fecee8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -0,0 +1,123 @@ +package com.mydreamjob.common.exception; + +import com.mydreamjob.common.api.ApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; + +class GlobalExceptionHandlerHttpStatusTest { + + private GlobalExceptionHandler handler; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/test"); + } + + // ── 403 ─────────────────────────────────────────────────── + + @Test + void forbiddenError_shouldReturnHttp403() { + BusinessException ex = new BusinessException(ErrorCode.FORBIDDEN); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.FORBIDDEN.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), resp.getBody().getCode()); + } + + // ── 404 ─────────────────────────────────────────────────── + + @Test + void companyNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.COMPANY_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + } + + @Test + void jobNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.JOB_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), resp.getBody().getCode()); + } + + @Test + void applicationNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.APPLICATION_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + } + + // ── 409 ─────────────────────────────────────────────────── + + @Test + void companyAlreadyExists_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.COMPANY_ALREADY_EXISTS); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.COMPANY_ALREADY_EXISTS.getCode(), resp.getBody().getCode()); + } + + @Test + void applicationDuplicate_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.APPLICATION_DUPLICATE); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.APPLICATION_DUPLICATE.getCode(), resp.getBody().getCode()); + } + + @Test + void invalidJobStatusTransition_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + } + + @Test + void invalidApplicationStatusTransition_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + } + + @Test + void duplicateReview_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.DUPLICATE_REVIEW); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), resp.getBody().getCode()); + } + + @Test + void reviewNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.REVIEW_NOT_FOUND.getCode(), resp.getBody().getCode()); + } + + // ── 400 ─────────────────────────────────────────────────── + + @Test + void genericBusinessError_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.BAD_REQUEST); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), resp.getBody().getCode()); + } + + @Test + void unauthorized_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.UNAUTHORIZED); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.UNAUTHORIZED.getCode(), resp.getBody().getCode()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java new file mode 100644 index 0000000..c8e71e0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java @@ -0,0 +1,70 @@ +package com.mydreamjob.common.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.slf4j.MDC.get; + +class RequestIdFilterTest { + + private final RequestIdFilter filter = new RequestIdFilter(); + + @Test + void longClientRequestId_isBoundedAcrossRequestResponseMdc() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + String longRequestId = "123456789012345678901234567890123456"; + request.addHeader("X-Request-Id", longRequestId); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference requestIdSeenByController = new AtomicReference<>(); + + FilterChain chain = (wrappedRequest, ignoredResponse) -> { + requestIdSeenByController.set(((HttpServletRequest) wrappedRequest).getHeader("X-Request-Id")); + assertEquals(32, requestIdSeenByController.get().length()); + assertEquals(requestIdSeenByController.get(), get("requestId")); + }; + + filter.doFilter(request, response, chain); + + assertEquals(32, response.getHeader("X-Request-Id").length()); + assertEquals(response.getHeader("X-Request-Id"), requestIdSeenByController.get()); + assertNull(get("requestId"), "MDC must be cleared after the request"); + } + + @Test + void shortClientRequestId_isPreserved() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Request-Id", "req-abc"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (wrappedRequest, ignoredResponse) -> + assertEquals("req-abc", ((HttpServletRequest) wrappedRequest).getHeader("X-Request-Id"))); + + assertEquals("req-abc", response.getHeader("X-Request-Id")); + } + + @Test + void missingOrBlankRequestId_generatesSixteenCharacterId() throws Exception { + for (String header : new String[]{null, " "}) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (header != null) { + request.addHeader("X-Request-Id", header); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicReference captured = new AtomicReference<>(); + + filter.doFilter(request, response, (wrappedRequest, ignoredResponse) -> + captured.set(((HttpServletRequest) wrappedRequest).getHeader("X-Request-Id"))); + + assertNotNull(captured.get()); + assertEquals(16, captured.get().length()); + assertEquals(captured.get(), response.getHeader("X-Request-Id")); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java new file mode 100644 index 0000000..5668cc8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java @@ -0,0 +1,34 @@ +package com.mydreamjob.company; + +import com.mydreamjob.auth.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CompanyController.class) +@AutoConfigureMockMvc(addFilters = false) +@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class}) +class CompanyControllerMvcTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private CompanyService companyService; + + @Test + void getCompany_nonNumericId_shouldReturn400WithCode1001() throws Exception { + mockMvc.perform(get("/companies/abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)) + .andExpect(jsonPath("$.message").value("bad request")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyPublicServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyPublicServiceTest.java new file mode 100644 index 0000000..63e584d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyPublicServiceTest.java @@ -0,0 +1,361 @@ +package com.mydreamjob.company; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.dto.CompanyPublicView; +import com.mydreamjob.job.JobService; +import com.mydreamjob.job.dto.JobSearchItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CompanyPublicServiceTest { + + @Mock + private CompanyRepository companyRepository; + + @Mock + private com.mydreamjob.auth.UserRepository userRepository; + + @Mock + private JobService jobService; + + @InjectMocks + private CompanyService companyService; + + private Company company; + private Company company2; + + @BeforeEach + void setUp() { + company = Company.builder() + .id(1L).ownerUserId(10L).name("阿里巴巴") + .logoUrl("https://logo1.png").description("企业简介") + .industry("IT").website("https://www.example.com") + .address("北京").verified(true) + .createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()) + .build(); + + company2 = Company.builder() + .id(2L).ownerUserId(20L).name("腾讯科技") + .logoUrl("https://logo2.png").description("简介2") + .industry("IT").verified(false) + .build(); + } + + // ── 1. 企业公开详情 ───────────────────────────────────────── + + @Test + void getPublicCompany_success_shouldReturn6Fields() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + + CompanyPublicView result = companyService.getPublicCompany(1L); + + assertNotNull(result); + assertEquals(1L, result.getId()); + assertEquals("阿里巴巴", result.getName()); + assertEquals("https://logo1.png", result.getLogoUrl()); + assertEquals("企业简介", result.getDescription()); + assertEquals("IT", result.getIndustry()); + assertTrue(result.getVerified()); + } + + @Test + void getPublicCompany_notFound_shouldThrow404() { + when(companyRepository.findById(999L)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getPublicCompany(999L)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void toPublicView_shouldNotLeakSensitiveFields() { + CompanyPublicView result = companyService.toPublicView(company); + + assertNotNull(result); + assertEquals(6, countNonNullFields(result)); + // 确认不包含敏感字段 + assertNotNull(result.getId()); + assertNotNull(result.getName()); + assertNotNull(result.getLogoUrl()); + assertNotNull(result.getDescription()); + assertNotNull(result.getIndustry()); + assertNotNull(result.getVerified()); + } + + @Test + void toPublicView_unverifiedCompany_shouldReturnFalse() { + CompanyPublicView result = companyService.toPublicView(company2); + + assertFalse(result.getVerified()); + } + + // ── 2. 企业公开岗位列表 ───────────────────────────────────── + + @Test + void getCompanyJobs_success_shouldReturnJobs() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + JobSearchItem item = createSearchItem(1L, "Java开发"); + when(jobService.searchJobs(isNull(), isNull(), isNull(), isNull(), isNull(), + eq(1L), isNull(), eq(0), eq(20))) + .thenReturn(new PageImpl<>(List.of(item))); + + Page result = companyService.getCompanyJobs(1L, null, 0, 20); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals("Java开发", result.getContent().get(0).getTitle()); + } + + @Test + void getCompanyJobs_noJobs_shouldReturnEmptyPage() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + Page result = companyService.getCompanyJobs(1L, null, 0, 20); + + assertNotNull(result); + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + @Test + void getCompanyJobs_companyNotFound_shouldThrow404() { + when(companyRepository.findById(999L)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(999L, null, 0, 20)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getCompanyJobs_companyNotFound_shouldNotCallJobService() { + when(companyRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(999L, null, 0, 20)); + + verify(jobService, never()).searchJobs(any(), any(), any(), any(), any(), + anyLong(), any(), anyInt(), anyInt()); + } + + @Test + void getCompanyJobs_withSort_shouldPassSortToJobService() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(isNull(), isNull(), isNull(), isNull(), isNull(), + eq(1L), eq("salary"), eq(0), eq(10))) + .thenReturn(new PageImpl<>(List.of())); + + companyService.getCompanyJobs(1L, "salary", 0, 10); + + verify(jobService).searchJobs(isNull(), isNull(), isNull(), isNull(), isNull(), + eq(1L), eq("salary"), eq(0), eq(10)); + } + + @Test + void getCompanyJobs_shouldNotLeakInternalFields() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + JobSearchItem item = createSearchItem(1L, "测试岗位"); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of(item))); + + Page result = companyService.getCompanyJobs(1L, null, 0, 20); + + JobSearchItem.CompanyInfo companyInfo = result.getContent().get(0).getCompany(); + assertNotNull(companyInfo); + assertEquals(1L, companyInfo.getId()); + assertEquals("阿里巴巴", companyInfo.getName()); + assertEquals("https://logo1.png", companyInfo.getLogoUrl()); + assertTrue(companyInfo.getVerified()); + // JobSearchItem 不包含 ownerUserId 等敏感字段 + } + + // ── 3. 现有 toView() 保持不变 ─────────────────────────────── + + @Test + void toView_shouldStillReturnAllFields() { + Map result = companyService.toView(company); + + assertNotNull(result); + assertEquals(1L, result.get("id")); + assertEquals(10L, result.get("ownerUserId")); // 企业端仍返回 + assertEquals("阿里巴巴", result.get("name")); + assertEquals("https://www.example.com", result.get("website")); // 企业端仍返回 + assertNotNull(result.get("createdAt")); + } + + @Test + void toView_null_shouldReturnNull() { + assertNull(companyService.toView(null)); + } + + // ── 4. 参数校验 ───────────────────────────────────────────── + + @Test + void getCompanyJobs_invalidPage_shouldThrow400() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), eq(-1), anyInt())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(1L, null, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getCompanyJobs_invalidSize_shouldThrow400() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), eq(0))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(1L, null, 0, 0)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getCompanyJobs_sizeExceedsMax_shouldThrow400() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), eq(51))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(1L, null, 0, 51)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getCompanyJobs_invalidSort_shouldThrow400() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + eq("invalid"), anyInt(), anyInt())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.getCompanyJobs(1L, "invalid", 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + // ── 5. 排序兼容性 ─────────────────────────────────────────── + + @Test + void getCompanyJobs_sortLatest_shouldWork() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + eq("latest"), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + assertDoesNotThrow(() -> companyService.getCompanyJobs(1L, "latest", 0, 20)); + } + + @Test + void getCompanyJobs_sortSalary_shouldWork() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + eq("salary"), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + assertDoesNotThrow(() -> companyService.getCompanyJobs(1L, "salary", 0, 20)); + } + + @Test + void getCompanyJobs_sortRelevance_shouldWork() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + eq("relevance"), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + assertDoesNotThrow(() -> companyService.getCompanyJobs(1L, "relevance", 0, 20)); + } + + // ── 6. 空结果场景 ─────────────────────────────────────────── + + @Test + void getCompanyJobs_onlyDraft_shouldReturnEmpty() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + Page result = companyService.getCompanyJobs(1L, null, 0, 20); + + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + @Test + void getCompanyJobs_onlyClosed_shouldReturnEmpty() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + Page result = companyService.getCompanyJobs(1L, null, 0, 20); + + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + // ── 7. 批量查询复用验证 ───────────────────────────────────── + + @Test + void getCompanyJobs_shouldNotProduceNPlus1() { + when(companyRepository.findById(1L)).thenReturn(Optional.of(company)); + when(jobService.searchJobs(any(), any(), any(), any(), any(), anyLong(), + any(), anyInt(), anyInt())) + .thenReturn(new PageImpl<>(List.of())); + + companyService.getCompanyJobs(1L, null, 0, 20); + + // JobService.searchJobs 内部已处理批量企业查询,CompanyService 不做额外查询 + verify(companyRepository, never()).findAllById(anyCollection()); + } + + // ── Helper ────────────────────────────────────────────────── + + private JobSearchItem createSearchItem(Long jobId, String title) { + return JobSearchItem.builder() + .id(jobId).title(title).description("描述").requirements("要求") + .city("北京").employmentType("FULL_TIME") + .salaryMin(15000).salaryMax(25000) + .status("PUBLISHED") + .publishedAt(LocalDateTime.now()).createdAt(LocalDateTime.now()) + .company(JobSearchItem.CompanyInfo.builder() + .id(1L).name("阿里巴巴").logoUrl("https://logo1.png") + .verified(true).build()) + .build(); + } + + private int countNonNullFields(CompanyPublicView view) { + int count = 0; + if (view.getId() != null) count++; + if (view.getName() != null) count++; + if (view.getLogoUrl() != null) count++; + if (view.getDescription() != null) count++; + if (view.getIndustry() != null) count++; + if (view.getVerified() != null) count++; + return count; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java new file mode 100644 index 0000000..e25854e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java @@ -0,0 +1,168 @@ +package com.mydreamjob.company; + +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.dto.CompanyCreateRequest; +import com.mydreamjob.company.dto.CompanyUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CompanyServiceTest { + + @Mock + private CompanyRepository companyRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CompanyService companyService; + + private static final Long COMPANY_USER_ID = 10L; + private static final Long STUDENT_USER_ID = 100L; + private Company company; + + @BeforeEach + void setUp() { + company = Company.builder() + .id(1L).ownerUserId(COMPANY_USER_ID) + .name("测试企业").description("一家科技公司") + .verified(false).build(); + } + + private void mockCompanyUser() { mockUserRole(COMPANY_USER_ID, "COMPANY"); } + private void mockStudentUser() { mockUserRole(STUDENT_USER_ID, "STUDENT"); } + + private void mockUserRole(Long userId, String roleName) { + User.Role role = User.Role.valueOf(roleName); + User user = User.builder() + .id(userId).phone("1380000" + userId).role(role) + .active(true).verified(true).build(); + user.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + } + + // ── 角色校验优先 ───────────────────────────────────────── + + @Test + void findCompany_asStudent_shouldReturn403() { + mockStudentUser(); + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.findCompanyByOwnerUserId(STUDENT_USER_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(companyRepository, never()).findByOwnerUserId(any()); + } + + @Test + void updateCompany_asStudent_shouldReturn403() { + mockStudentUser(); + CompanyUpdateRequest req = new CompanyUpdateRequest(); req.setName("hack"); + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.updateCompany(STUDENT_USER_ID, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(companyRepository, never()).save(any()); + } + + // ── 创建 ───────────────────────────────────────────────── + + @Test + void createCompany_shouldSucceed() { + mockCompanyUser(); + CompanyCreateRequest req = new CompanyCreateRequest(); + req.setName("新公司"); req.setIndustry("互联网"); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.empty()); + when(companyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Company created = companyService.createCompany(COMPANY_USER_ID, req); + assertEquals("新公司", created.getName()); + assertFalse(created.getVerified()); + } + + @Test + void createCompany_alreadyExists_shouldReturn409() { + mockCompanyUser(); + CompanyCreateRequest req = new CompanyCreateRequest(); req.setName("dup"); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.of(company)); + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.createCompany(COMPANY_USER_ID, req)); + assertEquals(ErrorCode.COMPANY_ALREADY_EXISTS.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createCompany_asStudent_shouldReturn403() { + mockStudentUser(); + CompanyCreateRequest req = new CompanyCreateRequest(); req.setName("hack"); + BusinessException ex = assertThrows(BusinessException.class, + () -> companyService.createCompany(STUDENT_USER_ID, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(companyRepository, never()).save(any()); + } + + // ── 查询 ───────────────────────────────────────────────── + + @Test + void findCompany_exists_shouldReturnCompany() { + mockCompanyUser(); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.of(company)); + Company result = companyService.findCompanyByOwnerUserId(COMPANY_USER_ID); + assertNotNull(result); + assertEquals("测试企业", result.getName()); + } + + @Test + void findCompany_notExists_shouldReturnNull() { + mockCompanyUser(); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.empty()); + assertNull(companyService.findCompanyByOwnerUserId(COMPANY_USER_ID)); + } + + // ── 更新 ───────────────────────────────────────────────── + + @Test + void updateCompany_shouldUpdateFields() { + mockCompanyUser(); + CompanyUpdateRequest req = new CompanyUpdateRequest(); + req.setName("新名称"); req.setIndustry("互联网"); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.of(company)); + when(companyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Company updated = companyService.updateCompany(COMPANY_USER_ID, req); + assertEquals("新名称", updated.getName()); + assertEquals("一家科技公司", updated.getDescription()); + } + + @Test + void updateCompany_notFound_shouldThrow() { + mockCompanyUser(); + CompanyUpdateRequest req = new CompanyUpdateRequest(); req.setName("x"); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.empty()); + assertThrows(BusinessException.class, () -> companyService.updateCompany(COMPANY_USER_ID, req)); + } + + // ── 视图 ───────────────────────────────────────────────── + + @Test + void toView_null_shouldReturnNull() { + assertNull(companyService.toView(null)); + } + + @Test + void toView_shouldIncludeFields() { + var view = companyService.toView(company); + assertEquals(1L, view.get("id")); + assertEquals("测试企业", view.get("name")); + assertEquals(false, view.get("verified")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java new file mode 100644 index 0000000..a019a8b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java @@ -0,0 +1,267 @@ +package com.mydreamjob.controller; + +import com.mydreamjob.auth.AuthTokenRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.SmsVerificationCode; +import com.mydreamjob.auth.SmsVerificationCodeRepository; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 认证链路集成测试: + * 1. 发码 → 登录 → 访问 /me → 重放失败 + * 2. purpose 不匹配失败 + * 3. 已使用验证码失败 + * 4. 最新验证码优先 + * 5. 登录 HTTP 状态与响应 code 一致 + * + * 激活 profile: integration-test(H2 内嵌数据库 + Redis mock) + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthIntegrationTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SmsVerificationCodeRepository codeRepository; + + @Autowired + private AuthTokenRepository authTokenRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private static final String PHONE = "13800000001"; + private static final String PURPOSE = "login"; + private static final String WRONG_PURPOSE = "password_reset"; + + @BeforeEach + void setUp() { + // Stub Redis 操作:禁用频率限制和锁定 + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // 使用 CALLS_REAL_METHODS 避免未 stub 方法抛出 NPE + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class, + org.mockito.Answers.CALLS_REAL_METHODS); + when(redisTemplate.opsForValue()).thenReturn(ops); + codeRepository.deleteAll(); + authTokenRepository.deleteAll(); + userRepository.deleteAll(); + } + + // ── 完整链路 ────────────────────────────────────────────── + + @Test + void fullFlow_sendCodeLoginAccessMe_replayFails() throws Exception { + // 1. 发送验证码 + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + // 2. 从数据库取最新未使用验证码 + String code = getLatestUnusedCode(PHONE, PURPOSE); + assertThat(code).isNotNull(); + + // 3. 登录 — 同时断言 HTTP 200 和业务 code 200,并从响应中提取 token + org.springframework.test.web.servlet.MvcResult loginResult = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + String loginResp = loginResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + // 5. 解析 token + String token = extractAccessToken(loginResp); + assertThat(token).isNotNull(); + + // 6. 访问 /me 成功 + mockMvc.perform(get("/auth/me") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.phone").value(PHONE)); + + // 7. 重放验证码必须失败(返回 400,不能签发新 token) + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + // 8. 确认验证码已被标记 used=true + Optional stored = codeRepository.findAll().stream() + .filter(c -> c.getCode().equals(code)) + .findFirst(); + assertThat(stored).isPresent(); + assertThat(stored.get().getUsed()).isTrue(); + } + + // ── purpose 不匹配 ──────────────────────────────────────── + + @Test + void login_withWrongPurpose_shouldFail() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()); + + String code = getLatestUnusedCode(PHONE, PURPOSE); + assertThat(code).isNotNull(); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + WRONG_PURPOSE + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + // ── 已使用验证码 ─────────────────────────────────────────── + + @Test + void verifyUsedCode_shouldFail() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()); + + String code = getLatestUnusedCode(PHONE, PURPOSE); + assertThat(code).isNotNull(); + + // 第一次登录成功 + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isOk()); + + // 第二次重放失败 + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + // ── 最新验证码优先 ───────────────────────────────────────── + + @Test + void latestCodeTakesPriority_overOldCode() throws Exception { + // 先发一条旧验证码 + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()); + + // 等一秒再发一条新验证码 + Thread.sleep(1001); + + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()); + + // 旧验证码(已被 used=true)应失败 + String oldCode = getOldUsedCode(PHONE, PURPOSE); + if (oldCode != null) { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + oldCode + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + // 新验证码应成功 + String newCode = getLatestUnusedCode(PHONE, PURPOSE); + assertThat(newCode).isNotNull(); + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + newCode + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + // ── 未登录访问 /me ──────────────────────────────────────── + + @Test + void accessMe_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/auth/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + // ── 辅助方法 ─────────────────────────────────────────────── + + private String getLatestUnusedCode(String phone, String purpose) { + List codes = + codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(phone, purpose); + for (SmsVerificationCode c : codes) { + if (!Boolean.TRUE.equals(c.getUsed())) return c.getCode(); + } + return null; + } + + private String getOldUsedCode(String phone, String purpose) { + List codes = + codeRepository.findByPhoneAndPurposeAndUsedTrueOrderByCreatedAtDesc(phone, purpose); + for (SmsVerificationCode c : codes) { + return c.getCode(); // 返回第一条(最新已使用) + } + return null; + } + + private String extractAccessToken(String json) { + Pattern p = Pattern.compile("\"accessToken\":\"([^\"]+)\""); + Matcher m = p.matcher(json); + return m.find() ? m.group(1) : null; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java new file mode 100644 index 0000000..c9163f5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java @@ -0,0 +1,173 @@ +package com.mydreamjob.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 认证输入校验测试:手机号必须11位数字,验证码必须6位数字 + * 激活 profile: integration-test + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthValidationTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + private static final String PHONE = "13800000001"; + private static final String VALID_CODE = "123456"; + + @BeforeEach + void setUp() { + when(redisTemplate.hasKey(anyString())).thenReturn(false); + org.springframework.data.redis.core.ValueOperations ops = + org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class, + org.mockito.Answers.CALLS_REAL_METHODS); + when(redisTemplate.opsForValue()).thenReturn(ops); + } + + // ── /auth/sms/send 校验 ────────────────────────────────── + + @Test + void sendCode_emptyPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", "")) + .andExpect(status().isBadRequest()); + } + + @Test + void sendCode_missingPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/sms/send")) + .andExpect(status().isBadRequest()); + } + + @Test + void sendCode_oneDigitPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + void sendCode_alphabeticPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", "abcdefghijk")) + .andExpect(status().isBadRequest()); + } + + @Test + void sendCode_phoneWithSpaces_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", "138 000 0001")) + .andExpect(status().isBadRequest()); + } + + @Test + void sendCode_validPhone_shouldReturn200() throws Exception { + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + // ── /auth/login 校验 ────────────────────────────────────── + + @Test + void login_emptyPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"\",\"code\":\"" + VALID_CODE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_missingPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"" + VALID_CODE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_oneDigitPhone_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"1\",\"code\":\"" + VALID_CODE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_alphabeticPhoneShouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"abcdefghijk\",\"code\":\"" + VALID_CODE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_emptyCode_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\",\"code\":\"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_missingCode_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_fiveDigitCode_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\",\"code\":\"12345\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_sevenDigitCode_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\",\"code\":\"1234567\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_nonNumericCode_shouldReturn400() throws Exception { + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\",\"code\":\"abcdef\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void login_validRequest_shouldReturn400BecauseCodeInvalid() throws Exception { + // 参数校验通过,但因验证码不存在返回400(业务错误) + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\",\"code\":\"" + VALID_CODE + "\"}")) + .andExpect(status().isBadRequest()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java new file mode 100644 index 0000000..f8f997b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java @@ -0,0 +1,102 @@ +package com.mydreamjob.feedback; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CandidateFeedbackControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private CandidateFeedbackService feedbackService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + private static final Long APP_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String token() { + return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + } + + @Test void createFeedback_shouldReturn200() throws Exception { + when(feedbackService.createFeedback(eq(10L), eq(APP_ID), any())) + .thenReturn(Map.of("id", 1, "rating", 4)); + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "Good")); + + mockMvc.perform(post("/applications/" + APP_ID + "/feedback") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(4)); + } + + @Test void createFeedback_noToken_shouldReturn401() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "Good")); + mockMvc.perform(post("/applications/" + APP_ID + "/feedback") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test void getFeedback_shouldReturn200() throws Exception { + when(feedbackService.getFeedback(10L, APP_ID)) + .thenReturn(Map.of("id", 1, "rating", 4)); + mockMvc.perform(get("/applications/" + APP_ID + "/feedback") + .header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(4)); + } + + @Test void updateFeedback_shouldReturn200() throws Exception { + when(feedbackService.updateFeedback(eq(10L), eq(APP_ID), any())) + .thenReturn(Map.of("id", 1, "rating", 5)); + String body = mapper.writeValueAsString(Map.of("rating", 5, "content", "Updated")); + mockMvc.perform(put("/applications/" + APP_ID + "/feedback") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(5)); + } + + @Test void deleteFeedback_shouldReturn200() throws Exception { + doNothing().when(feedbackService).deleteFeedback(10L, APP_ID); + mockMvc.perform(delete("/applications/" + APP_ID + "/feedback") + .header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void createFeedback_invalidRating_shouldReturn400() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 6, "content", "Bad")); + mockMvc.perform(post("/applications/" + APP_ID + "/feedback") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } + + @Test void createFeedback_blankContent_shouldReturn400() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "")); + mockMvc.perform(post("/applications/" + APP_ID + "/feedback") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java new file mode 100644 index 0000000..a603f19 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java @@ -0,0 +1,164 @@ +package com.mydreamjob.feedback; + +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.feedback.dto.CandidateFeedbackRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CandidateFeedbackServiceTest { + + @Mock private CandidateFeedbackRepository feedbackRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @InjectMocks private CandidateFeedbackService feedbackService; + + private static final Long COMPANY_USER_ID = 10L; + private static final Long OTHER_COMPANY_ID = 11L; + private static final Long STUDENT_USER_ID = 100L; + private static final Long OTHER_STUDENT_ID = 101L; + private static final Long APPLICATION_ID = 1L; + private static final Long JOB_ID = 50L; + private Application app; + private Job job; + + @BeforeEach + void setUp() { + app = Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_USER_ID).build(); + job = Job.builder().id(JOB_ID).companyId(COMPANY_USER_ID).title("Java").build(); + } + + private CandidateFeedbackRequest buildReq() { + CandidateFeedbackRequest req = new CandidateFeedbackRequest(); + req.setRating(4); req.setContent("Good candidate"); + return req; + } + + // ── 创建 ───────────────────────────────────────────────── + + @Test void createFeedback_shouldSucceed() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(feedbackRepository.existsByApplicationId(APPLICATION_ID)).thenReturn(false); + when(feedbackRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var r = feedbackService.createFeedback(COMPANY_USER_ID, APPLICATION_ID, buildReq()); + assertEquals(4, r.get("rating")); + } + + @Test void createFeedback_duplicate_shouldReturn409() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + when(feedbackRepository.existsByApplicationId(APPLICATION_ID)).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.createFeedback(COMPANY_USER_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.DUPLICATE_FEEDBACK.getCode(), ex.getErrorCodeInt()); + } + + @Test void createFeedback_wrongCompany_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.createFeedback(OTHER_COMPANY_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void createFeedback_appNotFound_shouldReturn404() { + when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.createFeedback(COMPANY_USER_ID, 999L, buildReq())); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 查询 ───────────────────────────────────────────────── + + @Test void getFeedback_asCompany_shouldSucceed() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); + when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); + + var r = feedbackService.getFeedback(COMPANY_USER_ID, APPLICATION_ID); + assertEquals(4, r.get("rating")); + } + + @Test void getFeedback_asStudent_shouldSucceed() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); + when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); + + var r = feedbackService.getFeedback(STUDENT_USER_ID, APPLICATION_ID); + assertEquals(4, r.get("rating")); + } + + @Test void getFeedback_otherUser_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.getFeedback(OTHER_STUDENT_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 修改 ───────────────────────────────────────────────── + + @Test void updateFeedback_shouldSucceed() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); + when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); + when(feedbackRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CandidateFeedbackRequest req = buildReq(); req.setRating(5); + var r = feedbackService.updateFeedback(COMPANY_USER_ID, APPLICATION_ID, req); + assertEquals(5, r.get("rating")); + } + + @Test void updateFeedback_wrongCompany_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.updateFeedback(OTHER_COMPANY_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 删除 ───────────────────────────────────────────────── + + @Test void deleteFeedback_shouldSucceed() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).build(); + when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); + + assertDoesNotThrow(() -> feedbackService.deleteFeedback(COMPANY_USER_ID, APPLICATION_ID)); + verify(feedbackRepository).delete(fb); + } + + @Test void deleteFeedback_wrongCompany_shouldReturn403() { + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); + when(jobService.getJob(JOB_ID)).thenReturn(job); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.deleteFeedback(OTHER_COMPANY_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java new file mode 100644 index 0000000..134ad82 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java @@ -0,0 +1,537 @@ +package com.mydreamjob.interview; + +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.interview.dto.InterviewCancelRequest; +import com.mydreamjob.interview.dto.InterviewCreateRequest; +import com.mydreamjob.interview.dto.InterviewRespondRequest; +import com.mydreamjob.interview.dto.InterviewUpdateRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.notification.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class InterviewServiceTest { + + @Mock private InterviewRepository interviewRepository; + @Mock private InterviewStatusHistoryRepository historyRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + + @InjectMocks private InterviewService interviewService; + + private static final Long COMPANY_USER_ID = 10L; + private static final Long OTHER_COMPANY_USER_ID = 20L; + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_ID = 1L; + private static final Long OTHER_COMPANY_ID = 2L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + private static final Long INTERVIEW_ID = 1L; + + private AuditContext ctx = AuditContext.builder().requestId("req-123").build(); + + @BeforeEach + void setUp() { + ctx = AuditContext.builder().requestId("req-123").build(); + } + + // ── 辅助 ────────────────────────────────────────────────── + + private void mockCompanyUser(Long userId) { + User u = User.builder().id(userId).phone("1").role(User.Role.COMPANY).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockStudentUser(Long userId) { + User u = User.builder().id(userId).phone("2").role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockCompany(Long userId, Long companyId) { + when(companyRepository.findByOwnerUserId(userId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("TestCorp").build())); + } + + private void mockCompanyById(Long companyId, Long ownerUserId) { + when(companyRepository.findById(companyId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").build())); + } + + private void mockCompanyByOwner(Long ownerUserId, Long companyId) { + when(companyRepository.findByOwnerUserId(ownerUserId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").build())); + } + + private Interview buildInterview(String status) { + return Interview.builder().id(INTERVIEW_ID).applicationId(APPLICATION_ID) + .companyUserId(COMPANY_USER_ID).studentUserId(STUDENT_ID) + .roundNo(1).title("技术面试").interviewType("ONLINE") + .startAt(LocalDateTime.now().plusDays(1).withHour(14).withMinute(0)) + .endAt(LocalDateTime.now().plusDays(1).withHour(15).withMinute(0)) + .timezone("Asia/Shanghai").meetingUrl("https://meet.example.com/123") + .status(status).version(0L).build(); + } + + private InterviewCreateRequest buildCreateReq() { + InterviewCreateRequest req = new InterviewCreateRequest(); + req.setTitle("技术面试"); + req.setInterviewType("ONLINE"); + req.setStartAt(LocalDateTime.now().plusDays(1).withHour(14).withMinute(0)); + req.setEndAt(LocalDateTime.now().plusDays(1).withHour(15).withMinute(0)); + req.setMeetingUrl("https://meet.example.com/123"); + return req; + } + + // ── 创建面试 ────────────────────────────────────────────── + + @Test + void createInterview_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + when(interviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Interview interview = interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, buildCreateReq(), ctx); + + assertEquals("SCHEDULED", interview.getStatus()); + assertEquals(COMPANY_USER_ID, interview.getCompanyUserId()); + assertEquals(STUDENT_ID, interview.getStudentUserId()); + verify(historyRepository).save(any()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_INVITED"), any(), any(), any(), any(), any()); + } + + @Test + void createInterview_applicationNotFound_shouldReturn404() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, 999L, buildCreateReq(), ctx)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_jobNotOwned_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(OTHER_COMPANY_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_startAtAfterEndAt_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + InterviewCreateRequest req = buildCreateReq(); + req.setStartAt(LocalDateTime.now().plusDays(1).withHour(15)); + req.setEndAt(LocalDateTime.now().plusDays(1).withHour(14)); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, req, ctx)); + assertEquals(ErrorCode.INTERVIEW_TIME_INVALID.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_startAtInPast_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + InterviewCreateRequest req = buildCreateReq(); + req.setStartAt(LocalDateTime.now().minusDays(1)); + req.setEndAt(LocalDateTime.now().plusDays(1)); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, req, ctx)); + assertEquals(ErrorCode.INTERVIEW_START_TIME_PAST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_onlineMissingUrl_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + InterviewCreateRequest req = buildCreateReq(); + req.setMeetingUrl(null); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, req, ctx)); + assertEquals(ErrorCode.INTERVIEW_ONLINE_URL_REQUIRED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_offlineMissingAddress_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + InterviewCreateRequest req = buildCreateReq(); + req.setInterviewType("OFFLINE"); + req.setMeetingUrl(null); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, req, ctx)); + assertEquals(ErrorCode.INTERVIEW_OFFLINE_ADDRESS_REQUIRED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createInterview_byStudent_shouldReturn403() { + mockStudentUser(STUDENT_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.createInterview(STUDENT_ID, APPLICATION_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 查看面试 ────────────────────────────────────────────── + + @Test + void getInterview_asStudent_shouldSucceed() { + mockStudentUser(STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + Interview interview = interviewService.getInterview(INTERVIEW_ID, STUDENT_ID); + assertEquals(INTERVIEW_ID, interview.getId()); + } + + @Test + void getInterview_asOtherStudent_shouldReturn403() { + mockStudentUser(OTHER_STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.getInterview(INTERVIEW_ID, OTHER_STUDENT_ID)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getInterview_asOtherCompany_shouldReturn403() { + mockCompanyUser(OTHER_COMPANY_USER_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.getInterview(INTERVIEW_ID, OTHER_COMPANY_USER_ID)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getInterview_asOwnCompany_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + assertDoesNotThrow(() -> interviewService.getInterview(INTERVIEW_ID, COMPANY_USER_ID)); + } + + @Test + void getInterview_notFound_shouldReturn404() { + when(interviewRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.getInterview(999L, STUDENT_ID)); + assertEquals(ErrorCode.INTERVIEW_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 学生响应 ────────────────────────────────────────────── + + private InterviewRespondRequest respondReq(String action) { + InterviewRespondRequest req = new InterviewRespondRequest(); + req.setAction(action); + return req; + } + + @Test + void respond_confirm_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyByOwner(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + when(interviewRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, respondReq("CONFIRM"), ctx); + + assertEquals("CONFIRMED", interview.getStatus()); + verify(historyRepository).save(any()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_CONFIRMED"), any(), any(), any(), any(), any()); + } + + @Test + void respond_decline_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyByOwner(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + when(interviewRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, respondReq("DECLINE"), ctx); + + assertEquals("DECLINED", interview.getStatus()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_DECLINED"), any(), any(), any(), any(), any()); + } + + @Test + void respond_rescheduleRequest_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyByOwner(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + when(interviewRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, + respondReq("REQUEST_RESCHEDULE"), ctx); + + assertEquals("RESCHEDULE_REQUESTED", interview.getStatus()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_RESCHEDULE_REQUESTED"), any(), any(), any(), any(), any()); + } + + @Test + void respond_alreadyConfirmed_shouldBeIdempotent() { + mockStudentUser(STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("CONFIRMED"))); + // 重复确认应幂等返回,不抛异常 + Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, respondReq("CONFIRM"), ctx); + assertEquals("CONFIRMED", interview.getStatus()); + } + + @Test + void respond_otherStudent_shouldFail() { + mockStudentUser(OTHER_STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.respondToInterview(OTHER_STUDENT_ID, INTERVIEW_ID, respondReq("CONFIRM"), ctx)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void respond_sameStatus_shouldBeIdempotent() { + mockStudentUser(STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("CONFIRMED"))); + // 幂等:相同状态不抛异常,直接返回 + Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, respondReq("CONFIRM"), ctx); + assertEquals("CONFIRMED", interview.getStatus()); + } + + // ── 企业取消 ────────────────────────────────────────────── + + @Test + void cancel_byCompany_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + when(interviewRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + InterviewCancelRequest req = new InterviewCancelRequest(); + Interview interview = interviewService.cancelInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx); + + assertEquals("CANCELLED", interview.getStatus()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_CANCELLED"), any(), any(), any(), any(), any()); + } + + @Test + void cancel_alreadyCancelled_shouldBeIdempotent() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("CANCELLED"))); + + InterviewCancelRequest req = new InterviewCancelRequest(); + Interview interview = interviewService.cancelInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx); + + assertEquals("CANCELLED", interview.getStatus()); + verify(interviewRepository, never()).saveAndFlush(any()); + } + + @Test + void cancel_completedInterview_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + + InterviewCancelRequest req = new InterviewCancelRequest(); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.cancelInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx)); + assertEquals(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void cancel_otherCompany_shouldFail() { + mockCompanyUser(OTHER_COMPANY_USER_ID); + mockCompany(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + + InterviewCancelRequest req = new InterviewCancelRequest(); + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.cancelInterview(OTHER_COMPANY_USER_ID, INTERVIEW_ID, req, ctx)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + // ── 完成面试 ────────────────────────────────────────────── + + @Test + void complete_afterEndTime_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + Interview interview = buildInterview("CONFIRMED"); + interview.setStartAt(LocalDateTime.now().minusHours(3)); + interview.setEndAt(LocalDateTime.now().minusHours(1)); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview)); + when(interviewRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Interview result = interviewService.completeInterview(COMPANY_USER_ID, INTERVIEW_ID, null, ctx); + + assertEquals("COMPLETED", result.getStatus()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_COMPLETED"), any(), any(), any(), any(), any()); + } + + @Test + void complete_beforeEndTime_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("CONFIRMED"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.completeInterview(COMPANY_USER_ID, INTERVIEW_ID, null, ctx)); + assertEquals(ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode(), ex.getErrorCodeInt()); + } + + @Test + void complete_notConfirmed_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.completeInterview(COMPANY_USER_ID, INTERVIEW_ID, null, ctx)); + assertEquals(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void complete_alreadyCompleted_shouldBeIdempotent() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + + Interview result = interviewService.completeInterview(COMPANY_USER_ID, INTERVIEW_ID, null, ctx); + + assertEquals("COMPLETED", result.getStatus()); + verify(interviewRepository, never()).saveAndFlush(any()); + } + + // ── 修改面试 ────────────────────────────────────────────── + + @Test + void update_scheduled_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + when(interviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + InterviewUpdateRequest req = new InterviewUpdateRequest(); + req.setTitle("新标题"); + Interview interview = interviewService.updateInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx); + + assertEquals("新标题", interview.getTitle()); + } + + @Test + void update_completed_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.updateInterview(COMPANY_USER_ID, INTERVIEW_ID, new InterviewUpdateRequest(), ctx)); + assertEquals(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + // ── 时间线 ──────────────────────────────────────────────── + + @Test + void timeline_shouldReturnHistory() { + mockStudentUser(STUDENT_ID); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); + InterviewStatusHistory h = InterviewStatusHistory.builder().id(1L).interviewId(INTERVIEW_ID) + .fromStatus(null).toStatus("SCHEDULED").actorUserId(COMPANY_USER_ID).reason("Created").build(); + when(historyRepository.findByInterviewIdOrderByCreatedAtAsc(INTERVIEW_ID)).thenReturn(List.of(h)); + var timeline = interviewService.getTimeline(INTERVIEW_ID, STUDENT_ID); + assertEquals(1, timeline.size()); + assertEquals("SCHEDULED", timeline.get(0).get("toStatus")); + } + + // ── 列表 ────────────────────────────────────────────────── + + @Test + void listMyInterviews_asStudent_shouldReturnOwn() { + mockStudentUser(STUDENT_ID); + when(interviewRepository.findByStudentUserIdOrderByStartAtDesc(eq(STUDENT_ID), any())) + .thenReturn(new PageImpl<>(List.of(buildInterview("SCHEDULED")))); + var page = interviewService.listMyInterviews(STUDENT_ID, null, null, null, 0, 20); + assertEquals(1, page.getTotalElements()); + } + + @Test + void listMyInterviews_asCompany_shouldReturnOwn() { + mockCompanyUser(COMPANY_USER_ID); + when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(eq(COMPANY_USER_ID), any())) + .thenReturn(new PageImpl<>(List.of(buildInterview("SCHEDULED")))); + var page = interviewService.listMyInterviews(COMPANY_USER_ID, null, null, null, 0, 20); + assertEquals(1, page.getTotalElements()); + } + + @Test + void listMyInterviews_invalidPage_shouldFail() { + BusinessException ex = assertThrows(BusinessException.class, + () -> interviewService.listMyInterviews(STUDENT_ID, null, null, null, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java new file mode 100644 index 0000000..6ac3fa8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java @@ -0,0 +1,203 @@ +package com.mydreamjob.job; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JobFavoriteServiceTest { + + @Mock + private JobFavoriteRepository favoriteRepository; + + @Mock + private JobService jobService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private JobFavoriteService favoriteService; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 10L; + private static final Long JOB_ID = 10L; + + private Job publishedJob; + private Job draftJob; + + @BeforeEach + void setUp() { + publishedJob = Job.builder().id(JOB_ID).companyId(1L) + .title("Java 后端").status("PUBLISHED").build(); + draftJob = Job.builder().id(20L).companyId(1L) + .title("Draft").status("DRAFT").build(); + } + + private void mockStudentRole(Long userId) { + User student = User.builder().id(userId).phone("13800000001") + .role(User.Role.STUDENT).active(true).verified(true).build(); + student.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(student)); + } + + private void mockCompanyRole(Long userId) { + User company = User.builder().id(userId).phone("13800000002") + .role(User.Role.COMPANY).active(true).verified(true).build(); + company.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(company)); + } + + // ── 成功收藏 ───────────────────────────────────────────── + + @Test + void favorite_publishedJob_shouldSucceed() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(favoriteRepository.findByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)) + .thenReturn(Optional.empty()); + when(favoriteRepository.save(any())).thenAnswer(inv -> { + JobFavorite f = inv.getArgument(0); + f.setCreatedAt(java.time.LocalDateTime.now()); + return f; + }); + + var result = favoriteService.favorite(STUDENT_ID, JOB_ID); + + assertEquals(JOB_ID, result.get("jobId")); + assertEquals(true, result.get("favorited")); + assertNotNull(result.get("favoritedAt")); + verify(favoriteRepository, times(1)).save(any()); + } + + // ── 重复收藏幂等 ───────────────────────────────────────── + + @Test + void favorite_duplicate_shouldReturn200() { + mockStudentRole(STUDENT_ID); + JobFavorite existing = JobFavorite.builder() + .id(1L).jobId(JOB_ID).studentUserId(STUDENT_ID).build(); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(favoriteRepository.findByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)) + .thenReturn(Optional.of(existing)); + + var result = favoriteService.favorite(STUDENT_ID, JOB_ID); + + assertEquals(true, result.get("favorited")); + verify(favoriteRepository, never()).save(any()); + } + + // ── DRAFT 职位拒绝 ─────────────────────────────────────── + + @Test + void favorite_draftJob_shouldThrow() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(20L)).thenReturn(draftJob); + + BusinessException ex = assertThrows(BusinessException.class, + () -> favoriteService.favorite(STUDENT_ID, 20L)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + verify(favoriteRepository, never()).save(any()); + } + + // ── 取消收藏 ───────────────────────────────────────────── + + @Test + void unfavorite_shouldReturn200() { + mockStudentRole(STUDENT_ID); + doNothing().when(favoriteRepository).deleteByJobIdAndStudentUserId(JOB_ID, STUDENT_ID); + + var result = favoriteService.unfavorite(STUDENT_ID, JOB_ID); + + assertEquals(JOB_ID, result.get("jobId")); + assertEquals(false, result.get("favorited")); + verify(favoriteRepository, times(1)).deleteByJobIdAndStudentUserId(JOB_ID, STUDENT_ID); + } + + // ── 查询分页 ───────────────────────────────────────────── + + @Test + void listFavorites_shouldReturnPagedResults() { + mockStudentRole(STUDENT_ID); + JobFavorite fav = JobFavorite.builder() + .id(1L).jobId(JOB_ID).studentUserId(STUDENT_ID) + .createdAt(java.time.LocalDateTime.now()).build(); + Page page = new PageImpl<>(List.of(fav)); + when(favoriteRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(STUDENT_ID), any())) + .thenReturn(page); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(jobService.toPublicView(publishedJob)).thenReturn( + Map.of("id", JOB_ID, "title", "Java 后端", "status", "PUBLISHED")); + + var result = favoriteService.listFavorites(STUDENT_ID, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals("Java 后端", result.getContent().get(0).get("title")); + assertNotNull(result.getContent().get(0).get("favoritedAt")); + } + + // ── 角色越权 ───────────────────────────────────────────── + + @Test + void favorite_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> favoriteService.favorite(COMPANY_ID, JOB_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void unfavorite_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> favoriteService.unfavorite(COMPANY_ID, JOB_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void listFavorites_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> favoriteService.listFavorites(COMPANY_ID, 0, 20)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 并发重复防御 ───────────────────────────────────────── + + @Test + void favorite_concurrentDuplicate_shouldStillReturn200() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(favoriteRepository.findByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)) + .thenReturn(Optional.empty()); + when(favoriteRepository.save(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + // 并发后查询到已有记录 + JobFavorite existing = JobFavorite.builder() + .id(2L).jobId(JOB_ID).studentUserId(STUDENT_ID).build(); + when(favoriteRepository.findByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)) + .thenReturn(Optional.empty()) // first call + .thenReturn(Optional.of(existing)); // retry after exception + + var result = favoriteService.favorite(STUDENT_ID, JOB_ID); + + assertEquals(true, result.get("favorited")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java new file mode 100644 index 0000000..7f910ae --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java @@ -0,0 +1,598 @@ +package com.mydreamjob.job; + +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.dto.JobSearchItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JobSearchServiceTest { + + @Mock + private JobRepository jobRepository; + + @Mock + private CompanyRepository companyRepository; + + @Spy + @InjectMocks + private JobService jobService; + + private Job pubJob1; + private Job pubJob2; + private Job pubJobNullSalary; + private Job draftJob; + private Job closedJob; + private Company company1; + private Company company2; + + @BeforeEach + void setUp() { + company1 = Company.builder() + .id(1L).name("阿里巴巴").logoUrl("https://logo1.png") + .industry("IT").verified(true).build(); + company2 = Company.builder() + .id(2L).name("腾讯科技").logoUrl("https://logo2.png") + .industry("IT").verified(false).build(); + + pubJob1 = Job.builder() + .id(1L).companyId(1L).title("Java开发工程师") + .description("负责后端开发").requirements("3年Java经验") + .city("北京").employmentType("FULL_TIME") + .salaryMin(15000).salaryMax(25000) + .status("PUBLISHED") + .publishedAt(LocalDateTime.of(2026, 8, 1, 10, 0)) + .createdAt(LocalDateTime.of(2026, 8, 1, 9, 0)) + .build(); + + pubJob2 = Job.builder() + .id(2L).companyId(2L).title("前端开发工程师") + .description("负责前端开发").requirements("3年React经验") + .city("上海").employmentType("FULL_TIME") + .salaryMin(20000).salaryMax(35000) + .status("PUBLISHED") + .publishedAt(LocalDateTime.of(2026, 8, 2, 10, 0)) + .createdAt(LocalDateTime.of(2026, 8, 2, 9, 0)) + .build(); + + pubJobNullSalary = Job.builder() + .id(5L).companyId(1L).title("无薪资岗位") + .description("无薪资").requirements("无") + .city("深圳").employmentType("INTERN") + .status("PUBLISHED") + .createdAt(LocalDateTime.of(2026, 8, 3, 9, 0)) + .build(); + + draftJob = Job.builder() + .id(3L).companyId(1L).title("Draft岗位") + .description("草稿").city("北京").employmentType("FULL_TIME") + .status("DRAFT").build(); + + closedJob = Job.builder() + .id(4L).companyId(1L).title("已关闭岗位") + .description("已关闭").city("北京").employmentType("FULL_TIME") + .salaryMin(10000).salaryMax(20000) + .status("CLOSED") + .createdAt(LocalDateTime.of(2026, 7, 1, 9, 0)) + .build(); + } + + // ── 1. 关键词搜索 ─────────────────────────────────────────── + + @Test + void search_keyword_in_title_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + "Java", null, null, null, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals("Java开发工程师", result.getContent().get(0).getTitle()); + } + + @Test + void search_keyword_in_description_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + "后端", null, null, null, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + } + + @Test + void search_keyword_in_requirements_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2)); + + Page result = jobService.searchJobs( + "React", null, null, null, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + } + + // ── 2. 城市筛选 ───────────────────────────────────────────── + + @Test + void search_city_filter_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2)); + + Page result = jobService.searchJobs( + null, "上海", null, null, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals("上海", result.getContent().get(0).getCity()); + } + + // ── 3. 用工类型筛选 ───────────────────────────────────────── + + @Test + void search_employmentType_filter_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1, pubJob2))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result = jobService.searchJobs( + null, null, "FULL_TIME", null, null, null, null, 0, 20); + + assertEquals(2, result.getTotalElements()); + } + + // ── 4. 薪资范围筛选 ───────────────────────────────────────── + + @Test + void search_salary_range_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company2)); + + Page result = jobService.searchJobs( + null, null, null, 20000, 30000, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals("前端开发工程师", result.getContent().get(0).getTitle()); + } + + @Test + void search_salary_null_shouldNotMatch() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + Page result = jobService.searchJobs( + null, null, null, 10000, 20000, null, null, 0, 20); + + assertEquals(0, result.getTotalElements()); + } + + // ── 5. 多条件组合 ─────────────────────────────────────────── + + @Test + void search_combined_filters_shouldWork() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + "Java", "北京", "FULL_TIME", 10000, 30000, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + } + + // ── 6. 分页 ───────────────────────────────────────────────── + + @Test + void search_pagination_shouldReturnCorrectStructure() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 5), 10)); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 5); + + assertEquals(10, result.getTotalElements()); + assertEquals(2, result.getTotalPages()); + assertEquals(0, result.getNumber()); + assertEquals(5, result.getSize()); + } + + // ── 7. 排序 latest ────────────────────────────────────────── + + @Test + void search_sort_latest_shouldUseCreatedAtDesc() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, "latest", 0, 20); + + assertEquals(2, result.getTotalElements()); + assertEquals("前端开发工程师", result.getContent().get(0).getTitle()); + } + + // ── 8. 排序 salary(通过 spy 模拟内部方法) ───────────────── + + @Test + void search_sort_salary_shouldUseSalaryMaxDesc() { + doReturn(new PageImpl<>(List.of(pubJob2, pubJob1))) + .when(jobService).findWithSalarySort(any(), anyInt(), anyInt()); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, "salary", 0, 20); + + assertEquals(2, result.getTotalElements()); + assertEquals("前端开发工程师", result.getContent().get(0).getTitle()); + } + + // ── 9. 排序 salary NULL 最后 ──────────────────────────────── + + @Test + void search_sort_salary_nullLast_shouldPutNullLast() { + // salaryMax=null 的岗位应在最后 + doReturn(new PageImpl<>(List.of(pubJob2, pubJob1, pubJobNullSalary))) + .when(jobService).findWithSalarySort(any(), anyInt(), anyInt()); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, "salary", 0, 20); + + assertEquals(3, result.getTotalElements()); + // 前两个有薪资,第三个无薪资 + assertNotNull(result.getContent().get(0).getSalaryMax()); + assertNotNull(result.getContent().get(1).getSalaryMax()); + assertNull(result.getContent().get(2).getSalaryMax()); + } + + @Test + void search_sort_salary_nullSalaryMax_shouldBeLast() { + // 只有一个 null 薪资岗位的场景 + doReturn(new PageImpl<>(List.of(pubJobNullSalary))) + .when(jobService).findWithSalarySort(any(), anyInt(), anyInt()); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, "salary", 0, 20); + + assertEquals(1, result.getTotalElements()); + assertNull(result.getContent().get(0).getSalaryMax()); + } + + // ── 10. 排序 relevance 兜底 ────────────────────────────────── + + @Test + void search_sort_relevance_shouldFallbackToLatest() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, "relevance", 0, 20); + + assertEquals(2, result.getTotalElements()); + } + + // ── 11. 空结果 ────────────────────────────────────────────── + + @Test + void search_empty_result_shouldReturnEmptyPage() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + Page result = jobService.searchJobs( + "不存在的关键词", null, null, null, null, null, null, 0, 20); + + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + // ── 12. 仅 PUBLISHED 可见 ─────────────────────────────────── + + @Test + void search_DRAFT_shouldNotAppear() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals("PUBLISHED", result.getContent().get(0).getStatus()); + } + + // ── 13. CLOSED 搜索隐藏 ───────────────────────────────────── + + @Test + void search_CLOSED_shouldNotAppear() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 20); + + assertFalse(result.getContent().stream().anyMatch(j -> "CLOSED".equals(j.getStatus()))); + } + + // ── 14. 边界 size ─────────────────────────────────────────── + + @Test + void search_size_1_shouldSucceed() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 1), 10)); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 1); + + assertEquals(1, result.getSize()); + } + + @Test + void search_size_50_shouldSucceed() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1), PageRequest.of(0, 50), 1)); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 50); + + assertEquals(50, result.getSize()); + } + + // ── 15. 边界异常 ──────────────────────────────────────────── + + @Test + void search_size_0_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.searchJobs(null, null, null, null, null, null, null, 0, 0)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void search_size_51_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.searchJobs(null, null, null, null, null, null, null, 0, 51)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void search_page_negative_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.searchJobs(null, null, null, null, null, null, null, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void search_salaryMin_greater_than_salaryMax_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.searchJobs(null, null, null, 20000, 10000, null, null, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void search_invalid_sort_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.searchJobs(null, null, null, null, null, null, "invalid", 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + // ── 16. SQL 注入防护 ──────────────────────────────────────── + + @Test + void search_sql_injection_shouldNotAffect() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + assertDoesNotThrow(() -> jobService.searchJobs( + "'; DROP TABLE jobs; --", null, null, null, null, null, null, 0, 20)); + } + + @Test + void search_sql_injection_union_shouldNotAffect() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.emptyList())); + + assertDoesNotThrow(() -> jobService.searchJobs( + "' UNION SELECT * FROM users --", null, null, null, null, null, null, 0, 20)); + } + + // ── 17. 公开字段脱敏 ──────────────────────────────────────── + + @Test + void search_public_dto_shouldNotLeakInternalFields() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 20); + + JobSearchItem item = result.getContent().get(0); + assertNotNull(item.getCompany()); + assertEquals(1L, item.getCompany().getId()); + assertEquals("阿里巴巴", item.getCompany().getName()); + assertEquals("https://logo1.png", item.getCompany().getLogoUrl()); + assertTrue(item.getCompany().getVerified()); + } + + @Test + void search_company_without_record_shouldNotNPE() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); + + Page result = jobService.searchJobs( + null, null, null, null, null, null, null, 0, 20); + + JobSearchItem item = result.getContent().get(0); + assertNull(item.getCompany()); + } + + // ── 18. 筛选选项(聚合查询) ──────────────────────────────── + + @Test + void filters_shouldUseAggregationQueries() { + when(jobRepository.findDistinctCitiesPublished()).thenReturn(List.of("北京", "上海")); + when(jobRepository.findDistinctEmploymentTypesPublished()).thenReturn(List.of("FULL_TIME")); + when(jobRepository.findMinSalaryPublished()).thenReturn(15000); + when(jobRepository.findMaxSalaryPublished()).thenReturn(35000); + + Map options = jobService.getFilterOptions(); + + @SuppressWarnings("unchecked") + List cities = (List) options.get("cities"); + @SuppressWarnings("unchecked") + List types = (List) options.get("employmentTypes"); + @SuppressWarnings("unchecked") + Map salaryRange = (Map) options.get("salaryRange"); + + assertTrue(cities.contains("北京")); + assertTrue(cities.contains("上海")); + assertEquals(2, cities.size()); + assertTrue(types.contains("FULL_TIME")); + assertEquals(15000, salaryRange.get("min")); + assertEquals(35000, salaryRange.get("max")); + + // 验证没有调用 findAll(Specification) + verify(jobRepository, never()).findAll(any(Specification.class)); + } + + @Test + void filters_empty_shouldReturnEmpty() { + when(jobRepository.findDistinctCitiesPublished()).thenReturn(Collections.emptyList()); + when(jobRepository.findDistinctEmploymentTypesPublished()).thenReturn(Collections.emptyList()); + when(jobRepository.findMinSalaryPublished()).thenReturn(null); + when(jobRepository.findMaxSalaryPublished()).thenReturn(null); + + Map options = jobService.getFilterOptions(); + + @SuppressWarnings("unchecked") + List cities = (List) options.get("cities"); + @SuppressWarnings("unchecked") + List types = (List) options.get("employmentTypes"); + @SuppressWarnings("unchecked") + Map salaryRange = (Map) options.get("salaryRange"); + + assertTrue(cities.isEmpty()); + assertTrue(types.isEmpty()); + assertNull(salaryRange.get("min")); + assertNull(salaryRange.get("max")); + + verify(jobRepository, never()).findAll(any(Specification.class)); + } + + // ── 19. 旧接口回归 ───────────────────────────────────────── + + @Test + void listPublishedJobs_regression_shouldStillWork() { + when(jobRepository.findPublishedJobs( + isNull(), isNull(), isNull(), isNull(), isNull(), any())) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + + Page> result = jobService.listPublishedJobs( + null, null, null, null, null, 0, 20); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @Test + void listPublishedJobs_salaryMinGreaterThanMax_shouldStillThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.listPublishedJobs(null, null, null, 15000, 8000, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getPublicJob_regression_shouldStillWork() { + when(jobRepository.findById(1L)).thenReturn(Optional.of(pubJob1)); + + Map result = jobService.getPublicJob(1L); + + assertNotNull(result); + assertEquals(1L, result.get("id")); + } + + @Test + void getPublicJob_CLOSED_shouldStillBeVisible() { + when(jobRepository.findById(4L)).thenReturn(Optional.of(closedJob)); + + Map result = jobService.getPublicJob(4L); + + assertNotNull(result); + assertEquals("CLOSED", result.get("status")); + } + + @Test + void getPublicJob_DRAFT_shouldStillThrow() { + when(jobRepository.findById(3L)).thenReturn(Optional.of(draftJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.getPublicJob(3L)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 20. companyId 筛选 ────────────────────────────────────── + + @Test + void search_companyId_filter_shouldReturn() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1)); + + Page result = jobService.searchJobs( + null, null, null, null, null, 1L, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals(1L, result.getContent().get(0).getCompany().getId()); + } + + // ── 21. 分页稳定性 ────────────────────────────────────────── + + @Test + void search_pagination_stability_shouldReturnSameOrder() { + when(jobRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(pubJob2, pubJob1))); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company1, company2)); + + Page result1 = jobService.searchJobs( + null, null, null, null, null, null, "latest", 0, 20); + Page result2 = jobService.searchJobs( + null, null, null, null, null, null, "latest", 0, 20); + + assertEquals(result1.getContent().get(0).getId(), result2.getContent().get(0).getId()); + assertEquals(result1.getContent().get(1).getId(), result2.getContent().get(1).getId()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java new file mode 100644 index 0000000..40bf93f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java @@ -0,0 +1,244 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.job.dto.JobCreateRequest; +import com.mydreamjob.job.dto.JobStatusRequest; +import com.mydreamjob.job.dto.JobUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JobServiceTest { + + @Mock + private JobRepository jobRepository; + + @InjectMocks + private JobService jobService; + + private Job draftJob; + private static final Long COMPANY_ID = 1L; + private static final Long OTHER_COMPANY_ID = 2L; + + @BeforeEach + void setUp() { + draftJob = Job.builder() + .id(10L) + .companyId(COMPANY_ID) + .title("Java 后端") + .description("负责微服务研发") + .city("北京") + .employmentType("FULL_TIME") + .status(JobStatus.DRAFT.name()) + .build(); + } + + // ── 1. 创建岗位默认状态为 DRAFT ───────────────────────── + + @Test + void createJob_shouldSetStatusDraft() { + JobCreateRequest req = new JobCreateRequest(); + req.setTitle("Java 后端"); + req.setDescription("负责微服务研发"); + req.setCity("北京"); + req.setEmploymentType("FULL_TIME"); + + when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Job created = jobService.createJob(COMPANY_ID, req); + + assertNotNull(created); + assertEquals(JobStatus.DRAFT.name(), created.getStatus()); + assertEquals(COMPANY_ID, created.getCompanyId()); + assertEquals("Java 后端", created.getTitle()); + verify(jobRepository, times(1)).save(any()); + } + + // ── 2. 岗位所属企业可以更新自己的岗位 ─────────────────── + + @Test + void updateJob_byOwner_shouldSucceed() { + JobUpdateRequest req = new JobUpdateRequest(); + req.setTitle("高级 Java 后端"); + req.setCity("上海"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); + when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Job updated = jobService.updateJob(COMPANY_ID, 10L, req); + + assertEquals("高级 Java 后端", updated.getTitle()); + assertEquals("上海", updated.getCity()); + // 未传字段应保留原值 + assertEquals("负责微服务研发", updated.getDescription()); + verify(jobRepository, times(1)).save(draftJob); + } + + // ── 3. 非岗位所属企业更新时必须被拒绝 ────────────────── + + @Test + void updateJob_byNonOwner_shouldThrow() { + JobUpdateRequest req = new JobUpdateRequest(); + req.setTitle("恶意修改"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.updateJob(OTHER_COMPANY_ID, 10L, req)); + assertEquals(ErrorCode.JOB_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + verify(jobRepository, never()).save(any()); + } + + // ── 4. DRAFT → PUBLISHED → CLOSED 状态流转成功 ───────── + + @Test + void transitionStatus_draftToPublished_shouldSucceed() { + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("PUBLISHED"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); + when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Job result = jobService.transitionStatus(COMPANY_ID, 10L, req); + + assertEquals("PUBLISHED", result.getStatus()); + assertNotNull(result.getPublishedAt()); + } + + @Test + void transitionStatus_publishedToClosed_shouldSucceed() { + Job publishedJob = Job.builder() + .id(10L).companyId(COMPANY_ID).title("Java 后端") + .status(JobStatus.PUBLISHED.name()).build(); + + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("CLOSED"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(publishedJob)); + when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Job result = jobService.transitionStatus(COMPANY_ID, 10L, req); + + assertEquals("CLOSED", result.getStatus()); + } + + // ── 5. 非法状态回退必须被拒绝 ────────────────────────── + + @Test + void transitionStatus_closedToPublished_shouldThrow() { + Job closedJob = Job.builder() + .id(10L).companyId(COMPANY_ID).title("Java 后端") + .status(JobStatus.CLOSED.name()).build(); + + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("PUBLISHED"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(closedJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.transitionStatus(COMPANY_ID, 10L, req)); + assertEquals(ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + verify(jobRepository, never()).save(any()); + } + + @Test + void transitionStatus_publishedToDraft_shouldThrow() { + Job publishedJob = Job.builder() + .id(10L).companyId(COMPANY_ID).title("Java 后端") + .status(JobStatus.PUBLISHED.name()).build(); + + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("DRAFT"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(publishedJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.transitionStatus(COMPANY_ID, 10L, req)); + assertEquals(ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + verify(jobRepository, never()).save(any()); + } + + @Test + void transitionStatus_draftToClosed_shouldThrow() { + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("CLOSED"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.transitionStatus(COMPANY_ID, 10L, req)); + assertEquals(ErrorCode.INVALID_JOB_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + verify(jobRepository, never()).save(any()); + } + + @Test + void transitionStatus_byNonOwner_shouldThrow() { + JobStatusRequest req = new JobStatusRequest(); + req.setStatus("PUBLISHED"); + + when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.transitionStatus(OTHER_COMPANY_ID, 10L, req)); + assertEquals(ErrorCode.JOB_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + verify(jobRepository, never()).save(any()); + } + + // ── 辅助 ──────────────────────────────────────────────── + + @Test + void getJob_notFound_shouldThrow() { + when(jobRepository.findById(999L)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.getJob(999L)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 薪资筛选 ─────────────────────────────────────────── + + @Test + void listPublishedJobs_salaryMinGreaterThanMax_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> jobService.listPublishedJobs(null, null, null, 15000, 8000, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void listPublishedJobs_withSalaryFilter_shouldSucceed() { + when(jobRepository.findPublishedJobs( + isNull(), isNull(), isNull(), eq(8000), eq(15000), any())) + .thenReturn(org.springframework.data.domain.Page.empty()); + + var result = jobService.listPublishedJobs(null, null, null, 8000, 15000, 0, 20); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void toView_shouldIncludeAllFields() { + draftJob.setPublishedAt(LocalDateTime.of(2026, 1, 1, 0, 0)); + draftJob.setCreatedAt(LocalDateTime.of(2026, 1, 1, 0, 0)); + draftJob.setUpdatedAt(LocalDateTime.of(2026, 6, 1, 0, 0)); + + var view = jobService.toView(draftJob); + + assertEquals(10L, view.get("id")); + assertEquals(COMPANY_ID, view.get("companyId")); + assertEquals("Java 后端", view.get("title")); + assertEquals("DRAFT", view.get("status")); + assertNotNull(view.get("createdAt")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java new file mode 100644 index 0000000..0226c7d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java @@ -0,0 +1,178 @@ +package com.mydreamjob.match; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.preference.StudentPreference; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchServiceTest { + + @Mock private JobService jobService; + @Mock private StudentPreferenceRepository preferenceRepository; + @Mock private StudentProfileRepository profileRepository; + @Mock private AuditService auditService; + @InjectMocks private MatchService matchService; + + private static final Long STUDENT_ID = 100L; + private static final Long JOB_ID = 10L; + + private Job publishedJob; + + @BeforeEach + void setUp() { + publishedJob = Job.builder().id(JOB_ID).companyId(1L).title("Java 后端") + .city("杭州").employmentType("FULL_TIME") + .salaryMax(20000).status("PUBLISHED").build(); + } + + private void mockPref() { + StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) + .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") + .expectedSalaryMin(10000).preferredIndustries("[\"互联网\"]") + .preferredJobTypes("[\"Java\"]").remoteAllowed(false).build(); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); + } + + private void mockProfile() { + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentProfile.builder().userId(STUDENT_ID).build())); + } + + // ── 完全匹配 ───────────────────────────────────────────── + + @Test void diagnose_fullMatch_shouldBeEligible() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + mockPref(); mockProfile(); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + assertEquals(true, r.get("eligible")); + assertEquals("RULES_V0_1", r.get("rulesVersion")); + assertTrue((Integer) r.get("matchScore") >= 70); + assertTrue(((java.util.List) r.get("matchedConditions")).size() >= 3); + } + + // ── 部分匹配 ───────────────────────────────────────────── + + @Test void diagnose_partialMatch_shouldBeEligibleWithGaps() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) + .preferredCities("[\"上海\"]").employmentTypes("[\"PART_TIME\"]") + .expectedSalaryMin(15000).build(); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentProfile.builder().userId(STUDENT_ID).build())); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + assertFalse(((java.util.List) r.get("gaps")).isEmpty()); + } + + // ── 硬性条件不满足 ─────────────────────────────────────── + + @Test void diagnose_hardGap_salaryTooHigh_shouldBeIneligible() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) + .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") + .expectedSalaryMin(30000).build(); // 期望下限 > 岗位上限 20000 + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentProfile.builder().userId(STUDENT_ID).build())); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + assertEquals(false, r.get("eligible")); + assertTrue(((String) r.get("explanation")).contains("硬性条件")); + } + + // ── 空档案 ─────────────────────────────────────────────── + + @Test void diagnose_emptyProfile_shouldBeIneligibleWithZero() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + assertEquals(0, r.get("matchScore")); + assertEquals(false, r.get("eligible")); + assertTrue(((java.util.List) r.get("gaps")).contains("尚未完善档案与偏好")); + } + + // ── 阈值边界 ───────────────────────────────────────────── + + @Test void diagnose_salaryBoundary_equal_shouldBeEligible() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); // salaryMax=20000 + StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) + .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") + .expectedSalaryMin(20000).build(); // 等于上限,边界 + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(p)); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentProfile.builder().userId(STUDENT_ID).build())); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + assertEquals(true, r.get("eligible")); + } + + // ── 岗位不存在 ─────────────────────────────────────────── + + @Test void diagnose_jobNotFound_shouldReturn404() { + when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + + assertThrows(BusinessException.class, () -> matchService.diagnose(STUDENT_ID, 999L)); + } + + // ── 不泄露敏感数据 ─────────────────────────────────────── + + @Test void diagnose_shouldNotIncludeSensitiveData() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + mockPref(); mockProfile(); + + Map r = matchService.diagnose(STUDENT_ID, JOB_ID); + + String json = r.toString(); + assertFalse(json.contains("phone")); + assertFalse(json.contains("resume")); + } + + // ── 审计记录 ───────────────────────────────────────────── + + @Test void diagnose_shouldLogAudit() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + mockPref(); mockProfile(); + + matchService.diagnose(STUDENT_ID, JOB_ID); + verify(auditService, times(1)).log(eq(STUDENT_ID), eq("MATCH_DIAGNOSE"), eq("JOB"), eq(JOB_ID), + anyString(), isNull()); + } + + @Test void diagnose_withContext_shouldPersistContext() { + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + mockPref(); mockProfile(); + com.mydreamjob.audit.AuditContext ctx = com.mydreamjob.audit.AuditContext.builder() + .requestId("req-ctx-1").ipAddress("10.1.1.1").userAgent("TestAgent").build(); + + matchService.diagnose(STUDENT_ID, JOB_ID, ctx); + verify(auditService, times(1)).log(eq(STUDENT_ID), eq("MATCH_DIAGNOSE"), eq("JOB"), eq(JOB_ID), + anyString(), eq(ctx)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/TokenGen.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/TokenGen.java new file mode 100644 index 0000000..457ec52 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/match/TokenGen.java @@ -0,0 +1,27 @@ +package com.mydreamjob.match; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; + +public class TokenGen { + public static void main(String[] args) { + SecretKey key = Keys.hmacShaKeyFor( + "test-jwt-secret-key-at-least-256-bits-required-for-safety" + .getBytes(StandardCharsets.UTF_8)); + long now = System.currentTimeMillis(); + String token = Jwts.builder() + .subject(String.valueOf(args[0])) + .claim("userId", Long.parseLong(args[0])) + .claim("role", args[1]) + .issuedAt(new Date(now)) + .expiration(new Date(now + 86400000L)) + .signWith(key) + .compact(); + System.out.println(token); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java new file mode 100644 index 0000000..bab0d50 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java @@ -0,0 +1,99 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.messaging.dto.CreateConversationRequest; +import com.mydreamjob.messaging.dto.SendMessageRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class MessagingControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private MessagingService messagingService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String token() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + } + + @Test void listConversations_shouldReturn200() throws Exception { + when(messagingService.listConversations(100L)).thenReturn(List.of()); + mockMvc.perform(get("/conversations").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void getConversation_shouldReturn200() throws Exception { + when(messagingService.getConversation(1L, 100L)).thenReturn(Map.of("id", 1, "type", "DIRECT")); + mockMvc.perform(get("/conversations/1").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(1)); + } + + @Test void createConversation_shouldReturn200() throws Exception { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(200L); + when(messagingService.createOrGetDirectConversation(eq(100L), any())) + .thenReturn(Map.of("id", 2, "type", "DIRECT")); + mockMvc.perform(post("/conversations").header("Authorization", token()) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(req))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(2)); + } + + @Test void sendMessage_shouldReturn200() throws Exception { + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(1L); req.setContent("Hello"); + when(messagingService.sendMessage(eq(100L), any())).thenReturn(Map.of("id", 1, "content", "Hello")); + mockMvc.perform(post("/conversations/messages").header("Authorization", token()) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(req))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.content").value("Hello")); + } + + @Test void listMessages_shouldReturn200() throws Exception { + when(messagingService.listMessages(eq(1L), eq(100L), eq(0), eq(20))) + .thenReturn(org.springframework.data.domain.Page.empty()); + mockMvc.perform(get("/conversations/1/messages").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void markRead_shouldReturn200() throws Exception { + doNothing().when(messagingService).markRead(1L, 100L); + mockMvc.perform(post("/conversations/1/read").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void createConversation_noToken_shouldReturn401() throws Exception { + String body = mapper.writeValueAsString(Map.of("participantUserId", 200)); + mockMvc.perform(post("/conversations").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java new file mode 100644 index 0000000..49682f3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java @@ -0,0 +1,218 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; +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.messaging.dto.CreateConversationRequest; +import com.mydreamjob.messaging.dto.SendMessageRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MessagingServiceTest { + + @Mock private ConversationRepository conversationRepository; + @Mock private ConversationMemberRepository memberRepository; + @Mock private MessageRepository messageRepository; + @Mock private MessageReadRepository readRepository; + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private ApplicationMatchRecordRepository applicationMatchRecordRepository; + @InjectMocks private MessagingService messagingService; + + private static final Long USER_A = 100L; + private static final Long USER_B = 200L; + private static final Long USER_C = 300L; + private static final Long CONV_ID = 1L; + + @BeforeEach + void setUp() { + } + + // ── 创建会话 ───────────────────────────────────────────── + + @Test void createConversation_withNewUser_shouldCreate() { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_B); + when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); + when(userRepository.existsById(USER_B)).thenReturn(true); + when(conversationRepository.save(any())).thenAnswer(inv -> { + Conversation c = inv.getArgument(0); + if (c.getId() == null) c.setId(99L); + return c; + }); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var r = messagingService.createOrGetDirectConversation(USER_A, req); + + assertNotNull(r.get("id")); + assertEquals("DIRECT", r.get("type")); + verify(memberRepository, times(2)).save(any()); + } + + @Test void createConversation_withSelf_shouldReturn400() { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_A); + assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(USER_A, req)); + } + + // ── 防绕过守卫:企业-求职者新建会话需 MATCHED ────────────── + + private User user(Long id, User.Role role) { + User u = User.builder().id(id).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + return u; + } + + private void stubNewConversationCreation() { + when(memberRepository.findByUserId(any())).thenReturn(List.of()); + when(conversationRepository.save(any())).thenAnswer(inv -> { + Conversation c = inv.getArgument(0); + if (c.getId() == null) c.setId(99L); + return c; + }); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test void createConversation_companyToStudent_withoutMatched_shouldReturn403() { + when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); + when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); + when(companyRepository.findByOwnerUserId(USER_A)) + .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).build())); + when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(false); + when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); + when(userRepository.existsById(USER_B)).thenReturn(true); + + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_B); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(USER_A, req)); + assertEquals(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode(), ex.getErrorCodeInt()); + verify(conversationRepository, never()).save(any()); + } + + @Test void createConversation_companyToStudent_withMatched_shouldCreate() { + when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); + when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); + when(companyRepository.findByOwnerUserId(USER_A)) + .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).build())); + when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(true); + stubNewConversationCreation(); + when(userRepository.existsById(USER_B)).thenReturn(true); + + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_B); + var r = messagingService.createOrGetDirectConversation(USER_A, req); + assertNotNull(r.get("id")); + verify(conversationRepository, times(1)).save(any()); + } + + @Test void createConversation_studentToStudent_shouldKeepOriginalSemantics() { + when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.STUDENT))); + when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); + stubNewConversationCreation(); + when(userRepository.existsById(USER_B)).thenReturn(true); + + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_B); + var r = messagingService.createOrGetDirectConversation(USER_A, req); + assertNotNull(r.get("id")); + // 学生-学生不受匹配守卫限制 + verify(conversationRepository, times(1)).save(any()); + } + + // ── 发送消息 ───────────────────────────────────────────── + + @Test void sendMessage_asMember_shouldSucceed() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + when(messageRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of( + Conversation.builder().id(CONV_ID).createdBy(USER_A).type("DIRECT").build())); + when(conversationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(CONV_ID); + req.setContent("Hello"); + + var r = messagingService.sendMessage(USER_A, req); + assertEquals("Hello", r.get("content")); + } + + @Test void sendMessage_asNonMember_shouldReturn403() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(CONV_ID); req.setContent("Hack"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.sendMessage(USER_C, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void sendMessage_tooLong_shouldReturn400() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(CONV_ID); + req.setContent("x".repeat(2001)); + + assertThrows(BusinessException.class, + () -> messagingService.sendMessage(USER_A, req)); + } + + // ── 消息列表 ───────────────────────────────────────────── + + @Test void listMessages_asMember_shouldSucceed() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_A).content("Hi").build(); + when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) + .thenReturn(new PageImpl<>(List.of(msg))); + when(readRepository.existsByMessageIdAndReaderUserId(1L, USER_A)).thenReturn(false); + + var r = messagingService.listMessages(CONV_ID, USER_A, 0, 20); + assertEquals(1, r.getTotalElements()); + assertEquals(false, r.getContent().get(0).get("isRead")); + } + + @Test void listMessages_asNonMember_shouldReturn403() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); + assertThrows(BusinessException.class, + () -> messagingService.listMessages(CONV_ID, USER_C, 0, 20)); + } + + // ── 标记已读 ───────────────────────────────────────────── + + @Test void markRead_shouldCreateReadRecords() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_B).content("Hi").build(); + when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) + .thenReturn(new PageImpl<>(List.of(msg))); + when(readRepository.findByMessageIdInAndReaderUserId(List.of(1L), USER_A)).thenReturn(List.of()); + when(readRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertDoesNotThrow(() -> messagingService.markRead(CONV_ID, USER_A)); + verify(readRepository, times(1)).save(any()); + } + + @Test void markRead_asNonMember_shouldReturn403() { + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); + assertThrows(BusinessException.class, + () -> messagingService.markRead(CONV_ID, USER_C)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java new file mode 100644 index 0000000..01b916f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java @@ -0,0 +1,75 @@ +package com.mydreamjob.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.support.RetryTemplate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationConsumerTest { + + @Mock private NotificationRepository notificationRepository; + @Mock private PushProvider pushProvider; + @Mock private RetryTemplate retryTemplate; + @InjectMocks private NotificationConsumer consumer; + + private NotificationEvent event; + + @BeforeEach + void setUp() { + event = NotificationEvent.builder() + .userId(100L).type("APPLICATION_STATUS").title("状态更新").content("内容") + .businessType("APPLICATION").businessId(1L).idempotencyKey("key-1").build(); + // RetryTemplate mock 直接执行回调(单次,无重试) + lenient().when(retryTemplate.execute(any(RetryCallback.class))).thenAnswer(inv -> { + RetryCallback cb = inv.getArgument(0); + return cb.doWithRetry(mock(RetryContext.class)); + }); + } + + @Test void handle_newEvent_shouldSave() { + when(notificationRepository.existsByIdempotencyKey("key-1")).thenReturn(false); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(pushProvider.send(any(), any(), any())).thenReturn(true); + + consumer.handle(event); + verify(notificationRepository, times(1)).save(any()); + } + + @Test void handle_duplicateEvent_shouldSkip() { + when(notificationRepository.existsByIdempotencyKey("key-1")).thenReturn(true); + + consumer.handle(event); + verify(notificationRepository, never()).save(any()); + } + + @Test void handle_saveThrowsDataIntegrity_shouldNotCrash() { + when(notificationRepository.existsByIdempotencyKey("key-1")).thenReturn(false); + when(notificationRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); + + assertDoesNotThrow(() -> consumer.handle(event)); + } + + @Test void handle_nullEvent_shouldReturn() { + assertDoesNotThrow(() -> consumer.handle(null)); + } + + @Test void handle_retryWrapsProcessing() { + when(notificationRepository.existsByIdempotencyKey("key-1")).thenReturn(false); + when(notificationRepository.save(any())).thenThrow(new RuntimeException("DB down")); + + assertThrows(RuntimeException.class, () -> consumer.handle(event)); + // 确认处理逻辑被 RetryTemplate 包裹执行 + verify(retryTemplate, times(1)).execute(any(RetryCallback.class)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java new file mode 100644 index 0000000..9dfb5ad --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java @@ -0,0 +1,83 @@ +package com.mydreamjob.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class NotificationControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private NotificationService notificationService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String token() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + } + + @Test void list_shouldReturn200() throws Exception { + when(notificationService.list(100L, 0, 20)).thenReturn(org.springframework.data.domain.Page.empty()); + mockMvc.perform(get("/notifications").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void markRead_shouldReturn200() throws Exception { + doNothing().when(notificationService).markRead(100L, 1L); + mockMvc.perform(post("/notifications/1/read").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void markAllRead_shouldReturn200() throws Exception { + when(notificationService.markAllRead(100L)).thenReturn(2); + mockMvc.perform(post("/notifications/read-all").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.marked").value(2)); + } + + @Test void unreadCount_shouldReturn200() throws Exception { + when(notificationService.unreadCount(100L)).thenReturn(3L); + mockMvc.perform(get("/notifications/unread-count").header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.unreadCount").value(3)); + } + + @Test void list_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/notifications")).andExpect(status().isUnauthorized()); + } + + @Test void markRead_otherUser_shouldReturn403() throws Exception { + doThrow(new com.mydreamjob.common.exception.BusinessException( + com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)) + .when(notificationService).markRead(100L, 1L); + mockMvc.perform(post("/notifications/1/read").header("Authorization", token())) + .andExpect(status().isForbidden()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java new file mode 100644 index 0000000..29ae3a1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java @@ -0,0 +1,109 @@ +package com.mydreamjob.notification; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock private NotificationRepository notificationRepository; + @Mock private PushProvider pushProvider; + @InjectMocks private NotificationService notificationService; + + private static final Long USER_A = 100L; + private static final Long USER_B = 200L; + private static final Long NOTIF_ID = 1L; + private Notification notif; + + @BeforeEach + void setUp() { + notif = Notification.builder().id(NOTIF_ID).userId(USER_A) + .type("APPLICATION").title("新投递").content("有新的投递").read(false).build(); + } + + @Test void create_shouldSaveAndPush() { + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(pushProvider.send(eq(USER_A), any(), any())).thenReturn(true); + + Notification n = notificationService.create(USER_A, "APPLICATION", "新投递", "内容"); + + assertEquals("新投递", n.getTitle()); + verify(pushProvider, times(1)).send(eq(USER_A), any(), any()); + } + + @Test void list_shouldReturnPaged() { + when(notificationRepository.findByUserIdOrderByCreatedAtDesc(eq(USER_A), any())) + .thenReturn(new PageImpl<>(List.of(notif))); + + var r = notificationService.list(USER_A, 0, 20); + assertEquals(1, r.getTotalElements()); + } + + @Test void list_invalidSize_shouldReturn400() { + assertThrows(BusinessException.class, () -> notificationService.list(USER_A, 0, 0)); + assertThrows(BusinessException.class, () -> notificationService.list(USER_A, -1, 20)); + assertThrows(BusinessException.class, () -> notificationService.list(USER_A, 0, 51)); + } + + @Test void markRead_shouldSucceed() { + when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.markRead(USER_A, NOTIF_ID); + assertTrue(notif.getRead()); + } + + @Test void markRead_alreadyRead_shouldBeIdempotent() { + notif.setRead(true); + when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif)); + + notificationService.markRead(USER_A, NOTIF_ID); + verify(notificationRepository, never()).save(any()); + } + + @Test void markRead_otherUser_shouldReturn403() { + when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif)); + + assertThrows(BusinessException.class, () -> notificationService.markRead(USER_B, NOTIF_ID)); + } + + @Test void markRead_notFound_shouldReturn404() { + when(notificationRepository.findById(999L)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> notificationService.markRead(USER_A, 999L)); + assertEquals(ErrorCode.NOTIFICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void unreadCount_shouldReturnCount() { + when(notificationRepository.countUnreadByUserId(USER_A)).thenReturn(5L); + + assertEquals(5L, notificationService.unreadCount(USER_A)); + } + + @Test void markAllRead_shouldMarkUnread() { + notif.setRead(false); + when(notificationRepository.findByUserIdOrderByCreatedAtDesc(eq(USER_A), any())) + .thenReturn(new PageImpl<>(List.of(notif))); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + int marked = notificationService.markAllRead(USER_A); + assertEquals(1, marked); + assertTrue(notif.getRead()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java new file mode 100644 index 0000000..a39c1cf --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java @@ -0,0 +1,306 @@ +package com.mydreamjob.offer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Offer 接口集成测试:JWT 鉴权、统一响应结构、权限、业务错误、OpenAPI 一致性。 + * 激活 profile: integration-test(H2 内嵌数据库 + Flyway V13 + Redis/Rabbit 关闭) + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class OfferControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private OfferService offerService; + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long COMPANY_USER_ID = 10L; + private static final Long STUDENT_ID = 100L; + private static final Long OFFER_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + } + + private Map offerView() { + return Map.of("id", OFFER_ID, "applicationId", 5L, "jobId", 10L, + "companyId", 1L, "candidateId", STUDENT_ID, "title", "后端工程师", + "status", "DRAFT", "salaryMin", 10000, "salaryMax", 20000); + } + + private void stubOfferView() { + when(offerService.toView(any())).thenReturn(offerView()); + } + + private String createBody() { + return """ + { + "applicationId": 5, + "title": "后端工程师", + "salaryMin": 10000, + "salaryMax": 20000, + "workCity": "杭州", + "description": "负责服务端开发", + "expireAt": "2030-12-31T23:59:59" + } + """; + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void create_shouldReturn200WithUnifiedResponse() throws Exception { + stubOfferView(); + when(offerService.createOffer(eq(COMPANY_USER_ID), any(), any())).thenReturn(Offer.builder().id(OFFER_ID).build()); + mockMvc.perform(post("/offers") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-abc") + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.message").value("ok")) + .andExpect(jsonPath("$.data.id").value(OFFER_ID)) + .andExpect(jsonPath("$.requestId").value("req-abc")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void create_invalidBody_shouldReturn400() throws Exception { + mockMvc.perform(post("/offers") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void create_noToken_shouldReturn401() throws Exception { + mockMvc.perform(post("/offers") + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void create_byStudent_shouldReturn403() throws Exception { + when(offerService.createOffer(eq(STUDENT_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(post("/offers") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content(createBody())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + // ── 列表 ────────────────────────────────────────────────── + + @Test + void list_shouldReturn200WithPagination() throws Exception { + stubOfferView(); + Page page = new PageImpl<>(List.of(Offer.builder().id(OFFER_ID).build()), + org.springframework.data.domain.PageRequest.of(0, 20), 1); + when(offerService.listOffers(eq(STUDENT_ID), isNull(), isNull(), eq(0), eq(20))).thenReturn(page); + mockMvc.perform(get("/offers").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.total").isNumber()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)); + } + + @Test + void list_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/offers")).andExpect(status().isUnauthorized()); + } + + // ── 详情 ────────────────────────────────────────────────── + + @Test + void get_shouldReturn200() throws Exception { + stubOfferView(); + when(offerService.getOffer(OFFER_ID, STUDENT_ID)).thenReturn(Offer.builder().id(OFFER_ID).build()); + mockMvc.perform(get("/offers/" + OFFER_ID).header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("DRAFT")); + } + + @Test + void get_otherUser_shouldReturn403() throws Exception { + when(offerService.getOffer(OFFER_ID, STUDENT_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/offers/" + OFFER_ID).header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void get_notFound_shouldReturn404() throws Exception { + when(offerService.getOffer(999L, STUDENT_ID)) + .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_FOUND)); + mockMvc.perform(get("/offers/999").header("Authorization", studentToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_FOUND.getCode())); + } + + // ── 修改 ────────────────────────────────────────────────── + + @Test + void update_sentOffer_shouldReturn409() throws Exception { + when(offerService.updateOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_EDITABLE)); + mockMvc.perform(put("/offers/" + OFFER_ID) + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"新标题\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_EDITABLE.getCode())); + } + + @Test + void update_draft_shouldReturn200() throws Exception { + stubOfferView(); + when(offerService.updateOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), any(), any())) + .thenReturn(Offer.builder().id(OFFER_ID).build()); + mockMvc.perform(put("/offers/" + OFFER_ID) + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"新标题\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("后端工程师")); + } + + // ── 状态变更 ────────────────────────────────────────────── + + @Test + void changeStatus_shouldReturn200() throws Exception { + stubOfferView(); + when(offerService.transitionStatus(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) + .thenReturn(Offer.builder().id(OFFER_ID).build()); + mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ACCEPTED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void changeStatus_invalidTransition_shouldReturn409() throws Exception { + when(offerService.transitionStatus(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION)); + mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"REJECTED\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode())); + } + + @Test + void changeStatus_invalidStatusValue_shouldReturn400() throws Exception { + mockMvc.perform(patch("/offers/" + OFFER_ID + "/status") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"INVALID\"}")) + .andExpect(status().isBadRequest()); + } + + // ── 时间线 ──────────────────────────────────────────────── + + @Test + void timeline_shouldReturn200() throws Exception { + when(offerService.getTimeline(OFFER_ID, STUDENT_ID)).thenReturn(List.of( + Map.of("fromStatus", "DRAFT", "toStatus", "SENT", "operatorRole", "COMPANY"))); + mockMvc.perform(get("/offers/" + OFFER_ID + "/timeline").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].toStatus").value("SENT")); + } + + // ── 提醒 ────────────────────────────────────────────────── + + @Test + void remind_shouldReturn200() throws Exception { + OfferReminder reminder = OfferReminder.builder().offerId(OFFER_ID) + .reminderType(OfferReminder.TYPE_MANUAL).status(OfferReminder.STATUS_SENT).build(); + when(offerService.remind(eq(COMPANY_USER_ID), eq(OFFER_ID), any())).thenReturn(reminder); + mockMvc.perform(post("/offers/" + OFFER_ID + "/remind").header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reminderType").value("MANUAL")) + .andExpect(jsonPath("$.code").value(200)); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldContainOfferEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + JsonNode paths = root.path("paths"); + assertThat(paths.has("/offers")).isTrue(); + assertThat(paths.has("/offers/{id}")).isTrue(); + assertThat(paths.has("/offers/{id}/status")).isTrue(); + assertThat(paths.has("/offers/{id}/timeline")).isTrue(); + assertThat(paths.has("/offers/{id}/remind")).isTrue(); + // POST /offers 必须声明 POST 方法 + assertTrue(paths.path("/offers").has("post")); + // GET /offers 必须声明 GET 方法 + assertTrue(paths.path("/offers").has("get")); + // 开发辅助:当 -Doffer.openapi.snapshot=<目录> 时导出 OpenAPI 快照到 <目录>/openapi-rc.json + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryServiceTest.java new file mode 100644 index 0000000..0c15942 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryServiceTest.java @@ -0,0 +1,109 @@ +package com.mydreamjob.offer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OfferExpiryServiceTest { + + @Mock private OfferRepository offerRepository; + @Mock private OfferExpiryWorker worker; + + @InjectMocks private OfferExpiryService offerExpiryService; + + private Offer offer(Long id, String status, LocalDateTime expireAt) { + return Offer.builder().id(id).status(status).expireAt(expireAt).activeKey("ACTIVE").build(); + } + + @Test + void expireDueOffers_shouldExpireDueSentAndNegotiating() { + LocalDateTime past = LocalDateTime.now().minusHours(1); + when(offerRepository.findActiveExpiredBefore(any(), any())) + .thenReturn(List.of(offer(1L, "SENT", past), offer(2L, "NEGOTIATING", past))); + + int count = offerExpiryService.expireDueOffers(); + + assertEquals(2, count); + verify(worker).expireOne(1L); + verify(worker).expireOne(2L); + } + + @Test + void expireDueOffers_noDue_shouldReturnZero() { + when(offerRepository.findActiveExpiredBefore(any(), any())).thenReturn(List.of()); + assertEquals(0, offerExpiryService.expireDueOffers()); + verifyNoInteractions(worker); + } + + @Test + void expireDueOffers_workerFailure_shouldNotBreakOthers() { + LocalDateTime past = LocalDateTime.now().minusHours(1); + when(offerRepository.findActiveExpiredBefore(any(), any())) + .thenReturn(List.of(offer(1L, "SENT", past), offer(2L, "SENT", past))); + doThrow(new RuntimeException("db down")).when(worker).expireOne(1L); + + int count = offerExpiryService.expireDueOffers(); + + // 单 Offer 失败不影响其余 Offer 处理 + assertEquals(1, count); + verify(worker).expireOne(2L); + } + + @Test + void expireDueOffers_secondRun_shouldNotReprocessExpired() { + // 第一次运行后状态已变 EXPIRED,查询按状态过滤不再返回 → 天然幂等 + when(offerRepository.findActiveExpiredBefore(any(), any())).thenReturn(List.of()); + assertEquals(0, offerExpiryService.expireDueOffers()); + verify(worker, never()).expireOne(any()); + } + + @Test + void createDueReminders_shouldAssignTypesByDeadline() { + LocalDateTime now = LocalDateTime.now(); + // 2 天后期满 → 仅 3 天提醒 + Offer twoDays = offer(1L, "SENT", now.plusDays(2)); + // 12 小时后期满 → 3 天 + 1 天提醒 + Offer halfDay = offer(2L, "NEGOTIATING", now.plusHours(12)); + when(offerRepository.findActiveExpiringBetween(any(), any(), any())) + .thenReturn(List.of(twoDays, halfDay)); + when(worker.createReminderOne(any(), any(), any())).thenReturn(true); + + int count = offerExpiryService.createDueReminders(); + + assertEquals(3, count); + verify(worker).createReminderOne(eq(1L), eq(OfferReminder.TYPE_EXPIRING_3_DAYS), any()); + verify(worker, never()).createReminderOne(eq(1L), eq(OfferReminder.TYPE_EXPIRING_1_DAY), any()); + verify(worker).createReminderOne(eq(2L), eq(OfferReminder.TYPE_EXPIRING_3_DAYS), any()); + verify(worker).createReminderOne(eq(2L), eq(OfferReminder.TYPE_EXPIRING_1_DAY), any()); + } + + @Test + void createDueReminders_duplicateReminder_shouldCountZero() { + LocalDateTime now = LocalDateTime.now(); + Offer offer = offer(1L, "SENT", now.plusDays(2)); + when(offerRepository.findActiveExpiringBetween(any(), any(), any())).thenReturn(List.of(offer)); + when(worker.createReminderOne(any(), any(), any())).thenReturn(false); // 已存在 → 跳过 + + int count = offerExpiryService.createDueReminders(); + assertEquals(0, count); + } + + @Test + void createDueReminders_acceptedOffersAreNotScanned() { + // 已接受/已拒绝/已过期的 Offer 不会被查询返回(查询仅扫描 SENT/NEGOTIATING) + when(offerRepository.findActiveExpiringBetween(any(), any(), any())).thenReturn(List.of()); + assertEquals(0, offerExpiryService.createDueReminders()); + verify(worker, never()).createReminderOne(any(), any(), any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java new file mode 100644 index 0000000..10f07e1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java @@ -0,0 +1,145 @@ +package com.mydreamjob.offer; + +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.notification.NotificationService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OfferExpiryWorkerTest { + + @Mock private OfferRepository offerRepository; + @Mock private OfferStatusHistoryRepository historyRepository; + @Mock private OfferReminderRepository reminderRepository; + @Mock private CompanyRepository companyRepository; + @Mock private NotificationService notificationService; + + @InjectMocks private OfferExpiryWorker worker; + + private static final Long OFFER_ID = 1L; + private static final Long CANDIDATE_ID = 100L; + private static final Long COMPANY_ID = 1L; + private static final Long COMPANY_OWNER_ID = 10L; + + private Offer offer(String status, LocalDateTime expireAt) { + return Offer.builder().id(OFFER_ID).candidateId(CANDIDATE_ID) + .status(status).expireAt(expireAt).activeKey("ACTIVE").build(); + } + + @Test + void expireOne_shouldMarkExpiredAndRecordHistory() { + Offer offer = offer("SENT", LocalDateTime.now().minusDays(1)); + offer.setCompanyId(COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + when(offerRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_OWNER_ID).build())); + + worker.expireOne(OFFER_ID); + + assertEquals("EXPIRED", offer.getStatus()); + assertNull(offer.getActiveKey()); + verify(historyRepository).save(argThat(h -> + h.getOfferId().equals(OFFER_ID) + && "SENT".equals(h.getFromStatus()) + && "EXPIRED".equals(h.getToStatus()) + && "SYSTEM".equals(h.getOperatorRole()) + && h.getOperatorId().equals(OfferService.SYSTEM_OPERATOR_ID))); + // 通知候选人 + 企业 + verify(notificationService).publishEvent(eq(CANDIDATE_ID), eq("OFFER_EXPIRED"), any(), any(), any(), any(), any()); + verify(notificationService).publishEvent(eq(COMPANY_OWNER_ID), eq("OFFER_EXPIRED"), any(), any(), any(), any(), any()); + } + + @Test + void expireOne_negotiating_shouldAlsoExpire() { + Offer offer = offer("NEGOTIATING", LocalDateTime.now().minusDays(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + when(offerRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + worker.expireOne(OFFER_ID); + assertEquals("EXPIRED", offer.getStatus()); + verify(historyRepository).save(argThat(h -> "NEGOTIATING".equals(h.getFromStatus()))); + } + + @Test + void expireOne_alreadyExpired_shouldBeIdempotent() { + Offer offer = offer("EXPIRED", LocalDateTime.now().minusDays(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + worker.expireOne(OFFER_ID); + verify(offerRepository, never()).saveAndFlush(any()); + verify(historyRepository, never()).save(any()); + verify(notificationService, never()).publishEvent(anyLong(), any(), any(), any(), any(), any(), any()); + } + + @Test + void expireOne_accepted_shouldBeSkipped() { + Offer offer = offer("ACCEPTED", LocalDateTime.now().minusDays(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + worker.expireOne(OFFER_ID); + verify(offerRepository, never()).saveAndFlush(any()); + verify(historyRepository, never()).save(any()); + } + + @Test + void createReminderOne_shouldCreateAndNotify() { + Offer offer = offer("SENT", LocalDateTime.now().plusDays(2)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + when(reminderRepository.existsByOfferIdAndReminderType(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS)).thenReturn(false); + when(reminderRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + boolean created = worker.createReminderOne(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS, LocalDateTime.now()); + + assertTrue(created); + verify(reminderRepository).saveAndFlush(argThat(r -> + r.getOfferId().equals(OFFER_ID) + && OfferReminder.TYPE_EXPIRING_3_DAYS.equals(r.getReminderType()) + && OfferReminder.STATUS_SENT.equals(r.getStatus()))); + verify(notificationService).publishEvent(eq(CANDIDATE_ID), eq("OFFER_REMINDER"), any(), any(), any(), any(), any()); + } + + @Test + void createReminderOne_duplicate_shouldSkip() { + Offer offer = offer("SENT", LocalDateTime.now().plusDays(2)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + when(reminderRepository.existsByOfferIdAndReminderType(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS)).thenReturn(true); + + boolean created = worker.createReminderOne(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS, LocalDateTime.now()); + + assertFalse(created); + verify(reminderRepository, never()).saveAndFlush(any()); + verify(notificationService, never()).publishEvent(anyLong(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createReminderOne_rejectedOffer_shouldSkip() { + Offer offer = offer("REJECTED", LocalDateTime.now().plusDays(2)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + boolean created = worker.createReminderOne(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS, LocalDateTime.now()); + assertFalse(created); + verify(reminderRepository, never()).saveAndFlush(any()); + } + + @Test + void createReminderOne_expiredOffer_shouldSkip() { + Offer offer = offer("EXPIRED", LocalDateTime.now().minusDays(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(offer)); + boolean created = worker.createReminderOne(OFFER_ID, OfferReminder.TYPE_EXPIRING_3_DAYS, LocalDateTime.now()); + assertFalse(created); + verify(reminderRepository, never()).saveAndFlush(any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferPersistenceIntegrationTest.java new file mode 100644 index 0000000..df57913 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferPersistenceIntegrationTest.java @@ -0,0 +1,161 @@ +package com.mydreamjob.offer; + +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.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Offer 持久化集成测试:使用 H2 + Flyway V13 的真实表结构, + * 验证实体映射、乐观锁、以及"每投递唯一有效 Offer"的数据库唯一约束。 + * (不依赖 ddl-auto=validate——H2 对既有 auth_tokens.revoked TINYINT/Boolean 校验不兼容,见报告) + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class OfferPersistenceIntegrationTest { + + @Autowired private OfferRepository offerRepository; + @Autowired private OfferStatusHistoryRepository historyRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + private Long companyId; + private Long jobId; + private Long applicationId; + private Long studentId; + private Long companyUserId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(100); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + // H2 内存库 (DB_CLOSE_DELAY=-1) 在同 JVM 的多个测试上下文间共享 → 先清理再建 + historyRepository.deleteAll(); + offerRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + + User student = userRepository.save(User.builder() + .phone(uniquePhone("s")).nickname("集成候选人") + .role(User.Role.STUDENT).active(true).verified(true).build()); + student.setRoles(java.util.Set.of(User.Role.STUDENT)); + studentId = student.getId(); + + User companyUser = userRepository.save(User.builder() + .phone(uniquePhone("c")).nickname("集成企业") + .role(User.Role.COMPANY).active(true).verified(true).build()); + companyUser.setRoles(java.util.Set.of(User.Role.COMPANY)); + companyUserId = companyUser.getId(); + + Company company = companyRepository.save(Company.builder() + .ownerUserId(companyUserId).name("集成测试公司").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder() + .companyId(companyId).title("集成测试岗位").description("desc").city("杭州") + .employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + Application app = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(studentId).status("SUBMITTED").build()); + applicationId = app.getId(); + } + + private String uniquePhone(String prefix) { + return "199" + prefix + String.format("%08d", PHONE_SEQ.incrementAndGet()); + } + + private Offer newDraft() { + return Offer.builder() + .applicationId(applicationId).jobId(jobId).companyId(companyId) + .candidateId(studentId) + .title("集成测试 Offer").description("desc").expireAt(LocalDateTime.now().plusDays(7)) + .status(OfferStatus.DRAFT.name()).activeKey("ACTIVE") + .createdBy(companyUserId).updatedBy(companyUserId) + .build(); + } + + @Test + void offer_crud_roundTrip_withVersion() { + Offer saved = offerRepository.saveAndFlush(newDraft()); + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getVersion()).isEqualTo(0L); + assertThat(saved.getStatus()).isEqualTo("DRAFT"); + assertThat(saved.getActiveKey()).isEqualTo("ACTIVE"); + + // 乐观锁:version 递增 + saved.setStatus("SENT"); + Offer updated = offerRepository.saveAndFlush(saved); + assertThat(updated.getVersion()).isEqualTo(1L); + + Optional reloaded = offerRepository.findById(saved.getId()); + assertTrue(reloaded.isPresent()); + assertThat(reloaded.get().getStatus()).isEqualTo("SENT"); + assertThat(offerRepository.existsByApplicationIdAndActiveKeyNotNull(applicationId)).isTrue(); + } + + @Test + void uniqueActiveOffer_databaseConstraint_enforced() { + offerRepository.saveAndFlush(newDraft()); + // 同一投递第二个有效 Offer → 唯一索引 uk_offers_active 兜底 + assertThrows(DataIntegrityViolationException.class, + () -> offerRepository.saveAndFlush(newDraft())); + } + + @Test + void rejectedOffer_releasesActiveSlot() { + Offer saved = offerRepository.saveAndFlush(newDraft()); + saved.setStatus("REJECTED"); + saved.setActiveKey(null); + offerRepository.saveAndFlush(saved); + + // 槽位释放后,可再创建新 Offer + Offer second = newDraft(); + assertThat(second.getActiveKey()).isEqualTo("ACTIVE"); + offerRepository.saveAndFlush(second); + assertThat(offerRepository.existsByApplicationIdAndActiveKeyNotNull(applicationId)).isTrue(); + } + + @Test + void statusHistory_roundTrip() { + Offer saved = offerRepository.saveAndFlush(newDraft()); + OfferStatusHistory history = historyRepository.save(OfferStatusHistory.builder() + .offerId(saved.getId()).fromStatus("DRAFT").toStatus("SENT") + .operatorId(3L).operatorRole("COMPANY").reason("send").requestId("req-1").build()); + assertThat(history.getId()).isNotNull(); + assertThat(history.getCreatedAt()).isNotNull(); + assertThat(historyRepository.findByOfferIdOrderByCreatedAtAsc(saved.getId())).hasSize(1); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java new file mode 100644 index 0000000..483e3cd --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java @@ -0,0 +1,567 @@ +package com.mydreamjob.offer; + +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 com.mydreamjob.notification.NotificationService; +import com.mydreamjob.offer.dto.OfferCreateRequest; +import com.mydreamjob.offer.dto.OfferStatusRequest; +import com.mydreamjob.offer.dto.OfferUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OfferServiceTest { + + @Mock private OfferRepository offerRepository; + @Mock private OfferStatusHistoryRepository historyRepository; + @Mock private OfferReminderRepository reminderRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + + @InjectMocks private OfferService offerService; + + private static final Long COMPANY_USER_ID = 10L; + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_ID = 1L; + private static final Long OTHER_COMPANY_ID = 2L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + private static final Long OFFER_ID = 1L; + + private AuditContext ctx = AuditContext.builder().requestId("req-123").build(); + + @BeforeEach + void setUp() { + ctx = AuditContext.builder().requestId("req-123").build(); + } + + // ── 辅助 ────────────────────────────────────────────────── + + private void mockCompanyUser(Long userId) { + User u = User.builder().id(userId).phone("1").role(User.Role.COMPANY).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockStudentUser(Long userId) { + User u = User.builder().id(userId).phone("2").role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockCompany(Long userId, Long companyId) { + when(companyRepository.findByOwnerUserId(userId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); + } + + private void mockCompanyById(Long companyId, Long ownerUserId) { + when(companyRepository.findById(companyId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).build())); + } + + private Offer buildOffer(String status) { + return Offer.builder().id(OFFER_ID).applicationId(APPLICATION_ID).jobId(JOB_ID) + .companyId(COMPANY_ID).candidateId(STUDENT_ID).title("后端工程师") + .description("desc").expireAt(LocalDateTime.now().plusDays(7)) + .status(status).activeKey("ACTIVE").version(0L).build(); + } + + private OfferCreateRequest buildCreateReq() { + OfferCreateRequest req = new OfferCreateRequest(); + req.setApplicationId(APPLICATION_ID); + req.setTitle("后端工程师"); + req.setSalaryMin(10000); + req.setSalaryMax(20000); + req.setWorkCity("杭州"); + req.setDescription("负责服务端开发"); + req.setExpireAt(LocalDateTime.now().plusDays(7)); + return req; + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void createOffer_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + when(offerRepository.existsByApplicationIdAndActiveKeyNotNull(APPLICATION_ID)).thenReturn(false); + when(offerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Offer offer = offerService.createOffer(COMPANY_USER_ID, buildCreateReq(), ctx); + + assertEquals(OfferStatus.DRAFT.name(), offer.getStatus()); + assertEquals("ACTIVE", offer.getActiveKey()); + assertEquals(STUDENT_ID, offer.getCandidateId()); + verify(historyRepository).save(any()); // 创建历史已记录 + verify(notificationService, never()).publishEvent(anyLong(), any(), any(), any(), any(), any(), any()); + } + + @Test + void createOffer_applicationNotFound_shouldReturn404() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + OfferCreateRequest req = buildCreateReq(); + req.setApplicationId(999L); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(COMPANY_USER_ID, req, ctx)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOffer_jobNotOwned_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(OTHER_COMPANY_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(COMPANY_USER_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOffer_duplicateActive_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + when(offerRepository.existsByApplicationIdAndActiveKeyNotNull(APPLICATION_ID)).thenReturn(true); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(COMPANY_USER_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.DUPLICATE_OFFER.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOffer_byStudent_shouldReturn403() { + mockStudentUser(STUDENT_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(STUDENT_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOffer_applicationNotEligible_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("HIRED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(COMPANY_USER_ID, buildCreateReq(), ctx)); + assertEquals(ErrorCode.OFFER_APPLICATION_NOT_ELIGIBLE.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createOffer_salaryMinGreaterThanMax_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + OfferCreateRequest req = buildCreateReq(); + req.setSalaryMin(20000); + req.setSalaryMax(10000); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createOffer(COMPANY_USER_ID, req, ctx)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + // ── 查看 ────────────────────────────────────────────────── + + @Test + void getOffer_asCandidate_shouldSucceed() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + Offer offer = offerService.getOffer(OFFER_ID, STUDENT_ID); + assertEquals(OFFER_ID, offer.getId()); + } + + @Test + void getOffer_otherCandidate_shouldReturn403() { + mockStudentUser(OTHER_STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.getOffer(OFFER_ID, OTHER_STUDENT_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getOffer_otherCompany_shouldReturn403() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, OTHER_COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.getOffer(OFFER_ID, COMPANY_USER_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getOffer_ownCompany_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + assertDoesNotThrow(() -> offerService.getOffer(OFFER_ID, COMPANY_USER_ID)); + } + + @Test + void getOffer_notFound_shouldReturn404() { + when(offerRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.getOffer(999L, STUDENT_ID)); + assertEquals(ErrorCode.OFFER_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 修改 ────────────────────────────────────────────────── + + @Test + void updateOffer_draft_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + when(offerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + OfferUpdateRequest req = new OfferUpdateRequest(); + req.setTitle("高级后端工程师"); + req.setSalaryMin(15000); + Offer offer = offerService.updateOffer(COMPANY_USER_ID, OFFER_ID, req, ctx); + assertEquals("高级后端工程师", offer.getTitle()); + assertEquals(15000, offer.getSalaryMin()); + } + + @Test + void updateOffer_sent_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + OfferUpdateRequest req = new OfferUpdateRequest(); + req.setTitle("改标题"); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.updateOffer(COMPANY_USER_ID, OFFER_ID, req, ctx)); + assertEquals(ErrorCode.OFFER_NOT_EDITABLE.getCode(), ex.getErrorCodeInt()); + } + + @Test + void updateOffer_salaryRangeInvalid_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + OfferUpdateRequest req = new OfferUpdateRequest(); + req.setSalaryMin(20000); + req.setSalaryMax(10000); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.updateOffer(COMPANY_USER_ID, OFFER_ID, req, ctx)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void updateOffer_expireAtInPast_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + OfferUpdateRequest req = new OfferUpdateRequest(); + req.setExpireAt(LocalDateTime.now().minusDays(1)); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.updateOffer(COMPANY_USER_ID, OFFER_ID, req, ctx)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void updateOffer_otherCompany_shouldReturn403() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, OTHER_COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.updateOffer(COMPANY_USER_ID, OFFER_ID, new OfferUpdateRequest(), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 状态流转 ────────────────────────────────────────────── + + private OfferStatusRequest statusReq(String status) { + OfferStatusRequest req = new OfferStatusRequest(); + req.setStatus(status); + return req; + } + + private void stubSaveAndFlush() { + when(offerRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void transition_draftToSent_byCompany_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + stubSaveAndFlush(); + Offer offer = offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("SENT"), ctx); + assertEquals("SENT", offer.getStatus()); + verify(historyRepository).save(any()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_SENT"), any(), any(), any(), any(), any()); + } + + @Test + void transition_sentToAccepted_byCandidate_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyById(COMPANY_ID, COMPANY_USER_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + stubSaveAndFlush(); + Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("ACCEPTED"), ctx); + assertEquals("ACCEPTED", offer.getStatus()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_ACCEPTED"), any(), any(), any(), any(), any()); + } + + @Test + void transition_sentToRejected_byCandidate_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyById(COMPANY_ID, COMPANY_USER_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + stubSaveAndFlush(); + Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("REJECTED"), ctx); + assertEquals("REJECTED", offer.getStatus()); + assertNull(offer.getActiveKey()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_REJECTED"), any(), any(), any(), any(), any()); + } + + @Test + void transition_sentToNegotiating_byCandidate_shouldSucceed() { + mockStudentUser(STUDENT_ID); + mockCompanyById(COMPANY_ID, COMPANY_USER_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + stubSaveAndFlush(); + Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("NEGOTIATING"), ctx); + assertEquals("NEGOTIATING", offer.getStatus()); + verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_NEGOTIATING"), any(), any(), any(), any(), any()); + } + + @Test + void transition_acceptedToRejected_shouldFail() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("ACCEPTED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("REJECTED"), ctx)); + assertEquals(ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_expiredOffer_accept_shouldFail() { + mockStudentUser(STUDENT_ID); + Offer expired = buildOffer("SENT"); + expired.setExpireAt(LocalDateTime.now().minusHours(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(expired)); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("ACCEPTED"), ctx)); + assertEquals(ErrorCode.OFFER_EXPIRED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_invalid_draftToAccepted_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("ACCEPTED"), ctx)); + assertEquals(ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_companyCannotAcceptCandidateDecision_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("ACCEPTED"), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_candidateCannotSend_shouldFail() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("SENT"), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_sameStatus_shouldBeIdempotent() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("SENT"), ctx); + assertEquals("SENT", offer.getStatus()); + verify(offerRepository, never()).saveAndFlush(any()); + verify(historyRepository, never()).save(any()); + } + + @Test + void transition_otherCandidate_shouldFail() { + mockStudentUser(OTHER_STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(OTHER_STUDENT_ID, OFFER_ID, statusReq("ACCEPTED"), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 时间线 ──────────────────────────────────────────────── + + @Test + void timeline_shouldReturnHistoryWithOperatorAndRequestId() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + OfferStatusHistory h = OfferStatusHistory.builder().id(1L).offerId(OFFER_ID) + .fromStatus("DRAFT").toStatus("SENT").operatorId(COMPANY_USER_ID) + .operatorRole("COMPANY").reason("send").requestId("req-123").build(); + when(historyRepository.findByOfferIdOrderByCreatedAtAsc(OFFER_ID)).thenReturn(List.of(h)); + var timeline = offerService.getTimeline(OFFER_ID, STUDENT_ID); + assertEquals(1, timeline.size()); + assertEquals("SENT", timeline.get(0).get("toStatus")); + assertEquals(COMPANY_USER_ID, timeline.get(0).get("operator")); + assertEquals("COMPANY", timeline.get(0).get("operatorRole")); + assertEquals("req-123", timeline.get(0).get("requestId")); + } + + // ── 列表 ────────────────────────────────────────────────── + + @Test + void list_candidate_shouldReturnOwnOffers() { + mockStudentUser(STUDENT_ID); + when(offerRepository.findByCandidateIdOrderByCreatedAtDesc(eq(STUDENT_ID), any())) + .thenReturn(new PageImpl<>(List.of(buildOffer("SENT")))); + var page = offerService.listOffers(STUDENT_ID, null, null, 0, 20); + assertEquals(1, page.getTotalElements()); + } + + @Test + void list_company_shouldReturnCompanyOffers() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findByCompanyIdAndStatusOrderByCreatedAtDesc(eq(COMPANY_ID), eq("SENT"), any())) + .thenReturn(new PageImpl<>(List.of(buildOffer("SENT")))); + var page = offerService.listOffers(COMPANY_USER_ID, "SENT", null, 0, 20); + assertEquals(1, page.getTotalElements()); + } + + @Test + void list_invalidPage_shouldFail() { + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.listOffers(STUDENT_ID, null, null, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + // ── 手动提醒 ────────────────────────────────────────────── + + @Test + void remind_byCompany_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + when(reminderRepository.findByOfferIdAndReminderType(OFFER_ID, OfferReminder.TYPE_MANUAL)) + .thenReturn(Optional.empty()); + when(reminderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + OfferReminder reminder = offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx); + assertEquals(OfferReminder.TYPE_MANUAL, reminder.getReminderType()); + assertEquals(OfferReminder.STATUS_SENT, reminder.getStatus()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_REMINDER"), any(), any(), any(), any(), any()); + } + + @Test + void remind_expiredOffer_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + Offer expired = buildOffer("EXPIRED"); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(expired)); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx)); + assertEquals(ErrorCode.OFFER_EXPIRED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void remind_draftOffer_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx)); + + assertEquals(ErrorCode.OFFER_NOT_REMINDABLE.getCode(), ex.getErrorCodeInt()); + verify(reminderRepository, never()).save(any()); + } + + @Test + void remind_acceptedOffer_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("ACCEPTED"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx)); + + assertEquals(ErrorCode.OFFER_NOT_REMINDABLE.getCode(), ex.getErrorCodeInt()); + verify(reminderRepository, never()).save(any()); + } + + @Test + void remind_duplicate_shouldBeIdempotent() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + OfferReminder existing = OfferReminder.builder().id(9L).offerId(OFFER_ID) + .reminderType(OfferReminder.TYPE_MANUAL).status(OfferReminder.STATUS_SENT).build(); + when(reminderRepository.findByOfferIdAndReminderType(OFFER_ID, OfferReminder.TYPE_MANUAL)) + .thenReturn(Optional.of(existing)); + OfferReminder reminder = offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx); + assertEquals(9L, reminder.getId()); + verify(reminderRepository, never()).save(any()); + verify(notificationService, never()).publishEvent(anyLong(), any(), any(), any(), any(), any(), any()); + } + + @Test + void remind_byStudent_shouldReturn403() { + mockStudentUser(STUDENT_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.remind(STUDENT_ID, OFFER_ID, ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java new file mode 100644 index 0000000..d59ef99 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java @@ -0,0 +1,197 @@ +package com.mydreamjob.preference; + +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.preference.dto.StudentPreferenceRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PreferenceServiceTest { + + @Mock + private StudentPreferenceRepository preferenceRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private PreferenceService preferenceService; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 10L; + + private StudentPreference pref; + + @BeforeEach + void setUp() { + pref = StudentPreference.builder() + .id(1L).studentUserId(STUDENT_ID) + .expectedSalaryMin(10000).expectedSalaryMax(20000) + .preferredCities("[\"杭州\",\"上海\"]") + .employmentTypes("[\"FULL_TIME\"]") + .preferredIndustries("[\"互联网\"]") + .preferredJobTypes("[\"后端开发\"]") + .remoteAllowed(false) + .build(); + } + + private void mockStudentRole(Long userId) { + User student = User.builder().id(userId).phone("13800000001") + .role(User.Role.STUDENT).active(true).verified(true).build(); + student.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(student)); + } + + private void mockCompanyRole(Long userId) { + User company = User.builder().id(userId).phone("13800000002") + .role(User.Role.COMPANY).active(true).verified(true).build(); + company.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(company)); + } + + private StudentPreferenceRequest buildRequest() { + StudentPreferenceRequest req = new StudentPreferenceRequest(); + req.setExpectedSalaryMin(10000); + req.setExpectedSalaryMax(20000); + req.setPreferredCities(List.of("杭州", "上海")); + req.setEmploymentTypes(List.of("FULL_TIME")); + req.setPreferredIndustries(List.of("互联网")); + req.setPreferredJobTypes(List.of("后端开发")); + req.setRemoteAllowed(false); + return req; + } + + // ── 成功保存 ───────────────────────────────────────────── + + @Test + void savePreferences_shouldSucceed() { + mockStudentRole(STUDENT_ID); + StudentPreferenceRequest req = buildRequest(); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(preferenceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = preferenceService.savePreferences(STUDENT_ID, req); + + assertEquals(10000, result.get("expectedSalaryMin")); + assertEquals(List.of("杭州", "上海"), result.get("preferredCities")); + verify(preferenceRepository, times(1)).save(any()); + } + + // ── 查询 ───────────────────────────────────────────────── + + @Test + void getPreferences_shouldReturnData() { + mockStudentRole(STUDENT_ID); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + + var result = preferenceService.getPreferences(STUDENT_ID); + + assertEquals(10000, result.get("expectedSalaryMin")); + assertEquals(List.of("杭州", "上海"), result.get("preferredCities")); + } + + @Test + void getPreferences_noRecord_shouldReturnEmpty() { + mockStudentRole(STUDENT_ID); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + + var result = preferenceService.getPreferences(STUDENT_ID); + + assertNull(result.get("expectedSalaryMin")); + assertEquals(List.of(), result.get("preferredCities")); + assertEquals(false, result.get("remoteAllowed")); + } + + // ── upsert 不重复 ──────────────────────────────────────── + + @Test + void savePreferences_upsert_shouldNotDuplicate() { + mockStudentRole(STUDENT_ID); + StudentPreferenceRequest req = buildRequest(); + req.setExpectedSalaryMin(15000); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = preferenceService.savePreferences(STUDENT_ID, req); + + assertEquals(15000, result.get("expectedSalaryMin")); + verify(preferenceRepository, times(1)).save(any()); + } + + // ── 角色越权 ───────────────────────────────────────────── + + @Test + void savePreferences_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> preferenceService.savePreferences(COMPANY_ID, buildRequest())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getPreferences_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> preferenceService.getPreferences(COMPANY_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 薪资范围错误 ───────────────────────────────────────── + + @Test + void savePreferences_salaryMinGreaterThanMax_shouldBeInvalid() { + StudentPreferenceRequest req = buildRequest(); + req.setExpectedSalaryMin(30000); + req.setExpectedSalaryMax(20000); + + assertFalse(req.isSalaryRangeValid()); + } + + @Test + void savePreferences_negativeSalary_shouldBeInvalid() { + StudentPreferenceRequest req = buildRequest(); + req.setExpectedSalaryMin(-1000); + + // @Min validation would catch this; test DTO constraint manually + assertEquals(-1000, req.getExpectedSalaryMin()); + } + + // ── 数组校验 ───────────────────────────────────────────── + + @Test + void savePreferences_tooManyItems_shouldBeLimited() { + StudentPreferenceRequest req = new StudentPreferenceRequest(); + List many = new ArrayList<>(); + for (int i = 0; i < 25; i++) many.add("item" + i); + req.setPreferredCities(many); + + assertTrue(req.getPreferredCities().size() > 20); + } + + @Test + void savePreferences_emptyArray_shouldSucceed() { + mockStudentRole(STUDENT_ID); + StudentPreferenceRequest req = new StudentPreferenceRequest(); + req.setPreferredCities(List.of()); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = preferenceService.savePreferences(STUDENT_ID, req); + + assertEquals(List.of(), result.get("preferredCities")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java new file mode 100644 index 0000000..e9bca1c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java @@ -0,0 +1,115 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProfileServiceTest { + + @Mock + private StudentProfileRepository profileRepository; + + @InjectMocks + private ProfileService profileService; + + private StudentProfile profile; + + @BeforeEach + void setUp() { + profile = StudentProfile.builder() + .id(1L) + .userId(10L) + .realName("张三") + .school("示例大学") + .build(); + } + + @Test + void findProfileByUserId_exists_shouldReturnProfile() { + when(profileRepository.findByUserId(10L)).thenReturn(Optional.of(profile)); + + StudentProfile result = profileService.findProfileByUserId(10L); + + assertNotNull(result); + assertEquals("张三", result.getRealName()); + } + + @Test + void findProfileByUserId_notExists_shouldReturnNull() { + when(profileRepository.findByUserId(99L)).thenReturn(Optional.empty()); + + StudentProfile result = profileService.findProfileByUserId(99L); + + assertNull(result); + } + + @Test + void updateProfile_autoCreateWhenNotExists() { + StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); + req.setRealName("李四"); + req.setSchool("北京大学"); + req.setSkills(List.of("Java", "Spring Boot")); + + when(profileRepository.findByUserId(20L)).thenReturn(Optional.empty()); + when(profileRepository.save(any())).thenAnswer(invocation -> { + StudentProfile p = invocation.getArgument(0); + p.setId(2L); + return p; + }); + + StudentProfile saved = profileService.updateProfile(20L, req); + + assertEquals(20L, saved.getUserId()); + assertEquals("李四", saved.getRealName()); + assertEquals("北京大学", saved.getSchool()); + assertEquals("[Java, Spring Boot]", saved.getSkillsJson()); + verify(profileRepository, times(1)).save(any()); + } + + @Test + void updateProfile_partialUpdate_preservesUnchangedFields() { + StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); + req.setRealName("李四"); + // bio、location 不传,应保留原值 + + when(profileRepository.findByUserId(10L)).thenReturn(Optional.of(profile)); + when(profileRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + StudentProfile updated = profileService.updateProfile(10L, req); + + assertEquals("李四", updated.getRealName()); + assertNull(updated.getBio()); // 原值就是 null,未传则保留 + verify(profileRepository, times(1)).save(profile); + } + + @Test + void toView_profileNull_returnsNull() { + assertNull(profileService.toView(null)); + } + + @Test + void toView_profileNotNull_returnsView() { + when(profileRepository.findByUserId(10L)).thenReturn(Optional.of(profile)); + StudentProfile p = profileService.findProfileByUserId(10L); + assertNotNull(p); + + var view = profileService.toView(p); + assertNotNull(view); + assertEquals(1L, view.get("id")); + assertEquals("张三", view.get("realName")); + assertEquals("示例大学", view.get("school")); + assertEquals(10L, view.get("userId")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java new file mode 100644 index 0000000..1f4706b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java @@ -0,0 +1,224 @@ +package com.mydreamjob.profile; + +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.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ResumeControllerTest { + + @Mock + private ProfileService profileService; + + @Mock + private Authentication authentication; + + @InjectMocks + private ProfileController controller; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 10L; + + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/profile/me/resume"); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void mockAuth(Long userId) { + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn(String.valueOf(userId)); + } + + // ── 未登录 401 ─────────────────────────────────────────── + + @Test + void uploadResume_unauthenticated_shouldReturn401() { + when(authentication.isAuthenticated()).thenReturn(false); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "data".getBytes()); + + assertThrows(BusinessException.class, + () -> controller.uploadResume(file, request)); + } + + // ── STUDENT 上传成功 ────────────────────────────────────── + + @Test + void uploadResume_asStudent_shouldReturn200() { + mockAuth(STUDENT_ID); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "data".getBytes()); + Map view = Map.of("resumeUrl", "path.pdf", "fileName", "r.pdf"); + when(profileService.uploadResume(eq(STUDENT_ID), any())).thenReturn(view); + + var resp = controller.uploadResume(file, request); + + assertEquals(200, resp.getStatusCode().value()); + assertNotNull(resp.getBody()); + assertEquals(200, resp.getBody().getCode()); + } + + // ── COMPANY 上传 403 ────────────────────────────────────── + + @Test + void uploadResume_asCompany_shouldReturn403() { + mockAuth(COMPANY_ID); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "data".getBytes()); + when(profileService.uploadResume(eq(COMPANY_ID), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> controller.uploadResume(file, request)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── ADMIN 上传 403 ──────────────────────────────────────── + + @Test + void uploadResume_asAdmin_shouldReturn403() { + mockAuth(999L); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "data".getBytes()); + when(profileService.uploadResume(eq(999L), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> controller.uploadResume(file, request)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 学生下载成功 ────────────────────────────────────────── + + @Test + void downloadResume_asStudent_shouldReturn200() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "my_resume.pdf", "application/pdf", "content".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + assertEquals(200, resp.getStatusCode().value()); + assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType()); + } + + // ── 学生删除成功 ────────────────────────────────────────── + + @Test + void deleteResume_asStudent_shouldReturn200() { + mockAuth(STUDENT_ID); + doNothing().when(profileService).deleteResume(STUDENT_ID); + + var resp = controller.deleteResume(request); + + assertEquals(200, resp.getStatusCode().value()); + assertEquals(200, resp.getBody().getCode()); + } + + // ── 非法扩展名 400 ──────────────────────────────────────── + + @Test + void uploadResume_invalidExtension_shouldReturn400() { + mockAuth(STUDENT_ID); + MockMultipartFile file = new MockMultipartFile("file", "r.exe", + "application/x-msdownload", "data".getBytes()); + when(profileService.uploadResume(eq(STUDENT_ID), any())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + assertThrows(BusinessException.class, + () -> controller.uploadResume(file, request)); + } + + // ── MIME 不匹配 400 ─────────────────────────────────────── + + @Test + void uploadResume_mimeTypeMismatch_shouldReturn400() { + mockAuth(STUDENT_ID); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", + "text/html", "data".getBytes()); + when(profileService.uploadResume(eq(STUDENT_ID), any())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + + assertThrows(BusinessException.class, + () -> controller.uploadResume(file, request)); + } + + // ── Content-Disposition 文件名 ───────────────────────── + + @Test + void downloadResume_englishFilename_shouldReturnContentDisposition() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "my_resume.pdf", "application/pdf", "data".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + assertNotNull(cd); + assertTrue(cd.contains("my_resume.pdf"), + "CD should contain original filename, got: " + cd); + assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType()); + } + + @Test + void downloadResume_chineseFilename_shouldReturnUtf8EncodedFilename() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "张三-简历.pdf", "application/pdf", "data".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + assertNotNull(cd); + assertTrue(cd.contains("filename*=UTF-8''")); + assertTrue(cd.contains("%E5%BC%A0%E4%B8%89") + || cd.contains("张三")); + } + + @Test + void downloadResume_contentTypeShouldMatchStoredType() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "cv.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "data".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + assertEquals(MediaType.valueOf( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + resp.getHeaders().getContentType()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java new file mode 100644 index 0000000..e8bd16b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java @@ -0,0 +1,190 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ResumeServiceTest { + + @Mock + private StudentProfileRepository profileRepository; + + @Mock + private ResumeStorageService storageService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ProfileService profileService; + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 10L; + private StudentProfile profile; + + @BeforeEach + void setUp() { + profile = StudentProfile.builder() + .id(1L).userId(STUDENT_ID).realName("张三") + .resumeUrl("old_resume.pdf") + .resumeOriginalName("my_resume.pdf") + .resumeContentType("application/pdf") + .resumeSize(1024L) + .build(); + } + + private void mockStudentRole() { + User student = User.builder().id(STUDENT_ID).phone("13800000001") + .role(User.Role.STUDENT).active(true).verified(true).build(); + student.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student)); + } + + private void mockCompanyRole() { + User company = User.builder().id(COMPANY_ID).phone("13800000002") + .role(User.Role.COMPANY).active(true).verified(true).build(); + company.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company)); + } + + // ── 成功上传 ───────────────────────────────────────────── + + @Test + void uploadResume_asStudent_shouldSucceed() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", "content".getBytes()); + + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); + when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = profileService.uploadResume(STUDENT_ID, file); + + assertEquals("new_resume.pdf", result.get("resumeUrl")); + assertEquals("resume.pdf", result.get("fileName")); + assertEquals("application/pdf", result.get("contentType")); + verify(storageService, times(1)).delete("old_resume.pdf"); + } + + @Test + void uploadResume_noExistingProfile_shouldCreateProfile() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "content".getBytes()); + + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(storageService.store(eq(STUDENT_ID), any())).thenReturn("r.pdf"); + when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var result = profileService.uploadResume(STUDENT_ID, file); + assertEquals("r.pdf", result.get("resumeUrl")); + } + + // ── 角色越权 ───────────────────────────────────────────── + + @Test + void uploadResume_asCompany_shouldReturn403() { + mockCompanyRole(); + MultipartFile file = new MockMultipartFile("file", "r.pdf", + "application/pdf", "data".getBytes()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> profileService.uploadResume(COMPANY_ID, file)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getResume_asCompany_shouldReturn403() { + mockCompanyRole(); + BusinessException ex = assertThrows(BusinessException.class, + () -> profileService.getResume(COMPANY_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void deleteResume_asCompany_shouldReturn403() { + mockCompanyRole(); + BusinessException ex = assertThrows(BusinessException.class, + () -> profileService.deleteResume(COMPANY_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 文件校验 ────────────────────────────────────────────── + + @Test + void uploadResume_invalidExtension_shouldReturn400() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.exe", + "application/x-msdownload", "data".getBytes()); + + assertThrows(BusinessException.class, + () -> profileService.uploadResume(STUDENT_ID, file)); + } + + @Test + void uploadResume_mimeTypeMismatch_shouldReturn400() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "text/html", "data".getBytes()); + + assertThrows(BusinessException.class, + () -> profileService.uploadResume(STUDENT_ID, file)); + } + + @Test + void uploadResume_noExtension_shouldReturn400() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "noext", + "application/pdf", "data".getBytes()); + + assertThrows(BusinessException.class, + () -> profileService.uploadResume(STUDENT_ID, file)); + } + + // ── 获取 / 删除 ────────────────────────────────────────── + + @Test + void getResume_shouldReturnFile() { + mockStudentRole(); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.read("old_resume.pdf")).thenReturn("content".getBytes()); + + ProfileService.ResumeFile rf = profileService.getResume(STUDENT_ID); + + assertArrayEquals("content".getBytes(), rf.getContent()); + assertEquals("my_resume.pdf", rf.getFilename()); + } + + @Test + void deleteResume_shouldClearAllFields() { + mockStudentRole(); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + profileService.deleteResume(STUDENT_ID); + + verify(storageService, times(1)).delete("old_resume.pdf"); + assertNull(profile.getResumeUrl()); + assertNull(profile.getResumeOriginalName()); + assertNull(profile.getResumeContentType()); + assertNull(profile.getResumeSize()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java new file mode 100644 index 0000000..64d4d6c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java @@ -0,0 +1,115 @@ +package com.mydreamjob.profile.storage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class LocalResumeStorageServiceTest { + + private LocalResumeStorageService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + service = new LocalResumeStorageService(); + ReflectionTestUtils.setField(service, "storageDir", tempDir.toString()); + service.init(); + } + + // ── 正常上传 ────────────────────────────────────────────── + + @Test + void store_validPdf_shouldSucceed() throws Exception { + MultipartFile file = new MockMultipartFile( + "file", "resume.pdf", "application/pdf", + "Fake PDF content".getBytes()); + + String path = service.store(1L, file); + + assertNotNull(path); + assertTrue(path.endsWith(".pdf")); + assertTrue(Files.exists(tempDir.resolve(path))); + } + + @Test + void store_validDocx_shouldSucceed() throws Exception { + MultipartFile file = new MockMultipartFile( + "file", "resume.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Fake DOCX".getBytes()); + + String path = service.store(2L, file); + assertTrue(path.endsWith(".docx")); + } + + // ── 读取 ────────────────────────────────────────────────── + + @Test + void read_storedFile_shouldReturnContent() throws Exception { + byte[] content = "My resume content".getBytes(); + MultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", content); + String path = service.store(3L, file); + + byte[] read = service.read(path); + + assertArrayEquals(content, read); + } + + // ── 删除 ────────────────────────────────────────────────── + + @Test + void delete_storedFile_shouldRemove() throws Exception { + MultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + String path = service.store(4L, file); + assertTrue(Files.exists(tempDir.resolve(path))); + + service.delete(path); + + assertFalse(Files.exists(tempDir.resolve(path))); + } + + @Test + void delete_nullPath_shouldNotThrow() { + assertDoesNotThrow(() -> service.delete(null)); + } + + // ── 路径穿越防御 ───────────────────────────────────────── + + @Test + void store_pathTraversal_shouldBeRejected() { + MultipartFile file = new MockMultipartFile("file", "ok.pdf", "application/pdf", "data".getBytes()); + // store() uses basePath.resolve(storedName) where storedName is UUID-based + // so path traversal is impossible by design; verify that ../ is stripped + String path = service.store(99L, file); + assertFalse(path.contains("..")); + assertFalse(path.contains("/")); + } + + // ── 无扩展名文件 ───────────────────────────────────────── + + @Test + void store_noExtension_shouldHandleGracefully() { + MultipartFile file = new MockMultipartFile("file", "noext", "application/pdf", "data".getBytes()); + String path = service.store(5L, file); + assertNotNull(path); + } + + // ── 文件不存在 ──────────────────────────────────────────── + + @Test + void read_nonExistent_shouldThrow() { + assertThrows(com.mydreamjob.common.exception.BusinessException.class, + () -> service.read("nonexistent.pdf")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportServiceTest.java new file mode 100644 index 0000000..4e98da2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportServiceTest.java @@ -0,0 +1,203 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.resume.storage.ResumeExportStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ResumeExportServiceTest { + + @Mock private ResumeExportTaskRepository taskRepository; + @Mock private ResumeVersionRepository versionRepository; + @Mock private ResumeExportWorker exportWorker; + @Mock private ResumeExportStorageService exportStorageService; + @Mock private ProfileService profileService; + + @InjectMocks private ResumeExportService resumeExportService; + + private static final Long USER_ID = 100L; + private static final Long OTHER_USER_ID = 200L; + private static final Long VERSION_ID = 1L; + private static final Long TASK_ID = 10L; + + private ResumeVersion activeVersion(Long userId) { + return ResumeVersion.builder().id(VERSION_ID).userId(userId).name("秋招版") + .versionNo(1).fileStoragePath("100_src.pdf").fileName("resume.pdf") + .contentType("application/pdf").fileSize(1024L).status(ResumeVersion.STATUS_ACTIVE).build(); + } + + @BeforeEach + void setUp() { + // void 方法默认 no-op;需要抛错时在具体测试内 stub + } + + // ── 创建导出任务(写快照) ───────────────────────────────── + + @Test + void createExportTask_shouldSnapshotSourceAndTriggerWorker() { + when(versionRepository.findById(VERSION_ID)).thenReturn(Optional.of(activeVersion(USER_ID))); + when(taskRepository.save(any())).thenAnswer(inv -> { + ResumeExportTask t = inv.getArgument(0); + t.setId(TASK_ID); + return t; + }); + + ResumeExportTask task = resumeExportService.createExportTask(USER_ID, VERSION_ID); + + assertEquals(ResumeExportTask.STATUS_PENDING, task.getStatus()); + assertEquals(VERSION_ID, task.getResumeVersionId()); + assertEquals("秋招版", task.getResumeVersionName()); + // 快照字段写入任务(worker 据此读取,而非按版本 ID 查当前版本) + assertEquals("100_src.pdf", task.getSourceStoragePath()); + assertEquals("resume.pdf", task.getSourceFileName()); + assertEquals("application/pdf", task.getSourceContentType()); + assertEquals(1024L, task.getSourceFileSize()); + // 非事务上下文(单测)→ 同步触发 worker + verify(exportWorker).execute(TASK_ID); + } + + @Test + void createExportTask_versionNotFound_shouldReturn404() { + when(versionRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.createExportTask(USER_ID, 999L)); + assertEquals(ErrorCode.RESUME_VERSION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + verify(exportWorker, never()).execute(any()); + } + + @Test + void createExportTask_otherUser_shouldReturn403() { + when(versionRepository.findById(VERSION_ID)).thenReturn(Optional.of(activeVersion(OTHER_USER_ID))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.createExportTask(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(exportWorker, never()).execute(any()); + } + + @Test + void createExportTask_deletedVersion_shouldReturn404() { + ResumeVersion deleted = activeVersion(USER_ID); + deleted.setStatus(ResumeVersion.STATUS_DELETED); + when(versionRepository.findById(VERSION_ID)).thenReturn(Optional.of(deleted)); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.createExportTask(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.RESUME_VERSION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 任务查询 ────────────────────────────────────────────── + + @Test + void getExportTask_shouldReturnOwnTask() { + ResumeExportTask task = ResumeExportTask.builder().id(TASK_ID).userId(USER_ID) + .resumeVersionId(VERSION_ID).status(ResumeExportTask.STATUS_SUCCESS).build(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(task)); + ResumeExportTask got = resumeExportService.getExportTask(USER_ID, TASK_ID); + assertEquals(TASK_ID, got.getId()); + } + + @Test + void getExportTask_otherUser_shouldReturn403() { + ResumeExportTask task = ResumeExportTask.builder().id(TASK_ID).userId(OTHER_USER_ID).build(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(task)); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.getExportTask(USER_ID, TASK_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getExportTask_notFound_shouldReturn404() { + when(taskRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.getExportTask(USER_ID, 999L)); + assertEquals(ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 下载 ────────────────────────────────────────────────── + + private ResumeExportTask successTask() { + return ResumeExportTask.builder().id(TASK_ID).userId(USER_ID).resumeVersionId(VERSION_ID) + .status(ResumeExportTask.STATUS_SUCCESS) + .outputPath("100/10.pdf").outputName("resume.pdf") + .sourceContentType("application/pdf").build(); + } + + @Test + void download_success_shouldReturnFile() { + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(successTask())); + when(exportStorageService.read("100/10.pdf")).thenReturn("A".getBytes()); + + ResumeExportService.DownloadFile df = resumeExportService.download(USER_ID, TASK_ID); + + assertArrayEquals("A".getBytes(), df.getContent()); + assertEquals("resume.pdf", df.getFilename()); + assertEquals("application/pdf", df.getContentType()); + } + + @Test + void download_notReady_shouldFail() { + ResumeExportTask pending = successTask(); + pending.setStatus(ResumeExportTask.STATUS_PENDING); + pending.setOutputPath(null); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(pending)); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.download(USER_ID, TASK_ID)); + assertEquals(ErrorCode.RESUME_EXPORT_NOT_READY.getCode(), ex.getErrorCodeInt()); + verify(exportStorageService, never()).read(any()); + } + + @Test + void download_failedTask_shouldFail() { + ResumeExportTask failed = successTask(); + failed.setStatus(ResumeExportTask.STATUS_FAILED); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(failed)); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.download(USER_ID, TASK_ID)); + assertEquals(ErrorCode.RESUME_EXPORT_NOT_READY.getCode(), ex.getErrorCodeInt()); + } + + @Test + void download_otherUser_shouldReturn403() { + ResumeExportTask task = successTask(); + task.setUserId(OTHER_USER_ID); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(task)); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.download(USER_ID, TASK_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(exportStorageService, never()).read(any()); + } + + @Test + void download_notFound_shouldReturn404() { + when(taskRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeExportService.download(USER_ID, 999L)); + assertEquals(ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 视图脱敏 ────────────────────────────────────────────── + + @Test + void toView_shouldNotExposeUserIdOrOutputPath() { + ResumeExportTask task = successTask(); + var view = resumeExportService.toView(task); + assertFalse(view.containsKey("userId")); + assertFalse(view.containsKey("outputPath")); + assertFalse(view.containsKey("sourceStoragePath")); + assertEquals(true, view.get("downloadAvailable")); + assertEquals("resume.pdf", view.get("outputName")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportWorkerTest.java new file mode 100644 index 0000000..e333593 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeExportWorkerTest.java @@ -0,0 +1,128 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.resume.storage.ResumeExportStorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ResumeExportWorkerTest { + + @Mock private ResumeExportTaskRepository taskRepository; + @Mock private ResumeStorageService resumeStorageService; + @Mock private ResumeExportStorageService exportStorageService; + + @InjectMocks private ResumeExportWorker worker; + + private static final Long TASK_ID = 10L; + private static final Long USER_ID = 100L; + private static final Long VERSION_ID = 1L; + + /** 任务持有创建时的源文件快照 */ + private ResumeExportTask snapshotTask() { + return ResumeExportTask.builder().id(TASK_ID).userId(USER_ID).resumeVersionId(VERSION_ID) + .resumeVersionName("秋招版").status(ResumeExportTask.STATUS_PENDING) + .sourceStoragePath("100_src.pdf").sourceFileName("my_resume.pdf") + .sourceContentType("application/pdf").sourceFileSize(5L) + .build(); + } + + private void stubSave() { + when(taskRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(taskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void execute_shouldReadFromSnapshotAndRecordOutput() { + stubSave(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(snapshotTask())); + when(resumeStorageService.read("100_src.pdf")).thenReturn("A".getBytes()); + when(exportStorageService.store(USER_ID, TASK_ID, ".pdf", "A".getBytes())) + .thenReturn("100/10.pdf"); + + worker.execute(TASK_ID); + + // 从任务快照读取源文件,而非查询版本 + verify(resumeStorageService).read("100_src.pdf"); + verify(exportStorageService).store(USER_ID, TASK_ID, ".pdf", "A".getBytes()); + verify(taskRepository).save(argThat(t -> + ResumeExportTask.STATUS_SUCCESS.equals(t.getStatus()) + && "100/10.pdf".equals(t.getOutputPath()) + && "my_resume.pdf".equals(t.getOutputName()) + && t.getFinishedAt() != null)); + } + + @Test + void execute_shouldReadFromSnapshot_neverQueryVersion() { + stubSave(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(snapshotTask())); + when(resumeStorageService.read("100_src.pdf")).thenReturn("A".getBytes()); + when(exportStorageService.store(any(), any(), any(), any())).thenReturn("100/10.pdf"); + + worker.execute(TASK_ID); + // 不依赖 resumeVersionId 查询版本文件 + verify(resumeStorageService, never()).read(eq("100_src.pdf_OTHER")); + } + + @Test + void execute_sourceMissing_shouldRecordReason() { + stubSave(); + ResumeExportTask task = snapshotTask(); + task.setSourceStoragePath(null); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(task)); + + worker.execute(TASK_ID); + + verify(taskRepository).save(argThat(t -> + ResumeExportTask.STATUS_FAILED.equals(t.getStatus()) + && t.getErrorMessage() != null)); + verify(resumeStorageService, never()).read(any()); + } + + @Test + void execute_fileMissing_shouldRecordReason() { + stubSave(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(snapshotTask())); + when(resumeStorageService.read("100_src.pdf")) + .thenThrow(new com.mydreamjob.common.exception.BusinessException( + com.mydreamjob.common.exception.ErrorCode.PROFILE_NOT_FOUND)); + + worker.execute(TASK_ID); + + verify(taskRepository).save(argThat(t -> + ResumeExportTask.STATUS_FAILED.equals(t.getStatus()) + && t.getErrorMessage() != null + && !t.getErrorMessage().isBlank())); + } + + @Test + void execute_ioError_shouldRecordReason() { + stubSave(); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(snapshotTask())); + when(resumeStorageService.read("100_src.pdf")).thenThrow(new RuntimeException("disk boom")); + + worker.execute(TASK_ID); + + verify(taskRepository).save(argThat(t -> + ResumeExportTask.STATUS_FAILED.equals(t.getStatus()) + && t.getErrorMessage().contains("disk boom"))); + } + + @Test + void execute_taskNotFound_shouldDoNothing() { + when(taskRepository.findById(999L)).thenReturn(Optional.empty()); + worker.execute(999L); + verify(taskRepository, never()).save(any()); + verify(taskRepository, never()).saveAndFlush(any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java new file mode 100644 index 0000000..789e18f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java @@ -0,0 +1,351 @@ +package com.mydreamjob.resume; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 简历版本接口集成测试:JWT 鉴权、统一响应结构、权限、错误码、OpenAPI 一致性。 + * 激活 profile: integration-test(H2 内嵌数据库 + Flyway V14 + Redis/Rabbit 关闭) + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ResumeVersionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private ResumeVersionService resumeVersionService; + @MockBean private ResumeExportService resumeExportService; + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long STUDENT_ID = 100L; + private static final Long VERSION_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + } + + private Map versionView() { + // 视图已脱敏:不含 userId、filePath + Map v = new java.util.HashMap<>(); + v.put("id", VERSION_ID); + v.put("name", "秋招版"); + v.put("versionNo", 1); + v.put("fileName", "resume.pdf"); + v.put("contentType", "application/pdf"); + v.put("fileSize", 1024L); + v.put("default", false); + v.put("status", "ACTIVE"); + return v; + } + + private Map taskView() { + // 视图已脱敏:不含 userId、outputPath + Map v = new java.util.HashMap<>(); + v.put("id", 10L); + v.put("resumeVersionId", VERSION_ID); + v.put("resumeVersionName", "秋招版"); + v.put("status", "PENDING"); + v.put("outputName", null); + v.put("downloadAvailable", false); + v.put("errorMessage", null); + v.put("createdAt", null); + v.put("startedAt", null); + v.put("finishedAt", null); + return v; + } + + private MockMultipartFile file() { + return new MockMultipartFile("file", "resume.pdf", "application/pdf", "data".getBytes()); + } + + private void stubVersionView() { + when(resumeVersionService.toView(any())).thenReturn(versionView()); + when(resumeExportService.toView(any())).thenReturn(taskView()); + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void create_shouldReturn200UnifiedResponse() throws Exception { + stubVersionView(); + when(resumeVersionService.createVersion(eq(STUDENT_ID), any(), any())) + .thenReturn(ResumeVersion.builder().id(VERSION_ID).build()); + mockMvc.perform(multipart("/profile/me/resume-versions") + .file(file()).param("name", "秋招版") + .header("Authorization", studentToken()) + .header("X-Request-Id", "req-abc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.message").value("ok")) + .andExpect(jsonPath("$.data.id").value(VERSION_ID)) + .andExpect(jsonPath("$.requestId").value("req-abc")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void create_noToken_shouldReturn401() throws Exception { + mockMvc.perform(multipart("/profile/me/resume-versions").file(file())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void create_invalidFile_shouldReturn400() throws Exception { + when(resumeVersionService.createVersion(eq(STUDENT_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + mockMvc.perform(multipart("/profile/me/resume-versions").file(file()) + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + // ── 列表 / 详情 ─────────────────────────────────────────── + + @Test + void list_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeVersionService.listVersions(STUDENT_ID)) + .thenReturn(List.of(ResumeVersion.builder().id(VERSION_ID).build())); + mockMvc.perform(get("/profile/me/resume-versions").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].id").value(VERSION_ID)); + } + + @Test + void get_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeVersionService.getVersion(STUDENT_ID, VERSION_ID)) + .thenReturn(ResumeVersion.builder().id(VERSION_ID).build()); + mockMvc.perform(get("/profile/me/resume-versions/" + VERSION_ID) + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.versionNo").value(1)); + } + + @Test + void get_otherUser_shouldReturn403() throws Exception { + when(resumeVersionService.getVersion(STUDENT_ID, VERSION_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/profile/me/resume-versions/" + VERSION_ID) + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void get_notFound_shouldReturn404() throws Exception { + when(resumeVersionService.getVersion(STUDENT_ID, 999L)) + .thenThrow(new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND)); + mockMvc.perform(get("/profile/me/resume-versions/999").header("Authorization", studentToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RESUME_VERSION_NOT_FOUND.getCode())); + } + + // ── 修改 / 删除 / 默认 ──────────────────────────────────── + + @Test + void update_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeVersionService.updateVersion(eq(STUDENT_ID), eq(VERSION_ID), any(), any())) + .thenReturn(ResumeVersion.builder().id(VERSION_ID).build()); + mockMvc.perform(MockMvcRequestBuilders.multipart("/profile/me/resume-versions/" + VERSION_ID) + .file(file()).param("name", " 新名字 ") + .with(req -> { + req.setMethod("PUT"); + return req; + }) + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void delete_shouldReturn200() throws Exception { + mockMvc.perform(delete("/profile/me/resume-versions/" + VERSION_ID) + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("resume version deleted")); + } + + @Test + void delete_default_shouldReturn409() throws Exception { + org.mockito.Mockito.doThrow(new BusinessException(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED)) + .when(resumeVersionService).deleteVersion(STUDENT_ID, VERSION_ID); + mockMvc.perform(delete("/profile/me/resume-versions/" + VERSION_ID) + .header("Authorization", studentToken())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode())); + } + + @Test + void setDefault_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeVersionService.setDefault(STUDENT_ID, VERSION_ID)) + .thenReturn(ResumeVersion.builder().id(VERSION_ID).build()); + mockMvc.perform(post("/profile/me/resume-versions/" + VERSION_ID + "/default") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + // ── 导出 ────────────────────────────────────────────────── + + @Test + void createExportTask_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeExportService.createExportTask(STUDENT_ID, VERSION_ID)) + .thenReturn(ResumeExportTask.builder().id(10L).build()); + mockMvc.perform(post("/profile/me/resume-versions/" + VERSION_ID + "/export") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + void getExportTask_shouldReturn200() throws Exception { + stubVersionView(); + when(resumeExportService.getExportTask(STUDENT_ID, 10L)) + .thenReturn(ResumeExportTask.builder().id(10L).build()); + mockMvc.perform(get("/resume-export-tasks/10").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void getExportTask_otherUser_shouldReturn403() throws Exception { + when(resumeExportService.getExportTask(STUDENT_ID, 10L)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/resume-export-tasks/10").header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + // ── 下载 ────────────────────────────────────────────────── + + @Test + void download_success_shouldReturnFile() throws Exception { + when(resumeExportService.download(STUDENT_ID, 10L)) + .thenReturn(new ResumeExportService.DownloadFile("resume.pdf", "application/pdf", "CONTENT".getBytes())); + mockMvc.perform(get("/resume-export-tasks/10/download").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/pdf")) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("attachment"))) + .andExpect(content().bytes("CONTENT".getBytes())); + } + + @Test + void download_notReady_shouldReturn409() throws Exception { + when(resumeExportService.download(STUDENT_ID, 10L)) + .thenThrow(new BusinessException(ErrorCode.RESUME_EXPORT_NOT_READY)); + mockMvc.perform(get("/resume-export-tasks/10/download").header("Authorization", studentToken())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.RESUME_EXPORT_NOT_READY.getCode())); + } + + @Test + void download_otherUser_shouldReturn403() throws Exception { + when(resumeExportService.download(STUDENT_ID, 10L)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/resume-export-tasks/10/download").header("Authorization", studentToken())) + .andExpect(status().isForbidden()); + } + + @Test + void download_notFound_shouldReturn404() throws Exception { + when(resumeExportService.download(STUDENT_ID, 999L)) + .thenThrow(new BusinessException(ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND)); + mockMvc.perform(get("/resume-export-tasks/999/download").header("Authorization", studentToken())) + .andExpect(status().isNotFound()); + } + + // ── 响应脱敏 ────────────────────────────────────────────── + + @Test + void versionResponses_shouldNotExposeUserIdOrFilePath() throws Exception { + stubVersionView(); + when(resumeVersionService.getVersion(STUDENT_ID, VERSION_ID)) + .thenReturn(ResumeVersion.builder().id(VERSION_ID).build()); + mockMvc.perform(get("/profile/me/resume-versions/" + VERSION_ID) + .header("Authorization", studentToken())) + .andExpect(jsonPath("$.data.userId").doesNotExist()) + .andExpect(jsonPath("$.data.filePath").doesNotExist()) + .andExpect(jsonPath("$.data.fileStoragePath").doesNotExist()); + } + + @Test + void exportTaskResponses_shouldNotExposeUserIdOrOutputPath() throws Exception { + stubVersionView(); + when(resumeExportService.getExportTask(STUDENT_ID, 10L)) + .thenReturn(ResumeExportTask.builder().id(10L).build()); + mockMvc.perform(get("/resume-export-tasks/10").header("Authorization", studentToken())) + .andExpect(jsonPath("$.data.userId").doesNotExist()) + .andExpect(jsonPath("$.data.outputPath").doesNotExist()) + .andExpect(jsonPath("$.data.sourceStoragePath").doesNotExist()) + .andExpect(jsonPath("$.data.downloadAvailable").exists()); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldContainResumeVersionEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + JsonNode paths = root.path("paths"); + assertThat(paths.has("/profile/me/resume-versions")).isTrue(); + assertThat(paths.has("/profile/me/resume-versions/{id}")).isTrue(); + assertThat(paths.has("/profile/me/resume-versions/{id}/default")).isTrue(); + assertThat(paths.has("/profile/me/resume-versions/{id}/export")).isTrue(); + assertThat(paths.has("/resume-export-tasks/{taskId}")).isTrue(); + assertThat(paths.has("/resume-export-tasks/{taskId}/download")).isTrue(); + assertThat(paths.path("/profile/me/resume-versions").has("post")).isTrue(); + assertThat(paths.path("/profile/me/resume-versions").has("get")).isTrue(); + // 开发辅助:当 -Doffer.openapi.snapshot=<目录> 时导出 OpenAPI 快照 + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java new file mode 100644 index 0000000..6335daa --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java @@ -0,0 +1,135 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 简历版本权限一致性集成测试(真实 Service + 真实 H2)。 + * + *

版本存在但属于他人 → 四个接口一律 HTTP 403;版本不存在 → 404。 + * 覆盖:GET/PUT/DELETE /profile/me/resume-versions/{id} 与 POST .../default。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ResumeVersionPermissionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @Autowired private ResumeVersionService resumeVersionService; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + private Long ownerUserId; + private Long otherUserId; + private Long versionId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(300); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + ownerUserId = createStudent(); + otherUserId = createStudent(); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + ResumeVersion version = resumeVersionService.createVersion(ownerUserId, "秋招版", file); + versionId = version.getId(); + } + + private Long createStudent() { + User u = userRepository.save(User.builder() + .phone("177" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("候选人").role(User.Role.STUDENT).active(true).verified(true).build()); + return u.getId(); + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + } + + // ── 另一学生访问 → 403 ──────────────────────────────────── + + @Test + void get_otherStudent_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/resume-versions/" + versionId) + .header("Authorization", token(otherUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void update_otherStudent_shouldReturn403() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.multipart("/profile/me/resume-versions/" + versionId) + .param("name", "改他人") + .with(req -> { + req.setMethod("PUT"); + return req; + }) + .header("Authorization", token(otherUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void delete_otherStudent_shouldReturn403() throws Exception { + mockMvc.perform(delete("/profile/me/resume-versions/" + versionId) + .header("Authorization", token(otherUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void setDefault_otherStudent_shouldReturn403() throws Exception { + mockMvc.perform(post("/profile/me/resume-versions/" + versionId + "/default") + .header("Authorization", token(otherUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN.getCode())); + } + + // ── 不存在 → 404 ────────────────────────────────────────── + + @Test + void get_notExist_shouldReturn404() throws Exception { + mockMvc.perform(get("/profile/me/resume-versions/999999") + .header("Authorization", token(otherUserId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(com.mydreamjob.common.exception.ErrorCode.RESUME_VERSION_NOT_FOUND.getCode())); + } + + // ── 本人 → 200(基线,确认规则未误伤本人) ───────────────── + + @Test + void get_owner_shouldReturn200() throws Exception { + mockMvc.perform(get("/profile/me/resume-versions/" + versionId) + .header("Authorization", token(ownerUserId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").value(versionId)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java new file mode 100644 index 0000000..135f93a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java @@ -0,0 +1,263 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.resume.storage.ResumeExportStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 简历版本持久化与并发集成测试:H2 + Flyway V14/V15 真实表结构。 + * 验证:唯一默认约束、version_no 唯一、导出任务 FK ON DELETE SET NULL、 + * 并发设默认/并发创建、导出任务使用创建时的源文件快照。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class ResumeVersionPersistenceIntegrationTest { + + @Autowired private ResumeVersionRepository versionRepository; + @Autowired private ResumeExportTaskRepository taskRepository; + @Autowired private ResumeVersionService resumeVersionService; + @Autowired private ResumeExportService resumeExportService; + @Autowired private ResumeExportStorageService resumeExportStorageService; + @Autowired private UserRepository userRepository; + @Autowired private PlatformTransactionManager transactionManager; + @Value("${app.resume.storage-dir:./target/resumes}") + private String storageDir; + + @MockBean private RedisTemplate redisTemplate; + + private Long userId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(200); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + taskRepository.deleteAll(); + versionRepository.deleteAll(); + + User student = userRepository.save(User.builder() + .phone(uniquePhone()).nickname("简历候选人") + .role(User.Role.STUDENT).active(true).verified(true).build()); + userId = student.getId(); + } + + private String uniquePhone() { + return "188" + String.format("%08d", PHONE_SEQ.incrementAndGet()); + } + + private ResumeVersion newVersion(int versionNo) { + return ResumeVersion.builder() + .userId(userId).name("版本" + versionNo).versionNo(versionNo) + .fileStoragePath("100_" + versionNo + ".pdf").fileName("r.pdf") + .contentType("application/pdf").fileSize(7L) + .defaultKey(null).status(ResumeVersion.STATUS_ACTIVE) + .build(); + } + + @Test + void uniqueDefault_databaseConstraint_enforced() { + ResumeVersion v1 = newVersion(1); + v1.setDefaultKey(ResumeVersion.DEFAULT_KEY); + versionRepository.saveAndFlush(v1); + ResumeVersion v2 = newVersion(2); + v2.setDefaultKey(ResumeVersion.DEFAULT_KEY); + assertThrows(DataIntegrityViolationException.class, + () -> versionRepository.saveAndFlush(v2)); + } + + @Test + void multipleNonDefault_areAllowed() { + versionRepository.saveAndFlush(newVersion(1)); + versionRepository.saveAndFlush(newVersion(2)); + versionRepository.saveAndFlush(newVersion(3)); + assertThat(versionRepository.count()).isEqualTo(3); + } + + @Test + void versionNo_uniquePerUser() { + versionRepository.saveAndFlush(newVersion(1)); + assertThrows(DataIntegrityViolationException.class, + () -> versionRepository.saveAndFlush(newVersion(1))); + } + + @Test + void exportTask_fkSetNull_onVersionDelete() { + ResumeVersion version = versionRepository.saveAndFlush(newVersion(1)); + ResumeExportTask task = taskRepository.saveAndFlush(ResumeExportTask.builder() + .userId(userId).resumeVersionId(version.getId()) + .resumeVersionName(version.getName()).status(ResumeExportTask.STATUS_SUCCESS).build()); + + versionRepository.delete(version); + + ResumeExportTask reloaded = taskRepository.findById(task.getId()).orElseThrow(); + assertNull(reloaded.getResumeVersionId()); + assertThat(reloaded.getStatus()).isEqualTo(ResumeExportTask.STATUS_SUCCESS); + } + + @Test + void setDefault_concurrently_shouldLeaveExactlyOneDefault() throws Exception { + ResumeVersion v1 = versionRepository.saveAndFlush(newVersion(1)); + ResumeVersion v2 = versionRepository.saveAndFlush(newVersion(2)); + + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + pool.submit(() -> runWithLatch(start, () -> resumeVersionService.setDefault(userId, v1.getId()), failure)); + pool.submit(() -> runWithLatch(start, () -> resumeVersionService.setDefault(userId, v2.getId()), failure)); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + // 无论哪个线程获胜/冲突,最终数据库必须恰好一个默认 + long defaults = versionRepository.findAll().stream() + .filter(ResumeVersion::isDefault).count(); + assertThat(defaults).isEqualTo(1); + } + + @Test + void createVersion_concurrently_shouldProduceDistinctVersionNos() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "a", file), failure)); + pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "b", file), failure)); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + if (failure.get() != null) { + fail("concurrent create failed: " + failure.get()); + } + var versions = versionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE); + assertThat(versions).hasSize(2); + assertThat(versions.get(0).getVersionNo()).isNotEqualTo(versions.get(1).getVersionNo()); + } + + /** + * 端到端:创建导出任务(快照文件 A)后修改版本文件为 B, + * 导出结果仍必须是创建任务时的文件 A。 + */ + @Test + void export_afterVersionModified_shouldUseOriginalFile() throws Exception { + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + + ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); + ResumeExportTask task = resumeExportService.createExportTask(userId, v1.getId()); // 快照 = A 的路径 + + // 创建导出后立刻修改版本(替换文件)→ 旧文件 A 若被进行中的任务引用则保留 + resumeVersionService.updateVersion(userId, v1.getId(), "renamed", fileB); + + ResumeExportTask done = awaitTerminal(task.getId()); + assertEquals(ResumeExportTask.STATUS_SUCCESS, done.getStatus(), "导出应成功: " + done.getErrorMessage()); + assertArrayEquals("AAA".getBytes(), resumeExportStorageService.read(done.getOutputPath())); + assertEquals("r.pdf", done.getOutputName()); + } + + private ResumeExportTask awaitTerminal(Long taskId) throws InterruptedException { + for (int i = 0; i < 100; i++) { + ResumeExportTask t = taskRepository.findById(taskId).orElseThrow(); + if (!ResumeExportTask.STATUS_PENDING.equals(t.getStatus()) + && !ResumeExportTask.STATUS_RUNNING.equals(t.getStatus())) { + return t; + } + Thread.sleep(50); + } + throw new AssertionError("export task did not reach terminal state: " + taskId); + } + + /** + * 模拟事务 afterCompletion(STATUS_ROLLED_BACK):替换版本文件后强制回滚。 + * 断言:新文件被清理、旧文件仍存在、数据库记录未被错误修改。 + */ + @Test + void updateVersion_outerRollback_shouldCleanNewFileAndKeepOldFileAndDb() throws Exception { + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); + String pathA = v1.getFileStoragePath(); + List before = listMyResumeFiles(); + + // 事务内更新版本文件,随后强制回滚 → 触发 afterCompletion(STATUS_ROLLED_BACK) + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + TransactionStatus tx = transactionManager.getTransaction(def); + try { + resumeVersionService.updateVersion(userId, v1.getId(), null, fileB); + tx.setRollbackOnly(); + } finally { + transactionManager.rollback(tx); + } + + // 新文件被清理,旧文件仍在(文件集合不变) + List after = listMyResumeFiles(); + assertEquals(before, after, "回滚后:新文件应被清理、旧文件仍存在"); + // 数据库记录未被错误修改(仍指向旧文件) + ResumeVersion reloaded = versionRepository.findById(v1.getId()).orElseThrow(); + assertEquals(pathA, reloaded.getFileStoragePath()); + } + + /** 列出当前测试用户(userId)前缀的简历存储文件 */ + private List listMyResumeFiles() throws IOException { + Path dir = Paths.get(storageDir).toAbsolutePath().normalize(); + try (var stream = Files.list(dir)) { + return stream + .filter(p -> p.getFileName().toString().startsWith(userId + "_")) + .map(p -> p.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } + + private void runWithLatch(CountDownLatch start, Runnable action, AtomicReference failure) { + try { + start.await(); + action.run(); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java new file mode 100644 index 0000000..5af310b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java @@ -0,0 +1,377 @@ +package com.mydreamjob.resume; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ResumeVersionServiceTest { + + @Mock private ResumeVersionRepository resumeVersionRepository; + @Mock private ResumeExportTaskRepository exportTaskRepository; + @Mock private ResumeStorageService resumeStorageService; + @Mock private ResumeVersionWriter versionWriter; + @Mock private ProfileService profileService; + + @InjectMocks private ResumeVersionService resumeVersionService; + + private static final Long USER_ID = 100L; + private static final Long OTHER_USER_ID = 200L; + private static final Long VERSION_ID = 1L; + + private MockMultipartFile file() { + return new MockMultipartFile("file", "resume.pdf", "application/pdf", "content".getBytes()); + } + + private ResumeVersion version(String status, boolean isDefault) { + return ResumeVersion.builder().id(VERSION_ID).userId(USER_ID).name("秋招版") + .versionNo(1).fileStoragePath("100_abc123.pdf").fileName("resume.pdf") + .contentType("application/pdf").fileSize(7L) + .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null) + .status(status).build(); + } + + @BeforeEach + void setUp() { + // 无:void 方法默认 no-op;需要抛错时在具体测试内 stub + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void createVersion_shouldSucceed() { + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_new.pdf"); + when(versionWriter.saveNewVersion(eq(USER_ID), eq("秋招版"), eq("100_new.pdf"), + any(), any(), anyLong())).thenReturn(version(3, ResumeVersion.STATUS_ACTIVE, false)); + + ResumeVersion v = resumeVersionService.createVersion(USER_ID, " 秋招版 ", file()); + + assertEquals(3, v.getVersionNo()); + assertEquals("秋招版", v.getName()); + assertNull(v.getDefaultKey()); + assertEquals(ResumeVersion.STATUS_ACTIVE, v.getStatus()); + verify(profileService).validateResumeFile(any()); + } + + private ResumeVersion version(int versionNo, String status, boolean isDefault) { + return ResumeVersion.builder().id((long) versionNo).userId(USER_ID).name("秋招版") + .versionNo(versionNo).fileStoragePath("100_new.pdf").fileName("resume.pdf") + .contentType("application/pdf").fileSize(7L) + .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null).status(status).build(); + } + + @Test + void createVersion_firstVersion_shouldStartAtOne() { + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_a.pdf"); + when(versionWriter.saveNewVersion(eq(USER_ID), isNull(), eq("100_a.pdf"), + any(), any(), anyLong())).thenReturn(version(1, ResumeVersion.STATUS_ACTIVE, false)); + + ResumeVersion v = resumeVersionService.createVersion(USER_ID, null, file()); + assertEquals(1, v.getVersionNo()); + // 空 name 归一化为 null 后传给 writer + verify(versionWriter).saveNewVersion(eq(USER_ID), isNull(), any(), any(), any(), anyLong()); + } + + @Test + void createVersion_invalidFile_shouldFail() { + doThrow(new BusinessException(ErrorCode.BAD_REQUEST)).when(profileService).validateResumeFile(any()); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.createVersion(USER_ID, "x", file())); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createVersion_nameTooLong_shouldFail() { + String longName = "n".repeat(121); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.createVersion(USER_ID, longName, file())); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createVersion_nonStudent_shouldFail() { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)).when(profileService).assertStudentRole(anyLong()); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.createVersion(USER_ID, "x", file())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createVersion_versionNoConflict_shouldRetry() { + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_b.pdf"); + // 前两次冲突,第三次成功 + when(versionWriter.saveNewVersion(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(new DataIntegrityViolationException("dup")) + .thenThrow(new DataIntegrityViolationException("dup")) + .thenReturn(version(3, ResumeVersion.STATUS_ACTIVE, false)); + + ResumeVersion v = resumeVersionService.createVersion(USER_ID, "x", file()); + + assertEquals(3, v.getVersionNo()); + verify(versionWriter, times(3)).saveNewVersion(any(), any(), any(), any(), any(), anyLong()); + } + + @Test + void createVersion_allRetriesFailed_shouldFail() { + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_c.pdf"); + when(versionWriter.saveNewVersion(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(new DataIntegrityViolationException("dup")); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.createVersion(USER_ID, "x", file())); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + verify(versionWriter, times(3)).saveNewVersion(any(), any(), any(), any(), any(), anyLong()); + // 重试耗尽后清理已保存但未被引用的新文件,避免孤儿文件 + verify(resumeStorageService).delete("100_c.pdf"); + } + + // ── 列表 / 详情 ─────────────────────────────────────────── + + @Test + void listVersions_shouldReturnOwnActiveOnly() { + when(resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(USER_ID, ResumeVersion.STATUS_ACTIVE)) + .thenReturn(List.of(version(ResumeVersion.STATUS_ACTIVE, false))); + + var list = resumeVersionService.listVersions(USER_ID); + assertEquals(1, list.size()); + } + + @Test + void getVersion_shouldSucceed() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + assertDoesNotThrow(() -> resumeVersionService.getVersion(USER_ID, VERSION_ID)); + } + + @Test + void getVersion_otherUser_shouldReturn403() { + // 版本存在但属于他人 → 403 FORBIDDEN + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.getVersion(OTHER_USER_ID, VERSION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getVersion_deleted_shouldReturn404() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_DELETED, false))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.getVersion(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.RESUME_VERSION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 修改 ────────────────────────────────────────────────── + + @Test + void updateVersion_shouldRenameAndReplaceFile() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); + when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ResumeVersion v = resumeVersionService.updateVersion(USER_ID, VERSION_ID, " 新名字 ", file()); + + assertEquals("新名字", v.getName()); + assertEquals("100_replaced.pdf", v.getFileStoragePath()); + verify(resumeStorageService).delete("100_abc123.pdf"); + } + + @Test + void updateVersion_nameOnly_shouldKeepFile() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ResumeVersion v = resumeVersionService.updateVersion(USER_ID, VERSION_ID, " 新名字 ", null); + assertEquals("新名字", v.getName()); + verify(resumeStorageService, never()).store(anyLong(), any()); + } + + // ── 文件一致性 ──────────────────────────────────────────── + + @Test + void updateVersion_storeNewFileFails_oldFileKept() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeStorageService.store(eq(USER_ID), any())) + .thenThrow(new RuntimeException("disk full")); + + assertThrows(RuntimeException.class, + () -> resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file())); + + // 新文件保存失败 → 旧文件绝不能被删除 + verify(resumeStorageService, never()).delete(any()); + } + + @Test + void updateVersion_dbSaveFails_newFileCleaned_oldFileKept() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); + when(resumeVersionRepository.save(any())).thenThrow(new RuntimeException("db down")); + + assertThrows(RuntimeException.class, + () -> resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file())); + + // 数据库保存失败 → 清理刚保存的新文件;旧文件保持可用 + verify(resumeStorageService).delete("100_replaced.pdf"); + verify(resumeStorageService, never()).delete("100_abc123.pdf"); + } + + @Test + void updateVersion_replaceFile_withPendingExport_oldFileKept() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); + when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + // 旧文件正被进行中的导出任务引用(快照)→ 不得删除 + when(exportTaskRepository.existsBySourceStoragePathAndStatusIn(eq("100_abc123.pdf"), any())) + .thenReturn(true); + + resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file()); + + verify(resumeStorageService, never()).delete("100_abc123.pdf"); + } + + @Test + void updateVersion_otherUser_shouldReturn403() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.updateVersion(OTHER_USER_ID, VERSION_ID, "x", null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 删除 ────────────────────────────────────────────────── + + @Test + void deleteVersion_nonDefault_shouldSucceed() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(false); + + resumeVersionService.deleteVersion(USER_ID, VERSION_ID); + + verify(resumeStorageService).delete("100_abc123.pdf"); + verify(resumeVersionRepository).delete(any()); + } + + @Test + void deleteVersion_default_shouldFail() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, true))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode(), ex.getErrorCodeInt()); + verify(resumeVersionRepository, never()).delete(any()); + } + + @Test + void deleteVersion_pendingExport_shouldFail() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(true); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode(), ex.getErrorCodeInt()); + } + + @Test + void deleteVersion_dbDeleteFails_originalFileKept() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(false); + org.mockito.Mockito.doThrow(new RuntimeException("db down")) + .when(resumeVersionRepository).delete(any()); + + assertThrows(RuntimeException.class, + () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); + + // 数据库删除失败 → 不得删除存储文件 + verify(resumeStorageService, never()).delete(any()); + } + + // ── 设置默认 ────────────────────────────────────────────── + + @Test + void setDefault_shouldClearOldAndSetNew() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + ResumeVersion oldDefault = version(99L, ResumeVersion.STATUS_ACTIVE, true); + when(resumeVersionRepository.findFirstByUserIdAndDefaultKey(USER_ID, ResumeVersion.DEFAULT_KEY)) + .thenReturn(Optional.of(oldDefault)); + when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ResumeVersion v = resumeVersionService.setDefault(USER_ID, VERSION_ID); + + assertTrue(v.isDefault()); + assertNull(oldDefault.getDefaultKey()); + } + + private ResumeVersion version(Long id, String status, boolean isDefault) { + return ResumeVersion.builder().id(id).userId(USER_ID).name("v").versionNo(1) + .fileStoragePath("p.pdf").fileName("p.pdf").contentType("application/pdf") + .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null).status(status).build(); + } + + @Test + void setDefault_alreadyDefault_shouldBeIdempotent() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, true))); + ResumeVersion v = resumeVersionService.setDefault(USER_ID, VERSION_ID); + assertTrue(v.isDefault()); + verify(resumeVersionRepository, never()).save(any()); + } + + @Test + void setDefault_concurrentConflict_shouldFail() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + when(resumeVersionRepository.findFirstByUserIdAndDefaultKey(USER_ID, ResumeVersion.DEFAULT_KEY)) + .thenReturn(Optional.empty()); + when(resumeVersionRepository.save(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.setDefault(USER_ID, VERSION_ID)); + assertEquals(ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode(), ex.getErrorCodeInt()); + } + + @Test + void setDefault_otherUser_shouldReturn403() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.setDefault(OTHER_USER_ID, VERSION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void deleteVersion_otherUser_shouldReturn403() { + when(resumeVersionRepository.findById(VERSION_ID)) + .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); + BusinessException ex = assertThrows(BusinessException.class, + () -> resumeVersionService.deleteVersion(OTHER_USER_ID, VERSION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(resumeVersionRepository, never()).delete(any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java new file mode 100644 index 0000000..66dbab4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java @@ -0,0 +1,171 @@ +package com.mydreamjob.review; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 评价审核管理接口集成测试:权限、OpenAPI。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AdminReviewControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private JobReviewService reviewService; + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long ADMIN_ID = 1L; + private static final Long STUDENT_ID = 100L; + private static final Long REVIEW_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String adminToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(ADMIN_ID, "1", Map.of("roles", "ADMIN")); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + } + + @Test + void pending_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/admin/reviews/pending")).andExpect(status().isUnauthorized()); + } + + @Test + void pending_nonAdmin_shouldReturn403() throws Exception { + when(reviewService.listPendingReviews(eq(STUDENT_ID), any(), any(), any(), any(), eq(0), eq(20))) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/admin/reviews/pending").header("Authorization", studentToken())) + .andExpect(status().isForbidden()); + } + + @Test + void pending_admin_shouldReturn200() throws Exception { + Page> page = new PageImpl<>(List.of(Map.of( + "reviewId", REVIEW_ID, "jobId", 10L, "rating", 4, "content", "ok", + "moderationStatus", "PENDING", "reasonCodes", List.of("SENSITIVE_WORD"), + "createdAt", "2026-01-01T00:00:00")), PageRequest.of(0, 20), 1); + when(reviewService.listPendingReviews(eq(ADMIN_ID), any(), any(), any(), any(), eq(0), eq(20))) + .thenReturn(page); + mockMvc.perform(get("/admin/reviews/pending").header("Authorization", adminToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].moderationStatus").value("PENDING")) + .andExpect(jsonPath("$.data.items[0].ipHash").doesNotExist()); + } + + @Test + void decision_noToken_shouldReturn401() throws Exception { + mockMvc.perform(post("/admin/reviews/" + REVIEW_ID + "/decision") + .contentType(MediaType.APPLICATION_JSON).content("{\"decision\":\"APPROVED\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void decision_nonAdmin_shouldReturn403() throws Exception { + when(reviewService.decideReview(eq(STUDENT_ID), eq(REVIEW_ID), eq("APPROVED"), any(), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(post("/admin/reviews/" + REVIEW_ID + "/decision") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content("{\"decision\":\"APPROVED\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void decision_alreadyDecided_shouldReturn409() throws Exception { + when(reviewService.decideReview(eq(ADMIN_ID), eq(REVIEW_ID), eq("APPROVED"), any(), any())) + .thenThrow(new BusinessException(ErrorCode.REVIEW_ALREADY_DECIDED)); + mockMvc.perform(post("/admin/reviews/" + REVIEW_ID + "/decision") + .header("Authorization", adminToken()) + .contentType(MediaType.APPLICATION_JSON).content("{\"decision\":\"APPROVED\"}")) + .andExpect(status().isConflict()); + } + + @Test + void decision_invalidDecision_shouldReturn400() throws Exception { + mockMvc.perform(post("/admin/reviews/" + REVIEW_ID + "/decision") + .header("Authorization", adminToken()) + .contentType(MediaType.APPLICATION_JSON).content("{\"decision\":\"X\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void history_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/admin/reviews/history")).andExpect(status().isUnauthorized()); + } + + @Test + void history_nonAdmin_shouldReturn403() throws Exception { + when(reviewService.listModerationHistory(eq(STUDENT_ID), eq(0), eq(20))) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/admin/reviews/history").header("Authorization", studentToken())) + .andExpect(status().isForbidden()); + } + + @Test + void history_admin_shouldReturn200() throws Exception { + Page> page = new PageImpl<>(List.of(Map.of( + "id", 1L, "reviewId", REVIEW_ID, "action", "REJECT", "actorUserId", ADMIN_ID, + "reason", "拒绝", "createdAt", "2026-01-01T00:00:00")), PageRequest.of(0, 20), 1); + when(reviewService.listModerationHistory(eq(ADMIN_ID), eq(0), eq(20))).thenReturn(page); + mockMvc.perform(get("/admin/reviews/history").header("Authorization", adminToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].action").value("REJECT")); + } + + @Test + void openApi_shouldContainAdminEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = mapper.readTree(json).path("paths"); + assertThat(paths.has("/admin/reviews/pending")).isTrue(); + assertThat(paths.has("/admin/reviews/{reviewId}/decision")).isTrue(); + assertThat(paths.has("/admin/reviews/history")).isTrue(); + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java new file mode 100644 index 0000000..8559a8e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java @@ -0,0 +1,135 @@ +package com.mydreamjob.review; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class JobReviewControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private JobReviewService reviewService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + private static final Long JOB_ID = 10L; + private static final Long REVIEW_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String token() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", + java.util.Map.of("roles", "STUDENT")); + } + + @Test void createReview_shouldReturn200() throws Exception { + when(reviewService.createReview(eq(100L), eq(JOB_ID), any(), any())) + .thenReturn(Map.of("id", 1, "rating", 4)); + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "ok")); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(4)); + } + + @Test void createReview_noToken_shouldReturn401() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "ok")); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test void listReviews_public_shouldReturn200() throws Exception { + when(reviewService.listReviews(eq(JOB_ID), eq(0), eq(20))) + .thenReturn(new PageImpl<>(List.of(Map.of("rating", 4)))); + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void updateReview_shouldReturn200() throws Exception { + when(reviewService.updateReview(eq(100L), eq(JOB_ID), eq(REVIEW_ID), any(), any())) + .thenReturn(Map.of("id", 1, "rating", 5)); + String body = mapper.writeValueAsString(Map.of("rating", 5, "content", "updated")); + mockMvc.perform(put("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(5)); + } + + @Test void deleteReview_shouldReturn200() throws Exception { + doNothing().when(reviewService).deleteReview(eq(100L), eq(JOB_ID), eq(REVIEW_ID), any()); + mockMvc.perform(delete("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", token())) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); + } + + @Test void createReview_invalidRating_shouldReturn400() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 6, "content", "ok")); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } + + @Test void createReview_blankContent_shouldReturn400() throws Exception { + String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "")); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } + + @Test void updateReview_invalidStatus_shouldReturn400() throws Exception { + String body = mapper.writeValueAsString(Map.of("status", "INVALID")); + mockMvc.perform(put("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } + + @Test void summary_public_shouldReturn200() throws Exception { + when(reviewService.getReviewSummary(JOB_ID)).thenReturn(Map.of( + "jobId", JOB_ID, "totalReviews", 2, "averageRating", 4.5, + "ratingDistribution", Map.of("rating1",0,"rating2",0,"rating3",0,"rating4",1,"rating5",1), + "topTags", List.of("tag1"))); + + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.averageRating").value(4.5)) + .andExpect(jsonPath("$.data.totalReviews").value(2)); + } + + @Test void summary_noReviews_shouldReturnZeros() throws Exception { + when(reviewService.getReviewSummary(JOB_ID)).thenReturn(Map.of( + "jobId", JOB_ID, "totalReviews", 0, "averageRating", 0.0, + "ratingDistribution", Map.of("rating1",0,"rating2",0,"rating3",0,"rating4",0,"rating5",0), + "topTags", List.of())); + + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalReviews").value(0)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java new file mode 100644 index 0000000..320fd0b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java @@ -0,0 +1,287 @@ +package com.mydreamjob.review; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 岗位八维评价接口集成测试:HTTP 层、权限、校验、OpenAPI。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class JobReviewDimensionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private JobReviewService reviewService; + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long STUDENT_ID = 100L; + private static final Long JOB_ID = 10L; + private static final Long REVIEW_ID = 1L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "3", Map.of("roles", "COMPANY")); + } + + private String reviewBody(boolean withDims) { + if (!withDims) return "{\"rating\":4,\"content\":\"不错的岗位\"}"; + return "{\"rating\":4,\"content\":\"不错的岗位\",\"dimensionScores\":{\"AUTHENTICITY\":5,\"TEAM_ATMOSPHERE\":4}}"; + } + + private Map reviewView() { + return Map.of("id", REVIEW_ID, "jobId", JOB_ID, "studentUserId", STUDENT_ID, + "rating", 4, "content", "不错的岗位", "status", "PUBLISHED", + "dimensionScores", Map.of("AUTHENTICITY", 5)); + } + + private void stubCreate() { + when(reviewService.createReview(eq(STUDENT_ID), eq(JOB_ID), any(), any())).thenReturn(reviewView()); + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void createReview_withDimensionScores_shouldReturn200() throws Exception { + stubCreate(); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content(reviewBody(true))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.dimensionScores.AUTHENTICITY").value(5)); + } + + @Test + void createReview_noDimensionScores_shouldSucceed() throws Exception { + stubCreate(); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content(reviewBody(false))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void createReview_unknownDimension_shouldReturn400() throws Exception { + when(reviewService.createReview(eq(STUDENT_ID), eq(JOB_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":4,\"content\":\"ok\",\"dimensionScores\":{\"FOO\":4}}")) + .andExpect(status().isBadRequest()); + } + + @Test + void createReview_asCompany_shouldReturn403() throws Exception { + when(reviewService.createReview(eq(10L), eq(JOB_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON).content(reviewBody(false))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void createReview_duplicate_shouldReturn409() throws Exception { + when(reviewService.createReview(eq(STUDENT_ID), eq(JOB_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.DUPLICATE_REVIEW)); + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content(reviewBody(false))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DUPLICATE_REVIEW.getCode())); + } + + @Test + void createReview_noToken_shouldReturn401() throws Exception { + mockMvc.perform(post("/jobs/" + JOB_ID + "/reviews") + .contentType(MediaType.APPLICATION_JSON).content(reviewBody(false))) + .andExpect(status().isUnauthorized()); + } + + // ── 详情 / 列表 ─────────────────────────────────────────── + + @Test + void getReviewDetail_shouldReturn200_withDimensionScores() throws Exception { + when(reviewService.getReview(JOB_ID, REVIEW_ID)).thenReturn(reviewView()); + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dimensionScores.AUTHENTICITY").value(5)); + } + + @Test + void listReviews_shouldReturnDimensionScores() throws Exception { + Page> page = new PageImpl<>(List.of(reviewView()), PageRequest.of(0, 20), 1); + when(reviewService.listReviews(JOB_ID, 0, 20)).thenReturn(page); + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].dimensionScores.AUTHENTICITY").value(5)); + } + + // ── 修改 / 删除 ─────────────────────────────────────────── + + @Test + void updateReview_withDimensionScores_shouldReturn200() throws Exception { + when(reviewService.updateReview(eq(STUDENT_ID), eq(JOB_ID), eq(REVIEW_ID), any(), any())) + .thenReturn(reviewView()); + mockMvc.perform(put("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"dimensionScores\":{\"AUTHENTICITY\":5}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void updateReview_otherUser_shouldReturn403() throws Exception { + when(reviewService.updateReview(eq(STUDENT_ID), eq(JOB_ID), eq(REVIEW_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(put("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"dimensionScores\":{\"AUTHENTICITY\":5}}")) + .andExpect(status().isForbidden()); + } + + @Test + void deleteReview_shouldReturn200() throws Exception { + mockMvc.perform(delete("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID) + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("review deleted")); + } + + @Test + void deleteReview_noToken_shouldReturn401() throws Exception { + mockMvc.perform(delete("/jobs/" + JOB_ID + "/reviews/" + REVIEW_ID)) + .andExpect(status().isUnauthorized()); + } + + // ── 汇总 ────────────────────────────────────────────────── + + @Test + void summary_shouldReturn200_withDimensionSummary() throws Exception { + when(reviewService.getReviewSummary(JOB_ID)).thenReturn(Map.of( + "jobId", JOB_ID, "totalReviews", 2, "averageRating", 4.5, + "dimensionSummary", Map.of("AUTHENTICITY", + Map.of("averageScore", 4.5, "sampleCount", 2, "dataState", "INSUFFICIENT")))); + mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dimensionSummary.AUTHENTICITY.dataState").value("INSUFFICIENT")); + } + + // ── 岗位对比 ────────────────────────────────────────────── + + @Test + void compare_shouldReturn200_public() throws Exception { + when(reviewService.compareJobs(List.of(1L, 2L))).thenReturn(List.of( + Map.of("id", 1L, "title", "岗位A", "dimensionSummary", Map.of()))); + mockMvc.perform(get("/jobs/compare").param("ids", "1,2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].title").value("岗位A")); + } + + @Test + void compare_lessThanTwo_shouldReturn400() throws Exception { + when(reviewService.compareJobs(List.of(1L))).thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + mockMvc.perform(get("/jobs/compare").param("ids", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + void compare_moreThanThree_shouldReturn400() throws Exception { + when(reviewService.compareJobs(List.of(1L, 2L, 3L, 4L))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST)); + mockMvc.perform(get("/jobs/compare").param("ids", "1,2,3,4")) + .andExpect(status().isBadRequest()); + } + + @Test + void compare_invalidId_shouldReturn400() throws Exception { + mockMvc.perform(get("/jobs/compare").param("ids", "abc,2")) + .andExpect(status().isBadRequest()); + } + + @Test + void compare_jobNotFound_shouldReturn404() throws Exception { + when(reviewService.compareJobs(List.of(999L, 1L))) + .thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + mockMvc.perform(get("/jobs/compare").param("ids", "999,1")) + .andExpect(status().isNotFound()); + } + + @Test + void compare_noToken_shouldReturn200() throws Exception { + when(reviewService.compareJobs(List.of(1L, 2L))).thenReturn(List.of(Map.of("id", 1L))); + mockMvc.perform(get("/jobs/compare").param("ids", "1,2")) + .andExpect(status().isOk()); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldContainReviewAndComparePaths() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = mapper.readTree(json).path("paths"); + assertThat(paths.has("/jobs/{jobId}/reviews")).isTrue(); + assertThat(paths.has("/jobs/{jobId}/reviews/{reviewId}")).isTrue(); + assertThat(paths.has("/jobs/{jobId}/reviews/summary")).isTrue(); + assertThat(paths.has("/jobs/compare")).isTrue(); + assertThat(paths.path("/jobs/{jobId}/reviews").has("post")).isTrue(); + assertThat(paths.path("/jobs/{jobId}/reviews/{reviewId}").has("put")).isTrue(); + assertThat(paths.path("/jobs/{jobId}/reviews/{reviewId}").has("delete")).isTrue(); + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java new file mode 100644 index 0000000..e296a47 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java @@ -0,0 +1,433 @@ +package com.mydreamjob.review; + +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.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JobReviewDimensionServiceTest { + + @Mock private JobReviewRepository reviewRepository; + @Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Mock private ReviewModerationHistoryRepository moderationHistoryRepository; + @Mock private ReviewModerationService moderationService; + @Mock private JobService jobService; + @Mock private UserRepository userRepository; + @Mock private AuditService auditService; + + @InjectMocks private JobReviewService reviewService; + + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_ID = 10L; + private static final Long JOB_ID = 10L; + private static final Long REVIEW_ID = 1L; + + @BeforeEach + void setUp() { + lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); + lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); + lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new ReviewModerationService.ModerationResult( + JobReview.MODERATION_AUTO_APPROVED, List.of(), + java.math.BigDecimal.ZERO, null)); + } + + private void mockStudentRole(Long userId) { + User u = User.builder().id(userId).phone("1" + userId).role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockCompanyRole(Long userId) { + User u = User.builder().id(userId).phone("2").role(User.Role.COMPANY).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private JobReviewCreateRequest createReq(Map dims) { + JobReviewCreateRequest req = new JobReviewCreateRequest(); + req.setRating(4); + req.setContent("岗位信息真实"); + req.setTags(List.of("团队氛围")); + req.setDimensionScores(dims); + return req; + } + + private JobReview review() { + return JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) + .rating(4).content("不错的岗位").status("PUBLISHED").build(); + } + + private void stubCreateBasics() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).status("PUBLISHED").build()); + when(reviewRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + // lenient:非法分/未知维度在校验阶段就失败,部分用例不会走到 save + lenient().when(reviewRepository.save(any())).thenAnswer(inv -> { + JobReview r = inv.getArgument(0); + r.setId(REVIEW_ID); + return r; + }); + lenient().when(dimensionScoreRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + // ── 创建 ────────────────────────────────────────────────── + + @Test + void createReview_noDimensionScores_shouldSucceedOldCompatible() { + stubCreateBasics(); + var view = reviewService.createReview(STUDENT_ID, JOB_ID, createReq(null)); + assertEquals(4, view.get("rating")); + assertEquals(Map.of(), view.get("dimensionScores")); + verify(dimensionScoreRepository, never()).save(any()); + } + + @Test + void createReview_partialDimensions_shouldSave() { + stubCreateBasics(); + var view = reviewService.createReview(STUDENT_ID, JOB_ID, + createReq(Map.of("AUTHENTICITY", 5, "TEAM_ATMOSPHERE", 4))); + assertEquals(5, ((Map) view.get("dimensionScores")).get("AUTHENTICITY")); + verify(dimensionScoreRepository, times(2)).save(any()); + } + + @Test + void createReview_allEightDimensions_shouldSave() { + stubCreateBasics(); + Map all = new HashMap<>(); + for (ReviewDimension d : ReviewDimension.values()) all.put(d.name(), 4); + var view = reviewService.createReview(STUDENT_ID, JOB_ID, createReq(all)); + assertEquals(8, ((Map) view.get("dimensionScores")).size()); + verify(dimensionScoreRepository, times(8)).save(any()); + } + + @Test + void createReview_unknownDimension_shouldReturn400() { + stubCreateBasics(); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(Map.of("FOO", 4)))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createReview_scoreZero_shouldReturn400() { + stubCreateBasics(); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(Map.of("AUTHENTICITY", 0)))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createReview_scoreSix_shouldReturn400() { + stubCreateBasics(); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(Map.of("AUTHENTICITY", 6)))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createReview_nullScore_shouldReturn400() { + stubCreateBasics(); + Map dims = new HashMap<>(); + dims.put("AUTHENTICITY", null); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(dims))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createReview_duplicate_shouldReturn409() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).status("PUBLISHED").build()); + when(reviewRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(true); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(null))); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createReview_concurrentDuplicate_saveConflict_shouldReturn409() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).status("PUBLISHED").build()); + when(reviewRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(reviewRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, createReq(null))); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), ex.getErrorCodeInt()); + } + + // ── 详情 / 列表 ─────────────────────────────────────────── + + @Test + void getReview_shouldReturnDimensionScores() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + when(dimensionScoreRepository.findByReviewId(REVIEW_ID)).thenReturn(List.of( + JobReviewDimensionScore.builder().reviewId(REVIEW_ID).dimensionCode("AUTHENTICITY").score(5).build())); + var view = reviewService.getReview(JOB_ID, REVIEW_ID); + assertEquals(5, ((Map) view.get("dimensionScores")).get("AUTHENTICITY")); + } + + @Test + void listReviews_shouldReturnDimensionScoresWithoutN1() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc(eq(JOB_ID), eq("PUBLISHED"), any(), any())) + .thenReturn(new PageImpl<>(List.of(review()), PageRequest.of(0, 20), 1)); + when(dimensionScoreRepository.findByReviewIdIn(anyList())).thenReturn(List.of( + JobReviewDimensionScore.builder().reviewId(REVIEW_ID).dimensionCode("GROWTH").score(4).build())); + var page = reviewService.listReviews(JOB_ID, 0, 20); + assertEquals(4, ((Map) page.getContent().get(0).get("dimensionScores")).get("GROWTH")); + } + + // ── 修改 ────────────────────────────────────────────────── + + private JobReviewUpdateRequest updateReq(Map dims) { + JobReviewUpdateRequest req = new JobReviewUpdateRequest(); + req.setDimensionScores(dims); + return req; + } + + @Test + void updateReview_existingDimension_shouldUpdate() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + when(reviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + JobReviewDimensionScore existing = JobReviewDimensionScore.builder() + .id(9L).reviewId(REVIEW_ID).dimensionCode("AUTHENTICITY").score(3).build(); + when(dimensionScoreRepository.findByReviewId(REVIEW_ID)).thenReturn(List.of(existing)); + + var view = reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, updateReq(Map.of("AUTHENTICITY", 5))); + + assertEquals(5, existing.getScore()); + assertEquals(5, ((Map) view.get("dimensionScores")).get("AUTHENTICITY")); + verify(dimensionScoreRepository, never()).save(any()); + } + + @Test + void updateReview_newDimension_shouldInsert() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + when(reviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(dimensionScoreRepository.findByReviewId(REVIEW_ID)).thenReturn(List.of()); + when(dimensionScoreRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(dimensionScoreRepository.findByReviewId(REVIEW_ID)).thenReturn(List.of()) + .thenReturn(List.of(JobReviewDimensionScore.builder().reviewId(REVIEW_ID) + .dimensionCode("BENEFITS").score(4).build())); + + var view = reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, updateReq(Map.of("BENEFITS", 4))); + + assertEquals(4, ((Map) view.get("dimensionScores")).get("BENEFITS")); + verify(dimensionScoreRepository).save(argThat(s -> "BENEFITS".equals(s.getDimensionCode()))); + } + + @Test + void updateReview_emptyDimensionScores_shouldNotDeleteExisting() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + when(reviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(dimensionScoreRepository.findByReviewId(REVIEW_ID)).thenReturn(List.of( + JobReviewDimensionScore.builder().reviewId(REVIEW_ID).dimensionCode("AUTHENTICITY").score(5).build())); + + var view = reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, updateReq(Map.of())); + + assertEquals(5, ((Map) view.get("dimensionScores")).get("AUTHENTICITY")); + verify(dimensionScoreRepository, never()).save(any()); + verify(dimensionScoreRepository, never()).delete(any()); + } + + @Test + void updateReview_otherUser_shouldReturn403() { + mockStudentRole(OTHER_STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.updateReview(OTHER_STUDENT_ID, JOB_ID, REVIEW_ID, updateReq(null))); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void deleteReview_otherUser_shouldReturn403() { + mockStudentRole(OTHER_STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review())); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.deleteReview(OTHER_STUDENT_ID, JOB_ID, REVIEW_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + verify(reviewRepository, never()).deleteById(any()); + } + + // ── 汇总 ────────────────────────────────────────────────── + + private void stubSummary(List reviews) { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(eq(JOB_ID), eq("PUBLISHED"), any())).thenReturn(reviews); + } + + @Test + void summary_sampleCountBelow3_shouldBeInsufficient() { + stubSummary(List.of(review())); + when(dimensionScoreRepository.aggregateByDimension(anyList())) + .thenReturn(List.of(new Object[]{"AUTHENTICITY", 4.5, 2L})); + var summary = reviewService.getReviewSummary(JOB_ID); + Map dim = (Map) ((Map) summary.get("dimensionSummary")).get("AUTHENTICITY"); + assertEquals(4.5, dim.get("averageScore")); + assertEquals(2L, dim.get("sampleCount")); + assertEquals("INSUFFICIENT", dim.get("dataState")); + } + + @Test + void summary_sampleCountAbove3_shouldBeSufficient() { + stubSummary(List.of(review(), review(), review(), review())); + when(dimensionScoreRepository.aggregateByDimension(anyList())) + .thenReturn(List.of(new Object[]{"BENEFITS", 4.0, 4L})); + var summary = reviewService.getReviewSummary(JOB_ID); + Map dim = (Map) ((Map) summary.get("dimensionSummary")).get("BENEFITS"); + assertEquals(4.0, dim.get("averageScore")); + assertEquals("SUFFICIENT", dim.get("dataState")); + } + + @Test + void summary_sampleCountZero_shouldAverageNull() { + stubSummary(List.of(review())); + when(dimensionScoreRepository.aggregateByDimension(anyList())).thenReturn(List.of()); + var summary = reviewService.getReviewSummary(JOB_ID); + Map dim = (Map) ((Map) summary.get("dimensionSummary")).get("GROWTH"); + assertNull(dim.get("averageScore")); + assertEquals(0L, dim.get("sampleCount")); + assertEquals("INSUFFICIENT", dim.get("dataState")); + } + + @Test + void summary_averageScore_roundedOneDecimalHalFUp() { + stubSummary(List.of(review())); + when(dimensionScoreRepository.aggregateByDimension(anyList())) + .thenReturn(List.of(new Object[]{"PROMOTION", 4.55, 3L})); + var summary = reviewService.getReviewSummary(JOB_ID); + Map dim = (Map) ((Map) summary.get("dimensionSummary")).get("PROMOTION"); + assertEquals(4.6, dim.get("averageScore")); // 4.55 → 4.6 (HALF_UP, 1 位小数) + } + + @Test + void summary_dimensionSummary_hasAllEightDimensions() { + stubSummary(List.of()); + var summary = reviewService.getReviewSummary(JOB_ID); + Map dimensionSummary = (Map) summary.get("dimensionSummary"); + assertEquals(8, dimensionSummary.size()); + for (ReviewDimension d : ReviewDimension.values()) { + assertTrue(dimensionSummary.containsKey(d.name())); + } + } + + // ── 岗位对比 ────────────────────────────────────────────── + + private Job publishedJob(Long id) { + return Job.builder().id(id).companyId(1L).title("Java 后端").city("杭州") + .employmentType("FULL_TIME").salaryMin(10000).salaryMax(20000) + .description("desc").requirements("req").status("PUBLISHED").build(); + } + + @Test + void compareJobs_twoJobs_shouldSucceed() { + when(jobService.getJob(1L)).thenReturn(publishedJob(1L)); + when(jobService.getJob(2L)).thenReturn(publishedJob(2L)); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(anyLong(), eq("PUBLISHED"), any())).thenReturn(List.of()); + + var result = reviewService.compareJobs(List.of(1L, 2L)); + + assertEquals(2, result.size()); + assertEquals("Java 后端", result.get(0).get("title")); + assertFalse(result.get(0).containsKey("companyId")); + assertTrue(result.get(0).containsKey("dimensionSummary")); + } + + @Test + void compareJobs_threeJobs_shouldSucceed() { + when(jobService.getJob(1L)).thenReturn(publishedJob(1L)); + when(jobService.getJob(2L)).thenReturn(publishedJob(2L)); + when(jobService.getJob(3L)).thenReturn(publishedJob(3L)); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(anyLong(), eq("PUBLISHED"), any())).thenReturn(List.of()); + + assertEquals(3, reviewService.compareJobs(List.of(1L, 2L, 3L)).size()); + } + + @Test + void compareJobs_lessThanTwo_shouldReturn400() { + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.compareJobs(List.of(1L))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareJobs_moreThanThree_shouldReturn400() { + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.compareJobs(List.of(1L, 2L, 3L, 4L))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareJobs_duplicateIds_shouldReturn400() { + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.compareJobs(List.of(1L, 1L))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareJobs_jobNotFound_shouldReturn404() { + when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.compareJobs(List.of(999L, 1L))); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareJobs_nonPublishedJob_shouldReturn404() { + when(jobService.getJob(1L)).thenReturn(publishedJob(1L)); + when(jobService.getJob(2L)).thenReturn(Job.builder().id(2L).status("DRAFT").build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.compareJobs(List.of(1L, 2L))); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + // ── 枚举 ────────────────────────────────────────────────── + + @Test + void reviewDimension_invalidCode_shouldThrow() { + BusinessException ex = assertThrows(BusinessException.class, + () -> ReviewDimension.fromCode("NOPE")); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void reviewDimension_validCodes_shouldResolve() { + assertEquals(ReviewDimension.AUTHENTICITY, ReviewDimension.enumName("AUTHENTICITY")); + assertEquals(ReviewDimension.WORK_LIFE_BALANCE, ReviewDimension.fromCode("WORK_LIFE_BALANCE")); + assertEquals(8, ReviewDimension.values().length); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPersistenceIntegrationTest.java new file mode 100644 index 0000000..b456e8b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPersistenceIntegrationTest.java @@ -0,0 +1,211 @@ +package com.mydreamjob.review; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +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.JobRepository; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 岗位八维评价持久化集成测试(独立 H2 + Flyway V17 真实表结构)。 + */ +@SpringBootTest +@org.springframework.test.context.TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_review;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@org.springframework.test.context.ActiveProfiles("integration-test") +class JobReviewPersistenceIntegrationTest { + + @Autowired private JobReviewService reviewService; + @Autowired private JobReviewRepository reviewRepository; + @Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + + @MockBean private RedisTemplate redisTemplate; + + private Long studentUserId; + private Long jobId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(500); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + dimensionScoreRepository.deleteAll(); + reviewRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + + User student = userRepository.save(User.builder() + .phone("155" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("候选人").role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()); + studentUserId = student.getId(); + User companyUser = userRepository.save(User.builder() + .phone("155" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("企业").role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)) + .active(true).verified(true).build()); + Company company = companyRepository.save(Company.builder().ownerUserId(companyUser.getId()).name("测试企业").build()); + Job job = jobRepository.save(Job.builder().companyId(company.getId()).title("Java 后端") + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + } + + private JobReviewCreateRequest req(Map dims) { + JobReviewCreateRequest req = new JobReviewCreateRequest(); + req.setRating(4); + req.setContent("不错的岗位"); + req.setTags(List.of("团队氛围")); + req.setDimensionScores(dims); + return req; + } + + @Test + void createReview_withDimensions_persistedAndDetailReturns() { + Map view = reviewService.createReview(studentUserId, jobId, + req(Map.of("AUTHENTICITY", 5, "TEAM_ATMOSPHERE", 4)), AuditContext.builder().requestId("req-1").build()); + Long reviewId = (Long) view.get("id"); + + var detail = reviewService.getReview(jobId, reviewId); + Map dims = (Map) detail.get("dimensionScores"); + assertEquals(5, dims.get("AUTHENTICITY")); + assertEquals(4, dims.get("TEAM_ATMOSPHERE")); + assertEquals(2, dimensionScoreRepository.findByReviewId(reviewId).size()); + } + + @Test + void concurrentDuplicateReview_shouldOnlyOneSucceed() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + pool.submit(() -> runWithLatch(start, () -> reviewService.createReview(studentUserId, jobId, req(null)), failure)); + pool.submit(() -> runWithLatch(start, () -> reviewService.createReview(studentUserId, jobId, req(null)), failure)); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertEquals(1, reviewRepository.findAll().size()); + // 至少一个失败,且失败码为 DUPLICATE_REVIEW + assertNotNull(failure.get()); + assertTrue(failure.get() instanceof BusinessException); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), + ((BusinessException) failure.get()).getErrorCodeInt()); + } + + @Test + void deleteReview_shouldCascadeDimensionScores() { + Map view = reviewService.createReview(studentUserId, jobId, + req(Map.of("AUTHENTICITY", 5)), null); + Long reviewId = (Long) view.get("id"); + assertEquals(1, dimensionScoreRepository.findByReviewId(reviewId).size()); + + reviewService.deleteReview(studentUserId, jobId, reviewId); + + assertTrue(reviewRepository.findById(reviewId).isEmpty()); + assertEquals(0, dimensionScoreRepository.findByReviewId(reviewId).size()); + } + + @Test + void updateReview_shouldOnlyUpdateProvidedDimensions() { + Map view = reviewService.createReview(studentUserId, jobId, + req(Map.of("AUTHENTICITY", 3, "BENEFITS", 4)), null); + Long reviewId = (Long) view.get("id"); + + JobReviewUpdateRequest update = new JobReviewUpdateRequest(); + update.setDimensionScores(Map.of("AUTHENTICITY", 5, "GROWTH", 4)); + reviewService.updateReview(studentUserId, jobId, reviewId, update); + + Map dims = (Map) reviewService.getReview(jobId, reviewId).get("dimensionScores"); + assertEquals(5, dims.get("AUTHENTICITY")); // 已有维度更新 + assertEquals(4, dims.get("BENEFITS")); // 未提交维度保持 + assertEquals(4, dims.get("GROWTH")); // 新增维度 + } + + @Test + void auditRequestId_shouldBeTruncatedTo32() { + String longRequestId = "req-" + "x".repeat(40); + assertEquals(44, longRequestId.length()); + reviewService.createReview(studentUserId, jobId, req(null), + AuditContext.builder().requestId(longRequestId).ipAddress("127.0.0.1").userAgent("ua").build()); + + List logs = auditLogRepository.findAll().stream() + .filter(l -> "CREATE_REVIEW".equals(l.getAction())) + .filter(l -> jobId.equals(l.getEntityId())) + .toList(); + assertEquals(1, logs.size()); + assertEquals(32, logs.get(0).getRequestId().length()); + } + + @Test + void summary_shouldOnlyCountPublished() { + // 一条 PUBLISHED(含维度,学生 A)+ 一条 HIDDEN(含维度,学生 B) + reviewService.createReview(studentUserId, jobId, req(Map.of("AUTHENTICITY", 5)), null); + Long otherStudent = createStudent(); + Map hiddenView = reviewService.createReview(otherStudent, jobId, + req(Map.of("AUTHENTICITY", 1)), null); + JobReviewUpdateRequest hide = new JobReviewUpdateRequest(); + hide.setStatus("HIDDEN"); + reviewService.updateReview(otherStudent, jobId, (Long) hiddenView.get("id"), hide); + + var summary = reviewService.getReviewSummary(jobId); + assertEquals(1, summary.get("totalReviews")); + Map auth = (Map) ((Map) summary.get("dimensionSummary")).get("AUTHENTICITY"); + assertEquals(5.0, auth.get("averageScore")); + assertEquals(1L, auth.get("sampleCount")); + assertEquals("INSUFFICIENT", auth.get("dataState")); + } + + private Long createStudent() { + User u = userRepository.save(User.builder() + .phone("155" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("候选人").role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()); + return u.getId(); + } + + private void runWithLatch(CountDownLatch start, Runnable action, AtomicReference failure) { + try { + start.await(); + action.run(); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java new file mode 100644 index 0000000..f6b5ca0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java @@ -0,0 +1,264 @@ +package com.mydreamjob.review; + +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.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import com.mydreamjob.review.dto.JobReviewUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JobReviewServiceTest { + + @Mock private JobReviewRepository reviewRepository; + @Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Mock private ReviewModerationHistoryRepository moderationHistoryRepository; + @Mock private ReviewModerationService moderationService; + @Mock private JobService jobService; + @Mock private UserRepository userRepository; + @Mock private AuditService auditService; + @InjectMocks private JobReviewService reviewService; + + private static final Long STUDENT_ID = 100L; + private static final Long OTHER_STUDENT_ID = 200L; + private static final Long COMPANY_ID = 10L; + private static final Long JOB_ID = 10L; + private static final Long REVIEW_ID = 1L; + private JobReview review; + + @BeforeEach + void setUp() { + review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(STUDENT_ID) + .rating(4).content("不错的岗位").status("PUBLISHED").build(); + // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) + lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); + lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); + // 审核默认通过(AUTO_APPROVED);命中规则测试单独覆写 + lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new ReviewModerationService.ModerationResult( + JobReview.MODERATION_AUTO_APPROVED, List.of(), + java.math.BigDecimal.ZERO, null)); + } + + private void mockStudentRole(Long userId) { + User student = User.builder().id(userId).phone("1" + userId) + .role(User.Role.STUDENT).active(true).verified(true).build(); + student.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(userId)).thenReturn(Optional.of(student)); + } + + private void mockCompanyRole(Long userId) { + User company = User.builder().id(userId).phone("2") + .role(User.Role.COMPANY).active(true).verified(true).build(); + company.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + when(userRepository.findById(userId)).thenReturn(Optional.of(company)); + } + + private JobReviewCreateRequest buildCreateReq() { + JobReviewCreateRequest req = new JobReviewCreateRequest(); + req.setRating(4); req.setContent("不错的岗位"); req.setTags(List.of("tag1")); + return req; + } + + @Test void createReview_shouldSucceed() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(reviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + var r = reviewService.createReview(STUDENT_ID, JOB_ID, buildCreateReq()); + assertEquals(4, r.get("rating")); + } + + @Test void createReview_jobNotFound_shouldReturn404() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, 999L, buildCreateReq())); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void createReview_duplicate_shouldReturn409() { + mockStudentRole(STUDENT_ID); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(true); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(STUDENT_ID, JOB_ID, buildCreateReq())); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), ex.getErrorCodeInt()); + } + + @Test void createReview_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.createReview(COMPANY_ID, JOB_ID, buildCreateReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_jobNotFound_shouldReturn404() { + when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(999L, 0, 20)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_pageNegative_shouldReturn400() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(JOB_ID, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_sizeZero_shouldReturn400() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(JOB_ID, 0, 0)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_sizeExceeds50_shouldReturn400() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(JOB_ID, 0, 51)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_shouldReturnPublishedOnly() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc(eq(JOB_ID), eq("PUBLISHED"), any(), any())) + .thenReturn(new PageImpl<>(List.of(review))); + var r = reviewService.listReviews(JOB_ID, 0, 20); + assertEquals(1, r.getTotalElements()); + } + + @Test void updateReview_shouldSucceed() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); + when(reviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + JobReviewUpdateRequest req = new JobReviewUpdateRequest(); + req.setRating(5); req.setContent("updated content"); + var r = reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, req); + assertEquals(5, r.get("rating")); + } + + @Test void updateReview_blankContent_shouldReturn400() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); + JobReviewUpdateRequest req = new JobReviewUpdateRequest(); + req.setContent(" "); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void updateReview_invalidStatus_shouldReturn400() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); + JobReviewUpdateRequest req = new JobReviewUpdateRequest(); + req.setStatus("DELETED"); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.updateReview(STUDENT_ID, JOB_ID, REVIEW_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void updateReview_otherStudent_shouldReturn403() { + mockStudentRole(OTHER_STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); + JobReviewUpdateRequest req = new JobReviewUpdateRequest(); req.setRating(3); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.updateReview(OTHER_STUDENT_ID, JOB_ID, REVIEW_ID, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void deleteReview_shouldSucceed() { + mockStudentRole(STUDENT_ID); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); + assertDoesNotThrow(() -> reviewService.deleteReview(STUDENT_ID, JOB_ID, REVIEW_ID)); + verify(reviewRepository).deleteById(REVIEW_ID); + } + + @Test void deleteReview_asCompany_shouldReturn403() { + mockCompanyRole(COMPANY_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.deleteReview(COMPANY_ID, JOB_ID, REVIEW_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 评分汇总 ───────────────────────────────────────────── + + @Test void getReviewSummary_noReviews_shouldReturnZero() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(eq(JOB_ID), eq("PUBLISHED"), any())).thenReturn(List.of()); + + var r = reviewService.getReviewSummary(JOB_ID); + + assertEquals(0, r.get("totalReviews")); + assertEquals(0.0, r.get("averageRating")); + assertEquals(List.of(), r.get("topTags")); + Map dist = (Map) r.get("ratingDistribution"); + assertEquals(0, dist.get("rating1")); + } + + @Test void getReviewSummary_withReviews_shouldReturnCorrectly() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + JobReview r1 = JobReview.builder().jobId(JOB_ID).rating(4).status("PUBLISHED") + .tags("[\"tag1\",\"tag2\"]").build(); + JobReview r2 = JobReview.builder().jobId(JOB_ID).rating(5).status("PUBLISHED") + .tags("[\"tag1\"]").build(); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(eq(JOB_ID), eq("PUBLISHED"), any())).thenReturn(List.of(r1, r2)); + + var r = reviewService.getReviewSummary(JOB_ID); + + assertEquals(2, r.get("totalReviews")); + assertEquals(4.5, r.get("averageRating")); + assertEquals(List.of("tag1", "tag2"), r.get("topTags")); + } + + @Test void getReviewSummary_excludesHidden() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + JobReview r1 = JobReview.builder().jobId(JOB_ID).rating(5).status("PUBLISHED").build(); + JobReview r2 = JobReview.builder().jobId(JOB_ID).rating(1).status("HIDDEN").build(); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(eq(JOB_ID), eq("PUBLISHED"), any())).thenReturn(List.of(r1)); + + var r = reviewService.getReviewSummary(JOB_ID); + + assertEquals(1, r.get("totalReviews")); + assertEquals(5.0, r.get("averageRating")); + } + + @Test void getReviewSummary_jobNotFound_shouldReturn404() { + when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.getReviewSummary(999L)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void getReviewSummary_roundingAverage() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + JobReview r1 = JobReview.builder().jobId(JOB_ID).rating(3).status("PUBLISHED").build(); + JobReview r2 = JobReview.builder().jobId(JOB_ID).rating(4).status("PUBLISHED").build(); + JobReview r3 = JobReview.builder().jobId(JOB_ID).rating(4).status("PUBLISHED").build(); + when(reviewRepository.findByJobIdAndStatusAndModerationStatusIn(eq(JOB_ID), eq("PUBLISHED"), any())) + .thenReturn(List.of(r1, r2, r3)); + + var r = reviewService.getReviewSummary(JOB_ID); + // (3+4+4)/3 = 3.666... → 3.7 (HALF_UP) + assertEquals(3.7, r.get("averageRating")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationIntegrationTest.java new file mode 100644 index 0000000..e654d94 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationIntegrationTest.java @@ -0,0 +1,261 @@ +package com.mydreamjob.review; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +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.JobRepository; +import com.mydreamjob.review.dto.JobReviewCreateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 评价审核与防刷集成测试(独立 H2 + Flyway V18 真实表结构)。 + */ +@SpringBootTest +@org.springframework.test.context.TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_moderation;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@org.springframework.test.context.ActiveProfiles("integration-test") +class ReviewModerationIntegrationTest { + + @Autowired private JobReviewService reviewService; + @Autowired private JobReviewRepository reviewRepository; + @Autowired private ReviewModerationHistoryRepository historyRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + private Long studentUserId; + private Long adminUserId; + private Long otherStudentId; + private Long jobId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(600); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + historyRepository.deleteAll(); + reviewRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + + studentUserId = createUser(User.Role.STUDENT); + otherStudentId = createUser(User.Role.STUDENT); + adminUserId = createUser(User.Role.ADMIN); + Long companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save(Company.builder().ownerUserId(companyUserId).name("测试企业").build()); + Job job = jobRepository.save(Job.builder().companyId(company.getId()).title("Java 后端") + .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("144" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("用户").role(role).roles(Set.of(role)) + .active(true).verified(true).build()); + return u.getId(); + } + + private JobReviewCreateRequest req(String content) { + JobReviewCreateRequest req = new JobReviewCreateRequest(); + req.setRating(4); + req.setContent(content); + return req; + } + + private Map create(String content) { + return reviewService.createReview(studentUserId, jobId, req(content), + AuditContext.builder().requestId("req-m").ipAddress("192.168.1.100").userAgent("Mozilla-test").build()); + } + + @Test + void normalReview_autoApprovedAndPublic() { + Map view = create("不错的岗位"); + Long id = (Long) view.get("id"); + assertEquals(JobReview.MODERATION_AUTO_APPROVED, view.get("moderationStatus")); + // 公开列表与汇总可见 + assertTrue(reviewService.listReviews(jobId, 0, 20).getContent().stream() + .anyMatch(r -> r.get("id").equals(id))); + assertEquals(1, reviewService.getReviewSummary(jobId).get("totalReviews")); + // 维度仍可保存读取 + assertEquals(Map.of(), view.get("dimensionScores")); + } + + @Test + void sensitiveWordReview_pendingAndNotPublic() { + Map view = create("这家公司在刷单"); + Long id = (Long) view.get("id"); + assertEquals(JobReview.MODERATION_PENDING, view.get("moderationStatus")); + assertTrue(((List) view.get("moderationReasonCodes")).contains("SENSITIVE_WORD")); + // 不进入公开列表/汇总 + assertTrue(reviewService.listReviews(jobId, 0, 20).getContent().stream() + .noneMatch(r -> r.get("id").equals(id))); + assertEquals(0, reviewService.getReviewSummary(jobId).get("totalReviews")); + } + + @Test + void ipAndUa_storedAsHashNotPlaintext() { + Map view = create("普通内容"); + JobReview saved = reviewRepository.findById((Long) view.get("id")).orElseThrow(); + assertEquals(64, saved.getIpHash().length()); + assertEquals(64, saved.getUaHash().length()); + assertEquals(ReviewModerationService.sha256("192.168.1.100"), saved.getIpHash()); + assertEquals(ReviewModerationService.sha256("Mozilla-test"), saved.getUaHash()); + assertNotEquals("192.168.1.100", saved.getIpHash()); + assertNotEquals("Mozilla-test", saved.getUaHash()); + } + + @Test + void pending_approved_shouldBecomePublic() { + Map view = create("这家公司在刷单"); + Long id = (Long) view.get("id"); + + reviewService.decideReview(adminUserId, id, "APPROVED", "人工确认真实", AuditContext.builder().requestId("r").build()); + + assertEquals(JobReview.MODERATION_APPROVED, + reviewRepository.findById(id).orElseThrow().getModerationStatus()); + assertTrue(reviewService.listReviews(jobId, 0, 20).getContent().stream() + .anyMatch(r -> r.get("id").equals(id))); + } + + @Test + void pending_rejected_shouldNeverPublic() { + Map view = create("联系方式 13812345678"); + Long id = (Long) view.get("id"); + + reviewService.decideReview(adminUserId, id, "REJECTED", "含联系方式", AuditContext.builder().requestId("r").build()); + + assertEquals(JobReview.MODERATION_REJECTED, + reviewRepository.findById(id).orElseThrow().getModerationStatus()); + assertTrue(reviewService.listReviews(jobId, 0, 20).getContent().stream() + .noneMatch(r -> r.get("id").equals(id))); + } + + @Test + void duplicateDecision_shouldReturn409() { + Map view = create("这家公司在刷单"); + Long id = (Long) view.get("id"); + reviewService.decideReview(adminUserId, id, "REJECTED", "拒绝", null); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.decideReview(adminUserId, id, "APPROVED", "重复", null)); + assertEquals(ErrorCode.REVIEW_ALREADY_DECIDED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void nonAdminDecision_shouldReturn403() { + Map view = create("这家公司在刷单"); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.decideReview(studentUserId, (Long) view.get("id"), "APPROVED", "x", null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void adminDecision_shouldWriteAuditLogs() { + Map view = create("这家公司在刷单"); + Long id = (Long) view.get("id"); + reviewService.decideReview(adminUserId, id, "REJECTED", "含敏感词", null); + + List logs = auditLogRepository.findAll().stream() + .filter(l -> "JOB_REVIEW".equals(l.getEntityType())) + .filter(l -> id.equals(l.getEntityId())) + .toList(); + assertTrue(logs.stream().anyMatch(l -> "REVIEW_REJECTED".equals(l.getAction()))); + assertThat(logs).isNotEmpty(); + } + + @Test + void concurrentDuplicate_shouldOnlyOneSucceed() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + pool.submit(() -> runWithLatch(start, () -> reviewService.createReview(studentUserId, jobId, req("不错的岗位")), failure)); + pool.submit(() -> runWithLatch(start, () -> reviewService.createReview(studentUserId, jobId, req("不错的岗位")), failure)); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + assertEquals(1, reviewRepository.findAll().size()); + assertNotNull(failure.get()); + assertEquals(ErrorCode.DUPLICATE_REVIEW.getCode(), ((BusinessException) failure.get()).getErrorCodeInt()); + } + + @Test + void dimensions_shouldSaveWithModeration() { + JobReviewCreateRequest r = req("不错的岗位"); + r.setDimensionScores(Map.of("AUTHENTICITY", 5, "TEAM_ATMOSPHERE", 4)); + Map view = reviewService.createReview(studentUserId, jobId, r, null); + Map dims = (Map) view.get("dimensionScores"); + assertEquals(5, dims.get("AUTHENTICITY")); + assertEquals(JobReview.MODERATION_AUTO_APPROVED, view.get("moderationStatus")); + } + + @Test + void pendingReviews_adminListable_withoutPlaintext() { + create("这家公司在刷单"); + var pending = reviewService.listPendingReviews(adminUserId, null, null, null, null, 0, 20); + assertEquals(1, pending.getTotalElements()); + Map item = pending.getContent().get(0); + assertEquals(JobReview.MODERATION_PENDING, item.get("moderationStatus")); + assertFalse(item.containsKey("ipHash")); + assertFalse(item.containsKey("uaHash")); + assertTrue(((List) item.get("reasonCodes")).contains("SENSITIVE_WORD")); + } + + private void runWithLatch(CountDownLatch start, Runnable action, AtomicReference failure) { + try { + start.await(); + action.run(); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + } + + /** V19 迁移验证:防刷哈希三列应为 VARCHAR 且可空(修复 CHAR(64) 与 JPA VARCHAR(64) 校验不一致) */ + @Test + void v19_hashColumns_shouldBeVarcharAndNullable() { + for (String column : new String[]{"CONTENT_HASH", "IP_HASH", "UA_HASH"}) { + Map row = jdbcTemplate.queryForMap( + "SELECT DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'JOB_REVIEWS' AND COLUMN_NAME = ?", column); + assertEquals("CHARACTER VARYING", row.get("DATA_TYPE"), column + " 应为 VARCHAR"); + assertEquals("YES", row.get("IS_NULLABLE"), column + " 应允许 NULL"); + assertEquals(64, ((Number) row.get("CHARACTER_MAXIMUM_LENGTH")).intValue(), column + " 长度应为 64"); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationServiceTest.java new file mode 100644 index 0000000..3b9a0b8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/ReviewModerationServiceTest.java @@ -0,0 +1,122 @@ +package com.mydreamjob.review; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReviewModerationServiceTest { + + @Mock private JobReviewRepository reviewRepository; + @InjectMocks private ReviewModerationService moderationService; + + private static final Long STUDENT_ID = 100L; + private static final Long JOB_ID = 10L; + + @BeforeEach + void setUp() { + // 注入配置(@Value 在纯 Mockito 单测中不会注入) + ReflectionTestUtils.setField(moderationService, "sensitiveWords", + List.of("刷单", "代刷", "有偿删评", "夸大宣传")); + ReflectionTestUtils.setField(moderationService, "rapidSubmissionWindowSeconds", 60L); + ReflectionTestUtils.setField(moderationService, "rapidSubmissionLimit", 3L); + ReflectionTestUtils.setField(moderationService, "duplicateContentWindowSeconds", 300L); + ReflectionTestUtils.setField(moderationService, "batchWindowSeconds", 300L); + ReflectionTestUtils.setField(moderationService, "batchLimit", 5L); + } + + private ReviewModerationService.ModerationResult moderate(String content) { + return moderationService.moderate(STUDENT_ID, JOB_ID, content, + ReviewModerationService.sha256(content), "ip-hash", "ua-hash", LocalDateTime.now()); + } + + @Test + void noRisk_shouldAutoApproved() { + var r = moderate("不错的岗位"); + assertEquals(JobReview.MODERATION_AUTO_APPROVED, r.getStatus()); + assertTrue(r.getReasonCodes().isEmpty()); + } + + @Test + void sensitiveWord_shouldPendingWithCode() { + var r = moderate("这家公司在刷单"); + assertEquals(JobReview.MODERATION_PENDING, r.getStatus()); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_SENSITIVE_WORD)); + // 不返回敏感词原文 + assertFalse(r.getReasonCodes().toString().contains("刷单")); + } + + @Test + void contactPhone_shouldPending() { + var r = moderate("加我微信 13812345678"); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_CONTACT_INFO)); + } + + @Test + void contactUrl_shouldPending() { + var r = moderate("详情请看 https://example.com"); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_CONTACT_INFO)); + } + + @Test + void repeatedChars_shouldPatternAnomaly() { + var r = moderate("好好好好好好好好好好"); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_PATTERN_ANOMALY)); + } + + @Test + void rapidSubmission_shouldFlag() { + when(reviewRepository.countByStudentUserIdAndCreatedAtAfter(eq(STUDENT_ID), any())) + .thenReturn(3L); // >= limit + var r = moderate("普通内容"); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_RAPID_SUBMISSION)); + } + + @Test + void duplicateContent_shouldFlag() { + String content = "完全相同的评价内容"; + when(reviewRepository.existsByJobIdAndContentHashAndCreatedAtAfter(eq(JOB_ID), + eq(ReviewModerationService.sha256(content)), any())).thenReturn(true); + var r = moderate(content); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_DUPLICATE_CONTENT)); + } + + @Test + void batchAbuse_ipHash_shouldFlag() { + when(reviewRepository.countByIpHashAndCreatedAtAfter(eq("ip-hash"), any())) + .thenReturn(5L); // >= limit + var r = moderate("普通内容"); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_ABUSIVE_BATCH)); + } + + @Test + void sha256_isDeterministicHex64() { + String hash = ReviewModerationService.sha256("hello"); + assertEquals(64, hash.length()); + assertEquals(hash, ReviewModerationService.sha256("hello")); + assertNotEquals(hash, ReviewModerationService.sha256("hello!")); + assertNull(ReviewModerationService.sha256(null)); + // 不得存明文 + assertNotEquals("127.0.0.1", ReviewModerationService.sha256("127.0.0.1")); + } + + @Test + void moderate_doesNotExposeSensitiveWord() { + String content = "夸大宣传不可信"; + var r = moderate(content); + assertTrue(r.getReasonCodes().contains(ReviewModerationService.REASON_SENSITIVE_WORD)); + assertFalse(r.getReasonCodes().toString().contains("夸大宣传")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java new file mode 100644 index 0000000..8f08945 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java @@ -0,0 +1,256 @@ +package com.mydreamjob.review; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.dto.PersonalizedScoreResponse; +import com.mydreamjob.review.dto.ReviewWeightResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = "spring.jpa.open-in-view=false") +class StudentReviewWeightControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private StudentReviewWeightService weightService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", + Map.of("roles", "STUDENT")); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", + Map.of("roles", "COMPANY")); + } + + private String adminToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", + Map.of("roles", "ADMIN")); + } + + // ── 模板列表 ────────────────────────────────────────────── + + @Test + void getTemplates_withValidToken_shouldReturn200() throws Exception { + when(weightService.getTemplates()).thenReturn(List.of()); + mockMvc.perform(get("/review-weight-templates") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void getTemplates_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/review-weight-templates")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + // ── 获取权重配置 ────────────────────────────────────────── + + @Test + void getMyWeights_studentToken_shouldReturn200() throws Exception { + ReviewWeightResponse resp = ReviewWeightResponse.builder() + .source("SYSTEM_DEFAULT").templateCode("BALANCED") + .configuredWeights(WeightTemplate.BALANCED.getWeights()) + .updatedAt(null).build(); + when(weightService.getMyWeights(anyLong())).thenReturn(resp); + + mockMvc.perform(get("/profile/me/review-weights") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.source").value("SYSTEM_DEFAULT")); + } + + @Test + void getMyWeights_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/review-weights")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getMyWeights_companyToken_shouldReturn403() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(weightService).getMyWeights(anyLong()); + + mockMvc.perform(get("/profile/me/review-weights") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getMyWeights_adminToken_shouldReturn403() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(weightService).getMyWeights(anyLong()); + + mockMvc.perform(get("/profile/me/review-weights") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + // ── 保存权重配置 ────────────────────────────────────────── + + @Test + void saveWeights_studentToken_shouldReturn200() throws Exception { + ReviewWeightResponse resp = ReviewWeightResponse.builder() + .source("TEMPLATE").templateCode("BALANCED") + .configuredWeights(WeightTemplate.BALANCED.getWeights()) + .updatedAt(null).build(); + when(weightService.saveWeights(anyLong(), any())).thenReturn(resp); + + String body = mapper.writeValueAsString(Map.of("templateCode", "BALANCED")); + mockMvc.perform(put("/profile/me/review-weights") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void saveWeights_noToken_shouldReturn401() throws Exception { + String body = mapper.writeValueAsString(Map.of("templateCode", "BALANCED")); + mockMvc.perform(put("/profile/me/review-weights") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void saveWeights_companyToken_shouldReturn403() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(weightService).saveWeights(anyLong(), any()); + + String body = mapper.writeValueAsString(Map.of("templateCode", "BALANCED")); + mockMvc.perform(put("/profile/me/review-weights") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isForbidden()); + } + + // ── 个性化评分 ──────────────────────────────────────────── + + @Test + void getPersonalizedScore_studentToken_shouldReturn200() throws Exception { + PersonalizedScoreResponse resp = PersonalizedScoreResponse.builder() + .jobId(1L).score(new BigDecimal("80.00")).status("OK") + .source("SYSTEM_DEFAULT").templateCode("BALANCED") + .configuredWeights(WeightTemplate.BALANCED.getWeights()) + .effectiveWeights(Map.of("AUTHENTICITY", new BigDecimal("100.00"))) + .dimensionScores(Map.of("AUTHENTICITY", new BigDecimal("4.00"))) + .coveredWeight(new BigDecimal("12.50")) + .missingDimensions(List.of("BENEFITS", "SALARY_TRANSPARENCY", "JD_CLARITY", + "GROWTH", "TEAM_ATMOSPHERE", "PROMOTION", "WORK_LIFE_BALANCE")) + .build(); + when(weightService.getPersonalizedScore(anyLong(), anyLong())).thenReturn(resp); + + mockMvc.perform(get("/jobs/1/personalized-review-score") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("OK")); + } + + @Test + void getPersonalizedScore_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/jobs/1/personalized-review-score")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getPersonalizedScore_companyToken_shouldReturn403() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(weightService).getPersonalizedScore(anyLong(), anyLong()); + + mockMvc.perform(get("/jobs/1/personalized-review-score") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test + void getPersonalizedScore_adminToken_shouldReturn403() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(weightService).getPersonalizedScore(anyLong(), anyLong()); + + mockMvc.perform(get("/jobs/1/personalized-review-score") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldContainReviewWeightEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + JsonNode paths = root.path("paths"); + + // GET /review-weight-templates + assertThat(paths.has("/review-weight-templates")).isTrue(); + assertTrue(paths.path("/review-weight-templates").has("get")); + + // GET /profile/me/review-weights + assertThat(paths.has("/profile/me/review-weights")).isTrue(); + assertTrue(paths.path("/profile/me/review-weights").has("get")); + + // PUT /profile/me/review-weights + assertTrue(paths.path("/profile/me/review-weights").has("put")); + + // GET /jobs/{jobId}/personalized-review-score + assertThat(paths.has("/jobs/{jobId}/personalized-review-score")).isTrue(); + assertTrue(paths.path("/jobs/{jobId}/personalized-review-score").has("get")); + + // 验证所有引用键必须是 "$ref" + String jsonStr = json; + assertThat(jsonStr).doesNotContain("\"\" : \"#/components/schemas/"); + + // 开发辅助:导出 OpenAPI 快照 + String snapshotDir = System.getProperty("offer.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(StandardCharsets.UTF_8)); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightPersistenceIntegrationTest.java new file mode 100644 index 0000000..7a83a2b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightPersistenceIntegrationTest.java @@ -0,0 +1,139 @@ +package com.mydreamjob.review; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.review.dto.ReviewWeightResponse; +import com.mydreamjob.review.dto.SaveWeightRequest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.annotation.Commit; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; + +@SpringBootTest +@ActiveProfiles("integration-test") +class StudentReviewWeightPersistenceIntegrationTest { + + @Autowired + private StudentReviewWeightService weightService; + + @Autowired + private StudentReviewWeightPreferenceRepository preferenceRepository; + + @Autowired + private StudentReviewDimensionWeightRepository dimensionWeightRepository; + + @Autowired + private UserRepository userRepository; + + @MockBean + private CompanyService companyService; + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private EntityManager entityManager; + + private static final AtomicLong PHONE_SEQ = new AtomicLong(800); + + private Long userId; + + @BeforeEach + void setUp() { + doNothing().when(companyService).assertStudentRole(anyLong()); + + // 清理旧权重数据,避免 FK 冲突 + dimensionWeightRepository.deleteAll(); + preferenceRepository.deleteAll(); + + // 创建真实 STUDENT 用户,满足 student_review_weight_preferences 的 FK 约束 + User student = userRepository.save(User.builder() + .phone("156" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("测试学生") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()); + userId = student.getId(); + } + + @Test + @Transactional + @Commit + void threeSequentialUpdates_shouldNotThrow9106() { + // 1. 保存 SALARY_FIRST 模板 + SaveWeightRequest req1 = new SaveWeightRequest(); + req1.setTemplateCode("SALARY_FIRST"); + ReviewWeightResponse resp1 = weightService.saveWeights(userId, req1); + assertEquals("TEMPLATE", resp1.getSource()); + assertEquals("SALARY_FIRST", resp1.getTemplateCode()); + assertPersistence(1, 8); + + // 2. 保存合法自定义八维权重 + SaveWeightRequest req2 = new SaveWeightRequest(); + req2.setWeights(buildCustomWeights()); + ReviewWeightResponse resp2 = weightService.saveWeights(userId, req2); + assertEquals("CUSTOM", resp2.getSource()); + assertNull(resp2.getTemplateCode()); + assertPersistence(1, 8); + + // 3. 保存 GROWTH_FIRST 模板 + SaveWeightRequest req3 = new SaveWeightRequest(); + req3.setTemplateCode("GROWTH_FIRST"); + ReviewWeightResponse resp3 = weightService.saveWeights(userId, req3); + assertEquals("TEMPLATE", resp3.getSource()); + assertEquals("GROWTH_FIRST", resp3.getTemplateCode()); + assertPersistence(1, 8); + + // 清除一级缓存,确保从数据库重新加载(saveWeights 中使用原生 SQL 删除,缓存中的集合可能过期) + entityManager.clear(); + + // 最终配置断言 + StudentReviewWeightPreference finalPref = preferenceRepository + .findByStudentUserId(userId).orElseThrow(); + assertEquals("TEMPLATE", finalPref.getSource()); + assertEquals("GROWTH_FIRST", finalPref.getTemplateCode()); + assertEquals(8, finalPref.getDimensionWeights().size()); + } + + private void assertPersistence(int expectedPrefCount, int expectedDimCount) { + long prefCount = preferenceRepository.count(); + assertEquals(expectedPrefCount, prefCount, + "preference 记录数应为 " + expectedPrefCount + ",实际为 " + prefCount); + + long dimCount = dimensionWeightRepository.count(); + assertEquals(expectedDimCount, dimCount, + "dimension weight 记录数应为 " + expectedDimCount + ",实际为 " + dimCount); + } + + private Map buildCustomWeights() { + Map weights = new LinkedHashMap<>(); + weights.put("AUTHENTICITY", new BigDecimal("15.00")); + weights.put("BENEFITS", new BigDecimal("15.00")); + weights.put("SALARY_TRANSPARENCY", new BigDecimal("25.00")); + weights.put("JD_CLARITY", new BigDecimal("10.00")); + weights.put("GROWTH", new BigDecimal("10.00")); + weights.put("TEAM_ATMOSPHERE", new BigDecimal("10.00")); + weights.put("PROMOTION", new BigDecimal("10.00")); + weights.put("WORK_LIFE_BALANCE", new BigDecimal("5.00")); + return weights; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightServiceTest.java new file mode 100644 index 0000000..b6f234b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightServiceTest.java @@ -0,0 +1,507 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.review.dto.PersonalizedScoreResponse; +import com.mydreamjob.review.dto.ReviewWeightResponse; +import com.mydreamjob.review.dto.SaveWeightRequest; +import com.mydreamjob.review.dto.WeightTemplateResponse; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.math.BigDecimal; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class StudentReviewWeightServiceTest { + + @Mock + private StudentReviewWeightPreferenceRepository preferenceRepository; + @Mock + private StudentReviewDimensionWeightRepository dimensionWeightRepository; + @Mock + private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Mock + private JobRepository jobRepository; + @Mock + private CompanyService companyService; + @Mock + private EntityManager entityManager; + @Mock + private Query query; + + @InjectMocks + private StudentReviewWeightService weightService; + + private static final Long USER_ID = 1L; + private static final Long JOB_ID = 1L; + + @BeforeEach + void setUp() { + when(entityManager.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), any())).thenReturn(query); + when(query.executeUpdate()).thenReturn(1); + } + + // ── 模板列表 ────────────────────────────────────────────── + + @Test + void getTemplates_shouldReturnAllFourTemplates() { + List templates = weightService.getTemplates(); + assertEquals(4, templates.size()); + } + + @Test + void eachTemplate_shouldHaveAllEightDimensions() { + for (WeightTemplate t : WeightTemplate.values()) { + assertEquals(8, t.getWeights().size()); + } + } + + @Test + void eachTemplate_shouldSumExactly100() { + for (WeightTemplate t : WeightTemplate.values()) { + BigDecimal sum = t.getWeights().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(0, sum.compareTo(new BigDecimal("100.00"))); + } + } + + // ── 获取权重配置 ────────────────────────────────────────── + + @Test + void getMyWeights_noConfig_shouldReturnSystemDefaultBalanced() { + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + ReviewWeightResponse resp = weightService.getMyWeights(USER_ID); + assertEquals("SYSTEM_DEFAULT", resp.getSource()); + assertEquals("BALANCED", resp.getTemplateCode()); + assertEquals(8, resp.getConfiguredWeights().size()); + assertNull(resp.getUpdatedAt()); + } + + @Test + void getMyWeights_withSavedConfig_shouldReturnCustomConfig() { + StudentReviewWeightPreference pref = buildPreference("CUSTOM", null); + addDimensionWeights(pref, buildCustomWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + ReviewWeightResponse resp = weightService.getMyWeights(USER_ID); + assertEquals("CUSTOM", resp.getSource()); + assertNull(resp.getTemplateCode()); + assertNotNull(resp.getUpdatedAt()); + } + + // ── 保存权重 ────────────────────────────────────────────── + + @Test + void saveWeights_withTemplate_shouldSucceed() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("SALARY_FIRST"); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + when(preferenceRepository.saveAndFlush(any())).thenReturn( + StudentReviewWeightPreference.builder() + .id(1L).studentUserId(USER_ID).source("TEMPLATE") + .templateCode("SALARY_FIRST").version(0L).build()); + ReviewWeightResponse resp = weightService.saveWeights(USER_ID, req); + assertEquals("TEMPLATE", resp.getSource()); + assertEquals("SALARY_FIRST", resp.getTemplateCode()); + verify(dimensionWeightRepository).saveAllAndFlush(any()); + } + + @Test + void saveWeights_withCustomWeights_shouldSucceed() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setWeights(buildCustomWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + when(preferenceRepository.saveAndFlush(any())).thenReturn( + StudentReviewWeightPreference.builder() + .id(1L).studentUserId(USER_ID).source("CUSTOM") + .templateCode(null).version(0L).build()); + ReviewWeightResponse resp = weightService.saveWeights(USER_ID, req); + assertEquals("CUSTOM", resp.getSource()); + assertNull(resp.getTemplateCode()); + } + + @Test + void saveWeights_bothTemplateAndWeights_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("SALARY_FIRST"); + req.setWeights(buildCustomWeights()); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_neitherTemplateNorWeights_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_unknownTemplate_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("INVALID"); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_unknownDimension_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = new LinkedHashMap<>(buildCustomWeights()); + weights.put("UNKNOWN_DIM", new BigDecimal("5.00")); + weights.remove("AUTHENTICITY"); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_missingDimension_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = buildCustomWeights(); + weights.remove("AUTHENTICITY"); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_sumNot100_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = new LinkedHashMap<>(buildCustomWeights()); + weights.put("AUTHENTICITY", new BigDecimal("20.00")); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_negativeWeight_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = buildCustomWeights(); + weights.put("AUTHENTICITY", new BigDecimal("-5.00")); + weights.put("BENEFITS", new BigDecimal("20.00")); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_weightOver100_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = buildCustomWeights(); + weights.put("AUTHENTICITY", new BigDecimal("150.00")); + weights.put("BENEFITS", new BigDecimal("20.00")); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_nullWeight_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = new LinkedHashMap<>(buildCustomWeights()); + weights.put("AUTHENTICITY", null); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_tooManyDecimals_shouldReturn400() { + SaveWeightRequest req = new SaveWeightRequest(); + Map weights = buildCustomWeights(); + weights.put("AUTHENTICITY", new BigDecimal("12.345")); + weights.put("BENEFITS", new BigDecimal("17.655")); + req.setWeights(weights); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_sameSourceAndTemplate_shouldStillUpdate() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("BALANCED"); + StudentReviewWeightPreference pref = buildPreference("TEMPLATE", "BALANCED"); + pref.setId(1L); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.saveAndFlush(any())).thenReturn(pref); + + weightService.saveWeights(USER_ID, req); + verify(preferenceRepository).saveAndFlush(any()); + verify(dimensionWeightRepository).saveAllAndFlush(any()); + } + + @Test + void saveWeights_updateExisting_shouldDeleteOldWeights() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("BALANCED"); + StudentReviewWeightPreference pref = buildPreference("CUSTOM", null); + pref.setId(1L); + addDimensionWeights(pref, buildCustomWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.saveAndFlush(any())).thenReturn(pref); + + weightService.saveWeights(USER_ID, req); + verify(entityManager, atLeastOnce()).createNativeQuery(anyString()); + verify(dimensionWeightRepository).saveAllAndFlush(any()); + } + + @Test + void saveWeights_optimisticLockConflict_shouldReturn409() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("BALANCED"); + StudentReviewWeightPreference pref = buildPreference("CUSTOM", null); + pref.setId(1L); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.saveAndFlush(any())).thenThrow(new ObjectOptimisticLockingFailureException("", new RuntimeException())); + + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode(), ex.getErrorCode()); + } + + @Test + void saveWeights_concurrentCreation_shouldReturn409() { + SaveWeightRequest req = new SaveWeightRequest(); + req.setTemplateCode("BALANCED"); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + when(preferenceRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.saveWeights(USER_ID, req)); + assertEquals(ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode(), ex.getErrorCode()); + } + + // ── 个性化评分 ──────────────────────────────────────────── + + @Test + void getPersonalizedScore_withDefaultConfig_shouldCalculateCorrectly() { + setupJobMock(); + setupAggregatedData(5L, 4L, 6L, null, 3L, null, null, null); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("OK", resp.getStatus()); + assertNotNull(resp.getScore()); + assertEquals(4, resp.getEffectiveWeights().size()); + assertEquals(4, resp.getMissingDimensions().size()); + } + + @Test + void getPersonalizedScore_withTemplateConfig_shouldCalculateCorrectly() { + setupJobMock(); + setupAggregatedData(5L, 4L, 6L, 3L, 3L, null, null, null); + StudentReviewWeightPreference pref = buildPreference("TEMPLATE", "SALARY_FIRST"); + addDimensionWeights(pref, WeightTemplate.SALARY_FIRST.getWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("OK", resp.getStatus()); + assertNotNull(resp.getScore()); + assertEquals("TEMPLATE", resp.getSource()); + assertEquals("SALARY_FIRST", resp.getTemplateCode()); + } + + @Test + void getPersonalizedScore_withCustomConfig_shouldCalculateCorrectly() { + setupJobMock(); + setupAggregatedData(5L, 4L, 6L, null, 3L, null, null, null); + StudentReviewWeightPreference pref = buildPreference("CUSTOM", null); + addDimensionWeights(pref, buildCustomWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("OK", resp.getStatus()); + assertEquals("CUSTOM", resp.getSource()); + assertEquals(4, resp.getEffectiveWeights().size()); + } + + @Test + void getPersonalizedScore_partialDimensions_shouldNormalizeCorrectly() { + setupJobMock(); + setupAggregatedData(5L, 4L, null, null, null, null, null, null); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("OK", resp.getStatus()); + assertEquals(2, resp.getEffectiveWeights().size()); + assertEquals(6, resp.getMissingDimensions().size()); + } + + @Test + void getPersonalizedScore_sampleCount1_shouldBeMissing() { + setupJobMock(); + setupAggregatedData(1L, null, null, null, null, null, null, null); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + assertNull(resp.getScore()); + assertTrue(resp.getMissingDimensions().contains("AUTHENTICITY")); + } + + @Test + void getPersonalizedScore_sampleCount2_shouldBeMissing() { + setupJobMock(); + setupAggregatedData(2L, null, null, null, null, null, null, null); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + assertTrue(resp.getMissingDimensions().contains("AUTHENTICITY")); + } + + @Test + void getPersonalizedScore_sampleCount3_shouldBeAvailable() { + setupJobMock(); + setupAggregatedData(3L, null, null, null, null, null, null, null); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("OK", resp.getStatus()); + assertNotNull(resp.getScore()); + assertFalse(resp.getMissingDimensions().contains("AUTHENTICITY")); + } + + @Test + void getPersonalizedScore_allDimensionsMissing_shouldReturnInsufficientData() { + setupJobMock(); + when(dimensionScoreRepository.aggregatePublishedByJobId(JOB_ID)).thenReturn(Collections.emptyList()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.empty()); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + assertNull(resp.getScore()); + assertEquals(0, resp.getEffectiveWeights().size()); + assertEquals(8, resp.getMissingDimensions().size()); + } + + @Test + void getPersonalizedScore_jobNotFound_shouldReturn404() { + when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.getPersonalizedScore(USER_ID, JOB_ID)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); + } + + @Test + void getPersonalizedScore_nonPublishedJob_shouldReturn404() { + Job job = new Job(); + job.setId(JOB_ID); + job.setStatus("DRAFT"); + when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(job)); + BusinessException ex = assertThrows(BusinessException.class, + () -> weightService.getPersonalizedScore(USER_ID, JOB_ID)); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); + } + + @Test + void getPersonalizedScore_effectiveWeightsSum_shouldBeExactly100() { + setupJobMock(); + setupAggregatedData(5L, 4L, 6L, 3L, 3L, null, null, null); + StudentReviewWeightPreference pref = buildPreference("CUSTOM", null); + addDimensionWeights(pref, buildCustomWeights()); + when(preferenceRepository.findByStudentUserId(USER_ID)).thenReturn(Optional.of(pref)); + + PersonalizedScoreResponse resp = weightService.getPersonalizedScore(USER_ID, JOB_ID); + BigDecimal sum = resp.getEffectiveWeights().values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(0, sum.compareTo(new BigDecimal("100.00"))); + } + + // ── 辅助方法 ────────────────────────────────────────────── + + private void setupJobMock() { + Job job = new Job(); + job.setId(JOB_ID); + job.setStatus("PUBLISHED"); + when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(job)); + } + + private void setupAggregatedData(Long authCount, Long benCount, Long salCount, + Long jdCount, Long groCount, Long teaCount, + Long proCount, Long wlbCount) { + List rows = new ArrayList<>(); + addRow(rows, "AUTHENTICITY", 4.2, authCount); + addRow(rows, "BENEFITS", 3.8, benCount); + addRow(rows, "SALARY_TRANSPARENCY", 4.5, salCount); + addRow(rows, "JD_CLARITY", 4.0, jdCount); + addRow(rows, "GROWTH", 3.0, groCount); + addRow(rows, "TEAM_ATMOSPHERE", 3.5, teaCount); + addRow(rows, "PROMOTION", 2.5, proCount); + addRow(rows, "WORK_LIFE_BALANCE", 4.0, wlbCount); + when(dimensionScoreRepository.aggregatePublishedByJobId(JOB_ID)).thenReturn(rows); + } + + private void addRow(List rows, String dim, double avg, Long count) { + if (count == null || count == 0) return; + rows.add(new Object[]{dim, avg, count}); + } + + private StudentReviewWeightPreference buildPreference(String source, String templateCode) { + return StudentReviewWeightPreference.builder() + .id(1L) + .studentUserId(USER_ID) + .source(source) + .templateCode(templateCode) + .version(0L) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .dimensionWeights(new ArrayList<>()) + .build(); + } + + private void addDimensionWeights(StudentReviewWeightPreference pref, Map weights) { + for (Map.Entry entry : weights.entrySet()) { + StudentReviewDimensionWeight dw = StudentReviewDimensionWeight.builder() + .preference(pref) + .dimensionCode(entry.getKey()) + .weight(entry.getValue()) + .build(); + pref.getDimensionWeights().add(dw); + } + } + + private Map buildCustomWeights() { + Map weights = new LinkedHashMap<>(); + weights.put("AUTHENTICITY", new BigDecimal("15.00")); + weights.put("BENEFITS", new BigDecimal("15.00")); + weights.put("SALARY_TRANSPARENCY", new BigDecimal("25.00")); + weights.put("JD_CLARITY", new BigDecimal("10.00")); + weights.put("GROWTH", new BigDecimal("10.00")); + weights.put("TEAM_ATMOSPHERE", new BigDecimal("10.00")); + weights.put("PROMOTION", new BigDecimal("10.00")); + weights.put("WORK_LIFE_BALANCE", new BigDecimal("5.00")); + return weights; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java new file mode 100644 index 0000000..c584542 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/verification/CompanyVerificationServiceTest.java @@ -0,0 +1,265 @@ +package com.mydreamjob.verification; + +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.notification.NotificationService; +import com.mydreamjob.verification.dto.VerificationDecisionRequest; +import com.mydreamjob.verification.dto.VerificationSubmitRequest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CompanyVerificationServiceTest { + + @Mock private CompanyVerificationRepository verificationRepository; + @Mock private CompanyVerificationHistoryRepository historyRepository; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + @Mock private EntityManager entityManager; + + @InjectMocks + private CompanyVerificationService verificationService; + + private final Long companyUserId = 1L; + private final Long companyId = 10L; + private final Long adminUserId = 100L; + private final AuditContext ctx = AuditContext.builder().ipAddress("127.0.0.1").build(); + + private Company company; + private User companyUser; + private User adminUser; + + @BeforeEach + void setUp() { + company = Company.builder().id(companyId).ownerUserId(companyUserId) + .name("测试企业").verified(false).build(); + companyUser = User.builder().id(companyUserId).roles(Set.of(User.Role.COMPANY)).build(); + adminUser = User.builder().id(adminUserId).roles(Set.of(User.Role.ADMIN)).build(); + } + + // ── 企业提交认证 ──────────────────────────────────────────── + + @Test + void shouldSubmitVerificationWhenNoExisting() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.empty()); + when(verificationRepository.save(any())).thenAnswer(inv -> { + CompanyVerification v = inv.getArgument(0); + v.setId(1L); + return v; + }); + + VerificationSubmitRequest req = new VerificationSubmitRequest(); + req.setMaterialsJson("{\"license\":\"url\"}"); + + CompanyVerification result = verificationService.submit(companyUserId, req, ctx); + + assertNotNull(result); + assertEquals("PENDING", result.getStatus()); + assertEquals(companyId, result.getCompanyId()); + verify(historyRepository).save(any()); + verify(auditService).log(eq(companyUserId), eq("SUBMIT_VERIFICATION"), any(), any(), any(), eq(ctx)); + } + + @Test + void shouldSetResubmittedWhenRejected() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + CompanyVerification rejected = CompanyVerification.builder() + .id(1L).companyId(companyId).status("REJECTED").submittedBy(companyUserId).build(); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.of(rejected)); + when(verificationRepository.save(any())).thenAnswer(inv -> { + CompanyVerification v = inv.getArgument(0); + v.setId(2L); + return v; + }); + + VerificationSubmitRequest req = new VerificationSubmitRequest(); + req.setMaterialsJson("{\"license\":\"url\"}"); + + CompanyVerification result = verificationService.submit(companyUserId, req, ctx); + + assertEquals("RESUBMITTED", result.getStatus()); + } + + @Test + void shouldRejectDuplicatePending() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + CompanyVerification pending = CompanyVerification.builder() + .id(1L).companyId(companyId).status("PENDING").submittedBy(companyUserId).build(); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.of(pending)); + + VerificationSubmitRequest req = new VerificationSubmitRequest(); + req.setMaterialsJson("{\"license\":\"url\"}"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> verificationService.submit(companyUserId, req, ctx)); + assertEquals(ErrorCode.VERIFICATION_ALREADY_PENDING.getCode(), ex.getErrorCode()); + } + + @Test + void shouldRejectAlreadyApproved() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + CompanyVerification approved = CompanyVerification.builder() + .id(1L).companyId(companyId).status("APPROVED").submittedBy(companyUserId).build(); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.of(approved)); + + VerificationSubmitRequest req = new VerificationSubmitRequest(); + req.setMaterialsJson("{\"license\":\"url\"}"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> verificationService.submit(companyUserId, req, ctx)); + assertEquals(ErrorCode.VERIFICATION_ALREADY_APPROVED.getCode(), ex.getErrorCode()); + } + + // ── 企业查询认证 ──────────────────────────────────────────── + + @Test + void shouldGetMyVerification() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + CompanyVerification v = CompanyVerification.builder() + .id(1L).companyId(companyId).status("PENDING").submittedBy(companyUserId).build(); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.of(v)); + + CompanyVerification result = verificationService.getMyVerification(companyUserId); + assertEquals("PENDING", result.getStatus()); + } + + @Test + void shouldThrowWhenNoVerification() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(company)); + when(verificationRepository.findTopByCompanyIdOrderByIdDesc(companyId)) + .thenReturn(Optional.empty()); + + assertThrows(BusinessException.class, + () -> verificationService.getMyVerification(companyUserId)); + } + + // ── 管理员审核 ────────────────────────────────────────────── + + @Test + void shouldApproveVerification() { + CompanyVerification pending = CompanyVerification.builder() + .id(1L).companyId(companyId).status("PENDING").submittedBy(companyUserId).build(); + when(verificationRepository.findById(1L)).thenReturn(Optional.of(pending)); + when(verificationRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(companyRepository.findById(companyId)).thenReturn(Optional.of(company)); + + VerificationDecisionRequest req = new VerificationDecisionRequest(); + req.setDecision("APPROVED"); + req.setReason("材料齐全"); + + CompanyVerification result = verificationService.decide(adminUserId, 1L, req, ctx); + + assertEquals("APPROVED", result.getStatus()); + assertEquals(adminUserId, result.getReviewerId()); + verify(historyRepository).save(any()); + verify(auditService).log(eq(adminUserId), eq("DECIDE_VERIFICATION"), any(), any(), any(), eq(ctx)); + verify(notificationService).publishEvent(eq(companyUserId), any(), any(), any(), any(), any(), any()); + } + + @Test + void shouldRejectVerification() { + CompanyVerification pending = CompanyVerification.builder() + .id(1L).companyId(companyId).status("PENDING").submittedBy(companyUserId).build(); + when(verificationRepository.findById(1L)).thenReturn(Optional.of(pending)); + when(verificationRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + VerificationDecisionRequest req = new VerificationDecisionRequest(); + req.setDecision("REJECTED"); + req.setReason("营业执照不清楚"); + + CompanyVerification result = verificationService.decide(adminUserId, 1L, req, ctx); + + assertEquals("REJECTED", result.getStatus()); + assertEquals("营业执照不清楚", result.getDecisionReason()); + } + + @Test + void shouldRequireReasonForReject() { + CompanyVerification pending = CompanyVerification.builder() + .id(1L).companyId(companyId).status("PENDING").submittedBy(companyUserId).build(); + when(verificationRepository.findById(1L)).thenReturn(Optional.of(pending)); + + VerificationDecisionRequest req = new VerificationDecisionRequest(); + req.setDecision("REJECTED"); + req.setReason(null); + + BusinessException ex = assertThrows(BusinessException.class, + () -> verificationService.decide(adminUserId, 1L, req, ctx)); + assertEquals(ErrorCode.VERIFICATION_REJECT_REASON_REQUIRED.getCode(), ex.getErrorCode()); + } + + @Test + void shouldNotAllowDoubleApprove() { + CompanyVerification approved = CompanyVerification.builder() + .id(1L).companyId(companyId).status("APPROVED").submittedBy(companyUserId).build(); + when(verificationRepository.findById(1L)).thenReturn(Optional.of(approved)); + + VerificationDecisionRequest req = new VerificationDecisionRequest(); + req.setDecision("APPROVED"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> verificationService.decide(adminUserId, 1L, req, ctx)); + assertEquals(ErrorCode.VERIFICATION_ALREADY_APPROVED.getCode(), ex.getErrorCode()); + } + + // ── 权限测试 ──────────────────────────────────────────────── + + @Test + void shouldRejectNonCompanyUser() { + User studentUser = User.builder().id(999L).roles(Set.of(User.Role.STUDENT)).build(); + when(userRepository.findById(999L)).thenReturn(Optional.of(studentUser)); + + VerificationSubmitRequest req = new VerificationSubmitRequest(); + req.setMaterialsJson("{}"); + + assertThrows(BusinessException.class, + () -> verificationService.submit(999L, req, ctx)); + } + + @Test + void shouldRejectNonAdminUser() { + when(userRepository.findById(companyUserId)).thenReturn(Optional.of(companyUser)); + + assertThrows(BusinessException.class, + () -> verificationService.assertAdminRole(companyUserId)); + } + + @Test + void shouldAllowAdminRole() { + when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); + + assertDoesNotThrow(() -> verificationService.assertAdminRole(adminUserId)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/resources/application-integration-test.yml b/mydreamjob-server/src/test/resources/application-integration-test.yml new file mode 100644 index 0000000..540ba0a --- /dev/null +++ b/mydreamjob-server/src/test/resources/application-integration-test.yml @@ -0,0 +1,57 @@ +# 集成测试配置:使用 H2 内嵌数据库(Hibernate 自动建表) +# 激活方式: mvn test -Dspring-boot.run.profiles=integration-test + +spring: + config: + activate: + on-profile: integration-test + main: + allow-bean-definition-overriding: true + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + datasource: + url: jdbc:h2:mem:integrationdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + data: + redis: + enabled: false + rabbitmq: + enabled: false + +app: + redis: + enabled: false + notification: + mq-enabled: false + offer: + scheduler: + enabled: false + match: + notification: + enabled: false + resume: + storage-dir: ./target/resumes + export-dir: ./target/resume-exports + jwt: + secret: test-jwt-secret-key-for-integration-tests-only-not-for-production + expiration-ms: 3600000 + refresh-expiration-ms: 86400000 + sms: + mock: + enabled: true + expire-seconds: 300 diff --git a/mydreamjob-server/src/test/resources/application-test.yml b/mydreamjob-server/src/test/resources/application-test.yml new file mode 100644 index 0000000..7666c4b --- /dev/null +++ b/mydreamjob-server/src/test/resources/application-test.yml @@ -0,0 +1,37 @@ +# 测试环境配置 - 纯单元测试用(无需 Spring 上下文) + +spring: + config: + activate: + on-profile: test + jpa: + hibernate: + ddl-auto: validate + show-sql: false + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + data: + redis: + enabled: false + rabbitmq: + enabled: false + +app: + jwt: + secret: test-jwt-secret-key-for-unit-tests-only-not-for-production + expiration-ms: 3600000 + refresh-expiration-ms: 604800000 + + sms: + mock: + enabled: true + +logging: + level: + root: WARN + com.mydreamjob: DEBUG + org.springframework.security: WARN + org.flywaydb: DEBUG diff --git a/product-docs/新建 文本文档.txt b/product-docs/新建 文本文档.txt new file mode 100644 index 0000000..d7cf68f --- /dev/null +++ b/product-docs/新建 文本文档.txt @@ -0,0 +1,156 @@ +UI设计系统完整生成提示词|大学生求职决策APP「职来 mydreamjob」V1.3版 + +整体任务说明 + +任务:基于《职来(mydreamjob)产品开发方案V1.3》完整文档,生成面向Figma/Sketch可用的移动端标准化设计Token全套规范文档,产品定位大学生可信求职决策工具,核心用户:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR;产品核心差异化:企业多维度可信评分、自定义权重排序、评分证据链溯源、匹配门槛沟通机制。整体视觉风格专业、清爽、理性商务工具向,拒绝卡通插画、娱乐化渐变、花哨动效;仅输出浅色标准主题,不开发深色模式。 +画板基准尺寸:390pt × 844pt(iPhone15标准移动端),全局统一4pt网格布局,所有可点击交互触摸热区最小48×48dp。输出仅为规范文档,不生成页面效果图,全页面、组件、小组件、图表、弹窗、空状态、骨架屏严格遵循本套Token。 + +一、全局色彩规范(固定HEX,贴合产品评分分层、求职风险、匹配度业务逻辑) + +品牌主色系 + +主色:#1677FF(商务蓝,主按钮、筛选激活态、高分企业、核心操作入口、雷达图高亮) +辅助色:#4096FF(次级操作、次要标签、进度条、滑块激活轨道) + +业务状态标签色(匹配8维度评分体系、求职匹配分级) + +优质匹配色:#00B42A(企业综合高分、高薪福利、低加班、匹配度≥80%、合规无风险) +中性提示色:#FF7D00(企业中等评分、数据待补充、普通匹配、估算分数标记、权重模板提示) +风险警示色:#F53F3F(企业合规风险、低分避雷、匹配度不足阈值、仲裁/处罚记录、恶意评价预警) + +文本中性分层色 + +#1D2129:H1/H2标题、企业全称、岗位名称、综合总分、核心数据数字、治理/产业战略标题 +#4E5968:正文文本、岗位职责、企业介绍、员工评价、权重说明、维度解释、对比表格文字 +#86909C:辅助小字、数据来源标注、时间、行业规模标签、「暂未覆盖/数据补充中」占位文案、简历备注 + +容器与页面底色 + +#F2F3F5:全局页面背景、列表分割底色、筛选面板底层、空状态背景 +#FFFFFF:卡片底色、弹窗面板、底部选择器、搜索框、表单输入框、雷达图画布底色 + +二、字体层级规范(无衬线字体:Inter/思源黑体,兼顾学生、HR中老年群体可读性) + +全局约束:页面最小展示文字≥12px,长评价、对比表格行高宽松,保证弱视力人群阅读体验 +H1:24px 粗体,页面顶级标题、模块总标题、企业对比页大标题 +H2:20px 粗体,板块分区标题、雷达图标题、评分体系模块标题、产业/治理维度标题 +H3:17px 中等粗,卡片标题、岗位名称、分项维度标题、权重分组标题 +Body:15px,正文描述、企业详情、评价正文、权重调节说明、匹配诊断文案 +Caption:13px,备注、数据来源、时间标签、维度状态小字、标签说明、AI提示小字 + +三、间距、圆角、阴影全局Token + +统一复用间距(全局4pt网格倍数) + +8px、16px、24px、32px + +圆角统一规范 + +全量内容卡片、底部弹窗面板:12px +按钮、搜索框、输入框、筛选标签、滑块容器、评分模块小卡片:8px +底部弹出选择器:顶部两角圆角归零,底部保留12px圆角 + +阴影规范 + +全部组件仅使用极低透明度柔和浅灰投影,无厚重黑色硬阴影;评分卡片、企业列表卡片弱化阴影,保持清爽理性工具质感 + +四、图标统一规范 + +统一线性轮廓图标,标准尺寸24px,线条粗细固定2px;禁用填充、渐变、粗描边图标 + +1. 底部导航4个固定图标:首页、企业库、求职匹配、我的 +2. 核心业务专属线性图标:权重设置、雷达图、证据链溯源、企业对比、多条件筛选、简历上传、AI助手、匹配度校验、避雷警示、收藏、投递、聊天会话、企业规模矩阵、评分趋势曲线、仲裁风险、工商数据源、重置权重、保存自定义方案、空数据占位、筛选收起/展开 + +五、全量基础组件规范 + +1. 按钮组件(统一高度、圆角、热区) + +1)主按钮:#1677FF填充,固定高度48px,用于投递、保存权重、查看证据链、确认匹配、企业对比、提交简历核心操作 +2)次要描边按钮:白底+主色边框,高度48px,用于重置筛选、取消弹窗、清空权重、关闭对比面板 +3)文字按钮:无边框无填充,主色文字,用于查看更多评价、展开数据源、查看完整评分趋势、导出个人档案 + +2. 搜索框组件 + +高度44px,圆角8px,内置24px线性搜索图标;支持一键清除关键词,适配企业/岗位双维度检索 + +3. 列表行规范 + +企业列表、岗位列表单项统一行高56–60px;布局包含企业名称、综合分数、业务标签、行业/规模/地点信息,信息层级克制无冗余元素 + +4. 业务标签组件(三类固定样式,对应评分风险体系) + +绿色标签#00B42A:高分企业、高薪、低加班、高匹配、合规企业 +橙色标签#FF7D00:中等评分、数据估算、数据补充中、普通匹配 +红色标签#F53F3F:合规风险、劳动仲裁、低分避雷、匹配不足、处罚记录 + +六、产品差异化核心专属组件(贴合V1.3文档8维度分层评分、权重、溯源、企业矩阵逻辑) + +1. 评分可视化组件(产品核心) + +1)综合总分卡片:大号突出数字展示0-100综合分,搭配对应颜色状态标签,标注企业规模层级(小微企业/中型/大厂国企) +2)八维度雷达图容器:分层区分基础必选维度、扩展维度、企业价值维度;未覆盖维度灰色占位,标注「暂未覆盖」;估算分数添加橙色虚线区分真实数据 +3)企业规模×行业二维矩阵可视化卡片:圆形面积区分企业体量,横向行业、纵向规模分栏展示,首期高亮热门行业大中型企业 +4)评分趋势曲线卡片:折线展示企业历史分数波动,上升绿色、下降红色、平稳灰色 + +2. 自定义权重调节组件(核心差异化功能) + +1)维度权重滑块组件:0-100滑动条、实时百分比数值输入框、全局归一化计算提示 +2)权重预设模板卡片:求稳版、薪资优先版、成长冲刺版、稳健通勤版,一键填充权重 +3)权重重置、保存自定义方案按钮组 + +3. 评分证据链溯源组件(可信核心) + +1)数据源详情弹窗:单维度分数点击唤起,分栏展示工商公示、司法仲裁、员工审核评价、行业研报;每条数据标注来源渠道、采集时间 +2)证据快照卡片:单条原始数据独立容器,区分官方数据源/用户评价,风险记录标红高亮 + +4. 企业并排对比组件 + +支持2-3家企业横向分栏对比卡片;调整权重实时同步更新各维度分数差异,高低分自动用绿/红底色标注差值 + +5. 匹配门槛校验组件 + +1)匹配度进度卡片:展示求职者与岗位匹配分数,低于阈值红色警示,标注差距维度 +2)沟通拦截弹窗:匹配不足时弹出提示,禁止开启聊天;达标后展示双向意向确认面板 + +6. AI求职辅助组件 + +AI打招呼文案输入框、投递前匹配诊断卡片,AI生成内容统一标注「AI辅助建议」小字 + +七、筛选、弹窗、选择器通用组件 + +1. 顶部多条件筛选栏:行业、企业规模、薪资区间、工作平衡、合规状态筛选项,标签切换样式统一 +2. 底部弹出选择器:城市、毕业年限、求职意向、权重模板选择面板 +3. 全局通用弹窗:操作确认、风险警示、证据溯源详情、企业完整评分说明 +4. Toast轻提示:操作成功、保存权重、投递提交、刷评价拦截、违规处置提示 + +八、求职流程专项组件 + +简历上传卡片、简历信息填写表单、投递状态标签(待企业查看/意向匹配/面试邀约/Offer)、聊天会话面板、候选人管道列表、面试记录卡片、求职落选原因留存模块、隐私脱敏提示组件 + +九、空状态、加载、骨架屏兜底组件(全页面适配) + +1. 骨架屏:企业列表骨架、评分雷达图骨架、企业详情骨架、对比页面骨架、权重设置页骨架 +2. 空页面:无匹配岗位、无意向企业、无员工评价、暂无投递记录、暂无企业入驻 +3. 数据占位模块:维度「暂未覆盖」「数据估算」灰色占位卡片、网络异常兜底页、冷启动引导页 + +十、完整覆盖页面清单(全部页面强制复用本套Token) + +1. 首页:高分企业榜单、快速筛选入口、权重模板快捷入口 +2. 企业库:企业搜索、多维度筛选、企业列表卡片 +3. 企业详情页:8维度分层雷达图、分项评分、证据链溯源入口、员工评价、收藏投递按钮、评分趋势 +4. 自定义权重配置页:维度滑块调节、预设模板、一键重排企业清单 +5. 企业对比页:2-3家企业横向维度对比、权重实时联动更新 +6. 求职匹配页:岗位匹配列表、匹配度标签、投递前置诊断 +7. 简历档案页:简历上传、个人信息填写、AI投递话术助手 +8. 沟通会话页:匹配门槛校验拦截、聊天面板、意向确认弹窗 +9. 个人中心:投递记录、收藏企业、我的评价、账号隐私设置、求职档案导出 +10. 企业端入驻/岗位发布页:企业资质认证、岗位编辑、评分数据查看、候选人匹配列表 + +输出硬性约束规则 + +1. 仅输出标准化完整设计Token规范文档,不产出任何页面效果图、截图、插画素材 +2. 所有视觉组件、图表、小组件、弹窗、骨架屏、空态严格遵循色彩、字体、间距、图标统一规范 +3. 全程禁止卡通插画、高饱和渐变、炫酷动效、深色模式,保持商务清爽工具视觉调性 +4. 所有业务视觉区分贴合V1.3产品文档核心逻辑:分层评分体系、权重自定义、证据溯源、匹配沟通门槛、企业二维覆盖矩阵,用户可直观区分企业稳定性、合规风险、匹配优劣、数据完整度 +5. 所有标注文案、占位提示、状态标签文字字号不低于12px,适配学生、HR多年龄段阅读需求 +6. 全部可点击控件热区统一≥48×48dp,符合移动端触摸交互标准 \ No newline at end of file diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md new file mode 100644 index 0000000..6bd33b1 --- /dev/null +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md @@ -0,0 +1,673 @@ +# 职来 AI 求职招聘平台产品提案(v3.1 修订版) + +**文档密级:** 机密 +**文档版本:** v3.1 +**编制日期:** 2026 年 7 月 30 日 +**计划周期:** 2026 年 8 月 1 日—2026 年 9 月 30 日(9 个日历周,共 61 天) +**文档状态:** 待批准 +**编制人:** 项目组 +**审阅人:** 公司管理层 + +--- + +## 修订记录(v3.0 → v3.1) + +本版本针对 **2 人团队** 的实际产能做了系统性修订,核心原则是:**砍范围保日期、合规前置、留缓冲**。 + +| 变更点 | v3.0 | v3.1 | 原因 | +| ----------- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------ | +| 交付周期 | 8 周(与 10 个 Sprint 周、日历 61 天三处矛盾) | **9 个日历周(61 天)**,Sprint 重排为 6 段 | 消除时间线自相矛盾 | +| 上线形态 | 公开测试版 | **邀请制封闭内测**(公测待资质齐备后开放) | 资质周期 4-8 周是硬约束,内测形态可按期交付 | +| P0 需求数量 | 约 29 项 | **21 项**(含 FR-0601 简版通知卡片) | 2 人 9 周无法承载 29 项 P0,且 v3.1 的 P0 多为合并收敛后的条目 | +| 双角色切换 | P0 | 移至 P1(MVP 注册即定角色) | 双角色数据隔离污染全部模块权限设计 | +| 多版本简历 | P0 | 移至 P1(MVP 单版本) | 目标用户为应届生,单简历够用 | +| AI 功能 | 3 项 P0(解析/诊断/匹配解释) | **1 项 P0(简历诊断,解析作为其内部环节)** | 1 个做深的亮点 > 3 个做浅的功能 | +| 面试/Offer 管理 | P1 但已排入 Sprint | 整体移至 P1 路线图,MVP 用 IM 系统卡片承载 | 节省约 1 周工作量 | +| 运营审核后台 | 独立后台 | **最简 Web 审核页** | 内测期日审核量 <10 单,不值得独立后台 | +| 举报处理 SLA | 30 分钟 | **24 小时** | 2 人开发兼任运营无法保证 30 分钟响应 | +| 阿里云 VOD | 在技术栈中 | **移除**(FR 中无任何视频功能) | 死重 | +| 发布策略 | 灰度 5%→20%→100% | 直接发布 + 热修复预案 | 无存量用户,灰度无意义 | +| 合规清单 | 缺软著、App 备案 | **补软件著作权、App 备案**;等保三级→二级起步;增值电信证注明真实周期(2-3 个月+注册资本门槛) | 软著/App 备案是安卓商店上架前置条件 | +| AI 合规 | 未明确路径 | **调用已备案第三方大模型 API + 登记** | 避免独立算法备案的长周期 | +| 新增章节 | 无 | **第 0 周资质启动**、**冷启动与种子计划**、**MVP 范围边界(不做清单)** | 双边平台冷启动是内测成败关键 | +| 总工时 | 792h(超载 24%) | **576h(产能 720h,预留约 20% 缓冲)** | 见第 5 章 | + +--- + +## 执行摘要 + +"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决求职市场三大痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 + +**核心价值主张:** + +- **可信**:企业及岗位经双重审核,杜绝虚假信息 +- **高效**:AI 简历诊断为求职者提供可执行的改进方案 +- **直接**:求职者与招聘方基于明确意向的直接沟通 + +**项目概要(v3.1 调整后):** + +- 技术基座:React Native 0.85 + TypeScript 5.8 + 阿里云生态 +- 团队规模:2 人(前端负责人 + 后端负责人)+ 公司负责人兼运营 +- 交付周期:9 个日历周(2026.8.1 - 2026.9.30) +- 上线形态:**邀请制封闭内测**(iOS + Android,30-50 名种子用户);公测在资质齐备后开放 +- 预算范围:¥2,000 - ¥7,000 元/月运营成本 + 一次性合规费用(软著加急、开发者账号等,约 ¥3,000-5,000) + +--- + +## 1. 产品需求文档(PRD) + +### 1.1 用户画像与核心场景 + +| 用户类型 | 画像描述 | 核心痛点 | 期望解决 | +| ------------------- | -------------------------- | ----------------- | ------------ | +| **应届毕业生**(MVP 首要用户) | 22-25 岁,在校生或应届毕业,1-3 份实习经验 | 缺乏求职经验、简历不规范、面试焦虑 | AI 简历诊断、可信岗位 | +| **职场新人** | 25-28 岁,1-3 年工作经验 | 信息过载、岗位匹配度低 | 精准岗位筛选、直接沟通 | +| **企业招聘者**(内测期为种子企业) | HR/招聘经理 | 简历筛选耗时、候选人追踪困难 | 候选人看板、管道状态机 | +| **企业管理员**(内测期从简) | HRD/业务负责人 | 企业认证、团队管理 | 企业工作台(基础版) | + +> v3.1 说明:资深求职者(多版本简历、Offer 比较)相关需求整体延后至 P1,MVP 聚焦应届生单角色闭环。 + +### 1.2 功能需求详述 + +#### FR-01 账号与身份认证 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | ---------------- | --- | ---------------------------- | +| FR-0101 | 手机号注册与验证码登录 | P0 | 验证码 60 秒有效,同一手机号 5 分钟内限发 3 次 | +| FR-0102 | 注册时选定角色(求职者/招聘者) | P0 | 单账号单角色,注册后不可切换;角色决定数据权限 | +| FR-0103 | 账号注销 | P0 | 7 天冷静期,到期删除账号及关联数据(合规底线,不可砍) | +| FR-0104 | 第三方登录(微信) | P1 | 与手机号绑定 | +| FR-0105 | 双角色切换 | P1 | 同账号双角色切换,数据隔离 | +| FR-0106 | 数据导出 | P2 | 内测期由客服人工处理,公测前实现自助导出 | + +**用户故事:** + +> 作为一名应届生,我希望能用手机号快速注册登录,立即开始使用平台。 + +#### FR-02 求职者职业档案 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | --------- | --- | ----------------------------------- | +| FR-0201 | 结构化职业档案 | P0 | 基本资料、教育经历(多段)、工作/实习经历(多段)、技能标签、求职偏好 | +| FR-0202 | 简历上传(单版本) | P0 | 支持 PDF/DOCX,单文件 ≤10MB,可替换 | +| FR-0203 | 隐私设置(基础版) | P0 | 简历可见范围、联系方式展示开关 | +| FR-0204 | 多版本简历管理 | P1 | 多版本创建、投递时选择 | +| FR-0205 | 数据授权细粒度控制 | P1 | 按企业/按维度授权 | + +**用户故事:** + +> 作为一名求职者,我希望能快速建立一份完整档案并上传简历,让招聘方看到真实的我。 + +#### FR-03 企业认证与岗位管理 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | --------------- | --- | ------------------------------------ | +| FR-0301 | 企业主体信息 + 营业执照上传 | P0 | 企业名称、统一社会信用代码、行业、规模;OCR 辅助识别,人工审核兜底 | +| FR-0302 | 认证审核流 | P0 | 提交→待审核→补充材料/驳回→已认证 | +| FR-0303 | 岗位创建与编辑 | P0 | 标题、职责、要求、薪资、地点、用工类型 | +| FR-0304 | 岗位审核与发布 | P0 | 敏感词检测 + 人工审核,内测期 1 个工作日内完成(审核人见 5.3) | +| FR-0305 | 岗位搜索与筛选 | P0 | 关键词、城市、行业、薪资、经验、学历 | +| FR-0306 | 岗位收藏与分享 | P1 | 收藏夹、分享链接快照 | + +**用户故事:** + +> 作为一名企业招聘者,我希望提交认证后 1 个工作日内获得结果,尽快发布岗位。 + +#### FR-04 投递与候选人管道 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | ------- | --- | ---------------------------------------------------------------------- | +| FR-0401 | 主动投递 | P0 | 简历快照绑定投递,不可篡改 | +| FR-0402 | 状态机与时间线 | P0 | 7 阶段:已投递→已查看→沟通中→待面试→面试中→Offer→已录用;含终止状态(撤回/不合适/拒绝/过期);每次变更记录操作者、时间、原因 | +| FR-0403 | 重复投递控制 | P1 | 同一岗位 30 天内不可重复投递 | + +**用户故事:** + +> 作为一名求职者,我希望能实时查看投递状态,了解招聘进展。 + +#### FR-05 招聘沟通与实时通讯 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | ----- | --- | -------------------------------------- | +| FR-0501 | 会话建立 | P0 | 仅在投递/授权后可建立会话 | +| FR-0502 | 消息收发 | P0 | 文字消息、系统卡片(含面试/Offer 通知卡片),已读状态 | +| FR-0503 | 未读提醒 | P0 | Tab 栏红点、推送通知、会话列表排序 | +| FR-0504 | 举报与屏蔽 | P0 | **24 小时内响应**(由运营责任人处理,见 5.3),屏蔽后不再接收消息 | +| FR-0505 | 消息撤回 | P1 | 2 分钟内可撤回 | + +**用户故事:** + +> 作为一名求职者,我希望收到招聘方消息时第一时间得到通知。 + +#### FR-06 面试与 Offer 管理(整体 P1,MVP 不建独立模块) + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | -------------- | ------ | -------------------------------------- | +| FR-0601 | 面试/Offer 通知卡片 | P0(简版) | 通过 IM 系统卡片发送面试时间、地点、Offer 关键信息,状态机同步流转 | +| FR-0602 | 面试安排/确认/改期 | P1 | 独立面试模块 | +| FR-0603 | 结构化评分卡 | P1 | 多维度评分 | +| FR-0604 | Offer 创建/接受/拒绝 | P1 | 结构化 Offer 流程 | + +> v3.1 说明:内测期真实场景中 HR 通过沟通约定面试与 Offer,平台用系统卡片留痕即可,独立模块在 P1 阶段一补齐。 + +#### FR-07 AI 求职助手(收敛为单一亮点) + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | ----------- | --- | -------------------------------------------------------------------- | +| FR-0701 | **AI 简历诊断** | P0 | 上传/填写档案后输出:优势分析、缺口识别、可执行改进建议;内部含简历解析环节,字段抽测准确率 ≥85%;内测用户"有用"评价率 ≥70% | +| FR-0702 | 岗位匹配解释 | P1 | 匹配项、缺口、建议提问 | +| FR-0703 | 打招呼建议 | P1 | 3 条可编辑候选文案 | +| FR-0704 | 面试训练 | P1 | 模拟问题、回答点评 | + +**AI 安全边界(保留不变):** + +- AI 输出显著标注"AI 生成/AI 辅助建议" +- 不自动淘汰候选人,不生成录用承诺 +- 不编造经历、公司事实 +- 高风险输出人工复核 + +**AI 合规路径(v3.1 新增):** 调用**已完成备案的第三方大模型 API**(如阿里云百炼/通义),我方履行登记手续即可,避免独立算法备案的长周期。AI Adapter 层保持可切换设计。 + +#### FR-08 运营审核与风控 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +| ------- | --------- | --- | ---------------------------------------------- | +| FR-0801 | 企业/岗位审核队列 | P0 | **最简 Web 审核页**(不做独立后台):待审核/已通过/已驳回列表 + 通过/驳回操作 | +| FR-0802 | 投诉处理流 | P0 | 举报→限制分发→复核→处置→申诉,24 小时响应 | +| FR-0803 | 数据删除任务 | P1 | 注销后数据可追踪删除(内测期脚本执行) | + +### 1.3 MVP 范围边界(明确不做清单) + +以下功能 **MVP 明确不做**,统一进入 P1 路线图,开发中不得临时加回: + +- 双角色切换、微信登录 +- 多版本简历管理、作品集 +- 独立面试/Offer 管理模块(用 IM 系统卡片替代) +- 消息撤回、岗位收藏与分享 +- AI 岗位匹配解释、打招呼建议、面试训练 +- 独立运营审核后台、视频相关功能(VOD/视频面试) +- PWA、Web 企业自助管理端 + +### 1.4 非功能需求 + +| 类别 | 指标 | 目标值(内测) | 目标值(公测 P1) | +| ------- | ------------- | -------------------- | ---------- | +| **性能** | 应用冷启动时间 | ≤ 3 秒 | ≤ 3 秒 | +| | 岗位详情页加载 | ≤ 2 秒 | ≤ 2 秒 | +| | AI 诊断响应时间 | ≤ 60 秒(异步任务 + 进度提示) | ≤ 30 秒 | +| **可用性** | 系统可用性 | ≥ 99% | ≥ 99.5% | +| | 推送到达率 | ≥ 95% | ≥ 95% | +| **安全性** | 敏感数据加密 | 全量加密 | 全量加密 | +| | 高危越权 | 0 | 0 | +| | 审计日志 | 高风险操作 100% 记录 | 同左 | +| **兼容性** | iOS / Android | 13.0+ / 7.0+(API 24) | 同左 | +| **容量** | 注册用户 | 500(邀请制) | 50,000 | +| | 消息吞吐 | 1,000/日 | 100,000/日 | + +--- + +## 2. 功能模块与路线图 + +### 2.1 MVP 开发计划(9 周,6 段 Sprint) + +#### 第 0 周:资质与冷启动启动(与 Sprint 1 并行,责任人以公司负责人为主) + +| 事项 | 责任人 | 说明 | +| ---------------------- | ----- | ----------------------- | +| 确认运营公司主体已注册,启动营业执照相关流程 | 公司负责人 | 一切资质的前提 | +| 启动人力资源服务许可证、网络招聘服务备案申请 | 公司负责人 | 周期 4-8 周,**本项目真正的关键路径** | +| 启动软件著作权申请(建议加急) | 公司负责人 | 安卓商店上架必备,正常 1-2 个月 | +| 启动 ICP 备案 | 张顺 | 周期 2-4 周,与域名/服务器绑定 | +| 洽谈种子企业(目标 10-20 家) | 公司负责人 | 见 2.2 冷启动计划 | + +#### Sprint 1:基础架构与账号档案(W1-W2,8/1-8/14) + +| 交付物 | 负责人 | 验收标准 | +| --------------- | ------ | --------------------- | +| RN 工程脚手架 | 杨幸幸 | 可运行、组件库就绪 | +| 后端 API 框架 | 张顺 | NestJS + Prisma + JWT | +| 手机号登录/注销、角色选定 | 杨幸幸/张顺 | 双端角色闭环 | +| 隐私同意流程 | 杨幸幸 | 版本化记录,可撤回 | +| 结构化档案 + 单版本简历上传 | 杨幸幸/张顺 | OSS 直传生效 | + +#### Sprint 2:企业认证与岗位(W3-W4,8/15-8/28) + +| 交付物 | 负责人 | 验收标准 | +| --------------- | ------ | --------------- | +| 企业认证表单 + OCR 识别 | 杨幸幸/张顺 | OCR 辅助 + 人工审核兜底 | +| 认证审核流 | 张顺 | 提交→审核→通过/驳回 | +| 岗位创建/编辑 | 杨幸幸 | 完整表单、实时校验 | +| 岗位审核/发布 | 张顺 | 敏感词检测、发布生效 | +| 搜索与筛选 | 杨幸幸 | 多条件组合搜索 | +| **种子企业入驻试用** | 公司负责人 | ≥5 家企业完成认证并发布岗位 | + +#### Sprint 3:投递管道与沟通(W5-W6,8/29-9/11) + +| 交付物 | 负责人 | 验收标准 | +| ------------ | ----- | --------------- | +| 投递流程(简历快照绑定) | 杨幸幸 | 快照不可篡改 | +| 7 阶段状态机 | 张顺 | 流转 + 时间线 + 终止状态 | +| 候选人看板 | 杨幸幸 | 招聘者视图、筛选排序 | +| 环信 IM 集成 | 张顺 | 会话建立、消息收发、系统卡片 | +| 未读与推送 | 张顺 | 阿里云推送、红点通知 | +| **内测用户招募启动** | 公司负责人 | 目标 30-50 名求职者 | + +#### Sprint 4:AI 简历诊断 + 审核页(W7,9/12-9/18) + +| 交付物 | 负责人 | 验收标准 | +| ---------- | --------------- | --------------------------- | +| AI 简历诊断 | 张顺(服务)+ 杨幸幸(交互) | 诊断报告生成、AI 标注、≥85% 字段准确率抽测通过 | +| 最简 Web 审核页 | 张顺 | 企业/岗位/投诉三队列可用 | +| 举报与屏蔽 | 杨幸幸/张顺 | 端到端走通 | + +#### Sprint 5:全量测试与内测准备(W8,9/19-9/25) + +| 交付物 | 负责人 | 验收标准 | +| ------ | ----------- | ------------- | +| 全量回归测试 | 两人 | 核心旅程 100% 通过 | +| 性能调优 | 杨幸幸 | 冷启动 ≤3s,列表流畅 | +| 安全扫描 | 张顺 | 高危漏洞 0 | +| 法务文档定稿 | 公司负责人 + 项目组 | 见 6.3,全部完成审校 | +| 应用商店提审 | 张顺 | 软著/App 备案材料齐备 | + +#### Sprint 6:邀请制内测发布(W9,9/26-9/30) + +| 交付物 | 负责人 | 验收标准 | +| ---------- | --- | ---------------- | +| 内测包发布(邀请制) | 张顺 | 30-50 名种子用户可下载使用 | +| 内测问题收集与热修复 | 两人 | P0 缺陷 24h 内修复 | +| 内测复盘报告 | 两人 | 留存、诊断满意度、岗位在架数 | + +### 2.2 冷启动与种子计划(v3.1 新增) + +招聘平台是双边市场,**没有岗位就没有内测意义**。内测成功的量化目标: + +| 指标 | 目标值 | 达成路径 | +| -------- | --------- | --------------------------- | +| 种子企业 | 10-20 家认证 | 公司负责人商务拓展(熟人企业、本地企业、自有公司岗位) | +| 在架岗位 | ≥30 个 | Sprint 2 起种子企业陆续发布 | +| 内测求职者 | 30-50 人 | 高校社群、校友群、朋友圈定向邀请 | +| 投递行为 | 人均 ≥3 次投递 | 岗位质量与匹配度保障 | +| AI 诊断使用率 | ≥50% 内测用户 | 诊断作为档案完成后的引导步骤 | + +### 2.3 长期路线图(P1:内测后 4-8 个月) + +#### 阶段一:补齐与增长 + +| 时间 | 功能模块 | 业务目标 | +| ---- | ------------------------ | ------------ | +| +1 月 | 面试/Offer 管理模块(FR-06 完整版) | 流程线上化 | +| +1 月 | 多版本简历 + 双角色切换 | 资深用户承接 | +| +2 月 | AI 岗位匹配解释 + 打招呼建议 | AI 使用率提升 40% | +| +2 月 | 智能推荐算法 | 岗位匹配效率提升 30% | +| +3 月 | AI 面试训练 | 用户粘性提升 | +| +3 月 | 企业付费席位(待增值电信证齐备) | 首个付费客户签约 | +| +4 月 | 独立运营后台 + 招聘漏斗分析 | 运营效率提升 | + +#### 阶段二:平台化升级 + +| 时间 | 功能模块 | 业务目标 | +| ---- | ---------------- | -------- | +| +5 月 | 企业 ATS 对接、校园招聘专区 | 大客户与高校合作 | +| +6 月 | 视频面试、背调测评服务 | 增值服务 | +| +7 月 | 多 AI 模型路由、企业知识库 | 成本优化 30% | +| +8 月 | 城市/行业运营平台 | 区域扩张 | + +--- + +## 3. 技术方案 + +### 3.1 系统架构设计 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端层 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ React Native 0.85 App │ │ +│ │ ┌──────────┬──────────┬──────────┐ │ │ +│ │ │ 求职者端 │ 招聘者端 │ 设置中心 │ (单角色,注册选定)│ │ +│ │ └──────────┴──────────┴──────────┘ │ │ +│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ │ Zustand │ Navigation│ Axios │ Services │ │ │ +│ │ └──────────┴──────────┴──────────┴──────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────┬──────────────────────────────────┘ + │ HTTPS +┌──────────────────────────────┼──────────────────────────────────┐ +│ 服务端层 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ API Gateway (Nginx):SSL · 限流 · 日志 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ NestJS 应用服务 │ │ +│ │ Auth │ Profile │ Org/Job │ Application │ Chat │ AI │ │ │ +│ │ Review │ Notification │ Privacy │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ MySQL RDS│ │ Redis │ │ 阿里云 │ │ AI │ │ +│ │ │ │ 缓存/队列│ │ OSS │ │ Adapter │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└──────────────────────────────┬──────────────────────────────────┘ +┌──────────────────────────────┼──────────────────────────────────┐ +│ 第三方:环信 IM │ 阿里云推送 │ 已备案大模型 API │ OCR │ +│ 运营侧:最简 Web 审核页(PC 浏览器访问,复用同一套 API + RBAC) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +> v3.1 架构变更:移除阿里云 VOD(无视频功能);审核后台降级为最简 Web 审核页;AI 走已备案第三方大模型 API;单角色模型简化权限设计。 + +### 3.2 技术栈选型 + +#### 移动端技术栈 + +| 层级 | 技术组件 | 版本 | 选型理由 | +| ---- | ----------------------- | ------- | ---------------- | +| 核心框架 | React Native | 0.85.3 | 跨平台,团队已有完整项目经验 | +| 编程语言 | TypeScript | 5.8.3 | 类型安全 | +| 状态管理 | Zustand | 4.5.5 | 轻量级,API 简洁 | +| 路由导航 | React Navigation | 6.x | 原生栈导航 | +| 网络请求 | Axios | 1.12.2 | 拦截器机制完善 | +| 实时通讯 | 环信 IM SDK | ^1.15.3 | 国内成熟 IM 服务 | +| 消息推送 | 阿里云推送 | ^1.0.5 | 国内厂商通道,到达率高 | +| 文件处理 | 阿里云 OSS | - | Presigned URL 直传 | +| 图片压缩 | react-native-compressor | ^2.0.2 | 硬件编码 | + +#### 服务端技术栈 + +| 层级 | 技术组件 | 选型理由 | +| ------ | ----------------------------- | ---------------------- | +| 运行时 | Node.js 20 LTS | 与前端 TS 统一 | +| 框架 | NestJS | 依赖注入、守卫、拦截器 | +| 数据库 | MySQL 8.0(阿里云 RDS) | 托管服务,免运维 | +| 缓存/队列 | Redis 7.x + BullMQ | Token、限流、AI 异步任务 | +| ORM | Prisma | 类型安全、迁移管理 | +| 认证 | JWT + Passport | Access 2h + Refresh 7d | +| 审核页 | 最简 Web 页(复用 API + RBAC 审核员角色) | 不建独立后台 | +| API 文档 | Swagger/OpenAPI | 自动生成 | +| 日志 | Winston + Morgan | 结构化日志 | + +### 3.3 数据安全设计 + +| 安全维度 | 实施措施 | +| ---- | --------------------------- | +| 传输安全 | 全量 HTTPS + HSTS,TLS 1.2+ | +| 存储加密 | AES-256-GCM(手机号、简历内容) | +| 身份认证 | JWT + Refresh Token | +| 权限控制 | RBAC(求职者/招聘者/审核员)+ 资源级鉴权 | +| 输入校验 | 前后端双重校验,class-validator | +| 审计日志 | 查看简历、导出、删除、封禁等高风险操作 100% 记录 | +| 数据脱敏 | 列表字段自动脱敏 | +| 备份策略 | RDS 每日全量 + binlog,保留 30 天 | +| 密钥管理 | 环境变量注入,密钥不入库 | + +### 3.4 核心 API 设计规范 + +统一响应格式: + +```typescript +interface ApiResponse { + code: number; // 200=成功,4xx=客户端错误,5xx=服务端错误 + data: T; + message: string; + requestId: string; + timestamp: number; +} +``` + +版本化路由(MVP 范围): + +``` +/api/v1/auth/* 认证(登录/注销/角色) +/api/v1/profile/* 求职者档案 +/api/v1/organizations/* 企业认证 +/api/v1/jobs/* 岗位 CRUD + 搜索 +/api/v1/applications/* 投递管道 +/api/v1/conversations/* 会话 +/api/v1/messages/* 消息(含系统卡片) +/api/v1/ai/* AI 简历诊断 +/api/v1/reviews/* 审核队列(Web 审核页复用) +/api/v1/notifications/* 通知推送 +/api/v1/privacy/* 隐私与注销 + +(P1 预留:/api/v1/interviews/* /api/v1/offers/*) +``` + +关键集成点: + +| 集成服务 | 集成方式 | 备注 | +| ------- | ---------------- | ---------------- | +| 环信 IM | REST API + SDK | 服务端建会话,客户端收发 | +| 阿里云推送 | 官方 SDK | 服务端触发 | +| 阿里云 OSS | Presigned URL | 客户端直传 | +| 大模型 API | Provider Adapter | **仅用已备案服务商**,可切换 | +| OCR | 阿里云 OCR | 营业执照识别,人工审核兜底 | + +--- + +## 4. 项目时间线 + +### 4.1 阶段划分与里程碑 + +``` +2026年8月 2026年9月 +├─ W1 ──┼─ W2 ──┼─ W3 ──┼─ W4 ──┼─ W5 ──┼─ W6 ──┼─ W7 ──┼─ W8 ──┼─ W9 ─┤ +│ │ +├─ Sprint 1 ──────┼─ Sprint 2 ──────┼─ Sprint 3 ──────┼─ Sp.4 ─┼─ Sp.5 ─┼ Sp.6 │ +│ 基础+账号+档案 │ 企业认证+岗位 │ 投递+IM+推送 │AI+审核 │全量测试│内测 │ +│ │ +│ 第0周(并行):资质申请 + 软著 + 种子企业洽谈 ──────────────────────────────▶│ +│ │ +│ M1 基线 M2 岗位闭环 M3 投递闭环 M4 AI M5 内测发布│ +│ 8/14 8/28 9/11 9/18 9/30 │ +``` + +### 4.2 里程碑定义 + +| 里程碑 | 日期 | 交付标准 | 验收人 | +| -------- | ----- | ------------------------- | ----- | +| M1:技术基线 | 8月14日 | 工程可运行、登录/档案闭环 | 项目组 | +| M2:岗位闭环 | 8月28日 | 企业可认证、岗位可发布可搜索、≥5 家种子企业入驻 | 公司负责人 | +| M3:投递闭环 | 9月11日 | 投递全流程演示、IM 可收发 | 公司负责人 | +| M4:AI 就绪 | 9月18日 | 简历诊断上线、审核页可用 | 项目组 | +| M5:内测发布 | 9月30日 | P0 缺陷为 0、30-50 名种子用户邀请入内 | 公司负责人 | + +### 4.3 风险缓冲与应急策略 + +| 场景 | 触发条件 | 应对措施 | +| --------------- | -------------- | --------------------------------------------------------------------- | +| 单 Sprint 延期 | 完成率 < 70% | 削减当期 P1 内容,W9 为全局缓冲 | +| 核心模块阻塞 | 超过 3 天无法推进 | 暂停该模块并行开发其他,24h 内技术复议 | +| **人员不可用(红色风险)** | 任一成员 > 3 天无法工作 | **立即冻结范围**(保 FR-01~05 主干,砍 AI),内测顺延;2 人团队一人缺失即产能减半,不做"最大延期 10 天"的乐观假设 | +| 资质办理延迟 | 第 6 周仍未受理 | 不影响邀请制内测;公测计划顺延,加急渠道跟进 | +| 第三方集成风险 | 环信/推送集成超估 50% | 降级:环信 REST 兜底;推送先单通道(APNs/厂商通道二选一) | +| AI 效果不达标 | 抽测准确率 < 85% | 诊断改为"规则模板 + LLM 润色"降级方案,先上线后迭代 | + +--- + +## 5. 团队分配 + +### 5.1 组织架构 + +``` + ┌─────────────┐ + │ 公司负责人 │ + │ 产品决策 + │ + │ 运营第一责任人│ + └──────┬──────┘ + ┌──────┴──────┐ + │ 项目组 │ + └──────┬──────┘ + ┌────────────┴────────────┐ + ┌──────┴──────┐ ┌──────┴──────┐ + │ 杨幸幸 │ │ 张顺 │ + │ 前端负责人 │ │ 后端负责人 │ + └─────────────┘ └─────────────┘ +``` + +### 5.2 详细分工表(v3.1 重估,合计 576h,预留约 20% 缓冲) + +#### 杨幸幸:前端负责人 — 合计 272h(34 人天,占可用产能 76%) + +| 职责领域 | 具体任务 | 预估工时 | +| ----- | ------------------- | ---- | +| 架构设计 | RN 工程架构、组件规范、设计系统 | 28h | +| 账号模块 | 手机号登录、角色选定、注销、隐私同意 | 24h | +| 档案模块 | 结构化档案、单版本简历上传、隐私设置 | 36h | +| 企业模块 | 企业认证表单、招聘者视图 | 24h | +| 岗位模块 | 岗位创建/编辑/搜索/详情 | 36h | +| 投递模块 | 投递流程、候选人看板 | 24h | +| 沟通模块 | 会话列表、聊天详情、未读状态 | 32h | +| AI 交互 | 简历诊断工作台(报告展示、AI 标注) | 24h | +| 测试验收 | 组件测试、核心旅程 E2E | 24h | +| 发布支持 | 构建打包、性能优化 | 12h | +| 文档编写 | 前端文档、用户帮助 | 8h | + +#### 张顺:后端负责人 — 合计 304h(38 人天,占可用产能 84%) + +| 职责领域 | 具体任务 | 预估工时 | +| ----- | ------------------------- | ---- | +| 架构设计 | NestJS 架构、数据库设计、API 规范 | 28h | +| 基础设施 | Docker、CI、监控日志(托管服务优先) | 20h | +| 认证模块 | JWT、单角色权限、注销与数据删除 | 16h | +| 用户模块 | 档案 CRUD、简历 OSS 存储 | 28h | +| 企业模块 | 企业认证、OCR、审核流 | 28h | +| 岗位模块 | 岗位 CRUD、敏感词审核、搜索 | 36h | +| 投递模块 | 投递、状态机、快照 | 24h | +| 通讯模块 | 环信 IM、阿里云推送 | 32h | +| AI 模块 | 简历诊断(解析+诊断一体)、BullMQ 异步队列 | 24h | +| 审核模块 | 最简 Web 审核页(企业/岗位/投诉) | 16h | +| 部署运维 | 部署、告警、备份 | 20h | +| 测试安全 | 集成测试、安全扫描 | 24h | +| 文档编写 | API 文档、部署文档 | 8h | + +> 产能口径:9 周 × 40h/周 = 每人 360h,两人合计 720h。计划 576h,整体预留约 20% 缓冲用于联调、返工与突发。张顺仍为瓶颈角色,若进度吃紧,按风险表先砍 AI 再砍审核页美化。 + +### 5.3 运营职责(v3.1 新增) + +| 事项 | 第一责任人 | 说明 | +| --------- | ----------- | ------------------------------- | +| 企业/岗位审核 | 公司负责人 | 内测期日量 <10 单,每日固定时段处理,SLA 1 个工作日 | +| 投诉举报处理 | 公司负责人 | **24 小时内响应** | +| 种子企业拓展 | 公司负责人 | 第 0 周启动,M2 前 ≥5 家入驻 | +| 内测用户招募 | 公司负责人 + 项目组 | Sprint 3 启动 | +| 客服与数据导出请求 | 公司负责人 | 内测期人工处理 | + +### 5.4 协作机制 + +| 机制 | 频率 | 产出物 | +| ----------- | ---- | ------------------------------- | +| 每日站会(15 分钟) | 每日 | 进展同步 | +| 周会评审 | 每周五 | 周报告、下周计划 | +| Sprint 验收 | 每段结束 | 验收结论 | +| 代码评审 | PR 时 | 互相 review(两人互为唯一 reviewer,必须执行) | + +--- + +## 6. 合规要求 + +### 6.1 资质证书清单 + +#### 必须获取(按启动时间排序,全部为第 0 周启动) + +| 序号 | 证书/资质 | 主管部门 | 预估周期 | 影响 | 优先级 | +| -- | ------------------- | ------- | ----------------------------- | ------------------- | --- | +| 1 | 营业执照(运营主体) | 市场监督管理局 | 1-2 周 | 一切资质前提 | P0 | +| 2 | ICP 备案 | 工信部 | 2-4 周 | 域名/服务上线 | P0 | +| 3 | **软件著作权**(v3.1 新增) | 版权保护中心 | 1-2 个月(加急 2-4 周) | **安卓商店上架必备**,建议立即加急 | P0 | +| 4 | **App 备案**(v3.1 新增) | 工信部 | 随上架流程 | 国内应用分发前置条件 | P0 | +| 5 | 人力资源服务许可证 | 人社局 | 4-8 周 | **公测前必须**(邀请制内测不阻塞) | P0 | +| 6 | 网络招聘服务备案 | 人社部 | 4-8 周 | **公测前必须** | P0 | +| 7 | 增值电信业务经营许可证 | 工信部 | **实际 2-3 个月,通常要求注册资本 ≥100 万** | 内测期免费服务可缓,商业化收费前必须 | P1 | + +#### 建议获取(运营中) + +| 序号 | 证书/资质 | 说明 | 优先级 | +| -- | ---------------------- | ------------------------------------ | --- | +| 8 | **等保二级备案**(v3.1 由三级下调) | 三级测评+整改通常 10 万+,击穿预算;公测稳定后再评估三级 | P1 | +| 9 | 网安备案 | 公安机关网络安全备案 | P1 | +| 10 | 算法登记 | **使用已备案第三方大模型 API,我方做登记即可**,无需独立算法备案 | P1 | + +### 6.2 行业法规遵从 + +| 法规名称 | 核心要求 | 实施措施 | +| ------------- | -------------- | -------------------------------- | +| 个人信息保护法 | 最小必要、知情同意、数据权利 | 隐私政策、同意流程、**注销(P0)**、导出(P2 人工过渡) | +| 网络招聘服务管理规定 | 招聘信息审核、备案、投诉处理 | 岗位审核流、24h 投诉响应 | +| 生成式 AI 服务管理办法 | AI 内容标识、安全评估 | AI 输出标注、已备案模型 API + 登记 | +| 网络安全法 | 等保备案、数据安全 | 等保二级起步、安全审计 | +| 数据安全法 | 数据分类分级 | 数据分级、访问控制、日志审计 | + +### 6.3 合规文档清单(v3.1 明确责任路径) + +> 团队无法务编制,统一采用「**项目组基于合规模板起草 → 公司负责人审校 → 必要时外部法务一次性审阅**」的路径,外部审阅费用计入一次性合规预算。 + +| 文档 | 起草方 | 审校方 | 完成时间 | +| -------- | ------- | ------------ | ---- | +| 用户服务协议 | 项目组(模板) | 公司负责人 + 外部法务 | W8 前 | +| 隐私政策 | 项目组(模板) | 公司负责人 + 外部法务 | W8 前 | +| 企业入驻协议 | 项目组(模板) | 公司负责人 | W8 前 | +| AI 服务说明 | 张顺 | 公司负责人 | W8 前 | +| 投诉举报处理规则 | 项目组 | 公司负责人 | W8 前 | +| 内容审核规范 | 项目组 | 公司负责人 | W8 前 | +| 应急预案 | 张顺 | 公司负责人 | W8 前 | + +### 6.4 上线合规检查清单(v3.1 分两级) + +**邀请制内测必需(9/30 前):** + +| 检查项 | 状态 | +| ------------- | -- | +| 软件著作权已受理/取得 | ⏳ | +| App 备案材料已提交 | ⏳ | +| 隐私政策、用户协议已发布 | ⏳ | +| 注销功能可用、投诉渠道公示 | ⏳ | +| AI 输出标注已实现 | ⏳ | +| 敏感词检测已部署 | ⏳ | + +**公测必需(内测后追加):** + +| 检查项 | 状态 | +| ---------------- | -- | +| 人力资源服务许可证已取得 | ⏳ | +| 网络招聘服务备案已完成 | ⏳ | +| ICP 备案已完成 | ⏳ | +| 算法登记已完成 | ⏳ | +| 等保二级备案 | ⏳ | +| 增值电信业务经营许可证(收费前) | ⏳ | + +--- + +## 附录 + +### A. 术语表 + +| 术语 | 定义 | +| -------- | ----------------------------- | +| MVP | Minimum Viable Product,最小可行产品 | +| P0/P1/P2 | 优先级,P0 最高 | +| Sprint | 迭代周期(本方案为 1-2 周一段) | +| RBAC | 基于角色的访问控制 | +| IM | 即时通讯 | +| OSS | 阿里云对象存储服务 | +| 软著 | 计算机软件著作权,国内安卓商店上架必备 | + +### B. 参考资料 + +1. 《中华人民共和国个人信息保护法》 +2. 《网络招聘服务管理规定》0 +3. 《生成式人工智能服务管理暂行办法》 +4. 《人工智能生成合成内容标识办法》 +5. React Native 官方文档: +6. NestJS 官方文档: +7. 环信 IM 开发文档: +8. 阿里云推送文档: + +--- + +**文档审批栏** + +| 角色 | 姓名 | 审批意见 | 日期 | 签名 | +| ----- | -- | ------------------ | -- | -- | +| 公司负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | +| 产品负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | +| 技术负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案.docx b/product-docs/职来AI求职招聘平台产品与开发实施方案.docx new file mode 100644 index 0000000..4a3cd10 Binary files /dev/null and b/product-docs/职来AI求职招聘平台产品与开发实施方案.docx differ diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案.md b/product-docs/职来AI求职招聘平台产品与开发实施方案.md new file mode 100644 index 0000000..99ae7a3 --- /dev/null +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案.md @@ -0,0 +1,623 @@ +# 职来 AI 求职招聘平台产品提案 + +**文档密级:** 机密 +**文档版本:** v3.0 +**编制日期:** 2026 年 7 月 29 日 +**计划周期:** 2026 年 8 月 1 日—2026 年 9 月 30 日 +**文档状态:** 待批准 +**编制人:** 项目组 +**审阅人:** 公司管理层 + +--- + +## 执行摘要 + +"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。本项目旨在通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决当前求职市场面临的三大核心痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 + +**核心价值主张:** +- **可信**:企业及岗位经双重审核,杜绝虚假信息 +- **高效**:AI 助手提供简历诊断、岗位匹配、面试训练全流程赋能 +- **直接**:求职者与招聘方基于明确意向的直接沟通,无需中间环节 + +**项目概要:** +- 技术基座:React Native 0.85 + TypeScript 5.8 + 阿里云生态 +- 团队规模:2 人(前端负责人 + 后端负责人) +- 交付周期:8 周(2026.8.1 - 2026.9.30) +- 上线形态:移动应用公开测试版(iOS + Android) +- 预算范围:¥2,000 - ¥7,000 元/月运营成本 + +--- + +## 1. 产品需求文档(PRD) + +### 1.1 用户画像与核心场景 + +| 用户类型 | 画像描述 | 核心痛点 | 期望解决 | +|----------|----------|----------|----------| +| **应届毕业生** | 22-25 岁,在校生或应届毕业,1-3 份实习经验 | 缺乏求职经验、简历不规范、面试焦虑 | AI 简历优化、面试训练 | +| **职场新人** | 25-28 岁,1-3 年工作经验,寻求更好机会 | 信息过载、岗位匹配度低、沟通效率低 | 精准岗位匹配、直接沟通 | +| **资深求职者** | 28-35 岁,3-8 年经验,追求职业升级 | 简历版本管理、多家企业同时对接、Offer 比较 | 多版本简历、管道管理 | +| **企业招聘者** | HR/招聘经理,需高效筛选候选人 | 简历筛选耗时、沟通碎片化、候选人追踪困难 | 求职者简历快照、管道状态机 | +| **企业管理员** | HRD/业务负责人,关注团队搭建 | 企业认证、招聘团队管理、数据统计 | 企业工作台、招聘漏斗 | + +### 1.2 功能需求详述 + +#### FR-01 账号与身份认证 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0101 | 手机号注册与验证码登录 | P0 | 验证码 60 秒有效,同一手机号 5 分钟内限发 3 次 | +| FR-0102 | 求职者/招聘者角色切换 | P0 | 同一账号可双角色切换,数据隔离 | +| FR-0103 | 第三方登录(微信) | P1 | 与手机号绑定,支持快速登录 | +| FR-0104 | 账号注销与数据导出 | P1 | 注销流程 7 天冷静期,数据可导出 JSON | + +**用户故事:** +> 作为一名应届生,我希望能用手机号快速注册登录,这样我可以立即开始使用平台而无需复杂的注册流程。 + +#### FR-02 求职者职业档案 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0201 | 基本资料录入 | P0 | 头像、昵称、性别、出生年月、所在地 | +| FR-0202 | 教育经历 | P0 | 支持多段教育经历,含学校、专业、学历、时间段 | +| FR-0203 | 工作/实习经历 | P0 | 支持多段经历,含公司、职位、描述、时间段 | +| FR-0204 | 技能标签 | P0 | 支持自定义标签,技能熟练度 1-5 级 | +| FR-0205 | 求职偏好 | P0 | 期望岗位、行业、城市、薪资范围、到岗时间 | +| FR-0206 | 简历上传与版本管理 | P0 | 支持 PDF/DOCX,单文件 ≤10MB,多版本管理 | +| FR-0207 | 隐私设置 | P0 | 控制简历可见范围、联系方式展示、数据授权 | + +**用户故事:** +> 作为一名求职者,我希望能创建多份不同版本的简历,以便针对不同岗位投递最匹配的版本。 + +#### FR-03 企业认证与岗位管理 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0301 | 企业主体信息 | P0 | 企业名称、统一社会信用代码、官网、行业、规模 | +| FR-0302 | 营业执照上传 | P0 | 图片 OCR 识别,与信用代码交叉验证 | +| FR-0303 | 审核流程 | P0 | 提交→待审核→补充材料/驳回→已认证 | +| FR-0304 | 岗位创建与编辑 | P0 | 标题、职责、要求、薪资、地点、用工类型 | +| FR-0305 | 岗位审核与发布 | P0 | 敏感词检测、人工审核、1 个工作日内完成 | +| FR-0306 | 岗位搜索与筛选 | P0 | 关键词、城市、行业、薪资、经验、学历 | +| FR-0307 | 岗位收藏与分享 | P1 | 收藏夹管理、分享链接带岗位快照 | + +**用户故事:** +> 作为一名企业招聘者,我希望提交企业认证后能快速获得审核结果,这样我可以尽快开始发布岗位。 + +#### FR-04 投递与候选人管道 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0401 | 主动投递 | P0 | 简历快照绑定投递,不可篡改 | +| FR-0402 | 状态流转 | P0 | 7 阶段状态机:已投递→已查看→沟通中→待面试→面试中→Offer→已录用 | +| FR-0403 | 时间线记录 | P0 | 每次状态变更记录操作者、时间、原因 | +| FR-0404 | 终止状态 | P0 | 求职者撤回/招聘方不合适/Offer 拒绝/Offer 过期 | +| FR-0405 | 重复投递控制 | P1 | 同一岗位 30 天内不可重复投递 | + +**用户故事:** +> 作为一名求职者,我希望能实时查看投递状态,这样我可以了解招聘进展并做好后续准备。 + +#### FR-05 招聘沟通与实时通讯 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0501 | 会话建立 | P0 | 仅在投递/打招呼/授权后可建立会话 | +| FR-0502 | 消息收发 | P0 | 文字消息、系统卡片,支持已读状态 | +| FR-0503 | 未读提醒 | P0 | Tab 栏红点、推送通知、会话列表排序 | +| FR-0504 | 举报与屏蔽 | P0 | 举报 30 分钟内处理,屏蔽后不再接收消息 | +| FR-0505 | 消息撤回 | P1 | 2 分钟内可撤回,显示"已撤回"标记 | + +**用户故事:** +> 作为一名求职者,我希望收到招聘方消息时能第一时间得到通知,这样我不会错过重要的沟通机会。 + +#### FR-06 面试与 Offer 管理 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0601 | 面试安排 | P1 | 时间、地点、面试官、备注,支持冲突检测 | +| FR-0602 | 面试确认 | P1 | 求职者可确认/改期/拒绝,双端同步 | +| FR-0603 | 结构化评分卡 | P1 | 多维度评分:专业能力、沟通能力、文化匹配 | +| FR-0604 | Offer 创建 | P1 | 薪资结构、工作地点、入职日期、有效期 | +| FR-0605 | Offer 接受/拒绝 | P1 | 求职者明确表态,超时自动标记"已过期" | + +#### FR-07 AI 求职助手 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0701 | 简历解析 | P0 | 自动提取结构化字段,准确率 ≥ 90% | +| FR-0702 | 简历诊断 | P0 | 优势分析、缺口识别、改进建议 | +| FR-0703 | 岗位匹配解释 | P0 | 匹配项、缺口、未知项、建议提问 | +| FR-0704 | 打招呼建议 | P1 | 3 条可编辑候选文案,发送前用户确认 | +| FR-0705 | 面试训练 | P1 | 模拟问题、回答点评、改进建议 | + +**AI 安全边界:** +- AI 输出显著标注"AI 生成/AI 辅助建议" +- 不自动淘汰候选人,不生成录用承诺 +- 不编造经历、公司事实 +- 高风险输出人工复核 + +#### FR-08 运营审核与风控 + +| 编号 | 需求描述 | 优先级 | 验收标准 | +|------|----------|--------|----------| +| FR-0801 | 企业审核队列 | P0 | 待审核、已通过、已驳回分类展示 | +| FR-0802 | 岗位审核队列 | P0 | 敏感词检测、人工审核、1 个工作日 SLA | +| FR-0803 | 投诉处理 | P0 | 举报→限制分发→复核→处置→申诉 | +| FR-0804 | 数据删除任务 | P1 | 用户注销后数据可追踪删除 | + +### 1.3 非功能需求 + +| 类别 | 指标 | 目标值 | +|------|------|--------| +| **性能** | 应用冷启动时间 | ≤ 3 秒 | +| | 岗位详情页加载 | ≤ 2 秒 | +| | 消息发送延迟 | ≤ 500ms | +| | AI 任务响应时间 | ≤ 30 秒 | +| **可用性** | 系统可用性 | ≥ 99% | +| | 消息送达率 | ≥ 99.9% | +| | 推送到达率 | ≥ 95% | +| **安全性** | 敏感数据加密 | 全量加密 | +| | 越权访问 | 高危越权为 0 | +| | 审计日志 | 高风险操作 100% 记录 | +| **兼容性** | iOS 最低版本 | 13.0+ | +| | Android 最低版本 | 7.0+(API 24) | +| | 屏幕适配 | iPhone SE - 15 Pro Max | +| **可扩展性** | 在线用户支持 | MVP 5,000 → P1 50,000 | +| | 消息吞吐 | MVP 10,000/日 → P1 100,000/日 | + +--- + +## 2. 功能模块与路线图 + +### 2.1 短期开发计划(MVP:8 周) + +#### Sprint 0-1:基础架构与账号体系(第 1-2 周) + +| 交付物 | 负责人 | 验收标准 | +|--------|--------|----------| +| RN 工程脚手架 | 杨幸幸 | 可运行、组件库就绪 | +| 后端 API 框架 | 张顺 | NestJS + Prisma + JWT 认证 | +| 登录/注册/注销 | 杨幸幸 | 三端(求职者/招聘者/审核员)登录 | +| 角色切换 | 杨幸幸 | 双角色数据隔离 | +| 隐私同意流程 | 杨幸幸 | 版本化记录,可撤回 | + +#### Sprint 2-3:企业认证与岗位管理(第 3-4 周) + +| 交付物 | 负责人 | 验收标准 | +|--------|--------|----------| +| 企业主体信息录入 | 杨幸幸 | 表单校验、图片上传 | +| 营业执照 OCR | 张顺 | 与信用代码匹配验证 | +| 审核工作流 | 张顺 | 提交→审核→通过/驳回 | +| 岗位创建/编辑 | 杨幸幸 | 完整表单、实时校验 | +| 岗位审核/发布 | 张顺 | 敏感词检测、发布生效 | +| 搜索与筛选 | 杨幸幸 | 多条件组合搜索 | + +#### Sprint 4-5:投递管道与沟通(第 5-6 周) + +| 交付物 | 负责人 | 验收标准 | +|--------|--------|----------| +| 投递流程 | 杨幸幸 | 简历快照绑定 | +| 状态机实现 | 张顺 | 7 阶段流转、历史记录 | +| 候选人看板 | 杨幸幸 | 招聘者视图、筛选排序 | +| 环信 IM 集成 | 张顺 | 会话建立、消息收发 | +| 未读推送 | 张顺 | 阿里云推送、红点通知 | + +#### Sprint 6-7:AI 助手与审核运营(第 7-8 周) + +| 交付物 | 负责人 | 验收标准 | +|--------|--------|----------| +| AI 简历解析 | 张顺 | 准确率 ≥ 90% | +| AI 岗位匹配 | 杨幸幸/张顺 | 匹配解释、可解释性 | +| AI 面试训练 | 杨幸幸/张顺 | 模拟问答、点评反馈 | +| 运营审核后台 | 张顺 | 企业/岗位/投诉审核 | +| 数据权利中心 | 杨幸幸 | 导出/删除/注销 | + +#### Sprint 8-9:测试与发布(第 9-10 周) + +| 交付物 | 负责人 | 验收标准 | +|--------|--------|----------| +| 全量回归测试 | 两人 | 核心旅程 100% 通过 | +| 性能调优 | 杨幸幸 | 首屏 ≤ 3s,列表流畅 | +| 安全扫描 | 张顺 | 高危漏洞 0 | +| 部署上线 | 张顺 | 灰度发布 5%→20%→100% | +| 封闭测试 | 杨幸幸 | 30 人测试,P0=0 | + +### 2.2 长期路线图(P1:公测后 4-8 个月) + +#### 阶段一:增长与商业化 + +| 时间 | 功能模块 | 业务目标 | +|------|----------|----------| +| +1 月 | 智能推荐算法 | 岗位匹配效率提升 30% | +| +1 月 | 求职者岗位订阅 | 日活提升 20% | +| +2 月 | 多简历版本与作品集 | 简历完成率提升 15% | +| +2 月 | AI 多轮面试训练 | AI 使用率提升 40% | +| +3 月 | 企业付费席位 | 首个付费客户签约 | +| +3 月 | 招聘漏斗分析 | 转化率提升 10% | +| +4 月 | PWA 离线支持 | 移动端体验优化 | +| +4 月 | Web 管理后台 | 企业客户自助管理 | + +#### 阶段二:平台化升级 + +| 时间 | 功能模块 | 业务目标 | +|------|----------|----------| +| +5 月 | 企业 ATS 对接 | 大型企业客户接入 | +| +5 月 | 校园招聘专区 | 高校合作启动 | +| +6 月 | 视频面试功能 | 沟通效率提升 | +| +6 月 | 背调测评服务 | 商业增值服务 | +| +7 月 | 多 AI 模型路由 | 成本优化 30% | +| +7 月 | 企业知识库 | AI 招聘助理 | +| +8 月 | 城市/行业运营平台 | 区域扩张 | + +--- + +## 3. 技术方案 + +### 3.1 系统架构设计 + +#### 总体架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端层 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ React Native 0.85 App │ │ +│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ │ 求职者端 │ 招聘者端 │ 审核后台 │ 设置中心 │ │ │ +│ │ └──────────┴──────────┴──────────┴──────────┘ │ │ +│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ │ Zustand │ Navigation│ Axios │ Services │ │ │ +│ │ │ Store │ Router │ Client │ (IM/Push)│ │ │ +│ │ └──────────┴──────────┴──────────┴──────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ HTTPS/WSS 协议 │ +└──────────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────────┼──────────────────────────────────┐ +│ 服务端层 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ API Gateway (Nginx) │ │ +│ │ 负载均衡 · SSL · 限流 · 日志 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ NestJS 应用服务 │ │ +│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ │ Auth │ Profile │ Job/ │ Chat │ │ │ +│ │ │ Module │ Module │ Applic. │ Module │ │ │ +│ │ └──────────┴──────────┴──────────┴──────────┘ │ │ +│ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ +│ │ │ Interview│ Review │ AI │ Notif. │ │ │ +│ │ │ Module │ Module │ Module │ Module │ │ │ +│ │ └──────────┴──────────┴──────────┴──────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ MySQL │ │ Redis │ │ 阿里云 │ │ AI │ │ +│ │ RDS │ │ 缓存/队列│ │ OSS/VOD │ │ Adapter │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌──────────────────────────────┼──────────────────────────────────┐ +│ 第三方服务 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 环信 IM │ │ 阿里云推送 │ │ AI 模型 │ │ +│ │ 实时通讯 │ │ 消息推送 │ │ API 调用 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 技术栈选型 + +#### 移动端技术栈 + +| 层级 | 技术组件 | 版本 | 选型理由 | +|------|----------|------|----------| +| **核心框架** | React Native | 0.85.3 | 成熟稳定,跨平台,团队已有完整项目经验 | +| **编程语言** | TypeScript | 5.8.3 | 类型安全,减少运行时错误 | +| **UI 渲染** | React | 19.2.3 | 最新稳定版,性能优化 | +| **状态管理** | Zustand | 4.5.5 | 轻量级,API 简洁,支持异步 | +| **路由导航** | React Navigation | 6.x | 原生栈导航,性能优秀 | +| **网络请求** | Axios | 1.12.2 | 拦截器机制完善,社区活跃 | +| **实时通讯** | 环信 IM SDK | ^1.15.3 | 国内领先 IM 服务,稳定可靠 | +| **消息推送** | 阿里云推送 | ^1.0.5 | 支持国内厂商通道,到达率高 | +| **文件处理** | 阿里云 OSS | - | 直传方案,减轻服务端压力 | +| **视频点播** | 阿里云 VOD | - | 视频转码、CDN 分发 | +| **图片压缩** | react-native-compressor | ^2.0.2 | 硬件编码,压缩率高 | + +#### 服务端技术栈 + +| 层级 | 技术组件 | 选型理由 | +|------|----------|----------| +| **运行时** | Node.js 20 LTS | 与前端 TS 统一,生态丰富 | +| **框架** | NestJS | 渐进式框架,内置依赖注入、守卫、拦截器 | +| **数据库** | MySQL 8.0 | 关系型数据,阿里云 RDS 托管 | +| **缓存** | Redis 7.x | Token 存储、会话缓存、限流计数 | +| **ORM** | Prisma | 类型安全,自动生成类型,迁移管理 | +| **认证** | JWT + Passport | 无状态认证,支持刷新令牌 | +| **API 文档** | Swagger/OpenAPI | 自动生成交互式文档 | +| **队列** | BullMQ (Redis) | AI 任务、通知发送、文件处理 | +| **日志** | Winston + Morgan | 结构化日志,请求追踪 | +| **校验** | class-validator | DTO 自动校验 | + +### 3.3 数据安全设计 + +| 安全维度 | 实施措施 | 详情 | +|----------|----------|------| +| **传输安全** | 全量 HTTPS + HSTS | 强制 TLS 1.2+,证书自动续期 | +| **存储加密** | AES-256-GCM | 敏感字段(手机号、简历内容)加密存储 | +| **身份认证** | JWT + Refresh Token | Access Token 2h,Refresh Token 7d | +| **权限控制** | RBAC + 资源级鉴权 | 角色权限矩阵 + 对象级访问控制 | +| **输入校验** | 前后端双重校验 | 服务端 class-validator 强制校验 | +| **防注入** | Prisma 参数化查询 | ORM 自动防止 SQL 注入 | +| **审计日志** | 全量高风险操作记录 | 查看简历、导出、删除、封禁等 | +| **数据脱敏** | 列表字段自动脱敏 | 手机号 138****1234,邮箱隐藏 | +| **备份策略** | 每日全量 + 实时 binlog | RDS 自动备份,保留 30 天 | +| **密钥管理** | 环境变量 + 独立密钥库 | 密钥不入库,启动时注入 | + +### 3.4 核心 API 设计规范 + +#### 统一响应格式 + +```typescript +interface ApiResponse { + code: number; // 200=成功,4xx=客户端错误,5xx=服务端错误 + data: T; // 业务数据 + message: string; // 提示信息 + requestId: string; // 请求追踪 ID + timestamp: number; // 时间戳 +} +``` + +#### 版本化 API 路由 + +``` +/api/v1/auth/* 认证服务 +/api/v1/profile/* 求职者档案 +/api/v1/organizations/* 企业管理 +/api/v1/jobs/* 岗位 CRUD +/api/v1/applications/* 投递管道 +/api/v1/conversations/* 会话管理 +/api/v1/messages/* 消息收发 +/api/v1/interviews/* 面试管理 +/api/v1/offers/* Offer 管理 +/api/v1/ai/* AI 助手 +/api/v1/reviews/* 运营审核 +/api/v1/notifications/* 通知推送 +/api/v1/privacy/* 数据权利 +``` + +#### 关键集成点 + +| 集成服务 | 集成方式 | 数据流向 | +|----------|----------|----------| +| **环信 IM** | REST API + SDK | 服务端创建会话,客户端收发消息 | +| **阿里云推送** | 官方 SDK | 服务端触发推送,客户端接收 | +| **阿里云 OSS** | Presigned URL | 客户端直传,服务端生成签名 | +| **阿里云 VOD** | 后端代理上传 | 服务端获取上传凭证,客户端直传 | +| **AI 模型** | Provider Adapter | 服务端调用,统一协议,可切换 | + +--- + +## 4. 项目时间线 + +### 4.1 阶段划分与里程碑 + +``` +2026年8月 2026年9月 +├── Week 1 ├── Week 2 ├── Week 3 ├── Week 4 ├── Week 5 ├── Week 6 ├── Week 7 ├── Week 8 ├── Week 9 ├── Week 10 ──┤ +│ │ +│ Sprint 0-1 Sprint 2-3 Sprint 4-5 Sprint 6-7 Sprint 8-9 │ +│ 基础架构+账号 企业+岗位 投递+沟通 AI+审核 测试+发布 │ +│ │ +│ M1: 架构就绪 M2: 岗位闭环 M3: 投递闭环 M4: AI 上线 M5: 公测发布 │ +│ 8月7日 8月21日 9月4日 9月18日 9月30日 │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 详细里程碑定义 + +| 里程碑 | 日期 | 交付标准 | 验收人 | +|--------|------|----------|--------| +| **M1:技术基线** | 8月7日 | 工程可运行、CI 绿色、基础架构就绪 | 项目组 | +| **M2:岗位闭环** | 8月21日 | 企业可认证、岗位可发布、搜索可用 | 公司负责人 | +| **M3:投递闭环** | 9月4日 | 投递全流程演示、消息可收发 | 公司负责人 | +| **M4:AI 就绪** | 9月18日 | AI 助手核心功能上线、评测通过 | 项目组 | +| **M5:公测发布** | 9月30日 | P0 缺陷为 0、灰度通过、全面发布 | 公司负责人 | + +### 4.3 风险缓冲与应急策略 + +| 场景 | 触发条件 | 应对措施 | 最大可延期 | +|------|----------|----------|------------| +| 单 Sprint 延期 | 完成率 < 70% | 削减 P2 功能,保留 P0 | 2 天 | +| 核心模块阻塞 | 超过 3 天无法推进 | 暂停该模块,并行开发其他 | 5 天 | +| 人员不可用 | 超过 3 天无法工作 | 冻结新增需求,执行交接文档 | 10 天 | +| 技术方案变更 | 原方案不可行 | 24 小时内重新评审 | 整体顺延 | + +--- + +## 5. 团队分配 + +### 5.1 组织架构 + +``` + ┌─────────────┐ + │ 公司负责人 │ + │ 产品决策 │ + └──────┬──────┘ + │ + ┌──────┴──────┐ + │ 项目组 │ + └──────┬──────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌──────┴──────┐ ┌──────┴──────┐ + │ 杨幸幸 │ │ 张顺 │ + │ 前端负责人 │ │ 后端负责人 │ + └─────────────┘ └─────────────┘ +``` + +### 5.2 详细分工表 + +#### 杨幸幸:前端负责人 + +| 职责领域 | 具体任务 | 预估工时 | 交付物 | +|----------|----------|----------|--------| +| **架构设计** | RN 工程架构、组件规范、设计系统 | 40h | 架构文档、组件库 | +| **账号模块** | 登录/注册/角色切换/隐私同意 | 32h | 4 个页面 | +| **档案模块** | 求职者档案、简历上传、隐私设置 | 48h | 8 个页面 | +| **企业模块** | 企业认证、招聘者管理 | 24h | 4 个页面 | +| **岗位模块** | 岗位创建/编辑/搜索/详情 | 48h | 6 个页面 | +| **投递模块** | 投递流程、候选人看板 | 24h | 3 个页面 | +| **沟通模块** | 会话列表、聊天详情、未读状态 | 40h | 4 个页面 | +| **AI 交互** | AI 工作台、简历诊断、面试训练 | 40h | 5 个页面 | +| **测试验收** | 前端单元测试、E2E 测试 | 32h | 测试报告 | +| **发布支持** | 构建打包、性能优化 | 16h | 发布包 | +| **文档编写** | 前端文档、用户手册 | 16h | 文档 | + +**合计:360 小时(45 人天)** + +#### 张顺:后端负责人 + +| 职责领域 | 具体任务 | 预估工时 | 交付物 | +|----------|----------|----------|--------| +| **架构设计** | NestJS 架构、数据库设计、API 规范 | 40h | 架构文档、Schema | +| **基础设施** | Docker、CI/CD、监控、日志 | 32h | 基础设施就绪 | +| **认证模块** | JWT、角色权限、数据隔离 | 24h | 认证 API | +| **用户模块** | 求职者档案 CRUD、简历存储 | 40h | 档案 API | +| **企业模块** | 企业认证、审核工作流 | 32h | 企业 API | +| **岗位模块** | 岗位 CRUD、审核、搜索 | 48h | 岗位 API | +| **投递模块** | 投递、状态机、快照 | 32h | 投递 API | +| **通讯模块** | 环信 IM 集成、阿里云推送 | 40h | 消息 API | +| **AI 模块** | AI 适配器、任务队列、安全 | 48h | AI API | +| **运营模块** | 审核后台、投诉处理 | 24h | 审核 API | +| **部署运维** | 部署上线、监控告警、备份 | 24h | 生产环境 | +| **测试安全** | 集成测试、安全扫描、压力测试 | 32h | 测试报告 | +| **文档编写** | API 文档、部署文档 | 16h | 文档 | + +**合计:432 小时(54 人天)** + +### 5.3 协作机制 + +| 机制 | 频率 | 参与人 | 产出物 | +|------|------|--------|--------| +| **每日站会** | 每日 | 项目组 | 进展同步 | +| **周会评审** | 每周五 | 项目组 + 负责人 | 周报告、下周计划 | +| **Sprint 验收** | 双周 | 项目组 + 负责人 | 验收报告 | +| **代码评审** | PR 时 | 项目组 | 合并的代码 | +| **技术评审** | 按需 | 项目组 | 技术决策记录 | + +--- + +## 6. 合规要求 + +### 6.1 资质证书清单 + +#### 必须获取(上线前) + +| 序号 | 证书/资质 | 主管部门 | 申请主体 | 预估周期 | 优先级 | +|------|-----------|----------|----------|----------|--------| +| 1 | **营业执照** | 市场监督管理局 | 运营公司 | 1-2 周 | P0 | +| 2 | **ICP 备案** | 工业和信息化部 | 运营公司 | 2-4 周 | P0 | +| 3 | **网络招聘服务备案** | 人力资源和社会保障部 | 运营公司 | 4-8 周 | P0 | +| 4 | **人力资源服务许可证** | 人力资源和社会保障局 | 运营公司 | 4-8 周 | P0 | +| 5 | **增值电信业务经营许可证** | 工业和信息化部 | 运营公司 | 4-8 周 | P1 | + +#### 建议获取(运营中) + +| 序号 | 证书/资质 | 主管部门 | 说明 | 优先级 | +|------|-----------|----------|------|--------| +| 6 | **等保三级备案** | 公安机关 | 信息安全等级保护 | P1 | +| 7 | **网安备案** | 公安机关 | 网络安全备案 | P1 | +| 8 | **算法备案** | 网信办 | 生成式 AI 算法备案 | P1 | +| 9 | **小应用备案** | 网信办 | 移动应用备案 | P1 | + +### 6.2 行业法规遵从 + +| 法规名称 | 核心要求 | 实施措施 | 文档依据 | +|----------|----------|----------|----------| +| **个人信息保护法** | 最小必要、知情同意、数据权利 | 隐私政策、同意流程、数据导出/删除 | 隐私政策文档 | +| **网络招聘服务管理规定** | 招聘信息审核、备案、投诉处理 | 岗位审核流程、投诉处理机制 | 用户协议文档 | +| **生成式 AI 服务管理办法** | AI 内容标识、安全评估、算法备案 | AI 输出标识、安全评测 | AI 服务说明文档 | +| **网络安全法** | 等保备案、数据安全、应急处置 | 等保备案、安全审计、应急预案 | 安全白皮书 | +| **数据安全法** | 数据分类分级、安全评估 | 数据分级、访问控制、日志审计 | 数据处理协议 | + +### 6.3 合规文档清单 + +| 文档 | 状态 | 负责方 | 完成时间 | +|------|------|--------|----------| +| 用户服务协议 | 待编写 | 法务 + 项目组 | 上线前 | +| 隐私政策 | 待编写 | 法务 + 项目组 | 上线前 | +| 企业入驻协议 | 待编写 | 法务 + 项目组 | 上线前 | +| AI 服务说明 | 待编写 | 项目组 | 上线前 | +| 投诉举报处理规则 | 待编写 | 项目组 | 上线前 | +| 数据处理协议 | 待编写 | 法务 + 项目组 | 上线前 | +| 内容审核规范 | 待编写 | 项目组 | 上线前 | +| 应急预案 | 待编写 | 项目组 | 上线前 | +| 招聘信息审核规范 | 待编写 | 项目组 | 上线前 | + +### 6.4 上线合规检查清单 + +| 检查项 | 要求 | 负责方 | 状态 | +|--------|------|--------|------| +| 营业执照已获取 | 有效期内 | 公司 | ⏳ 待确认 | +| ICP 备案已完成 | 与域名一致 | 公司 | ⏳ 待确认 | +| 人力资源服务许可证 | 范围覆盖招聘服务 | 公司 | ⏳ 待确认 | +| 网络招聘服务备案 | 当地人社部门 | 公司 | ⏳ 待确认 | +| 隐私政策已审核 | 法务复核通过 | 法务 | ⏳ 待确认 | +| 用户协议已审核 | 法务复核通过 | 法务 | ⏳ 待确认 | +| AI 服务说明已编写 | 包含标识和边界 | 项目组 | ⏳ 待确认 | +| 投诉处理流程已验证 | 端到端走通 | 项目组 | ⏳ 待确认 | +| 数据导出/删除功能 | 用户可自助操作 | 项目组 | ⏳ 待确认 | +| AI 输出标注已实现 | "AI 生成/辅助建议" | 项目组 | ⏳ 待确认 | +| 敏感词检测已部署 | 岗位、消息、AI 输出 | 项目组 | ⏳ 待确认 | + +--- + +## 附录 + +### A. 术语表 + +| 术语 | 定义 | +|------|------| +| MVP | Minimum Viable Product,最小可行产品 | +| SLA | Service Level Agreement,服务水平协议 | +| P0/P1/P2 | 优先级,P0 最高 | +| Sprint | 双周迭代周期 | +| RBAC | Role-Based Access Control,基于角色的访问控制 | +| JWT | JSON Web Token,JSON 网络令牌 | +| IM | Instant Messaging,即时通讯 | +| OSS | Object Storage Service,对象存储服务 | +| VOD | Video On Demand,视频点播 | + +### B. 参考资料 + +1. 《中华人民共和国个人信息保护法》 +2. 《网络招聘服务管理规定》 +3. 《生成式人工智能服务管理暂行办法》 +4. 《人工智能生成合成内容标识办法》 +5. React Native 官方文档:https://reactnative.dev +6. NestJS 官方文档:https://nestjs.com +7. 环信 IM 开发文档:https://docs.easemob.com +8. 阿里云推送文档:https://help.aliyun.com/product/27324.html + +--- + +**文档审批栏** + +| 角色 | 姓名 | 审批意见 | 日期 | 签名 | +|------|------|----------|------|------| +| 公司负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | +| 产品负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | +| 技术负责人 | | ☐ 同意 ☐ 有条件同意 ☐ 不同意 | | | diff --git a/product-docs/职来UI设计系统规范V1.0.md b/product-docs/职来UI设计系统规范V1.0.md new file mode 100644 index 0000000..9e0d424 --- /dev/null +++ b/product-docs/职来UI设计系统规范V1.0.md @@ -0,0 +1,725 @@ +# 职来(mydreamjob)移动端UI设计系统规范 V1.0 + +> **产品定位**:大学生可信求职决策工具 +> **核心用户**:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR +> **视觉风格**:专业、清爽、理性商务工具向 +> **画板基准**:390pt × 844pt(iPhone 15标准) +> **网格系统**:全局4pt网格布局 + +--- + +## 一、全局色彩规范 + +### 1.1 品牌主色系 + +| 名称 | HEX | RGB | 用途 | +|------|-----|-----|------| +| **主色 Primary** | `#1677FF` | 22, 119, 255 | 主按钮、筛选激活态、高分企业标识、核心操作入口、雷达图高亮 | +| **辅助色 Secondary** | `#4096FF` | 64, 150, 255 | 次级操作、次要标签、进度条、滑块激活轨道 | + +### 1.2 业务状态标签色 + +| 名称 | HEX | RGB | 用途 | +|------|-----|-----|------| +| **优质匹配色 Success** | `#00B42A` | 0, 180, 42 | 企业综合高分、高薪福利、低加班、匹配度≥80%、合规无风险 | +| **中性提示色 Warning** | `#FF7D00` | 255, 125, 0 | 企业中等评分、数据待补充、普通匹配、估算分数标记、权重模板提示 | +| **风险警示色 Danger** | `#F53F3F` | 245, 63, 63 | 企业合规风险、低分避雷、匹配度不足阈值、仲裁/处罚记录、恶意评价预警 | + +### 1.3 文本中性分层色 + +| 名称 | HEX | RGB | 用途 | +|------|-----|-----|------| +| **标题文本 Title** | `#1D2129` | 29, 33, 41 | H1/H2标题、企业全称、岗位名称、综合总分、核心数据数字 | +| **正文文本 Body** | `#4E5968` | 78, 89, 104 | 正文文本、岗位职责、企业介绍、员工评价、权重说明 | +| **辅助文本 Caption** | `#86909C` | 134, 144, 156 | 辅助小字、数据来源标注、时间、行业规模标签、占位文案 | + +### 1.4 容器与页面底色 + +| 名称 | HEX | RGB | 用途 | +|------|-----|-----|------| +| **页面背景 Background** | `#F2F3F5` | 242, 243, 245 | 全局页面背景、列表分割底色、筛选面板底层、空状态背景 | +| **卡片背景 Surface** | `#FFFFFF` | 255, 255, 255 | 卡片底色、弹窗面板、底部选择器、搜索框、表单输入框、雷达图画布 | + +### 1.5 边框与分割线 + +| 名称 | HEX | RGB | 用途 | +|------|-----|-----|------| +| **分割线 Border** | `#E5E6EB` | 229, 230, 235 | 列表分割线、卡片边框 | +| **禁用边框 Disabled** | `#C9CDD4` | 201, 205, 212 | 禁用状态边框 | + +--- + +## 二、字体层级规范 + +### 2.1 字体族 + +| 平台 | 字体 | +|------|------| +| iOS | SF Pro Text / 思源黑体 | +| Android | Roboto / 思源黑体 | +| Fallback | Inter, -apple-system, sans-serif | + +### 2.2 字体层级 + +| 层级 | 字号 | 字重 | 行高 | 用途 | +|------|------|------|------|------| +| **H1** | 24px | Bold (700) | 32px | 页面顶级标题、模块总标题、企业对比页大标题 | +| **H2** | 20px | Bold (700) | 28px | 板块分区标题、雷达图标题、评分体系模块标题 | +| **H3** | 17px | Medium (500) | 24px | 卡片标题、岗位名称、分项维度标题、权重分组标题 | +| **Body** | 15px | Regular (400) | 22px | 正文描述、企业详情、评价正文、权重调节说明 | +| **Caption** | 13px | Regular (400) | 18px | 备注、数据来源、时间标签、维度状态小字、AI提示小字 | + +### 2.3 特殊数字展示 + +| 类型 | 字号 | 字重 | 用途 | +|------|------|------|------| +| **大号数字** | 48px | Bold (700) | 综合总分展示 | +| **数字强调** | 28px | Bold (700) | 维度分数、匹配度百分比 | +| **数字标准** | 17px | Medium (500) | 列表数据、进度百分比 | + +### 2.4 约束规则 + +- 页面最小展示文字 ≥ 12px +- 长评价、对比表格行高宽松,保证可读性 +- 所有可点击控件热区 ≥ 48×48dp + +--- + +## 三、间距规范 + +### 3.1 统一间距Token(4pt网格倍数) + +| Token | 数值 | 用途 | +|-------|------|------| +| **spacing-xs** | 4px | 最小间距、图标内边距 | +| **spacing-sm** | 8px | 组件内间距、元素紧凑间距 | +| **spacing-md** | 16px | 标准组件间距、卡片内边距 | +| **spacing-lg** | 24px | 模块间距、列表项间距 | +| **spacing-xl** | 32px | 页面边距、大模块间距 | +| **spacing-2xl** | 48px | 页面顶部间距、底部安全区 | + +### 3.2 页面边距 + +| 区域 | 数值 | +|------|------| +| 页面左右边距 | 16px | +| 卡片内边距 | 16px | +| 列表项内边距 | 16px | +| 底部安全区 | 34px (iPhone 15) | + +--- + +## 四、圆角规范 + +| Token | 数值 | 用途 | +|-------|------|------| +| **radius-sm** | 4px | 小型元素、分隔线端点 | +| **radius-md** | 8px | 按钮、搜索框、输入框、筛选标签、滑块容器、评分小卡片 | +| **radius-lg** | 12px | 全量内容卡片、底部弹窗面板 | +| **radius-full** | 9999px | 圆形头像、圆形图标按钮 | + +### 特殊规则 + +- 底部弹出选择器:顶部两角圆角归零,底部保留 12px 圆角 +- 弹窗面板:四角统一 12px 圆角 + +--- + +## 五、阴影规范 + +| Token | 数值 | 用途 | +|-------|------|------| +| **shadow-sm** | 0 1px 2px rgba(0,0,0,0.04) | 卡片默认状态 | +| **shadow-md** | 0 2px 8px rgba(0,0,0,0.06) | 卡片悬停状态、弹窗面板 | +| **shadow-lg** | 0 4px 16px rgba(0,0,0,0.08) | 模态弹窗、底部弹出层 | + +### 约束规则 + +- 全部组件仅使用极低透明度柔和浅灰投影 +- 禁止厚重黑色硬阴影 +- 保持清爽理性工具质感 + +--- + +## 六、图标规范 + +### 6.1 基本规范 + +| 属性 | 数值 | +|------|------| +| 标准尺寸 | 24px × 24px | +| 线条粗细 | 2px | +| 风格 | 统一线性轮廓 | +| 禁止 | 填充、渐变、粗描边图标 | + +### 6.2 底部导航图标(4个) + +| 图标 | 名称 | 激活色 | 非激活色 | +|------|------|--------|----------| +| 🏠 | 首页 | #1677FF | #86909C | +| 🏢 | 企业库 | #1677FF | #86909C | +| 📊 | 求职匹配 | #1677FF | #86909C | +| 👤 | 我的 | #1677FF | #86909C | + +### 6.3 核心业务图标清单 + +| 图标名称 | 用途 | +|----------|------| +| `icon-weight` | 权重设置 | +| `icon-radar` | 雷达图 | +| `icon-evidence` | 证据链溯源 | +| `icon-compare` | 企业对比 | +| `icon-filter` | 多条件筛选 | +| `icon-resume` | 简历上传 | +| `icon-ai` | AI助手 | +| `icon-match` | 匹配度校验 | +| `icon-warning` | 避雷警示 | +| `icon-favorite` | 收藏 | +| `icon-send` | 投递 | +| `icon-chat` | 聊天会话 | +| `icon-matrix` | 企业规模矩阵 | +| `icon-trend` | 评分趋势曲线 | +| `icon-arbitration` | 仲裁风险 | +| `icon-source` | 工商数据源 | +| `icon-reset` | 重置权重 | +| `icon-save` | 保存自定义方案 | +| `icon-empty` | 空数据占位 | +| `icon-collapse` | 筛选收起/展开 | + +--- + +## 七、基础组件规范 + +### 7.1 按钮组件 + +#### 主按钮 Primary Button + +| 属性 | 数值 | +|------|------| +| 高度 | 48px | +| 圆角 | 8px | +| 背景 | #1677FF | +| 文字 | #FFFFFF, 17px, Medium | +| 内边距 | 左右 24px | +| 最小宽度 | 120px | +| 热区 | ≥ 48×48dp | +| 用途 | 投递、保存权重、查看证据链、确认匹配、企业对比、提交简历 | + +#### 次要描边按钮 Secondary Button + +| 属性 | 数值 | +|------|------| +| 高度 | 48px | +| 圆角 | 8px | +| 背景 | #FFFFFF | +| 边框 | 1px solid #1677FF | +| 文字 | #1677FF, 17px, Medium | +| 用途 | 重置筛选、取消弹窗、清空权重、关闭对比面板 | + +#### 文字按钮 Text Button + +| 属性 | 数值 | +|------|------| +| 背景 | 无 | +| 边框 | 无 | +| 文字 | #1677FF, 15px, Regular | +| 用途 | 查看更多评价、展开数据源、查看完整评分趋势、导出个人档案 | + +### 7.2 搜索框组件 + +| 属性 | 数值 | +|------|------| +| 高度 | 44px | +| 圆角 | 8px | +| 背景 | #FFFFFF | +| 边框 | 1px solid #E5E6EB | +| 内边距 | 左 44px, 右 40px | +| 图标尺寸 | 24px | +| 图标位置 | 左侧 12px | +| 清除按钮 | 右侧 12px | +| 文字 | #1D2129, 15px | +| 占位符 | #86909C, 15px | + +### 7.3 列表行规范 + +| 属性 | 数值 | +|------|------| +| 行高 | 56-60px | +| 内边距 | 左右 16px | +| 分割线 | 0.5px solid #E5E6EB | +| 企业名称 | #1D2129, 17px, Medium | +| 综合分数 | #1D2129, 28px, Bold | +| 业务标签 | 13px, 圆角 4px | +| 行业/规模/地点 | #86909C, 13px | + +### 7.4 业务标签组件 + +#### 绿色标签(优质) + +| 属性 | 数值 | +|------|------| +| 背景 | rgba(0, 180, 42, 0.1) | +| 文字 | #00B42A, 13px | +| 边框 | 无 | +| 圆角 | 4px | +| 内边距 | 4px 8px | +| 用途 | 高分企业、高薪、低加班、高匹配、合规企业 | + +#### 橙色标签(中性) + +| 属性 | 数值 | +|------|------| +| 背景 | rgba(255, 125, 0, 0.1) | +| 文字 | #FF7D00, 13px | +| 边框 | 无 | +| 圆角 | 4px | +| 内边距 | 4px 8px | +| 用途 | 中等评分、数据估算、数据补充中、普通匹配 | + +#### 红色标签(风险) + +| 属性 | 数值 | +|------|------| +| 背景 | rgba(245, 63, 63, 0.1) | +| 文字 | #F53F3F, 13px | +| 边框 | 无 | +| 圆角 | 4px | +| 内边距 | 4px 8px | +| 用途 | 合规风险、劳动仲裁、低分避雷、匹配不足、处罚记录 | + +--- + +## 八、核心专属组件规范 + +### 8.1 评分可视化组件 + +#### 综合总分卡片 + +| 属性 | 数值 | +|------|------| +| 卡片高度 | 120px | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 阴影 | shadow-md | +| 大号数字 | 48px, Bold, #1D2129 | +| 状态标签 | 紧贴数字右侧 | +| 企业规模层级 | #86909C, 13px | + +#### 八维度雷达图容器 + +| 属性 | 数值 | +|------|------| +| 尺寸 | 280px × 280px | +| 背景 | #FFFFFF | +| 圆角 | 12px | +| 维度标签 | 13px, #4E5968 | +| 分数标签 | 15px, #1D2129 | +| 高亮区域填充 | rgba(22, 119, 255, 0.2) | +| 高亮边线 | #1677FF, 2px | +| 未覆盖维度 | #E5E6EB填充, 「暂未覆盖」标注 | +| 估算分数 | 橙色虚线区分 | + +#### 企业规模×行业二维矩阵 + +| 属性 | 数值 | +|------|------| +| 容器高度 | 200px | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 圆形面积 | 区分企业体量 | +| 热门行业高亮 | #1677FF 20%透明度填充 | + +#### 评分趋势曲线卡片 + +| 属性 | 数值 | +|------|------| +| 容器高度 | 160px | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 折线粗细 | 2px | +| 上升曲线 | #00B42A | +| 下降曲线 | #F53F3F | +| 平稳曲线 | #86909C | + +### 8.2 自定义权重调节组件 + +#### 维度权重滑块 + +| 属性 | 数值 | +|------|------| +| 滑块轨道高度 | 4px | +| 滑块手柄尺寸 | 24px | +| 轨道背景 | #E5E6EB | +| 激活轨道 | #1677FF | +| 数值输入框宽度 | 60px | +| 数值字号 | 15px, Medium | +| 归一化提示 | #86909C, 13px | + +#### 权重预设模板卡片 + +| 模板名称 | 标签色 | +|----------|--------| +| 求稳版 | #1677FF | +| 薪资优先版 | #FF7D00 | +| 成长冲刺版 | #00B42A | +| 稳健通勤版 | #86909C | + +| 属性 | 数值 | +|------|------| +| 卡片高度 | 64px | +| 圆角 | 8px | +| 背景 | #FFFFFF | +| 边框 | 1px solid #E5E6EB | +| 激活边框 | 2px solid #1677FF | + +### 8.3 评分证据链溯源组件 + +#### 数据源详情弹窗 + +| 属性 | 数值 | +|------|------| +| 最大高度 | 80vh | +| 圆角 | 顶部 0, 底部 12px | +| 背景 | #FFFFFF | +| 分栏标题 | 17px, Medium, #1D2129 | +| 数据来源标注 | 13px, #86909C | +| 采集时间 | 13px, #86909C | + +#### 证据快照卡片 + +| 属性 | 数值 | +|------|------| +| 卡片圆角 | 8px | +| 背景 | #F7F8FA | +| 内边距 | 12px | +| 官方数据源标识 | #1677FF标签 | +| 用户评价标识 | #86909C标签 | +| 风险记录高亮 | #F53F3F背景 | + +### 8.4 企业并排对比组件 + +| 属性 | 数值 | +|------|------| +| 支持2-3家企业 | 横向分栏 | +| 卡片宽度 | 自适应 | +| 卡片间距 | 12px | +| 高分维度底色 | rgba(0, 180, 42, 0.08) | +| 低分维度底色 | rgba(245, 63, 63, 0.08) | +| 分数差值标注 | 绿色/红色底色 | + +### 8.5 匹配门槛校验组件 + +#### 匹配度进度卡片 + +| 属性 | 数值 | +|------|------| +| 卡片高度 | 80px | +| 圆角 | 12px | +| 进度条高度 | 8px | +| 进度条背景 | #E5E6EB | +| 达标进度填充 | #00B42A | +| 未达标进度填充 | #F53F3F | +| 匹配度数字 | 28px, Bold | +| 差距维度标注 | #86909C, 13px | + +#### 沟通拦截弹窗 + +| 属性 | 数值 | +|------|------| +| 弹窗宽度 | 300px | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 警示图标 | 48px, #F53F3F | +| 警示文字 | 17px, Medium, #1D2129 | +| 说明文字 | 15px, #4E5968 | +| 确认按钮 | 主按钮样式 | + +### 8.6 AI求职辅助组件 + +| 属性 | 数值 | +|------|------| +| AI生成内容标注 | 「AI辅助建议」小字, 12px, #86909C | +| AI图标 | 20px, #1677FF | +| 输入框高度 | 44px | +| 输入框圆角 | 8px | + +--- + +## 九、筛选、弹窗、选择器规范 + +### 9.1 顶部多条件筛选栏 + +| 属性 | 数值 | +|------|------| +| 高度 | 44px | +| 背景 | #FFFFFF | +| 分割线 | 0.5px solid #E5E6EB | +| 标签样式 | 圆角 8px, 内边距 8px 12px | +| 未激活标签 | 背景 #F2F3F5, 文字 #4E5968 | +| 激活标签 | 背景 rgba(22, 119, 255, 0.1), 文字 #1677FF | + +### 9.2 底部弹出选择器 + +| 属性 | 数值 | +|------|------| +| 最大高度 | 50vh | +| 圆角 | 顶部 0, 底部 12px | +| 背景 | #FFFFFF | +| 遮罩 | rgba(0, 0, 0, 0.5) | +| 选项行高 | 56px | +| 选项文字 | 17px, #1D2129 | +| 选中项文字 | 17px, Medium, #1677FF | +| 取消/确认按钮 | 高度 48px | + +### 9.3 全局通用弹窗 + +| 属性 | 数值 | +|------|------| +| 弹窗宽度 | 300px (居中) / 全宽 (底部) | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 遮罩 | rgba(0, 0, 0, 0.5) | +| 标题 | 17px, Medium, #1D2129 | +| 内容文字 | 15px, #4E5968 | +| 按钮区域 | 底部, 右对齐 | + +### 9.4 Toast轻提示 + +| 属性 | 数值 | +|------|------| +| 最小宽度 | 120px | +| 最大宽度 | 280px | +| 圆角 | 8px | +| 背景 | rgba(0, 0, 0, 0.75) | +| 文字 | #FFFFFF, 15px | +| 内边距 | 12px 20px | +| 显示时长 | 2秒 | + +--- + +## 十、求职流程专项组件 + +### 10.1 简历上传卡片 + +| 属性 | 数值 | +|------|------| +| 卡片高度 | 80px | +| 圆角 | 12px | +| 背景 | #FFFFFF | +| 边框 | 1px dashed #1677FF (未上传) | +| 图标 | 32px, #1677FF | +| 标题 | 15px, Medium, #1D2129 | +| 副标题 | 13px, #86909C | + +### 10.2 投递状态标签 + +| 状态 | 颜色 | 文字 | +|------|------|------| +| 待企业查看 | #1677FF | 「待查看」 | +| 意向匹配 | #00B42A | 「意向匹配」 | +| 面试邀约 | #FF7D00 | 「面试邀约」 | +| Offer | #00B42A | 「Offer」 | +| 已拒绝 | #86909C | 「已拒绝」 | + +### 10.3 聊天会话面板 + +| 属性 | 数值 | +|------|------| +| 消息气泡圆角 | 12px | +| 发送方气泡背景 | #1677FF | +| 接收方气泡背景 | #F2F3F5 | +| 消息文字 | 15px | +| 时间戳 | 12px, #86909C | +| 输入框高度 | 44px | + +--- + +## 十一、空状态、骨架屏规范 + +### 11.1 骨架屏 + +| 属性 | 数值 | +|------|------| +| 动画 | 渐变闪烁 (1.5s循环) | +| 背景 | #F2F3F5 | +| 高亮条 | rgba(255, 255, 255, 0.6) | +| 圆角 | 与实际组件一致 | + +#### 骨架屏类型 + +- 企业列表骨架 (行高 60px) +- 评分雷达图骨架 (280px × 280px) +- 企业详情骨架 +- 对比页面骨架 +- 权重设置页骨架 + +### 11.2 空页面 + +| 属性 | 数值 | +|------|------| +| 图标尺寸 | 80px | +| 图标颜色 | #C9CDD4 | +| 标题 | 17px, Medium, #1D2129 | +| 副标题 | 15px, #86909C | +| 操作按钮 | 次要描边按钮样式 | + +#### 空状态类型 + +- 无匹配岗位 +- 无意向企业 +- 无员工评价 +- 暂无投递记录 +- 暂无企业入驻 + +### 11.3 数据占位模块 + +| 类型 | 样式 | +|------|------| +| 维度「暂未覆盖」 | 灰色占位卡片, #E5E6EB背景, #86909C文字 | +| 「数据估算」 | 橙色虚线标识 | +| 网络异常 | 空状态页面 + 重试按钮 | +| 冷启动引导 | 引导卡片 + 操作提示 | + +--- + +## 十二、完整页面清单 + +### 12.1 C端页面(求职者) + +| 页面 | 核心组件 | +|------|----------| +| **首页** | 高分企业榜单、快速筛选入口、权重模板快捷入口 | +| **企业库** | 企业搜索、多维度筛选、企业列表卡片 | +| **企业详情页** | 8维度雷达图、分项评分、证据链溯源入口、员工评价、收藏投递按钮、评分趋势 | +| **自定义权重配置页** | 维度滑块调节、预设模板、一键重排企业清单 | +| **企业对比页** | 2-3家企业横向维度对比、权重实时联动更新 | +| **求职匹配页** | 岗位匹配列表、匹配度标签、投递前置诊断 | +| **简历档案页** | 简历上传、个人信息填写、AI投递话术助手 | +| **沟通会话页** | 匹配门槛校验拦截、聊天面板、意向确认弹窗 | +| **个人中心** | 投递记录、收藏企业、我的评价、账号隐私设置、求职档案导出 | + +### 12.2 B端页面(企业) + +| 页面 | 核心组件 | +|------|----------| +| **企业入驻页** | 企业资质认证、工商信息填写 | +| **岗位发布页** | 岗位编辑、薪资范围设置 | +| **评分数据查看页** | 企业评分详情、维度分数、证据链 | +| **候选人匹配列表** | 候选人卡片、匹配度标签、意向状态 | + +--- + +## 十三、输出硬性约束规则 + +### 13.1 视觉约束 + +1. ✅ 仅输出标准化设计Token规范文档 +2. ❌ 不产出任何页面效果图、截图、插画素材 +3. ❌ 禁止卡通插画、高饱和渐变、炫酷动效、深色模式 +4. ✅ 保持商务清爽工具视觉调性 + +### 13.2 业务逻辑约束 + +所有业务视觉区分贴合V1.3产品文档核心逻辑: + +- 分层评分体系 (8维度) +- 权重自定义功能 +- 证据溯源机制 +- 匹配沟通门槛 +- 企业二维覆盖矩阵 + +用户可直观区分: + +- 企业稳定性 +- 合规风险 +- 匹配优劣 +- 数据完整度 + +### 13.3 可用性约束 + +- 所有标注文案、占位提示、状态标签文字字号 ≥ 12px +- 全部可点击控件热区 ≥ 48×48dp +- 适配学生、HR多年龄段阅读需求 + +--- + +## 十四、设计Token快速参考表 + +### 14.1 色彩Token + +```css +/* 品牌色 */ +--color-primary: #1677FF; +--color-secondary: #4096FF; + +/* 状态色 */ +--color-success: #00B42A; +--color-warning: #FF7D00; +--color-danger: #F53F3F; + +/* 文本色 */ +--color-text-title: #1D2129; +--color-text-body: #4E5968; +--color-text-caption: #86909C; + +/* 背景色 */ +--color-bg-page: #F2F3F5; +--color-bg-surface: #FFFFFF; + +/* 边框色 */ +--color-border: #E5E6EB; +--color-border-disabled: #C9CDD4; +``` + +### 14.2 间距Token + +```css +--spacing-xs: 4px; +--spacing-sm: 8px; +--spacing-md: 16px; +--spacing-lg: 24px; +--spacing-xl: 32px; +--spacing-2xl: 48px; +``` + +### 14.3 圆角Token + +```css +--radius-sm: 4px; +--radius-md: 8px; +--radius-lg: 12px; +--radius-full: 9999px; +``` + +### 14.4 阴影Token + +```css +--shadow-sm: 0 1px 2px rgba(0,0,0,0.04); +--shadow-md: 0 2px 8px rgba(0,0,0,0.06); +--shadow-lg: 0 4px 16px rgba(0,0,0,0.08); +``` + +### 14.5 字体Token + +```css +/* 层级 */ +--font-h1: 700 24px/32px 'Inter', 'SF Pro Text', sans-serif; +--font-h2: 700 20px/28px 'Inter', 'SF Pro Text', sans-serif; +--font-h3: 500 17px/24px 'Inter', 'SF Pro Text', sans-serif; +--font-body: 400 15px/22px 'Inter', 'SF Pro Text', sans-serif; +--font-caption: 400 13px/18px 'Inter', 'SF Pro Text', sans-serif; +``` + +--- + +## 十五、版本记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| V1.0 | 2026-08-04 | 初始版本,完整设计Token规范 | + +--- + +> **文档状态**:已完成 +> **适用范围**:职来(mydreamjob)移动端全平台 +> **维护团队**:产品设计组 \ No newline at end of file diff --git a/product-docs/职来mydreamjob_架构图.puml b/product-docs/职来mydreamjob_架构图.puml new file mode 100644 index 0000000..62c23c7 --- /dev/null +++ b/product-docs/职来mydreamjob_架构图.puml @@ -0,0 +1,86 @@ +@startuml 职来mydreamjob_产品开发方案_架构图 + +' ========================= +' 职来(mydreamjob)产品开发方案 — 架构图 +' ========================= + +title 职来(mydreamjob)产品开发方案 - 总体架构 + +skinparam rectangle { + BackgroundColor<> #E3F2FD + BackgroundColor<> #FFF3E0 + BackgroundColor<> #E8F5E9 + BackgroundColor<> #FCE4EC + BackgroundColor<> #F3E5F5 + BorderColor #455A64 + FontStyle bold +} + +skinparam defaultTextAlignment center +skinparam componentStyle rectangle +skinparam note { + BackgroundColor #FFFDE7 + BorderColor #F57F17 +} + +' ---------- 客户端层 ---------- +package "客户端层" { + [Android App] as Android <> + [iOS App] as iOS <> +} + +' ---------- 跨端框架(核心差异化) ---------- +rectangle "Flutter 3 跨端\n(一套代码出双端)" <> as Flutter + +Android -[hidden]right- iOS +Android -down- Flutter : 渲染 +iOS -down- Flutter : 渲染 + +' ---------- API 网关 ---------- +rectangle "API 网关 / Nginx" <> as Gateway + +Flutter -down- Gateway : HTTPS / JSON +Android -down- Gateway : HTTPS / JSON +iOS -down- Gateway : HTTPS / JSON + +' ---------- 应用层 ---------- +package "应用服务层 (Spring Boot 3.x · Java 17)" <> { + + rectangle "Spring Security\n(JWT 鉴权 / RBAC 权限)" as Security + rectangle "业务服务\n账号/档案/企业/岗位/投递/沟通" as Biz + rectangle "ranking 评价排名引擎\n(异步批算)" as Ranking <> + rectangle "AI 适配层\n(模型调用/降级/成本控制)" as AI + rectangle "运营后台 API" as Admin + + note right of Ranking + 核心差异化模块 + end note + + Security -[hidden]down- Biz + Biz -[hidden]down- Ranking + Ranking -[hidden]down- AI + AI -[hidden]down- Admin +} + +Gateway -down- Security : 路由转发 +Security -down- Biz : 鉴权后 +Biz -down- Ranking : 调用 +Biz -down- AI : 调用 +Admin -up- Biz : 读写 + +' ---------- 数据层 ---------- +package "数据存储层" <> { + database "MySQL 8\n主数据" as MySQL + database "Redis\n缓存 / 限流" as Redis + queue "RabbitMQ\n异步队列" as MQ + storage "MinIO / S3\n文件存储" as OSS +} + +Biz -down- MySQL : CRUD +Biz -down- Redis : 缓存 / 限流 +Ranking -down- MQ : 异步批算任务 +Biz -down- OSS : 简历 / 图片 +AI -down- Redis : 限流 / 缓存 +Admin -down- MySQL : 读写 + +@enduml diff --git a/product-docs/职来产品功能设计.xlsx b/product-docs/职来产品功能设计.xlsx new file mode 100644 index 0000000..f105613 Binary files /dev/null and b/product-docs/职来产品功能设计.xlsx differ diff --git a/scripts/blackbox_test.py b/scripts/blackbox_test.py new file mode 100644 index 0000000..184f374 --- /dev/null +++ b/scripts/blackbox_test.py @@ -0,0 +1,578 @@ +""" +面试模块黑盒验收脚本 +使用新账号,验证码从数据库读取(模拟后端日志行为) +""" +import requests +import json +import time +import sys +import pymysql +import datetime +import os + +BASE = "http://127.0.0.1:8080/api/v1" +HEADERS = {"Content-Type": "application/json"} + +# 数据库连接 +def get_db(): + return pymysql.connect(host='127.0.0.1', port=3307, + user=os.environ.get('DB_USERNAME', 'mydreamjob'), + password=os.environ['DB_PASSWORD'], + database='mydreamjob') + +def get_db_code(phone, purpose="login"): + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT code FROM sms_verification_codes WHERE phone=%s AND purpose=%s AND used=0 ORDER BY created_at DESC LIMIT 1", + (phone, purpose)) + row = cur.fetchone() + conn.close() + return row[0] if row else None + +results = [] +passed = 0 +failed = 0 + +def test(name, fn): + global passed, failed + try: + fn() + print(f" PASS: {name}") + results.append((name, "PASS", "")) + passed += 1 + except AssertionError as e: + print(f" FAIL: {name} - {e}") + results.append((name, "FAIL", str(e))) + failed += 1 + except Exception as e: + print(f" FAIL: {name} - {type(e).__name__}: {e}") + results.append((name, "FAIL", f"{type(e).__name__}: {e}")) + failed += 1 + +def send_code(phone): + r = requests.post(f"{BASE}/auth/sms/send?phone={phone}&purpose=login", headers=HEADERS) + if r.status_code != 200: + raise Exception(f"Send code failed: {r.status_code} {r.text}") + time.sleep(0.5) + code = get_db_code(phone, "login") + if not code: + raise Exception(f"No code found for {phone}") + return code + +def login(phone, role): + code = send_code(phone) + r = requests.post(f"{BASE}/auth/login", json={ + "phone": phone, "code": code + }, headers=HEADERS) + if r.status_code != 200: + raise Exception(f"Login failed: {r.status_code} {r.text}") + user_id = r.json()["data"]["userId"] + # Set role in DB (mock login always creates STUDENT) + if role == "COMPANY": + conn = get_db() + cur = conn.cursor() + cur.execute( + "INSERT INTO user_roles (user_id, role) VALUES (%s, 'COMPANY') ON DUPLICATE KEY UPDATE role=role", (user_id,)) + cur.execute( + "UPDATE users SET role = 'COMPANY' WHERE id = %s", (user_id,)) + conn.commit() + conn.close() + print(f" Set user {user_id} to COMPANY role") + return r.json()["data"]["accessToken"] + +def auth_header(token): + return {"Content-Type": "application/json", "Authorization": f"Bearer {token}"} + +# ── Setup: 创建新账号 ── +print("=== Setup: New Accounts ===") +ts = int(time.time()) + +# 企业账号 +company_phone = f"138{ts % 100000000:08d}" +print(f" Company: {company_phone}") +company_token = login(company_phone, "COMPANY") +print(f" Company token: {company_token[:20]}...") + +# 求职者账号 +student_phone = f"139{ts % 100000000:08d}" +print(f" Student: {student_phone}") +student_token = login(student_phone, "STUDENT") +print(f" Student token: {student_token[:20]}...") + +# 第三方账号(非参与者) +third_phone = f"150{ts % 100000000:08d}" +print(f" Third party: {third_phone}") +third_token = login(third_phone, "STUDENT") +print(f" Third token: {third_token[:20]}...") + +# 其他企业 +other_company_phone = f"151{ts % 100000000:08d}" +print(f" Other company: {other_company_phone}") +other_company_token = login(other_company_phone, "COMPANY") +print(f" Other company token: {other_company_token[:20]}...") + +# ── 创建公司资料 ── +print("=== Company Profile ===") +r = requests.post(f"{BASE}/companies/me", json={ + "name": "黑盒测试科技有限公司", + "description": "面试模块黑盒验收测试企业", + "industry": "互联网" +}, headers=auth_header(company_token)) +if r.status_code != 200: + print(f" Create company warning: {r.status_code} {r.text}") +else: + print(f" Company created: {r.json()['data']['name']}") + +# ── 创建其他企业资料 ── +r = requests.post(f"{BASE}/companies/me", json={ + "name": "其他黑盒测试企业", + "description": "其他企业", + "industry": "金融" +}, headers=auth_header(other_company_token)) +print(f" Other company profile: {r.status_code}") + +# ── 创建学生资料 ── +print("=== Student Profile ===") +r = requests.put(f"{BASE}/profile/me", json={ + "realName": "黑盒测试学生", + "education": "本科" +}, headers=auth_header(student_token)) +print(f" Student profile: {r.status_code}") + +# ── 创建岗位并投递 ── +print("=== Create Job ===") +r = requests.post(f"{BASE}/jobs", json={ + "title": "黑盒测试岗位", + "description": "面试模块黑盒验收测试岗位", + "city": "北京", + "employmentType": "FULL_TIME", + "salaryMin": 10000, + "salaryMax": 20000 +}, headers=auth_header(company_token)) +assert r.status_code == 200, f"Create job failed: {r.status_code} {r.text}" +job_id = r.json()["data"]["id"] +print(f" Job ID: {job_id}") + +# 发布岗位 (DRAFT -> PUBLISHED) +print("=== Publish Job ===") +r = requests.patch(f"{BASE}/jobs/{job_id}/status", json={"status": "PUBLISHED"}, + headers=auth_header(company_token)) +if r.status_code != 200: + print(f" Publish warning: {r.status_code} {r.text}") +else: + print(f" Job published: {r.json()['data']['status']}") + +# 投递 +print("=== Create Application ===") +r = requests.post(f"{BASE}/applications", json={"jobId": job_id}, + headers=auth_header(student_token)) +if r.status_code != 200: + print(f" Create application failed: {r.status_code} {r.text}") + # Try listing applications to see if there's already one + r2 = requests.get(f"{BASE}/applications/me", headers=auth_header(student_token)) + print(f" My apps: {r2.status_code}") + if r2.status_code == 200: + data = r2.json()["data"] + if data["total"] > 0: + application_id = data["items"][0]["id"] + print(f" Using existing application: {application_id}") + else: + print(" No existing application, exiting") + sys.exit(1) + else: + sys.exit(1) +else: + application_id = r.json()["data"]["id"] + print(f" Application ID: {application_id}") + +# ── 黑盒测试 ── +print("\n=== Black-box Tests ===") + +interview_id = None + +# 1. 企业创建面试 +def test_01(): + global interview_id + future = (datetime.datetime.now() + datetime.timedelta(days=7)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=7)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "技术面试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/blackbox" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + d = r.json() + assert d["data"]["status"] == "SCHEDULED", f"Expected SCHEDULED, got {d['data']['status']}" + interview_id = d["data"]["id"] + print(f" Interview ID: {interview_id}") + +test("1. 企业创建面试", test_01) + +# 2. 企业查询该投递的面试列表 +def test_02(): + r = requests.get(f"{BASE}/applications/{application_id}/interviews", + headers=auth_header(company_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + data = r.json()["data"] + assert len(data) > 0, "Expected at least 1 interview" + assert data[0]["id"] == interview_id + +test("2. 企业查询投递面试列表", test_02) + +# 3. 求职者查询自己的面试 +def test_03(): + r = requests.get(f"{BASE}/interviews/me", headers=auth_header(student_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + assert r.json()["data"]["total"] > 0, "Expected at least 1 interview" + +test("3. 求职者查询自己的面试", test_03) + +# 4. 双方查看面试详情 +def test_04(): + r1 = requests.get(f"{BASE}/interviews/{interview_id}", headers=auth_header(company_token)) + assert r1.status_code == 200, f"Company view failed: {r1.status_code}" + r2 = requests.get(f"{BASE}/interviews/{interview_id}", headers=auth_header(student_token)) + assert r2.status_code == 200, f"Student view failed: {r2.status_code}" + assert r1.json()["data"]["status"] == "SCHEDULED" + +test("4. 双方查看面试详情", test_04) + +# 5. 企业修改 SCHEDULED 面试 +def test_05(): + new_future = (datetime.datetime.now() + datetime.timedelta(days=14)).strftime("%Y-%m-%dT10:00:00") + new_end = (datetime.datetime.now() + datetime.timedelta(days=14)).strftime("%Y-%m-%dT11:00:00") + r = requests.put(f"{BASE}/interviews/{interview_id}", json={ + "title": "技术面试(已修改)", + "startAt": new_future, + "endAt": new_end + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + assert r.json()["data"]["title"] == "技术面试(已修改)" + +test("5. 企业修改SCHEDULED面试", test_05) + +# 6. 求职者 CONFIRMED +def test_06(): + r = requests.post(f"{BASE}/interviews/{interview_id}/respond", json={ + "action": "CONFIRM", "reason": "时间可以接受" + }, headers=auth_header(student_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + assert r.json()["data"]["status"] == "CONFIRMED" + +test("6. 求职者CONFIRMED", test_06) + +# 7. 求职者 DECLINED (需要新面试) +def test_07(): + future = (datetime.datetime.now() + datetime.timedelta(days=21)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=21)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "拒绝测试面试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/decline" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + r2 = requests.post(f"{BASE}/interviews/{iid}/respond", json={ + "action": "DECLINE", "reason": "时间不合适" + }, headers=auth_header(student_token)) + assert r2.status_code == 200, f"Expected 200, got {r2.status_code}: {r2.text}" + assert r2.json()["data"]["status"] == "DECLINED" + +test("7. 求职者DECLINED", test_07) + +# 8. 求职者 RESCHEDULE_REQUESTED (需要新面试) +def test_08(): + future = (datetime.datetime.now() + datetime.timedelta(days=28)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=28)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "改期请求测试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/reschedule" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + r2 = requests.post(f"{BASE}/interviews/{iid}/respond", json={ + "action": "REQUEST_RESCHEDULE", "reason": "希望改到下周" + }, headers=auth_header(student_token)) + assert r2.status_code == 200, f"Expected 200, got {r2.status_code}: {r2.text}" + assert r2.json()["data"]["status"] == "RESCHEDULE_REQUESTED" + +test("8. 求职者RESCHEDULE_REQUESTED", test_08) + +# 9. 企业 CANCEL (需要新面试) +def test_09(): + future = (datetime.datetime.now() + datetime.timedelta(days=35)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=35)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "取消测试面试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/cancel" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + r2 = requests.post(f"{BASE}/interviews/{iid}/cancel", json={ + "reason": "岗位已招满" + }, headers=auth_header(company_token)) + assert r2.status_code == 200, f"Expected 200, got {r2.status_code}: {r2.text}" + assert r2.json()["data"]["status"] == "CANCELLED" + +test("9. 企业CANCEL", test_09) + +# 10. 企业 COMPLETE - 需要 CONFIRMED 且已过 endAt +def test_10(): + # 创建一个很快结束的面试 + soon_start = (datetime.datetime.now() + datetime.timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S") + soon_end = (datetime.datetime.now() + datetime.timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "完成测试面试", + "interviewType": "ONLINE", + "startAt": soon_start, + "endAt": soon_end, + "meetingUrl": "https://meet.example.com/complete" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + # 确认 + r2 = requests.post(f"{BASE}/interviews/{iid}/respond", json={ + "action": "CONFIRM" + }, headers=auth_header(student_token)) + assert r2.status_code == 200, f"Confirm failed: {r2.status_code}: {r2.text}" + # 等待面试结束 + print(f" Waiting for interview to end...") + time.sleep(130) # wait 130 seconds (2+ minutes) + # 完成 + r3 = requests.post(f"{BASE}/interviews/{iid}/complete", json={ + "notes": "面试表现优秀" + }, headers=auth_header(company_token)) + assert r3.status_code == 200, f"Expected 200, got {r3.status_code}: {r3.text}" + assert r3.json()["data"]["status"] == "COMPLETED" + +test("10. 企业COMPLETE", test_10) + +# 11. 查询 timeline +def test_11(): + r = requests.get(f"{BASE}/interviews/{interview_id}/timeline", + headers=auth_header(company_token)) + assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" + data = r.json()["data"] + assert len(data) > 0, "Expected at least 1 timeline entry" + # Check history entries + statuses = [e["toStatus"] for e in data] + assert "SCHEDULED" in statuses, "Timeline should contain SCHEDULED" + assert "CONFIRMED" in statuses, "Timeline should contain CONFIRMED" + +test("11. 查询timeline", test_11) + +# 12. 确认通知发送 - check notification queue +def test_12(): + # 检查 notification 队列是否有消息(通过 RabbitMQ API) + # 注意:消息可能已被消费,所以这里只检查队列存在 + r = requests.get("http://127.0.0.1:15672/api/queues/%2F/queue.notification", + auth=("mydreamjob_dev", "Ch8mP@ssw0rd_2024!")) + assert r.status_code == 200, f"Queue check failed: {r.status_code}" + print(f" Notification queue messages: {r.json().get('messages', 'N/A')}") + +test("12. 通知队列存在", test_12) + +# 13. 幂等测试 - 重复操作 +def test_13(): + future = (datetime.datetime.now() + datetime.timedelta(days=42)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=42)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "幂等测试面试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/idempotent" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + + # 重复取消 + r2 = requests.post(f"{BASE}/interviews/{iid}/cancel", json={"reason": "第一次取消"}, + headers=auth_header(company_token)) + assert r2.status_code == 200, f"First cancel: {r2.status_code}: {r2.text}" + r3 = requests.post(f"{BASE}/interviews/{iid}/cancel", json={"reason": "第二次取消"}, + headers=auth_header(company_token)) + assert r3.status_code == 200, f"Idempotent cancel: {r3.status_code}: {r3.text}" + assert r3.json()["data"]["status"] == "CANCELLED" + + # 重复确认 + future2 = (datetime.datetime.now() + datetime.timedelta(days=43)).strftime("%Y-%m-%dT14:00:00") + future_end2 = (datetime.datetime.now() + datetime.timedelta(days=43)).strftime("%Y-%m-%dT15:00:00") + r4 = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "幂等确认测试", + "interviewType": "ONLINE", + "startAt": future2, + "endAt": future_end2, + "meetingUrl": "https://meet.example.com/idem2" + }, headers=auth_header(company_token)) + assert r4.status_code == 200 + iid2 = r4.json()["data"]["id"] + r5 = requests.post(f"{BASE}/interviews/{iid2}/respond", json={"action": "CONFIRM"}, + headers=auth_header(student_token)) + assert r5.status_code == 200 + r6 = requests.post(f"{BASE}/interviews/{iid2}/respond", json={"action": "CONFIRM"}, + headers=auth_header(student_token)) + assert r6.status_code == 200, f"Idempotent confirm: {r6.status_code}: {r6.text}" + assert r6.json()["data"]["status"] == "CONFIRMED" + +test("13. 幂等测试(重复取消/确认返回200)", test_13) + +# 14. 非参与者访问 403 +def test_14(): + r = requests.get(f"{BASE}/interviews/{interview_id}", headers=auth_header(third_token)) + assert r.status_code == 403, f"Expected 403, got {r.status_code}: {r.text}" + +test("14. 非参与者访问返回403", test_14) + +# 15. 其他企业修改 403 +def test_15(): + # 其他企业尝试创建面试(不属于他的投递) + future = (datetime.datetime.now() + datetime.timedelta(days=50)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=50)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "越权测试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/unauth" + }, headers=auth_header(other_company_token)) + assert r.status_code == 403, f"Expected 403, got {r.status_code}: {r.text}" + +test("15. 其他企业创建返回403", test_15) + +# 16. 非法状态流转 409 +def test_16(): + future = (datetime.datetime.now() + datetime.timedelta(days=56)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=56)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "状态流转测试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/flow" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + # 确认 + requests.post(f"{BASE}/interviews/{iid}/respond", json={"action": "CONFIRM"}, + headers=auth_header(student_token)) + # 再次确认 - 应该返回 200 (幂等) 或 409 + r2 = requests.post(f"{BASE}/interviews/{iid}/respond", json={"action": "DECLINE"}, + headers=auth_header(student_token)) + # CONFIRMED -> DECLINE 是非法流转,预期 409 + assert r2.status_code == 409, f"Expected 409, got {r2.status_code}: {r2.text}" + +test("16. 非法状态流转返回409", test_16) + +# 17. 已取消的面试再次修改 +def test_17(): + future = (datetime.datetime.now() + datetime.timedelta(days=60)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=60)).strftime("%Y-%m-%dT15:00:00") + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "修改已取消测试", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/cancelled" + }, headers=auth_header(company_token)) + assert r.status_code == 200, f"Create failed: {r.status_code}: {r.text}" + iid = r.json()["data"]["id"] + requests.post(f"{BASE}/interviews/{iid}/cancel", json={}, headers=auth_header(company_token)) + r2 = requests.put(f"{BASE}/interviews/{iid}", json={"title": "修改已取消"}, + headers=auth_header(company_token)) + assert r2.status_code != 200, f"Expected error, got {r2.status_code}: {r2.text}" + +test("17. 已取消面试不能修改", test_17) + +# 18. 缺失字段/过去时间/结束时间早于开始时间 +def test_18(): + future = (datetime.datetime.now() + datetime.timedelta(days=65)).strftime("%Y-%m-%dT14:00:00") + future_end = (datetime.datetime.now() + datetime.timedelta(days=65)).strftime("%Y-%m-%dT15:00:00") + + # 空标题 + r = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end, + "meetingUrl": "https://meet.example.com/empty" + }, headers=auth_header(company_token)) + assert r.status_code == 400, f"Empty title: expected 400, got {r.status_code}: {r.text}" + + # 过去时间 + r2 = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "过去时间", + "interviewType": "ONLINE", + "startAt": "2020-01-01T14:00:00", + "endAt": "2020-01-01T15:00:00", + "meetingUrl": "https://meet.example.com/past" + }, headers=auth_header(company_token)) + assert r2.status_code != 200, f"Past time: expected error, got {r2.status_code}: {r2.text}" + + # 结束时间早于开始时间 + r3 = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "时间错误", + "interviewType": "ONLINE", + "startAt": future_end, + "endAt": future, + "meetingUrl": "https://meet.example.com/timeerr" + }, headers=auth_header(company_token)) + assert r3.status_code != 200, f"End before start: expected error, got {r3.status_code}: {r3.text}" + + # 缺失必填字段 + r4 = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "缺字段" + }, headers=auth_header(company_token)) + assert r4.status_code == 400, f"Missing fields: expected 400, got {r4.status_code}: {r4.text}" + + # ONLINE 缺少 meetingUrl + r5 = requests.post(f"{BASE}/applications/{application_id}/interviews", json={ + "title": "缺链接", + "interviewType": "ONLINE", + "startAt": future, + "endAt": future_end + }, headers=auth_header(company_token)) + assert r5.status_code == 400, f"Missing meetingUrl: expected 400, got {r5.status_code}: {r5.text}" + +test("18. 字段校验(缺失/过去/倒序)", test_18) + +# 19. 并发更新不能覆盖状态历史 +def test_19(): + # 验证 version 乐观锁 + # 先获取当前 interview 的 version + r = requests.get(f"{BASE}/interviews/{interview_id}", headers=auth_header(company_token)) + assert r.status_code == 200 + version = r.json()["data"]["version"] + print(f" Current version: {version}") + + # 查看 timeline 记录数量 + r2 = requests.get(f"{BASE}/interviews/{interview_id}/timeline", + headers=auth_header(company_token)) + assert r2.status_code == 200 + timeline_count = len(r2.json()["data"]) + print(f" Timeline entries: {timeline_count}") + + # 验证 version 随着操作递增 + # 状态历史记录数应该 >= 状态变更次数 + assert timeline_count >= 2, f"Expected at least 2 timeline entries, got {timeline_count}" + +test("19. 状态历史记录完整", test_19) + +# ── Summary ── +print(f"\n=== Results: {passed}/{passed+failed} passed ===") +for name, status, detail in results: + if status == "FAIL": + print(f" FAIL: {name} - {detail}") + +sys.exit(0 if failed == 0 else 1) diff --git a/scripts/debug2.py b/scripts/debug2.py new file mode 100644 index 0000000..44f90c1 --- /dev/null +++ b/scripts/debug2.py @@ -0,0 +1,25 @@ +import os, requests, time, pymysql +BASE = 'http://127.0.0.1:8080/api/v1' +conn = pymysql.connect(host=os.environ.get('DB_HOST', '127.0.0.1'), + port=int(os.environ.get('DB_PORT', '3307')), + user=os.environ.get('DB_USERNAME', 'mydreamjob'), + password=os.environ['DB_PASSWORD'], + database=os.environ.get('DB_NAME', 'mydreamjob')) +r = requests.post(f'{BASE}/auth/sms/send', params={'phone': '13886593964', 'purpose': 'login'}) +print(f'SMS send: {r.status_code}') +time.sleep(1) +with conn.cursor() as c: + c.execute('SELECT code FROM sms_verification_codes WHERE phone=%s ORDER BY id DESC LIMIT 1', ('13886593964',)) + code = c.fetchone()[0] +r = requests.post(f'{BASE}/auth/login', json={'phone': '13886593964', 'code': code}) +print(f'Login: {r.status_code}') +resp = r.json() +role = resp.get('data', {}).get('role', 'N/A') +print(f'Role: {role}') +token = resp.get('data', {}).get('accessToken', '') +r = requests.get(f'{BASE}/auth/me', headers={'Authorization': f'Bearer {token}'}) +print(f'Auth/me: {r.status_code} {r.text[:200]}') +with conn.cursor() as c: + c.execute('SELECT u.role, GROUP_CONCAT(ur.role) FROM users u JOIN user_roles ur ON u.id=ur.user_id WHERE u.phone=%s GROUP BY u.id', ('13886593964',)) + print(f'DB: {c.fetchone()}') +conn.close() diff --git a/scripts/debug3.py b/scripts/debug3.py new file mode 100644 index 0000000..5aec259 --- /dev/null +++ b/scripts/debug3.py @@ -0,0 +1,33 @@ +import os, requests, time, pymysql +BASE = 'http://127.0.0.1:8080/api/v1' +conn = pymysql.connect(host=os.environ.get('DB_HOST', '127.0.0.1'), + port=int(os.environ.get('DB_PORT', '3307')), + user=os.environ.get('DB_USERNAME', 'mydreamjob'), + password=os.environ['DB_PASSWORD'], + database=os.environ.get('DB_NAME', 'mydreamjob')) + +# Send SMS +r = requests.post(f'{BASE}/auth/sms/send', params={'phone': '13886593964', 'purpose': 'login'}) +print(f'SMS send: {r.status_code}') +time.sleep(2) + +# Get code +with conn.cursor() as c: + c.execute('SELECT code, created_at FROM sms_verification_codes WHERE phone=%s AND purpose=%s AND used=0 ORDER BY id DESC LIMIT 1', ('13886593964', 'login')) + row = c.fetchone() + code = row[0] + print(f'Code: {code}, created: {row[1]}') + +# Login +r = requests.post(f'{BASE}/auth/login', json={'phone': '13886593964', 'code': code, 'purpose': 'login'}) +print(f'Login: {r.status_code}') +if r.status_code == 200: + resp = r.json() + token = resp.get('data', {}).get('accessToken', '') + print(f'Role: {resp.get("data", {}).get("role", "N/A")}') + r = requests.get(f'{BASE}/companies/me', headers={'Authorization': f'Bearer {token}'}) + print(f'Companies/me: {r.status_code} {r.text[:200]}') +else: + print(f'Error: {r.text[:200]}') + +conn.close() diff --git a/scripts/debug_login.py b/scripts/debug_login.py new file mode 100644 index 0000000..7a595d1 --- /dev/null +++ b/scripts/debug_login.py @@ -0,0 +1,42 @@ +import os, requests, time, pymysql +BASE = 'http://127.0.0.1:8080/api/v1' +conn = pymysql.connect(host=os.environ.get('DB_HOST', '127.0.0.1'), + port=int(os.environ.get('DB_PORT', '3307')), + user=os.environ.get('DB_USERNAME', 'mydreamjob'), + password=os.environ['DB_PASSWORD'], + database=os.environ.get('DB_NAME', 'mydreamjob')) + +# Check user data +with conn.cursor() as c: + c.execute("SELECT u.id, u.phone, u.role, GROUP_CONCAT(ur.role) FROM users u LEFT JOIN user_roles ur ON u.id=ur.user_id WHERE u.phone=%s GROUP BY u.id", ('13886593964',)) + print(f'User: {c.fetchone()}') + +# Send SMS +r = requests.post(f'{BASE}/auth/sms/send', params={'phone': '13886593964', 'purpose': 'login'}) +print(f'SMS send: {r.status_code} {r.text[:100]}') +time.sleep(1) + +# Get code +with conn.cursor() as c: + c.execute('SELECT code FROM sms_verification_codes WHERE phone=%s AND purpose=%s ORDER BY id DESC LIMIT 1', ('13886593964', 'login')) + code = c.fetchone()[0] +print(f'Code: {code}') + +# Login +r = requests.post(f'{BASE}/auth/login', json={'phone': '13886593964', 'code': code, 'purpose': 'login'}) +print(f'Login: {r.status_code}') +resp = r.json() +token = resp.get('data', {}).get('accessToken', '') +role = resp.get('data', {}).get('role', 'N/A') +print(f'Role in response: {role}') +print(f'Token: {token[:30]}...') + +# Try /companies/me +r = requests.get(f'{BASE}/companies/me', headers={'Authorization': f'Bearer {token}'}) +print(f'Companies/me: {r.status_code} {r.text[:200]}') + +# Try /auth/me +r = requests.get(f'{BASE}/auth/me', headers={'Authorization': f'Bearer {token}'}) +print(f'Auth/me: {r.status_code} {r.text[:200]}') + +conn.close() diff --git a/scripts/fix_bom.py b/scripts/fix_bom.py new file mode 100644 index 0000000..065ad3e --- /dev/null +++ b/scripts/fix_bom.py @@ -0,0 +1,25 @@ +import json +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +OPENAPI_FILE = PROJECT_ROOT / 'docs' / 'openapi-rc.json' + +with open(OPENAPI_FILE, 'rb') as f: + raw = f.read() +if raw[:3] == b'\xef\xbb\xbf': + raw = raw[3:] + with open(OPENAPI_FILE, 'wb') as f: + f.write(raw) + print('BOM removed') +else: + print('No BOM found') + +with open(OPENAPI_FILE, encoding='utf-8') as f: + d = json.load(f) +paths = d.get('paths', {}) +print(f'Paths: {len(paths)}') +verif_paths = [p for p in paths.keys() if 'verification' in p.lower()] +print(f'Verification paths: {len(verif_paths)}') +for p in verif_paths: + print(f' {p}') +print('JSON OK') diff --git a/scripts/format_doc_v2.py b/scripts/format_doc_v2.py new file mode 100644 index 0000000..45c269c --- /dev/null +++ b/scripts/format_doc_v2.py @@ -0,0 +1,557 @@ +""" +职来产品开发方案 Word 格式化脚本 +将原始 docx 转为规范的 A4 企业级文档:封面、目录、多级标题、正文排版、表格、页眉页脚 +""" + +import copy +from docx import Document +from docx.shared import Pt, Cm, RGBColor, Emu +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.section import WD_ORIENT +from docx.oxml.ns import qn, nsdecls +from docx.oxml import parse_xml + +# ⚠️ 历史遗留脚本:原依赖青核项目(collegeCli)目录下的 Word 文档 +# 青核项目已独立为 C:\Users\15642\Desktop\青核\collegeCli +# 若输入文件不存在,请手动指定 SRC 路径后运行 +SRC = r"C:\Users\15642\Desktop\青核\collegeCli\职来(mydreamjob)产品开发方案-v1.docx" +OUT = r"C:\Users\15642\Desktop\青核\collegeCli\职来(mydreamjob)产品开发方案-v1-格式化.docx" + +# ── style mapping ── +HEADING_STYLE_MAP = {"Heading 1": "Heading 1", "Heading 2": "Heading 2", "Heading 3": "Heading 3"} +MONO_FONTS = {"JetBrains Mono", "Consolas", "Courier New", "Fira Code", "Source Code Pro"} + + +def setup_page(doc): + """A4, margins""" + for sec in doc.sections: + sec.page_width = Cm(21.0) + sec.page_height = Cm(29.7) + sec.top_margin = Cm(2.54) + sec.bottom_margin = Cm(2.54) + sec.left_margin = Cm(3.18) + sec.right_margin = Cm(3.18) + + +def setup_styles(doc): + """Define heading & body styles""" + # ── Normal (body text) ── + style = doc.styles["Normal"] + style.font.name = "宋体" + style.font.size = Pt(12) # 小四 + style.font.color.rgb = RGBColor(0x00, 0x00, 0x00) + style.paragraph_format.line_spacing = 1.5 + style.paragraph_format.space_before = Pt(0) + style.paragraph_format.space_after = Pt(0) + style.paragraph_format.first_line_indent = Pt(24) # 2 chars ≈ 24pt + # Set east-asian font + rPr = style.element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = parse_xml(f'') + rPr.append(rFonts) + else: + rFonts.set(qn("w:eastAsia"), "宋体") + + # ── Heading 1 ── + h1 = doc.styles["Heading 1"] + h1.font.name = "黑体" + h1.font.size = Pt(18) # 小二 + h1.font.bold = True + h1.font.color.rgb = RGBColor(0x00, 0x00, 0x00) + h1.paragraph_format.space_before = Pt(18) + h1.paragraph_format.space_after = Pt(12) + h1.paragraph_format.line_spacing = 1.5 + h1.paragraph_format.first_line_indent = Pt(0) + h1.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT + rPr = h1.element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = parse_xml(f'') + rPr.append(rFonts) + else: + rFonts.set(qn("w:eastAsia"), "黑体") + + # ── Heading 2 ── + h2 = doc.styles["Heading 2"] + h2.font.name = "黑体" + h2.font.size = Pt(15) # 小三 + h2.font.bold = True + h2.font.color.rgb = RGBColor(0x00, 0x00, 0x00) + h2.paragraph_format.space_before = Pt(12) + h2.paragraph_format.space_after = Pt(6) + h2.paragraph_format.line_spacing = 1.5 + h2.paragraph_format.first_line_indent = Pt(0) + h2.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT + rPr = h2.element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = parse_xml(f'') + rPr.append(rFonts) + else: + rFonts.set(qn("w:eastAsia"), "黑体") + + # ── Heading 3 ── + h3 = doc.styles["Heading 3"] + h3.font.name = "黑体" + h3.font.size = Pt(14) # 四号 + h3.font.bold = True + h3.font.color.rgb = RGBColor(0x00, 0x00, 0x00) + h3.paragraph_format.space_before = Pt(8) + h3.paragraph_format.space_after = Pt(4) + h3.paragraph_format.line_spacing = 1.5 + h3.paragraph_format.first_line_indent = Pt(0) + h3.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT + rPr = h3.element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = parse_xml(f'') + rPr.append(rFonts) + else: + rFonts.set(qn("w:eastAsia"), "黑体") + + +def setup_numbering(doc): + """Multi-level heading auto-numbering: 1 / 1.1 / 1.1.1""" + # Create a numbering part if it doesn't exist + numbering_part = doc.part.numbering_part + numbering = numbering_part.element + + # Abstract numbering + abstract_num_id = 0 + abstract_num = parse_xml( + f'' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f'' + ) + numbering.insert(0, abstract_num) + + # Numbering instance + num_id = 1 + num = parse_xml( + f'' + f' ' + f'' + ) + numbering.append(num) + + # Link numbering to heading styles + for level, style_name in [(0, "Heading 1"), (1, "Heading 2"), (2, "Heading 3")]: + style = doc.styles[style_name] + pPr = style.element.get_or_add_pPr() + numPr = pPr.find(qn("w:numPr")) + if numPr is not None: + pPr.remove(numPr) + numPr = parse_xml( + f'' + f' ' + f' ' + f'' + ) + pPr.append(numPr) + + +def add_cover_page(doc): + """Professional cover page""" + for _ in range(6): + doc.add_paragraph("") + + title_p = doc.add_paragraph("") + title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + title_run = title_p.add_run("职来(mydreamjob)产品开发方案") + title_run.font.name = "黑体" + title_run.font.size = Pt(26) + title_run.font.bold = True + title_run.font.color.rgb = RGBColor(0x00, 0x00, 0x00) + rPr = title_run._r.get_or_add_rPr() + rFonts = parse_xml(f'') + rPr.append(rFonts) + + doc.add_paragraph("") + + subtitle_p = doc.add_paragraph("") + subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + sub_run = subtitle_p.add_run("面向大学生与应届生的可信求职平台") + sub_run.font.name = "宋体" + sub_run.font.size = Pt(16) + sub_run.font.color.rgb = RGBColor(0x44, 0x44, 0x44) + + for _ in range(8): + doc.add_paragraph("") + + meta_items = [ + ("文档版本:", "v1.0"), + ("编制日期:", "2026 年 7 月 30 日"), + ("密级:", "内部"), + ("编制:", "项目组"), + ("送阅对象:", "公司负责人 / 投资人"), + ] + for label, value in meta_items: + mp = doc.add_paragraph("") + mp.alignment = WD_ALIGN_PARAGRAPH.CENTER + lr = mp.add_run(label) + lr.font.name = "宋体" + lr.font.size = Pt(12) + vr = mp.add_run(value) + vr.font.name = "宋体" + vr.font.size = Pt(12) + vr.font.bold = True + + doc.add_page_break() + + +def add_toc(doc): + """Insert TOC field (populated when opened in Word)""" + toc_heading = doc.add_paragraph("目 录") + toc_heading.alignment = WD_ALIGN_PARAGRAPH.CENTER + toc_heading.style = doc.styles["Normal"] + toc_heading.paragraph_format.first_line_indent = Pt(0) + toc_heading.paragraph_format.space_after = Pt(12) + for run in toc_heading.runs: + run.font.name = "黑体" + run.font.size = Pt(18) + run.font.bold = True + + # Insert TOC field + paragraph = doc.add_paragraph("") + paragraph.paragraph_format.first_line_indent = Pt(0) + run = paragraph.add_run() + fldChar1 = parse_xml(f'') + run._r.append(fldChar1) + + run2 = paragraph.add_run() + instrText = parse_xml( + f' TOC \\o "1-3" \\h \\z \\u ' + ) + run2._r.append(instrText) + + run3 = paragraph.add_run() + fldChar2 = parse_xml(f'') + run3._r.append(fldChar2) + + run4 = paragraph.add_run('(请在 Word 中右键此处,选择"更新域"以生成目录)') + run4.font.name = "宋体" + run4.font.size = Pt(10) + run4.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + + run5 = paragraph.add_run() + fldChar3 = parse_xml(f'') + run5._r.append(fldChar3) + + +def add_header_footer(doc): + """Header with doc title, footer with page number""" + for sec in doc.sections: + # Header + header = sec.header + header.is_linked_to_previous = False + hp = header.paragraphs[0] if header.paragraphs else header.add_paragraph() + hp.alignment = WD_ALIGN_PARAGRAPH.CENTER + hp.paragraph_format.first_line_indent = Pt(0) + hr = hp.add_run("职来(mydreamjob)产品开发方案") + hr.font.name = "宋体" + hr.font.size = Pt(9) + hr.font.color.rgb = RGBColor(0x88, 0x88, 0x88) + # Add bottom border + pPr = hp._p.get_or_add_pPr() + pBdr = parse_xml( + f'' + f' ' + f'' + ) + pPr.append(pBdr) + + # Footer + footer = sec.footer + footer.is_linked_to_previous = False + fp = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + fp.alignment = WD_ALIGN_PARAGRAPH.CENTER + fp.paragraph_format.first_line_indent = Pt(0) + # Page number field + fr1 = fp.add_run() + fldChar1 = parse_xml(f'') + fr1._r.append(fldChar1) + fr2 = fp.add_run() + instrText = parse_xml(f' PAGE ') + fr2._r.append(instrText) + fr3 = fp.add_run() + fldChar2 = parse_xml(f'') + fr3._r.append(fldChar2) + fr4 = fp.add_run("1") + fr4.font.size = Pt(9) + fr5 = fp.add_run() + fldChar3 = parse_xml(f'') + fr5._r.append(fldChar3) + + +def is_code_paragraph(p): + """Detect code/monospace paragraphs""" + if not p.runs: + return False + for r in p.runs: + if r.font.name and r.font.name in MONO_FONTS: + return True + return False + + +def is_separator(text): + """Detect separator lines""" + s = text.strip() + if not s: + return False + # Check if mostly dashes or special separator chars + dash_chars = {"─", "—", "-", "=", "━", "┅", "―"} + total = len(s) + dash_count = sum(1 for c in s if c in dash_chars) + return dash_count / total > 0.6 and total > 10 + + +def fixup_font_eastasia(run, font_name): + """Set east-Asian font on a run""" + rPr = run._r.get_or_add_rPr() + rFonts_elem = rPr.find(qn("w:rFonts")) + if rFonts_elem is None: + rFonts_elem = parse_xml(f'') + rPr.append(rFonts_elem) + else: + rFonts_elem.set(qn("w:eastAsia"), font_name) + + +def copy_paragraph(doc, src_p, heading_counter): + """Copy a paragraph with style-aware formatting and heading auto-numbering""" + src_style = src_p.style.name if src_p.style else "Normal" + text = src_p.text.strip() + + # Determine target style + if src_style in HEADING_STYLE_MAP: + target_style = HEADING_STYLE_MAP[src_style] + # Increment heading counter + if src_style == "Heading 1": + heading_counter[0] += 1 + heading_counter[1] = 0 + heading_counter[2] = 0 + elif src_style == "Heading 2": + heading_counter[1] += 1 + heading_counter[2] = 0 + elif src_style == "Heading 3": + heading_counter[2] += 1 + new_p = doc.add_paragraph(style=target_style) + else: + new_p = doc.add_paragraph(style="Normal") + + # Handle separator + if is_separator(text): + new_p.paragraph_format.first_line_indent = Pt(0) + new_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = new_p.add_run("· · ·") + run.font.name = "宋体" + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(0xCC, 0xCC, 0xCC) + fixup_font_eastasia(run, "宋体") + return + + # Reset first-line indent for non-body paragraphs + if src_style in HEADING_STYLE_MAP or src_style == "List Paragraph": + new_p.paragraph_format.first_line_indent = Pt(0) + + # Handle List Paragraph style + if src_style == "List Paragraph": + new_p.paragraph_format.left_indent = Cm(1.0) + new_p.paragraph_format.first_line_indent = Cm(-0.5) + + # Check for code paragraphs + is_code = is_code_paragraph(src_p) + + # Copy runs + for src_run in src_p.runs: + new_run = new_p.add_run(src_run.text) + + # Determine font + if is_code: + new_run.font.name = "Consolas" + new_run.font.size = Pt(9.5) + elif src_style in HEADING_STYLE_MAP: + # Heading font handled by style + pass + else: + new_run.font.name = "宋体" + new_run.font.size = Pt(12) + fixup_font_eastasia(new_run, "宋体") + + # Copy bold + if src_run.bold is not None: + new_run.bold = src_run.bold + + # Copy italic + if src_run.italic is not None: + new_run.italic = src_run.italic + + return new_p + + +def format_table(new_tbl): + """Apply uniform formatting to a table""" + new_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER + # Table borders + tbl = new_tbl._tbl + tblPr = tbl.tblPr if tbl.tblPr is not None else parse_xml(f'') + borders = parse_xml( + f'' + f' ' + f' ' + f' ' + f' ' + f' ' + f' ' + f'' + ) + existing = tblPr.find(qn("w:tblBorders")) + if existing is not None: + tblPr.remove(existing) + tblPr.append(borders) + + # Format header row (first row) + for ri, row in enumerate(new_tbl.rows): + for cell in row.cells: + for p in cell.paragraphs: + p.paragraph_format.space_before = Pt(2) + p.paragraph_format.space_after = Pt(2) + p.paragraph_format.line_spacing = 1.15 + p.paragraph_format.first_line_indent = Pt(0) + for run in p.runs: + if ri == 0: + run.font.name = "黑体" + run.font.size = Pt(10.5) + run.font.bold = True + fixup_font_eastasia(run, "黑体") + else: + run.font.name = "宋体" + run.font.size = Pt(10.5) + fixup_font_eastasia(run, "宋体") + + # Set column widths based on column count + ncols = len(new_tbl.columns) + if ncols > 0: + col_width = Cm(14.0 / ncols) # approx usable width + for col in new_tbl.columns: + for cell in col.cells: + cell.width = col_width + + +def copy_table(doc, src_tbl, table_index): + """Copy a table from source to target document with formatting""" + # Add table caption + caption_p = doc.add_paragraph("") + caption_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + caption_p.paragraph_format.first_line_indent = Pt(0) + caption_p.paragraph_format.space_before = Pt(12) + caption_p.paragraph_format.space_after = Pt(4) + cr = caption_p.add_run(f"表 {table_index + 1}") + cr.font.name = "黑体" + cr.font.size = Pt(10.5) + cr.font.bold = True + fixup_font_eastasia(cr, "黑体") + + # Create new table + nrows = len(src_tbl.rows) + ncols = len(src_tbl.columns) + new_tbl = doc.add_table(rows=nrows, cols=ncols) + + # Copy cell contents + for ri, src_row in enumerate(src_tbl.rows): + for ci, src_cell in enumerate(src_row.cells): + new_cell = new_tbl.rows[ri].cells[ci] + # Clear default paragraph + new_cell.paragraphs[0].clear() + # Copy paragraphs from source + for sp in src_cell.paragraphs: + np = new_cell.add_paragraph(sp.text) if sp.text.strip() else new_cell.paragraphs[0] + if not sp.text.strip() and new_cell.paragraphs[0].text == "": + continue + for sr in sp.runs: + nr = np.add_run(sr.text) if np != new_cell.paragraphs[0] else new_cell.paragraphs[0].add_run(sr.text) + if ri == 0: + nr.font.bold = True + + format_table(new_tbl) + return new_tbl + + +def main(): + import os + if not os.path.exists(SRC): + print(f"❌ 输入文件不存在: {SRC}") + print(" 请手动指定 SRC 路径后重试(本脚本为历史遗留脚本,依赖青核项目目录)") + return + print("Reading source document...") + src = Document(SRC) + print(f" {len(src.paragraphs)} paragraphs, {len(src.tables)} tables") + + print("Creating new document...") + doc = Document() + setup_page(doc) + setup_styles(doc) + # 原文档已有一、二、三...手工编号,不叠自动编号以避免冲突 + # setup_numbering(doc) + + print("Adding cover page...") + add_cover_page(doc) + + print("Adding TOC...") + add_toc(doc) + + print("Copying content...") + heading_counter = [0, 0, 0] # [H1, H2, H3] + + # Walk through body elements in original order + body = src.element.body + para_idx = 0 + tbl_idx = 0 + + for child in body: + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if tag == "p": + # Paragraph + if para_idx < len(src.paragraphs): + copy_paragraph(doc, src.paragraphs[para_idx], heading_counter) + para_idx += 1 + elif tag == "tbl": + # Table + if tbl_idx < len(src.tables): + copy_table(doc, src.tables[tbl_idx], tbl_idx) + tbl_idx += 1 + + print(f" Processed {para_idx} paragraphs, {tbl_idx} tables") + + print("Adding header/footer...") + add_header_footer(doc) + + print(f"Saving to {OUT}...") + doc.save(OUT) + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/scripts/md_to_word_converter.py b/scripts/md_to_word_converter.py new file mode 100644 index 0000000..480146a --- /dev/null +++ b/scripts/md_to_word_converter.py @@ -0,0 +1,51 @@ +import pypandoc +import os + +def convert_md_to_word(input_path, output_path): + """ + 将Markdown文件转换为Word文档 + + Args: + input_path (str): 输入的Markdown文件路径 + output_path (str): 输出的Word文档路径 + """ + try: + # 检查输入文件是否存在 + if not os.path.exists(input_path): + print(f"错误: 输入文件 '{input_path}' 不存在") + return False + + # 检查并获取pandoc路径 + try: + pandoc_path = pypandoc.get_pandoc_path() + print(f"已找到 pandoc: {pandoc_path}") + except OSError: + print("未检测到 pandoc,正在尝试下载...") + pypandoc.download_pandoc() + pandoc_path = pypandoc.get_pandoc_path() + print(f"pandoc 下载完成: {pandoc_path}") + + # 执行转换 + print(f"正在转换 {input_path} 到 {output_path}...") + pypandoc.convert_file(input_path, 'docx', outputfile=output_path) + print(f"\033[92m转换成功!输出文件: {output_path}\033[0m") + return True + + except Exception as e: + print(f"转换失败: {str(e)}") + return False + +# 使用示例 +if __name__ == "__main__": + # 在这里设置你的输入和输出文件路径 + # 注意:使用原始字符串(r前缀)或双反斜杠来避免转义问题 + input_file = r"D:\Octopus\Documents\看山.md" # 输入的Markdown文件 + output_file = r"D:\Octopus\Documents\看山文档.docx" # 输出的Word文件 + + # 执行转换 + success = convert_md_to_word(input_file, output_file) + + if success: + print("文档转换完成!") + else: + print("文档转换失败!") \ No newline at end of file diff --git a/scripts/verify_blackbox.py b/scripts/verify_blackbox.py new file mode 100644 index 0000000..533b2e6 --- /dev/null +++ b/scripts/verify_blackbox.py @@ -0,0 +1,269 @@ +import requests +import json +import time +import threading +import pymysql +import redis +import os + +BASE = "http://127.0.0.1:8080/api/v1" +HEADERS = {"Content-Type": "application/json"} +DB = { + "host": os.environ.get("DB_HOST", "127.0.0.1"), + "port": int(os.environ.get("DB_PORT", "3307")), + "user": os.environ.get("DB_USERNAME", "mydreamjob"), + "password": os.environ["DB_PASSWORD"], + "database": os.environ.get("DB_NAME", "mydreamjob") +} + +def db_conn(): + return pymysql.connect(**DB, autocommit=True) + +def get_code(phone, purpose="login"): + conn = db_conn() + with conn.cursor() as c: + c.execute("SELECT code FROM sms_verification_codes WHERE phone=%s AND purpose=%s AND used=0 ORDER BY id DESC LIMIT 1", + (phone, purpose)) + row = c.fetchone() + conn.close() + return row[0] if row else None + +def auth_header(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + +def login(phone, role): + r = redis.Redis(host='127.0.0.1', port=6379) + r.delete(f'sms:rate:{phone}') + r.delete(f'sms:lockout:{phone}') + requests.post(f"{BASE}/auth/sms/send", params={"phone": phone, "purpose": "login"}, headers=HEADERS) + time.sleep(2) + code = get_code(phone) + if not code: raise Exception(f"No code for {phone}") + r = requests.post(f"{BASE}/auth/login", json={"phone": phone, "code": code, "purpose": "login"}, headers=HEADERS) + if r.status_code != 200: raise Exception(f"Login failed: {r.status_code} {r.text}") + return r.json()["data"]["accessToken"] + +results = [] + +def test(name, method, url, headers, expected_status, body=None, check_fn=None): + full_url = f"{BASE}{url}" if not url.startswith("http") else url + if body: + r = requests.request(method, full_url, json=body, headers=headers) + else: + r = requests.request(method, full_url, headers=headers) + status = r.status_code + passed = status == expected_status + detail = "" + try: + resp = r.json() + if check_fn: passed = passed and check_fn(resp) + detail = resp.get("message", "") or str(resp)[:200] + except: + detail = r.text[:200] + results.append((name, method, url, status, expected_status, "PASS" if passed else "FAIL", detail)) + if not passed: + print(f" FAIL: {name} - expected {expected_status}, got {status}, detail={detail}") + return r + +print("=== Login ===") +company_token = login("13886593964", "COMPANY") +print(f" Company token: {company_token[:20]}...") +admin_token = login("19986593964", "ADMIN") +print(f" Admin token: {admin_token[:20]}...") +other_company_token = login("15186593964", "COMPANY") +print(f" Other company token: {other_company_token[:20]}...") +student_token = login("13986593964", "STUDENT") +print(f" Student token: {student_token[:20]}...") + +# 1. Create company profile +print("\n=== Test 1: Create company profile ===") +company_body = {"name": "黑盒测试企业", "description": "test", "industry": "IT", "address": "北京"} +r = requests.get(f"{BASE}/companies/me", headers=auth_header(company_token)) +if r.status_code == 200: + body = r.json() + if body.get("data") is not None: + test("1. Company exists", "GET", "/companies/me", auth_header(company_token), 200, None, + lambda j: j.get("data", {}).get("id") is not None) + else: + test("1. Create company", "POST", "/companies/me", auth_header(company_token), 200, company_body, + lambda j: j.get("data", {}).get("id") is not None) +elif r.status_code == 404: + test("1. Create company", "POST", "/companies/me", auth_header(company_token), 200, company_body, + lambda j: j.get("data", {}).get("id") is not None) +else: + test("1. Create company failed", "GET", "/companies/me", auth_header(company_token), r.status_code, None) + +# 2. Submit verification +print("\n=== Test 2: Submit verification ===") +r = test("2. Submit verification", "POST", f"{BASE}/companies/me/verification", + auth_header(company_token), 200, + {"materialsJson": "{\"license\":\"http://example.com/license.jpg\"}"}, + lambda j: j.get("data", {}).get("status") == "PENDING") +verification_id = r.json().get("data", {}).get("id") +print(f" Verification ID: {verification_id}") + +# 3. Query own verification +print("\n=== Test 3: Query own verification ===") +test("3. Query own verification", "GET", f"{BASE}/companies/me/verification", + auth_header(company_token), 200, None, + lambda j: j.get("data", {}).get("status") == "PENDING") + +# 4. Non-admin accesses admin list -> 403 +print("\n=== Test 4: Non-admin -> 403 ===") +test("4. Company access admin list", "GET", f"{BASE}/admin/companies/verifications", + auth_header(company_token), 403) +test("4b. Student access admin list", "GET", f"{BASE}/admin/companies/verifications", + auth_header(student_token), 403) + +# 5. Admin query pending list +print("\n=== Test 5: Admin query pending list ===") +test("5. Admin list pending", "GET", f"{BASE}/admin/companies/verifications?status=PENDING", + auth_header(admin_token), 200, + None, lambda j: j.get("data", {}).get("total", 0) > 0) + +# 6. Admin query detail +print("\n=== Test 6: Admin query detail ===") +test("6. Admin query detail", "GET", f"{BASE}/admin/companies/verifications/{verification_id}", + auth_header(admin_token), 200, + None, lambda j: j.get("data", {}).get("status") == "PENDING") + +# 7. REJECT without reason -> 400 +print("\n=== Test 7: REJECT without reason ===") +test("7. REJECT without reason", "POST", f"{BASE}/admin/companies/verifications/{verification_id}/decision", + auth_header(admin_token), 400, {"decision": "REJECTED"}) + +# 8. Admin REJECT -> 200 +print("\n=== Test 8: Admin REJECT ===") +test("8. Admin REJECT", "POST", f"{BASE}/admin/companies/verifications/{verification_id}/decision", + auth_header(admin_token), 200, + {"decision": "REJECTED", "reason": "营业执照不清晰,请重新上传"}, + lambda j: j.get("data", {}).get("status") == "REJECTED") + +# 9. Company sees REJECTED +print("\n=== Test 9: Company sees REJECTED ===") +test("9. Company sees REJECTED", "GET", f"{BASE}/companies/me/verification", + auth_header(company_token), 200, + None, lambda j: j.get("data", {}).get("status") == "REJECTED") + +# 10. Company resubmit -> RESUBMITTED +print("\n=== Test 10: Resubmit ===") +r = test("10. Resubmit", "POST", f"{BASE}/companies/me/verification", + auth_header(company_token), 200, + {"materialsJson": "{\"license\":\"http://example.com/license_v2.jpg\"}"}, + lambda j: j.get("data", {}).get("status") == "RESUBMITTED") +verification_id2 = r.json().get("data", {}).get("id") +print(f" New Verification ID: {verification_id2}") + +# 11. Admin APPROVE -> 200 +print("\n=== Test 11: Admin APPROVE ===") +test("11. Admin APPROVE", "POST", f"{BASE}/admin/companies/verifications/{verification_id2}/decision", + auth_header(admin_token), 200, + {"decision": "APPROVED", "reason": "材料齐全,通过"}, + lambda j: j.get("data", {}).get("status") == "APPROVED") + +# 12. Double APPROVE -> 409 +print("\n=== Test 12: Double APPROVE -> 409 ===") +test("12. Double APPROVE", "POST", f"{BASE}/admin/companies/verifications/{verification_id2}/decision", + auth_header(admin_token), 409, {"decision": "APPROVED"}) + +# 13. Approved company resubmit -> 409 +print("\n=== Test 13: Approved resubmit -> 409 ===") +test("13. Approved resubmit", "POST", f"{BASE}/companies/me/verification", + auth_header(company_token), 409, {"materialsJson": "{}"}) + +# 14. Other company views detail -> 403 +print("\n=== Test 14: Other company -> 403 ===") +test("14. Other company views detail", "GET", f"{BASE}/admin/companies/verifications/{verification_id2}", + auth_header(other_company_token), 403) + +# 15. Query history +print("\n=== Test 15: Query history ===") +test("15. Query history", "GET", f"{BASE}/admin/companies/verifications/history?verificationId={verification_id2}", + auth_header(admin_token), 200, + None, lambda j: len(j.get("data", [])) >= 2) + +# 16. Verify companies.verified updated +print("\n=== Test 16: companies.verified ===") +conn = db_conn() +with conn.cursor() as c: + c.execute("SELECT verified FROM companies WHERE name=%s", ("黑盒测试企业",)) + row = c.fetchone() +conn.close() +verified = row[0] if row else 0 +print(f" verified={verified}") +results.append(("16. companies.verified", "DB", "", 0, 0, "PASS" if verified == 1 else "FAIL", f"verified={verified}")) + +# 17. Verify notification sent +print("\n=== Test 17: Notification ===") +conn = db_conn() +with conn.cursor() as c: + c.execute("SELECT COUNT(*) FROM notifications WHERE user_id=(SELECT id FROM users WHERE phone=%s) AND type LIKE %s", + ("13886593964", "VERIFICATION_%")) + count = c.fetchone()[0] +conn.close() +print(f" Notification count: {count}") +results.append(("17. Notification", "DB", "", 0, 0, "PASS" if count >= 2 else "FAIL", f"count={count}")) + +# 18. Concurrent verification +print("\n=== Test 18: Concurrent ===") +# Ensure other company has a company profile and no pending verifications +conc_body = {"name": "并发测试企业", "description": "test", "industry": "IT", "address": "上海"} +r_other_get = requests.get(f"{BASE}/companies/me", headers=auth_header(other_company_token)) +company_id = None +if r_other_get.status_code == 200 and r_other_get.json().get("data") is not None: + company_id = r_other_get.json()["data"]["id"] +elif r_other_get.status_code == 404 or (r_other_get.status_code == 200 and r_other_get.json().get("data") is None): + r_create = requests.post(f"{BASE}/companies/me", json=conc_body, headers=auth_header(other_company_token)) + if r_create.status_code == 200: + company_id = r_create.json()["data"]["id"] + elif r_create.status_code == 409: + # company exists, get it + r_get = requests.get(f"{BASE}/companies/me", headers=auth_header(other_company_token)) + if r_get.status_code == 200 and r_get.json().get("data"): + company_id = r_get.json()["data"]["id"] + +if company_id is None: + results.append(("18. Concurrent", "DB", "", 0, 0, "FAIL", "no company for other user")) +else: + # Clean up any existing pending verifications for this company + conn = db_conn() + with conn.cursor() as c: + c.execute("DELETE FROM company_verification_history WHERE verification_id IN (SELECT id FROM company_verifications WHERE company_id=%s)", (company_id,)) + c.execute("DELETE FROM company_verifications WHERE company_id=%s", (company_id,)) + conn.close() + + r_sub = requests.post(f"{BASE}/companies/me/verification", + json={"materialsJson": "{}"}, headers=auth_header(other_company_token)) + if r_sub.status_code == 200: + cid = r_sub.json().get("data", {}).get("id") + if cid: + def approve(): + requests.post(f"{BASE}/admin/companies/verifications/{cid}/decision", + {"decision": "APPROVED", "reason": "test"}, headers=auth_header(admin_token)) + t1 = threading.Thread(target=approve) + t2 = threading.Thread(target=approve) + t1.start(); t2.start(); t1.join(); t2.join() + # Verify only one history entry for APPROVED + conn = db_conn() + with conn.cursor() as c: + c.execute("SELECT COUNT(*) FROM company_verification_history WHERE verification_id=%s AND to_status='APPROVED'", (cid,)) + cnt = c.fetchone()[0] + conn.close() + print(f" Concurrent APPROVED history count: {cnt}") + results.append(("18. Concurrent", "DB", "", 0, 0, "PASS" if cnt <= 1 else "FAIL", f"concurrent count={cnt}")) + else: + results.append(("18. Concurrent", "DB", "", 0, 0, "FAIL", "no verification id")) + else: + results.append(("18. Concurrent", "DB", "", 0, 0, "FAIL", f"submit failed: {r_sub.status_code} {r_sub.text[:200]}")) + +# Summary +print("\n" + "="*60) +print("RESULTS SUMMARY") +print("="*60) +passed = 0 +failed = 0 +for name, method, url, status, expected, result, detail in results: + print(f" [{result}] {name} | {method} {url} | {status}/{expected} {detail}") + if result == "PASS": passed += 1 + else: failed += 1 +print(f"\nTOTAL: {len(results)} tests, {passed} PASS, {failed} FAIL") diff --git a/scripts/启用_WSL2_前置功能.ps1 b/scripts/启用_WSL2_前置功能.ps1 new file mode 100644 index 0000000..a485eb1 --- /dev/null +++ b/scripts/启用_WSL2_前置功能.ps1 @@ -0,0 +1,15 @@ +#requires -RunAsAdministrator +$ErrorActionPreference = 'Stop' + +$features = @( + 'Microsoft-Windows-Subsystem-Linux', + 'VirtualMachinePlatform' +) + +foreach ($feature in $features) { + Enable-WindowsOptionalFeature -Online -FeatureName $feature -All -NoRestart | Out-Host +} + +Write-Host '' +Write-Host 'WSL2 前置 Windows 功能已启用。请先在 BIOS 中开启 Intel VT-x/Virtualization,然后重启电脑。' -ForegroundColor Green +Write-Host '重启后,继续执行:wsl --install -d Ubuntu-24.04' -ForegroundColor Yellow diff --git a/xlsx-analysis/read_workbook.mjs b/xlsx-analysis/read_workbook.mjs new file mode 100644 index 0000000..52a5461 --- /dev/null +++ b/xlsx-analysis/read_workbook.mjs @@ -0,0 +1,46 @@ +import { FileBlob, SpreadsheetFile } from '@oai/artifact-tool'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const inputPath = path.join(projectRoot, 'product-docs', '职来产品功能设计.xlsx'); +const input = await FileBlob.load(inputPath); +const workbook = await SpreadsheetFile.importXlsx(input); + +const summary = await workbook.inspect({ + kind: 'workbook,sheet,table', + maxChars: 12000, + tableMaxRows: 8, + tableMaxCols: 16, + tableMaxCellChars: 120, +}); +console.log('SUMMARY'); +console.log(summary.ndjson); + +const sheets = await workbook.inspect({ kind: 'sheet', include: 'id,name', maxChars: 4000 }); +console.log('SHEETS'); +console.log(sheets.ndjson); + +for (const s of workbook.worksheets.items) { + const used = s.getUsedRange(); + if (!used) continue; + console.log(`SHEET_RANGE ${s.name} ${used.address}`); + const region = await workbook.inspect({ + kind: 'region', + sheetId: s.name, + range: used.address, + maxChars: 30000, + tableMaxRows: 250, + tableMaxCols: 40, + tableMaxCellChars: 200, + }); + console.log(region.ndjson); +} + +const detail = workbook.worksheets.getItem('功能详细清单').getRange('A1:I96').values; +const rows = detail.slice(1).filter(r => r.some(v => v !== null && v !== '')); +const count = (keyFn) => rows.reduce((m, r) => { const k = keyFn(r); m[k] = (m[k] || 0) + 1; return m; }, {}); +console.log('COUNTS_PRIORITY', JSON.stringify(count(r => r[7]))); +console.log('COUNTS_PHASE', JSON.stringify(count(r => r[8]))); +console.log('COUNTS端模块', JSON.stringify(count(r => `${r[1]}|${r[2]}|${r[3]}`))); +console.log('一期_P0', JSON.stringify(rows.filter(r => r[7] === 'P0' && r[8] === 'Y').map(r => ({id:r[0],端:r[1],tab:r[2],module:r[3],name:r[4]}))));