package com.movie.service.impl; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.SearchHitSupport; import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.SearchPage; import org.springframework.data.elasticsearch.core.query.Query; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.doc.MovieDoc; import com.movie.dto.MovieDTO; import com.movie.dto.SearchDTO; import com.movie.entity.Actor; import com.movie.entity.ActorInfo; import com.movie.entity.Director; import com.movie.entity.DirectorInfo; import com.movie.entity.Info; import com.movie.mapper.InfoMapper; import com.movie.repository.MovieRepository; import com.movie.service.IActorInfoService; import com.movie.service.IActorService; // 引入新的 Query 类 import com.movie.service.IDirectorInfoService; import com.movie.service.IDirectorService; import com.movie.service.IMovieService; import com.movie.vo.MovieDetailVO; import cn.hutool.core.bean.BeanUtil; /** *

* 电影信息表 服务实现类 *

* * @author Liu * @since 2025-12-25 */ @Service public class InfoServiceImpl extends ServiceImpl implements IMovieService { // 注入关联表服务 (注意名字:IActorService 对应 movie_actor 表) @Autowired private IActorService actorRelationService; // 注入关联表服务 (注意名字:IDirectorService 对应 movie_director 表) @Autowired private IDirectorService directorRelationService; //注入 ES Repository --- @Autowired private MovieRepository movieRepository; @Autowired private IActorInfoService actorInfoService; @Autowired private IDirectorInfoService directorInfoService; @Autowired private ElasticsearchOperations elasticsearchOperations; @Override @Transactional(rollbackFor = Exception.class) // 开启事务,任何一步报错都回滚 public void addMovie(MovieDTO dto) { // 1. 保存电影基本信息 (Info 表) Info movie = new Info(); BeanUtil.copyProperties(dto, movie); // 初始化统计数据 movie.setRating(BigDecimal.ZERO); movie.setReviewCount(0); // 如果数据库有默认值这行可以省,为了保险起见设为false movie.setIsDeleted(false); this.save(movie); // 保存到数据库,自动生成 ID Long movieId = movie.getId(); // 获取新生成的电影 ID // 2. 保存演员关联 (Actor 表: movie_actor) if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) { for (Long actorId : dto.getActorIds()) { Actor relation = new Actor(); // 这是关联对象 relation.setMovieId(movieId); relation.setActorId(actorId); actorRelationService.save(relation); } } // 3. 保存导演关联 (Director 表: movie_director) if (dto.getDirectorIds() != null && !dto.getDirectorIds().isEmpty()) { for (Long directorId : dto.getDirectorIds()) { Director relation = new Director(); // 这是关联对象 relation.setMovieId(movieId); relation.setDirectorId(directorId); directorRelationService.save(relation); } } // 【数据同步到 Elasticsearch】 // 4. 根据 ID 查出演员名和导演名 List actorNames = Collections.emptyList(); if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) { actorNames = actorInfoService.listByIds(dto.getActorIds()) .stream().map(ActorInfo::getName).collect(Collectors.toList()); } List directorNames = Collections.emptyList(); if (dto.getDirectorIds() != null && !dto.getDirectorIds().isEmpty()) { directorNames = directorInfoService.listByIds(dto.getDirectorIds()) .stream().map(DirectorInfo::getName).collect(Collectors.toList()); } // 5. 组装 MovieDoc 对象 MovieDoc doc = new MovieDoc(); BeanUtil.copyProperties(dto, doc); // 把 dto 的 title, genre 等信息拷过来 doc.setId(movieId); // 设置 ES 文档的 ID doc.setPosterUrl(dto.getPosterUrl()); // 显式赋值海报 doc.setActors(actorNames); doc.setDirectors(directorNames); if (dto.getReleaseDate() != null) doc.setReleaseDate(dto.getReleaseDate().toString()); doc.setRating(0.0); // 6. 保存到 ES movieRepository.save(doc); } @SuppressWarnings("unchecked") @Override public Page search(SearchDTO dto) { // 1. 构建分页请求 (这部分不变) PageRequest pageRequest = PageRequest.of(dto.getPage() - 1, dto.getSize()); // 2. 构建 ES 查询条件 (Spring Boot 3.x 全新写法) Query query = NativeQuery.builder() .withQuery(q -> q .multiMatch(mq -> mq .query(dto.getKeyword()) .fields("title", "originalTitle", "actors", "directors", "synopsis") ) ) .withPageable(pageRequest) .build(); // 3. 执行查询 (这部分不变) SearchHits searchHits = elasticsearchOperations.search(query, MovieDoc.class); // 4. 转换成分页对象 (这部分不变) SearchPage searchPage = SearchHitSupport.searchPageFor(searchHits, pageRequest); return (Page) SearchHitSupport.unwrapSearchHits(searchPage); } @Override @Transactional(rollbackFor = Exception.class) public void updateMovie(MovieDTO dto) { // 1. 更新 MySQL 主表 Info movie = new Info(); BeanUtil.copyProperties(dto, movie); // 如果 dto.id 为空,抛异常 if (movie.getId() == null) throw new RuntimeException("修改必须指定ID"); this.updateById(movie); // 2. 更新关联关系 (先删后加,这是处理多对多更新最稳妥的策略) // 2.1 删除旧的演员关联 QueryWrapper actorDeleteWrapper = new QueryWrapper<>(); actorDeleteWrapper.eq("movie_id", movie.getId()); actorRelationService.remove(actorDeleteWrapper); // 2.2 删除旧的导演关联 QueryWrapper directorDeleteWrapper = new QueryWrapper<>(); directorDeleteWrapper.eq("movie_id", movie.getId()); directorRelationService.remove(directorDeleteWrapper); // 2.3 插入新的演员关联 if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) { for (Long actorId : dto.getActorIds()) { Actor relation = new Actor(); relation.setMovieId(movie.getId()); relation.setActorId(actorId); actorRelationService.save(relation); } } // 2.4 插入新的导演关联 if (dto.getDirectorIds() != null && !dto.getDirectorIds().isEmpty()) { for (Long directorId : dto.getDirectorIds()) { Director relation = new Director(); relation.setMovieId(movie.getId()); relation.setDirectorId(directorId); directorRelationService.save(relation); } } // 3. 同步更新 Elasticsearch // 查出最新的名字 List actorNames = Collections.emptyList(); if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) { actorNames = actorInfoService.listByIds(dto.getActorIds()) .stream().map(ActorInfo::getName).collect(Collectors.toList()); } List directorNames = Collections.emptyList(); if (dto.getDirectorIds() != null && !dto.getDirectorIds().isEmpty()) { directorNames = directorInfoService.listByIds(dto.getDirectorIds()) .stream().map(DirectorInfo::getName).collect(Collectors.toList()); } MovieDoc doc = new MovieDoc(); BeanUtil.copyProperties(dto, doc); // 此时 dto 里有 id doc.setPosterUrl(dto.getPosterUrl()); doc.setActors(actorNames); doc.setDirectors(directorNames); if (dto.getReleaseDate() != null) doc.setReleaseDate(dto.getReleaseDate().toString()); // save 方法在 ES 里是 "Upsert" (有则更新,无则新增) movieRepository.save(doc); } @Override @Transactional(rollbackFor = Exception.class) public void removeMovie(Long id) { // 1. MySQL 逻辑删除 (因为加了 @TableLogic) this.removeById(id); // 2. ES 物理删除 (搜不到才是目的) movieRepository.deleteById(id); } @Override public MovieDetailVO getMovieDetail(Long id) { // 1. 查电影主表 Info movie = this.getById(id); if (movie == null) throw new RuntimeException("电影不存在"); MovieDetailVO vo = new MovieDetailVO(); BeanUtil.copyProperties(movie, vo); // 2. 查演员关联关系 (movie_actor) -> 拿到 actorId 列表 QueryWrapper actorQuery = new QueryWrapper<>(); actorQuery.eq("movie_id", id); List actorRelations = actorRelationService.list(actorQuery); if (!actorRelations.isEmpty()) { List actorIds = actorRelations.stream().map(Actor::getActorId).collect(Collectors.toList()); // 3. 查演员详情 (actor_info) List actorInfos = actorInfoService.listByIds(actorIds); vo.setActorList(actorInfos); } // 4. 查导演关联关系 (movie_director) -> 拿到 directorId 列表 QueryWrapper directorQuery = new QueryWrapper<>(); directorQuery.eq("movie_id", id); List directorRelations = directorRelationService.list(directorQuery); if (!directorRelations.isEmpty()) { List directorIds = directorRelations.stream().map(Director::getDirectorId).collect(Collectors.toList()); // 5. 查导演详情 (director_info) List directorInfos = directorInfoService.listByIds(directorIds); vo.setDirectorList(directorInfos); } return vo; } @Override public void syncEsData() { // 1. 先清空 ES 中的旧数据 (防止重复或脏数据) movieRepository.deleteAll(); // 2. 查出 MySQL 里所有的电影 List allMovies = this.list(); if (allMovies.isEmpty()) return; List docs = new ArrayList<>(); // 3. 遍历每一部电影,组装数据 for (Info movie : allMovies) { MovieDoc doc = new MovieDoc(); BeanUtil.copyProperties(movie, doc); doc.setPosterUrl(movie.getPosterUrl()); // 确保海报地址存入 if (movie.getRating() != null) { doc.setRating(movie.getRating().doubleValue()); // BigDecimal 转为 Double } if (movie.getReleaseDate() != null) { doc.setReleaseDate(movie.getReleaseDate().toString()); // LocalDate 转为 String } // --- 查演员名字 --- // 查中间表 QueryWrapper actorQuery = new QueryWrapper<>(); actorQuery.eq("movie_id", movie.getId()); List actorRelations = actorRelationService.list(actorQuery); List actorNames = new ArrayList<>(); if (!actorRelations.isEmpty()) { List actorIds = actorRelations.stream().map(Actor::getActorId).collect(Collectors.toList()); // 查信息表 List actors = actorInfoService.listByIds(actorIds); actorNames = actors.stream().map(ActorInfo::getName).collect(Collectors.toList()); } doc.setActors(actorNames); // --- 查导演名字 --- QueryWrapper directorQuery = new QueryWrapper<>(); directorQuery.eq("movie_id", movie.getId()); List directorRelations = directorRelationService.list(directorQuery); List directorNames = new ArrayList<>(); if (!directorRelations.isEmpty()) { List directorIds = directorRelations.stream().map(Director::getDirectorId).collect(Collectors.toList()); List directors = directorInfoService.listByIds(directorIds); directorNames = directors.stream().map(DirectorInfo::getName).collect(Collectors.toList()); } doc.setDirectors(directorNames); // 加入待保存列表 docs.add(doc); } // 4. 批量保存到 ES (性能比一条条存快得多) movieRepository.saveAll(docs); } } package com.movie.service.impl; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import com.movie.websocket.WebSocketServer; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; // 引入 RedisLockService import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.movie.config.RabbitMQConfig; import com.movie.dto.OrderDTO; import com.movie.dto.PayDTO; import com.movie.entity.CinemaHall; import com.movie.entity.CinemaInfo; import com.movie.entity.Info; import com.movie.entity.Order; import com.movie.entity.Schedule; import com.movie.entity.UserWallet; import com.movie.entity.WalletLog; import com.movie.mapper.OrderMapper; import com.movie.service.ICinemaHallService; import com.movie.service.ICinemaInfoService; import com.movie.service.IMovieService; import com.movie.service.IOrderService; import com.movie.service.IScheduleService; import com.movie.service.IUserWalletService; import com.movie.service.IWalletLogService; import com.movie.service.RedisLockService; import com.movie.vo.SeatInfoVO; // 引入 Set import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.BCrypt; @Service public class OrderServiceImpl extends ServiceImpl implements IOrderService { @Autowired private IScheduleService scheduleService; @Autowired private ICinemaHallService hallService; @Autowired private StringRedisTemplate redisTemplate; @Autowired private ObjectMapper objectMapper; @Autowired private RabbitTemplate rabbitTemplate; @Autowired private RedisLockService redisLockService; // 注入新的锁服务 @Autowired private IUserWalletService userWalletService; @Autowired private IWalletLogService walletLogService; @Autowired private IMovieService movieService; // 【新增】用于查电影名 @Autowired private ICinemaInfoService cinemaService; // --- 1. 获取座位图 (含坏座处理) --- @Override @SuppressWarnings("UseSpecificCatch") public SeatInfoVO getSeatInfo(Long scheduleId) throws Exception { // 1. 查询排片信息 Schedule schedule = scheduleService.getById(scheduleId); if (schedule == null) { throw new RuntimeException("该排片场次不存在或已下架"); } // 2. 【新增】查询关联的 电影、影院、影厅 信息 // 前端右侧卡片需要这些数据,否则会显示空白或默认值 Info movie = movieService.getById(schedule.getMovieId()); CinemaInfo cinema = cinemaService.getById(schedule.getCinemaId()); CinemaHall hall = hallService.getById(schedule.getHallId()); if (hall == null || movie == null || cinema == null) { throw new RuntimeException("排片关联信息缺失(影厅/电影/影院)"); } // 3. 解析影厅座位配置 (JSON -> Map) Map hallConfig = null; try { hallConfig = objectMapper.readValue( hall.getSeatConfig(), new TypeReference>() {} ); } catch (Exception e) { // 容错处理:如果解析失败,给个默认空对象,防止整个页面打不开 hallConfig = new HashMap<>(); hallConfig.put("rows", 0); hallConfig.put("cols", 0); } // 4. 安全提取坏座 (broken_seats) // 解决 unchecked 警告,先判断是否存在且类型是否正确 List brokenSeats = new ArrayList<>(); if (hallConfig.containsKey("broken_seats")) { Object brokenObj = hallConfig.get("broken_seats"); if (brokenObj instanceof List) { // 安全转换 List list = (List) brokenObj; for (Object item : list) { brokenSeats.add(item.toString()); } } } // 5. 查询【已售出】座位 (从数据库) // 逻辑:查询该场次下,状态为 "已支付(1)" 或 "已观影(4)" 的订单 // 如果你有 "待支付(0)" 但没存 Redis 的逻辑,也要加上 0 QueryWrapper soldQuery = new QueryWrapper<>(); soldQuery.eq("schedule_id", scheduleId) .in("status", 1, 4); // 假设 1=已支付, 4=已完成。具体看你的状态定义 // .ne("status", 2); // 或者直接查“不等于已取消”的所有订单 List soldOrders = this.list(soldQuery); // 将订单里的座位号字符串 "1-1,1-2" 拆分并收集 List finalSoldSeats = soldOrders.stream() .filter(o -> o.getSeatInfo() != null) .flatMap(order -> Arrays.stream(order.getSeatInfo().split(","))) .collect(Collectors.toList()); // 6. 查询【锁定中】座位 (从 Redis) // 这里的 Key 格式必须和 createOrder 存入 Redis 时保持一致 String lockKeyPattern = "lock:seat:" + scheduleId + ":*"; Set lockedKeys = redisTemplate.keys(lockKeyPattern); if (lockedKeys != null && !lockedKeys.isEmpty()) { List lockedSeats = lockedKeys.stream() // 假设 key 是 "lock:seat:101:5-6",截取最后的 "5-6" .map(key -> key.substring(key.lastIndexOf(":") + 1)) .collect(Collectors.toList()); finalSoldSeats.addAll(lockedSeats); } // 7. 组装 VO 返回 SeatInfoVO vo = new SeatInfoVO(); // --- 填充基础座位信息 --- vo.setHallConfig(hallConfig); vo.setBrokenSeats(brokenSeats); vo.setSoldSeats(finalSoldSeats.stream().distinct().collect(Collectors.toList())); // 去重 // --- 【关键】填充右侧详情信息 --- vo.setMovieTitle(movie.getTitle()); vo.setCinemaName(cinema.getName()); vo.setHallName(hall.getName()); vo.setPrice(schedule.getPrice()); vo.setStartTime(schedule.getStartTime()); return vo; } // --- 锁座并创建订单 --- @SuppressWarnings("unchecked") @Override @Transactional(rollbackFor = Exception.class) public String createOrder(OrderDTO dto, Long userId) { // --- 1. 数据清洗与格式验证 --- List rawSeats = dto.getSeats(); if (rawSeats == null || rawSeats.isEmpty()) { throw new RuntimeException("请选择座位"); } Set distinctSet = new HashSet<>(); for (String seatStr : rawSeats) { // 防御性编程:以逗号分割,防止前端传 ["5-6,5-7"] 这种怪异格式 String[] splitSeats = seatStr.split(","); for (String s : splitSeats) { // 去除空格 " 5-6 " -> "5-6" String cleanSeat = s.trim(); if (cleanSeat.isEmpty()) { continue; } if (distinctSet.contains(cleanSeat)) { throw new RuntimeException("订单中包含重复座位: " + cleanSeat); } distinctSet.add(cleanSeat); } } if (distinctSet.isEmpty()) { throw new RuntimeException("有效座位为空"); } // 转回 List,确保后续所有逻辑都使用这个清洗过的 finalSeats List finalSeats = new ArrayList<>(distinctSet); // --- 2. 尝试原子锁座 (使用 Lua 脚本) --- // 使用 finalSeats 生成 Key,这样就是标准的 lock:seat:4:5-6 了 boolean lockSuccess = redisLockService.tryLockSeats(dto.getScheduleId(), finalSeats, userId); if (!lockSuccess) { throw new RuntimeException("部分座位已被锁定,请重新选择"); } try { // 3. 校验排片是否存在 Schedule schedule = scheduleService.getById(dto.getScheduleId()); if (schedule == null) { throw new RuntimeException("排片不存在"); } // 如果 (开场时间 < 当前时间),说明已经开演了,不能卖票 if (schedule.getStartTime().isBefore(LocalDateTime.now())) { redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats); throw new RuntimeException("电影已开场,停止售票!"); } CinemaHall hall = hallService.getById(schedule.getHallId()); if (hall == null) { throw new RuntimeException("关联影厅不存在"); } // --- 检查是否选了坏座 --- Map config; try { config = objectMapper.readValue( hall.getSeatConfig(), new TypeReference>() {} ); } catch (JsonProcessingException e) { // 如果解析失败,说明数据库里的 JSON 格式不对 // 【关键步骤】必须释放刚才锁住的座位! redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats); throw new RuntimeException("影厅座位数据异常,无法下单"); } // 提取坏座列表 List brokenSeats = new ArrayList<>(); if (config.containsKey("broken_seats")) { brokenSeats = (List) config.get("broken_seats"); } int maxRow = (int) config.get("rows"); int maxCol = (int) config.get("cols"); for (String seat : finalSeats) { // 1. 检查是否是坏座 if (brokenSeats.contains(seat)) { throw new RuntimeException("座位 " + seat + " 是损坏座位,无法购买"); } // 2. 检查边界 (之前的逻辑) String[] parts = seat.split("-"); int row = Integer.parseInt(parts[0]); int col = Integer.parseInt(parts[1]); if (row < 1 || row > maxRow || col < 1 || col > maxCol) { throw new RuntimeException("座位 " + seat + " 不存在"); } } // 4. 双重检查数据库 (已支付的作为兜底) QueryWrapper checkQuery = new QueryWrapper<>(); checkQuery.eq("schedule_id", dto.getScheduleId()) .eq("status", 1) // 已支付 .in("seat_info", finalSeats); // MybatisPlus 会自动处理 List in ('5-6', '5-7') if (this.count(checkQuery) > 0) { throw new RuntimeException("部分座位已售出"); } // 5. 计算金额 (现在 finalSeats.size() 是真实的座位数了) BigDecimal price = schedule.getPrice(); BigDecimal count = new BigDecimal(finalSeats.size()); BigDecimal totalPrice = price.multiply(count); // 6. 创建订单 Order order = new Order(); String orderNo = IdUtil.getSnowflakeNextIdStr(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScheduleId(dto.getScheduleId()); // 存入数据库的是标准格式 "5-6,5-7" order.setSeatInfo(String.join(",", finalSeats)); order.setTotalPrice(totalPrice); order.setStatus(0); this.save(order); // 7. 发送延迟消息 rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_TTL_QUEUE, orderNo); return orderNo; } catch (RuntimeException e) { // 失败释放锁 redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats); throw e; // 继续抛出异常给Controller } } @Override @Transactional(rollbackFor = Exception.class) // 【核心】开启事务:只要下面任何一行报错,所有数据库操作自动回滚! public void payOrder(PayDTO dto, Long userId) { // 1. 查询并校验订单 QueryWrapper query = new QueryWrapper<>(); query.eq("order_no", dto.getOrderNo()).eq("user_id", userId); Order order = this.getOne(query); if (order == null) { throw new RuntimeException("订单不存在或不属于当前用户"); } if (order.getStatus() != 0) { // 0-待支付 throw new RuntimeException("订单状态异常,无法支付"); // 抛出异常 -> 触发回滚 } // 2. 查询用户钱包 QueryWrapper walletQuery = new QueryWrapper<>(); walletQuery.eq("user_id", userId); UserWallet wallet = userWalletService.getOne(walletQuery); if (wallet == null) { throw new RuntimeException("用户钱包不存在"); // 抛出异常 -> 触发回滚 } // 3. 【核心】校验是否设置了支付密码 if (wallet.getPayPassword() == null) { throw new RuntimeException("未设置支付密码,请先前往设置!"); } // 4. 【核心】校验支付密码是否正确 if (!BCrypt.checkpw(dto.getPayPassword(), wallet.getPayPassword())) { throw new RuntimeException("支付密码错误"); // 抛出异常 -> 触发回滚 } // 5. 【核心】余额校验 (Balance Check) // wallet.getBalance() < order.getTotalPrice() if (wallet.getBalance().compareTo(order.getTotalPrice()) < 0) { throw new RuntimeException("余额不足,请充值!"); // 抛出异常 -> 触发回滚 } // --- 到这里说明一切正常,开始执行扣款和更新 --- try { // 6. 扣减余额 BigDecimal newBalance = wallet.getBalance().subtract(order.getTotalPrice()); wallet.setBalance(newBalance); // 这里利用了 MyBatis-Plus 的乐观锁插件(如果配置了 @Version),可以防止并发扣款 boolean updateWalletSuccess = userWalletService.updateById(wallet); if (!updateWalletSuccess) { throw new RuntimeException("扣款失败,请重试"); // 并发冲突时回滚 } // 7. 修改订单状态为“已支付” order.setStatus(1); // 1-已支付 order.setPayTime(LocalDateTime.now()); boolean updateOrderSuccess = this.updateById(order); if (!updateOrderSuccess) { throw new RuntimeException("更新订单状态失败"); // 回滚钱包扣款 } // 8. 支付成功,释放 Redis 座位锁 // 因为座位已经永久属于用户了(存入数据库了),不再需要 Redis 的临时锁 Schedule schedule = scheduleService.getById(order.getScheduleId()); if (schedule != null) { redisLockService.releaseSeatLocks(schedule.getId(), List.of(order.getSeatInfo().split(","))); } // --- 新增:推送通知 --- WebSocketServer.sendInfo(userId.toString(), " 支付成功!祝您观影愉快!"); // 8. 记录支出流水 WalletLog walletLog = new WalletLog(); walletLog.setUserId(userId); walletLog.setAmount(order.getTotalPrice().negate()); walletLog.setType((byte) 2); // 2-购票 walletLog.setOrderNo(order.getOrderNo()); walletLog.setRemark("购买电影票: " + order.getSeatInfo()); walletLogService.save(walletLog); } catch (RuntimeException e) { // 捕获所有未知异常,手动抛出 RuntimeException 以确保事务回滚 throw new RuntimeException("支付过程中发生错误: " + e.getMessage()); } } @Override @Transactional(rollbackFor = Exception.class) // 【核心】开启事务:钱和状态必须同时成功 public void refundOrder(String orderNo, Long userId) { // 1. 查询并校验订单 QueryWrapper query = new QueryWrapper<>(); query.eq("order_no", orderNo).eq("user_id", userId); Order order = this.getOne(query); if (order == null) { throw new RuntimeException("订单不存在或不属于当前用户"); } // 只有 "已支付(1)" 的订单才能退款 // 待支付(0)的应该走取消流程,已取消(2)或已退款(3)的不能重复退 if (order.getStatus() != 1) { throw new RuntimeException("订单状态异常,无法退款(仅已支付订单可退)"); } // --- 新增:核心时间校验 --- Schedule schedule = scheduleService.getById(order.getScheduleId()); // 如果电影已经开始了,就不让退了 (通常规定开场前15分钟不能退,这里简单点,开场后不能退) if (schedule.getStartTime().isBefore(LocalDateTime.now())) { throw new RuntimeException("电影已开场,无法退票"); } // ------------------------- // 2. 查询用户钱包 QueryWrapper walletQuery = new QueryWrapper<>(); walletQuery.eq("user_id", userId); UserWallet wallet = userWalletService.getOne(walletQuery); if (wallet == null) { throw new RuntimeException("用户钱包不存在"); } try { // 3. 【核心】执行退款:余额加回去 // balance = balance + totalPrice BigDecimal refundAmount = order.getTotalPrice(); BigDecimal newBalance = wallet.getBalance().add(refundAmount); wallet.setBalance(newBalance); boolean updateWalletSuccess = userWalletService.updateById(wallet); if (!updateWalletSuccess) { throw new RuntimeException("退款入账失败"); } // 4. 【核心】修改订单状态 order.setStatus(3); // 3-已退款 // (可选) 可以加一个 refund_time 字段记录退款时间,这里为了简化省略 boolean updateOrderSuccess = this.updateById(order); if (!updateOrderSuccess) { throw new RuntimeException("更新订单状态失败"); // 回滚钱包 } // 5. 【兜底】清理 Redis 锁 // 虽然支付成功时应该已经释放了锁,但为了防止当时释放失败导致死锁, // 这里再执行一次释放操作,确保座位彻底回归自由。 redisLockService.releaseSeatLocks(schedule.getId(), List.of(order.getSeatInfo().split(","))); WebSocketServer.sendInfo(userId.toString(), " 退款成功!金额已返回钱包。"); // 6. 记录收入流水 WalletLog walletLog = new WalletLog(); walletLog.setUserId(userId); walletLog.setAmount(order.getTotalPrice()); // 收入为正 walletLog.setType((byte) 3); // 3-退款 walletLog.setOrderNo(order.getOrderNo()); walletLog.setRemark("订单退款"); walletLogService.save(walletLog); } catch (RuntimeException e) { // 捕获异常,抛出 RuntimeException 触发回滚 throw new RuntimeException("退款失败: " + e.getMessage()); } } @Override @Transactional(rollbackFor = Exception.class) public void cancelOrder(String orderNo, Long userId) { // 1. 查询订单 QueryWrapper query = new QueryWrapper<>(); query.eq("order_no", orderNo).eq("user_id", userId); Order order = this.getOne(query); if (order == null) { throw new RuntimeException("订单不存在"); } // 2. 只有“待支付”的订单可以手动取消 if (order.getStatus() != 0) { throw new RuntimeException("订单状态已改变,无法取消"); } try { // 3. 修改状态为“已取消” order.setStatus(2); // 2-已取消 // (可选) setCancelTime this.updateById(order); // 4. 【核心】立刻释放 Redis 座位锁 // 这样别人就能马上买这几个座了,不用等15分钟 Schedule schedule = scheduleService.getById(order.getScheduleId()); if (schedule != null) { redisLockService.releaseSeatLocks(schedule.getId(), List.of(order.getSeatInfo().split(","))); } WebSocketServer.sendInfo(userId.toString(), " 订单取消成功!"); } catch (Exception e) { throw new RuntimeException("取消订单失败: " + e.getMessage()); } } } package com.movie.service.impl; import java.util.List; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.config.RabbitMQConfig; import com.movie.dto.LikeMsgDTO; import com.movie.entity.ReviewReply; import com.movie.mapper.ReviewReplyMapper; import com.movie.service.IReviewReplyService; import com.movie.vo.ReplyVO; /** *

* 评论回复表 服务实现类 *

* * @author Liu * @since 2025-12-25 */ @Service public class ReviewReplyServiceImpl extends ServiceImpl implements IReviewReplyService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private RabbitTemplate rabbitTemplate; // 点赞回复 public void likeReply(Long replyId, Long userId) { String key = "reply:like:" + replyId; Boolean isMember = redisTemplate.opsForSet().isMember(key, userId.toString()); boolean isLikeAction; if (Boolean.TRUE.equals(isMember)) { redisTemplate.opsForSet().remove(key, userId.toString()); isLikeAction = false; } else { redisTemplate.opsForSet().add(key, userId.toString()); isLikeAction = true; } // 发送完整 DTO LikeMsgDTO msg = new LikeMsgDTO(userId, replyId, 2, isLikeAction); // type=2 rabbitTemplate.convertAndSend(RabbitMQConfig.LIKE_COUNT_QUEUE, msg); } @Override public void loadLikeState(List list, Long userId) { // 删除这行:if (userId == null) return; // 即使没登录,也要显示有多少人点赞 if (list == null || list.isEmpty()) return; for (ReplyVO vo : list) { String key = "reply:like:" + vo.getId(); // 1. 先查总数 (Redis 数据是最准的) Long count = redisTemplate.opsForSet().size(key); vo.setLikeCount(count != null ? count.intValue() : 0); // 2. 如果用户登录了,再查该用户是否点赞 if (userId != null) { // 必须转 String,防止 Long 和 String 比较导致永远 false Boolean isLiked = redisTemplate.opsForSet().isMember(key, userId.toString()); vo.setIsLiked(Boolean.TRUE.equals(isLiked)); } else { // 没登录肯定没点赞 vo.setIsLiked(false); } } } @Override public Page getReplyPage(Long reviewId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); // 直接调用 Mapper 的分页方法 return baseMapper.findRepliesByReviewId(page, reviewId); } } package com.movie.service.impl; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.List; import java.util.Map; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.config.RabbitMQConfig; import com.movie.dto.LikeMsgDTO; import com.movie.dto.ReviewDTO; import com.movie.entity.Info; import com.movie.entity.Review; import com.movie.entity.ReviewReply; import com.movie.entity.User; import com.movie.mapper.ReviewMapper; import com.movie.service.IMovieService; import com.movie.service.IReviewReplyService; // 修正引用 import com.movie.service.IReviewService; import com.movie.utils.UserHolder; import com.movie.vo.ReplyVO; import com.movie.vo.ReviewVO; @Service public class ReviewServiceImpl extends ServiceImpl implements IReviewService { @Autowired private IMovieService infoService; @Autowired private StringRedisTemplate redisTemplate; @Autowired private RabbitTemplate rabbitTemplate; // 建议注入接口,而不是具体的 Impl 类,防止循环依赖 @Autowired private IReviewReplyService replyService; // --- 1. 添加评论 (修复 500 错误) --- @Override @Transactional(rollbackFor = Exception.class) public void addReview(ReviewDTO dto, Long userId) { // 1.1 【关键修复】检查是否已经评论过 long existCount = this.count(new QueryWrapper() .eq("user_id", userId) .eq("movie_id", dto.getMovieId())); if (existCount > 0) { // 这里抛出异常,前端会收到 500,但这是业务异常。 // 更好的做法是自定义一个 BusinessException 返回 400,但为了你现有架构, // 这里抛出 RuntimeException,你需要在 Controller 或全局异常处理器里看日志。 throw new RuntimeException("您已经评价过该电影,无法重复评价!"); } // 1.2 校验评分范围 if (dto.getScore() == null || dto.getScore().doubleValue() < 0 || dto.getScore().doubleValue() > 10) { throw new RuntimeException("评分无效,必须在 0-10 之间"); } // 1.3 保存评论 Review review = new Review(); review.setMovieId(dto.getMovieId()); review.setUserId(userId); review.setScore(dto.getScore()); review.setContent(dto.getContent()); review.setLikeCount(0); boolean saveSuccess = this.save(review); if (!saveSuccess) { throw new RuntimeException("评论保存失败"); } // 1.4 刷新电影分数 refreshMovieRating(dto.getMovieId()); } // --- 2. 删除评论 --- @Override @Transactional(rollbackFor = Exception.class) public void deleteReview(Long id) { Review review = this.getById(id); if (review == null) return; this.removeById(id); QueryWrapper replyQuery = new QueryWrapper<>(); replyQuery.eq("review_id", id); replyService.remove(replyQuery); refreshMovieRating(review.getMovieId()); } // --- 3. 前台列表 --- @Override public Page getReviewsByMovieId(Long movieId, int pageNum, int pageSize) { // 1. 先查出影评 Page page = new Page<>(pageNum, pageSize); Page resultPage = baseMapper.findReviewsByMovieId(page, movieId); // 获取当前用户ID (用于检查是否点赞) User currentUser = UserHolder.getUser(); Long currentUserId = (currentUser != null) ? currentUser.getId() : null; // 2. 填充回复数据 if (resultPage.getRecords() != null && !resultPage.getRecords().isEmpty()) { for (ReviewVO vo : resultPage.getRecords()) { // 改成 50,一次性查多点,方便前端做"展开" Page replyPage = replyService.getReplyPage(vo.getId(), 1, 20); List replies = replyPage.getRecords(); // 【关键修复】必须在这里加载回复的点赞状态! // 之前就是因为缺了这行,导致回复的点赞数全是数据库旧值(0),且状态全灰 replyService.loadLikeState(replies, currentUserId); vo.setReplyList(replies); } } return resultPage; } // --- 4. 后台管理员列表 --- @Override public Page getAdminReviewList(int pageNum, int pageSize, String keyword) { Page page = new Page<>(pageNum, pageSize); return baseMapper.findAdminReviews(page, keyword); } // --- 5. 点赞逻辑 --- @Override public void likeReview(Long reviewId, Long userId) { String key = "review:like:" + reviewId; Boolean isMember = redisTemplate.opsForSet().isMember(key, userId.toString()); boolean isLikeAction; if (Boolean.TRUE.equals(isMember)) { redisTemplate.opsForSet().remove(key, userId.toString()); isLikeAction = false; } else { redisTemplate.opsForSet().add(key, userId.toString()); isLikeAction = true; } LikeMsgDTO msg = new LikeMsgDTO(userId, reviewId, 1, isLikeAction); rabbitTemplate.convertAndSend(RabbitMQConfig.LIKE_COUNT_QUEUE, msg); } // --- 6. 加载点赞状态 --- @Override public void loadLikeState(List records, Long currentUserId) { if (records == null || records.isEmpty()) return; for (ReviewVO record : records) { String key = "review:like:" + record.getId(); Long likeCount = redisTemplate.opsForSet().size(key); record.setLikeCount(likeCount != null ? likeCount.intValue() : 0); if (currentUserId != null) { Boolean isLiked = redisTemplate.opsForSet().isMember(key, currentUserId.toString()); record.setIsLiked(Boolean.TRUE.equals(isLiked)); } else { record.setIsLiked(false); } } } // ================= 核心优化:使用 SQL 聚合计算评分 ================= // 你之前的写法查出所有 list 是重大隐患,这里改用 getMap private void refreshMovieRating(Long movieId) { QueryWrapper query = new QueryWrapper<>(); // 直接让数据库计算平均分和总数 query.select("IFNULL(AVG(score), 0) as avgScore", "COUNT(*) as totalCount"); query.eq("movie_id", movieId); Map result = this.getMap(query); if (result == null) return; // 安全转换类型(不同数据库驱动返回类型可能不同,转 string 再转 BigDecimal 最稳) BigDecimal avgScore = new BigDecimal(String.valueOf(result.get("avgScore"))); avgScore = avgScore.setScale(1, RoundingMode.HALF_UP); @SuppressWarnings("UnnecessaryTemporaryOnConversionFromString") Integer reviewCount = Integer.parseInt(String.valueOf(result.get("totalCount"))); // 更新电影表 Info movieToUpdate = new Info(); movieToUpdate.setId(movieId); movieToUpdate.setRating(avgScore); movieToUpdate.setReviewCount(reviewCount); infoService.updateById(movieToUpdate); } }package com.movie.service.impl; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.entity.Schedule; import com.movie.mapper.ScheduleMapper; import com.movie.service.IScheduleService; @Service public class ScheduleServiceImpl extends ServiceImpl implements IScheduleService { @Override public boolean isTimeConflict(Long hallId, LocalDateTime startTime, LocalDateTime endTime) { QueryWrapper query = new QueryWrapper<>(); query.eq("hall_id", hallId) .lt("start_time", endTime) // 已有排片的开始时间 < 新排片的结束时间 .gt("end_time", startTime); // 已有排片的结束时间 > 新排片的开始时间 return this.count(query) > 0; } }package com.movie.service.impl; import java.math.BigDecimal; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.dto.UserLoginDTO; import com.movie.dto.UserRegisterDTO; import com.movie.entity.User; import com.movie.entity.UserWallet; import com.movie.mapper.UserMapper; import com.movie.service.IUserService; import com.movie.service.IUserWalletService; import com.movie.vo.UserLoginVO; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.BCrypt; import cn.hutool.json.JSONUtil; /** * UserServiceImpl */ @Service public class UserServiceImpl extends ServiceImpl implements IUserService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private IUserWalletService userWalletService; @Override public void register(UserRegisterDTO dto) { // 1. 检查用户名是否已存在 QueryWrapper query = new QueryWrapper<>(); query.eq("username", dto.getUsername()); if (count(query) > 0) { throw new RuntimeException("用户名已存在!"); } // 2. 检查手机号 QueryWrapper phoneQuery = new QueryWrapper<>(); phoneQuery.eq("phone", dto.getPhone()); if (count(phoneQuery) > 0) { throw new RuntimeException("手机号已被注册!"); } // 3. 创建用户对象 User user = new User(); user.setUsername(dto.getUsername()); user.setNickname(dto.getNickname()); user.setPhone(dto.getPhone()); user.setStatus(true); user.setIsAdmin(false); // 4. 密码加密 (BCrypt) // 注意:如果这里报错,说明你没引 Hutool 包,或者 User 表没 password 字段 String encodedPassword = BCrypt.hashpw(dto.getPassword()); user.setPassword(encodedPassword); // 5. 保存 save(user); UserWallet wallet = new UserWallet(); wallet.setUserId(user.getId()); wallet.setBalance(BigDecimal.ZERO); wallet.setPayPassword(null); userWalletService.save(wallet); } @Override public UserLoginVO login(UserLoginDTO dto) { // 1. 根据用户名查询用户 QueryWrapper query = new QueryWrapper<>(); query.eq("username", dto.getUsername()); User user = getOne(query); // 2. 校验用户是否存在 if (user == null) { throw new RuntimeException("用户不存在"); } if (Boolean.FALSE.equals(user.getStatus())) { throw new RuntimeException("该账号已被禁用,请联系管理员"); } // 3. 校验密码 (必须用 BCrypt.checkpw) if (!BCrypt.checkpw(dto.getPassword(), user.getPassword())) { throw new RuntimeException("密码错误"); } // 4. 生成 Token (用 UUID) String token = IdUtil.simpleUUID(); String role = Boolean.TRUE.equals(user.getIsAdmin()) ? "admin" : "user"; // 5. 把用户信息存入 Redis (Key: "login_token:xxxx", Value: UserJson, TTL: 24h) // 为什么存 Redis?因为这样后端就是无状态的,且查询极快 String key = "login_token:" + token; // 把 User 对象转成 JSON 字符串 String userJson = JSONUtil.toJsonStr(user); redisTemplate.opsForValue().set(key, userJson, 24, TimeUnit.HOURS); // 6. 组装返回给前端的数据 UserLoginVO vo = new UserLoginVO(); BeanUtil.copyProperties(user, vo); // 属性拷贝 vo.setToken(token); // 塞入 Token vo.setRole(role); return vo; } @Override @Transactional(rollbackFor = Exception.class) public void updateUserInfo(User user, String token) { // 1. 强制只允许修改特定字段 (安全过滤) // 防止用户恶意修改 id, username, balance, is_admin 等敏感字段 User updateUser = new User(); updateUser.setId(user.getId()); // ID 是必须的,用于 WHERE 条件 // 只拷贝允许修改的字段 updateUser.setNickname(user.getNickname()); updateUser.setPhone(user.getPhone()); updateUser.setEmail(user.getEmail()); updateUser.setAvatarUrl(user.getAvatarUrl()); // 2. 更新数据库 this.updateById(updateUser); // 3. 【核心】同步更新 Redis // 因为拦截器是根据 token 去 Redis 拿用户的,如果 Redis 不更,拦截器拿到的永远是旧数据 String key = "login_token:" + token; // 为了数据完整性,建议重新从数据库查一份最新的完整数据 User latestUser = this.getById(user.getId()); // 转 JSON String userJson = JSONUtil.toJsonStr(latestUser); // 更新 Redis,并重置过期时间 (例如 24小时) redisTemplate.opsForValue().set(key, userJson, 24, TimeUnit.HOURS); } }package com.movie.service.impl; import java.math.BigDecimal; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.movie.dto.ChangePayPwdDTO; import com.movie.entity.UserWallet; import com.movie.entity.WalletLog; import com.movie.mapper.UserWalletMapper; import com.movie.service.IUserWalletService; import com.movie.service.IWalletLogService; import cn.hutool.crypto.digest.BCrypt; /** *

* 用户钱包表 服务实现类 *

* * @author Liu * @since 2025-12-25 */ @Service public class UserWalletServiceImpl extends ServiceImpl implements IUserWalletService { @Autowired private IWalletLogService walletLogService; @Override @Transactional(rollbackFor = Exception.class) public void setPayPassword(Long userId, String password) { // 1. 查询钱包 QueryWrapper query = new QueryWrapper<>(); query.eq("user_id", userId); UserWallet wallet = this.getOne(query); if (wallet == null) { throw new RuntimeException("钱包不存在"); } // 2. 校验密码强度 (企业级做法:必须是6位数字) if (!password.matches("^\\d{6}$")) { throw new RuntimeException("支付密码必须是6位数字"); } // 3. 加密并保存 String encodedPwd = BCrypt.hashpw(password); wallet.setPayPassword(encodedPwd); this.updateById(wallet); } @Override public void changePayPassword(Long userId, ChangePayPwdDTO dto) { UserWallet wallet = this.getOne(new QueryWrapper().eq("user_id", userId)); if (wallet == null) throw new RuntimeException("钱包不存在"); // 1. 校验旧密码 if (wallet.getPayPassword() != null) { if (!BCrypt.checkpw(dto.getOldPassword(), wallet.getPayPassword())) { throw new RuntimeException("旧支付密码错误"); } } // 2. 校验新密码格式 if (!dto.getNewPassword().matches("^\\d{6}$")) { throw new RuntimeException("新密码必须是6位数字"); } // 3. 更新 wallet.setPayPassword(BCrypt.hashpw(dto.getNewPassword())); this.updateById(wallet); } @Override @Transactional(rollbackFor = Exception.class) public void recharge(Long userId, BigDecimal amount) { // 1. 基础校验 if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new RuntimeException("充值金额必须大于0"); } if (amount.compareTo(new BigDecimal("100000")) > 0) { throw new RuntimeException("单次充值不能超过 10万"); // 风控限制 } // 2. 查询钱包 UserWallet wallet = this.getOne(new QueryWrapper().eq("user_id", userId)); if (wallet == null) { throw new RuntimeException("钱包不存在"); } // 3. 增加余额 wallet.setBalance(wallet.getBalance().add(amount)); boolean updateSuccess = this.updateById(wallet); if (!updateSuccess) { throw new RuntimeException("充值失败"); } // 4. 记录流水 (Type = 1: 充值) WalletLog walletLog = new WalletLog(); walletLog.setUserId(userId); walletLog.setAmount(amount); // 正数 walletLog.setType((byte) 1); // 1-充值 // 生成一个充值流水号,避免和其他订单号混淆 walletLog.setOrderNo("RECH" + System.currentTimeMillis()); walletLog.setRemark("余额充值"); walletLogService.save(walletLog); } }