Spring JPA关联保存时子实体Movie ID被覆盖为User ID问题排查
问题背景
开发Spring Boot CRUD应用时,wUser(父实体)与Movie(子实体)配置OneToMany关联,需求是通过@PostMapping接收用户ID和电影名,调用OMDB API获取电影数据并关联到对应用户。调试发现:
- Movie初始生成正确的自增ID
- 执行
repository.save(user)后,Movie的ID被覆盖为User的ID(如用户ID为2时,Movie ID也变为2) - 后续添加电影触发H2数据库的主键冲突、
MOVIE_ID不能为空等错误,无法为用户添加多部电影
错误信息(翻译后)
错误1:字段不能为空
org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: 字段"MOVIE_ID"不能为NULL
NULL not allowed for column "MOVIE_ID"; SQL statement:
update movie set movie_id=null where movie_id=? [23502-214]
错误2:主键冲突
org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: 违反主键或唯一索引约束:"PUBLIC.MOVIE(MOVIE_ID)上的主键"
( /* key:2 / 2, 'David Fincher', 'Drama', '一个失眠的上班族和一个随心所欲的肥皂制造商成立了地下搏击俱乐部,后来事态逐渐升级。', 'https://m.media-amazon.com/images/M/MV5BNDIzNDU0YzEtYzE5Ni00ZjlkLTk5ZjgtNjM3NWE4YzA3Nzk3XkEyXkFqcGdeQXVyMjUzOTY1NTc@._V1_SX300.jpg', '搏击俱乐部')"
唯一索引或主键冲突:"PRIMARY KEY ON PUBLIC.MOVIE(MOVIE_ID) ( / key:2 */ 2, 'David Fincher', 'Drama', '一个失眠的上班族和一个随心所欲的肥皂制造商成立了地下搏击俱乐部,后来事态逐渐升级。', 'https://m.media-amazon.com/images/M/MV5BNDIzNDU0YzEtYzE5Ni00ZjlkLTk5ZjgtNjM3NWE4YzA3Nzk3XkEyXkFqcGdeQXVyMjUzOTY1NTc@._V1_SX300.jpg', '搏击俱乐部')"; SQL statement:
insert into movie (director, genre, plot, posterurl, title, movie_id) values (?, ?, ?, ?, ?, ?) [23505-214]
问题根源
- 关联配置完全错误:
wUser的@JoinColumn(name = "movie_id")把子实体的主键字段当成了外键,导致JPA试图将Movie的ID更新为User的ID,彻底搞反了关联关系。正常OneToMany关联的外键应该在子表(Movie)中,存储父表(wUser)的ID。 - 重复持久化操作:
MovieService中提前调用repository.save(movie),加上wUser的Cascade.ALL配置,导致JPA对同一个Movie执行多次持久化操作,引发状态冲突。 - Controller逻辑冗余:新建
moviesToAdd列表完全没必要,反而可能导致集合处理异常。
修复步骤及代码修改
1. 修正Movie实体(添加双向关联)
在Movie中添加@ManyToOne关联,明确与wUser的关系,同时指定外键字段:
package com.example.omdbapirest.movie; import com.example.omdbapirest.user.wUser; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Entity @Data @AllArgsConstructor @NoArgsConstructor public class Movie { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name="movie_id") private Integer id; private String title; private String plot; private String genre; private String director; private String posterURL; // 添加与wUser的多对一关联 @ManyToOne @JoinColumn(name = "user_id") // 外键字段,关联wUser的id private wUser user; public Movie(String title, String plot, String genre, String director, String posterURL) { this.title = title; this.plot = plot; this.genre = genre; this.director = director; this.posterURL = posterURL; } }
2. 修正wUser实体的关联配置
修改@JoinColumn的name为外键字段user_id,同时设置mappedBy指定双向关联的属性:
package com.example.omdbapirest.user; import com.example.omdbapirest.movie.Movie; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; @Data @Entity @NoArgsConstructor @AllArgsConstructor public class wUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; // 修正关联配置,外键为Movie表的user_id @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<Movie> favouriteMovies; public wUser(String name) { this.name = name; } }
注:使用
mappedBy后,关联关系由Movie端维护;如果要保持单向关联,也可以改为@OneToMany(cascade = CascadeType.ALL) @JoinColumn(name = "user_id"),但双向关联更利于后续数据操作。
3. 简化MovieService的持久化操作
移除getDataFromOMDBAsMovie中提前保存Movie的代码,由Cascade操作自动处理:
package com.example.omdbapirest.movie; import lombok.RequiredArgsConstructor; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.springframework.stereotype.Service; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; @Service @RequiredArgsConstructor public class MovieService { private final MovieRepository repository; public String getJSONFromURL(String strUrl) { String jsonText = ""; try { URL url = new URL(strUrl); InputStream is = url.openStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is)); String line; while ((line = bufferedReader.readLine()) != null) { jsonText += line + "\n"; } is.close(); bufferedReader.close(); } catch (Exception e) { e.printStackTrace(); } return jsonText; } public Movie getDataFromOMDBAsMovie(String strURL) throws ParseException { String json = getJSONFromURL(strURL); Movie movie = new Movie(); JSONParser parser = new JSONParser(); Object object = parser.parse(json); JSONObject mainJsonObject = (JSONObject) object; movie.setTitle((String) mainJsonObject.get("Title")); movie.setPlot((String) mainJsonObject.get("Plot")); movie.setGenre((String) mainJsonObject.get("Genre")); movie.setDirector((String) mainJsonObject.get("Director")); movie.setPosterURL((String) mainJsonObject.get("Poster")); // 移除这里的repository.save(movie),由Cascade自动处理 return movie; } public Movie addMovie(Movie movie){ return repository.save(movie); } }
4. 简化UserController的逻辑
直接在用户的电影列表中添加新电影,关联用户与电影:
package com.example.omdbapirest.user; import com.example.omdbapirest.movie.Movie; import com.example.omdbapirest.movie.MovieService; import lombok.RequiredArgsConstructor; import org.json.simple.parser.ParseException; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final MovieService movieService; private final UserRepository repository; private final UserService service; @GetMapping public List<wUser> getUsers(){ return repository.findAll(); } @PostMapping("/{id}/{moviename}") public void addMovieAsFavorite (@PathVariable(name= "id") int id, @PathVariable(name="moviename") String moviename) throws ParseException{ String url = "https://www.omdbapi.com/?t="+moviename+"&apikey=30ccf40c"; wUser user = repository.getById(id); Movie movie = movieService.getDataFromOMDBAsMovie(url); // 关联电影与用户 movie.setUser(user); // 添加到用户收藏列表 user.getFavouriteMovies().add(movie); repository.save(user); } }
关键说明
- 双向关联中,必须手动设置Movie的
user属性,否则JPA无法维护外键关系 CascadeType.ALL会在保存/更新用户时自动处理关联的Movie,无需单独保存orphanRemoval = true(可选)会在电影从用户收藏列表中移除时自动删除数据库中的Movie记录
内容的提问来源于stack exchange,提问作者Bajter

