本文最后更新于61 天前,其中的信息可能已经过时,如有错误请发送邮件到big_fw@foxmail.com1.easylive-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
// 存储提供者: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 示例(根据环境调整):
storage: provider: miniominio: endpoint: http://192.168.32.1:9000 accessKey: 你的AccessKey secretKey: 你的SecretKey bucket: easylive# 一次性迁移(可选)migrate: local2minio: enabled: true deleteLocal: false二、对象存储抽象与实现2.1 统一抽象接口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 实现(按配置条件装配)@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 本地实现(默认启用,便于快速回退)@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,并清理本地临时文件):
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,完成后整目录上传,并删除本地输出目录。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,可选删除本地。
@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文件@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 接口扩展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 实现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
.bucket(appConfig.getMinioBucket())
.prefix(prefix)
.recursive(true)
.build());
for (Result
Item item = r.get();
client.removeObject(RemoveObjectArgs.builder()
.bucket(appConfig.getMinioBucket())
.object(item.objectName())
.build());
}
}2.3 本地存储实现(回退)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 调用。
@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 客户端@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 目录前缀,失败时回退本地递归删除。// 删除封面(优先通过资源服务删除,失败再回退到本地)
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=DEBUGlogging.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一、依赖与基础配置web、resource 模块引入依赖
开关与日志建议# 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 模块)// 关键定义,省略 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 模块)// com.easylive.mq.MqPublisher
@Component
@RequiredArgsConstructor
public class MqPublisher {
private final RocketMQTemplate rocketMQTemplate;
public void send(String topic, Object payload) {
rocketMQTemplate.convertAndSend(topic, payload);
}
}播放事件发布// 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);
}
}转码任务发布// 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(...)示例(播放上报处):
// com.easylive.controller.FileController 片段
playEventPublisher.publishPlayEvent(new VideoPlayEvent(videoId, userId, fileIndex, System.currentTimeMillis()));示例(稿件提交触发转码):
// com.easylive.service.impl.VideoInfoPostServiceImpl 片段
transcodePublisher.publishTranscode(msg);三、消费端(Consumer)播放事件消费(替换原 Redis 队列 + 线程池)// com.easylive.mq.PlayEventConsumer
@Component
@RocketMQMessageListener(topic = "video-play", consumerGroup = "play-cg")
@RequiredArgsConstructor
@Slf4j
public class PlayEventConsumer implements RocketMQListener
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 上传)// com.easylive.mq.TranscodeConsumer(resource 模块)
@Component
@RocketMQMessageListener(topic = "video-transcode", consumerGroup = "transcode-cg")
@RequiredArgsConstructor
@Slf4j
public class TranscodeConsumer implements RocketMQListener
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 两处)增加条件开关:@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.xmleasylive-cloud-resource/pom.xmlDTO:easylive-cloud-common/.../mq/dto/VideoTranscodeMessage.javaeasylive-cloud-common/.../mq/dto/VideoPlayEvent.java发布端:easylive-cloud-web/.../mq/MqPublisher.javaeasylive-cloud-web/.../component/PlayEventPublisher.javaeasylive-cloud-web/.../component/TranscodePublisher.java消费端:easylive-cloud-web/.../mq/PlayEventConsumer.javaeasylive-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 +1video_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(新客户端),逐步替换已废弃 API5.前端美化-美化登录页面二次元品牌风 + 粉色主色 + 保留插画 + 强动效
原
现
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
// 忘记密码:仅校验邮箱存在
@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
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 阶段引用未初始化报错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: "两次输入的密码不一致" },
],
};进入重置模式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("请完成图片验证码并设置新密码");
};提交重置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();
});
};返回登录(关键小修复:强制显示并刷新验证码,防止“验证码不能为空”)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 toolRan tool
7.邮箱验证码功能开发笔记背景与目标注册与忘记密码场景需要“双重校验”:图片验证码 + 邮箱验证码。登录仅保留图片验证码。加入发送频控与验证码有效期,避免刷接口和暴力破解。一、后端改造1) 依赖与配置新增邮件发送依赖(easylive-cloud-web/pom.xml)
Nacos 配置(示例,QQ 邮箱)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 键位与工具在常量中新增键前缀(注册/忘记密码通道分别限流与存码)// 找回密码
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 中封装保存/读取/清理及限流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) 接口与业务流程新增发送验证码接口(注册/忘记密码共用)@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);
}注册接口新增邮箱验证码校验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);
}重置密码新增邮箱验证码校验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) APIsendEmailCode: server_web + "/account/sendEmailCode",2) UI 与交互(关键片段)注册页与忘记密码面板新增“邮箱验证码 + 发送验证码”区,倒计时 60s
{{ mailCountdown>0 ? mailCountdown + 's' : '发送验证码' }}
注册提交携带邮箱验证码;忘记密码提交同样携带if (opType.value == 0) {
params.emailCode = formData.value.emailCode;
}const params = { email: formData.value.email, newPassword: ..., checkCodeKey: ..., emailCode: formData.value.emailCode };发送验证码逻辑与邮箱本地校验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);
};忘记密码时禁用邮箱输入,避免被篡改
.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、emailCodePOST /web/account/resetPassword参数:email、newPassword、checkCodeKey、checkCode、emailCode