1.GQVideo-cloud 资源服务:从本地存储迁移到 MinIO 实战记录

前言-为什么选minio

方案 构成 单价(参考来源) 月金额(估算)
阿里云 OSS(Standard LRS) 存储 1024 GB $0.0173/GB·月 ≈ $17.72/月(1024×0.0173) (阿里云)
下行 100 GB(CDN/公网) $0.04/GB(中国内地阶梯首档) ≈ $4.00/月(100×0.04) (阿里云)
合计 ≈ $21.72/月
阿里云 OSS(在 2025-12-31 前享 50 TB/月出网免费活动) 存储 同上 ≈ $17.72/月
下行 活动期 0 元 ≈ $0.00/月(100 GB 在 50 TB 免费额度内) (阿里云)
合计 ≈ $17.72/月(活动到 2025-12-31,之后恢复按量)
MinIO 自建(家用/自有服务器) 硬盘一次性 1–2 TB HDD 市价(示例参考) $40–$60 一次性(摊三年≈$1.1–$1.7/月/块;做镜像备份×2) (磁盘价格)
电费(主机+硬盘常年通电) 30 W 平均功耗21.6 kWh/月 ≈ $2.6/月(按 $0.12/kWh 估)
合计(粗算) ≈ $5–$7/月(含一块盘折旧 + 电费;若双盘镜像≈$6–$9/月)

img

目标

  • 将封面图与视频切片等静态资源从本地磁盘存储切换到 MinIO 对象存储。
  • 保持对外接口与业务流程不变(Controller/Service 调用感知不到实现差异)。
  • 支持一键切换回本地存储。
  • 提供一次性“历史本地文件 → MinIO”的迁移能力。

一、依赖与配置

1.1 引入 MinIO SDK

1
2
3
4
5
6
<!-- easylive-cloud-resource/pom.xml -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>

1.2 应用配置项

AppConfig 新增存储提供者与 MinIO 参数、迁移开关:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 存储提供者:local|minio
@Value("${storage.provider:local}")
private String storageProvider;

// MinIO
@Value("${minio.endpoint:}") private String minioEndpoint;
@Value("${minio.accessKey:}") private String minioAccessKey;
@Value("${minio.secretKey:}") private String minioSecretKey;
@Value("${minio.bucket:easylive}") private String minioBucket;

// 迁移控制
@Value("${migrate.local2minio.enabled:false}") private Boolean migrateLocal2MinioEnabled;
@Value("${migrate.local2minio.deleteLocal:false}") private Boolean migrateDeleteLocalAfterUpload;

YAML 或 Nacos 示例(根据环境调整):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
storage:
provider: minio

minio:
endpoint: http://192.168.32.1:9000
accessKey: 你的AccessKey
secretKey: 你的SecretKey
bucket: easylive

# 一次性迁移(可选)
migrate:
local2minio:
enabled: true
deleteLocal: false

二、对象存储抽象与实现

2.1 统一抽象接口

1
2
3
4
5
public interface ObjectStorageClient {
InputStream getObject(String objectKey) throws Exception;
void putObject(String objectKey, InputStream input, long size, String contentType) throws Exception;
void putDirectory(String localDir, String prefix) throws Exception;
}

设计要点:

  • 屏蔽本地/MinIO/OSS 差异,业务侧只依赖 ObjectStorageClient
  • 统一以对象键(key)读写,前缀使用项目现有的 file/cover/file/video/ 等。

2.2 MinIO 实现(按配置条件装配)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Component
@ConditionalOnProperty(name = "storage.provider", havingValue = "minio")
public class MinioStorageClient implements ObjectStorageClient {

@PostConstruct
public void init() throws Exception {
if (!"minio".equalsIgnoreCase(appConfig.getStorageProvider())) { return; }
client = MinioClient.builder()
.endpoint(appConfig.getMinioEndpoint())
.credentials(appConfig.getMinioAccessKey(), appConfig.getMinioSecretKey())
.build();
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(appConfig.getMinioBucket()).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(appConfig.getMinioBucket()).build());
}
}

public InputStream getObject(String objectKey) throws Exception {
ensureClient();
return client.getObject(GetObjectArgs.builder()
.bucket(appConfig.getMinioBucket())
.object(objectKey)
.build());
}

public void putObject(String objectKey, InputStream input, long size, String contentType) throws Exception {
ensureClient();
PutObjectArgs.Builder builder = PutObjectArgs.builder()
.bucket(appConfig.getMinioBucket())
.object(objectKey)
.stream(input, size, 10 * 1024 * 1024);
if (contentType != null) {
builder.contentType(contentType);
}
client.putObject(builder.build());
}

public void putDirectory(String localDir, String prefix) throws Exception {
ensureClient();
File dir = new File(localDir);
if (!dir.exists() || !dir.isDirectory()) { return; }
for (File file : FileUtils.listFiles(dir, null, true)) {
String relative = dir.toURI().relativize(file.toURI()).getPath();
String key = prefix + relative.replace("\\", "/");
try (InputStream in = new FileInputStream(file)) {
putObject(key, in, file.length(), null);
}
}
}
}

