新增用户红包功能

This commit is contained in:
tianfeng 2026-01-09 18:14:02 +08:00
parent a77872c681
commit d2a3c8e6e1
26 changed files with 1440 additions and 0 deletions

View File

@ -164,6 +164,10 @@ public enum OtherErrorCode implements IResponseErrorCode {
*/
DEVICE_ALREADY_USED(3274, "Device has been used by another account"),
CANNOT_SEND_TO_SELF(40301, "You can't give yourself red envelopes"),
NOT_RED_PACKET_RECEIVER(40302, "You are not a red envelope recipient"),
;
private final int code;

View File

@ -732,6 +732,11 @@ public enum GoldOrigin {
* 家族宝箱奖励
*/
FAMILY_BOX_REWARD("Family box reward"),
/**
* 房间红包
*/
USER_RED_PACKET("User red packet"),
;
private final String desc;

View File

@ -0,0 +1,46 @@
package com.red.circle.other.adapter.app;
import com.red.circle.other.app.dto.clientobject.GrabUserRedPacketResultCO;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.app.dto.cmd.GrabUserRedPacketCmd;
import com.red.circle.other.app.dto.cmd.QueryUserRedPacketDetailCmd;
import com.red.circle.other.app.dto.cmd.SendUserRedPacketCmd;
import com.red.circle.other.app.service.UserRedPacketService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 用户红包REST控制器
*/
@RestController
@RequestMapping("/user/red-packet")
@RequiredArgsConstructor
public class UserRedPacketRestController {
private final UserRedPacketService userRedPacketService;
/**
* 发送用户红包
*/
@PostMapping("/send")
public UserRedPacketCO sendRedPacket(@Valid @RequestBody SendUserRedPacketCmd cmd) {
return userRedPacketService.sendRedPacket(cmd);
}
/**
* 领取用户红包
*/
@PostMapping("/grab")
public GrabUserRedPacketResultCO grabRedPacket(@Valid @RequestBody GrabUserRedPacketCmd cmd) {
return userRedPacketService.grabRedPacket(cmd);
}
/**
* 查询红包详情
*/
@GetMapping("/detail")
public UserRedPacketCO queryRedPacketDetail(@Valid QueryUserRedPacketDetailCmd cmd) {
return userRedPacketService.queryRedPacketDetail(cmd);
}
}

View File

@ -0,0 +1,153 @@
package com.red.circle.other.app.command.redpacket;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.other.app.convertor.UserRedPacketAppConvertor;
import com.red.circle.other.app.dto.clientobject.GrabUserRedPacketResultCO;
import com.red.circle.other.app.dto.cmd.GrabUserRedPacketCmd;
import com.red.circle.other.domain.gateway.UserRedPacketGateway;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.domain.redpacket.UserRedPacketStatus;
import com.red.circle.other.infra.database.cache.service.other.UserRedPacketCacheService;
import com.red.circle.other.inner.asserts.OtherErrorCode;
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;
import java.util.concurrent.Executor;
/**
* 领取用户红包命令执行器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GrabUserRedPacketCmdExe {
private final UserRedPacketGateway userRedPacketGateway;
private final UserRedPacketCacheService userRedPacketCacheService;
private final WalletGoldClient walletGoldClient;
private final Executor redPacketAsyncExecutor;
/**
* 执行领取红包
*/
public GrabUserRedPacketResultCO execute(GrabUserRedPacketCmd cmd) {
Long userId = cmd.getReqUserId();
String packetId = cmd.getPacketId();
boolean grabbed = userRedPacketCacheService.hasUserGrabbed(packetId, userId);
ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_ALREADY_GRABBED, grabbed);
Map<Object, Object> packetData = userRedPacketCacheService.getRedPacket(packetId);
if (packetData == null || packetData.isEmpty()) {
UserRedPacket redPacket = userRedPacketGateway.findByPacketId(packetId);
ResponseAssert.notNull(OtherErrorCode.RED_PACKET_NOT_FOUND, redPacket);
checkRedPacketStatus(redPacket, userId);
cacheRedPacketData(redPacket);
packetData = userRedPacketCacheService.getRedPacket(packetId);
}
String expireTimeStr = String.valueOf(packetData.get("expireTime"));
LocalDateTime expireTime = LocalDateTime.parse(expireTimeStr);
ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_EXPIRED, LocalDateTime.now().isAfter(expireTime));
Long receiverUserId = Long.valueOf(String.valueOf(packetData.get("receiverUserId")));
ResponseAssert.isTrue(OtherErrorCode.NOT_RED_PACKET_RECEIVER, userId.equals(receiverUserId));
Integer status = Integer.valueOf(String.valueOf(packetData.get("status")));
ResponseAssert.isTrue(OtherErrorCode.RED_PACKET_ALREADY_GRABBED, UserRedPacketStatus.PENDING.getCode().equals(status));
Long amount = Long.valueOf(String.valueOf(packetData.get("totalAmount")));
Long senderUserId = Long.valueOf(String.valueOf(packetData.get("senderUserId")));
long expireSeconds = Math.max(60, Duration.between(LocalDateTime.now(), expireTime).getSeconds());
boolean setSuccess = userRedPacketCacheService.setUserGrabbedFlag(packetId, userId, expireSeconds);
ResponseAssert.isTrue(OtherErrorCode.RED_PACKET_ALREADY_GRABBED, setSuccess);
CompletableFuture.runAsync(() -> {
asyncProcessGrab(packetId, cmd.getReqSysOrigin().getOrigin(), userId, amount);
}, redPacketAsyncExecutor);
log.info("领取用户红包成功 userId={} packetId={} amount={}", userId, packetId, amount);
return UserRedPacketAppConvertor.toGrabResultCO(packetId, amount, senderUserId);
}
/**
* 检查红包状态
*/
private void checkRedPacketStatus(UserRedPacket redPacket, Long userId) {
ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_ALREADY_GRABBED,
UserRedPacketStatus.GRABBED.getCode().equals(redPacket.getStatus()));
ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_EXPIRED,
UserRedPacketStatus.EXPIRED.getCode().equals(redPacket.getStatus()));
ResponseAssert.isFalse(OtherErrorCode.RED_PACKET_EXPIRED,
LocalDateTime.now().isAfter(redPacket.getExpireTime()));
ResponseAssert.isTrue(OtherErrorCode.NOT_RED_PACKET_RECEIVER,
userId.equals(redPacket.getReceiverUserId()));
}
/**
* 异步处理领取
*/
private void asyncProcessGrab(String packetId, String sysOrigin, Long userId, Long amount) {
try {
LocalDateTime grabbedAt = LocalDateTime.now();
boolean updated = userRedPacketGateway.updateToGrabbed(packetId, grabbedAt);
if (!updated) {
log.warn("更新红包状态失败 packetId={} userId={}", packetId, userId);
}
addUserGold(userId, sysOrigin, amount, packetId);
} catch (Exception e) {
log.error("异步处理领取失败 userId={} packetId={} amount={}", userId, packetId, amount, e);
}
}
/**
* 增加用户金币
*/
private void addUserGold(Long userId, String sysOrigin, Long amount, String packetId) {
GoldReceiptCmd build = GoldReceiptCmd.builder()
.appIncome()
.userId(userId)
.amount(PennyAmount.ofDollar(amount))
.eventId(packetId + "_grab")
.sysOrigin(sysOrigin)
.origin(GoldOrigin.USER_RED_PACKET)
.build();
try {
walletGoldClient.changeBalance(build);
} catch (Exception e) {
log.error("增加金币失败 userId={} amount={} packetId={}", userId, amount, packetId, e);
throw new RuntimeException("Failed to increase gold coins");
}
}
/**
* 缓存红包数据
*/
private void cacheRedPacketData(UserRedPacket redPacket) {
Map<String, Object> packetData = Map.of(
"senderUserId", redPacket.getSenderUserId(),
"receiverUserId", redPacket.getReceiverUserId(),
"totalAmount", redPacket.getTotalAmount(),
"expireTime", redPacket.getExpireTime().toString(),
"status", redPacket.getStatus()
);
long expireSeconds = Duration.between(LocalDateTime.now(), redPacket.getExpireTime()).getSeconds();
userRedPacketCacheService.setRedPacket(redPacket.getPacketId(), packetData, expireSeconds);
}
}

