feat: 增强TMDB刮削服务并新增用户资料功能

- TMDB刮削服务增强: 添加刮削状态跟踪(scraping/success/failed), 支持横版背景图下载和存储, 优化缓存策略和错误处理

- 新增每日推荐功能: 添加DailyRecommendDO数据对象及Mapper和Repository

- 新增用户资料管理: 添加ProfileController用户资料控制器, 支持头像上传(MinIO配置)

- 配置优化: 移除默认加密密码使用环境变量, 调整日志级别为INFO, 移除SQL日志输出

- 数据库增强: Movie和DriveMovie实体添加背景图、刮削状态等字段, 更新相关Mapper和Repository方法
This commit is contained in:
liRQ 2026-06-24 17:48:52 +08:00
parent fba79655c4
commit ab4ab5b797
26 changed files with 832 additions and 51 deletions

View File

@ -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; }
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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<DailyRecommendDO> {
/** 建表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);
}

View File

@ -38,8 +38,11 @@ public interface DriveMovieMapper extends BaseMapper<DriveMovieDO> {
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 '创建时间',

View File

@ -48,6 +48,12 @@ public interface MovieMapper extends BaseMapper<MovieDO> {
@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<MovieDO> {
*/
@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);
}

View File

@ -49,8 +49,10 @@ public interface TmdbScraperLogMapper extends BaseMapper<TmdbScraperLogDO> {
/**
* 分页查询最近的执行记录
* 复杂SQL在XML中实现
* @param offset 偏移量从0开始
* @param pageSize 每页条数
*/
Map<String, Object> queryRecent(@Param("pageNo") int pageNo, @Param("pageSize") int pageSize);
List<Map<String, Object>> queryRecent(@Param("offset") int offset, @Param("pageSize") int pageSize);
/**
* 查询最近的N条执行记录

View File

@ -98,4 +98,16 @@ public interface UserMapper extends BaseMapper<UserDO> {
*/
@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);
}

View File

@ -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<DailyRecommendMapper, DailyRecommendDO> {
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);
}
}
}

View File