2.3 本地实现(默认启用,便于快速回退)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@ConditionalOnProperty(name = "storage.provider", havingValue = "local", matchIfMissing = true)
public class LocalStorageClient implements ObjectStorageClient {
public InputStream getObject(String objectKey) throws Exception {
String path = appConfig.getProjectFolder() + objectKey;
return new FileInputStream(new File(path));
}
public void putObject(String objectKey, InputStream input, long size, String contentType) throws Exception {
String path = appConfig.getProjectFolder() + objectKey;
File file = new File(path);
FileUtils.forceMkdirParent(file);
try (FileOutputStream out = new FileOutputStream(file)) {
byte[] buf = new byte[8192];
int len; while ((len = input.read(buf)) != -1) { out.write(buf, 0, len); }
}
}
public void putDirectory(String localDir, String prefix) throws Exception {
String target = appConfig.getProjectFolder() + prefix;
FileUtils.copyDirectory(new File(localDir), new File(target));
}
}

装配策略:

  • storage.provider=minio → 注入 MinioStorageClient
  • 其他/缺省 → 注入 LocalStorageClient

三、业务接入点改造

3.1 封面上传:先本地处理后上传对象存储

关键片段(缩略图生成后上传 MinIO,并清理本地临时文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String provider = appConfig.getStorageProvider();
// 上传到对象存储
if ("minio".equalsIgnoreCase(provider)) {
String key = Constants.FILE_COVER + day + "/" + realFileName;
try (java.io.InputStream in = new FileInputStream(filePath)) {
objectStorageClient.putObject(Constants.FILE_FOLDER + key, in, new File(filePath).length(), null);
}
// 缩略图
if (createThumbnail) {
File thumb = new File(filePath + Constants.IMAGE_THUMBNAIL_SUFFIX);
if (thumb.exists()) {
try (java.io.InputStream in = new FileInputStream(thumb)) {
objectStorageClient.putObject(Constants.FILE_FOLDER + key + Constants.IMAGE_THUMBNAIL_SUFFIX, in, thumb.length(), null);
}
}
}
// 清理本地
FileUtils.deleteQuietly(new File(filePath));
FileUtils.deleteQuietly(new File(filePath + Constants.IMAGE_THUMBNAIL_SUFFIX));
return key;
}

3.2 视频转码与切片上传

  • 仍在本地用 FFmpeg 合并分片、转码、切 HLS。
  • 若启用 MinIO,完成后整目录上传,并删除本地输出目录。
1
2
3
4
5
6
7
8
9
if ("minio".equalsIgnoreCase(appConfig.getStorageProvider())) {
String prefix = Constants.FILE_VIDEO + fileDto.getFilePath() + "/";
try {
objectStorageClient.putDirectory(targetFilePath, Constants.FILE_FOLDER + prefix);
FileUtils.deleteDirectory(new File(targetFilePath));
} catch (Exception e) {
log.error("上传视频目录到对象存储失败", e);
}
}

四、历史数据迁移

一次性迁移任务:启动时递归上传 file/cover/**file/video/** 到 MinIO,可选删除本地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
@ConditionalOnProperty(name = "migrate.local2minio.enabled", havingValue = "true")
public class LocalToMinioMigrateTask {
@PostConstruct
public void run() {
if (!"minio".equalsIgnoreCase(appConfig.getStorageProvider())) { return; }
String base = appConfig.getProjectFolder() + Constants.FILE_FOLDER;
migrateDir(new File(base + Constants.FILE_COVER), Constants.FILE_FOLDER + Constants.FILE_COVER);
migrateDir(new File(base + Constants.FILE_VIDEO), Constants.FILE_FOLDER + Constants.FILE_VIDEO);
}
private void migrateDir(File localDir, String objectPrefix) throws Exception {
if (!localDir.exists()) { return; }
for (File file : FileUtils.listFiles(localDir, null, true)) {
String relative = localDir.toURI().relativize(file.toURI()).getPath();
String key = objectPrefix + relative.replace("\\", "/");
try (InputStream in = new FileInputStream(file)) {
objectStorageClient.putObject(key, in, file.length(), null);
}
}
if (Boolean.TRUE.equals(appConfig.getMigrateDeleteLocalAfterUpload())) {
FileUtils.deleteDirectory(localDir);
}
}
}

使用步骤:

  1. 确保配置 storage.provider=minio
  2. 开启迁移:
    • migrate.local2minio.enabled=true
    • 可选:migrate.local2minio.deleteLocal=true(二次确认后再开)
  3. 重启资源服务,观察日志与 MinIO 控制台。
  4. 完成后将 enabled 改回 false

五、验证与回滚

  • 验证点:
    • MinIO 控制台出现 file/cover/**file/video/** 对象。
    • 本地无新增文件(转码临时输出在上传后被清理)。
    • 资源读取接口正常返回流。
  • 回滚:
    • storage.provider 切换为 local 即回到本地实现,无需改代码。

六、注意事项与最佳实践

  • 对象键前缀统一走 file/,避免与其他业务对象冲突。
  • 上传大文件/大量小文件时,建议开启 MinIO/网关的限流与重试策略。
  • 生产环境建议:
    • 给 MinIO 配置独立存储卷与备份策略。
    • 前置 CDN 或 Nginx 加缓存,提高热点资源访问效率。
    • 使用带有效期的签名 URL 直传/直下,进一步减少后端带宽开销。

变更清单

  • 依赖:easylive-cloud-resource/pom.xml 增加 io.minio:minio
  • 新增:ObjectStorageClientMinioStorageClientLocalStorageClient
  • 改造:FileController 上传封面改为对象存储;TransferFileComponent 切片产物目录上传对象存储
  • 配置:AppConfig 新增 MinIO 与迁移项
  • 迁移:LocalToMinioMigrateTask 一次性迁移任务

测试结果如图:

img

2.快速启动环境的bat文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@echo off
echo Starting Elasticsearch...

:: 启动 Elasticsearch
start /D "D:\ES\elasticsearch\elasticsearch-7.12.1\bin" elasticsearch.bat

:: 启动 Seata
echo Starting Seata...
start /D "D:\seata2.1.0\seata-2.1.0-incubating-bin\bin" seata-server.bat

:: 启动 Redis
echo Starting Redis...
start /D "D:\Redis" redis-server.exe

:: 启动 Nacos
echo Starting Nacos...
start /D "D:\nacos\bin" startup.cmd -m standalone

:: 启动 MinIO
echo Starting MinIO...
cd /d "D:\minio2024" && start minio.RELEASE.2024-09-13T20-26-02Z server ./data

:: 保持窗口打开,查看服务日志
pause

3.删除稿件同步清理 MinIO 存储

目标

  • 当管理员或作者删除视频时,自动删除 MinIO 中对应的封面图与视频切片目录。
  • 业务方无需感知存储类型;若资源服务不可用,自动回退本地删除以保持幂等。

一、整体设计

  • 在资源服务中为对象存储抽象补充删除能力:
  • 删除单文件:deleteObject(objectKey)
  • 递归删除目录前缀:deleteDirectory(prefix)
  • 资源服务对外提供内部接口供 Web 服务调用:
  • INNER/file/deleteObject?objectKey=...
  • INNER/file/deleteDirectory?prefix=...
  • Web 服务在删除视频时,异步调用资源服务删除 MinIO 对象;若调用失败,回退删除本地文件或目录。

关键点:

  • 对象键统一带 file/ 前缀(保持与上传时一致),例如:
  • 封面:file/cover/2025xxxx/xxx.pngfile/cover/.../xxx.png_thumbnail.jpg
  • 切片目录:file/video/2025xxxx/{...}/

二、对象存储抽象与实现

2.1 接口扩展

1
2
3
4
5
6
7
8
9
public interface ObjectStorageClient {
InputStream getObject(String objectKey) throws Exception;
void putObject(String objectKey, InputStream input, long size, String contentType) throws Exception;
void putDirectory(String localDir, String prefix) throws Exception;

// 新增:删除能力
void deleteObject(String objectKey) throws Exception;
void deleteDirectory(String prefix) throws Exception;
}

2.2 MinIO 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void deleteObject(String objectKey) throws Exception {
client.removeObject(RemoveObjectArgs.builder()
.bucket(appConfig.getMinioBucket())
.object(objectKey)
.build());
}
public void deleteDirectory(String prefix) throws Exception {
Iterable<Result<Item>> results = client.listObjects(ListObjectsArgs.builder()
.bucket(appConfig.getMinioBucket())
.prefix(prefix)
.recursive(true)
.build());
for (Result<Item> r : results) {
Item item = r.get();
client.removeObject(RemoveObjectArgs.builder()
.bucket(appConfig.getMinioBucket())
.object(item.objectName())
.build());
}
}

2.3 本地存储实现(回退)

1
2
3
4
5
6
7
8
public void deleteObject(String objectKey) throws Exception {
String path = appConfig.getProjectFolder() + objectKey;
FileUtils.deleteQuietly(new File(path));
}
public void deleteDirectory(String prefix) throws Exception {
String path = appConfig.getProjectFolder() + prefix;
FileUtils.deleteDirectory(new File(path));
}

三、资源服务内部接口

资源服务对外提供内部 API,封装删除动作给 Web 调用。

1
2
3
4
5
6
7
8
9
@RequestMapping("/deleteObject")
public void deleteObject(@RequestParam @NotEmpty String objectKey) throws Exception {
objectStorageClient.deleteObject(objectKey);
}

@RequestMapping("/deleteDirectory")
public void deleteDirectory(@RequestParam @NotEmpty String prefix) throws Exception {
objectStorageClient.deleteDirectory(prefix);
}

四、Web 服务集成与调用

4.1 Feign 客户端

1
2
3
4
5
6
7
8
@FeignClient(name = Constants.SERVER_NAME_RESOURCE)
public interface ResourceClient {
@RequestMapping(Constants.INNER_API_PREFIX + "/file/deleteObject")
void deleteObject(@RequestParam @NotEmpty String objectKey);

@RequestMapping(Constants.INNER_API_PREFIX + "/file/deleteDirectory")
void deleteDirectory(@RequestParam @NotEmpty String prefix);
}

确保 Web 启动类启用了 @EnableFeignClients(basePackages="com.easylive.api.consumer")

4.2 删除视频时的删除链路

  • 删除封面:先调资源服务删除 MinIO 对象,失败时回退删除本地文件与缩略图。
  • 删除分P切片目录:先调资源服务删除 MinIO 目录前缀,失败时回退本地递归删除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 删除封面(优先通过资源服务删除,失败再回退到本地)
try {
String cover = videoInfo.getVideoCover();
if (!StringTools.isEmpty(cover)) {
try {
resourceClient.deleteObject(Constants.FILE_FOLDER + cover);
resourceClient.deleteObject(Constants.FILE_FOLDER + cover + Constants.IMAGE_THUMBNAIL_SUFFIX);
} catch (Exception ex) {
FileUtils.deleteQuietly(new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER + cover));
FileUtils.deleteQuietly(new File(appConfig.getProjectFolder() + Constants.FILE_FOLDER + cover + Constants.IMAGE_THUMBNAIL_SUFFIX));
}
}
} catch (Exception e) {
log.error("删除封面失败,cover:{}", videoInfo.getVideoCover(), e);
}

// 删除分P视频切片目录(优先通过资源服务删除,失败再回退到本地)
for (VideoInfoFile item : videoInfoFileList) {
try {
try {
resourceClient.deleteDirectory(Constants.FILE_FOLDER + item.getFilePath() + "/");
} catch (Exception ex) {
FileUtils.deleteDirectory(new File(appConfig.getProjectFolder() + item.getFilePath()));
}
} catch (Exception e) {
log.error("删除视频目录失败,路径:{}", item.getFilePath(), e);
}
}
  • 上述逻辑运行在异步线程池中(与原实现一致),删除后稍等几秒即可在 MinIO 控制台看到对象被移除。

五、注意事项

  • 对象键与前缀要与上传保持一致:
  • 上传时使用 file/cover/...file/video/... 前缀,删除时也应加上 file/
  • 需要同时重启资源服务与 Web 服务,确保两边版本一致(资源服务需包含删除接口)。
  • 建议在 Web 添加 Feign 日志便于排查:
  • logging.level.com.easylive.api.consumer.ResourceClient=DEBUG
  • logging.level.org.springframework.cloud.openfeign=DEBUG

变更清单

  • 扩展接口:ObjectStorageClient 新增删除方法
  • 实现更新:MinioStorageClientLocalStorageClient
  • 资源端 API:ResourceApi 新增删除端点
  • Web 集成:新增 ResourceClient,改造 VideoInfoServiceImpl.deleteVideo 删除逻辑

如需,我可以把本文档写入 docs/delete-video-minio-cleanup.md 并提交到仓库。

我先把“mq-verify”验证任务勾选为完成,然后给你整理一份完整的 Markdown 开发文档。
Ran tool

4.RocketMQ 改造开发文档(由线程池/Redis 队列迁移至 MQ)

  • 目标:将原本依赖线程池和 Redis 列表的异步任务(视频转码、播放事件统计)迁移到 RocketMQ,实现解耦、削峰、可重试、可观测。
  • 涉及模块easylive-cloud-webeasylive-cloud-resourceeasylive-cloud-common
  • 主题/消费组
  • 播放事件:topic=video-play,group=play-cg
  • 转码任务:topic=video-transcode,group=transcode-cg

一、依赖与基础配置

  • web、resource 模块引入依赖
1
2
3
4
5
6
<!-- pom.xml -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
  • 开关与日志建议
1
2
3
4
5
6
7
# application.yml 或 Nacos
mq.enabled: true
rocketmq:
name-server: 127.0.0.1:9876
producer:
group: easylive-producer
logging.level.com.easylive.mq: INFO
  • 统一消息 DTO(common 模块)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 关键定义,省略 getter/setter
public class VideoTranscodeMessage {
private String fileId;
private String uploadId;
private String userId;
private String videoId;
private Integer fileIndex;
private String tempFilePath;
}

public class VideoPlayEvent {
private String videoId;
private String userId;
private Integer fileIndex;
private Long ts;
}

二、发布端(Producer)

  • 通用发布器(web 模块)
1
2
3
4
5
6
7
8
9
10
// com.easylive.mq.MqPublisher
@Component
@RequiredArgsConstructor
public class MqPublisher {
private final RocketMQTemplate rocketMQTemplate;

public void send(String topic, Object payload) {
rocketMQTemplate.convertAndSend(topic, payload);
}
}
  • 播放事件发布
1
2
3
4
5
6
7
8
9
10
11
12
13
// com.easylive.component.PlayEventPublisher
@Component
@ConditionalOnProperty(name = "mq.enabled", havingValue = "true")
@RequiredArgsConstructor
@Slf4j
public class PlayEventPublisher {
private final MqPublisher mqPublisher;

public void publishPlayEvent(VideoPlayEvent e) {
log.info("[MQ] 发布播放事件 videoId={} userId={} fileIndex={}", e.getVideoId(), e.getUserId(), e.getFileIndex());
mqPublisher.send("video-play", e);
}
}
  • 转码任务发布
1
2
3
4
5
6
7
8
9
10
11
12
13
// com.easylive.component.TranscodePublisher
@Component
@ConditionalOnProperty(name = "mq.enabled", havingValue = "true")
@RequiredArgsConstructor
@Slf4j
public class TranscodePublisher {
private final MqPublisher mqPublisher;

public void publishTranscode(VideoTranscodeMessage msg) {
log.info("[MQ] 发布转码任务 fileId={} uploadId={} videoId={}", msg.getFileId(), msg.getUploadId(), msg.getVideoId());
mqPublisher.send("video-transcode", msg);
}
}
  • 业务接入点改造
  • RedisComponent.addVideoPlay(...) 改为优先走 MQ(开关可回退本地逻辑)
  • FileController 中播放上报改用 PlayEventPublisher.publishPlayEvent(...)
  • VideoInfoPostServiceImpl 发布转码改用 TranscodePublisher.publishTranscode(...)

示例(播放上报处):

1
2
// com.easylive.controller.FileController 片段
playEventPublisher.publishPlayEvent(new VideoPlayEvent(videoId, userId, fileIndex, System.currentTimeMillis()));

示例(稿件提交触发转码):

1
2
// com.easylive.service.impl.VideoInfoPostServiceImpl 片段
transcodePublisher.publishTranscode(msg);

三、消费端(Consumer)

  • 播放事件消费(替换原 Redis 队列 + 线程池)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// com.easylive.mq.PlayEventConsumer
@Component
@RocketMQMessageListener(topic = "video-play", consumerGroup = "play-cg")
@RequiredArgsConstructor
@Slf4j
public class PlayEventConsumer implements RocketMQListener<VideoPlayEvent> {
private final VideoInfoMapper videoInfoMapper;
private final VideoPlayHistoryMapper videoPlayHistoryMapper;
private final EsSearchComponent esSearchComponent;

@Override
public void onMessage(VideoPlayEvent e) {
log.info("[MQ] 接收播放事件 topic=video-play videoId={} userId={} fileIndex={} ts={}",
e.getVideoId(), e.getUserId(), e.getFileIndex(), e.getTs());

// 1) MySQL 计数 + 最近播放时间
videoInfoMapper.updateCountInfo(e.getVideoId(), 1);

// 2) 播放历史 upsert
videoPlayHistoryMapper.insertOrUpdate(e.getUserId(), e.getVideoId(), e.getFileIndex(), new Timestamp(System.currentTimeMillis()));

// 3) ES 计数(已做 null 兼容与 upsert)
esSearchComponent.updateDocCount(e.getVideoId(), SearchOrderTypeEnum.VIDEO_PLAY.getField(), 1);
}
}
  • 转码任务消费(调用原组件转码 + MinIO 上传)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// com.easylive.mq.TranscodeConsumer(resource 模块)
@Component
@RocketMQMessageListener(topic = "video-transcode", consumerGroup = "transcode-cg")
@RequiredArgsConstructor
@Slf4j
public class TranscodeConsumer implements RocketMQListener<VideoTranscodeMessage> {
private final TransferFileComponent transferFileComponent;

@Override
public void onMessage(VideoTranscodeMessage msg) {
log.info("[MQ] 接收转码任务 topic=video-transcode fileId={} uploadId={} videoId={}",
msg.getFileId(), msg.getUploadId(), msg.getVideoId());
transferFileComponent.transferVideoFile(
msg.getTempFilePath(), msg.getFileId(), msg.getUploadId(), msg.getUserId(),
msg.getVideoId(), msg.getFileIndex());
}
}

四、与旧逻辑的平滑切换

  • 旧的 ExecuteQueueTask(web/resource 两处)增加条件开关:
1
2
3
@ConditionalOnProperty(name = "mq.enabled", havingValue = "false", matchIfMissing = true)
@Component
public class ExecuteQueueTask { ... } // 原 Redis 列表 + 线程池逻辑保留为兜底
  • RedisComponent.addVideoPlay(...) 内部判断 mq.enabled=true 时转发至 PlayEventPublisher,否则走旧逻辑。

五、可观测性与排错

  • 关键日志
  • Producer 发布时:[MQ] 发布播放事件 ...[MQ] 发布转码任务 ...
  • Consumer 消费时:[MQ] 接收播放事件 ...[MQ] 接收转码任务 ...
  • 常见问题
  • NameServer 未启动:Producer 或 Consumer 启动报错,或发布无效
  • 主题/消费组拼写不一致导致收不到消息
  • 消息体不可序列化(确保使用 Jackson 默认映射的简单 POJO)
  • ES 自增脚本 NPE:旧数据字段为空,已通过 upsert 和 null 兼容修复

六、关键改动清单(索引到代码)

  • 依赖:
  • easylive-cloud-web/pom.xml
  • easylive-cloud-resource/pom.xml
  • DTO:
  • easylive-cloud-common/.../mq/dto/VideoTranscodeMessage.java
  • easylive-cloud-common/.../mq/dto/VideoPlayEvent.java
  • 发布端:
  • easylive-cloud-web/.../mq/MqPublisher.java
  • easylive-cloud-web/.../component/PlayEventPublisher.java
  • easylive-cloud-web/.../component/TranscodePublisher.java
  • 消费端:
  • easylive-cloud-web/.../mq/PlayEventConsumer.java
  • easylive-cloud-resource/.../mq/TranscodeConsumer.java
  • 入口改造:
  • easylive-cloud-resource/.../controller/FileController.java(播放上报)
  • easylive-cloud-web/.../service/impl/VideoInfoPostServiceImpl.java(发布转码)
  • easylive-cloud-common/.../component/RedisComponent.java(转发开关)
  • 旧任务下线/开关:
  • .../task/ExecuteQueueTask.java(web/resource)
  • ES 自增修复(null 兼容 + upsert):
  • easylive-cloud-web/.../component/EsSearchComponent.javaupdateDocCount 方法

七、回滚与应急

  • 配置层面立即回退:mq.enabled=false,恢复 Redis + 线程池路径
  • 代码层面:保留了旧实现,无需回滚代码即可生效
  • MQ 不可用时建议监控切换:在启动或健康检查时自动判定 NameServer/Topic 可用性,必要时将 mq.enabled 置回 false

八、测试用例/验收点

  • 播放一次视频
  • MySQL 的 video_info.play_count +1
  • video_play_history upsert 成功
  • ES 文档对应计数 +1,无 400/NPE
  • 日志出现 Producer/Consumer 的 MQ 轨迹
  • 提交一条稿件(或转码触发)
  • TransferFileComponent 执行本地转码 + MinIO 上传
  • 本地临时目录被清理
  • 日志出现 [MQ] 发布转码任务[MQ] 接收转码任务

九、后续优化建议

  • 引入重试与 DLQ(消费端设置 consumeThreadMaxmaxReconsumeTimes,并为失败消息配置专用 DLQ 监听)
  • 幂等性:对转码任务和播放事件添加业务幂等键,避免重复消费带来的副作用
  • 统一追踪:通过 msgKey 注入 traceId,对接 RocketMQ Tracing 或链路追踪系统
  • 升级 ES 客户端为 elasticsearch-java(新客户端),逐步替换已废弃 API

5.前端美化-美化登录页面

二次元品牌风 + 粉色主色 + 保留插画 + 强动效

img

img

6.忘记密码功能开发文档(验证码版)

本实现基于“图片验证码 + 新密码”重置流程,不依赖邮件系统。前后端均已改造,兼容原有登录/自动登录逻辑,并修复了重置后返回登录不显示验证码的小问题。

一、功能概述

  • 前端:在登录弹窗中点击“忘记密码?”进入“重置模式”,展示图片验证码与两次新密码输入,提交后完成重置;返回登录时强制显示并刷新验证码。
  • 后端:提供两类接口
  • forgetPassword:仅校验邮箱存在,不发送邮件
  • resetPassword:校验图片验证码,通过后按邮箱重置密码

二、后端改造

1) 接口定义

  • 路由前缀:/web/account
  • 接口列表:
  • POST /forgetPassword:校验邮箱存在,返回成功
  • POST /resetPassword:参数 email + checkCodeKey + checkCode + newPassword,通过后更新密码

2) 关键代码

后端控制器:easylive-server/easylive-cloud/easylive-cloud-web/src/main/java/com/easylive/controller/AccountController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 忘记密码:仅校验邮箱存在
@RequestMapping("/forgetPassword")
@GlobalInterceptor
public ResponseVO<?> forgetPassword(@NotEmpty @Email String email) {
if (userInfoService.getUserInfoByEmail(email) == null) {
throw new BusinessException("邮箱不存在");
}
return getSuccessResponseVO(null);
}

// 重置密码:图片验证码校验 + 更新密码
@RequestMapping("/resetPassword")
@GlobalInterceptor
public ResponseVO<?> resetPassword(@NotEmpty @Email String email,
@NotEmpty String checkCodeKey,
@NotEmpty String checkCode,
@NotEmpty @Pattern(regexp = Constants.REGEX_PASSWORD) String newPassword) {
try {
String real = redisComponent.getCheckCode(checkCodeKey);
if (StringTools.isEmpty(real) || !real.equalsIgnoreCase(checkCode)) {
throw new BusinessException("图片验证码不正确");
}
com.easylive.entity.po.UserInfo update = new com.easylive.entity.po.UserInfo();
update.setPassword(StringTools.encodeByMD5(newPassword));
userInfoService.updateUserInfoByEmail(update, email);
return getSuccessResponseVO(null);
} finally {
redisComponent.cleanCheckCode(checkCodeKey);
}
}

说明

  • 验证码依旧复用现有图形验证码体系:/web/account/checkCode -> 返回 checkCodeKey/图片Base64
  • 密码规则复用 Constants.REGEX_PASSWORD:必须包含数字与字母,允许特殊字符,8-18 位
  • 新密码使用 StringTools.encodeByMD5 入库(与现有登录加密保持一致)

三、前端改造

1) API

文件:easylive-front/easylive-front-web/src/utils/Api.js

1
2
3
4
5
6
7
const server_web = "/web";
const Api = {
// ...
forgetPassword: server_web + "/account/forgetPassword",
resetPassword: server_web + "/account/resetPassword",
// ...
}

2) 登录弹窗交互

文件:easylive-front/easylive-front-web/src/views/account/Account.vue

  • 新增“重置模式”与表单规则
  • resetMode:控制是否显示重置表单(新密码/确认新密码 + 图片验证码)
  • checkReNewPassword:校验二次新密码;已“提前定义在 rules 之前”避免 setup 阶段引用未初始化报错
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const resetMode = ref(false);

function checkReNewPassword(rule, value, callback) {
if (value !== formData.value.newPassword) return callback(new Error(rule.message));
callback();
}

const rules = {
// ...
newPassword: [
{ required: true, message: "请输入新密码" },
{ validator: proxy.Verify.password, message: "密码只能是数字,字母,特殊字符 8-18位" },
],
reNewPassword: [
{ required: true, message: "请再次输入新密码" },
{ validator: checkReNewPassword, message: "两次输入的密码不一致" },
],
};
  • 进入重置模式
1
2
3
4
5
6
7
8
9
const openForget = async () => {
const email = formData.value.email;
if (!email) return proxy.Message.error("请先输入邮箱");
const res = await proxy.Request({ url: proxy.Api.forgetPassword, params: { email }, showLoading: true });
if (!res) return;
resetMode.value = true;
changeCheckCode(); // 刷新图片验证码
proxy.Message.success("请完成图片验证码并设置新密码");
};
  • 提交重置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const submitReset = () => {
formDataRef.value.validate(async (valid) => {
if (!valid) return;
const params = {
email: formData.value.email,
newPassword: formData.value.newPassword,
checkCode: formData.value.checkCode,
checkCodeKey: checkCodeInfo.value.checkCodeKey,
};
const res = await proxy.Request({
url: proxy.Api.resetPassword,
params, showLoading: true, errorCallback: () => changeCheckCode(),
});
if (!res) return;
proxy.Message.success("重置成功,请使用新密码登录");
cancelReset();
});
};
  • 返回登录(关键小修复:强制显示并刷新验证码,防止“验证码不能为空”)
1
2
3
4
5
6
7
8
const cancelReset = () => {
resetMode.value = false;
formData.value.newPassword = "";
formData.value.reNewPassword = "";
formData.value.checkCode = "";
captchaVisible.value = true; // 强制显示验证码
changeCheckCode(); // 刷新验证码
};
  • 登录密码输入在重置模式隐藏;底部按钮切换为“重置密码/返回登录”

四、交互与校验

  • 图片验证码在以下场景出现:
  • 注册
  • 登录(默认显示;若自动登录成功临时隐藏,返回登录会强制显示)
  • 忘记密码重置(进入重置模式时显示)
  • 重置成功后会清理使用过的 checkCodeKey
  • 错误提示:
  • 邮箱不存在:在 forgetPassword 返回
  • 验证码错误:resetPassword 返回“图片验证码不正确”
  • 密码不符合规则/两次密码不一致:前端 rules 拦截

五、测试用例

  • 忘记密码正常流
    1. 输入邮箱;点击“忘记密码?”进入重置模式
    2. 刷新图片验证码,输入新密码并确认,提交
    3. 返回登录页显示验证码,使用新密码登录成功
  • 边界
  • 未填邮箱点击“忘记密码?” -> 前端提示
  • 验证码错误 -> 后端报“图片验证码不正确”,前端刷新验证码
  • 新密码不符合规则/不一致 -> 前端拦截
  • 重置后返回登录验证码缺失 -> 已修复,应始终显示

六、后续可优化

  • 支持“邮箱验证码”与“图片验证码”双模式,配置切换
  • 增加重置频次限制与安全日志
  • 重置完成后可选自动聚焦密码框或引导提示

Ran tool

Ran tool
Ran tool

7.邮箱验证码功能开发笔记

背景与目标

  • 注册与忘记密码场景需要“双重校验”:图片验证码 + 邮箱验证码。
  • 登录仅保留图片验证码。
  • 加入发送频控与验证码有效期,避免刷接口和暴力破解。

一、后端改造

1) 依赖与配置

  • 新增邮件发送依赖(easylive-cloud-web/pom.xml
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
  • Nacos 配置(示例,QQ 邮箱)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
mail:
host: smtp.qq.com
port: 465
username: 你的邮箱@qq.com
password: 你的授权码
protocol: smtp
properties:
mail:
smtp:
auth: true
ssl:
enable: true
# 也可改用 587 + STARTTLS(与 465+SSL 二选一)

2) Redis 键位与工具

  • 在常量中新增键前缀(注册/忘记密码通道分别限流与存码)
1
2
3
4
5
6
7
// 找回密码
public static final String REDIS_KEY_FORGET_PWD_CODE = REDIS_KEY_PREFIX + "forget:code:";
public static final String REDIS_KEY_FORGET_PWD_LIMIT = REDIS_KEY_PREFIX + "forget:limit:";

// 注册
public static final String REDIS_KEY_REGISTER_EMAIL_CODE = REDIS_KEY_PREFIX + "register:code:";
public static final String REDIS_KEY_REGISTER_EMAIL_LIMIT = REDIS_KEY_PREFIX + "register:limit:";
  • RedisComponent 中封装保存/读取/清理及限流
1
2
3
4
5
public void saveRegisterEmailCode(String email, String code) { ... }      // 10 分钟
public String getRegisterEmailCode(String email) { ... }
public void cleanRegisterEmailCode(String email) { ... }
public boolean hitRegisterLimit(String email) { ... } // 1 分钟
// 已有 forgetPwd 的 save/get/clean/hitLimit 同理

3) 接口与业务流程

  • 新增发送验证码接口(注册/忘记密码共用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequestMapping("/sendEmailCode")
@GlobalInterceptor
public ResponseVO<?> sendEmailCode(@NotEmpty @Email String email, @NotEmpty String type) {
if ("register".equalsIgnoreCase(type)) {
if (redisComponent.hitRegisterLimit(email)) throw new BusinessException("发送太频繁,请稍后再试");
} else if ("forget".equalsIgnoreCase(type)) {
if (userInfoService.getUserInfoByEmail(email) == null) throw new BusinessException("邮箱不存在");
if (redisComponent.hitForgetPwdLimit(email)) throw new BusinessException("发送太频繁,请稍后再试");
} else throw new BusinessException("type不正确");

String code = StringTools.getRandomNumber(6);
if (mailSender == null) throw new BusinessException("邮件服务未启用");

SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("【Easylive】邮箱验证码");
message.setText("您的验证码为:" + code + ",10分钟内有效。");
if (mailFrom != null && mailFrom.length() > 0) message.setFrom(mailFrom); // 与 spring.mail.username 一致

try { mailSender.send(message); } catch (Exception e) { e.getMessage(); }

if ("register".equalsIgnoreCase(type)) redisComponent.saveRegisterEmailCode(email, code);
else redisComponent.saveForgetPwdCode(email, code);
return getSuccessResponseVO(null);
}
  • 注册接口新增邮箱验证码校验
1
2
3
4
5
6
7
8
public ResponseVO<?> register(..., @NotEmpty String emailCode) {
if (!checkCode.equalsIgnoreCase(redisComponent.getCheckCode(checkCodeKey))) throw new BusinessException("图片验证码不正确");
String realEmailCode = redisComponent.getRegisterEmailCode(email);
if (realEmailCode == null || !realEmailCode.equalsIgnoreCase(emailCode)) throw new BusinessException("邮箱验证码不正确或已过期");
userInfoService.register(...);
...
redisComponent.cleanRegisterEmailCode(email);
}
  • 重置密码新增邮箱验证码校验
1
2
3
4
5
6
7
8
public ResponseVO<?> resetPassword(..., @NotEmpty String emailCode, @NotEmpty @Pattern(...) String newPassword) {
String real = redisComponent.getCheckCode(checkCodeKey);
if (StringTools.isEmpty(real) || !real.equalsIgnoreCase(checkCode)) throw new BusinessException("图片验证码不正确");
String realEmailCode = redisComponent.getForgetPwdCode(email);
if (realEmailCode == null || !realEmailCode.equalsIgnoreCase(emailCode)) throw new BusinessException("邮箱验证码不正确或已过期");
// 更新密码...
redisComponent.cleanForgetPwdCode(email);
}

二、前端改造(easylive-front-web

1) API

1
sendEmailCode: server_web + "/account/sendEmailCode",

2) UI 与交互(关键片段)

  • 注册页与忘记密码面板新增“邮箱验证码 + 发送验证码”区,倒计时 60s
1
2
3
4
5
6
7
8
9
10
<el-form-item prop="emailCode">
<div class="check-code-panel">
<el-input size="large" placeholder="请输入邮箱验证码" v-model="formData.emailCode">
<template #prefix><span class="iconfont icon-checkcode"></span></template>
</el-input>
<el-button class="send-mail" :disabled="mailCountdown>0" @click="sendMail('forget')" type="primary">
{{ mailCountdown>0 ? mailCountdown + 's' : '发送验证码' }}
</el-button>
</div>
</el-form-item>
  • 注册提交携带邮箱验证码;忘记密码提交同样携带
1
2
3
4
if (opType.value == 0) {
params.emailCode = formData.value.emailCode;
}
const params = { email: formData.value.email, newPassword: ..., checkCodeKey: ..., emailCode: formData.value.emailCode };
  • 发送验证码逻辑与邮箱本地校验
1
2
3
4
5
6
7
8
9
10
11
const sendMail = async (type) => {
const email = formData.value.email;
const emailReg = /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/;
if (!email || !emailReg.test(email)) { proxy.Message.error("请先填写正确的邮箱"); return; }
if (mailCountdown.value > 0) return;
const res = await proxy.Request({ url: proxy.Api.sendEmailCode, params: { email, type }, showLoading: true });
if (!res) return;
proxy.Message.success("验证码已发送,请查收邮件");
mailCountdown.value = 60;
const timer = setInterval(() => { mailCountdown.value--; if (mailCountdown.value <= 0) clearInterval(timer); }, 1000);
};
  • 忘记密码时禁用邮箱输入,避免被篡改
1
<el-input ... v-model="formData.email" :disabled="resetMode" ... />
  • 发送按钮与输入框间距优化
1
2
.check-code-panel { display:flex; ... }
.send-mail { margin-left:10px; height:40px; border-radius:8px; padding:0 14px; }

三、频控与安全策略

  • 限流:同邮箱 1 分钟内只允许发送一次(注册与忘记密码通道分别限流)。
  • 验证码有效期:10 分钟。
  • 忘记密码场景禁用邮箱输入框,防止已验证邮箱被替换。
  • 登录不增加邮箱验证码,避免过度打扰。

四、联调与排错建议

  • SMTP 必须使用授权码(非邮箱登录密码),QQ 邮箱需设置发件人等于认证账号。
  • 若发送失败,优先核对端口与 SSL/STARTTLS;确认服务器能连通 smtp.qq.com:465/587
  • Nacos 修改配置后需重启 web 模块生效。

五、接口清单

  • POST /web/account/sendEmailCode
  • 参数:emailtype=register|forget
  • 返回:成功空体;频控或未配置邮件时返回错误信息
  • POST /web/account/register
  • 参数:emailnickNameregisterPasswordcheckCodeKeycheckCodeemailCode
  • POST /web/account/resetPassword
  • 参数:emailnewPasswordcheckCodeKeycheckCodeemailCode