View File

@ -0,0 +1,135 @@
package com.red.circle.other.app.command.redpacket;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.other.app.convertor.UserRedPacketAppConvertor;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.app.dto.cmd.SendUserRedPacketCmd;
import com.red.circle.other.domain.gateway.UserRedPacketGateway;
import com.red.circle.other.domain.gateway.user.UserProfileGateway;
import com.red.circle.other.domain.redpacket.RoomRedPacketType;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.domain.redpacket.UserRedPacketStatus;
import com.red.circle.other.infra.database.cache.service.other.UserRedPacketCacheService;
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.error.WalletErrorCode;
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 org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 发送用户红包命令执行器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SendUserRedPacketCmdExe {
private final UserRedPacketGateway userRedPacketGateway;
private final UserRedPacketCacheService userRedPacketCacheService;
private final WalletGoldClient walletGoldClient;
/**
* 执行发用户红包
*/
@Transactional(rollbackFor = Exception.class)
public UserRedPacketCO execute(SendUserRedPacketCmd cmd) {
Long senderUserId = cmd.getReqUserId();
Long receiverUserId = cmd.getReceiverUserId();
validateParams(cmd);
ResponseAssert.isFalse(OtherErrorCode.CANNOT_SEND_TO_SELF, senderUserId.equals(receiverUserId));
String packetId = IdWorkerUtils.getIdStr();
LocalDateTime expireTime = LocalDateTime.now().plusHours(24);
UserRedPacket redPacket = buildRedPacket(cmd, senderUserId, receiverUserId, packetId, expireTime);
userRedPacketGateway.save(redPacket);
deductUserGold(senderUserId, cmd, packetId);
cacheRedPacketData(redPacket);
log.info("发用户红包成功 senderUserId={} receiverUserId={} packetId={} amount={}",
senderUserId, receiverUserId, packetId, cmd.getTotalAmount());
return UserRedPacketAppConvertor.toCO(redPacket);
}
/**
* 参数校验
*/
private void validateParams(SendUserRedPacketCmd cmd) {
if (cmd.getTotalAmount() < 100 || cmd.getTotalAmount() > 50000) {
throw new RuntimeException(OtherErrorCode.RED_PACKET_AMOUNT_ERROR.getMessage());
}
}
/**
* 扣除用户金币
*/
private void deductUserGold(Long userId, SendUserRedPacketCmd cmd, String packetId) {
GoldReceiptCmd build = GoldReceiptCmd.builder()
.appExpenditure()
.userId(userId)
.amount(PennyAmount.ofDollar(cmd.getTotalAmount()))
.eventId(packetId)
.sysOrigin(cmd.getReqSysOrigin().getOrigin())
.origin(GoldOrigin.USER_RED_PACKET)
.build();
try {
walletGoldClient.changeBalance(build);
} catch (Exception e) {
log.error("扣除金币失败 userId={} amount={} packetId={}", userId, cmd.getTotalAmount(), packetId, e);
ResponseAssert.isTrue(WalletErrorCode.INSUFFICIENT_BALANCE, false);
}
}
/**
* 构建红包对象
*/
private UserRedPacket buildRedPacket(SendUserRedPacketCmd cmd, Long senderUserId,
Long receiverUserId, String packetId, LocalDateTime expireTime) {
return UserRedPacket.builder()
.packetId(packetId)
.senderUserId(senderUserId)
.receiverUserId(receiverUserId)
.packetType(RoomRedPacketType.FIXED.getCode())
.totalAmount(cmd.getTotalAmount())
.expireTime(expireTime)
.status(UserRedPacketStatus.PENDING.getCode())
.refundStatus(0)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build();
}
/**
* 缓存红包数据到Redis
*/
private void cacheRedPacketData(UserRedPacket redPacket) {
Map<String, Object> packetData = new HashMap<>();
packetData.put("senderUserId", redPacket.getSenderUserId());
packetData.put("receiverUserId", redPacket.getReceiverUserId());
packetData.put("totalAmount", redPacket.getTotalAmount());
packetData.put("expireTime", redPacket.getExpireTime().toString());
packetData.put("status", redPacket.getStatus());
long expireSeconds = Duration.between(LocalDateTime.now(), redPacket.getExpireTime()).getSeconds();
userRedPacketCacheService.setRedPacket(redPacket.getPacketId(), packetData, expireSeconds);
}
}