@ -91,6 +91,62 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
.eq(DriveMovieDO::getStatus, "active"));
}
/**
* 查询用户所有电影的文件夹ID列表去重分页
* 使用 MyBatis-Plus LambdaQueryWrapper 构建查询
*/
public List<Long> queryFolderIdsByUser(Long userId, int pageNo, int pageSize) {
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.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<Long>();
for (var movieDO : list) {
if (movieDO.getFolderId() != null) {
folderIds.add(movieDO.getFolderId());
}
}
return folderIds;
}
/**
* 统计用户所有电影的不同文件夹数量
*/
public int countFoldersByUser(Long userId) {
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.select(DriveMovieDO::getFolderId)
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active")
.groupBy(DriveMovieDO::getFolderId);
return (int) count(wrapper);
}
/**
* 查询指定文件夹下的电影列表限制数量
* 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询
*/
public List<Map<String, Object>> queryByFolder(Long userId, Long folderId, int limit) {
var page = new Page<DriveMovieDO>(1, limit);
var wrapper = new LambdaQueryWrapper<DriveMovieDO>()
.eq(DriveMovieDO::getUserId, userId)
.eq(DriveMovieDO::getStatus, "active")
.eq(DriveMovieDO::getFolderId, folderId)
.orderByDesc(DriveMovieDO::getCreateTime);
var result = page(page, wrapper);
var list = new ArrayList<Map<String, Object>>();
for (var movieDO : result.getRecords()) {
list.add(toMap(movieDO));
}
return list;
}
/**
* 查询用户所有网盘下的电影列表分页
* 使用 MyBatis-Plus LambdaQueryWrapper 构建分页查询
@ -249,8 +305,11 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
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());
return movieDO;
@ -279,8 +338,11 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
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());
@ -307,9 +369,12 @@ public class DriveMovieRepository extends ServiceImpl<DriveMovieMapper, DriveMov
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("tags", movieDO.getTags());
map.put("posterUrl", movieDO.getPosterUrl());
map.put("backdropUrl", movieDO.getBackdropUrl());
map.put("minioUrl", movieDO.getMinioUrl());
map.put("createTime", movieDO.getCreateTime() != null ? movieDO.getCreateTime().toString() : null);
map.put("updateTime", movieDO.getUpdateTime() != null ? movieDO.getUpdateTime().toString() : null);

View File

@ -11,6 +11,7 @@ import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 电影 Repository Dao
@ -69,6 +70,27 @@ public class MovieRepository extends ServiceImpl<MovieMapper, MovieDO> {
return movieDO != null ? toMovie(movieDO) : null;
}
/**
* 根据电影ID列表批量查询电影用于每日推荐缓存回查
*
* @param movieIds 电影ID列表
* @return 电影Map列表保持传入ID的顺序
*/
public List<Map<String, Object>> findByIds(List<Long> movieIds) {
if (movieIds == null || movieIds.isEmpty()) {
return new ArrayList<>();
}
var wrapper = new LambdaQueryWrapper<MovieDO>()
.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<MovieMapper, MovieDO> {
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<MovieMapper, MovieDO> {
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<MovieMapper, MovieDO> {
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<MovieMapper, MovieDO> {
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<MovieMapper, MovieDO> {
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);

View File

@ -34,9 +34,12 @@ public class TmdbScraperLogRepository extends ServiceImpl<TmdbScraperLogMapper,
/**
* 查询最近的执行记录分页
* 委托给 TmdbScraperLogMapper 中定义的 XML 查询
* @param pageNo 页码从1开始
* @param pageSize 每页条数
*/
public Map<String, Object> queryRecent(int pageNo, int pageSize) {
return baseMapper.queryRecent(pageNo, pageSize);
public List<Map<String, Object>> queryRecent(int pageNo, int pageSize) {
int offset = (pageNo - 1) * pageSize;
return baseMapper.queryRecent(offset, pageSize);
}
/**

View File

@ -203,6 +203,22 @@ public class UserRepository extends ServiceImpl<UserMapper, UserDO> {
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 转换 ====================
/**

View File

@ -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; }

View File

@ -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; }

View File

@ -21,7 +21,7 @@
<!-- 分页查询最近的执行记录 -->
<select id="queryRecent" resultType="java.util.Map">
SELECT * FROM wp_tmdb_scraper_log ORDER BY start_time DESC
LIMIT #{pageSize} OFFSET #{pageNo}
LIMIT #{pageSize} OFFSET #{offset}
</select>
<!-- 查询最近的N条执行记录 -->

View File

@ -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 图片 URLMinIO 存储)'");

View File

@ -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());

View File

@ -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 信息

View File

@ -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;
}

View File

@ -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] 表创建完成");
}
}
}

View File

@ -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<String, Object> resultData = movieRepository.queryByPage(
user.getId(), keyword, status, category, scrapeType,
sortBy, sortOrder, pageNo, pageSize
);
// 1. 查询用户的文件夹ID列表分页
List<Long> folderIds = driveMovieRepository.queryFolderIdsByUser(user.getId(), pageNo, pageSize);
int totalFolders = driveMovieRepository.countFoldersByUser(user.getId());
// 2. 对每个文件夹查询最多 10 条电影数据
List<Map<String, Object>> folderGroups = new ArrayList<>();
for (Long folderId : folderIds) {
List<Map<String, Object>> movies = driveMovieRepository.queryByFolder(user.getId(), folderId, 10);
Map<String, Object> group = new HashMap<>();
group.put("folderId", folderId);
group.put("movies", movies);
group.put("movieCount", movies.size());
folderGroups.add(group);
}
// 3. 构建分页结果
Map<String, Object> 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<Map<String, Object>> recommendList = movieRepository.findRandomMovies(user.getId(), 3);
List<Map<String, Object>> recommendList;
// 第1步查询当日推荐缓存
DailyRecommendDO cached = dailyRecommendRepository.findTodayRecommend(user.getId());
if (cached != null && cached.getMovieIds() != null && !cached.getMovieIds().isBlank()) {
// 有缓存根据缓存的电影ID查询电影详情
log.info("用户 [{}] 使用今日推荐缓存", user.getUsername());
List<Long> movieIds = parseMovieIds(cached.getMovieIds());
recommendList = movieRepository.findByIds(movieIds);
} else {
// 无缓存随机查询3部电影并缓存
log.info("用户 [{}] 生成新的今日推荐", user.getUsername());
List<Map<String, Object>> randomMovies = movieRepository.findRandomMovies(user.getId(), 3);
if (randomMovies.isEmpty()) {
recommendList = new ArrayList<>();
} else {
// 提取电影ID并缓存
List<Long> 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<String, Object> result = new HashMap<>();
result.put("list", recommendList);
@ -271,6 +332,21 @@ public class MovieController {
}
}
/**
* 解析逗号分隔的电影ID字符串
*/
private List<Long> parseMovieIds(String movieIdsStr) {
List<Long> 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) {

View File

@ -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<String, Object> logData = scraperLogRepository.queryRecent(pageNo, pageSize);
List<Map<String, Object>> logs = scraperLogRepository.queryRecent(pageNo, pageSize);
return Result.success("查询成功", logData);
// 构建分页响应
Map<String, Object> result = new HashMap<>();
result.put("list", logs);
result.put("pageNo", pageNo);
result.put("pageSize", pageSize);
result.put("total", logs.size());
return Result.success("查询成功", result);
}
/**

View File

@ -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<String, Object> buildCacheData(JsonNode tmdbData, Long movieId, Long userId, String localPosterPath) {
private Map<String, Object> buildCacheData(JsonNode tmdbData, Long movieId, Long userId, String localPosterPath, String localBackdropPath) {
// 提取基本信息
var data = new java.util.LinkedHashMap<String, Object>();
@ -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<String, Object> cacheData, String encryptedPosterUrl) {
private void updateMovieFromScrape(Movie movie, Map<String, Object> 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);
}
// ==================== 缓存过期判断 ====================

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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("系统繁忙,请稍后重试");
}
}
}

View File

@ -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