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/月) |

目标
- 将封面图与视频切片等静态资源从本地磁盘存储切换到 MinIO 对象存储。
- 保持对外接口与业务流程不变(Controller/Service 调用感知不到实现差异)。
- 支持一键切换回本地存储。
- 提供一次性“历史本地文件 → MinIO”的迁移能力。
一、依赖与配置
1.1 引入 MinIO SDK
1 2 3 4 5 6
| <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
| @Value("${storage.provider:local}") private String storageProvider;
@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); } } }
|
使用步骤:
- 确保配置
storage.provider=minio。
- 开启迁移:
migrate.local2minio.enabled=true
- 可选:
migrate.local2minio.deleteLocal=true(二次确认后再开)
- 重启资源服务,观察日志与 MinIO 控制台。
- 完成后将
enabled 改回 false。
五、验证与回滚
- 验证点:
- MinIO 控制台出现
file/cover/**、file/video/** 对象。
- 本地无新增文件(转码临时输出在上传后被清理)。
- 资源读取接口正常返回流。
- 回滚:
- 将
storage.provider 切换为 local 即回到本地实现,无需改代码。
六、注意事项与最佳实践
- 对象键前缀统一走
file/,避免与其他业务对象冲突。
- 上传大文件/大量小文件时,建议开启 MinIO/网关的限流与重试策略。
- 生产环境建议:
- 给 MinIO 配置独立存储卷与备份策略。
- 前置 CDN 或 Nginx 加缓存,提高热点资源访问效率。
- 使用带有效期的签名 URL 直传/直下,进一步减少后端带宽开销。
变更清单
- 依赖:
easylive-cloud-resource/pom.xml 增加 io.minio:minio
- 新增:
ObjectStorageClient、MinioStorageClient、LocalStorageClient
- 改造:
FileController 上传封面改为对象存储;TransferFileComponent 切片产物目录上传对象存储
- 配置:
AppConfig 新增 MinIO 与迁移项
- 迁移:
LocalToMinioMigrateTask 一次性迁移任务
测试结果如图:

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.png 和 file/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); }
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 新增删除方法
- 实现更新:
MinioStorageClient、LocalStorageClient
- 资源端 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-web、easylive-cloud-resource、easylive-cloud-common
- 主题/消费组
- 播放事件:topic=
video-play,group=play-cg
- 转码任务:topic=
video-transcode,group=transcode-cg
一、依赖与基础配置
1 2 3 4 5 6
| <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
| mq.enabled: true rocketmq: name-server: 127.0.0.1:9876 producer: group: easylive-producer logging.level.com.easylive.mq: INFO
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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)
1 2 3 4 5 6 7 8 9 10
| @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
| @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
| @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
| playEventPublisher.publishPlayEvent(new VideoPlayEvent(videoId, userId, fileIndex, System.currentTimeMillis()));
|
示例(稿件提交触发转码):
1 2
| 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
| @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());
videoInfoMapper.updateCountInfo(e.getVideoId(), 1);
videoPlayHistoryMapper.insertOrUpdate(e.getUserId(), e.getVideoId(), e.getFileIndex(), new Timestamp(System.currentTimeMillis()));
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
| @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 { ... }
|
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.java 的 updateDocCount 方法
七、回滚与应急
- 配置层面立即回退:
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(消费端设置
consumeThreadMax、maxReconsumeTimes,并为失败消息配置专用 DLQ 监听)
- 幂等性:对转码任务和播放事件添加业务幂等键,避免重复消费带来的副作用
- 统一追踪:通过 msgKey 注入
traceId,对接 RocketMQ Tracing 或链路追踪系统
- 升级 ES 客户端为
elasticsearch-java(新客户端),逐步替换已废弃 API
5.前端美化-美化登录页面
二次元品牌风 + 粉色主色 + 保留插画 + 强动效
原

现

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 拦截
五、测试用例
- 忘记密码正常流
- 输入邮箱;点击“忘记密码?”进入重置模式
- 刷新图片验证码,输入新密码并确认,提交
- 返回登录页显示验证码,使用新密码登录成功
- 边界
- 未填邮箱点击“忘记密码?” -> 前端提示
- 验证码错误 -> 后端报“图片验证码不正确”,前端刷新验证码
- 新密码不符合规则/不一致 -> 前端拦截
- 重置后返回登录验证码缺失 -> 已修复,应始终显示
六、后续可优化
- 支持“邮箱验证码”与“图片验证码”双模式,配置切换
- 增加重置频次限制与安全日志
- 重置完成后可选自动聚焦密码框或引导提示
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>
|
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
|
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) { ... } public String getRegisterEmailCode(String email) { ... } public void cleanRegisterEmailCode(String email) { ... } public boolean hitRegisterLimit(String email) { ... }
|
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);
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
- 参数:
email、type=register|forget
- 返回:成功空体;频控或未配置邮件时返回错误信息
- POST
/web/account/register
- 参数:
email、nickName、registerPassword、checkCodeKey、checkCode、emailCode
- POST
/web/account/resetPassword
- 参数:
email、newPassword、checkCodeKey、checkCode、emailCode