View File

@ -0,0 +1,50 @@
package com.red.circle.other.app.command.redpacket.query;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.other.app.convertor.UserRedPacketAppConvertor;
import com.red.circle.other.app.dto.clientobject.SimpleUserInfoCO;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.app.dto.cmd.QueryUserRedPacketDetailCmd;
import com.red.circle.other.domain.gateway.UserRedPacketGateway;
import com.red.circle.other.domain.gateway.user.UserProfileGateway;
import com.red.circle.other.domain.model.user.UserProfile;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.inner.asserts.OtherErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 查询用户红包详情执行器
*/
@Component
@RequiredArgsConstructor
public class QueryUserRedPacketDetailQryExe {
private final UserRedPacketGateway userRedPacketGateway;
private final UserProfileGateway userProfileGateway;
public UserRedPacketCO execute(QueryUserRedPacketDetailCmd cmd) {
UserRedPacket redPacket = userRedPacketGateway.findByPacketId(cmd.getPacketId());
ResponseAssert.notNull(OtherErrorCode.RED_PACKET_NOT_FOUND, redPacket);
UserProfile senderProfile = userProfileGateway.getByUserId(redPacket.getSenderUserId());
UserProfile receiverProfile = userProfileGateway.getByUserId(redPacket.getReceiverUserId());
SimpleUserInfoCO senderInfo = buildSimpleUserInfo(senderProfile);
SimpleUserInfoCO receiverInfo = buildSimpleUserInfo(receiverProfile);
return UserRedPacketAppConvertor.toDetailCO(redPacket, senderInfo, receiverInfo);
}
private SimpleUserInfoCO buildSimpleUserInfo(UserProfile userProfile) {
if (userProfile == null) {
return null;
}
return SimpleUserInfoCO.builder()
.userId(userProfile.getId())
.account(userProfile.getAccount())
.nickName(userProfile.getUserNickname())
.avatar(userProfile.getUserAvatar())
.build();
}
}

