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:
1 parent
a4aa749cc6
commit
b3179fc836
251 files changed
+7261
-767
No files matched your search
+33
@@ -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 * * *";
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+483
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+102
@@ -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();
|
||||
}
|
||||
}
|
||||
+128
@@ -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>判定顺序(结果由服务端决定,客户端不得自行推断):
|
||||
* 已有权益 > 免费集 > 试看 > 需金币解锁。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
+181
@@ -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()));
|
||||
}
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fruitshorts.playback.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 下载偏好设置。 */
|
||||
@Data
|
||||
public class DownloadSettingReqVO {
|
||||
|
||||
private Boolean wifiOnly;
|
||||
private String maxQuality;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user