spring boot JPA 关系映射-----20
独立完整项目,聚焦「一对一用户档案、多对多岗位收藏、中间实体岗位投递」三大核心场景,严格遵循生产级最佳实践,复制即可启动运行。
一、项目整体结构
plaintext
com.example.jobapp
├── JobappApplication.java # 启动类
├── entity
│ ├── SysUser.java # 用户表
│ ├── UserProfile.java # 用户档案(一对一)
│ ├── Job.java # 岗位表
│ └── JobApplication.java # 投递记录(中间实体)
├── repository
│ ├── SysUserRepository.java
│ ├── UserProfileRepository.java
│ ├── JobRepository.java
│ └── JobApplicationRepository.java
├── service
│ └── JobAppService.java # 业务逻辑封装
└── controller
└── JobAppController.java # REST接口
二、pom.xml 核心依赖
xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> </parent> <groupId>com.example</groupId> <artifactId>jpa-relation-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> </project>
三、实体类(核心映射关系)
1. 用户主表 SysUser.java
对应 194、198、199 集:一对一档案 + 多对多收藏岗位
java
运行
package com.example.jobapp.entity; import jakarta.persistence.*; import lombok.Data; import java.util.HashSet; import java.util.Set; @Data @Entity @Table(name = "sys_user") public class SysUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; private String email; /** * 一对一:用户 ↔ 用户档案 * mappedBy = "user":外键在档案表维护,用户表不存外键 * 级联保存/更新:操作用户时同步操作档案 */ @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private UserProfile profile; /** * 多对多:用户 ↔ 收藏的岗位 * @JoinTable:指定中间表名和两个外键列 * 用 Set 避免重复收藏;不设置级联删除,删用户不能删岗位 * 对应 198、199 集 @ManyToMany 配置 */ @ManyToMany @JoinTable( name = "user_saved_jobs", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "job_id") ) private Set<Job> savedJobs = new HashSet<>(); }
2. 用户档案表 UserProfile.java
对应 194 集:一对一关系的拥有方,存储简历、头像等扩展信息
java
运行
package com.example.jobapp.entity; import jakarta.persistence.*; import lombok.Data; @Data @Entity @Table(name = "user_profile") public class UserProfile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String realName; // 真实姓名 private String phone; // 手机号 private String resumeUrl; // 简历文件地址 private String avatarUrl; // 头像地址 /** * 一对一:档案属于一个用户 * @JoinColumn:外键列 user_id 存在这张表 */ @OneToOne @JoinColumn(name = "user_id", unique = true) private SysUser user; }
3. 岗位表 Job.java
java
运行
package com.example.jobapp.entity; import jakarta.persistence.*; import lombok.Data; @Data @Entity @Table(name = "job") public class Job { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String jobTitle; private String company; private Integer salary; private String location; }
4. 岗位投递记录 JobApplication.java
对应 201 集:有额外业务字段时,绝对不要用 @ManyToMany,改用独立中间实体
java
运行
package com.example.jobapp.entity; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime; @Data @Entity @Table(name = "job_application") public class JobApplication { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** * 多个投递记录 → 属于一个用户 */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private SysUser user; /** * 多个投递记录 → 对应一个岗位 */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "job_id") private Job job; // ===== 额外业务字段:这就是不用 @ManyToMany 的原因 ===== private String status; // 投递状态:APPLIED-已投递 REVIEWED-已查看 OFFER-已录用 REJECTED-已拒绝 private LocalDateTime applyTime; // 投递时间 private String resumeVersion; // 投递时使用的简历版本 }
四、Repository 层
java
运行
// SysUserRepository.java package com.example.jobapp.repository; import com.example.jobapp.entity.SysUser; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface SysUserRepository extends JpaRepository<SysUser, Long> { // 查询用户+收藏的岗位,解决N+1 @EntityGraph(attributePaths = "savedJobs") Optional<SysUser> findWithSavedJobsById(Long id); }
java
运行
// UserProfileRepository.java package com.example.jobapp.repository; import com.example.jobapp.entity.UserProfile; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface UserProfileRepository extends JpaRepository<UserProfile, Long> { }
java
运行
// JobRepository.java package com.example.jobapp.repository; import com.example.jobapp.entity.Job; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface JobRepository extends JpaRepository<Job, Long> { }
java
运行
// JobApplicationRepository.java package com.example.jobapp.repository; import com.example.jobapp.entity.JobApplication; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @Repository public interface JobApplicationRepository extends JpaRepository<JobApplication, Long> { // 查询用户的所有投递记录 List<JobApplication> findByUserId(Long userId); // 查询用户是否投递过某岗位 Optional<JobApplication> findByUserIdAndJobId(Long userId, Long jobId); }
五、Service 业务层
对应 195、200、202 集:档案管理、收藏功能、投递流程
java
运行
package com.example.jobapp.service; import com.example.jobapp.entity.Job; import com.example.jobapp.entity.JobApplication; import com.example.jobapp.entity.SysUser; import com.example.jobapp.entity.UserProfile; import com.example.jobapp.repository.JobApplicationRepository; import com.example.jobapp.repository.JobRepository; import com.example.jobapp.repository.SysUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; import java.util.Set; @Service @RequiredArgsConstructor public class JobAppService { private final SysUserRepository userRepository; private final JobRepository jobRepository; private final JobApplicationRepository applicationRepository; // ========== 1. 用户档案管理(对应194、195集) ========== @Transactional(readOnly = true) public UserProfile getUserProfile(Long userId) { SysUser user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("用户不存在")); return user.getProfile(); } @Transactional(rollbackFor = Exception.class) public UserProfile saveOrUpdateProfile(Long userId, UserProfile profile) { SysUser user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("用户不存在")); // 双向绑定 profile.setUser(user); user.setProfile(profile); // 级联保存,只存用户即可自动保存档案 SysUser saved = userRepository.save(user); return saved.getProfile(); } // ========== 2. 岗位收藏功能(对应200集) ========== /** * 收藏/取消收藏岗位 * 已收藏则取消,未收藏则添加 */ @Transactional(rollbackFor = Exception.class) public String toggleSaveJob(Long userId, Long jobId) { SysUser user = userRepository.findWithSavedJobsById(userId) .orElseThrow(() -> new RuntimeException("用户不存在")); Job job = jobRepository.findById(jobId) .orElseThrow(() -> new RuntimeException("岗位不存在")); Set<Job> savedJobs = user.getSavedJobs(); if (savedJobs.contains(job)) { savedJobs.remove(job); return "已取消收藏"; } else { savedJobs.add(job); return "收藏成功"; } } /** * 查询我的收藏列表 */ @Transactional(readOnly = true) public Set<Job> getMySavedJobs(Long userId) { SysUser user = userRepository.findWithSavedJobsById(userId) .orElseThrow(() -> new RuntimeException("用户不存在")); return user.getSavedJobs(); } // ========== 3. 岗位投递流程(对应202、203集) ========== /** * 投递岗位 */ @Transactional(rollbackFor = Exception.class) public String applyJob(Long userId, Long jobId, String resumeVersion) { // 校验是否已投递 if (applicationRepository.findByUserIdAndJobId(userId, jobId).isPresent()) { return "请勿重复投递"; } SysUser user = userRepository.getReferenceById(userId); Job job = jobRepository.getReferenceById(jobId); JobApplication application = new JobApplication(); application.setUser(user); application.setJob(job); application.setStatus("APPLIED"); application.setApplyTime(LocalDateTime.now()); application.setResumeVersion(resumeVersion); applicationRepository.save(application); return "投递成功"; } /** * 撤回投递 */ @Transactional(rollbackFor = Exception.class) public String withdrawApplication(Long applyId) { JobApplication app = applicationRepository.findById(applyId) .orElseThrow(() -> new RuntimeException("投递记录不存在")); if (!"APPLIED".equals(app.getStatus())) { return "当前状态不可撤回"; } applicationRepository.delete(app); return "已撤回投递"; } /** * 查询我的投递记录 */ @Transactional(readOnly = true) public List<JobApplication> getMyApplications(Long userId) { return applicationRepository.findByUserId(userId); } }
六、Controller 接口层
对应 196、197、203 集:完整 REST 接口
java
运行
package com.example.jobapp.controller; import com.example.jobapp.entity.Job; import com.example.jobapp.entity.JobApplication; import com.example.jobapp.entity.UserProfile; import com.example.jobapp.service.JobAppService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Set; @RestController @RequestMapping("/api") @RequiredArgsConstructor public class JobAppController { private final JobAppService jobAppService; // ===== 用户档案接口 ===== @GetMapping("/profile/{userId}") public UserProfile getProfile(@PathVariable Long userId) { return jobAppService.getUserProfile(userId); } @PostMapping("/profile/{userId}") public UserProfile saveProfile(@PathVariable Long userId, @RequestBody UserProfile profile) { return jobAppService.saveOrUpdateProfile(userId, profile); } // ===== 岗位收藏接口 ===== @PostMapping("/save-job/{userId}/{jobId}") public String toggleSaveJob(@PathVariable Long userId, @PathVariable Long jobId) { return jobAppService.toggleSaveJob(userId, jobId); } @GetMapping("/save-job/{userId}") public Set<Job> getSavedJobs(@PathVariable Long userId) { return jobAppService.getMySavedJobs(userId); } // ===== 岗位投递接口 ===== @PostMapping("/apply/{userId}/{jobId}") public String applyJob(@PathVariable Long userId, @PathVariable Long jobId, @RequestParam String resumeVersion) { return jobAppService.applyJob(userId, jobId, resumeVersion); } @DeleteMapping("/apply/{applyId}") public String withdraw(@PathVariable Long applyId) { return jobAppService.withdrawApplication(applyId); } @GetMapping("/apply/{userId}") public List<JobApplication> getApplications(@PathVariable Long userId) { return jobAppService.getMyApplications(userId); } }
七、启动类 + 配置
java
运行
package com.example.jobapp; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class JobappApplication { public static void main(String[] args) { SpringApplication.run(JobappApplication.class, args); } } application.properties
properties
spring.datasource.url=jdbc:mysql://localhost:3306/jobapp?serverTimezone=Asia/Shanghai&useSSL=false spring.datasource.username=root spring.datasource.password=root spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true
八、核心最佳实践(对应 201 集)
什么时候用 @ManyToMany?
✅ 只有纯关联,没有任何额外字段时使用,比如「用户收藏岗位」,只有用户和岗位的绑定关系,没有其他属性。
什么时候绝对不能用 @ManyToMany?
❌ 中间表有业务字段时,必须拆成独立实体,比如「岗位投递」有投递时间、投递状态、简历版本等字段,用独立实体扩展性更强。
其他通用原则
- 多对多用
Set不用List,避免重复数据、提升性能 - 关联查询用
@EntityGraph解决 N+1 问题 - 多对多不要设置 CascadeType.ALL,删除用户不能把岗位也删了
- 一对一外键放在从属表,主表用
mappedBy维护关系 - 懒加载作为默认策略,需要关联数据时再主动抓取
九、课程对应表
表格
| 集数 | 核心内容 | 对应代码 |
|---|---|---|
| 194 | 用户档案库表与实体设计 | SysUser + UserProfile 一对一映射 |
| 195 | 档案、简历、图片上传接口 | saveOrUpdateProfile 方法 |
| 196 | 档案详情查询接口 | getUserProfile 方法 |
| 197 | 档案管理完整演示 | 档案全套接口 |
| 198 | @ManyToMany 原理讲解 | SysUser 中 savedJobs 多对多设计 |
| 199 | @ManyToMany 实体配置 | @JoinTable 中间表配置 |
| 200 | 收藏 / 取消收藏接口 | toggleSaveJob 方法 |
| 201 | 什么时候不用 @ManyToMany | JobApplication 独立中间实体 |
| 202 | 岗位投递工作流实现 | applyJob、withdrawApplication 方法 |
| 203 | 投递与撤回接口演示 | 投递全套 REST 接口 |

浙公网安备 33010602011771号