抽奖新增发放奖励

This commit is contained in:
tianfeng 2025-10-21 16:07:29 +08:00
parent f23433b4d7
commit c69679b883
10 changed files with 365 additions and 132 deletions

View File

@ -320,7 +320,10 @@ public enum DiamondOrigin {
*/
DEDUCT_DIAMOND("Deduct diamonds"),
/**
* lottery 奖励
*/
LOTTERY_REWARD("Lottery Diamonds Rewards"),
;

View File

@ -254,6 +254,11 @@ public enum SendPropsOrigin {
*/
MEMBER_ACTIVE_TASK_REWARD("member activity Reward"),
/**
* lottery 奖励
*/
LOTTERY_REWARD("Lottery Rewards"),
;
private final String desc;

View File

@ -1,125 +0,0 @@
# 抽奖系统数据库初始化SQL
## 1. 抽奖活动表
```sql
CREATE TABLE lottery_activity (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_code VARCHAR(50) UNIQUE NOT NULL COMMENT '活动编码',
activity_name VARCHAR(100) NOT NULL COMMENT '活动名称',
activity_desc VARCHAR(500) COMMENT '活动描述',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
status TINYINT DEFAULT 0 COMMENT '状态0-未开始1-进行中2-已结束3-已关闭',
total_stock INT DEFAULT 0 COMMENT '总奖品库存',
consumed_stock INT DEFAULT 0 COMMENT '已消耗库存',
draw_count_limit INT DEFAULT 0 COMMENT '每人抽奖次数限制0为不限制',
draw_count_cycle VARCHAR(20) COMMENT '次数限制周期DAILY-每天TOTAL-总计',
need_ticket TINYINT DEFAULT 0 COMMENT '是否需要抽奖券0-否1-是',
ticket_cost INT DEFAULT 1 COMMENT '每次消耗抽奖券数量',
sort_order INT DEFAULT 0 COMMENT '排序',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_time (status, start_time, end_time),
INDEX idx_code (activity_code)
) COMMENT '抽奖活动表';
```
## 2. 奖品配置表
```sql
CREATE TABLE lottery_prize (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL COMMENT '活动ID',
prize_code VARCHAR(50) NOT NULL COMMENT '奖品编码',
prize_name VARCHAR(100) NOT NULL COMMENT '奖品名称',
prize_type TINYINT NOT NULL COMMENT '奖品类型1-实物2-虚拟币3-优惠券4-积分5-谢谢参与',
prize_value DECIMAL(10,2) COMMENT '奖品价值/数量',
prize_image VARCHAR(200) COMMENT '奖品图片',
prize_level TINYINT COMMENT '奖品等级1-特等奖2-一等奖3-二等奖...',
total_stock INT NOT NULL COMMENT '总库存',
remaining_stock INT NOT NULL COMMENT '剩余库存',
daily_stock INT DEFAULT 0 COMMENT '每日库存限制0为不限制',
probability DECIMAL(10,6) NOT NULL COMMENT '中奖概率0-1之间',
sort_order INT DEFAULT 0 COMMENT '排序(九宫格位置)',
is_display TINYINT DEFAULT 1 COMMENT '是否展示0-否1-是',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_activity (activity_id),
INDEX idx_code (prize_code),
UNIQUE KEY uk_activity_code (activity_id, prize_code)
) COMMENT '奖品配置表';
```
## 3. 中奖记录表
```sql
CREATE TABLE lottery_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_no VARCHAR(50) UNIQUE NOT NULL COMMENT '记录编号',
user_id BIGINT NOT NULL COMMENT '用户ID',
activity_id BIGINT NOT NULL COMMENT '活动ID',
prize_id BIGINT COMMENT '奖品ID未中奖为NULL',
prize_name VARCHAR(100) COMMENT '奖品名称快照',
prize_type TINYINT COMMENT '奖品类型快照',
is_win TINYINT NOT NULL COMMENT '是否中奖0-未中奖1-中奖',
draw_time DATETIME NOT NULL COMMENT '抽奖时间',
prize_status TINYINT DEFAULT 0 COMMENT '奖品状态0-待发放1-已发放2-发放失败',
deliver_time DATETIME COMMENT '发放时间',
remark VARCHAR(500) COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_activity (user_id, activity_id),
INDEX idx_draw_time (draw_time),
INDEX idx_activity_win (activity_id, is_win),
INDEX idx_record_no (record_no)
) COMMENT '中奖记录表';
```
## 4. 抽奖券表
```sql
CREATE TABLE lottery_ticket (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
ticket_source VARCHAR(50) COMMENT '来源SYSTEM-系统发放ACTIVITY-活动赠送PURCHASE-购买',
source_id VARCHAR(50) COMMENT '来源ID',
total_count INT NOT NULL COMMENT '总数量',
used_count INT DEFAULT 0 COMMENT '已使用数量',
remaining_count INT NOT NULL COMMENT '剩余数量',
expire_time DATETIME COMMENT '过期时间',
status TINYINT DEFAULT 1 COMMENT '状态0-已失效1-正常',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_status (user_id, status),
INDEX idx_expire (expire_time)
) COMMENT '抽奖券表';
```
## 5. 抽奖券使用记录表
```sql
CREATE TABLE lottery_ticket_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
ticket_id BIGINT NOT NULL COMMENT '抽奖券ID',
change_count INT NOT NULL COMMENT '变动数量(正数增加,负数减少)',
change_type TINYINT NOT NULL COMMENT '变动类型1-发放2-消耗3-过期4-退回',
lottery_record_id BIGINT COMMENT '关联的抽奖记录ID',
remark VARCHAR(200) COMMENT '备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_ticket (ticket_id),
INDEX idx_lottery_record (lottery_record_id)
) COMMENT '抽奖券使用记录表';
```
## 6. 用户抽奖次数统计表
```sql
CREATE TABLE lottery_user_count (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
activity_id BIGINT NOT NULL COMMENT '活动ID',
draw_date DATE NOT NULL COMMENT '统计日期',
draw_count INT DEFAULT 0 COMMENT '抽奖次数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_activity_date (user_id, activity_id, draw_date),
INDEX idx_user_activity (user_id, activity_id)
) COMMENT '用户抽奖次数统计表';
```

View File

@ -14,7 +14,9 @@ public enum SysCurrencySendReasonEnum {
REWARD(1, "奖励"),
INTERNAL(2, "内部"),
WAGE(3, "工资"),
OTHER(4, "其他");
OTHER(4, "其他"),
LOTTERY(5, "抽奖"),
;
private final Integer type;
private final String typeName;

View File

@ -4,6 +4,7 @@ import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.other.app.dto.clientobject.activity.LotteryDrawResultCO;
import com.red.circle.other.app.dto.clientobject.activity.LotteryPrizeCO;
import com.red.circle.other.app.dto.cmd.activity.LotteryDrawCmd;
import com.red.circle.other.app.service.activity.LotteryPrizeGrantService;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryActivity;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryPrize;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryRecord;
@ -53,6 +54,7 @@ public class LotteryDrawExe {
private final LotteryTicketRecordService lotteryTicketRecordService;
private final LotteryUserCountService lotteryUserCountService;
private final LotteryUserProgressService lotteryUserProgressService;
private final LotteryPrizeGrantService lotteryPrizeGrantService;
@Transactional(rollbackFor = Exception.class)
public LotteryDrawResultCO execute(LotteryDrawCmd cmd) {
@ -89,13 +91,18 @@ public class LotteryDrawExe {
// 7. 保存抽奖记录
LotteryRecord record = saveDrawRecord(userId, activityId, prize, now, null, 1);
// 8. 更新用户抽奖次数
// 8. 发放奖励
if (prize != null) {
lotteryPrizeGrantService.grantPrize(userId, prize, record.getRecordNo());
}
// 9. 更新用户抽奖次数
updateUserDrawCount(userId, activityId);
// 9. 更新用户进度累加金额和次数
// 10. 更新用户进度累加金额和次数
updateUserProgress(progress, prize);
// 10. 组装返回结果
// 11. 组装返回结果
return convertToResultCO(record, prize);
}

View File

@ -5,6 +5,7 @@ import com.red.circle.other.app.dto.clientobject.activity.LotteryDrawResultCO;
import com.red.circle.other.app.dto.clientobject.activity.LotteryMultiDrawResultCO;
import com.red.circle.other.app.dto.clientobject.activity.LotteryPrizeCO;
import com.red.circle.other.app.dto.cmd.activity.LotteryMultiDrawCmd;
import com.red.circle.other.app.service.activity.LotteryPrizeGrantService;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryActivity;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryPrize;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryRecord;
@ -54,6 +55,7 @@ public class LotteryMultiDrawExe {
private final LotteryTicketRecordService lotteryTicketRecordService;
private final LotteryUserCountService lotteryUserCountService;
private final LotteryUserProgressService lotteryUserProgressService;
private final LotteryPrizeGrantService lotteryPrizeGrantService;
@Transactional(rollbackFor = Exception.class)
public LotteryMultiDrawResultCO execute(LotteryMultiDrawCmd cmd) {
@ -113,6 +115,11 @@ public class LotteryMultiDrawExe {
LotteryRecord record = saveDrawRecord(userId, activityId, prize, now, batchNo, 2);
results.add(convertToResultCO(record, prize));
// 发放奖励
if (prize != null) {
lotteryPrizeGrantService.grantPrize(userId, prize, record.getRecordNo());
}
// 临时更新用户进度用于下次抽奖判断阶段
if (prize != null && prize.getPrizeValue() != null) {
BigDecimal prizeValue = prize.getPrizeValue();
@ -340,10 +347,15 @@ public class LotteryMultiDrawExe {
record.setPrizeType(guaranteePrize.getPrizeType());
record.setIsWin(1);
lotteryRecordService.updateSelectiveById(record);
results.set(replaceIndex, convertToResultCO(record, guaranteePrize));
// 发放保底奖励
lotteryPrizeGrantService.grantPrize(userId, guaranteePrize, record.getRecordNo());
}
results.set(replaceIndex, convertToResultCO(record, guaranteePrize));
updateUserProgress(progress, guaranteePrize);
}
}
private LotteryPrize getGuaranteePrize(Long activityId, Long guaranteePrizeId) {

View File

@ -0,0 +1,22 @@
package com.red.circle.other.app.service.activity;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryPrize;
/**
* 抽奖奖励发放服务.
*
* @author system
* @since 2025-10-21
*/
public interface LotteryPrizeGrantService {
/**
* 发放奖励.
*
* @param userId 用户ID
* @param prize 奖品信息
* @param recordNo 抽奖记录编号
*/
void grantPrize(Long userId, LotteryPrize prize, String recordNo);
}

View File

@ -0,0 +1,280 @@
package com.red.circle.other.app.service.activity.impl;
import com.red.circle.common.business.core.enums.ReceiptType;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.common.business.enums.DiamondOrigin;
import com.red.circle.common.business.enums.SendPropsOrigin;
import com.red.circle.other.app.service.activity.LotteryPrizeGrantService;
import com.red.circle.other.infra.database.rds.entity.activity.LotteryPrize;
import com.red.circle.other.inner.endpoint.material.props.BadgeBackpackClient;
import com.red.circle.other.inner.endpoint.material.props.PropsBackpackClient;
import com.red.circle.other.inner.endpoint.material.props.RoomThemeBackpackClient;
import com.red.circle.other.inner.endpoint.user.user.UserProfileClient;
import com.red.circle.other.inner.enums.material.BadgeBackpackExpireTypeEnum;
import com.red.circle.other.inner.enums.sys.SysBadgeConfigTypeEnum;
import com.red.circle.other.inner.enums.sys.SysCurrencySendReasonEnum;
import com.red.circle.other.inner.model.cmd.material.GiveBadgeCmd;
import com.red.circle.other.inner.model.cmd.material.GivePropsBackpackCmd;
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
import com.red.circle.tool.core.date.TimestampUtils;
import com.red.circle.tool.core.sequence.IdWorkerUtils;
import com.red.circle.wallet.inner.endpoint.wallet.WalletDiamondClient;
import com.red.circle.wallet.inner.endpoint.wallet.WalletGoldClient;
import com.red.circle.wallet.inner.model.cmd.DiamondReceiptCmd;
import com.red.circle.wallet.inner.model.cmd.GoldReceiptCmd;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 抽奖奖励发放服务实现.
*
* @author system
* @since 2025-10-21
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LotteryPrizeGrantServiceImpl implements LotteryPrizeGrantService {
private final WalletGoldClient walletGoldClient;
private final WalletDiamondClient walletDiamondClient;
private final PropsBackpackClient propsBackpackClient;
private final BadgeBackpackClient badgeBackpackClient;
private final RoomThemeBackpackClient roomThemeBackpackClient;
private final UserProfileClient userProfileClient;
@Override
public void grantPrize(Long userId, LotteryPrize prize, String recordNo) {
if (prize == null || prize.getPrizeValue() == null) {
log.warn("Prize is null or prizeValue is null, skip grant. userId: {}, recordNo: {}", userId, recordNo);
return;
}
String prizeType = prize.getPrizeType();
if (prizeType == null) {
log.warn("Prize type is null, skip grant. userId: {}, prizeId: {}", userId, prize.getId());
return;
}
try {
switch (prizeType) {
case "MONEY":
// 金钱类型暂不处理由业务方自行提现
log.info("Money prize granted, userId: {}, amount: {}, recordNo: {}",
userId, prize.getPrizeValue(), recordNo);
break;
case "GOLD":
grantGold(userId, prize, recordNo);
break;
case "PROPS":
grantProps(userId, prize, recordNo);
break;
case "BADGE":
grantBadge(userId, prize, recordNo);
break;
case "THEME":
grantTheme(userId, prize, recordNo);
break;
case "AVATAR_FRAME":
case "RIDE":
case "CHAT_BUBBLE":
case "DATA_CARD":
grantBackpackProps(userId, prize, recordNo, prizeType);
break;
case "THANKS":
// 谢谢参与不发放奖励
log.info("Thanks prize, no reward. userId: {}, recordNo: {}", userId, recordNo);
break;
default:
log.warn("Unknown prize type: {}, userId: {}, prizeId: {}", prizeType, userId, prize.getId());
}
} catch (Exception e) {
log.error("Grant prize failed, userId: {}, prizeId: {}, recordNo: {}",
userId, prize.getId(), recordNo, e);
}
}
/**
* 发放金币.
*/
private void grantGold(Long userId, LotteryPrize prize, String recordNo) {
UserProfileDTO userProfile = userProfileClient.getByUserId(userId).getBody();
if (userProfile == null) {
log.warn("User profile not found, userId: {}", userId);
return;
}
BigDecimal amount = prize.getPrizeValue();
walletGoldClient.changeBalance(
GoldReceiptCmd.builder()
.opsIncome()
.userId(userId)
.sysOrigin(userProfile.getOriginSys())
.eventId(recordNo)
.origin(SysCurrencySendReasonEnum.LOTTERY.name())
.originDescribe("抽奖奖励")
.amount(amount)
.remark("抽奖获得金币")
.build()
);
log.info("Gold granted, userId: {}, amount: {}, recordNo: {}", userId, amount, recordNo);
}
/**
* 发放钻石.
*/
private void grantDiamond(Long userId, LotteryPrize prize, String recordNo) {
UserProfileDTO userProfile = userProfileClient.getByUserId(userId).getBody();
if (userProfile == null) {
log.warn("User profile not found, userId: {}", userId);
return;
}
Long trackId = IdWorkerUtils.getId();
BigDecimal amount = prize.getPrizeValue();
walletDiamondClient.changeBalance(
new DiamondReceiptCmd()
.setConsumeId(recordNo)
.setTrackId(trackId)
.setUserId(userId)
.setSysOrigin(SysOriginPlatformEnum.valueOf(userProfile.getOriginSys()))
.setOriginUserId(userId)
.setOrigin(DiamondOrigin.LOTTERY_REWARD)
.setAmount(amount)
.setCreateTime(TimestampUtils.now())
.setType(ReceiptType.INCOME)
);
log.info("Diamond granted, userId: {}, amount: {}, recordNo: {}", userId, amount, recordNo);
}
/**
* 发放道具通用道具.
*/
private void grantProps(Long userId, LotteryPrize prize, String recordNo) {
if (prize.getPropsId() == null) {
log.warn("Props id is null, skip grant. userId: {}, prizeId: {}", userId, prize.getId());
return;
}
Integer days = prize.getDays() != null ? prize.getDays() : 7;
GivePropsBackpackCmd cmd = new GivePropsBackpackCmd()
.setAcceptUserId(userId)
.setPropsId(prize.getPropsId())
.setType("PROPS")
.setOrigin(SendPropsOrigin.LOTTERY_REWARD.name())
.setOriginDesc("抽奖奖励")
.setDays(days)
.setUseProps(Boolean.FALSE)
.setAllowGive(Boolean.FALSE)
.setOpUser(userId);
propsBackpackClient.giveProps(cmd);
// 发送通知
// propsBackpackClient.givePropsNotify(userId, "PROPS", days, prize.getPropsId());
log.info("Props granted, userId: {}, propsId: {}, days: {}, recordNo: {}",
userId, prize.getPropsId(), days, recordNo);
}
/**
* 发放背包道具头像框座驾气泡名片.
*/
private void grantBackpackProps(Long userId, LotteryPrize prize, String recordNo, String type) {
if (prize.getPropsId() == null) {
log.warn("Props id is null, skip grant. userId: {}, prizeId: {}", userId, prize.getId());
return;
}
Integer days = prize.getDays() != null ? prize.getDays() : 7;
GivePropsBackpackCmd cmd = new GivePropsBackpackCmd()
.setAcceptUserId(userId)
.setPropsId(prize.getPropsId())
.setType(type)
.setOrigin(SendPropsOrigin.LOTTERY_REWARD.name())
.setOriginDesc("抽奖奖励")
.setDays(days)
.setUseProps(Boolean.FALSE)
.setAllowGive(Boolean.FALSE)
.setOpUser(userId);
propsBackpackClient.giveProps(cmd);
// 发送通知
propsBackpackClient.givePropsNotify(userId, type, days, prize.getPropsId());
log.info("Backpack props granted, userId: {}, type: {}, propsId: {}, days: {}, recordNo: {}",
userId, type, prize.getPropsId(), days, recordNo);
}
/**
* 发放徽章.
*/
private void grantBadge(Long userId, LotteryPrize prize, String recordNo) {
if (prize.getPropsId() == null) {
log.warn("Badge id is null, skip grant. userId: {}, prizeId: {}", userId, prize.getId());
return;
}
Integer days = prize.getDays() != null ? prize.getDays() : 7;
// 徽章赠送天数大于1000天则为永久赠送
BadgeBackpackExpireTypeEnum expireType = days >= 1000
? BadgeBackpackExpireTypeEnum.PERMANENT
: BadgeBackpackExpireTypeEnum.TEMPORARY;
badgeBackpackClient.giveBadge(new GiveBadgeCmd()
.setAcceptUserId(userId)
.setBadgeId(prize.getPropsId())
.setExpireType(expireType)
.setDays(days)
.setBadgeType(SysBadgeConfigTypeEnum.ACTIVITY)
);
// 发送通知
propsBackpackClient.givePropsNotify(userId, "BADGE", days, prize.getPropsId());
log.info("Badge granted, userId: {}, badgeId: {}, days: {}, expireType: {}, recordNo: {}",
userId, prize.getPropsId(), days, expireType, recordNo);
}
/**
* 发放主题.
*/
private void grantTheme(Long userId, LotteryPrize prize, String recordNo) {
if (prize.getPropsId() == null) {
log.warn("Theme id is null, skip grant. userId: {}, prizeId: {}", userId, prize.getId());
return;
}
Integer days = prize.getDays() != null ? prize.getDays() : 7;
roomThemeBackpackClient.sendRoomTheme(
userId,
prize.getPropsId(),
days,
SendPropsOrigin.LOTTERY_REWARD
);
// 发送通知
// propsBackpackClient.givePropsNotify(userId, "THEME", days, prize.getPropsId());
log.info("Theme granted, userId: {}, themeId: {}, days: {}, recordNo: {}",
userId, prize.getPropsId(), days, recordNo);
}
}

View File

@ -47,6 +47,21 @@ public class LotteryPrizeCO extends ClientObject {
*/
private String prizeImage;
/**
* 奖品图标用于中奖记录展示.
*/
private String prizeIcon;
/**
* 道具ID.
*/
private Long propsId;
/**
* 有效天数.
*/
private Integer days;
/**
* 奖品等级.
*/

View File

@ -52,7 +52,7 @@ public class LotteryPrize implements Serializable {
private String prizeName;
/**
* 奖品类型1-实物2-虚拟币3-优惠券4-积分5-谢谢参与.
* 奖品类型MONEY-金钱GOLD-金币DIAMOND-钻石PROPS-道具BADGE-徽章THEME-主题NOBLE_VIP-贵族VIPTHANKS-谢谢参与.
*/
@TableField("prize_type")
private String prizeType;
@ -69,6 +69,18 @@ public class LotteryPrize implements Serializable {
@TableField("prize_image")
private String prizeImage;
/**
* 道具ID当prize_type为PROPS类时使用.
*/
@TableField("props_id")
private Long propsId;
/**
* 有效天数道具/徽章使用.
*/
@TableField("days")
private Integer days;
/**
* 奖品等级1-特等奖2-一等奖3-二等奖.
*/