refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载

为什么:
1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、
   模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*,
   这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts,
   数据库连接名上一轮已改名,保持不变。
2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、
   下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。
3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引,
   表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就
   按分区表定义,并验证脚本可重复执行。
This commit is contained in:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

No files matched your search

@@ -0,0 +1,33 @@
package com.fruitshorts.playback.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 离线下载策略。这些值同时通过 /app/downloads/policy 下发给客户端,
* 客户端不要自己写死,否则改策略要发版。
*/
@Data
@Component
@ConfigurationProperties(prefix = "fruitshorts.download")
public class DownloadProperties {
/** 单设备最多同时保有的下载授权数。 */
private int maxPerDevice = 20;
/** 下载授权有效期(天)。到期后客户端必须联网续期,否则不可离线播放。 */
private int licenseDays = 30;
/** 默认是否仅 Wi-Fi 下载,用户可在客户端改。 */
private boolean wifiOnlyDefault = true;
/** 允许下载的最高清晰度,AUTO 表示不限制。 */
private String maxQuality = "AUTO";
/** 预留:单设备下载占用上限(字节),0 表示不限制。 */
private long maxStorageBytes = 0L;
/** 过期授权清理任务(cron)。 */
private String expireCron = "0 25 3 * * *";
}
@@ -0,0 +1,38 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 离线下载授权:绑定用户 + 剧集 + 设备,有有效期,可吊销。 */
@Data
@TableName("download_license")
public class DownloadLicense {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long dramaId;
private Long episodeId;
private String deviceId;
/** 客户端离线播放时用它校验授权,不落库明文以外的信息 */
private String licenseKey;
/** ACTIVE/EXPIRED/REVOKED */
private String status;
private LocalDateTime grantedAt;
private LocalDateTime expireAt;
private LocalDateTime revokedAt;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,44 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 下载任务与断点续传进度。
* 分片由客户端直接从 CDN 拉取,服务端只记账,用于跨端展示与恢复下载。
*/
@Data
@TableName("download_task")
public class DownloadTask {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String deviceId;
private Long dramaId;
private Long episodeId;
private Long licenseId;
private String quality;
/** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */
private String status;
private Integer progressPercent;
private Long downloadedBytes;
private Long totalBytes;
private String errorMsg;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,38 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 播放票据的签发审计记录;真正的鉴权在签名 URL 上。 */
@Data
@TableName("playback_ticket")
public class PlaybackTicket {
@TableId(type = IdType.AUTO)
private Long id;
private String ticketNo;
private Long userId;
private Long dramaId;
private Long episodeId;
private String quality;
private String playUrl;
private LocalDateTime expireAt;
private String ip;
private String deviceId;
/** ISSUED/USED/REVOKED */
private String status;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,30 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 剧集收藏。 */
@Data
@TableName("user_favorite")
public class UserFavorite {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long dramaId;
private Integer sort;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,29 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 观看流水。只追加不修改,按 watched_at 月分区。
* 写入时机:一部剧第一次观看、以及切换剧集时各记一条(不是每 10 秒的进度上报都记),
* 否则单集会产生几十条噪音数据。
*/
@Data
@TableName("watch_history")
public class WatchHistory {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long dramaId;
private Long episodeId;
private Long positionMs;
private Long durationMs;
private String source;
private String deviceId;
private LocalDateTime watchedAt;
}
@@ -0,0 +1,36 @@
package com.fruitshorts.playback.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 每用户每剧一条续看进度,跨端同步的权威数据。 */
@Data
@TableName("watch_progress")
public class WatchProgress {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long dramaId;
private Long episodeId;
private Long positionMs;
private Long durationMs;
private Integer finished;
private Integer playCount;
private String deviceId;
private String source;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.DownloadLicense;
public interface DownloadLicenseMapper extends BaseMapper<DownloadLicense> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.DownloadTask;
public interface DownloadTaskMapper extends BaseMapper<DownloadTask> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
public interface PlaybackTicketMapper extends BaseMapper<PlaybackTicket> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.UserFavorite;
public interface UserFavoriteMapper extends BaseMapper<UserFavorite> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.WatchHistory;
public interface WatchHistoryMapper extends BaseMapper<WatchHistory> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.WatchProgress;
public interface WatchProgressMapper extends BaseMapper<WatchProgress> {
}
@@ -0,0 +1,17 @@
package com.fruitshorts.playback.service;
import org.springframework.stereotype.Component;
/**
* 默认实现:一律视为未解锁。第一阶段所有内容都是免费集,
* 付费链路(订单 → 权益)落地后由交易模块提供一个 {@code @Primary} 实现覆盖它,
* 播放链路不需要改代码。
*/
@Component
public class DefaultEntitlementPort implements EntitlementPort {
@Override
public boolean isUnlocked(Long userId, Long dramaId, Long episodeId) {
return false;
}
}
@@ -0,0 +1,483 @@
package com.fruitshorts.playback.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.playback.config.DownloadProperties;
import com.fruitshorts.playback.dal.entity.DownloadLicense;
import com.fruitshorts.playback.dal.entity.DownloadTask;
import com.fruitshorts.playback.dal.mapper.DownloadLicenseMapper;
import com.fruitshorts.playback.dal.mapper.DownloadTaskMapper;
import com.fruitshorts.playback.vo.DownloadGrantReqVO;
import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadPolicyRespVO;
import com.fruitshorts.playback.vo.DownloadProgressReqVO;
import com.fruitshorts.playback.vo.DownloadQueryReqVO;
import com.fruitshorts.playback.vo.DownloadSettingReqVO;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 离线下载。
*
* <p>职责划分:服务端负责**授权**(谁、哪一集、哪台设备、有效期多久),
* 分片下载由客户端直接向 CDN 拉取,服务端只在 {@code download_task} 里记账,
* 用于进度展示、断点续传恢复与配额统计。这样 1 万并发下载不会打到应用服务器。
*
* <p>授权与地址分离:授权有效期 {@code licenseDays} 天,播放地址是短时效签名,
* 客户端恢复下载时重新取一次地址即可,不需要重新走授权流程。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DownloadService {
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String STATUS_REVOKED = "REVOKED";
public static final String TASK_QUEUED = "QUEUED";
public static final String TASK_DOWNLOADING = "DOWNLOADING";
public static final String TASK_COMPLETED = "COMPLETED";
public static final String TASK_FAILED = "FAILED";
public static final String TASK_CANCELED = "CANCELED";
private static final SecureRandom RANDOM = new SecureRandom();
private final DownloadLicenseMapper licenseMapper;
private final DownloadTaskMapper taskMapper;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final PlaybackSigner signer;
private final EntitlementPort entitlementPort;
private final AppUserMapper appUserMapper;
private final DownloadProperties properties;
// ---------------- 客户端 ----------------
/** 申请/续期下载授权。同一用户+剧集+设备重复申请是幂等的,只会刷新有效期。 */
@Transactional(rollbackFor = Exception.class)
public DownloadLicenseRespVO grant(Long userId, DownloadGrantReqVO req, String headerDeviceId) {
String deviceId = resolveDevice(req.getDeviceId(), headerDeviceId);
DramaEpisode episode = episodeService.getPublished(req.getEpisodeId());
Drama drama = dramaService.getPublished(episode.getDramaId());
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
if (!free && !entitlementPort.isUnlocked(userId, drama.getId(), episode.getId())) {
throw new ServiceException(ErrorCode.EPISODE_NOT_AVAILABLE, "该剧集需要先解锁才能下载");
}
String source = playableSource(episode);
if (!StringUtils.hasText(source)) {
throw new ServiceException(ErrorCode.EPISODE_NOT_AVAILABLE, "片源还没准备好,暂时无法下载");
}
LocalDateTime now = LocalDateTime.now();
DownloadLicense license = findLicense(userId, episode.getId(), deviceId);
if (license == null) {
long active = countActive(userId, deviceId);
if (active >= properties.getMaxPerDevice()) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"该设备最多保存 " + properties.getMaxPerDevice() + " 集,请先删除部分下载");
}
license = new DownloadLicense();
license.setUserId(userId);
license.setDramaId(drama.getId());
license.setEpisodeId(episode.getId());
license.setDeviceId(deviceId);
license.setLicenseKey(newLicenseKey());
license.setStatus(STATUS_ACTIVE);
license.setGrantedAt(now);
license.setExpireAt(now.plusDays(properties.getLicenseDays()));
licenseMapper.insert(license);
} else {
// 已吊销的重新激活、已过期的续期,都只延长有效期,授权 key 不变
license.setStatus(STATUS_ACTIVE);
license.setRevokedAt(null);
license.setExpireAt(now.plusDays(properties.getLicenseDays()));
licenseMapper.updateById(license);
}
DownloadTask task = findTask(userId, episode.getId(), deviceId);
if (task == null) {
task = newTask(userId, deviceId, drama.getId(), episode.getId(), license.getId(),
req.getQuality());
taskMapper.insert(task);
} else if (!TASK_COMPLETED.equals(task.getStatus())) {
task.setLicenseId(license.getId());
task.setStatus(TASK_QUEUED);
task.setErrorMsg("");
taskMapper.updateById(task);
}
DownloadLicenseRespVO vo = toResp(license, task, drama, episode);
vo.setPlayUrl(signer.sign(source));
return vo;
}
public List<DownloadLicenseRespVO> list(Long userId, String deviceId) {
List<DownloadLicense> licenses = licenseMapper.selectList(
Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getUserId, userId)
.eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId)
.orderByDesc(DownloadLicense::getGrantedAt));
if (licenses.isEmpty()) {
return List.of();
}
// 批量补剧名与任务状态,避免逐条查库
Map<Long, Drama> dramas = new HashMap<>();
for (Drama drama : dramaService.listByIds(licenses.stream()
.map(DownloadLicense::getDramaId).distinct().toList())) {
dramas.put(drama.getId(), drama);
}
Map<Long, DramaEpisode> episodes = new HashMap<>();
for (DramaEpisode episode : episodeService.listByIds(licenses.stream()
.map(DownloadLicense::getEpisodeId).distinct().toList())) {
episodes.put(episode.getId(), episode);
}
Map<String, DownloadTask> tasks = new HashMap<>();
for (DownloadTask task : taskMapper.selectList(Wrappers.<DownloadTask>lambdaQuery()
.eq(DownloadTask::getUserId, userId)
.in(DownloadTask::getEpisodeId, licenses.stream()
.map(DownloadLicense::getEpisodeId).distinct().toList()))) {
tasks.put(taskKey(task.getEpisodeId(), task.getDeviceId()), task);
}
List<DownloadLicenseRespVO> result = new ArrayList<>(licenses.size());
for (DownloadLicense license : licenses) {
DownloadTask task = tasks.get(taskKey(license.getEpisodeId(), license.getDeviceId()));
result.add(toResp(license, task, dramas.get(license.getDramaId()),
episodes.get(license.getEpisodeId())));
}
return result;
}
/** 重新取一次下载地址(断点续传用),不改变授权有效期。 */
public DownloadLicenseRespVO refreshUrl(Long userId, Long episodeId, String headerDeviceId) {
String deviceId = resolveDevice(null, headerDeviceId);
DownloadLicense license = findLicense(userId, episodeId, deviceId);
if (license == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "没有该剧集的下载授权");
}
if (STATUS_REVOKED.equals(license.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该下载授权已被吊销");
}
if (isExpired(license)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "下载授权已过期,请重新申请");
}
DramaEpisode episode = episodeService.getExisting(episodeId);
DownloadLicenseRespVO vo = toResp(license, findTask(userId, episodeId, deviceId),
dramaService.getExisting(license.getDramaId()), episode);
vo.setPlayUrl(signer.sign(playableSource(episode)));
return vo;
}
/** 上报下载进度,实现断点续传与跨端展示。 */
@Transactional(rollbackFor = Exception.class)
public DownloadLicenseRespVO reportProgress(Long userId, Long episodeId, DownloadProgressReqVO req,
String headerDeviceId) {
String deviceId = resolveDevice(req.getDeviceId(), headerDeviceId);
DownloadLicense license = findLicense(userId, episodeId, deviceId);
if (license == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "没有该剧集的下载授权");
}
DownloadTask task = findTask(userId, episodeId, deviceId);
if (task == null) {
task = newTask(userId, deviceId, license.getDramaId(), episodeId, license.getId(), null);
taskMapper.insert(task);
}
LocalDateTime now = LocalDateTime.now();
String status = StringUtils.hasText(req.getStatus()) ? req.getStatus().toUpperCase() : null;
if (status != null) {
if (TASK_DOWNLOADING.equals(status) && task.getStartedAt() == null) {
task.setStartedAt(now);
}
if (TASK_COMPLETED.equals(status)) {
task.setFinishedAt(now);
task.setProgressPercent(100);
}
task.setStatus(status);
}
if (req.getProgressPercent() != null) {
task.setProgressPercent(Math.max(0, Math.min(100, req.getProgressPercent())));
}
if (req.getDownloadedBytes() != null) {
task.setDownloadedBytes(req.getDownloadedBytes());
}
if (req.getTotalBytes() != null) {
task.setTotalBytes(req.getTotalBytes());
}
task.setErrorMsg(req.getErrorMsg() == null ? "" : req.getErrorMsg());
taskMapper.updateById(task);
DownloadLicenseRespVO vo = toResp(license, task, dramaService.getExisting(license.getDramaId()),
episodeService.getExisting(episodeId));
return vo;
}
@Transactional(rollbackFor = Exception.class)
public void revoke(Long userId, Long episodeId, String deviceId) {
DownloadLicense license = findLicense(userId, episodeId, deviceId);
if (license == null) {
return;
}
revokeLicense(license);
DownloadTask task = findTask(userId, episodeId, deviceId);
if (task != null) {
task.setStatus(TASK_CANCELED);
taskMapper.updateById(task);
}
}
@Transactional(rollbackFor = Exception.class)
public int clear(Long userId, String deviceId) {
List<DownloadLicense> licenses = licenseMapper.selectList(
Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getUserId, userId)
.eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId)
.eq(DownloadLicense::getStatus, STATUS_ACTIVE));
for (DownloadLicense license : licenses) {
revokeLicense(license);
}
return licenses.size();
}
public DownloadPolicyRespVO policy(Long userId, String deviceId) {
AppUser user = appUserMapper.selectById(userId);
DownloadPolicyRespVO vo = new DownloadPolicyRespVO();
vo.setMaxPerDevice(properties.getMaxPerDevice());
vo.setLicenseDays(properties.getLicenseDays());
vo.setMaxQuality(StringUtils.hasText(user == null ? null : user.getDownloadMaxQuality())
? user.getDownloadMaxQuality() : properties.getMaxQuality());
vo.setWifiOnly(user == null || user.getDownloadWifiOnly() == null
? properties.isWifiOnlyDefault() : user.getDownloadWifiOnly() == 1);
vo.setMaxStorageBytes(properties.getMaxStorageBytes());
int used = (int) countActive(userId, deviceId);
vo.setUsed(used);
vo.setRemaining(Math.max(0, properties.getMaxPerDevice() - used));
vo.setExpirePolicyText("下载授权有效期 " + properties.getLicenseDays()
+ " 天,到期后需联网续期;过期或已删除的下载不可离线播放。");
return vo;
}
@Transactional(rollbackFor = Exception.class)
public void updateSettings(Long userId, DownloadSettingReqVO req) {
AppUser user = appUserMapper.selectById(userId);
if (user == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在");
}
if (req.getWifiOnly() != null) {
user.setDownloadWifiOnly(req.getWifiOnly() ? 1 : 0);
}
if (StringUtils.hasText(req.getMaxQuality())) {
user.setDownloadMaxQuality(req.getMaxQuality().toUpperCase());
}
appUserMapper.updateById(user);
}
// ---------------- 定时清理 ----------------
/** 过期授权统一置为 EXPIRED,客户端联网后即可发现并清理本地文件。 */
@Scheduled(cron = "${fruitshorts.download.expire-cron:0 25 3 * * *}")
@Transactional(rollbackFor = Exception.class)
public int expireLicenses() {
int updated = licenseMapper.update(null, Wrappers.<DownloadLicense>lambdaUpdate()
.eq(DownloadLicense::getStatus, STATUS_ACTIVE)
.isNotNull(DownloadLicense::getExpireAt)
.lt(DownloadLicense::getExpireAt, LocalDateTime.now())
.set(DownloadLicense::getStatus, STATUS_EXPIRED));
if (updated > 0) {
log.info("下载授权过期清理:{} 条", updated);
}
return updated;
}
// ---------------- 后台 ----------------
public PageResult<DownloadLicenseRespVO> page(DownloadQueryReqVO req) {
Page<DownloadLicense> page = new Page<>(
req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
IPage<DownloadLicense> result = licenseMapper.selectPage(page,
Wrappers.<DownloadLicense>lambdaQuery()
.eq(req.getUserId() != null, DownloadLicense::getUserId, req.getUserId())
.eq(StringUtils.hasText(req.getStatus()), DownloadLicense::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getDeviceId()), DownloadLicense::getDeviceId,
req.getDeviceId())
.orderByDesc(DownloadLicense::getId));
List<DownloadLicense> rows = result.getRecords();
if (rows.isEmpty()) {
return new PageResult<>(0, List.of());
}
Map<Long, Drama> dramas = new HashMap<>();
for (Drama drama : dramaService.listByIds(
rows.stream().map(DownloadLicense::getDramaId).distinct().toList())) {
dramas.put(drama.getId(), drama);
}
Map<Long, DramaEpisode> episodes = new HashMap<>();
for (DramaEpisode episode : episodeService.listByIds(
rows.stream().map(DownloadLicense::getEpisodeId).distinct().toList())) {
episodes.put(episode.getId(), episode);
}
List<DownloadLicenseRespVO> list = new ArrayList<>(rows.size());
for (DownloadLicense license : rows) {
DownloadLicenseRespVO vo = toResp(license,
findTask(license.getUserId(), license.getEpisodeId(), license.getDeviceId()),
dramas.get(license.getDramaId()), episodes.get(license.getEpisodeId()));
list.add(vo);
}
return new PageResult<>(result.getTotal(), list);
}
@Transactional(rollbackFor = Exception.class)
public void adminRevoke(Long licenseId) {
DownloadLicense license = licenseMapper.selectById(licenseId);
if (license == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "下载授权不存在");
}
revokeLicense(license);
}
/** 后台看板与排查用:授权与任务的分布。 */
public Map<String, Object> stats() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("active", licenseMapper.selectCount(Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getStatus, STATUS_ACTIVE)));
data.put("expired", licenseMapper.selectCount(Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getStatus, STATUS_EXPIRED)));
data.put("revoked", licenseMapper.selectCount(Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getStatus, STATUS_REVOKED)));
data.put("completedTasks", taskMapper.selectCount(Wrappers.<DownloadTask>lambdaQuery()
.eq(DownloadTask::getStatus, TASK_COMPLETED)));
data.put("failedTasks", taskMapper.selectCount(Wrappers.<DownloadTask>lambdaQuery()
.eq(DownloadTask::getStatus, TASK_FAILED)));
return data;
}
// ---------------- 内部 ----------------
private void revokeLicense(DownloadLicense license) {
license.setStatus(STATUS_REVOKED);
license.setRevokedAt(LocalDateTime.now());
licenseMapper.updateById(license);
}
private boolean isExpired(DownloadLicense license) {
return license.getExpireAt() != null && license.getExpireAt().isBefore(LocalDateTime.now());
}
private long countActive(Long userId, String deviceId) {
Long count = licenseMapper.selectCount(Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getUserId, userId)
.eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId)
.eq(DownloadLicense::getStatus, STATUS_ACTIVE));
return count == null ? 0L : count;
}
private DownloadLicense findLicense(Long userId, Long episodeId, String deviceId) {
return licenseMapper.selectOne(Wrappers.<DownloadLicense>lambdaQuery()
.eq(DownloadLicense::getUserId, userId)
.eq(DownloadLicense::getEpisodeId, episodeId)
.eq(DownloadLicense::getDeviceId, deviceId)
.last("limit 1"));
}
private DownloadTask findTask(Long userId, Long episodeId, String deviceId) {
return taskMapper.selectOne(Wrappers.<DownloadTask>lambdaQuery()
.eq(DownloadTask::getUserId, userId)
.eq(DownloadTask::getEpisodeId, episodeId)
.eq(DownloadTask::getDeviceId, deviceId)
.last("limit 1"));
}
private DownloadTask newTask(Long userId, String deviceId, Long dramaId, Long episodeId,
Long licenseId, String quality) {
DownloadTask task = new DownloadTask();
task.setUserId(userId);
task.setDeviceId(deviceId);
task.setDramaId(dramaId);
task.setEpisodeId(episodeId);
task.setLicenseId(licenseId == null ? 0L : licenseId);
task.setQuality(StringUtils.hasText(quality) ? quality.toUpperCase() : "AUTO");
task.setStatus(TASK_QUEUED);
task.setProgressPercent(0);
task.setDownloadedBytes(0L);
task.setTotalBytes(0L);
task.setErrorMsg("");
return task;
}
private DownloadLicenseRespVO toResp(DownloadLicense license, DownloadTask task,
Drama drama, DramaEpisode episode) {
DownloadLicenseRespVO vo = new DownloadLicenseRespVO();
vo.setLicenseId(license.getId());
vo.setDramaId(license.getDramaId());
vo.setEpisodeId(license.getEpisodeId());
vo.setDeviceId(license.getDeviceId());
vo.setLicenseKey(license.getLicenseKey());
vo.setStatus(license.getStatus());
vo.setGrantedAt(license.getGrantedAt());
vo.setExpireAt(license.getExpireAt());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
}
if (episode != null) {
vo.setEpisodeNo(episode.getEpNo());
}
if (task != null) {
vo.setTaskStatus(task.getStatus());
vo.setProgressPercent(task.getProgressPercent());
vo.setDownloadedBytes(task.getDownloadedBytes());
vo.setTotalBytes(task.getTotalBytes());
vo.setErrorMsg(task.getErrorMsg());
vo.setQuality(task.getQuality());
}
return vo;
}
private String playableSource(DramaEpisode episode) {
if (StringUtils.hasText(episode.getHlsUrl())) {
return episode.getHlsUrl();
}
return episode.getVideoUrl() == null ? "" : episode.getVideoUrl();
}
private String resolveDevice(String bodyDeviceId, String headerDeviceId) {
String deviceId = StringUtils.hasText(bodyDeviceId) ? bodyDeviceId.trim() : headerDeviceId;
if (!StringUtils.hasText(deviceId)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少设备标识(X-Device-Id)");
}
return deviceId.trim();
}
private String newLicenseKey() {
byte[] bytes = new byte[24];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String taskKey(Long episodeId, String deviceId) {
return episodeId + "|" + (deviceId == null ? "" : deviceId);
}
}
@@ -0,0 +1,15 @@
package com.fruitshorts.playback.service;
/**
* 权益判定端口。
*
* <p>权益的**写入**属于交易域(订单支付成功后发权益),还没实现;
* 这里只定义播放侧需要的只读判定,交易域落地后提供一个实现替换默认值:
* 届时在 fruitshorts-module-commerce 里实现本接口并标注 {@code @Primary} 即可,
* 播放链路不需要改动。
*/
public interface EntitlementPort {
/** 用户是否已拥有该剧集(或整剧)的有效权益。 */
boolean isUnlocked(Long userId, Long dramaId, Long episodeId);
}
@@ -0,0 +1,102 @@
package com.fruitshorts.playback.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.playback.dal.entity.UserFavorite;
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** 收藏:切换、列表、批量删除。计数走异步聚合,不在事务里更新统计列。 */
@Service
@RequiredArgsConstructor
public class FavoriteService {
private final UserFavoriteMapper favoriteMapper;
private final DramaService dramaService;
/** 返回切换后的状态:true 表示已收藏。 */
@Transactional(rollbackFor = Exception.class)
public boolean toggle(Long userId, Long dramaId) {
dramaService.getPublished(dramaId);
UserFavorite existing = favoriteMapper.selectOne(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.eq(UserFavorite::getDramaId, dramaId)
.last("limit 1"));
if (existing != null) {
favoriteMapper.deleteById(existing.getId());
return false;
}
UserFavorite favorite = new UserFavorite();
favorite.setUserId(userId);
favorite.setDramaId(dramaId);
favorite.setSort(0);
favoriteMapper.insert(favorite);
return true;
}
public boolean isFavorite(Long userId, Long dramaId) {
Long count = favoriteMapper.selectCount(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.eq(UserFavorite::getDramaId, dramaId));
return count != null && count > 0;
}
public List<Long> dramaIds(Long userId) {
return favoriteMapper.selectList(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.orderByDesc(UserFavorite::getCreatedAt))
.stream().map(UserFavorite::getDramaId).toList();
}
public List<Drama> listDramas(Long userId) {
List<Long> ids = dramaIds(userId);
if (ids.isEmpty()) {
return List.of();
}
List<Drama> dramas = dramaService.listPublishedByIds(ids);
// 按收藏时间排序,已被下架的剧自然被过滤掉
List<Drama> ordered = new ArrayList<>(dramas.size());
for (Long id : ids) {
for (Drama drama : dramas) {
if (drama.getId().equals(id)) {
ordered.add(drama);
break;
}
}
}
return ordered;
}
@Transactional(rollbackFor = Exception.class)
public void remove(Long userId, Long dramaId) {
favoriteMapper.delete(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.eq(UserFavorite::getDramaId, dramaId));
}
@Transactional(rollbackFor = Exception.class)
public void removeBatch(Long userId, List<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return;
}
favoriteMapper.delete(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.in(UserFavorite::getDramaId, dramaIds));
}
/** 供收藏数聚合任务调用,标记最近变更时间。 */
public LocalDateTime lastChangedAt(Long userId, Long dramaId) {
UserFavorite favorite = favoriteMapper.selectOne(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.eq(UserFavorite::getDramaId, dramaId)
.last("limit 1"));
return favorite == null ? null : favorite.getCreatedAt();
}
}
@@ -0,0 +1,128 @@
package com.fruitshorts.playback.service;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
import com.fruitshorts.playback.vo.PlayRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 播放地址签发。
*
* <p>判定顺序(结果由服务端决定,客户端不得自行推断):
* 已有权益 &gt; 免费集 &gt; 试看 &gt; 需金币解锁。
*
* <p>可播放时签发短时效签名地址;签名只覆盖路径与过期时间,不含用户信息,
* 这样 CDN 可以做纯 URL 缓存。需要按用户吊销时走票据表与并发设备限制。
*/
@Service
@RequiredArgsConstructor
public class PlaybackService {
public static final String ACCESS_FREE = "FREE";
public static final String ACCESS_ENTITLED = "ENTITLED";
public static final String ACCESS_COIN_REQUIRED = "COIN_REQUIRED";
public static final String ACCESS_NO_MEDIA = "NO_MEDIA";
private final EpisodeService episodeService;
private final DramaService dramaService;
private final PlaybackTicketMapper ticketMapper;
private final PlaybackSigner signer;
private final EntitlementPort entitlementPort;
private final TranscodeService transcodeService;
@Transactional(rollbackFor = Exception.class)
public PlayRespVO play(Long userId, Long episodeId, String deviceId, String clientIp) {
DramaEpisode episode = episodeService.getPublished(episodeId);
Drama drama = dramaService.getPublished(episode.getDramaId());
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
boolean entitled = userId != null && entitlementPort.isUnlocked(userId, drama.getId(), episode.getId());
String source = playableSource(episode);
PlayRespVO vo = new PlayRespVO();
vo.setEpisodeId(episode.getId());
vo.setDramaId(drama.getId());
vo.setNumber(episode.getEpNo());
vo.setTitle(episode.getTitle());
vo.setDurationSec(episode.getDurationSec());
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setPreviewSeconds(episode.getPreviewSeconds());
vo.setQualities(qualitiesOf(episode.getId()));
if (!StringUtils.hasText(source)) {
// 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址
vo.setAccessible(false);
vo.setAccessType(ACCESS_NO_MEDIA);
vo.setPlayUrl("");
return vo;
}
if (!free && !entitled) {
vo.setAccessible(false);
vo.setAccessType(ACCESS_COIN_REQUIRED);
vo.setPlayUrl("");
return vo;
}
String signedUrl = signer.sign(source);
vo.setAccessible(true);
vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE);
vo.setPlayUrl(signedUrl);
if (userId != null) {
recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp);
}
return vo;
}
/** 有 HLS 多码率就用 HLS,否则退回原文件(当前阶段常见形态)。 */
private String playableSource(DramaEpisode episode) {
if (StringUtils.hasText(episode.getHlsUrl())) {
return episode.getHlsUrl();
}
return episode.getVideoUrl() == null ? "" : episode.getVideoUrl();
}
private List<String> qualitiesOf(Long episodeId) {
List<MediaRendition> renditions = transcodeService.listRenditions(episodeId);
if (renditions.isEmpty()) {
return List.of("AUTO");
}
List<String> qualities = new ArrayList<>(renditions.size() + 1);
qualities.add("AUTO");
for (MediaRendition rendition : renditions) {
qualities.add(rendition.getQuality());
}
return qualities;
}
private void recordTicket(Long userId, Long dramaId, Long episodeId, String playUrl,
String deviceId, String clientIp) {
PlaybackTicket ticket = new PlaybackTicket();
ticket.setTicketNo(UUID.randomUUID().toString().replace("-", ""));
ticket.setUserId(userId);
ticket.setDramaId(dramaId);
ticket.setEpisodeId(episodeId);
ticket.setQuality("AUTO");
ticket.setPlayUrl(playUrl.length() > 1024 ? playUrl.substring(0, 1024) : playUrl);
ticket.setExpireAt(LocalDateTime.now().plusMinutes(15));
ticket.setIp(clientIp == null ? "" : clientIp);
ticket.setDeviceId(deviceId == null ? "" : deviceId);
ticket.setStatus("ISSUED");
ticketMapper.insert(ticket);
}
}
@@ -0,0 +1,181 @@
package com.fruitshorts.playback.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.playback.dal.entity.WatchProgress;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 观看进度。
*
* <p>每次上报都写库在高并发下会成为瓶颈(1 万并发 × 每 10 秒一次 ≈ 1000 写/秒),
* 所以生产形态是「先写 Redis,后台任务按 user+drama 合并后批量 UPSERT」。
* 这里先落地权威表与合并规则,缓存层在接入 Redis 时补齐,接口契约不变。
*
* <p>合并规则:以客户端上报的 positionMs 为准,但只在「更靠后」时覆盖,
* 避免用户换设备后进度倒退。
*/
@Service
@RequiredArgsConstructor
public class WatchProgressService {
private static final long FINISH_THRESHOLD_MS = 1000L;
private final WatchProgressMapper progressMapper;
private final WatchHistoryMapper historyMapper;
private final DramaService dramaService;
private final EpisodeService episodeService;
@Transactional(rollbackFor = Exception.class)
public ProgressRespVO save(Long userId, ProgressSaveReqVO req) {
DramaEpisode episode = episodeService.getPublished(req.getEpisodeId());
Drama drama = dramaService.getPublished(episode.getDramaId());
long position = req.getPositionMs() == null ? 0L : Math.max(0L, req.getPositionMs());
long duration = req.getDurationMs() == null ? 0L : Math.max(0L, req.getDurationMs());
boolean finished = Boolean.TRUE.equals(req.getFinished())
|| (duration > 0 && position + FINISH_THRESHOLD_MS >= duration);
WatchProgress existing = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
.eq(WatchProgress::getDramaId, drama.getId())
.last("limit 1"));
if (existing == null) {
WatchProgress progress = new WatchProgress();
progress.setUserId(userId);
progress.setDramaId(drama.getId());
progress.setEpisodeId(episode.getId());
progress.setPositionMs(position);
progress.setDurationMs(duration);
progress.setFinished(finished ? 1 : 0);
progress.setPlayCount(1);
progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
progress.setSource(req.getSource() == null ? "" : req.getSource());
progressMapper.insert(progress);
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
progress.getSource(), progress.getDeviceId());
// updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致
return toResp(progressMapper.selectById(progress.getId()));
}
boolean sameEpisode = existing.getEpisodeId() != null
&& existing.getEpisodeId().equals(episode.getId());
if (!sameEpisode) {
// 换集:直接采纳新集与新的播放位置
existing.setEpisodeId(episode.getId());
existing.setPositionMs(position);
existing.setDurationMs(duration);
existing.setFinished(finished ? 1 : 0);
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
req.getSource(), existing.getDeviceId());
} else {
existing.setDurationMs(Math.max(existing.getDurationMs() == null ? 0L : existing.getDurationMs(),
duration));
if (position >= (existing.getPositionMs() == null ? 0L : existing.getPositionMs())) {
existing.setPositionMs(position);
}
if (finished) {
existing.setFinished(1);
}
}
existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
existing.setSource(req.getSource() == null ? "" : req.getSource());
progressMapper.updateById(existing);
return toResp(progressMapper.selectById(existing.getId()));
}
public ProgressRespVO ofDrama(Long userId, Long dramaId) {
WatchProgress progress = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
.eq(WatchProgress::getDramaId, dramaId)
.last("limit 1"));
return progress == null ? null : toResp(progress);
}
/** 最近观看列表("我的-观看记录")。 */
public List<ProgressRespVO> recent(Long userId, int limit) {
List<WatchProgress> list = progressMapper.selectList(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
.orderByDesc(WatchProgress::getUpdatedAt)
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
List<ProgressRespVO> result = new ArrayList<>(list.size());
for (WatchProgress item : list) {
result.add(toResp(item));
}
return result;
}
@Transactional(rollbackFor = Exception.class)
public void remove(Long userId, Long dramaId) {
progressMapper.delete(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
.eq(WatchProgress::getDramaId, dramaId));
}
@Transactional(rollbackFor = Exception.class)
public void clear(Long userId) {
progressMapper.delete(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId));
}
private ProgressRespVO toResp(WatchProgress progress) {
ProgressRespVO vo = new ProgressRespVO();
vo.setDramaId(progress.getDramaId());
vo.setEpisodeId(progress.getEpisodeId());
vo.setPositionMs(progress.getPositionMs());
vo.setDurationMs(progress.getDurationMs());
vo.setFinished(progress.getFinished() != null && progress.getFinished() == 1);
vo.setPlayCount(progress.getPlayCount());
vo.setUpdatedAt(progress.getUpdatedAt());
return vo;
}
/** 观看流水明细(客服视图与后续行为分析用),只读。 */
public List<WatchHistory> history(Long userId, int limit) {
return historyMapper.selectList(Wrappers.<WatchHistory>lambdaQuery()
.eq(WatchHistory::getUserId, userId)
.orderByDesc(WatchHistory::getWatchedAt)
.last("limit " + (limit <= 0 || limit > 200 ? 50 : limit)));
}
/**
* 追加一条观看流水。只在「首次观看一部剧」和「切换剧集」时调用,
* 每 10 秒的进度上报不写这里 —— 否则单集会产出几十条噪音。
*/
private void appendHistory(Long userId, Long dramaId, Long episodeId, long positionMs,
long durationMs, String source, String deviceId) {
WatchHistory history = new WatchHistory();
history.setUserId(userId);
history.setDramaId(dramaId);
history.setEpisodeId(episodeId);
history.setPositionMs(positionMs);
history.setDurationMs(durationMs);
history.setSource(source == null ? "" : source);
history.setDeviceId(deviceId == null ? "" : deviceId);
historyMapper.insert(history);
}
/** 供后续批量合并任务使用:只更新进度字段,避免整行覆盖。 */
public void mergeInto(Long userId, Long dramaId, Long episodeId, long positionMs) {
progressMapper.update(null, Wrappers.<WatchProgress>lambdaUpdate()
.eq(WatchProgress::getUserId, userId)
.eq(WatchProgress::getDramaId, dramaId)
.set(WatchProgress::getEpisodeId, episodeId)
.set(WatchProgress::getPositionMs, positionMs)
.set(WatchProgress::getUpdatedAt, LocalDateTime.now()));
}
}
@@ -0,0 +1,18 @@
package com.fruitshorts.playback.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 申请离线下载授权。 */
@Data
public class DownloadGrantReqVO {
@NotNull(message = "缺少 episodeId")
private Long episodeId;
/** 期望清晰度,AUTO 表示由客户端按策略决定 */
private String quality;
/** 设备标识,绑定授权用;请求头带 X-Device-Id 时可以省略 */
private String deviceId;
}
@@ -0,0 +1,33 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 下载授权 + 任务状态的合并视图,客户端一处即可渲染下载列表。 */
@Data
public class DownloadLicenseRespVO {
private Long licenseId;
private Long dramaId;
private String dramaTitle;
private Long episodeId;
private Integer episodeNo;
private String deviceId;
private String quality;
/** 签名后的下载地址;过期后需要重新申请 */
private String playUrl;
/** 离线播放校验用 */
private String licenseKey;
/** ACTIVE/EXPIRED/REVOKED */
private String status;
private LocalDateTime grantedAt;
private LocalDateTime expireAt;
/** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */
private String taskStatus;
private Integer progressPercent;
private Long downloadedBytes;
private Long totalBytes;
private String errorMsg;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
/**
* 下载策略与配额。客户端不要写死这些值:改策略要能不发版。
*/
@Data
public class DownloadPolicyRespVO {
private Integer maxPerDevice;
private Integer licenseDays;
/** 用户当前的"仅 Wi-Fi 下载"偏好 */
private Boolean wifiOnly;
private String maxQuality;
/** 预留:单设备占用上限(字节),0 表示不限制 */
private Long maxStorageBytes;
/** 当前设备已用授权数 */
private Integer used;
private Integer remaining;
/** 过期策略说明,用于客户端文案与客服解释 */
private String expirePolicyText;
}
@@ -0,0 +1,16 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
/** 上报下载进度,用于断点续传与跨端展示。 */
@Data
public class DownloadProgressReqVO {
private String deviceId;
/** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */
private String status;
private Integer progressPercent;
private Long downloadedBytes;
private Long totalBytes;
private String errorMsg;
}
@@ -0,0 +1,15 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
/** 后台下载授权检索条件。 */
@Data
public class DownloadQueryReqVO {
private Long userId;
private String deviceId;
/** ACTIVE/EXPIRED/REVOKED */
private String status;
private Integer pageIndex = 1;
private Integer pageSize = 10;
}
@@ -0,0 +1,11 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
/** 下载偏好设置。 */
@Data
public class DownloadSettingReqVO {
private Boolean wifiOnly;
private String maxQuality;
}
@@ -0,0 +1,12 @@
package com.fruitshorts.playback.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 收藏/取消收藏。 */
@Data
public class FavoriteToggleReqVO {
@NotNull(message = "缺少 dramaId")
private Long dramaId;
}
@@ -0,0 +1,28 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
import java.util.List;
/** 播放票据响应。 */
@Data
public class PlayRespVO {
private Long episodeId;
private Long dramaId;
private Integer number;
private String title;
private Integer durationSec;
/** 是否可直接播放 */
private Boolean accessible;
/** FREE/ENTITLED/TRIAL/COIN_REQUIRED/NO_MEDIA */
private String accessType;
/** 签名后的播放地址,accessible=false 时为空 */
private String playUrl;
/** 可用的清晰度档位 */
private List<String> qualities;
/** 需要多少金币解锁 */
private Integer unlockCoin;
/** 试看秒数,0 表示不限制 */
private Integer previewSeconds;
}
@@ -0,0 +1,18 @@
package com.fruitshorts.playback.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 续看进度。 */
@Data
public class ProgressRespVO {
private Long dramaId;
private Long episodeId;
private Long positionMs;
private Long durationMs;
private Boolean finished;
private Integer playCount;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.fruitshorts.playback.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 上报观看进度。客户端建议每 10 秒一次,切换剧集时立即上报一次。 */
@Data
public class ProgressSaveReqVO {
@NotNull(message = "缺少 episodeId")
private Long episodeId;
private Long positionMs;
private Long durationMs;
private Boolean finished;
/** HOME/DETAIL/SEARCH/FAVORITE */
private String source;
private String deviceId;
}