View File

@ -0,0 +1,57 @@
package com.red.circle.other.app.convertor;
import com.red.circle.other.app.dto.clientobject.GrabUserRedPacketResultCO;
import com.red.circle.other.app.dto.clientobject.SimpleUserInfoCO;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import java.sql.Time;
import java.sql.Timestamp;
/**
* 用户红包应用层转换器
*/
public class UserRedPacketAppConvertor {
public static UserRedPacketCO toCO(UserRedPacket domain) {
if (domain == null) {
return null;
}
return UserRedPacketCO.builder()
.packetId(domain.getPacketId())
.senderUserId(domain.getSenderUserId())
.receiverUserId(domain.getReceiverUserId())
.totalAmount(domain.getTotalAmount())
.status(domain.getStatus())
.expireTime(domain.getExpireTime() != null ? Timestamp.valueOf(domain.getExpireTime()) : null)
.grabbedAt(domain.getGrabbedAt() != null ? Timestamp.valueOf(domain.getGrabbedAt()) : null)
.createTime(domain.getCreateTime() != null ? Timestamp.valueOf(domain.getCreateTime()) : null)
.build();
}
public static UserRedPacketCO toDetailCO(UserRedPacket domain, SimpleUserInfoCO senderInfo, SimpleUserInfoCO receiverInfo) {
if (domain == null) {
return null;
}
return UserRedPacketCO.builder()
.packetId(domain.getPacketId())
.senderUserId(domain.getSenderUserId())
.receiverUserId(domain.getReceiverUserId())
.senderInfo(senderInfo)
.receiverInfo(receiverInfo)
.totalAmount(domain.getTotalAmount())
.status(domain.getStatus())
.expireTime(domain.getExpireTime() != null ? Timestamp.valueOf(domain.getExpireTime()) : null)
.grabbedAt(domain.getGrabbedAt() != null ? Timestamp.valueOf(domain.getGrabbedAt()) : null)
.createTime(domain.getCreateTime() != null ? Timestamp.valueOf(domain.getCreateTime()) : null)
.build();
}
public static GrabUserRedPacketResultCO toGrabResultCO(String packetId, Long amount, Long senderUserId) {
return GrabUserRedPacketResultCO.builder()
.packetId(packetId)
.amount(amount)
.senderUserId(senderUserId)
.build();
}
}

View File

