← 返回文章详情

service-snippets.txt

相关文档预览

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;

/**
 * <p>
 * 电影信息表 服务实现类
 * </p>
 *
 * @author Liu
 * @since 2025-12-25
 */
@Service
public class InfoServiceImpl extends ServiceImpl<InfoMapper, Info> 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<String> actorNames = Collections.emptyList();
        if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) {
            actorNames = actorInfoService.listByIds(dto.getActorIds())
                    .stream().map(ActorInfo::getName).collect(Collectors.toList());
        }

        List<String> 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<MovieDoc> 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<MovieDoc> searchHits = elasticsearchOperations.search(query, MovieDoc.class);
        
        // 4. 转换成分页对象 (这部分不变)
        SearchPage<MovieDoc> searchPage = SearchHitSupport.searchPageFor(searchHits, pageRequest);

        return (Page<MovieDoc>) 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<Actor> actorDeleteWrapper = new QueryWrapper<>();
        actorDeleteWrapper.eq("movie_id", movie.getId());
        actorRelationService.remove(actorDeleteWrapper);
        
        // 2.2 删除旧的导演关联
        QueryWrapper<Director> 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<String> actorNames = Collections.emptyList();
        if (dto.getActorIds() != null && !dto.getActorIds().isEmpty()) {
            actorNames = actorInfoService.listByIds(dto.getActorIds())
                    .stream().map(ActorInfo::getName).collect(Collectors.toList());
        }
        List<String> 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<Actor> actorQuery = new QueryWrapper<>();
        actorQuery.eq("movie_id", id);
        List<Actor> actorRelations = actorRelationService.list(actorQuery);
        
        if (!actorRelations.isEmpty()) {
            List<Long> actorIds = actorRelations.stream().map(Actor::getActorId).collect(Collectors.toList());
            // 3. 查演员详情 (actor_info)
            List<ActorInfo> actorInfos = actorInfoService.listByIds(actorIds);
            vo.setActorList(actorInfos);
        }

        // 4. 查导演关联关系 (movie_director) -> 拿到 directorId 列表
        QueryWrapper<Director> directorQuery = new QueryWrapper<>();
        directorQuery.eq("movie_id", id);
        List<Director> directorRelations = directorRelationService.list(directorQuery);
        
        if (!directorRelations.isEmpty()) {
            List<Long> directorIds = directorRelations.stream().map(Director::getDirectorId).collect(Collectors.toList());
            // 5. 查导演详情 (director_info)
            List<DirectorInfo> directorInfos = directorInfoService.listByIds(directorIds);
            vo.setDirectorList(directorInfos);
        }

        return vo;
    }

    @Override
    public void syncEsData() {
        // 1. 先清空 ES 中的旧数据 (防止重复或脏数据)
        movieRepository.deleteAll();

        // 2. 查出 MySQL 里所有的电影
        List<Info> allMovies = this.list();
        if (allMovies.isEmpty()) return;

        List<MovieDoc> 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<Actor> actorQuery = new QueryWrapper<>();
            actorQuery.eq("movie_id", movie.getId());
            List<Actor> actorRelations = actorRelationService.list(actorQuery);
            
            List<String> actorNames = new ArrayList<>();
            if (!actorRelations.isEmpty()) {
                List<Long> actorIds = actorRelations.stream().map(Actor::getActorId).collect(Collectors.toList());
                // 查信息表
                List<ActorInfo> actors = actorInfoService.listByIds(actorIds);
                actorNames = actors.stream().map(ActorInfo::getName).collect(Collectors.toList());
            }
            doc.setActors(actorNames);

            // --- 查导演名字 ---
            QueryWrapper<Director> directorQuery = new QueryWrapper<>();
            directorQuery.eq("movie_id", movie.getId());
            List<Director> directorRelations = directorRelationService.list(directorQuery);

            List<String> directorNames = new ArrayList<>();
            if (!directorRelations.isEmpty()) {
                List<Long> directorIds = directorRelations.stream().map(Director::getDirectorId).collect(Collectors.toList());
                List<DirectorInfo> 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<OrderMapper, Order> 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<String, Object> hallConfig = null;
        try {
            hallConfig = objectMapper.readValue(
                hall.getSeatConfig(), 
                new TypeReference<Map<String, Object>>() {}
            );
        } catch (Exception e) {
            // 容错处理:如果解析失败,给个默认空对象,防止整个页面打不开
            hallConfig = new HashMap<>();
            hallConfig.put("rows", 0);
            hallConfig.put("cols", 0);
        }

        // 4. 安全提取坏座 (broken_seats)
        // 解决 unchecked 警告,先判断是否存在且类型是否正确
        List<String> 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<Order> soldQuery = new QueryWrapper<>();
        soldQuery.eq("schedule_id", scheduleId)
                .in("status", 1, 4); // 假设 1=已支付, 4=已完成。具体看你的状态定义
             // .ne("status", 2); // 或者直接查“不等于已取消”的所有订单

        List<Order> soldOrders = this.list(soldQuery);
    
        // 将订单里的座位号字符串 "1-1,1-2" 拆分并收集
        List<String> 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<String> lockedKeys = redisTemplate.keys(lockKeyPattern);
    
        if (lockedKeys != null && !lockedKeys.isEmpty()) {
            List<String> 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<String> rawSeats = dto.getSeats();
        if (rawSeats == null || rawSeats.isEmpty()) {
            throw new RuntimeException("请选择座位");
        }

        Set<String> 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<String> 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<String, Object> config;
            try {
                config = objectMapper.readValue(
                    hall.getSeatConfig(), 
                    new TypeReference<Map<String, Object>>() {}
                );
            } catch (JsonProcessingException e) {
                // 如果解析失败,说明数据库里的 JSON 格式不对
                // 【关键步骤】必须释放刚才锁住的座位!
                redisLockService.releaseSeatLocks(dto.getScheduleId(), finalSeats);
                throw new RuntimeException("影厅座位数据异常,无法下单");
            }
            
            // 提取坏座列表
            List<String> brokenSeats = new ArrayList<>();
            if (config.containsKey("broken_seats")) {
                brokenSeats = (List<String>) 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<Order> 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<Order> 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<UserWallet> 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<Order> 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<UserWallet> 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<Order> 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;

/**
 * <p>
 * 评论回复表 服务实现类
 * </p>
 *
 * @author Liu
 * @since 2025-12-25
 */
@Service
public class ReviewReplyServiceImpl extends ServiceImpl<ReviewReplyMapper, ReviewReply> 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<ReplyVO> 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<ReplyVO> getReplyPage(Long reviewId, int pageNum, int pageSize) {
        Page<ReplyVO> 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<ReviewMapper, Review> 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<Review>()
                .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<ReviewReply> replyQuery = new QueryWrapper<>();
        replyQuery.eq("review_id", id);
        replyService.remove(replyQuery);

        refreshMovieRating(review.getMovieId());
    }

    // --- 3. 前台列表 ---
    @Override
    public Page<ReviewVO> getReviewsByMovieId(Long movieId, int pageNum, int pageSize) {
        // 1. 先查出影评
        Page<ReviewVO> page = new Page<>(pageNum, pageSize);
        Page<ReviewVO> 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<ReplyVO> replyPage = replyService.getReplyPage(vo.getId(), 1, 20);
                List<ReplyVO> replies = replyPage.getRecords();
                
                // 【关键修复】必须在这里加载回复的点赞状态!
                // 之前就是因为缺了这行,导致回复的点赞数全是数据库旧值(0),且状态全灰
                replyService.loadLikeState(replies, currentUserId);
                
                vo.setReplyList(replies);
            }
        }
        
        return resultPage;
    }

    // --- 4. 后台管理员列表 ---
    @Override
    public Page<ReviewVO> getAdminReviewList(int pageNum, int pageSize, String keyword) {
        Page<ReviewVO> 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<ReviewVO> 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<Review> query = new QueryWrapper<>();
        // 直接让数据库计算平均分和总数
        query.select("IFNULL(AVG(score), 0) as avgScore", "COUNT(*) as totalCount");
        query.eq("movie_id", movieId);
        
        Map<String, Object> 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<ScheduleMapper, Schedule> implements IScheduleService {
    @Override
    public boolean isTimeConflict(Long hallId, LocalDateTime startTime, LocalDateTime endTime) {
        QueryWrapper<Schedule> 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<UserMapper, User> implements IUserService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IUserWalletService userWalletService;

    @Override
    public void register(UserRegisterDTO dto) {
        // 1. 检查用户名是否已存在
        QueryWrapper<User> query = new QueryWrapper<>();
        query.eq("username", dto.getUsername());
        if (count(query) > 0) {
            throw new RuntimeException("用户名已存在!");
        }

        // 2. 检查手机号
        QueryWrapper<User> 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<User> 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;

/**
 * <p>
 * 用户钱包表 服务实现类
 * </p>
 *
 * @author Liu
 * @since 2025-12-25
 */
@Service
public class UserWalletServiceImpl extends ServiceImpl<UserWalletMapper, UserWallet> implements IUserWalletService {
    
    @Autowired
    private IWalletLogService walletLogService;
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void setPayPassword(Long userId, String password) {
        // 1. 查询钱包
        QueryWrapper<UserWallet> 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<UserWallet>().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<UserWallet>().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);
    }
}
返回顶部