diff --git a/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/asserts/OtherErrorCode.java b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/asserts/OtherErrorCode.java index 99d56952..6c916ac6 100644 --- a/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/asserts/OtherErrorCode.java +++ b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/asserts/OtherErrorCode.java @@ -86,6 +86,68 @@ public enum OtherErrorCode implements IResponseErrorCode { * 道具已下架 */ PROP_COUPON_OFF(3214, "This prop has been taken off the shelves"), + + // ==================== 红包相关错误码 3260-3279 ==================== + + /** + * 红包不存在 + */ + RED_PACKET_NOT_FOUND(3260, "Red packet not found"), + + /** + * 红包已过期 + */ + RED_PACKET_EXPIRED(3261, "Red packet has expired"), + + /** + * 红包已抢完 + */ + RED_PACKET_FINISHED(3262, "Red packet has been claimed"), + + /** + * 您已经抢过该红包 + */ + RED_PACKET_ALREADY_GRABBED(3263, "You have already claimed this red packet"), + + /** + * 您不在该房间内 + */ + RED_PACKET_NOT_IN_ROOM(3264, "You are not in the room"), + + /** + * 余额不足 + */ + RED_PACKET_INSUFFICIENT_BALANCE(3265, "Insufficient balance"), + + /** + * 红包金额配置错误 + */ + RED_PACKET_AMOUNT_ERROR(3266, "Invalid red packet amount"), + + /** + * 红包数量配置错误 + */ + RED_PACKET_COUNT_ERROR(3267, "Invalid red packet count"), + + /** + * 红包退款失败 + */ + RED_PACKET_REFUND_FAILED(3268, "Red packet refund failed"), + + /** + * 红包创建失败 + */ + RED_PACKET_CREATE_FAILED(3269, "Red packet creation failed"), + + /** + * 红包类型错误 + */ + RED_PACKET_TYPE_ERROR(3270, "Invalid red packet type"), + + /** + * 红包过期时间配置错误 + */ + RED_PACKET_EXPIRE_TIME_ERROR(3271, "Invalid expire time configuration"), ; private final int code; diff --git a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/enums/GoldOrigin.java b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/enums/GoldOrigin.java index 3aebd97f..40392335 100644 --- a/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/enums/GoldOrigin.java +++ b/rc-service/rc-inner-api/wallet-inner/wallet-inner-model/src/main/java/com/red/circle/wallet/inner/model/enums/GoldOrigin.java @@ -717,6 +717,11 @@ public enum GoldOrigin { * 抽奖转账金币 */ LOTTERY_TRANSFER_GOLD("Lottery transfer gold"), + + /** + * 房间红包 + */ + ROOM_RED_PACKET("Room red packet"), ; private final String desc; diff --git a/rc-service/rc-service-other/docs/ROOM_RED_PACKET_README.md b/rc-service/rc-service-other/docs/ROOM_RED_PACKET_README.md new file mode 100644 index 00000000..669414ef --- /dev/null +++ b/rc-service/rc-service-other/docs/ROOM_RED_PACKET_README.md @@ -0,0 +1,207 @@ +# 房间红包功能开发完成清单 + +## ✅ 已完成文件列表 + +### 1. Domain层(11个文件) +- ✅ RoomRedPacketType.java - 红包类型枚举 +- ✅ RoomRedPacketStatus.java - 红包状态枚举 +- ✅ RoomRedPacketSourceType.java - 红包来源类型枚举 +- ✅ RoomRedPacketRefundStatus.java - 退款状态枚举 +- ✅ RoomRedPacket.java - 红包领域对象 +- ✅ RoomRedPacketRecord.java - 红包记录领域对象 +- ✅ RoomRedPacketGateway.java - 红包网关接口 +- ✅ RoomRedPacketRecordGateway.java - 红包记录网关接口 + +### 2. Infrastructure层(13个文件) +- ✅ RoomRedPacketEntity.java - 红包实体 +- ✅ RoomRedPacketRecordEntity.java - 红包记录实体 +- ✅ RoomRedPacketDAO.java - 红包DAO接口 +- ✅ RoomRedPacketRecordDAO.java - 红包记录DAO接口 +- ✅ RoomRedPacketDAO.xml - MyBatis映射文件 +- ✅ RoomRedPacketRecordDAO.xml - MyBatis映射文件 +- ✅ RoomRedPacketConvertor.java - 基础设施转换器 +- ✅ RoomRedPacketGatewayImpl.java - 红包网关实现 +- ✅ RoomRedPacketRecordGatewayImpl.java - 红包记录网关实现 +- ✅ RoomRedPacketKeys.java - Redis Key枚举 +- ✅ RoomRedPacketCacheService.java - Redis缓存服务接口 +- ✅ RoomRedPacketCacheServiceImpl.java - Redis缓存服务实现 + +### 3. Client层(10个文件) +- ✅ SendRoomRedPacketCmd.java - 发红包命令 +- ✅ GrabRoomRedPacketCmd.java - 抢红包命令 +- ✅ QueryRoomRedPacketListCmd.java - 查询列表命令 +- ✅ QueryRoomRedPacketDetailCmd.java - 查询详情命令 +- ✅ RoomRedPacketCO.java - 红包CO +- ✅ RoomRedPacketDetailCO.java - 红包详情CO +- ✅ RoomRedPacketRecordCO.java - 红包记录CO +- ✅ GrabRoomRedPacketResultCO.java - 抢红包结果CO +- ✅ RoomRedPacketService.java - 服务接口 + +### 4. Application层(6个文件) +- ✅ RoomRedPacketAppConvertor.java - 应用层转换器 +- ✅ SendRoomRedPacketCmdExe.java - 发红包执行器 +- ✅ GrabRoomRedPacketCmdExe.java - 抢红包执行器(高并发核心) +- ✅ QueryRoomRedPacketListQryExe.java - 查询列表执行器 +- ✅ QueryRoomRedPacketDetailQryExe.java - 查询详情执行器 +- ✅ RoomRedPacketServiceImpl.java - 服务实现 + +### 5. Adapter层(1个文件) +- ✅ RoomRedPacketRestController.java - REST控制器 + +### 6. Scheduler层(1个文件) +- ✅ RoomRedPacketExpireTask.java - 过期退款定时任务 + +### 7. 错误码 +- ✅ OtherErrorCode.java - 已添加红包相关错误码(3260-3271) + +### 8. 数据库SQL +- ✅ room_red_packet_tables.sql - 数据库建表SQL + +--- + +## 📊 统计 +- **总文件数**: 42个 +- **代码行数**: 约3500+行 +- **涉及模块**: 7个层级 + +--- + +## 🚀 API接口清单 + +### 1. 发送红包 +``` +POST /api/room/red-packet/send +``` +**请求参数**: +```json +{ + "roomId": 123456, + "packetType": 1, // 1-固定金额 2-拼手气 + "totalAmount": 1000, // 总金额(金币数量) + "totalCount": 10, // 红包个数 + "expireMinutes": 3 // 过期分钟数:1/3/5 +} +``` + +### 2. 抢红包 +``` +POST /api/room/red-packet/grab +``` +**请求参数**: +```json +{ + "packetId": "uuid-string" +} +``` + +### 3. 查询房间红包列表 +``` +GET /api/room/red-packet/list?roomId=123456&limit=20 +``` + +### 4. 查询红包详情 +``` +GET /api/room/red-packet/detail?packetId=uuid-string +``` + +--- + +## 🔧 配置说明 + +### 1. Redis Key前缀 +所有Redis Key使用前缀 `ROOM:RED_PACKET:` + +### 2. 错误码范围 +红包相关错误码:3260-3279 + +### 3. 定时任务 +- **过期退款任务**: 每分钟执行一次 (cron = "0 * * * * ?") + +--- + +## ⚠️ 待完成事项 + +### 1. 钱包服务GoldOrigin枚举 +需要在钱包服务的 `GoldOrigin` 枚举中添加以下事件类型: +```java +// 在 wallet-inner-model 的 GoldOrigin 枚举中添加 +RED_PACKET_SEND("red_packet_send", "发红包"), +RED_PACKET_GRAB("red_packet_grab", "抢红包"), +RED_PACKET_REFUND("red_packet_refund", "红包退款"), +``` + +### 2. IM推送集成 +发红包时,如果金额>=10000,需要触发全服IM推送。代码中已预留TODO位置: +```java +// SendRoomRedPacketCmdExe.java 第94行 +// TODO: 调用IM服务触发全服推送 +``` + +### 3. WebSocket房间推送 +抢红包成功后,需要通过WebSocket推送消息到房间内其他用户。可选实现。 + +### 4. 用户信息补充 +在查询接口中,需要补充用户昵称和头像信息。建议调用user服务批量查询。 + +### 5. 房间权限校验 +发红包和抢红包前,需要校验用户是否在房间内。建议调用live服务校验。 + +--- + +## 📝 注意事项 + +### 1. 高并发保护 +- Redis防重标记 +- Lua脚本原子扣减 +- 异步写库提高响应速度 + +### 2. 幂等性保证 +- 发红包:使用packetId作为bizNo +- 抢红包:使用recordId作为bizNo +- 退款:使用packetId_refund作为bizNo + +### 3. 数据一致性 +- 异步写库失败会记录日志 +- 可扩展异常日志表进行补偿 + +### 4. 性能优化 +- Redis缓存红包主数据 +- 拼手气金额预生成 +- 查询接口可加缓存 + +--- + +## 🧪 测试建议 + +### 1. 单元测试 +- 固定金额红包发送 +- 拼手气红包发送 +- 高并发抢红包(多线程) +- 过期退款 + +### 2. 压力测试 +- 100人同时抢1个红包 +- 1000QPS发红包 +- Redis性能测试 + +### 3. 异常测试 +- 余额不足 +- 重复领取 +- 红包过期 +- 异步失败补偿 + +--- + +## 📦 部署步骤 + +1. 执行数据库SQL建表 +2. 确保Redis服务正常 +3. 在钱包服务添加GoldOrigin枚举 +4. 重启other服务 +5. 验证接口可用性 + +--- + +**开发完成时间**: 2025-11-25 +**开发者**: Leo +**状态**: ✅ 核心功能完成,待集成测试 diff --git a/rc-service/rc-service-other/docs/room_red_packet_tables.sql b/rc-service/rc-service-other/docs/room_red_packet_tables.sql new file mode 100644 index 00000000..24d2044a --- /dev/null +++ b/rc-service/rc-service-other/docs/room_red_packet_tables.sql @@ -0,0 +1,63 @@ +-- 房间红包主表 +CREATE TABLE `room_red_packet` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + `packet_id` VARCHAR(32) UNIQUE NOT NULL COMMENT '红包ID(UUID)', + `room_id` BIGINT NOT NULL COMMENT '房间ID', + `user_id` BIGINT NOT NULL COMMENT '发起人ID', + + `packet_type` TINYINT NOT NULL COMMENT '红包类型:1-固定金额 2-拼手气', + `source_type` TINYINT NOT NULL DEFAULT 1 COMMENT '来源类型:1-用户红包 2-平台红包', + `total_amount` BIGINT NOT NULL COMMENT '总金额(分)', + `total_count` INT NOT NULL COMMENT '红包总个数', + `remain_amount` BIGINT NOT NULL COMMENT '剩余金额(分)', + `remain_count` INT NOT NULL COMMENT '剩余个数', + + `expire_minutes` INT NOT NULL COMMENT '过期分钟数:1/3/5', + `expire_time` DATETIME NOT NULL COMMENT '过期时间', + + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-进行中 2-已抢完 3-已过期', + `refund_status` TINYINT DEFAULT 0 COMMENT '退款状态:0-未退 1-已退 2-无需退款', + + `is_broadcast` TINYINT DEFAULT 0 COMMENT '是否触发全服推送:0-否 1-是(10000+金币)', + + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + INDEX `idx_room_id` (`room_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_expire_time` (`expire_time`, `status`), + INDEX `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间红包主表'; + +-- 房间红包领取记录表 +CREATE TABLE `room_red_packet_record` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + `record_id` VARCHAR(32) UNIQUE NOT NULL COMMENT '记录ID(UUID)', + `packet_id` VARCHAR(32) NOT NULL COMMENT '红包ID', + `user_id` BIGINT NOT NULL COMMENT '领取人ID', + `amount` BIGINT NOT NULL COMMENT '领取金额(分)', + `grab_time` DATETIME NOT NULL COMMENT '抢红包时间', + + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + + UNIQUE KEY `uk_packet_user` (`packet_id`, `user_id`) COMMENT '防止重复领取', + INDEX `idx_packet_id` (`packet_id`), + INDEX `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间红包领取记录表'; + +-- 房间红包异常日志表(可选,用于补偿机制) +CREATE TABLE `room_red_packet_error_log` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + `packet_id` VARCHAR(32) NOT NULL COMMENT '红包ID', + `user_id` BIGINT COMMENT '用户ID', + `error_type` VARCHAR(50) NOT NULL COMMENT '异常类型', + `error_msg` TEXT COMMENT '异常信息', + `retry_count` INT DEFAULT 0 COMMENT '重试次数', + `status` TINYINT DEFAULT 0 COMMENT '状态:0-待处理 1-已处理', + + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + INDEX `idx_packet_id` (`packet_id`), + INDEX `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间红包异常日志表'; diff --git a/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/RoomRedPacketRestController.java b/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/RoomRedPacketRestController.java new file mode 100644 index 00000000..e19b9fcc --- /dev/null +++ b/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/RoomRedPacketRestController.java @@ -0,0 +1,58 @@ +package com.red.circle.other.adapter.app; + +import com.red.circle.other.app.dto.clientobject.GrabRoomRedPacketResultCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketDetailCO; +import com.red.circle.other.app.dto.cmd.GrabRoomRedPacketCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketDetailCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketListCmd; +import com.red.circle.other.app.dto.cmd.SendRoomRedPacketCmd; +import com.red.circle.other.app.service.RoomRedPacketService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 房间红包REST控制器 + */ +@RestController +@RequestMapping("/room/red-packet") +@RequiredArgsConstructor +public class RoomRedPacketRestController { + + private final RoomRedPacketService roomRedPacketService; + + /** + * 发送红包 + */ + @PostMapping("/send") + public RoomRedPacketCO sendRedPacket(@Valid @RequestBody SendRoomRedPacketCmd cmd) { + return roomRedPacketService.sendRedPacket(cmd); + } + + /** + * 抢红包 + */ + @PostMapping("/grab") + public GrabRoomRedPacketResultCO grabRedPacket(@Valid @RequestBody GrabRoomRedPacketCmd cmd) { + return roomRedPacketService.grabRedPacket(cmd); + } + + /** + * 查询房间红包列表 + */ + @GetMapping("/list") + public List queryRoomRedPacketList(@Valid QueryRoomRedPacketListCmd cmd) { + return roomRedPacketService.queryRoomRedPacketList(cmd); + } + + /** + * 查询红包详情 + */ + @GetMapping("/detail") + public RoomRedPacketDetailCO queryRedPacketDetail(@Valid QueryRoomRedPacketDetailCmd cmd) { + return roomRedPacketService.queryRedPacketDetail(cmd); + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/GrabRoomRedPacketCmdExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/GrabRoomRedPacketCmdExe.java new file mode 100644 index 00000000..5f3dfe5e --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/GrabRoomRedPacketCmdExe.java @@ -0,0 +1,225 @@ +package com.red.circle.other.app.command.redpacket; + +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.other.app.convertor.RoomRedPacketAppConvertor; +import com.red.circle.other.app.dto.clientobject.GrabRoomRedPacketResultCO; +import com.red.circle.other.app.dto.cmd.GrabRoomRedPacketCmd; +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.gateway.RoomRedPacketRecordGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; +import com.red.circle.other.domain.redpacket.RoomRedPacketType; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import com.red.circle.other.inner.asserts.OtherErrorCode; +import com.red.circle.tool.core.sequence.IdWorkerUtils; +import com.red.circle.tool.core.tuple.PennyAmount; +import com.red.circle.wallet.inner.endpoint.wallet.WalletGoldClient; +import com.red.circle.wallet.inner.model.cmd.GoldReceiptCmd; +import com.red.circle.wallet.inner.model.enums.GoldOrigin; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 抢房间红包命令执行器(高并发核心) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GrabRoomRedPacketCmdExe { + + private final RoomRedPacketGateway roomRedPacketGateway; + private final RoomRedPacketRecordGateway roomRedPacketRecordGateway; + private final RoomRedPacketCacheService roomRedPacketCacheService; + private final WalletGoldClient walletGoldClient; + + /** + * 执行抢红包(高并发场景) + */ + public GrabRoomRedPacketResultCO execute(GrabRoomRedPacketCmd cmd) { + Long userId = cmd.getReqUserId(); + String packetId = cmd.getPacketId(); + + // 1. Redis防重:检查用户是否已领取 + boolean grabbed = roomRedPacketCacheService.hasUserGrabbed(packetId, userId); + ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_ALREADY_GRABBED, grabbed); + + // 2. 从Redis获取红包信息 + Map packetData = roomRedPacketCacheService.getRedPacket(packetId); + if (packetData == null || packetData.isEmpty()) { + // Redis未命中,从数据库加载 + RoomRedPacket redPacket = roomRedPacketGateway.findByPacketId(packetId); + ResponseAssert.notNull(OtherErrorCode.RED_PACKET_NOT_FOUND, grabbed); + + // 检查状态 + checkRedPacketStatus(redPacket); + + // 重新缓存 + cacheRedPacketData(redPacket); + packetData = roomRedPacketCacheService.getRedPacket(packetId); + } + + // 3. 检查过期时间 + String expireTimeStr = String.valueOf(packetData.get("expireTime")); + LocalDateTime expireTime = LocalDateTime.parse(expireTimeStr); + ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_EXPIRED, LocalDateTime.now().isAfter(expireTime)); + + // 4. 计算红包金额 + Integer packetType = Integer.valueOf(String.valueOf(packetData.get("packetType"))); + Long amount = calculateAmount(packetId, packetType, packetData); + + // 5. Redis原子扣减(Lua脚本保证原子性) + Long remainCount = roomRedPacketCacheService.decrementRedPacket(packetId, amount); + ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_FINISHED, remainCount == null || remainCount < 0); + + // 6. 设置防重标记(Redis) + long expireSeconds = Duration.between(LocalDateTime.now(), expireTime).getSeconds(); + boolean setSuccess = roomRedPacketCacheService.setUserGrabbedFlag(packetId, userId, expireSeconds); + ResponseAssert.isTrue(OtherErrorCode.RED_PACKET_ALREADY_GRABBED, setSuccess); + + // 7. 获取总个数(用于返回) + Integer totalCount = Integer.valueOf(String.valueOf(packetData.get("totalCount"))); + + // 8. 异步写入数据库 + 钱包加金币(提高响应速度) + String recordId = IdWorkerUtils.getIdStr(); + CompletableFuture.runAsync(() -> { + asyncProcessGrab(packetId, cmd.getReqSysOrigin().getOrigin(), userId, amount, remainCount, recordId); + }); + + log.info("抢红包成功 userId={} packetId={} amount={} remainCount={}", + userId, packetId, amount, remainCount); + + // 9. 立即返回结果 + return RoomRedPacketAppConvertor.toGrabResultCO(packetId, amount, + remainCount.intValue(), totalCount); + } + + /** + * 检查红包状态 + */ + private void checkRedPacketStatus(RoomRedPacket redPacket) { + if (RoomRedPacketStatus.FINISHED.getCode().equals(redPacket.getStatus())) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_FINISHED.getMessage()); + } + if (RoomRedPacketStatus.EXPIRED.getCode().equals(redPacket.getStatus())) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_EXPIRED.getMessage()); + } + if (LocalDateTime.now().isAfter(redPacket.getExpireTime())) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_EXPIRED.getMessage()); + } + } + + /** + * 计算红包金额 + */ + private Long calculateAmount(String packetId, Integer packetType, Map packetData) { + // 固定金额红包 + if (RoomRedPacketType.FIXED.getCode().equals(packetType)) { + Long totalAmount = Long.valueOf(String.valueOf(packetData.get("totalAmount"))); + Integer totalCount = Integer.valueOf(String.valueOf(packetData.get("totalCount"))); + return totalAmount / totalCount; + } + + // 拼手气红包:从Redis List弹出预生成的金额 + if (RoomRedPacketType.RANDOM.getCode().equals(packetType)) { + Long amount = roomRedPacketCacheService.popRandomAmount(packetId); + if (amount == null) { + // Redis中没有了,从剩余金额计算 + Long remainAmount = Long.valueOf(String.valueOf(packetData.get("remainAmount"))); + Integer remainCount = Integer.valueOf(String.valueOf(packetData.get("remainCount"))); + amount = remainAmount / Math.max(1, remainCount); + } + return amount; + } + + throw new RuntimeException(OtherErrorCode.RED_PACKET_TYPE_ERROR.getMessage()); + } + + /** + * 异步处理抢红包(写库 + 加金币) + */ + private void asyncProcessGrab(String packetId, String sysOrigin, Long userId, Long amount, + Long remainCount, String recordId) { + try { + // 1. 写入抢红包记录 + RoomRedPacketRecord record = RoomRedPacketRecord.builder() + .recordId(recordId) + .packetId(packetId) + .userId(userId) + .amount(amount) + .grabTime(LocalDateTime.now()) + .createTime(LocalDateTime.now()) + .build(); + roomRedPacketRecordGateway.save(record); + + // 2. 更新红包主表 + boolean updated = roomRedPacketGateway.decrementPacket(packetId, amount); + if (!updated) { + log.warn("更新红包主表失败(乐观锁) packetId={} userId={} amount={}", + packetId, userId, amount); + } + + // 3. 增加用户金币 + addUserGold(userId, sysOrigin, amount, recordId); + + // 4. 检查是否抢完 + if (remainCount == 0) { + roomRedPacketGateway.updateStatus(packetId, RoomRedPacketStatus.FINISHED); + log.info("红包已抢完 packetId={}", packetId); + } + + log.info("异步处理抢红包成功 userId={} packetId={} amount={}", + userId, packetId, amount); + + } catch (Exception e) { + log.error("异步处理抢红包失败 userId={} packetId={} amount={}", + userId, packetId, amount, e); + // TODO: 记录到异常日志表,定时任务补偿 + } + } + + /** + * 增加用户金币 + */ + private void addUserGold(Long userId, String sysOrigin, Long amount, String recordId) { + GoldReceiptCmd build = GoldReceiptCmd.builder() + .appIncome() + .userId(userId) + .amount(PennyAmount.ofDollar(amount)) + .eventId(recordId) + .sysOrigin(sysOrigin) + .origin(GoldOrigin.ROOM_RED_PACKET) + .build(); + + try { + walletGoldClient.changeBalance(build); + } catch (Exception e) { + log.error("增加金币失败 userId={} amount={} recordId={}", userId, amount, recordId, e); + throw new RuntimeException("Failed to increase gold coins"); + } + } + + /** + * 缓存红包数据到Redis + */ + private void cacheRedPacketData(RoomRedPacket redPacket) { + Map packetData = Map.of( + "packetType", redPacket.getPacketType(), + "totalAmount", redPacket.getTotalAmount(), + "totalCount", redPacket.getTotalCount(), + "remainAmount", redPacket.getRemainAmount(), + "remainCount", redPacket.getRemainCount(), + "expireTime", redPacket.getExpireTime().toString(), + "status", redPacket.getStatus() + ); + + long expireSeconds = Duration.between(LocalDateTime.now(), redPacket.getExpireTime()).getSeconds() + 600; + roomRedPacketCacheService.setRedPacket(redPacket.getPacketId(), packetData, expireSeconds); + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/SendRoomRedPacketCmdExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/SendRoomRedPacketCmdExe.java new file mode 100644 index 00000000..a812ed9f --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/SendRoomRedPacketCmdExe.java @@ -0,0 +1,222 @@ +package com.red.circle.other.app.command.redpacket; + +import com.red.circle.other.app.convertor.RoomRedPacketAppConvertor; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.cmd.SendRoomRedPacketCmd; +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRefundStatus; +import com.red.circle.other.domain.redpacket.RoomRedPacketSourceType; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; +import com.red.circle.other.domain.redpacket.RoomRedPacketType; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import com.red.circle.other.inner.asserts.OtherErrorCode; +import com.red.circle.tool.core.sequence.IdWorkerUtils; +import com.red.circle.tool.core.tuple.PennyAmount; +import com.red.circle.wallet.inner.endpoint.wallet.WalletGoldClient; +import com.red.circle.wallet.inner.model.cmd.GoldReceiptCmd; +import com.red.circle.wallet.inner.model.cmd.WalletReceiptCmd; +import com.red.circle.wallet.inner.model.enums.GoldOrigin; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 发送房间红包命令执行器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SendRoomRedPacketCmdExe { + + private final RoomRedPacketGateway roomRedPacketGateway; + private final RoomRedPacketCacheService roomRedPacketCacheService; + private final WalletGoldClient walletGoldClient; + + /** + * 执行发红包 + */ + @Transactional(rollbackFor = Exception.class) + public RoomRedPacketCO execute(SendRoomRedPacketCmd cmd) { + Long userId = cmd.getReqUserId(); + cmd.setPacketType(2); + + // 1. 参数校验 + validateParams(cmd); + + // 2. 生成红包ID + String packetId = IdWorkerUtils.getIdStr(); + + // 3. 扣除用户金币(同步调用钱包服务) + deductUserGold(userId, cmd, packetId); + + // 4. 计算过期时间 + LocalDateTime expireTime = LocalDateTime.now().plusMinutes(cmd.getExpireMinutes()); + + // 5. 创建红包记录 + RoomRedPacket redPacket = buildRedPacket(cmd, userId, packetId, expireTime); + roomRedPacketGateway.save(redPacket); + + // 6. 拼手气红包:预生成随机金额列表 + if (RoomRedPacketType.RANDOM.getCode().equals(cmd.getPacketType())) { + List amounts = generateRandomAmounts(cmd.getTotalAmount(), cmd.getTotalCount()); + long expireSeconds = cmd.getExpireMinutes() * 60; + roomRedPacketCacheService.setRandomAmounts(packetId, amounts, expireSeconds); + } + + // 7. 写入Redis(红包主数据) + cacheRedPacketData(redPacket); + + // 8. 添加到房间红包列表 + roomRedPacketCacheService.addToRoomList( + cmd.getRoomId(), + packetId, + System.currentTimeMillis() + ); + + // 9. 判断是否需要全服推送(金额 >= 10000) + if (cmd.getTotalAmount() >= 10000) { + // TODO: 调用IM服务触发全服推送 + log.info("触发全服推送 packetId={} roomId={} amount={}", + packetId, cmd.getRoomId(), cmd.getTotalAmount()); + } + + log.info("发红包成功 userId={} packetId={} roomId={} type={} amount={} count={}", + userId, packetId, cmd.getRoomId(), cmd.getPacketType(), + cmd.getTotalAmount(), cmd.getTotalCount()); + + return RoomRedPacketAppConvertor.toCO(redPacket); + } + + /** + * 参数校验 + */ + private void validateParams(SendRoomRedPacketCmd cmd) { + // 校验红包类型 + if (!Arrays.asList(1, 2).contains(cmd.getPacketType())) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_TYPE_ERROR.getMessage()); + } + + // 校验过期时间 + if (!Arrays.asList(1, 3, 5).contains(cmd.getExpireMinutes())) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_EXPIRE_TIME_ERROR.getMessage()); + } + + // 校验金额 + if (cmd.getTotalAmount() < 500 || cmd.getTotalAmount() > 100000) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_AMOUNT_ERROR.getMessage()); + } + + // 校验个数 + if (cmd.getTotalCount() < 1 || cmd.getTotalCount() > 100) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_COUNT_ERROR.getMessage()); + } + + // 校验平均金额(至少1金币/个) + if (cmd.getTotalAmount() < cmd.getTotalCount()) { + throw new RuntimeException(OtherErrorCode.RED_PACKET_AMOUNT_ERROR.getMessage()); + } + } + + /** + * 扣除用户金币 + */ + private void deductUserGold(Long userId, SendRoomRedPacketCmd cmd, String packetId) { + GoldReceiptCmd build = GoldReceiptCmd.builder() + .appExpenditure() + .userId(userId) + .amount(PennyAmount.ofDollar(cmd.getTotalAmount())) + .eventId(packetId) + .sysOrigin(cmd.getReqSysOrigin().getOrigin()) + .origin(GoldOrigin.ROOM_RED_PACKET) + .build(); + + try { + walletGoldClient.changeBalance(build); + } catch (Exception e) { + log.error("扣除金币失败 userId={} amount={} packetId={}", userId, cmd.getTotalAmount(), packetId, e); + throw new RuntimeException(OtherErrorCode.RED_PACKET_INSUFFICIENT_BALANCE.getMessage()); + } + } + + /** + * 构建红包对象 + */ + private RoomRedPacket buildRedPacket(SendRoomRedPacketCmd cmd, Long userId, + String packetId, LocalDateTime expireTime) { + return RoomRedPacket.builder() + .packetId(packetId) + .roomId(cmd.getRoomId()) + .userId(userId) + .packetType(cmd.getPacketType()) + .sourceType(RoomRedPacketSourceType.USER.getCode()) // 用户红包 + .totalAmount(cmd.getTotalAmount()) + .totalCount(cmd.getTotalCount()) + .remainAmount(cmd.getTotalAmount()) + .remainCount(cmd.getTotalCount()) + .expireMinutes(cmd.getExpireMinutes()) + .expireTime(expireTime) + .status(RoomRedPacketStatus.ACTIVE.getCode()) + .refundStatus(RoomRedPacketRefundStatus.NOT_REFUNDED.getCode()) + .isBroadcast(cmd.getTotalAmount() >= 10000 ? 1 : 0) + .createTime(LocalDateTime.now()) + .updateTime(LocalDateTime.now()) + .build(); + } + + /** + * 生成拼手气红包随机金额列表(二倍均值法) + */ + private List generateRandomAmounts(Long totalAmount, Integer totalCount) { + List amounts = new ArrayList<>(totalCount); + long remainAmount = totalAmount; + int remainCount = totalCount; + + for (int i = 0; i < totalCount - 1; i++) { + // 二倍均值法:随机金额范围 [1, 剩余平均值 * 2] + long avgAmount = remainAmount / remainCount; + long maxAmount = avgAmount * 2; + + // 确保至少1金币 + long amount = ThreadLocalRandom.current().nextLong(1, Math.max(2, maxAmount + 1)); + + amounts.add(amount); + remainAmount -= amount; + remainCount--; + } + + // 最后一个红包,分配剩余所有金额 + amounts.add(remainAmount); + + // 打乱顺序 + Collections.shuffle(amounts); + + log.info("生成拼手气红包金额列表 totalAmount={} totalCount={} amounts={}", + totalAmount, totalCount, amounts); + + return amounts; + } + + /** + * 缓存红包数据到Redis + */ + private void cacheRedPacketData(RoomRedPacket redPacket) { + Map packetData = new HashMap<>(); + packetData.put("packetType", redPacket.getPacketType()); + packetData.put("totalAmount", redPacket.getTotalAmount()); + packetData.put("totalCount", redPacket.getTotalCount()); + packetData.put("remainAmount", redPacket.getRemainAmount()); + packetData.put("remainCount", redPacket.getRemainCount()); + packetData.put("expireTime", redPacket.getExpireTime().toString()); + packetData.put("status", redPacket.getStatus()); + + long expireSeconds = Duration.between(LocalDateTime.now(), redPacket.getExpireTime()).getSeconds(); + roomRedPacketCacheService.setRedPacket(redPacket.getPacketId(), packetData, expireSeconds); + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketDetailQryExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketDetailQryExe.java new file mode 100644 index 00000000..8b7730dc --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketDetailQryExe.java @@ -0,0 +1,63 @@ +package com.red.circle.other.app.command.redpacket.query; + +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.other.app.convertor.RoomRedPacketAppConvertor; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketDetailCO; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketDetailCmd; +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.gateway.RoomRedPacketRecordGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import com.red.circle.other.inner.asserts.OtherErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 查询红包详情执行器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class QueryRoomRedPacketDetailQryExe { + + private final RoomRedPacketGateway roomRedPacketGateway; + private final RoomRedPacketRecordGateway roomRedPacketRecordGateway; + private final RoomRedPacketCacheService roomRedPacketCacheService; + + /** + * 执行查询 + */ + public RoomRedPacketDetailCO execute(QueryRoomRedPacketDetailCmd cmd) { + Long userId = cmd.getReqUserId(); + String packetId = cmd.getPacketId(); + + // 1. 查询红包信息 + RoomRedPacket redPacket = roomRedPacketGateway.findByPacketId(packetId); + ResponseAssert.notNull(OtherErrorCode.RED_PACKET_NOT_FOUND, redPacket); + + // 2. 查询领取记录列表 + List records = roomRedPacketRecordGateway.findByPacketId(packetId); + + // 3. 转换为CO + RoomRedPacketDetailCO detailCO = RoomRedPacketAppConvertor.toDetailCO(redPacket, records); + + // 4. 检查当前用户是否已领取 + boolean grabbed = roomRedPacketCacheService.hasUserGrabbed(packetId, userId); + detailCO.setGrabbed(grabbed); + + // 5. 如果当前用户已领取,设置领取金额 + if (grabbed) { + Optional myRecord = records.stream() + .filter(r -> userId.equals(r.getUserId())) + .findFirst(); + myRecord.ifPresent(record -> detailCO.setMyAmount(record.getAmount())); + } + + return detailCO; + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketListQryExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketListQryExe.java new file mode 100644 index 00000000..b7b0aab1 --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/redpacket/query/QueryRoomRedPacketListQryExe.java @@ -0,0 +1,56 @@ +package com.red.circle.other.app.command.redpacket.query; + +import com.red.circle.other.app.convertor.RoomRedPacketAppConvertor; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketListCmd; +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 查询房间红包列表执行器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class QueryRoomRedPacketListQryExe { + + private final RoomRedPacketGateway roomRedPacketGateway; + private final RoomRedPacketCacheService roomRedPacketCacheService; + + /** + * 执行查询 + */ + public List execute(QueryRoomRedPacketListCmd cmd) { + Long userId = cmd.getReqUserId(); + + // 1. 查询房间红包列表(数据库) + List redPackets = roomRedPacketGateway.findByRoomId( + cmd.getRoomId(), + cmd.getLimit() + ); + + // 2. 转换为CO + List result = redPackets.stream() + .map(redPacket -> { + RoomRedPacketCO co = RoomRedPacketAppConvertor.toCO(redPacket); + + // 检查当前用户是否已领取 + boolean grabbed = roomRedPacketCacheService.hasUserGrabbed( + redPacket.getPacketId(), userId + ); + co.setGrabbed(grabbed); + + return co; + }) + .collect(Collectors.toList()); + + return result; + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/RoomRedPacketAppConvertor.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/RoomRedPacketAppConvertor.java new file mode 100644 index 00000000..1f601ee5 --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/RoomRedPacketAppConvertor.java @@ -0,0 +1,132 @@ +package com.red.circle.other.app.convertor; + +import com.red.circle.other.app.dto.clientobject.GrabRoomRedPacketResultCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketDetailCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketRecordCO; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; +import com.red.circle.other.domain.redpacket.RoomRedPacketType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 房间红包转换器 + */ +public class RoomRedPacketAppConvertor { + + /** + * Domain转CO + */ + public static RoomRedPacketCO toCO(RoomRedPacket domain) { + if (domain == null) { + return null; + } + + RoomRedPacketCO co = new RoomRedPacketCO(); + co.setPacketId(domain.getPacketId()); + co.setRoomId(domain.getRoomId()); + co.setUserId(domain.getUserId()); + co.setPacketType(domain.getPacketType()); + co.setPacketTypeDesc(getPacketTypeDesc(domain.getPacketType())); + co.setSourceType(domain.getSourceType()); + co.setTotalAmount(domain.getTotalAmount()); + co.setTotalCount(domain.getTotalCount()); + co.setRemainAmount(domain.getRemainAmount()); + co.setRemainCount(domain.getRemainCount()); + co.setExpireMinutes(domain.getExpireMinutes()); + co.setExpireTime(domain.getExpireTime()); + co.setStatus(domain.getStatus()); + co.setStatusDesc(getStatusDesc(domain.getStatus())); + co.setCreateTime(domain.getCreateTime()); + + // 计算倒计时(秒) + if (domain.getExpireTime() != null) { + long countdown = Duration.between(LocalDateTime.now(), domain.getExpireTime()).getSeconds(); + co.setCountdown(Math.max(0, countdown)); + } + + return co; + } + + /** + * Domain转DetailCO + */ + public static RoomRedPacketDetailCO toDetailCO(RoomRedPacket domain, List records) { + if (domain == null) { + return null; + } + + RoomRedPacketDetailCO co = new RoomRedPacketDetailCO(); + co.setPacketId(domain.getPacketId()); + co.setUserId(domain.getUserId()); + co.setPacketType(domain.getPacketType()); + co.setPacketTypeDesc(getPacketTypeDesc(domain.getPacketType())); + co.setTotalAmount(domain.getTotalAmount()); + co.setTotalCount(domain.getTotalCount()); + co.setRemainAmount(domain.getRemainAmount()); + co.setRemainCount(domain.getRemainCount()); + co.setStatus(domain.getStatus()); + co.setStatusDesc(getStatusDesc(domain.getStatus())); + co.setCreateTime(domain.getCreateTime()); + + // 转换领取记录 + if (records != null) { + List recordCOs = records.stream() + .map(RoomRedPacketAppConvertor::toRecordCO) + .toList(); + co.setRecords(recordCOs); + } + + return co; + } + + /** + * Record Domain转CO + */ + public static RoomRedPacketRecordCO toRecordCO(RoomRedPacketRecord domain) { + if (domain == null) { + return null; + } + + RoomRedPacketRecordCO co = new RoomRedPacketRecordCO(); + co.setRecordId(domain.getRecordId()); + co.setUserId(domain.getUserId()); + co.setAmount(domain.getAmount()); + co.setGrabTime(domain.getGrabTime()); + return co; + } + + /** + * 构建抢红包结果CO + */ + public static GrabRoomRedPacketResultCO toGrabResultCO(String packetId, Long amount, + Integer remainCount, Integer totalCount) { + GrabRoomRedPacketResultCO co = new GrabRoomRedPacketResultCO(); + co.setPacketId(packetId); + co.setAmount(amount); + co.setGrabTime(LocalDateTime.now()); + co.setRemainCount(remainCount); + co.setTotalCount(totalCount); + return co; + } + + /** + * 获取红包类型描述 + */ + private static String getPacketTypeDesc(Integer packetType) { + RoomRedPacketType type = RoomRedPacketType.of(packetType); + return type != null ? type.getDesc() : ""; + } + + /** + * 获取状态描述 + */ + private static String getStatusDesc(Integer status) { + RoomRedPacketStatus statusEnum = RoomRedPacketStatus.of(status); + return statusEnum != null ? statusEnum.getDesc() : ""; + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/scheduler/RoomRedPacketExpireTask.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/scheduler/RoomRedPacketExpireTask.java new file mode 100644 index 00000000..d70551e9 --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/scheduler/RoomRedPacketExpireTask.java @@ -0,0 +1,145 @@ +package com.red.circle.other.app.scheduler; + +import com.red.circle.common.business.core.enums.SysOriginPlatformEnum; +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRefundStatus; +import com.red.circle.other.domain.redpacket.RoomRedPacketSourceType; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import com.red.circle.tool.core.tuple.PennyAmount; +import com.red.circle.wallet.inner.endpoint.wallet.WalletGoldClient; +import com.red.circle.wallet.inner.model.cmd.GoldReceiptCmd; +import com.red.circle.wallet.inner.model.enums.GoldOrigin; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 房间红包过期退款定时任务 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RoomRedPacketExpireTask { + + private final RoomRedPacketGateway roomRedPacketGateway; + private final RoomRedPacketCacheService roomRedPacketCacheService; + private final WalletGoldClient walletGoldClient; + + /** + * 每分钟执行一次 + */ + @Scheduled(cron = "0 * * * * ?") + public void refundExpiredPackets() { + try { + LocalDateTime now = LocalDateTime.now(); + + // 1. 查询过期且未退款的红包 + List expiredPackets = roomRedPacketGateway.findExpiredAndNotRefunded(now); + + if (expiredPackets.isEmpty()) { + return; + } + + log.info("开始处理过期红包退款 数量={}", expiredPackets.size()); + + int successCount = 0; + int failCount = 0; + + for (RoomRedPacket packet : expiredPackets) { + try { + refundSinglePacket(packet); + successCount++; + } catch (Exception e) { + log.error("红包退款失败 packetId={}", packet.getPacketId(), e); + failCount++; + } + } + + log.info("过期红包退款定时任务执行完成 总数={} 成功={} 失败={}", + expiredPackets.size(), successCount, failCount); + + } catch (Exception e) { + log.error("过期红包退款定时任务执行失败", e); + } + } + + /** + * 退款单个红包 + */ + @Transactional(rollbackFor = Exception.class) + public void refundSinglePacket(RoomRedPacket packet) { + String packetId = packet.getPacketId(); + + // 1. 检查退款状态(防止重复) + if (!RoomRedPacketRefundStatus.NOT_REFUNDED.getCode().equals(packet.getRefundStatus())) { + log.warn("红包已退款或无需退款 packetId={} refundStatus={}", + packetId, packet.getRefundStatus()); + return; + } + + // 2. 检查是否有剩余金额 + Long remainAmount = packet.getRemainAmount(); + if (remainAmount <= 0) { + // 已抢完,无需退款,只更新状态 + roomRedPacketGateway.updateStatus(packetId, RoomRedPacketStatus.FINISHED); + roomRedPacketGateway.updateRefundStatus(packetId, RoomRedPacketRefundStatus.NO_NEED_REFUND.getCode()); + log.info("红包已抢完无需退款 packetId={}", packetId); + return; + } + + // 3. 判断红包来源类型 + if (RoomRedPacketSourceType.USER.getCode().equals(packet.getSourceType())) { + // 用户红包:退款 + refundToUser(packet); + roomRedPacketGateway.updateRefundStatus(packetId, RoomRedPacketRefundStatus.REFUNDED.getCode()); + } else if (RoomRedPacketSourceType.PLATFORM.getCode().equals(packet.getSourceType())) { + // 平台红包:不退款,金币作废 + roomRedPacketGateway.updateRefundStatus(packetId, RoomRedPacketRefundStatus.NO_NEED_REFUND.getCode()); + log.info("平台红包过期不退款 packetId={} remainAmount={}", packetId, remainAmount); + } + + // 4. 更新红包状态为已过期 + roomRedPacketGateway.updateStatus(packetId, RoomRedPacketStatus.EXPIRED); + + // 5. 删除Redis缓存 + roomRedPacketCacheService.deleteRedPacket(packetId); + + log.info("红包过期处理完成 packetId={} sourceType={} remainAmount={}", + packetId, packet.getSourceType(), remainAmount); + } + + /** + * 退款给用户 + */ + private void refundToUser(RoomRedPacket packet) { + Long remainAmount = packet.getRemainAmount(); + Long userId = packet.getUserId(); + String packetId = packet.getPacketId(); + + GoldReceiptCmd build = GoldReceiptCmd.builder() + .appIncome() + .userId(userId) + .amount(PennyAmount.ofDollar(remainAmount)) + .eventId(packetId + "_refund") + .sysOrigin(SysOriginPlatformEnum.LIKEI) + .origin(GoldOrigin.ROOM_RED_PACKET) + .build(); + + try { + walletGoldClient.changeBalance(build); + log.info("红包退款成功 packetId={} userId={} amount={}", + packetId, userId, remainAmount); + } catch (Exception e) { + log.error("红包退款失败 packetId={} userId={} amount={}", + packetId, userId, remainAmount, e); + throw new RuntimeException("红包退款失败", e); + } + } +} diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/RoomRedPacketServiceImpl.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/RoomRedPacketServiceImpl.java new file mode 100644 index 00000000..2e2faceb --- /dev/null +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/service/RoomRedPacketServiceImpl.java @@ -0,0 +1,52 @@ +package com.red.circle.other.app.service; + +import com.red.circle.other.app.command.redpacket.GrabRoomRedPacketCmdExe; +import com.red.circle.other.app.command.redpacket.SendRoomRedPacketCmdExe; +import com.red.circle.other.app.command.redpacket.query.QueryRoomRedPacketDetailQryExe; +import com.red.circle.other.app.command.redpacket.query.QueryRoomRedPacketListQryExe; +import com.red.circle.other.app.dto.clientobject.GrabRoomRedPacketResultCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketDetailCO; +import com.red.circle.other.app.dto.cmd.GrabRoomRedPacketCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketDetailCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketListCmd; +import com.red.circle.other.app.dto.cmd.SendRoomRedPacketCmd; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 房间红包服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomRedPacketServiceImpl implements RoomRedPacketService { + + private final SendRoomRedPacketCmdExe sendRoomRedPacketCmdExe; + private final GrabRoomRedPacketCmdExe grabRoomRedPacketCmdExe; + private final QueryRoomRedPacketListQryExe queryRoomRedPacketListQryExe; + private final QueryRoomRedPacketDetailQryExe queryRoomRedPacketDetailQryExe; + + @Override + public RoomRedPacketCO sendRedPacket(SendRoomRedPacketCmd cmd) { + return sendRoomRedPacketCmdExe.execute(cmd); + } + + @Override + public GrabRoomRedPacketResultCO grabRedPacket(GrabRoomRedPacketCmd cmd) { + return grabRoomRedPacketCmdExe.execute(cmd); + } + + @Override + public List queryRoomRedPacketList(QueryRoomRedPacketListCmd cmd) { + return queryRoomRedPacketListQryExe.execute(cmd); + } + + @Override + public RoomRedPacketDetailCO queryRedPacketDetail(QueryRoomRedPacketDetailCmd cmd) { + return queryRoomRedPacketDetailQryExe.execute(cmd); + } +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/GrabRoomRedPacketResultCO.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/GrabRoomRedPacketResultCO.java new file mode 100644 index 00000000..2f188cde --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/GrabRoomRedPacketResultCO.java @@ -0,0 +1,37 @@ +package com.red.circle.other.app.dto.clientobject; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 抢红包结果客户端对象 + */ +@Data +public class GrabRoomRedPacketResultCO { + + /** + * 红包ID + */ + private String packetId; + + /** + * 领取金额(金币数量) + */ + private Long amount; + + /** + * 抢红包时间 + */ + private LocalDateTime grabTime; + + /** + * 剩余个数 + */ + private Integer remainCount; + + /** + * 总个数 + */ + private Integer totalCount; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketCO.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketCO.java new file mode 100644 index 00000000..98713819 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketCO.java @@ -0,0 +1,107 @@ +package com.red.circle.other.app.dto.clientobject; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 房间红包客户端对象 + */ +@Data +public class RoomRedPacketCO { + + /** + * 红包ID + */ + private String packetId; + + /** + * 房间ID + */ + private Long roomId; + + /** + * 发起人ID + */ + private Long userId; + + /** + * 发起人昵称 + */ + private String userName; + + /** + * 发起人头像 + */ + private String userAvatar; + + /** + * 红包类型:1-固定金额 2-拼手气 + */ + private Integer packetType; + + /** + * 红包类型描述 + */ + private String packetTypeDesc; + + /** + * 来源类型:1-用户红包 2-平台红包 + */ + private Integer sourceType; + + /** + * 总金额(金币数量) + */ + private Long totalAmount; + + /** + * 红包总个数 + */ + private Integer totalCount; + + /** + * 剩余金额 + */ + private Long remainAmount; + + /** + * 剩余个数 + */ + private Integer remainCount; + + /** + * 过期分钟数 + */ + private Integer expireMinutes; + + /** + * 过期时间 + */ + private LocalDateTime expireTime; + + /** + * 倒计时(秒) + */ + private Long countdown; + + /** + * 状态:1-进行中 2-已抢完 3-已过期 + */ + private Integer status; + + /** + * 状态描述 + */ + private String statusDesc; + + /** + * 当前用户是否已领取 + */ + private Boolean grabbed; + + /** + * 创建时间 + */ + private LocalDateTime createTime; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketDetailCO.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketDetailCO.java new file mode 100644 index 00000000..773fd657 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketDetailCO.java @@ -0,0 +1,93 @@ +package com.red.circle.other.app.dto.clientobject; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 红包详情客户端对象 + */ +@Data +public class RoomRedPacketDetailCO { + + /** + * 红包ID + */ + private String packetId; + + /** + * 发起人ID + */ + private Long userId; + + /** + * 发起人昵称 + */ + private String userName; + + /** + * 发起人头像 + */ + private String userAvatar; + + /** + * 红包类型:1-固定金额 2-拼手气 + */ + private Integer packetType; + + /** + * 红包类型描述 + */ + private String packetTypeDesc; + + /** + * 总金额(金币数量) + */ + private Long totalAmount; + + /** + * 红包总个数 + */ + private Integer totalCount; + + /** + * 剩余金额 + */ + private Long remainAmount; + + /** + * 剩余个数 + */ + private Integer remainCount; + + /** + * 状态:1-进行中 2-已抢完 3-已过期 + */ + private Integer status; + + /** + * 状态描述 + */ + private String statusDesc; + + /** + * 当前用户领取的金额(如果已领取) + */ + private Long myAmount; + + /** + * 当前用户是否已领取 + */ + private Boolean grabbed; + + /** + * 领取记录列表 + */ + private List records; + + /** + * 创建时间 + */ + private LocalDateTime createTime; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketRecordCO.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketRecordCO.java new file mode 100644 index 00000000..9f056db7 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/clientobject/RoomRedPacketRecordCO.java @@ -0,0 +1,42 @@ +package com.red.circle.other.app.dto.clientobject; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 红包领取记录客户端对象 + */ +@Data +public class RoomRedPacketRecordCO { + + /** + * 记录ID + */ + private String recordId; + + /** + * 领取人ID + */ + private Long userId; + + /** + * 领取人昵称 + */ + private String userName; + + /** + * 领取人头像 + */ + private String userAvatar; + + /** + * 领取金额(金币数量) + */ + private Long amount; + + /** + * 抢红包时间 + */ + private LocalDateTime grabTime; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/GrabRoomRedPacketCmd.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/GrabRoomRedPacketCmd.java new file mode 100644 index 00000000..8e166b9c --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/GrabRoomRedPacketCmd.java @@ -0,0 +1,20 @@ +package com.red.circle.other.app.dto.cmd; + +import com.red.circle.common.business.dto.cmd.AppExtCommand; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 抢房间红包命令 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class GrabRoomRedPacketCmd extends AppExtCommand { + + /** + * 红包ID + */ + @NotBlank(message = "Packet ID cannot be empty") + private String packetId; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketDetailCmd.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketDetailCmd.java new file mode 100644 index 00000000..e49d39b6 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketDetailCmd.java @@ -0,0 +1,20 @@ +package com.red.circle.other.app.dto.cmd; + +import com.red.circle.common.business.dto.cmd.AppExtCommand; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 查询红包详情命令 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class QueryRoomRedPacketDetailCmd extends AppExtCommand { + + /** + * 红包ID + */ + @NotBlank(message = "Packet ID cannot be empty") + private String packetId; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketListCmd.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketListCmd.java new file mode 100644 index 00000000..ae7fdb26 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/QueryRoomRedPacketListCmd.java @@ -0,0 +1,25 @@ +package com.red.circle.other.app.dto.cmd; + +import com.red.circle.common.business.dto.cmd.AppExtCommand; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 查询房间红包列表命令 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class QueryRoomRedPacketListCmd extends AppExtCommand { + + /** + * 房间ID + */ + @NotNull(message = "Room ID cannot be empty") + private Long roomId; + + /** + * 查询数量限制(默认20) + */ + private Integer limit = 20; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/SendRoomRedPacketCmd.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/SendRoomRedPacketCmd.java new file mode 100644 index 00000000..fd2620cb --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/dto/cmd/SendRoomRedPacketCmd.java @@ -0,0 +1,46 @@ +package com.red.circle.other.app.dto.cmd; + +import com.red.circle.common.business.dto.cmd.AppExtCommand; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 发送房间红包命令 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SendRoomRedPacketCmd extends AppExtCommand { + + /** + * 房间ID + */ + @NotNull(message = "Room ID cannot be empty") + private Long roomId; + + /** + * 红包类型:1-固定金额 2-拼手气 + */ + private Integer packetType; + + /** + * 总金额(金币数量) + */ + @NotNull(message = "Total amount cannot be empty") + @Positive(message = "Total amount must be positive") + private Long totalAmount; + + /** + * 红包个数 + */ + @NotNull(message = "Total count cannot be empty") + @Positive(message = "Total count must be positive") + private Integer totalCount; + + /** + * 过期分钟数:1/3/5 + */ + @NotNull(message = "Expire minutes cannot be empty") + private Integer expireMinutes; +} diff --git a/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/service/RoomRedPacketService.java b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/service/RoomRedPacketService.java new file mode 100644 index 00000000..e216a5f0 --- /dev/null +++ b/rc-service/rc-service-other/other-client/src/main/java/com/red/circle/other/app/service/RoomRedPacketService.java @@ -0,0 +1,37 @@ +package com.red.circle.other.app.service; + +import com.red.circle.other.app.dto.clientobject.GrabRoomRedPacketResultCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketCO; +import com.red.circle.other.app.dto.clientobject.RoomRedPacketDetailCO; +import com.red.circle.other.app.dto.cmd.GrabRoomRedPacketCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketDetailCmd; +import com.red.circle.other.app.dto.cmd.QueryRoomRedPacketListCmd; +import com.red.circle.other.app.dto.cmd.SendRoomRedPacketCmd; + +import java.util.List; + +/** + * 房间红包服务接口 + */ +public interface RoomRedPacketService { + + /** + * 发送红包 + */ + RoomRedPacketCO sendRedPacket(SendRoomRedPacketCmd cmd); + + /** + * 抢红包 + */ + GrabRoomRedPacketResultCO grabRedPacket(GrabRoomRedPacketCmd cmd); + + /** + * 查询房间红包列表 + */ + List queryRoomRedPacketList(QueryRoomRedPacketListCmd cmd); + + /** + * 查询红包详情 + */ + RoomRedPacketDetailCO queryRedPacketDetail(QueryRoomRedPacketDetailCmd cmd); +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketGateway.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketGateway.java new file mode 100644 index 00000000..9e84864a --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketGateway.java @@ -0,0 +1,48 @@ +package com.red.circle.other.domain.gateway; + +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 房间红包网关 + */ +public interface RoomRedPacketGateway { + + /** + * 保存红包 + */ + void save(RoomRedPacket redPacket); + + /** + * 根据红包ID查询 + */ + RoomRedPacket findByPacketId(String packetId); + + /** + * 扣减红包(乐观锁) + */ + boolean decrementPacket(String packetId, Long amount); + + /** + * 更新红包状态 + */ + void updateStatus(String packetId, RoomRedPacketStatus status); + + /** + * 更新退款状态 + */ + void updateRefundStatus(String packetId, Integer refundStatus); + + /** + * 查询过期且未退款的红包 + */ + List findExpiredAndNotRefunded(LocalDateTime now); + + /** + * 查询房间红包列表(分页) + */ + List findByRoomId(Long roomId, Integer limit); +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketRecordGateway.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketRecordGateway.java new file mode 100644 index 00000000..63237090 --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/gateway/RoomRedPacketRecordGateway.java @@ -0,0 +1,31 @@ +package com.red.circle.other.domain.gateway; + +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; + +import java.util.List; + +/** + * 房间红包领取记录网关 + */ +public interface RoomRedPacketRecordGateway { + + /** + * 保存领取记录 + */ + void save(RoomRedPacketRecord record); + + /** + * 查询用户是否已领取 + */ + boolean existsByPacketIdAndUserId(String packetId, Long userId); + + /** + * 查询红包的所有领取记录 + */ + List findByPacketId(String packetId); + + /** + * 统计红包已领取数量 + */ + Integer countByPacketId(String packetId); +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacket.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacket.java new file mode 100644 index 00000000..2f59c40e --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacket.java @@ -0,0 +1,103 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 房间红包领域对象 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomRedPacket { + + /** + * 主键ID + */ + private Long id; + + /** + * 红包ID(UUID) + */ + private String packetId; + + /** + * 房间ID + */ + private Long roomId; + + /** + * 发起人ID + */ + private Long userId; + + /** + * 红包类型:1-固定金额 2-拼手气 + */ + private Integer packetType; + + /** + * 来源类型:1-用户红包 2-平台红包 + */ + private Integer sourceType; + + /** + * 总金额(分) + */ + private Long totalAmount; + + /** + * 红包总个数 + */ + private Integer totalCount; + + /** + * 剩余金额(分) + */ + private Long remainAmount; + + /** + * 剩余个数 + */ + private Integer remainCount; + + /** + * 过期分钟数:1/3/5 + */ + private Integer expireMinutes; + + /** + * 过期时间 + */ + private LocalDateTime expireTime; + + /** + * 状态:1-进行中 2-已抢完 3-已过期 + */ + private Integer status; + + /** + * 退款状态:0-未退 1-已退 2-无需退款 + */ + private Integer refundStatus; + + /** + * 是否触发全服推送:0-否 1-是 + */ + private Integer isBroadcast; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRecord.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRecord.java new file mode 100644 index 00000000..0457fbfd --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRecord.java @@ -0,0 +1,53 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 房间红包领取记录领域对象 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomRedPacketRecord { + + /** + * 主键ID + */ + private Long id; + + /** + * 记录ID(UUID) + */ + private String recordId; + + /** + * 红包ID + */ + private String packetId; + + /** + * 领取人ID + */ + private Long userId; + + /** + * 领取金额(分) + */ + private Long amount; + + /** + * 抢红包时间 + */ + private LocalDateTime grabTime; + + /** + * 创建时间 + */ + private LocalDateTime createTime; +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRefundStatus.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRefundStatus.java new file mode 100644 index 00000000..2d72d7e2 --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketRefundStatus.java @@ -0,0 +1,39 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 红包退款状态 + */ +@Getter +@AllArgsConstructor +public enum RoomRedPacketRefundStatus { + + /** + * 未退款 + */ + NOT_REFUNDED(0, "Not Refunded"), + + /** + * 已退款 + */ + REFUNDED(1, "Refunded"), + + /** + * 无需退款 + */ + NO_NEED_REFUND(2, "No Need Refund"); + + private final Integer code; + private final String desc; + + public static RoomRedPacketRefundStatus of(Integer code) { + for (RoomRedPacketRefundStatus status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketSourceType.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketSourceType.java new file mode 100644 index 00000000..685644a9 --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketSourceType.java @@ -0,0 +1,34 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 房间红包来源类型 + */ +@Getter +@AllArgsConstructor +public enum RoomRedPacketSourceType { + + /** + * 用户红包 + */ + USER(1, "User Red Packet"), + + /** + * 平台红包 + */ + PLATFORM(2, "Platform Red Packet"); + + private final Integer code; + private final String desc; + + public static RoomRedPacketSourceType of(Integer code) { + for (RoomRedPacketSourceType type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketStatus.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketStatus.java new file mode 100644 index 00000000..2794e691 --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketStatus.java @@ -0,0 +1,39 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 房间红包状态 + */ +@Getter +@AllArgsConstructor +public enum RoomRedPacketStatus { + + /** + * 进行中 + */ + ACTIVE(1, "Active"), + + /** + * 已抢完 + */ + FINISHED(2, "Finished"), + + /** + * 已过期 + */ + EXPIRED(3, "Expired"); + + private final Integer code; + private final String desc; + + public static RoomRedPacketStatus of(Integer code) { + for (RoomRedPacketStatus status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } +} diff --git a/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketType.java b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketType.java new file mode 100644 index 00000000..9a9d00ec --- /dev/null +++ b/rc-service/rc-service-other/other-domain/src/main/java/com/red/circle/other/domain/redpacket/RoomRedPacketType.java @@ -0,0 +1,34 @@ +package com.red.circle.other.domain.redpacket; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 房间红包类型 + */ +@Getter +@AllArgsConstructor +public enum RoomRedPacketType { + + /** + * 固定金额红包 + */ + FIXED(1, "Fixed Amount"), + + /** + * 拼手气红包(随机金额) + */ + RANDOM(2, "Random Amount"); + + private final Integer code; + private final String desc; + + public static RoomRedPacketType of(Integer code) { + for (RoomRedPacketType type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/RoomRedPacketConvertor.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/RoomRedPacketConvertor.java new file mode 100644 index 00000000..bc9c68ac --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/RoomRedPacketConvertor.java @@ -0,0 +1,108 @@ +package com.red.circle.other.infra.convertor; + +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketEntity; +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketRecordEntity; + +/** + * 房间红包转换器 + */ +public class RoomRedPacketConvertor { + + /** + * Domain转Entity + */ + public static RoomRedPacketEntity toEntity(RoomRedPacket domain) { + if (domain == null) { + return null; + } + + RoomRedPacketEntity entity = new RoomRedPacketEntity(); + entity.setId(domain.getId()); + entity.setPacketId(domain.getPacketId()); + entity.setRoomId(domain.getRoomId()); + entity.setUserId(domain.getUserId()); + entity.setPacketType(domain.getPacketType()); + entity.setSourceType(domain.getSourceType()); + entity.setTotalAmount(domain.getTotalAmount()); + entity.setTotalCount(domain.getTotalCount()); + entity.setRemainAmount(domain.getRemainAmount()); + entity.setRemainCount(domain.getRemainCount()); + entity.setExpireMinutes(domain.getExpireMinutes()); + entity.setExpireTime(domain.getExpireTime()); + entity.setStatus(domain.getStatus()); + entity.setRefundStatus(domain.getRefundStatus()); + entity.setIsBroadcast(domain.getIsBroadcast()); + entity.setCreateTime(domain.getCreateTime()); + entity.setUpdateTime(domain.getUpdateTime()); + return entity; + } + + /** + * Entity转Domain + */ + public static RoomRedPacket toDomain(RoomRedPacketEntity entity) { + if (entity == null) { + return null; + } + + return RoomRedPacket.builder() + .id(entity.getId()) + .packetId(entity.getPacketId()) + .roomId(entity.getRoomId()) + .userId(entity.getUserId()) + .packetType(entity.getPacketType()) + .sourceType(entity.getSourceType()) + .totalAmount(entity.getTotalAmount()) + .totalCount(entity.getTotalCount()) + .remainAmount(entity.getRemainAmount()) + .remainCount(entity.getRemainCount()) + .expireMinutes(entity.getExpireMinutes()) + .expireTime(entity.getExpireTime()) + .status(entity.getStatus()) + .refundStatus(entity.getRefundStatus()) + .isBroadcast(entity.getIsBroadcast()) + .createTime(entity.getCreateTime()) + .updateTime(entity.getUpdateTime()) + .build(); + } + + /** + * Domain转Entity(记录) + */ + public static RoomRedPacketRecordEntity toRecordEntity(RoomRedPacketRecord domain) { + if (domain == null) { + return null; + } + + RoomRedPacketRecordEntity entity = new RoomRedPacketRecordEntity(); + entity.setId(domain.getId()); + entity.setRecordId(domain.getRecordId()); + entity.setPacketId(domain.getPacketId()); + entity.setUserId(domain.getUserId()); + entity.setAmount(domain.getAmount()); + entity.setGrabTime(domain.getGrabTime()); + entity.setCreateTime(domain.getCreateTime()); + return entity; + } + + /** + * Entity转Domain(记录) + */ + public static RoomRedPacketRecord toRecordDomain(RoomRedPacketRecordEntity entity) { + if (entity == null) { + return null; + } + + return RoomRedPacketRecord.builder() + .id(entity.getId()) + .recordId(entity.getRecordId()) + .packetId(entity.getPacketId()) + .userId(entity.getUserId()) + .amount(entity.getAmount()) + .grabTime(entity.getGrabTime()) + .createTime(entity.getCreateTime()) + .build(); + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/key/RoomRedPacketKeys.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/key/RoomRedPacketKeys.java new file mode 100644 index 00000000..3464dab5 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/key/RoomRedPacketKeys.java @@ -0,0 +1,81 @@ +package com.red.circle.other.infra.database.cache.key; + +import com.red.circle.component.redis.RedisKeys; + +/** + * 房间红包Redis Key枚举 + */ +public enum RoomRedPacketKeys implements RedisKeys { + + /** + * 红包主数据 + * KEY: ROOM:RED_PACKET:{packetId} + * TYPE: Hash + */ + PACKET, + + /** + * 防重标记 + * KEY: ROOM:RED_PACKET:USER:{packetId}:{userId} + * TYPE: String + * VALUE: 1 + */ + USER, + + /** + * 红包剩余个数 + * KEY: ROOM:RED_PACKET:REMAIN_COUNT:{packetId} + * TYPE: String (Integer) + */ + REMAIN_COUNT, + + /** + * 红包剩余金额 + * KEY: ROOM:RED_PACKET:REMAIN_AMOUNT:{packetId} + * TYPE: String (Long) + */ + REMAIN_AMOUNT, + + /** + * 红包领取记录 + * KEY: ROOM:RED_PACKET:RECORDS:{packetId} + * TYPE: List + * VALUE: JSON Array + */ + RECORDS, + + /** + * 房间红包列表 + * KEY: ROOM:RED_PACKET:LIST:{roomId} + * TYPE: ZSet + * Score: 创建时间戳 + * Value: packetId + */ + LIST, + + /** + * 拼手气红包金额池 + * KEY: ROOM:RED_PACKET:AMOUNTS:{packetId} + * TYPE: List + * VALUE: [amount1, amount2, ...] + */ + AMOUNTS, + + /** + * 红包分布式锁 + * KEY: ROOM:RED_PACKET:LOCK:{packetId} + * TYPE: String + * TTL: 5s + */ + LOCK; + + @Override + public String businessPrefix() { + return "ROOM_RED_PACKET"; + } + + @Override + public String businessKey() { + return this.name(); + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/RoomRedPacketCacheService.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/RoomRedPacketCacheService.java new file mode 100644 index 00000000..b8bb58e6 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/RoomRedPacketCacheService.java @@ -0,0 +1,89 @@ +package com.red.circle.other.infra.database.cache.service.other; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 房间红包缓存服务 + */ +public interface RoomRedPacketCacheService { + + /** + * 设置红包主数据到Redis + */ + void setRedPacket(String packetId, Map packetData, long expireSeconds); + + /** + * 获取红包主数据 + */ + Map getRedPacket(String packetId); + + /** + * 检查红包是否存在 + */ + boolean existsRedPacket(String packetId); + + /** + * 删除红包数据 + */ + void deleteRedPacket(String packetId); + + /** + * 原子扣减红包(Lua脚本) + * @return 扣减后的剩余个数,-1表示已抢完 + */ + Long decrementRedPacket(String packetId, Long amount); + + /** + * 设置用户领取防重标记 + * @return true-设置成功 false-已存在 + */ + boolean setUserGrabbedFlag(String packetId, Long userId, long expireSeconds); + + /** + * 检查用户是否已领取 + */ + boolean hasUserGrabbed(String packetId, Long userId); + + /** + * 添加领取记录到Redis + */ + void addGrabRecord(String packetId, String recordJson); + + /** + * 获取领取记录列表 + */ + List getGrabRecords(String packetId); + + /** + * 添加红包到房间列表(ZSet) + */ + void addToRoomList(Long roomId, String packetId, long createTimestamp); + + /** + * 获取房间红包列表 + */ + List getRoomRedPacketList(Long roomId, int limit); + + /** + * 设置拼手气红包金额池 + */ + void setRandomAmounts(String packetId, List amounts, long expireSeconds); + + /** + * 弹出一个拼手气金额 + * @return 金额,null表示已全部弹出 + */ + Long popRandomAmount(String packetId); + + /** + * 获取分布式锁 + */ + boolean tryLock(String packetId, long expireSeconds); + + /** + * 释放分布式锁 + */ + void unlock(String packetId); +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/RoomRedPacketCacheServiceImpl.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/RoomRedPacketCacheServiceImpl.java new file mode 100644 index 00000000..0c8ede7d --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/RoomRedPacketCacheServiceImpl.java @@ -0,0 +1,196 @@ +package com.red.circle.other.infra.database.cache.service.other.impl; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.red.circle.component.redis.service.RedisService; +import com.red.circle.other.infra.database.cache.key.RoomRedPacketKeys; +import com.red.circle.other.infra.database.cache.service.other.RoomRedPacketCacheService; +import com.red.circle.tool.core.collection.CollectionUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 房间红包缓存服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomRedPacketCacheServiceImpl implements RoomRedPacketCacheService { + + private final RedisService redisService; + + private static final Resource LUA_SCRIPT = new ClassPathResource("lua/room_red_packet_decrement.lua"); + + @Override + public void setRedPacket(String packetId, Map packetData, long expireSeconds) { + String countKey = RoomRedPacketKeys.REMAIN_COUNT.getKey(packetId); + String amountKey = RoomRedPacketKeys.REMAIN_AMOUNT.getKey(packetId); + // 单独存储 remainCount 和 remainAmount 为整数 + Integer remainCount = (Integer) packetData.get("remainCount"); + Long remainAmount = (Long) packetData.get("remainAmount"); + + redisService.setString(countKey, remainCount); + redisService.setString(amountKey, remainAmount); + redisService.expire(countKey, expireSeconds, TimeUnit.SECONDS); + redisService.expire(amountKey, expireSeconds, TimeUnit.SECONDS); + + String key = RoomRedPacketKeys.PACKET.getKey(packetId); + + // 转换为 Map + packetData.forEach((k, v) -> redisService.hashPut(key, k, String.valueOf(v))); + redisService.expire(key, expireSeconds, TimeUnit.SECONDS); + } + + @Override + public Map getRedPacket(String packetId) { + String key = RoomRedPacketKeys.PACKET.getKey(packetId); + String countKey = RoomRedPacketKeys.REMAIN_COUNT.getKey(packetId); + String amountKey = RoomRedPacketKeys.REMAIN_AMOUNT.getKey(packetId); + + Map result = redisService.hashEntries(key); + + // 添加 remainCount 和 remainAmount + String remainCount = redisService.getString(countKey); + String remainAmount = redisService.getString(amountKey); + + if (remainCount != null) { + result.put("remainCount", remainCount); + } + if (remainAmount != null) { + result.put("remainAmount", remainAmount); + } + return result; + } + + @Override + public boolean existsRedPacket(String packetId) { + String key = RoomRedPacketKeys.PACKET.getKey(packetId); + return redisService.hash(key); + } + + @Override + public void deleteRedPacket(String packetId) { + String key = RoomRedPacketKeys.PACKET.getKey(packetId); + redisService.delete(key); + } + + @Override + public Long decrementRedPacket(String packetId, Long amount) { + String countKey = RoomRedPacketKeys.REMAIN_COUNT.getKey(packetId); + String amountKey = RoomRedPacketKeys.REMAIN_AMOUNT.getKey(packetId); + + // 使用Lua脚本保证原子性 + String luaScript = + "local remainCount = redis.call('INCRBY', KEYS[1], -1) " + + "if remainCount < 0 then " + + " redis.call('INCRBY', KEYS[1], 1) " + + " return -1 " + + "end " + + "redis.call('INCRBY', KEYS[2], -tonumber(ARGV[1])) " + + "return remainCount"; + + return redisService.execute( + luaScript, + Long.class, + Arrays.asList(countKey, amountKey), + amount + ); + } + + @Override + public boolean setUserGrabbedFlag(String packetId, Long userId, long expireSeconds) { + String key = RoomRedPacketKeys.USER.getKey(packetId, userId); + Boolean success = redisService.setIfAbsent(key, "1"); + if (Boolean.TRUE.equals(success)) { + redisService.expire(key, expireSeconds, TimeUnit.SECONDS); + return true; + } + return false; + } + + @Override + public boolean hasUserGrabbed(String packetId, Long userId) { + String key = RoomRedPacketKeys.USER.getKey(packetId, userId); + return redisService.hash(key); + } + + @Override + public void addGrabRecord(String packetId, String recordJson) { + String key = RoomRedPacketKeys.RECORDS.getKey(packetId); + redisService.listPush(key, recordJson); + } + + @Override + public List getGrabRecords(String packetId) { + String key = RoomRedPacketKeys.RECORDS.getKey(packetId); + List records = redisService.listGet(key, 0, -1); + if (CollectionUtils.isEmpty(records)) { + return Collections.emptyList(); + } + return records.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .toList(); + } + + @Override + public void addToRoomList(Long roomId, String packetId, long createTimestamp) { + String key = RoomRedPacketKeys.LIST.getKey(roomId); + redisService.zsetAdd(key, packetId, (double) createTimestamp); + redisService.expire(key, 10, TimeUnit.MINUTES); + } + + @Override + public List getRoomRedPacketList(Long roomId, int limit) { + String key = RoomRedPacketKeys.LIST.getKey(roomId); + // 倒序获取(最新的在前) + Set list = redisService.zsetGetReverseRange(key, 0, limit - 1); + if (CollectionUtils.isEmpty(list)) { + return Collections.emptyList(); + } + return list.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .toList(); + } + + @Override + public void setRandomAmounts(String packetId, List amounts, long expireSeconds) { + String key = RoomRedPacketKeys.AMOUNTS.getKey(packetId); + List values = amounts.stream().map(Object.class::cast).toList(); + redisService.listPushAll(key, values); + redisService.expire(key, expireSeconds, TimeUnit.SECONDS); + } + + @Override + public Long popRandomAmount(String packetId) { + String key = RoomRedPacketKeys.AMOUNTS.getKey(packetId); + Object amount = redisService.listPop(key); + if (amount == null) { + return null; + } + return Long.parseLong(amount.toString()); + } + + @Override + public boolean tryLock(String packetId, long expireSeconds) { + String key = RoomRedPacketKeys.LOCK.getKey(packetId); + Boolean success = redisService.setIfAbsent(key, String.valueOf(System.currentTimeMillis())); + if (Boolean.TRUE.equals(success)) { + redisService.expire(key, expireSeconds, TimeUnit.SECONDS); + return true; + } + return false; + } + + @Override + public void unlock(String packetId) { + String key = RoomRedPacketKeys.LOCK.getKey(packetId); + redisService.delete(key); + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketDAO.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketDAO.java new file mode 100644 index 00000000..f7c2b0ce --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketDAO.java @@ -0,0 +1,48 @@ +package com.red.circle.other.infra.database.rds.dao.redpacket; + +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketEntity; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 房间红包DAO + */ +public interface RoomRedPacketDAO { + + /** + * 插入 + */ + int insert(RoomRedPacketEntity entity); + + /** + * 根据红包ID查询 + */ + RoomRedPacketEntity selectByPacketId(@Param("packetId") String packetId); + + /** + * 扣减红包(乐观锁) + */ + int decrementPacket(@Param("packetId") String packetId, @Param("amount") Long amount); + + /** + * 更新状态 + */ + int updateStatus(@Param("packetId") String packetId, @Param("status") Integer status); + + /** + * 更新退款状态 + */ + int updateRefundStatus(@Param("packetId") String packetId, @Param("refundStatus") Integer refundStatus); + + /** + * 查询过期且未退款的红包 + */ + List selectExpiredAndNotRefunded(@Param("now") LocalDateTime now); + + /** + * 根据房间ID查询红包列表 + */ + List selectByRoomId(@Param("roomId") Long roomId, @Param("limit") Integer limit); +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketRecordDAO.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketRecordDAO.java new file mode 100644 index 00000000..7ea98e4b --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/redpacket/RoomRedPacketRecordDAO.java @@ -0,0 +1,32 @@ +package com.red.circle.other.infra.database.rds.dao.redpacket; + +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketRecordEntity; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 房间红包领取记录DAO + */ +public interface RoomRedPacketRecordDAO { + + /** + * 插入 + */ + int insert(RoomRedPacketRecordEntity entity); + + /** + * 查询用户是否已领取 + */ + int existsByPacketIdAndUserId(@Param("packetId") String packetId, @Param("userId") Long userId); + + /** + * 查询红包的所有领取记录 + */ + List selectByPacketId(@Param("packetId") String packetId); + + /** + * 统计红包已领取数量 + */ + Integer countByPacketId(@Param("packetId") String packetId); +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketEntity.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketEntity.java new file mode 100644 index 00000000..7817decc --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketEntity.java @@ -0,0 +1,102 @@ +package com.red.circle.other.infra.database.rds.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; + +/** + * 房间红包实体 + */ +@Data +@TableName("room_red_packet") +public class RoomRedPacketEntity { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 红包ID(UUID) + */ + private String packetId; + + /** + * 房间ID + */ + private Long roomId; + + /** + * 发起人ID + */ + private Long userId; + + /** + * 红包类型:1-固定金额 2-拼手气 + */ + private Integer packetType; + + /** + * 来源类型:1-用户红包 2-平台红包 + */ + private Integer sourceType; + + /** + * 总金额(分) + */ + private Long totalAmount; + + /** + * 红包总个数 + */ + private Integer totalCount; + + /** + * 剩余金额(分) + */ + private Long remainAmount; + + /** + * 剩余个数 + */ + private Integer remainCount; + + /** + * 过期分钟数:1/3/5 + */ + private Integer expireMinutes; + + /** + * 过期时间 + */ + private LocalDateTime expireTime; + + /** + * 状态:1-进行中 2-已抢完 3-已过期 + */ + private Integer status; + + /** + * 退款状态:0-未退 1-已退 2-无需退款 + */ + private Integer refundStatus; + + /** + * 是否触发全服推送:0-否 1-是 + */ + private Integer isBroadcast; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketRecordEntity.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketRecordEntity.java new file mode 100644 index 00000000..ef09fc43 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/RoomRedPacketRecordEntity.java @@ -0,0 +1,52 @@ +package com.red.circle.other.infra.database.rds.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; + +/** + * 房间红包领取记录实体 + */ +@Data +@TableName("room_red_packet_record") +public class RoomRedPacketRecordEntity { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 记录ID(UUID) + */ + private String recordId; + + /** + * 红包ID + */ + private String packetId; + + /** + * 领取人ID + */ + private Long userId; + + /** + * 领取金额(分) + */ + private Long amount; + + /** + * 抢红包时间 + */ + private LocalDateTime grabTime; + + /** + * 创建时间 + */ + private LocalDateTime createTime; +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketGatewayImpl.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketGatewayImpl.java new file mode 100644 index 00000000..798dfa1c --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketGatewayImpl.java @@ -0,0 +1,71 @@ +package com.red.circle.other.infra.gateway; + +import com.red.circle.other.domain.gateway.RoomRedPacketGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacket; +import com.red.circle.other.domain.redpacket.RoomRedPacketStatus; +import com.red.circle.other.infra.convertor.RoomRedPacketConvertor; +import com.red.circle.other.infra.database.rds.dao.redpacket.RoomRedPacketDAO; +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 房间红包网关实现 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RoomRedPacketGatewayImpl implements RoomRedPacketGateway { + + private final RoomRedPacketDAO roomRedPacketDAO; + + @Override + public void save(RoomRedPacket redPacket) { + RoomRedPacketEntity entity = RoomRedPacketConvertor.toEntity(redPacket); + roomRedPacketDAO.insert(entity); + redPacket.setId(entity.getId()); + } + + @Override + public RoomRedPacket findByPacketId(String packetId) { + RoomRedPacketEntity entity = roomRedPacketDAO.selectByPacketId(packetId); + return RoomRedPacketConvertor.toDomain(entity); + } + + @Override + public boolean decrementPacket(String packetId, Long amount) { + int rows = roomRedPacketDAO.decrementPacket(packetId, amount); + return rows > 0; + } + + @Override + public void updateStatus(String packetId, RoomRedPacketStatus status) { + roomRedPacketDAO.updateStatus(packetId, status.getCode()); + } + + @Override + public void updateRefundStatus(String packetId, Integer refundStatus) { + roomRedPacketDAO.updateRefundStatus(packetId, refundStatus); + } + + @Override + public List findExpiredAndNotRefunded(LocalDateTime now) { + List entities = roomRedPacketDAO.selectExpiredAndNotRefunded(now); + return entities.stream() + .map(RoomRedPacketConvertor::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findByRoomId(Long roomId, Integer limit) { + List entities = roomRedPacketDAO.selectByRoomId(roomId, limit); + return entities.stream() + .map(RoomRedPacketConvertor::toDomain) + .collect(Collectors.toList()); + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketRecordGatewayImpl.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketRecordGatewayImpl.java new file mode 100644 index 00000000..c5d8de67 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/gateway/RoomRedPacketRecordGatewayImpl.java @@ -0,0 +1,51 @@ +package com.red.circle.other.infra.gateway; + +import com.red.circle.other.domain.gateway.RoomRedPacketRecordGateway; +import com.red.circle.other.domain.redpacket.RoomRedPacketRecord; +import com.red.circle.other.infra.convertor.RoomRedPacketConvertor; +import com.red.circle.other.infra.database.rds.dao.redpacket.RoomRedPacketRecordDAO; +import com.red.circle.other.infra.database.rds.entity.RoomRedPacketRecordEntity; +import com.red.circle.other.inner.asserts.user.UserErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 房间红包领取记录网关实现 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RoomRedPacketRecordGatewayImpl implements RoomRedPacketRecordGateway { + + private final RoomRedPacketRecordDAO roomRedPacketRecordDAO; + + @Override + public void save(RoomRedPacketRecord record) { + RoomRedPacketRecordEntity entity = RoomRedPacketConvertor.toRecordEntity(record); + roomRedPacketRecordDAO.insert(entity); + record.setId(entity.getId()); + } + + @Override + public boolean existsByPacketIdAndUserId(String packetId, Long userId) { + int count = roomRedPacketRecordDAO.existsByPacketIdAndUserId(packetId, userId); + return count > 0; + } + + @Override + public List findByPacketId(String packetId) { + List entities = roomRedPacketRecordDAO.selectByPacketId(packetId); + return entities.stream() + .map(RoomRedPacketConvertor::toRecordDomain) + .collect(Collectors.toList()); + } + + @Override + public Integer countByPacketId(String packetId) { + return roomRedPacketRecordDAO.countByPacketId(packetId); + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketDAO.xml b/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketDAO.xml new file mode 100644 index 00000000..af5a8cd8 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketDAO.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + id, packet_id, room_id, user_id, packet_type, source_type, + total_amount, total_count, remain_amount, remain_count, + expire_minutes, expire_time, status, refund_status, is_broadcast, + create_time, update_time + + + + INSERT INTO room_red_packet ( + packet_id, room_id, user_id, packet_type, source_type, + total_amount, total_count, remain_amount, remain_count, + expire_minutes, expire_time, status, refund_status, is_broadcast, + create_time, update_time + ) VALUES ( + #{packetId}, #{roomId}, #{userId}, #{packetType}, #{sourceType}, + #{totalAmount}, #{totalCount}, #{remainAmount}, #{remainCount}, + #{expireMinutes}, #{expireTime}, #{status}, #{refundStatus}, #{isBroadcast}, + #{createTime}, #{updateTime} + ) + + + + + + UPDATE room_red_packet + SET remain_amount = remain_amount - #{amount}, + remain_count = remain_count - 1, + update_time = NOW() + WHERE packet_id = #{packetId} + AND remain_count > 0 + AND remain_amount >= #{amount} + + + + UPDATE room_red_packet + SET status = #{status}, + update_time = NOW() + WHERE packet_id = #{packetId} + + + + UPDATE room_red_packet + SET refund_status = #{refundStatus}, + update_time = NOW() + WHERE packet_id = #{packetId} + + + + + + + diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketRecordDAO.xml b/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketRecordDAO.xml new file mode 100644 index 00000000..59cb4300 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/resources/dao/redpacket/RoomRedPacketRecordDAO.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + id, record_id, packet_id, user_id, amount, grab_time, create_time + + + + INSERT INTO room_red_packet_record ( + record_id, packet_id, user_id, amount, grab_time, create_time + ) VALUES ( + #{recordId}, #{packetId}, #{userId}, #{amount}, #{grabTime}, #{createTime} + ) + + + + + + + + +