@ -0,0 +1,124 @@
package com.red.circle.other.app.scheduler;
import com.red.circle.component.redis.annotation.TaskCacheLock;
import com.red.circle.other.domain.gateway.UserRedPacketGateway;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.domain.redpacket.UserRedPacketStatus;
import com.red.circle.other.infra.database.cache.service.other.UserRedPacketCacheService;
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.boot.autoconfigure.condition.ConditionalOnProperty;
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
@ConditionalOnProperty(name = "scheduler.user-red-packet-expire", havingValue = "true", matchIfMissing = true)
public class UserRedPacketExpireTask {
private final UserRedPacketGateway userRedPacketGateway;
private final UserRedPacketCacheService userRedPacketCacheService;
private final WalletGoldClient walletGoldClient;
/**
* 每30秒执行一次
*/
@Scheduled(cron = "0/30 * * * * ?")
@TaskCacheLock(key = "USER_RED_PACKET_TASK", expireSecond = 20)
public void refundExpiredPackets() {
try {
LocalDateTime now = LocalDateTime.now();
List<UserRedPacket> expiredPackets = userRedPacketGateway.findExpiredAndNotRefunded(now);
if (expiredPackets.isEmpty()) {
return;
}
log.info("开始处理过期用户红包退款 数量={}", expiredPackets.size());
int successCount = 0;
int failCount = 0;
for (UserRedPacket 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(UserRedPacket packet) {
String packetId = packet.getPacketId();
if (packet.getRefundStatus() != 0) {
log.warn("用户红包已退款 packetId={} refundStatus={}",
packetId, packet.getRefundStatus());
return;
}
refundToUser(packet);
userRedPacketGateway.updateStatus(packetId, UserRedPacketStatus.EXPIRED);
userRedPacketGateway.updateRefundStatus(packetId, 1);
userRedPacketCacheService.deleteRedPacket(packetId);
log.info("用户红包过期处理完成 packetId={} senderUserId={} amount={}",
packetId, packet.getSenderUserId(), packet.getTotalAmount());
}
/**
* 退款给用户
*/
private void refundToUser(UserRedPacket packet) {
Long amount = packet.getTotalAmount();
Long userId = packet.getSenderUserId();
String packetId = packet.getPacketId();
GoldReceiptCmd build = GoldReceiptCmd.builder()
.appIncome()
.userId(userId)
.amount(PennyAmount.ofDollar(amount))
.eventId(packetId + "_refund")
.sysOrigin("LIKEI")
.origin(GoldOrigin.USER_RED_PACKET)
.build();
try {
walletGoldClient.changeBalance(build);
log.info("用户红包退款成功 packetId={} userId={} amount={}",
packetId, userId, amount);
} catch (Exception e) {
log.error("用户红包退款失败 packetId={} userId={} amount={}",
packetId, userId, amount, e);
throw new RuntimeException("用户红包退款失败", e);
}
}
}

View File

@ -0,0 +1,43 @@
package com.red.circle.other.app.service;
import com.red.circle.other.app.command.redpacket.GrabUserRedPacketCmdExe;
import com.red.circle.other.app.command.redpacket.SendUserRedPacketCmdExe;
import com.red.circle.other.app.command.redpacket.query.QueryUserRedPacketDetailQryExe;
import com.red.circle.other.app.dto.clientobject.GrabUserRedPacketResultCO;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.app.dto.cmd.GrabUserRedPacketCmd;
import com.red.circle.other.app.dto.cmd.QueryUserRedPacketDetailCmd;
import com.red.circle.other.app.dto.cmd.SendUserRedPacketCmd;
import com.red.circle.other.app.util.DistributedLockUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 用户红包服务实现
*/
@Service
@RequiredArgsConstructor
public class UserRedPacketServiceImpl implements UserRedPacketService {
private final SendUserRedPacketCmdExe sendUserRedPacketCmdExe;
private final GrabUserRedPacketCmdExe grabUserRedPacketCmdExe;
private final QueryUserRedPacketDetailQryExe queryUserRedPacketDetailQryExe;
private final DistributedLockUtil distributedLockUtil;
@Override
public UserRedPacketCO sendRedPacket(SendUserRedPacketCmd cmd) {
String key = "user:red_packet:send:" + cmd.getReqUserId();
return distributedLockUtil.executeWithLock(key, () -> sendUserRedPacketCmdExe.execute(cmd));
}
@Override
public GrabUserRedPacketResultCO grabRedPacket(GrabUserRedPacketCmd cmd) {
String key = "user:red_packet:grab:" + cmd.getPacketId() + ":" + cmd.getReqUserId();
return distributedLockUtil.executeWithLock(key, () -> grabUserRedPacketCmdExe.execute(cmd));
}
@Override
public UserRedPacketCO queryRedPacketDetail(QueryUserRedPacketDetailCmd cmd) {
return queryUserRedPacketDetailQryExe.execute(cmd);
}
}

View File

@ -0,0 +1,31 @@
package com.red.circle.other.app.dto.clientobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 领取用户红包结果CO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GrabUserRedPacketResultCO {
/**
* 红包ID
*/
private String packetId;
/**
* 领取到的金额金币
*/
private Long amount;
/**
* 发送者用户ID
*/
private Long senderUserId;
}

View File

@ -0,0 +1,36 @@
package com.red.circle.other.app.dto.clientobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户简要信息CO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SimpleUserInfoCO {
/**
* 用户ID
*/
private Long userId;
/**
* 账号
*/
private String account;
/**
* 昵称
*/
private String nickName;
/**
* 头像
*/
private String avatar;
}

View File

@ -0,0 +1,69 @@
package com.red.circle.other.app.dto.clientobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.sql.Timestamp;
import java.time.LocalDateTime;
/**
* 用户红包CO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserRedPacketCO {
/**
* 红包ID
*/
private String packetId;
/**
* 发送者用户ID
*/
private Long senderUserId;
/**
* 接收者用户ID
*/
private Long receiverUserId;
/**
* 发送者信息
*/
private SimpleUserInfoCO senderInfo;
/**
* 接收者信息
*/
private SimpleUserInfoCO receiverInfo;
/**
* 总金额金币
*/
private Long totalAmount;
/**
* 状态1-待领取 2-已领取 3-已过期
*/
private Integer status;
/**
* 过期时间
*/
private Timestamp expireTime;
/**
* 领取时间
*/
private Timestamp grabbedAt;
/**
* 创建时间
*/
private Timestamp createTime;
}

View File

@ -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;
/**
* 领取用户红包命令
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class GrabUserRedPacketCmd extends AppExtCommand {
/**
* 红包ID
*/
@NotBlank(message = "红包ID不能为空")
private String packetId;
}

View File

@ -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;
/**
* 查询用户红包详情命令
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class QueryUserRedPacketDetailCmd extends AppExtCommand {
/**
* 红包ID
*/
@NotBlank(message = "红包ID不能为空")
private String packetId;
}

View File

@ -0,0 +1,31 @@
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;
/**
* 发送用户红包命令
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SendUserRedPacketCmd extends AppExtCommand {
/**
* 接收者用户ID
*/
@NotNull(message = "接收者用户ID不能为空")
private Long receiverUserId;
/**
* 总金额金币
*/
@NotNull(message = "红包金额不能为空")
private Long totalAmount;
/**
* 红包类型1-固定金额
*/
private Integer packetType = 1;
}

