diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DailyRecommendDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DailyRecommendDO.java new file mode 100644 index 0000000..9e54b59 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DailyRecommendDO.java @@ -0,0 +1,59 @@ +package com.wangpan.common.dal.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 每日推荐缓存数据对象 + * 对应数据库表:wp_daily_recommend + * + * 设计说明: + * - 每个用户每天缓存一组推荐电影(3部) + * - 当天多次请求返回相同的推荐结果 + * - 第二天自动重新随机生成 + */ +@TableName(value = "wp_daily_recommend", autoResultMap = true) +public class DailyRecommendDO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 主键,自增长 */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 用户ID */ + private Long userId; + + /** 推荐日期(格式:yyyy-MM-dd) */ + private LocalDate recommendDate; + + /** 推荐的电影ID列表(逗号分隔,如 "1,5,12") */ + private String movieIds; + + /** 创建时间 */ + @TableField(fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== Getter 和 Setter ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public LocalDate getRecommendDate() { return recommendDate; } + public void setRecommendDate(LocalDate recommendDate) { this.recommendDate = recommendDate; } + + public String getMovieIds() { return movieIds; } + public void setMovieIds(String movieIds) { this.movieIds = movieIds; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java index acc5199..4114703 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/DriveMovieDO.java @@ -77,12 +77,21 @@ public class DriveMovieDO implements Serializable { /** 刮削类型:tmdb / manual / none */ private String scrapeType; + /** 刮削状态:pending / scraping / success / failed */ + private String scrapeStatus; + + /** 刮削失败原因 */ + private String scrapeError; + /** 评分(0-100) */ private Integer rating; - /** 海报 URL(加密存储,DO 中为解密后的明文) */ + /** 海报 URL(竖版,加密存储,DO 中为解密后的明文) */ private String posterUrl; + /** 背景图 URL(横版,加密存储,DO 中为解密后的明文) */ + private String backdropUrl; + /** 标签(逗号分隔) */ private String tags; @@ -150,12 +159,21 @@ public class DriveMovieDO implements Serializable { public String getScrapeType() { return scrapeType; } public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + public String getScrapeStatus() { return scrapeStatus; } + public void setScrapeStatus(String scrapeStatus) { this.scrapeStatus = scrapeStatus; } + + public String getScrapeError() { return scrapeError; } + public void setScrapeError(String scrapeError) { this.scrapeError = scrapeError; } + public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } public String getPosterUrl() { return posterUrl; } public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + public String getBackdropUrl() { return backdropUrl; } + public void setBackdropUrl(String backdropUrl) { this.backdropUrl = backdropUrl; } + public String getTags() { return tags; } public void setTags(String tags) { this.tags = tags; } diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java index 5588411..2e55d5e 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/dataobject/MovieDO.java @@ -50,12 +50,21 @@ public class MovieDO implements Serializable { /** 刮削类型:auto / manual / none */ private String scrapeType; + /** 刮削状态:pending / scraping / success / failed */ + private String scrapeStatus; + + /** 刮削失败原因 */ + private String scrapeError; + /** 评分(0-100) */ private Integer rating; - /** 海报图片URL */ + /** 海报图片URL(竖版) */ private String posterUrl; + /** 背景图URL(横版) */ + private String backdropUrl; + /** 标签(逗号分隔) */ private String tags; @@ -96,12 +105,21 @@ public class MovieDO implements Serializable { public String getScrapeType() { return scrapeType; } public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + public String getScrapeStatus() { return scrapeStatus; } + public void setScrapeStatus(String scrapeStatus) { this.scrapeStatus = scrapeStatus; } + + public String getScrapeError() { return scrapeError; } + public void setScrapeError(String scrapeError) { this.scrapeError = scrapeError; } + public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } public String getPosterUrl() { return posterUrl; } public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + public String getBackdropUrl() { return backdropUrl; } + public void setBackdropUrl(String backdropUrl) { this.backdropUrl = backdropUrl; } + public String getTags() { return tags; } public void setTags(String tags) { this.tags = tags; } diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DailyRecommendMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DailyRecommendMapper.java new file mode 100644 index 0000000..e7b80e1 --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DailyRecommendMapper.java @@ -0,0 +1,35 @@ +package com.wangpan.common.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wangpan.common.dal.dataobject.DailyRecommendDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 每日推荐缓存 Mapper 接口 + * + * 继承 MyBatis-Plus BaseMapper,自动获得 CRUD 能力 + */ +@Mapper +public interface DailyRecommendMapper extends BaseMapper { + + /** 建表SQL(保留供 DatabaseInitializer 使用) */ + String CREATE_TABLE_SQL = """ + CREATE TABLE IF NOT EXISTS wp_daily_recommend ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + recommend_date DATE NOT NULL COMMENT '推荐日期(yyyy-MM-dd)', + movie_ids VARCHAR(200) NOT NULL COMMENT '推荐电影ID列表(逗号分隔)', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE INDEX uk_user_date (user_id, recommend_date), + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日推荐缓存表' + """; + + /** + * 根据用户ID和日期查询当日推荐缓存 + */ + @Select("SELECT * FROM wp_daily_recommend WHERE user_id = #{userId} AND recommend_date = #{recommendDate}") + DailyRecommendDO findByUserAndDate(@Param("userId") Long userId, @Param("recommendDate") String recommendDate); +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java index 457dacb..ce1001a 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/DriveMovieMapper.java @@ -38,8 +38,11 @@ public interface DriveMovieMapper extends BaseMapper { status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态:active/deleted', category VARCHAR(50) DEFAULT NULL COMMENT '分类', scrape_type VARCHAR(20) DEFAULT 'none' COMMENT '刮削类型:tmdb/manual/none', + scrape_status VARCHAR(20) DEFAULT 'pending' COMMENT '刮削状态:pending/scraping/success/failed', + scrape_error VARCHAR(500) DEFAULT NULL COMMENT '刮削失败原因', rating INT DEFAULT NULL COMMENT '评分(0-100)', - poster_url TEXT DEFAULT NULL COMMENT '海报URL(加密存储)', + poster_url TEXT DEFAULT NULL COMMENT '海报URL(竖版,加密存储)', + backdrop_url TEXT DEFAULT NULL COMMENT '背景图URL(横版,加密存储)', tags VARCHAR(500) DEFAULT NULL COMMENT '标签(逗号分隔)', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java index 5e8389f..6a7af30 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/MovieMapper.java @@ -48,6 +48,12 @@ public interface MovieMapper extends BaseMapper { @Update("UPDATE wp_drive_movie SET poster_url = #{posterUrl}, update_time = NOW() WHERE id = #{id}") int updatePosterUrl(@Param("id") Long id, @Param("posterUrl") String posterUrl); + /** + * 更新电影背景图URL(横版) + */ + @Update("UPDATE wp_drive_movie SET backdrop_url = #{backdropUrl}, update_time = NOW() WHERE id = #{id}") + int updateBackdropUrl(@Param("id") Long id, @Param("backdropUrl") String backdropUrl); + /** * 更新电影标签 */ @@ -59,4 +65,10 @@ public interface MovieMapper extends BaseMapper { */ @Update("UPDATE wp_drive_movie SET scrape_type = #{scrapeType}, update_time = NOW() WHERE id = #{id}") int updateScrapeType(@Param("id") Long id, @Param("scrapeType") String scrapeType); + + /** + * 更新刮削状态和失败原因 + */ + @Update("UPDATE wp_drive_movie SET scrape_status = #{scrapeStatus}, scrape_error = #{scrapeError}, update_time = NOW() WHERE id = #{id}") + int updateScrapeStatus(@Param("id") Long id, @Param("scrapeStatus") String scrapeStatus, @Param("scrapeError") String scrapeError); } diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java index 5fe9b5a..123e27a 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.java @@ -49,8 +49,10 @@ public interface TmdbScraperLogMapper extends BaseMapper { /** * 分页查询最近的执行记录 * 复杂SQL,在XML中实现 + * @param offset 偏移量(从0开始) + * @param pageSize 每页条数 */ - Map queryRecent(@Param("pageNo") int pageNo, @Param("pageSize") int pageSize); + List> queryRecent(@Param("offset") int offset, @Param("pageSize") int pageSize); /** * 查询最近的N条执行记录 diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java index c5d70e9..e5db334 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/mapper/UserMapper.java @@ -98,4 +98,16 @@ public interface UserMapper extends BaseMapper { */ @Update("UPDATE wp_user SET is_deleted = 1, update_time = NOW() WHERE id = #{userId} AND is_deleted = 0") int softDelete(@Param("userId") Long userId); + + /** + * 更新用户名(需先检查唯一性) + */ + @Update("UPDATE wp_user SET username = #{username}, update_time = NOW() WHERE id = #{userId} AND is_deleted = 0") + int updateUsername(@Param("userId") Long userId, @Param("username") String username); + + /** + * 更新用户头像URL + */ + @Update("UPDATE wp_user SET avatar_url = #{avatarUrl}, update_time = NOW() WHERE id = #{userId} AND is_deleted = 0") + int updateAvatarUrl(@Param("userId") Long userId, @Param("avatarUrl") String avatarUrl); } diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DailyRecommendRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DailyRecommendRepository.java new file mode 100644 index 0000000..1484b4d --- /dev/null +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DailyRecommendRepository.java @@ -0,0 +1,59 @@ +package com.wangpan.common.dal.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.wangpan.common.dal.dataobject.DailyRecommendDO; +import com.wangpan.common.dal.mapper.DailyRecommendMapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 每日推荐缓存 Repository 层 + * + * 职责: + * 1. 查询用户当日推荐缓存 + * 2. 保存/更新用户当日推荐缓存 + */ +@Repository +public class DailyRecommendRepository extends ServiceImpl { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 查询用户当日推荐缓存 + * + * @param userId 用户ID + * @return 缓存记录,不存在返回 null + */ + public DailyRecommendDO findTodayRecommend(Long userId) { + String today = LocalDate.now().format(DATE_FORMATTER); + return baseMapper.findByUserAndDate(userId, today); + } + + /** + * 保存用户当日推荐缓存 + * 如果当天已有缓存则更新,否则插入新记录 + * + * @param userId 用户ID + * @param movieIds 推荐电影ID列表(逗号分隔) + */ + public void saveOrUpdateTodayRecommend(Long userId, String movieIds) { + String today = LocalDate.now().format(DATE_FORMATTER); + var existing = baseMapper.findByUserAndDate(userId, today); + + if (existing != null) { + // 更新已有缓存 + existing.setMovieIds(movieIds); + updateById(existing); + } else { + // 插入新缓存 + var record = new DailyRecommendDO(); + record.setUserId(userId); + record.setRecommendDate(LocalDate.now()); + record.setMovieIds(movieIds); + save(record); + } + } +} diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java index eafeca9..b238631 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/DriveMovieRepository.java @@ -91,6 +91,62 @@ public class DriveMovieRepository extends ServiceImpl queryFolderIdsByUser(Long userId, int pageNo, int pageSize) { + var wrapper = new LambdaQueryWrapper() + .select(DriveMovieDO::getFolderId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .groupBy(DriveMovieDO::getFolderId) + .orderByAsc(DriveMovieDO::getFolderId) + .last("LIMIT " + pageSize + " OFFSET " + (pageNo - 1) * pageSize); + + var list = list(wrapper); + var folderIds = new ArrayList(); + for (var movieDO : list) { + if (movieDO.getFolderId() != null) { + folderIds.add(movieDO.getFolderId()); + } + } + return folderIds; + } + + /** + * 统计用户所有电影的不同文件夹数量 + */ + public int countFoldersByUser(Long userId) { + var wrapper = new LambdaQueryWrapper() + .select(DriveMovieDO::getFolderId) + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .groupBy(DriveMovieDO::getFolderId); + + return (int) count(wrapper); + } + + /** + * 查询指定文件夹下的电影列表(限制数量) + * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 + */ + public List> queryByFolder(Long userId, Long folderId, int limit) { + var page = new Page(1, limit); + var wrapper = new LambdaQueryWrapper() + .eq(DriveMovieDO::getUserId, userId) + .eq(DriveMovieDO::getStatus, "active") + .eq(DriveMovieDO::getFolderId, folderId) + .orderByDesc(DriveMovieDO::getCreateTime); + + var result = page(page, wrapper); + var list = new ArrayList>(); + for (var movieDO : result.getRecords()) { + list.add(toMap(movieDO)); + } + return list; + } + /** * 查询用户所有网盘下的电影列表(分页) * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询 @@ -249,8 +305,11 @@ public class DriveMovieRepository extends ServiceImpl { return movieDO != null ? toMovie(movieDO) : null; } + /** + * 根据电影ID列表批量查询电影(用于每日推荐缓存回查) + * + * @param movieIds 电影ID列表 + * @return 电影Map列表(保持传入ID的顺序) + */ + public List> findByIds(List movieIds) { + if (movieIds == null || movieIds.isEmpty()) { + return new ArrayList<>(); + } + var wrapper = new LambdaQueryWrapper() + .in(MovieDO::getId, movieIds); + var movieDOs = list(wrapper); + // 按传入的ID顺序排序 + var movieMap = movieDOs.stream().collect(Collectors.toMap(MovieDO::getId, m -> m)); + return movieIds.stream() + .filter(movieMap::containsKey) + .map(id -> toMap(movieMap.get(id))) + .collect(Collectors.toList()); + } + /** * 分页查询用户的电影列表 * 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询,支持动态条件 @@ -254,6 +276,14 @@ public class MovieRepository extends ServiceImpl { return baseMapper.updatePosterUrl(id, posterUrl); } + /** + * 更新电影背景图URL(横版) + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updateBackdropUrl(Long id, String backdropUrl) { + return baseMapper.updateBackdropUrl(id, backdropUrl); + } + /** * 更新电影标签 * 委托给 MovieMapper 中定义的 @Update 注解方法 @@ -270,6 +300,14 @@ public class MovieRepository extends ServiceImpl { return baseMapper.updateScrapeType(id, scrapeType); } + /** + * 更新刮削状态和失败原因 + * 委托给 MovieMapper 中定义的 @Update 注解方法 + */ + public int updateScrapeStatus(Long id, String scrapeStatus, String scrapeError) { + return baseMapper.updateScrapeStatus(id, scrapeStatus, scrapeError); + } + // ==================== Entity ↔ DO 转换 ==================== /** @@ -286,8 +324,11 @@ public class MovieRepository extends ServiceImpl { movieDO.setStatus(movie.getStatus()); movieDO.setCategory(movie.getCategory()); movieDO.setScrapeType(movie.getScrapeType()); + movieDO.setScrapeStatus(movie.getScrapeStatus()); + movieDO.setScrapeError(movie.getScrapeError()); movieDO.setRating(movie.getRating()); movieDO.setPosterUrl(movie.getPosterUrl()); + movieDO.setBackdropUrl(movie.getBackdropUrl()); movieDO.setTags(movie.getTags()); movieDO.setUpdateTime(movie.getUpdateTime()); movieDO.setCreateTime(movie.getCreateTime()); @@ -308,8 +349,11 @@ public class MovieRepository extends ServiceImpl { movie.setStatus(movieDO.getStatus()); movie.setCategory(movieDO.getCategory()); movie.setScrapeType(movieDO.getScrapeType()); + movie.setScrapeStatus(movieDO.getScrapeStatus()); + movie.setScrapeError(movieDO.getScrapeError()); movie.setRating(movieDO.getRating()); movie.setPosterUrl(movieDO.getPosterUrl()); + movie.setBackdropUrl(movieDO.getBackdropUrl()); movie.setTags(movieDO.getTags()); movie.setUpdateTime(movieDO.getUpdateTime()); movie.setCreateTime(movieDO.getCreateTime()); @@ -330,8 +374,11 @@ public class MovieRepository extends ServiceImpl { map.put("status", movieDO.getStatus()); map.put("category", movieDO.getCategory()); map.put("scrapeType", movieDO.getScrapeType()); + map.put("scrapeStatus", movieDO.getScrapeStatus()); + map.put("scrapeError", movieDO.getScrapeError()); map.put("rating", movieDO.getRating()); map.put("posterUrl", movieDO.getPosterUrl()); + map.put("backdropUrl", movieDO.getBackdropUrl()); map.put("tags", movieDO.getTags()); map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null); map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null); diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java index 585b7b7..f8b26db 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/TmdbScraperLogRepository.java @@ -34,9 +34,12 @@ public class TmdbScraperLogRepository extends ServiceImpl queryRecent(int pageNo, int pageSize) { - return baseMapper.queryRecent(pageNo, pageSize); + public List> queryRecent(int pageNo, int pageSize) { + int offset = (pageNo - 1) * pageSize; + return baseMapper.queryRecent(offset, pageSize); } /** diff --git a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java index 963e1ca..076e332 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java +++ b/wangpan-common/src/main/java/com/wangpan/common/dal/repository/UserRepository.java @@ -203,6 +203,22 @@ public class UserRepository extends ServiceImpl { return baseMapper.softDelete(userId); } + /** + * 更新用户名 + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateUsername(Long userId, String username) { + return baseMapper.updateUsername(userId, username); + } + + /** + * 更新用户头像URL + * 委托给 UserMapper 中定义的 @Update 注解方法 + */ + public int updateAvatarUrl(Long userId, String avatarUrl) { + return baseMapper.updateAvatarUrl(userId, avatarUrl); + } + // ==================== Entity ↔ DO 转换 ==================== /** diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java index 737c2b8..0479475 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/DriveMovie.java @@ -97,12 +97,27 @@ public class DriveMovie implements Serializable { */ private String scrapeType; + /** + * 刮削状态: + * - pending - 待刮削(默认) + * - scraping - 刮削中 + * - success - 刮削成功 + * - failed - 刮削失败 + */ + private String scrapeStatus; + + /** 刮削失败原因 */ + private String scrapeError; + /** 评分:0-100 */ private Integer rating; - /** 海报 URL(加密存储) */ + /** 海报 URL(竖版,加密存储) */ private String posterUrl; + /** 背景图 URL(横版,加密存储) */ + private String backdropUrl; + /** 标签:逗号分隔,如 "科幻,IMAX,杜比视界" */ private String tags; @@ -136,6 +151,7 @@ public class DriveMovie implements Serializable { this.originalName = originalName; this.status = "active"; this.scrapeType = "none"; + this.scrapeStatus = "pending"; this.createTime = LocalDateTime.now(); } @@ -195,12 +211,21 @@ public class DriveMovie implements Serializable { public String getScrapeType() { return scrapeType; } public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + public String getScrapeStatus() { return scrapeStatus; } + public void setScrapeStatus(String scrapeStatus) { this.scrapeStatus = scrapeStatus; } + + public String getScrapeError() { return scrapeError; } + public void setScrapeError(String scrapeError) { this.scrapeError = scrapeError; } + public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } public String getPosterUrl() { return posterUrl; } public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + public String getBackdropUrl() { return backdropUrl; } + public void setBackdropUrl(String backdropUrl) { this.backdropUrl = backdropUrl; } + public String getTags() { return tags; } public void setTags(String tags) { this.tags = tags; } diff --git a/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java b/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java index 36f014b..42a8274 100644 --- a/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java +++ b/wangpan-common/src/main/java/com/wangpan/common/entity/Movie.java @@ -67,14 +67,29 @@ public class Movie implements Serializable { */ private String scrapeType; + /** + * 刮削状态: + * - pending - 待刮削(默认) + * - scraping - 刮削中 + * - success - 刮削成功 + * - failed - 刮削失败 + */ + private String scrapeStatus; + + /** 刮削失败原因 */ + private String scrapeError; + // ==================== 评分与展示属性 ==================== /** 评分:0-100分制 */ private Integer rating; - /** 海报图片URL地址 */ + /** 海报图片URL地址(竖版) */ private String posterUrl; + /** 背景图URL地址(横版) */ + private String backdropUrl; + /** 标签:逗号分隔,如"科幻, IMAX, 杜比视界" */ private String tags; @@ -134,12 +149,21 @@ public class Movie implements Serializable { public String getScrapeType() { return scrapeType; } public void setScrapeType(String scrapeType) { this.scrapeType = scrapeType; } + public String getScrapeStatus() { return scrapeStatus; } + public void setScrapeStatus(String scrapeStatus) { this.scrapeStatus = scrapeStatus; } + + public String getScrapeError() { return scrapeError; } + public void setScrapeError(String scrapeError) { this.scrapeError = scrapeError; } + public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } public String getPosterUrl() { return posterUrl; } public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } + public String getBackdropUrl() { return backdropUrl; } + public void setBackdropUrl(String backdropUrl) { this.backdropUrl = backdropUrl; } + public String getTags() { return tags; } public void setTags(String tags) { this.tags = tags; } diff --git a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml index 3a22a24..f02cf8d 100644 --- a/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml +++ b/wangpan-common/src/main/resources/com/wangpan/common/dal/mapper/TmdbScraperLogMapper.xml @@ -21,7 +21,7 @@ diff --git a/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java b/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java index b723d62..9ec00dc 100644 --- a/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java +++ b/wangpan-drive-service/src/main/java/com/wangpan/config/DatabaseInitializer.java @@ -74,6 +74,18 @@ public class DatabaseInitializer { SchemaUtil.ensureColumnExists(conn, "wp_drive", "logo_url", "TEXT DEFAULT NULL COMMENT 'drive logo url'"); + // 检查 wp_drive_movie 表是否有 scrape_status 字段(刮削状态) + SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "scrape_status", + "VARCHAR(20) DEFAULT 'pending' COMMENT '刮削状态:pending/scraping/success/failed'"); + + // 检查 wp_drive_movie 表是否有 scrape_error 字段(刮削失败原因) + SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "scrape_error", + "VARCHAR(500) DEFAULT NULL COMMENT '刮削失败原因'"); + + // 检查 wp_drive_movie 表是否有 backdrop_url 字段(横版背景图) + SchemaUtil.ensureColumnExists(conn, "wp_drive_movie", "backdrop_url", + "TEXT DEFAULT NULL COMMENT '背景图URL(横版,加密存储)'"); + // 检查 wp_icon 表是否有 logo_url 字段 SchemaUtil.ensureColumnExists(conn, "wp_icon", "logo_url", "VARCHAR(500) DEFAULT NULL COMMENT 'Logo 图片 URL(MinIO 存储)'"); diff --git a/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java b/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java index af12efe..8fc3b92 100644 --- a/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java +++ b/wangpan-drive-service/src/main/java/com/wangpan/controller/DriveController.java @@ -700,6 +700,8 @@ public class DriveController { map.put("status", movie.getStatus()); map.put("category", movie.getCategory()); map.put("scrapeType", movie.getScrapeType()); + map.put("scrapeStatus", movie.getScrapeStatus()); + map.put("scrapeError", movie.getScrapeError()); map.put("rating", movie.getRating()); map.put("tags", movie.getTags()); map.put("minioUrl", movie.getMinioUrl()); diff --git a/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java b/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java index 6dab674..7e38a72 100644 --- a/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java +++ b/wangpan-drive-service/src/main/java/com/wangpan/controller/IconController.java @@ -428,17 +428,10 @@ public class IconController { iconRepository.updateLogoUrl(id, newLogoUrl); log.info("图标更新成功,新 URL: {}", newLogoUrl); - // 异步删除旧的 MinIO 图片(防止硬盘过满) + // 注意:旧图片不立即删除,由 IconCleanupScheduler 定时任务在 3 天后自动清理 + // 这样可以避免前端仍在使用旧 URL 时图片被删除导致 403/404 if (oldLogoUrl != null && !oldLogoUrl.isEmpty()) { - String oldObjectName = extractObjectName(oldLogoUrl); - if (oldObjectName != null) { - boolean deleted = MinioUtil.deleteFile(oldObjectName); - if (deleted) { - log.info("旧图标删除成功: {}", oldObjectName); - } else { - log.warn("旧图标删除失败: {}", oldObjectName); - } - } + log.info("旧图标将在 3 天后自动清理: {}", oldLogoUrl); } // 返回更新后的 Icon 信息 diff --git a/wangpan-drive-service/src/main/java/com/wangpan/listener/IconCleanupScheduler.java b/wangpan-drive-service/src/main/java/com/wangpan/listener/IconCleanupScheduler.java index 81820dd..dcc9f8b 100644 --- a/wangpan-drive-service/src/main/java/com/wangpan/listener/IconCleanupScheduler.java +++ b/wangpan-drive-service/src/main/java/com/wangpan/listener/IconCleanupScheduler.java @@ -10,7 +10,11 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import jakarta.annotation.PostConstruct; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.Timer; import java.util.TimerTask; @@ -22,12 +26,14 @@ import java.util.concurrent.atomic.AtomicBoolean; * 功能: * 1. 定时扫描 MinIO 中 icons/ 目录下的所有图片 * 2. 对比数据库中在用的 logoUrl,找出无用的图片 - * 3. 删除无用图片,释放硬盘空间 + * 3. 只删除超过 3 天的无用图片(保留期,避免前端仍在使用旧 URL) + * 4. 删除过期无用图片,释放硬盘空间 * * 调度策略: * - 首次延迟:启动后 10 分钟 * - 执行间隔:每 24 小时执行一次 * - 防止并发:同一时间只允许一个清理任务运行 + * - 保留期:无用图片创建/修改后 3 天才删除 */ @Component public class IconCleanupScheduler { @@ -47,6 +53,9 @@ public class IconCleanupScheduler { /** MinIO 中 icon 图片的前缀路径 */ private static final String ICONS_PREFIX = "icons/"; + /** 无用图片保留期(天):超过此天数的无用图片才会被删除 */ + private static final int RETENTION_DAYS = 3; + public IconCleanupScheduler(IconRepository iconRepository) { this.iconRepository = iconRepository; } diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java b/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java index 72ecd7d..92a1545 100644 --- a/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/config/DatabaseInitializer.java @@ -1,5 +1,6 @@ package com.wangpan.config; +import com.wangpan.common.dal.mapper.DailyRecommendMapper; import com.wangpan.common.dal.mapper.TmdbCacheMapper; import com.wangpan.common.dal.mapper.TmdbScraperLogMapper; import com.wangpan.common.util.DBUtil; @@ -76,9 +77,11 @@ public class DatabaseInitializer { // wp_drive_movie 表由 drive-service 的 DatabaseInitializer 负责创建,此处不再重复创建 SchemaUtil.createTableIfNotExists(conn, "wp_tmdb_cache", TmdbCacheMapper.CREATE_TABLE_SQL); SchemaUtil.createTableIfNotExists(conn, "wp_tmdb_scraper_log", TmdbScraperLogMapper.CREATE_TABLE_SQL); + SchemaUtil.createTableIfNotExists(conn, "wp_daily_recommend", DailyRecommendMapper.CREATE_TABLE_SQL); System.out.println(" ✓ [wp_tmdb_cache] 表创建完成"); System.out.println(" ✓ [wp_tmdb_scraper_log] 表创建完成"); + System.out.println(" ✓ [wp_daily_recommend] 表创建完成"); } } } diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java index 06fa991..4997769 100644 --- a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/MovieController.java @@ -2,6 +2,9 @@ package com.wangpan.controller; import com.wangpan.common.Result; import com.wangpan.common.annotation.RequireAuth; +import com.wangpan.common.dal.dataobject.DailyRecommendDO; +import com.wangpan.common.dal.repository.DailyRecommendRepository; +import com.wangpan.common.dal.repository.DriveMovieRepository; import com.wangpan.common.dal.repository.MovieRepository; import com.wangpan.common.entity.Movie; import com.wangpan.common.entity.User; @@ -15,6 +18,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 电影管理控制器 @@ -30,12 +34,18 @@ public class MovieController { private static final Logger log = LoggerFactory.getLogger(MovieController.class); private final MovieRepository movieRepository; + private final DriveMovieRepository driveMovieRepository; + private final DailyRecommendRepository dailyRecommendRepository; /** * 构造函数注入 Repository 依赖 */ - public MovieController(MovieRepository movieRepository) { + public MovieController(MovieRepository movieRepository, + DriveMovieRepository driveMovieRepository, + DailyRecommendRepository dailyRecommendRepository) { this.movieRepository = movieRepository; + this.driveMovieRepository = driveMovieRepository; + this.dailyRecommendRepository = dailyRecommendRepository; } /** @@ -46,13 +56,15 @@ public class MovieController { } /** - * 分页查询电影列表 + * 按文件夹分组查询电影列表(从 wp_drive_movie 表查询当前用户所有电影) * GET /api/movies + * + * 返回格式:按文件夹分组,每个文件夹最多返回 10 条电影数据 + * 支持分页:pageNo 控制文件夹分页,每个文件夹固定返回最多 10 条 */ @GetMapping public Result listMovies(@RequestParam(required = false) String keyword, @RequestParam(required = false) String status, - @RequestParam(required = false) String category, @RequestParam(required = false) String scrapeType, @RequestParam(required = false) String sortBy, @RequestParam(required = false) String sortOrder, @@ -61,12 +73,30 @@ public class MovieController { HttpServletRequest request) { User user = getCurrentUser(request); - log.info("用户 [{}] 查询电影列表 | 关键词={} 状态={} 页码={}", user.getUsername(), keyword, status, pageNo); + log.info("用户 [{}] 按文件夹查询电影列表 | 关键词={} 页码={}", user.getUsername(), keyword, pageNo); - Map resultData = movieRepository.queryByPage( - user.getId(), keyword, status, category, scrapeType, - sortBy, sortOrder, pageNo, pageSize - ); + // 1. 查询用户的文件夹ID列表(分页) + List folderIds = driveMovieRepository.queryFolderIdsByUser(user.getId(), pageNo, pageSize); + int totalFolders = driveMovieRepository.countFoldersByUser(user.getId()); + + // 2. 对每个文件夹查询最多 10 条电影数据 + List> folderGroups = new ArrayList<>(); + for (Long folderId : folderIds) { + List> movies = driveMovieRepository.queryByFolder(user.getId(), folderId, 10); + + Map group = new HashMap<>(); + group.put("folderId", folderId); + group.put("movies", movies); + group.put("movieCount", movies.size()); + folderGroups.add(group); + } + + // 3. 构建分页结果 + Map resultData = new HashMap<>(); + resultData.put("totalFolders", totalFolders); + resultData.put("folderGroups", folderGroups); + resultData.put("pageNo", pageNo); + resultData.put("pageSize", pageSize); return Result.success("查询成功", resultData); } @@ -246,9 +276,14 @@ public class MovieController { } /** - * 今日推荐(随机推荐3部电影) + * 今日推荐(每天固定推荐3部电影) * GET /api/movies/recommend - * 从当前用户的电影库中随机抽取3部电影用于首页展示 + * + * 推荐规则: + * - 从 00:00 到 23:59 返回固定的3部电影 + * - 当天不管查询多少次都返回相同的结果 + * - 第二天 00:00 后重新随机挑选3部 + * - 推荐结果缓存在 wp_daily_recommend 表中 */ @GetMapping("/recommend") public Result getRecommendations(HttpServletRequest request) { @@ -257,8 +292,34 @@ public class MovieController { log.info("用户 [{}] 请求今日推荐", user.getUsername()); try { - // 随机查询3部电影 - List> recommendList = movieRepository.findRandomMovies(user.getId(), 3); + List> recommendList; + + // 第1步:查询当日推荐缓存 + DailyRecommendDO cached = dailyRecommendRepository.findTodayRecommend(user.getId()); + + if (cached != null && cached.getMovieIds() != null && !cached.getMovieIds().isBlank()) { + // 有缓存:根据缓存的电影ID查询电影详情 + log.info("用户 [{}] 使用今日推荐缓存", user.getUsername()); + List movieIds = parseMovieIds(cached.getMovieIds()); + recommendList = movieRepository.findByIds(movieIds); + } else { + // 无缓存:随机查询3部电影并缓存 + log.info("用户 [{}] 生成新的今日推荐", user.getUsername()); + List> randomMovies = movieRepository.findRandomMovies(user.getId(), 3); + + if (randomMovies.isEmpty()) { + recommendList = new ArrayList<>(); + } else { + // 提取电影ID并缓存 + List movieIds = randomMovies.stream() + .map(m -> ((Number) m.get("id")).longValue()) + .collect(Collectors.toList()); + String movieIdsStr = movieIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + dailyRecommendRepository.saveOrUpdateTodayRecommend(user.getId(), movieIdsStr); + + recommendList = randomMovies; + } + } Map result = new HashMap<>(); result.put("list", recommendList); @@ -271,6 +332,21 @@ public class MovieController { } } + /** + * 解析逗号分隔的电影ID字符串 + */ + private List parseMovieIds(String movieIdsStr) { + List ids = new ArrayList<>(); + for (String id : movieIdsStr.split(",")) { + try { + ids.add(Long.parseLong(id.trim())); + } catch (NumberFormatException e) { + // 忽略无效ID + } + } + return ids; + } + /** * 获取统计信息 * GET /api/movies/stats @@ -307,6 +383,7 @@ public class MovieController { map.put("scrapeType", movie.getScrapeType()); map.put("rating", movie.getRating()); map.put("posterUrl", movie.getPosterUrl()); + map.put("backdropUrl", movie.getBackdropUrl()); map.put("tags", movie.getTags()); if (movie.getUpdateTime() != null) { diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java index 1c295d7..ec1b50c 100644 --- a/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/controller/TmdbScraperController.java @@ -15,6 +15,7 @@ import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -212,9 +213,16 @@ public class TmdbScraperController { pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, pageSize)); - Map logData = scraperLogRepository.queryRecent(pageNo, pageSize); + List> logs = scraperLogRepository.queryRecent(pageNo, pageSize); - return Result.success("查询成功", logData); + // 构建分页响应 + Map result = new HashMap<>(); + result.put("list", logs); + result.put("pageNo", pageNo); + result.put("pageSize", pageSize); + result.put("total", logs.size()); + + return Result.success("查询成功", result); } /** diff --git a/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java b/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java index 718cfb4..7f6d96a 100644 --- a/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java +++ b/wangpan-tmdb-service/src/main/java/com/wangpan/service/TmdbScraperService.java @@ -53,8 +53,8 @@ public class TmdbScraperService { // ==================== TMDB API 配置常量 ==================== - /** TMDB API v3 基础地址 */ - public static final String TMDB_API_BASE = "https://api.themoviedb.org/3"; + /** TMDB API 基础地址(通过 Cloudflare Worker 代理访问,解决国内网络无法直连的问题) */ + public static final String TMDB_API_BASE = "https://tmdb-proxy.naihun782.workers.dev/tmdb-proxy/3"; /** TMDB 图片服务器基础地址(原始尺寸) */ public static final String TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"; @@ -128,45 +128,57 @@ public class TmdbScraperService { // 检查电影标题是否为空 if (movie.getTitle() == null || movie.getTitle().isBlank()) { + // 更新刮削状态为失败 + movieRepository.updateScrapeStatus(movieId, "failed", "电影标题为空,无法刮削"); return errorResult("电影标题为空,无法刮削"); } // ========== 第2步:检查缓存有效性(7天内已刮削则跳过) ========== var existingCache = tmdbCacheRepository.findByMovieId(movieId); if (existingCache != null && !isCacheExpired(existingCache.getCacheTime().toString())) { + // 使用缓存数据,更新刮削状态为成功 + movieRepository.updateScrapeStatus(movieId, "success", null); return successResult("使用缓存数据(" + existingCache.getTitle() + ")", tmdbCacheRepository.toMap(existingCache)); } // ========== 第3步:获取用户的TMDB API Key ========== var user = userRepository.findById(userId); if (user == null) { + movieRepository.updateScrapeStatus(movieId, "failed", "用户不存在"); return errorResult("用户不存在"); } var encryptedKey = user.getTmdbApiKey(); if (encryptedKey == null || encryptedKey.isBlank()) { + movieRepository.updateScrapeStatus(movieId, "failed", "用户未配置TMDB API Key"); return errorResult("用户未配置TMDB API Key,请先设置"); } // 解密API Key var apiKey = EncryptUtil.decrypt(encryptedKey); + // 更新刮削状态为刮削中 + movieRepository.updateScrapeStatus(movieId, "scraping", null); + // ========== 第4步:向TMDB搜索电影 ========== LOGGER.info("[TmdbScraper] 开始搜索电影: " + movie.getTitle()); var searchResult = searchMovie(movie.getTitle(), apiKey); if (searchResult == null || !searchResult.has("results")) { + movieRepository.updateScrapeStatus(movieId, "failed", "TMDB搜索无结果"); return errorResult("TMDB搜索无结果: " + movie.getTitle()); } var results = searchResult.get("results"); if (results.isEmpty() || results.size() == 0) { + movieRepository.updateScrapeStatus(movieId, "failed", "TMDB未找到匹配的电影"); return errorResult("TMDB未找到匹配的电影: " + movie.getTitle()); } // ========== 第5步:匹配最佳结果 ========== var bestMatch = findBestMatch(movie.getTitle(), results); if (bestMatch == null) { + movieRepository.updateScrapeStatus(movieId, "failed", "未找到匹配度足够高的结果"); return errorResult("未找到匹配度足够高的结果: " + movie.getTitle()); } @@ -177,28 +189,40 @@ public class TmdbScraperService { rateLimitDelay(); // 遵守速率限制 var detailResult = getMovieDetail(tmdbId, apiKey); - // ========== 第7步:下载海报图片 ========== + // ========== 第7步:下载海报图片(竖版) ========== String localPosterPath = null; if (detailResult != null && detailResult.has("poster_path") && !detailResult.get("poster_path").isNull() && !detailResult.get("poster_path").asText().isBlank()) { var posterPath = detailResult.get("poster_path").asText(); - localPosterPath = downloadPoster(posterPath, movieId, tmdbId); + localPosterPath = downloadPoster(posterPath, movieId, tmdbId, "poster"); + } + + // ========== 第7.5步:下载背景图(横版) ========== + String localBackdropPath = null; + if (detailResult != null && detailResult.has("backdrop_path") + && !detailResult.get("backdrop_path").isNull() + && !detailResult.get("backdrop_path").asText().isBlank()) { + + var backdropPath = detailResult.get("backdrop_path").asText(); + localBackdropPath = downloadPoster(backdropPath, movieId, tmdbId, "backdrop"); } // ========== 第8步:构建缓存数据并保存 ========== - var cacheData = buildCacheData(detailResult != null ? detailResult : bestMatch, movieId, userId, localPosterPath); + var cacheData = buildCacheData(detailResult != null ? detailResult : bestMatch, movieId, userId, localPosterPath, localBackdropPath); tmdbCacheRepository.saveOrUpdate(cacheData); // ========== 第9步:更新wp_drive_movie表的相关字段 ========== - updateMovieFromScrape(movie, cacheData, localPosterPath); + updateMovieFromScrape(movie, cacheData, localPosterPath, localBackdropPath); LOGGER.info("[TmdbScraper] 刮削完成: " + cacheData.get("title")); return successResult("刮削成功", cacheData); } catch (Exception e) { LOGGER.log(Level.SEVERE, "[TmdbScraper] 刮削异常: movieId=" + movieId, e); + // 更新刮削状态为失败 + movieRepository.updateScrapeStatus(movieId, "failed", "刮削异常: " + e.getMessage()); return errorResult("刮削异常: " + e.getMessage()); } } @@ -334,22 +358,23 @@ public class TmdbScraperService { /** * 从TMDB下载海报图片并上传到MinIO对象存储 * - * 存储路径规则:{POSTER_PREFIX}/{movieId}_{tmdbId}.jpg - * 例如:posters/123_45678.jpg + * 存储路径规则:{POSTER_PREFIX}/{imageType}_{movieId}_{tmdbId}.jpg + * 例如:posters/poster_123_45678.jpg 或 posters/backdrop_123_45678.jpg * * 上传成功后,返回的 URL 会使用 EncryptUtil 加密后存储到数据库。 * - * @param posterPath TMDB返回的poster_path(如 /xxx.jpg) + * @param imagePath TMDB返回的图片路径(如 /xxx.jpg) * @param movieId 本地电影ID * @param tmdbId TMDB电影ID + * @param imageType 图片类型:poster(竖版海报)或 backdrop(横版背景图) * @return MinIO 访问 URL(已加密),上传失败返回 null */ - private String downloadPoster(String posterPath, Long movieId, Long tmdbId) { + private String downloadPoster(String imagePath, Long movieId, Long tmdbId, String imageType) { // 构建完整的TMDB图片URL - var imageUrl = TMDB_IMAGE_BASE + posterPath; + var imageUrl = TMDB_IMAGE_BASE + imagePath; - // 构建 MinIO 存储路径 - var objectName = POSTER_PREFIX + "/" + movieId + "_" + tmdbId + ".jpg"; + // 构建 MinIO 存储路径,区分竖版和横版 + var objectName = POSTER_PREFIX + "/" + imageType + "_" + movieId + "_" + tmdbId + ".jpg"; try { // 下载图片到内存 @@ -484,7 +509,7 @@ public class TmdbScraperService { /** * 构建缓存数据Map(从TMDB响应提取关键字段) */ - private Map buildCacheData(JsonNode tmdbData, Long movieId, Long userId, String localPosterPath) { + private Map buildCacheData(JsonNode tmdbData, Long movieId, Long userId, String localPosterPath, String localBackdropPath) { // 提取基本信息 var data = new java.util.LinkedHashMap(); @@ -499,7 +524,8 @@ public class TmdbScraperService { ? tmdbData.get("poster_path").asText() : ""); data.put("backdropPath", tmdbData.has("backdrop_path") && !tmdbData.get("backdrop_path").isNull() ? tmdbData.get("backdrop_path").asText() : ""); - data.put("localPosterUrl", localPosterPath); // 本地路径 + data.put("localPosterUrl", localPosterPath); // 本地竖版海报路径 + data.put("localBackdropUrl", localBackdropPath); // 本地横版背景图路径 // 评分信息(TMDB 10分制转换为百分制) double voteAvg = tmdbData.has("vote_average") ? tmdbData.get("vote_average").asDouble() : 0; @@ -569,7 +595,7 @@ public class TmdbScraperService { /** * 根据刮削结果更新wp_drive_movie表的相关字段 */ - private void updateMovieFromScrape(Movie movie, Map cacheData, String encryptedPosterUrl) { + private void updateMovieFromScrape(Movie movie, Map cacheData, String encryptedPosterUrl, String encryptedBackdropUrl) { // 更新评分(如果之前没有或更低) Integer tmdbRating = cacheData.get("tmdbRating") != null ? ((Number) cacheData.get("tmdbRating")).intValue() : null; @@ -578,7 +604,7 @@ public class TmdbScraperService { movieRepository.updateRating(movie.getId(), tmdbRating); } - // 更新海报URL(优先使用 MinIO 存储的加密 URL) + // 更新海报URL(竖版,优先使用 MinIO 存储的加密 URL) if (encryptedPosterUrl != null) { // 解密后存储到 wp_drive_movie 表(前端可直接使用) try { @@ -596,6 +622,23 @@ public class TmdbScraperService { movieRepository.updatePosterUrl(movie.getId(), TMDB_IMAGE_W500 + cacheData.get("posterPath")); } + // 更新背景图URL(横版,优先使用 MinIO 存储的加密 URL) + if (encryptedBackdropUrl != null) { + try { + var decryptedUrl = EncryptUtil.decrypt(encryptedBackdropUrl); + movieRepository.updateBackdropUrl(movie.getId(), decryptedUrl); + } catch (Exception e) { + // 解密失败时使用 TMDB 远程地址作为降级方案 + System.err.println("[TmdbScraper] MinIO URL 解密失败,使用 TMDB 远程地址: " + e.getMessage()); + if (cacheData.get("backdropPath") != null && !cacheData.get("backdropPath").toString().isBlank()) { + movieRepository.updateBackdropUrl(movie.getId(), TMDB_IMAGE_W500 + cacheData.get("backdropPath")); + } + } + } else if (cacheData.get("backdropPath") != null && !cacheData.get("backdropPath").toString().isBlank()) { + // MinIO 上传失败时使用 TMDB 远程地址 + movieRepository.updateBackdropUrl(movie.getId(), TMDB_IMAGE_W500 + cacheData.get("backdropPath")); + } + // 更新标签(添加TMDB类型信息) var genres = cacheData.get("genres") != null ? cacheData.get("genres").toString() : ""; if (!genres.isBlank()) { @@ -609,6 +652,9 @@ public class TmdbScraperService { // 更新刮削类型标记 movieRepository.updateScrapeType(movie.getId(), "auto"); + + // 更新刮削状态为成功 + movieRepository.updateScrapeStatus(movie.getId(), "success", null); } // ==================== 缓存过期判断 ==================== diff --git a/wangpan-user-service/src/main/java/com/wangpan/controller/ProfileController.java b/wangpan-user-service/src/main/java/com/wangpan/controller/ProfileController.java new file mode 100644 index 0000000..1efc3c4 --- /dev/null +++ b/wangpan-user-service/src/main/java/com/wangpan/controller/ProfileController.java @@ -0,0 +1,226 @@ +package com.wangpan.controller; + +import com.wangpan.common.Result; +import com.wangpan.common.dal.repository.UserRepository; +import com.wangpan.common.entity.User; +import com.wangpan.common.interceptor.AuthInterceptor; +import com.wangpan.common.util.MinioUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +import java.io.ByteArrayInputStream; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 用户个人资料控制器 + * + * 功能:允许每个登录用户修改自己的个人资料 + * - GET /api/profile 获取当前用户个人资料 + * - PUT /api/profile/username 修改用户名 + * - PUT /api/profile/avatar 修改头像(Base64图片上传到MinIO) + * + * 与 UserController 的区别: + * - UserController 是管理员专用的用户管理接口(CRUD所有用户) + * - ProfileController 是普通用户修改自己资料的接口 + */ +@RestController +public class ProfileController { + + private static final Logger log = LoggerFactory.getLogger(ProfileController.class); + + private final UserRepository userRepository; + + /** + * 构造函数注入(Spring 自动装配) + */ + public ProfileController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 从请求属性中获取当前登录用户(由 AuthInterceptor 注入) + */ + private User getCurrentUser(HttpServletRequest request) { + return (User) request.getAttribute(AuthInterceptor.CURRENT_USER_ATTR); + } + + // ==================== API 接口 ==================== + + /** + * 获取当前用户个人资料 + * GET /api/profile + * + * 返回当前登录用户的基本信息(不含密码等敏感字段) + */ + @GetMapping("/api/profile") + public Result getProfile(HttpServletRequest request) { + User currentUser = getCurrentUser(request); + log.info("用户 [{}] 查询个人资料", currentUser.getUsername()); + + try { + // 从数据库重新查询最新数据 + User user = userRepository.findById(currentUser.getId()); + if (user == null) { + return Result.error(404, "用户不存在"); + } + + // 构建返回数据(排除敏感字段) + Map data = new LinkedHashMap<>(); + data.put("id", user.getId()); + data.put("username", user.getUsername()); + data.put("email", user.getEmail()); + data.put("phone", user.getPhone()); + data.put("realName", user.getRealName()); + data.put("avatarUrl", user.getAvatarUrl()); + data.put("userType", user.getUserType()); + data.put("status", user.getStatus()); + data.put("createTime", user.getCreateTime() != null ? user.getCreateTime().toString() : null); + + return Result.success("查询成功", data); + } catch (Exception e) { + log.error("查询个人资料失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } + + /** + * 修改用户名 + * PUT /api/profile/username + * + * 请求体:{"username": "新用户名"} + * + * 校验规则: + * 1. 用户名不能为空 + * 2. 用户名不能与当前用户名相同 + * 3. 用户名不能被其他用户占用 + */ + @PutMapping("/api/profile/username") + public Result updateUsername(HttpServletRequest request, + @RequestBody Map body) { + + User currentUser = getCurrentUser(request); + log.info("用户 [{}] 请求修改用户名", currentUser.getUsername()); + + try { + // 1. 参数校验 + String newUsername = (String) body.get("username"); + if (newUsername == null || newUsername.isBlank()) { + return Result.error(400, "用户名不能为空"); + } + newUsername = newUsername.trim(); + + // 2. 检查是否与当前用户名相同 + if (newUsername.equals(currentUser.getUsername())) { + return Result.error(400, "新用户名与当前用户名相同"); + } + + // 3. 检查用户名是否已被其他用户占用 + User existingUser = userRepository.findByUsername(newUsername); + if (existingUser != null) { + return Result.error(409, "用户名已被占用"); + } + + // 4. 执行更新 + int rows = userRepository.updateUsername(currentUser.getId(), newUsername); + if (rows > 0) { + log.info("用户 [{}] 用户名修改为 [{}]", currentUser.getUsername(), newUsername); + return Result.success("用户名修改成功", null); + } else { + return Result.error(400, "用户名修改失败"); + } + + } catch (Exception e) { + log.error("修改用户名失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } + + /** + * 修改头像 + * PUT /api/profile/avatar + * + * 请求体:{"avatarData": "data:image/png;base64,iVBOR..."} + * + * 流程: + * 1. 接收 Base64 编码的图片数据 + * 2. 解析 MIME 类型和 Base64 内容 + * 3. 上传到 MinIO(路径:avatar/{userId}_{timestamp}.{ext}) + * 4. 更新数据库中的 avatar_url 字段 + */ + @PutMapping("/api/profile/avatar") + public Result updateAvatar(HttpServletRequest request, + @RequestBody Map body) { + + User currentUser = getCurrentUser(request); + log.info("用户 [{}] 请求修改头像", currentUser.getUsername()); + + try { + // 1. 参数校验 + String avatarData = (String) body.get("avatarData"); + if (avatarData == null || avatarData.isBlank()) { + return Result.error(400, "头像数据不能为空"); + } + + // 2. 解析 Base64 图片数据 + String base64Content = avatarData; + String mimeType = "image/png"; + + if (avatarData.startsWith("data:")) { + int semicolonIndex = avatarData.indexOf(';'); + int commaIndex = avatarData.indexOf(','); + if (semicolonIndex > 0 && commaIndex > semicolonIndex) { + mimeType = avatarData.substring(5, semicolonIndex); + base64Content = avatarData.substring(commaIndex + 1); + } + } + + // 3. 解码 Base64 + byte[] imageBytes = Base64.getDecoder().decode(base64Content); + + // 4. 确定文件扩展名 + String extension = switch (mimeType) { + case "image/jpeg", "image/jpg" -> ".jpg"; + case "image/gif" -> ".gif"; + case "image/webp" -> ".webp"; + default -> ".png"; + }; + + // 5. 生成 MinIO 对象名(路径:avatar/{userId}_{timestamp}.{ext}) + String objectName = "avatar/" + currentUser.getId() + "_" + + System.currentTimeMillis() + extension; + + // 6. 上传到 MinIO + try (var inputStream = new ByteArrayInputStream(imageBytes)) { + String uploadedName = MinioUtil.upload(inputStream, objectName, mimeType); + if (uploadedName == null) { + return Result.error(500, "头像上传失败"); + } + } + + // 7. 获取文件访问 URL + String avatarUrl = MinioUtil.getFileUrl(objectName); + + // 8. 更新数据库 + int rows = userRepository.updateAvatarUrl(currentUser.getId(), avatarUrl); + if (rows > 0) { + log.info("用户 [{}] 头像更新成功: {}", currentUser.getUsername(), avatarUrl); + Map data = new LinkedHashMap<>(); + data.put("avatarUrl", avatarUrl); + return Result.success("头像更新成功", data); + } else { + return Result.error(400, "头像更新失败"); + } + + } catch (IllegalArgumentException e) { + log.error("Base64解码失败", e); + return Result.error(400, "图片数据格式错误"); + } catch (Exception e) { + log.error("修改头像失败", e); + return Result.error("系统繁忙,请稍后重试"); + } + } +} diff --git a/wangpan-user-service/src/main/resources/application.yml b/wangpan-user-service/src/main/resources/application.yml index 76a2ab8..2e9508e 100644 --- a/wangpan-user-service/src/main/resources/application.yml +++ b/wangpan-user-service/src/main/resources/application.yml @@ -38,3 +38,10 @@ mybatis-plus: logic-delete-field: isDeleted logic-delete-value: 1 logic-not-delete-value: 0 + +# MinIO 对象存储配置(头像上传用) +minio: + endpoint: ENC(nDpSqjv62odavtIjInqIzcjmWua20QGTKKfKsJlxgCTkWGWj/JJsIAEfsLIzKjpbeAMl/mxVJuPQv8o58b46gQ==) + accessKey: ENC(YM+d/P5KM/QqGFcbbxcZlM+jH0dTgaYlGiYOJ04dIyBX+Z4fu+h05tdylTy0SroK) + secretKey: ENC(BKqQFlceb8mGmAMWbmXFVV1US1Ib+24VApZCci0xyz+xAqL9MQetHJKsrWW1Vib2) + bucketName: wangpan