View File

@ -0,0 +1,28 @@
package com.red.circle.other.app.service;
import com.red.circle.other.app.dto.clientobject.GrabUserRedPacketResultCO;
import com.red.circle.other.app.dto.clientobject.UserRedPacketCO;
import com.red.circle.other.app.dto.cmd.GrabUserRedPacketCmd;
import com.red.circle.other.app.dto.cmd.QueryUserRedPacketDetailCmd;
import com.red.circle.other.app.dto.cmd.SendUserRedPacketCmd;
/**
* 用户红包服务
*/
public interface UserRedPacketService {
/**
* 发送用户红包
*/
UserRedPacketCO sendRedPacket(SendUserRedPacketCmd cmd);
/**
* 领取用户红包
*/
GrabUserRedPacketResultCO grabRedPacket(GrabUserRedPacketCmd cmd);
/**
* 查询红包详情
*/
UserRedPacketCO queryRedPacketDetail(QueryUserRedPacketDetailCmd cmd);
}

View File

@ -0,0 +1,53 @@
package com.red.circle.other.domain.gateway;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.domain.redpacket.UserRedPacketStatus;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户红包Gateway
*/
public interface UserRedPacketGateway {
/**
* 保存红包
*/
void save(UserRedPacket userRedPacket);
/**
* 根据红包ID查询
*/
UserRedPacket findByPacketId(String packetId);
/**
* 更新红包状态
*/
boolean updateStatus(String packetId, UserRedPacketStatus status);
/**
* 更新退款状态
*/
boolean updateRefundStatus(String packetId, Integer refundStatus);
/**
* 更新为已领取
*/
boolean updateToGrabbed(String packetId, LocalDateTime grabbedAt);
/**
* 查询过期且未退款的红包
*/
List<UserRedPacket> findExpiredAndNotRefunded(LocalDateTime now);
/**
* 查询用户发送的红包列表
*/
List<UserRedPacket> findBySenderUserId(Long senderUserId, Integer limit);
/**
* 查询用户接收的红包列表
*/
List<UserRedPacket> findByReceiverUserId(Long receiverUserId, Integer limit);
}

View File

@ -0,0 +1,78 @@
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 UserRedPacket {
/**
* 主键ID
*/
private Long id;
/**
* 红包IDUUID
*/
private String packetId;
/**
* 发送者用户ID
*/
private Long senderUserId;
/**
* 接收者用户ID
*/
private Long receiverUserId;
/**
* 红包类型1-固定金额 2-拼手气
*/
private Integer packetType;
/**
* 总金额
*/
private Long totalAmount;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 状态1-待领取 2-已领取 3-已过期
*/
private Integer status;
/**
* 退款状态0-未退 1-已退
*/
private Integer refundStatus;
/**
* 领取时间
*/
private LocalDateTime grabbedAt;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,39 @@
package com.red.circle.other.domain.redpacket;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户红包状态
*/
@Getter
@AllArgsConstructor
public enum UserRedPacketStatus {
/**
* 待领取
*/
PENDING(1, "Pending"),
/**
* 已领取
*/
GRABBED(2, "Grabbed"),
/**
* 已过期
*/
EXPIRED(3, "Expired");
private final Integer code;
private final String desc;
public static UserRedPacketStatus of(Integer code) {
for (UserRedPacketStatus status : values()) {
if (status.getCode().equals(code)) {
return status;
}
}
return null;
}
}

View File

@ -0,0 +1,50 @@
package com.red.circle.other.infra.convertor;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.infra.database.rds.entity.UserRedPacketEntity;
/**
* 用户红包转换器
*/
public class UserRedPacketConvertor {
public static UserRedPacket toDomain(UserRedPacketEntity entity) {
if (entity == null) {
return null;
}
return UserRedPacket.builder()
.id(entity.getId())
.packetId(entity.getPacketId())
.senderUserId(entity.getSenderUserId())
.receiverUserId(entity.getReceiverUserId())
.packetType(entity.getPacketType())
.totalAmount(entity.getTotalAmount())
.expireTime(entity.getExpireTime())
.status(entity.getStatus())
.refundStatus(entity.getRefundStatus())
.grabbedAt(entity.getGrabbedAt())
.createTime(entity.getCreateTime())
.updateTime(entity.getUpdateTime())
.build();
}
public static UserRedPacketEntity toEntity(UserRedPacket domain) {
if (domain == null) {
return null;
}
UserRedPacketEntity entity = new UserRedPacketEntity();
entity.setId(domain.getId());
entity.setPacketId(domain.getPacketId());
entity.setSenderUserId(domain.getSenderUserId());
entity.setReceiverUserId(domain.getReceiverUserId());
entity.setPacketType(domain.getPacketType());
entity.setTotalAmount(domain.getTotalAmount());
entity.setExpireTime(domain.getExpireTime());
entity.setStatus(domain.getStatus());
entity.setRefundStatus(domain.getRefundStatus());
entity.setGrabbedAt(domain.getGrabbedAt());
entity.setCreateTime(domain.getCreateTime());
entity.setUpdateTime(domain.getUpdateTime());
return entity;
}
}

View File

@ -0,0 +1,39 @@
package com.red.circle.other.infra.database.cache.service.other;
import java.util.Map;
/**
* 用户红包缓存服务
*/
public interface UserRedPacketCacheService {
/**
* 设置红包数据
*/
void setRedPacket(String packetId, Map<String, Object> data, long expireSeconds);
/**
* 获取红包数据
*/
Map<Object, Object> getRedPacket(String packetId);
/**
* 检查用户是否已领取
*/
boolean hasUserGrabbed(String packetId, Long userId);
/**
* 设置用户已领取标记
*/
boolean setUserGrabbedFlag(String packetId, Long userId, long expireSeconds);
/**
* 删除红包缓存
*/
void deleteRedPacket(String packetId);
/**
* 删除用户领取标记
*/
void deleteUserGrabbedFlag(String packetId, Long userId);
}

View File

@ -0,0 +1,59 @@
package com.red.circle.other.infra.database.cache.service.other.impl;
import com.red.circle.other.infra.database.cache.service.other.UserRedPacketCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 用户红包缓存服务实现
*/
@Service
@RequiredArgsConstructor
public class UserRedPacketCacheServiceImpl implements UserRedPacketCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private static final String PACKET_KEY_PREFIX = "user:red_packet:";
private static final String GRABBED_KEY_PREFIX = "user:red_packet:grabbed:";
@Override
public void setRedPacket(String packetId, Map<String, Object> data, long expireSeconds) {
String key = PACKET_KEY_PREFIX + packetId;
redisTemplate.opsForHash().putAll(key, data);
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
}
@Override
public Map<Object, Object> getRedPacket(String packetId) {
String key = PACKET_KEY_PREFIX + packetId;
return redisTemplate.opsForHash().entries(key);
}
@Override
public boolean hasUserGrabbed(String packetId, Long userId) {
String key = GRABBED_KEY_PREFIX + packetId + ":" + userId;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
@Override
public boolean setUserGrabbedFlag(String packetId, Long userId, long expireSeconds) {
String key = GRABBED_KEY_PREFIX + packetId + ":" + userId;
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "1", expireSeconds, TimeUnit.SECONDS));
}
@Override
public void deleteRedPacket(String packetId) {
String key = PACKET_KEY_PREFIX + packetId;
redisTemplate.delete(key);
}
@Override
public void deleteUserGrabbedFlag(String packetId, Long userId) {
String key = GRABBED_KEY_PREFIX + packetId + ":" + userId;
redisTemplate.delete(key);
}
}

View File

@ -0,0 +1,53 @@
package com.red.circle.other.infra.database.rds.dao.redpacket;
import com.red.circle.framework.mybatis.dao.BaseDAO;
import com.red.circle.other.infra.database.rds.entity.UserRedPacketEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户红包DAO
*/
@Mapper
public interface UserRedPacketDAO extends BaseDAO<UserRedPacketEntity> {
/**
* 根据红包ID查询
*/
UserRedPacketEntity selectByPacketId(@Param("packetId") String packetId);
/**
* 更新红包状态
*/
int updateStatus(@Param("packetId") String packetId, @Param("status") Integer status);
/**
* 更新退款状态
*/
int updateRefundStatus(@Param("packetId") String packetId, @Param("refundStatus") Integer refundStatus);
/**
* 更新为已领取
*/
int updateToGrabbed(@Param("packetId") String packetId, @Param("grabbedAt") LocalDateTime grabbedAt);
/**
* 查询过期且未退款的红包
*/
List<UserRedPacketEntity> selectExpiredAndNotRefunded(@Param("now") LocalDateTime now);
/**
* 查询用户发送的红包列表
*/
List<UserRedPacketEntity> selectBySenderUserId(@Param("senderUserId") Long senderUserId,
@Param("limit") Integer limit);
/**
* 查询用户接收的红包列表
*/
List<UserRedPacketEntity> selectByReceiverUserId(@Param("receiverUserId") Long receiverUserId,
@Param("limit") Integer limit);
}

View File

@ -0,0 +1,77 @@
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("user_red_packet")
public class UserRedPacketEntity {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 红包IDUUID
*/
private String packetId;
/**
* 发送者用户ID
*/
private Long senderUserId;
/**
* 接收者用户ID
*/
private Long receiverUserId;
/**
* 红包类型1-固定金额 2-拼手气
*/
private Integer packetType;
/**
* 总金额
*/
private Long totalAmount;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 状态1-待领取 2-已领取 3-已过期
*/
private Integer status;
/**
* 退款状态0-未退 1-已退
*/
private Integer refundStatus;
/**
* 领取时间
*/
private LocalDateTime grabbedAt;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,75 @@
package com.red.circle.other.infra.gateway;
import com.red.circle.other.domain.gateway.UserRedPacketGateway;
import com.red.circle.other.domain.redpacket.UserRedPacket;
import com.red.circle.other.domain.redpacket.UserRedPacketStatus;
import com.red.circle.other.infra.convertor.UserRedPacketConvertor;
import com.red.circle.other.infra.database.rds.dao.redpacket.UserRedPacketDAO;
import com.red.circle.other.infra.database.rds.entity.UserRedPacketEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 用户红包Gateway实现
*/
@Component
@RequiredArgsConstructor
public class UserRedPacketGatewayImpl implements UserRedPacketGateway {
private final UserRedPacketDAO userRedPacketDAO;
@Override
public void save(UserRedPacket userRedPacket) {
UserRedPacketEntity entity = UserRedPacketConvertor.toEntity(userRedPacket);
userRedPacketDAO.insert(entity);
}
@Override
public UserRedPacket findByPacketId(String packetId) {
UserRedPacketEntity entity = userRedPacketDAO.selectByPacketId(packetId);
return UserRedPacketConvertor.toDomain(entity);
}
@Override
public boolean updateStatus(String packetId, UserRedPacketStatus status) {
return userRedPacketDAO.updateStatus(packetId, status.getCode()) > 0;
}
@Override
public boolean updateRefundStatus(String packetId, Integer refundStatus) {
return userRedPacketDAO.updateRefundStatus(packetId, refundStatus) > 0;
}
@Override
public boolean updateToGrabbed(String packetId, LocalDateTime grabbedAt) {
return userRedPacketDAO.updateToGrabbed(packetId, grabbedAt) > 0;
}
@Override
public List<UserRedPacket> findExpiredAndNotRefunded(LocalDateTime now) {
List<UserRedPacketEntity> entities = userRedPacketDAO.selectExpiredAndNotRefunded(now);
return entities.stream()
.map(UserRedPacketConvertor::toDomain)
.collect(Collectors.toList());
}
@Override
public List<UserRedPacket> findBySenderUserId(Long senderUserId, Integer limit) {
List<UserRedPacketEntity> entities = userRedPacketDAO.selectBySenderUserId(senderUserId, limit);
return entities.stream()
.map(UserRedPacketConvertor::toDomain)
.collect(Collectors.toList());
}
@Override
public List<UserRedPacket> findByReceiverUserId(Long receiverUserId, Integer limit) {
List<UserRedPacketEntity> entities = userRedPacketDAO.selectByReceiverUserId(receiverUserId, limit);
return entities.stream()
.map(UserRedPacketConvertor::toDomain)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.red.circle.other.infra.database.rds.dao.redpacket.UserRedPacketDAO">
<resultMap id="BaseResultMap" type="com.red.circle.other.infra.database.rds.entity.UserRedPacketEntity">
<id column="id" property="id"/>
<result column="packet_id" property="packetId"/>
<result column="sender_user_id" property="senderUserId"/>
<result column="receiver_user_id" property="receiverUserId"/>
<result column="packet_type" property="packetType"/>
<result column="total_amount" property="totalAmount"/>
<result column="expire_time" property="expireTime"/>
<result column="status" property="status"/>
<result column="refund_status" property="refundStatus"/>
<result column="grabbed_at" property="grabbedAt"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<select id="selectByPacketId" resultMap="BaseResultMap">
SELECT * FROM user_red_packet
WHERE packet_id = #{packetId}
</select>
<update id="updateStatus">
UPDATE user_red_packet
SET status = #{status}, update_time = NOW()
WHERE packet_id = #{packetId}
</update>
<update id="updateRefundStatus">
UPDATE user_red_packet
SET refund_status = #{refundStatus}, update_time = NOW()
WHERE packet_id = #{packetId}
</update>
<update id="updateToGrabbed">
UPDATE user_red_packet
SET status = 2, grabbed_at = #{grabbedAt}, update_time = NOW()
WHERE packet_id = #{packetId} AND status = 1
</update>
<select id="selectExpiredAndNotRefunded" resultMap="BaseResultMap">
SELECT * FROM user_red_packet
WHERE expire_time &lt; #{now}
AND status = 1
AND refund_status = 0
LIMIT 100
</select>
<select id="selectBySenderUserId" resultMap="BaseResultMap">
SELECT * FROM user_red_packet
WHERE sender_user_id = #{senderUserId}
ORDER BY create_time DESC
LIMIT #{limit}
</select>
<select id="selectByReceiverUserId" resultMap="BaseResultMap">
SELECT * FROM user_red_packet
WHERE receiver_user_id = #{receiverUserId}
ORDER BY create_time DESC
LIMIT #{limit}
</select>
</mapper>