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 892d4006..748fd911 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 @@ -159,6 +159,11 @@ public enum OtherErrorCode implements IResponseErrorCode { */ USER_RECHARGE_INVALID(3273, "The recharge amount is insufficient to claim the reward"), + /** + * 设备已被其他账号使用 + */ + DEVICE_ALREADY_USED(3274, "Device has been used by another account"), + ; private final int code; diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/activity/LotteryDrawExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/activity/LotteryDrawExe.java index 9c9a0165..4b1bf234 100644 --- a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/activity/LotteryDrawExe.java +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/activity/LotteryDrawExe.java @@ -13,6 +13,7 @@ import com.red.circle.other.infra.database.rds.entity.activity.LotteryTicket; import com.red.circle.other.infra.database.rds.entity.activity.LotteryTicketRecord; import com.red.circle.other.infra.database.rds.entity.activity.LotteryUserCount; import com.red.circle.other.infra.database.rds.entity.activity.LotteryUserProgress; +import com.red.circle.other.infra.database.rds.entity.user.user.LatestMobileDevice; import com.red.circle.other.infra.database.rds.service.activity.LotteryActivityService; import com.red.circle.other.infra.database.rds.service.activity.LotteryPrizeService; import com.red.circle.other.infra.database.rds.service.activity.LotteryRecordService; @@ -20,7 +21,11 @@ import com.red.circle.other.infra.database.rds.service.activity.LotteryTicketRec import com.red.circle.other.infra.database.rds.service.activity.LotteryTicketService; import com.red.circle.other.infra.database.rds.service.activity.LotteryUserCountService; import com.red.circle.other.infra.database.rds.service.activity.LotteryUserProgressService; +import com.red.circle.other.infra.database.rds.service.activity.LotteryDeviceRecordService; +import com.red.circle.other.infra.database.rds.entity.activity.LotteryDeviceRecord; +import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService; import com.red.circle.other.infra.utils.ZonedDateTimeUtils; +import com.red.circle.other.inner.asserts.OtherErrorCode; import com.red.circle.other.inner.asserts.lottery.LotteryErrorCode; import com.red.circle.tool.core.date.TimestampUtils; import java.math.BigDecimal; @@ -59,6 +64,8 @@ public class LotteryDrawExe { private final LotteryUserProgressService lotteryUserProgressService; private final LotteryPrizeGrantService lotteryPrizeGrantService; private final RedisService redisService; + private final LatestMobileDeviceService latestMobileDeviceService; + private final LotteryDeviceRecordService lotteryDeviceRecordService; @Transactional(rollbackFor = Exception.class) public LotteryDrawResultCO execute(LotteryDrawCmd cmd) { @@ -69,19 +76,21 @@ public class LotteryDrawExe { // 1. 校验活动 LotteryActivity activity = validateActivity(activityId, now); - // 2. 校验抽奖次数限制 - validateDrawCount(userId, activityId, activity); + // 2. 校验设备唯一性(防止小号薅羊毛) + checkDeviceUnique(activityId, userId); - // 3. 校验并扣除抽奖券(如果需要) + // 3. 校验抽奖次数限制 + validateDrawCount(userId, activityId, activity); + // 4. 校验并扣除抽奖券(如果需要) deductTicket(userId, activity.getTicketCost()); - // 4. 获取或创建用户进度(用于动态概率) + // 5. 获取或创建用户进度(用于动态概率) LotteryUserProgress progress = getOrCreateUserProgress(userId, activityId); - // 5. 执行抽奖算法(动态概率) + // 6. 执行抽奖算法(动态概率) LotteryPrize prize = drawPrizeWithDynamicProbability(activityId, activity, progress); - // 6. 扣减奖品库存(使用悲观锁防止超卖) + // 7. 扣减奖品库存(使用悲观锁防止超卖) if (prize != null) { boolean deductSuccess = deductPrizeStock(prize.getId()); if (!deductSuccess) { @@ -90,24 +99,62 @@ public class LotteryDrawExe { } } - // 7. 保存抽奖记录 + // 8. 保存抽奖记录 LotteryRecord record = saveDrawRecord(userId, activityId, prize, now, null, 1); - // 8. 发放奖励 + // 9. 发放奖励 if (prize != null) { lotteryPrizeGrantService.grantPrize(userId, prize, record.getRecordNo()); } - // 9. 更新用户抽奖次数 + // 10. 更新用户抽奖次数 updateUserDrawCount(userId, activityId); - // 10. 更新用户进度(累加金额和次数) + // 11. 更新用户进度(累加金额和次数) updateUserProgress(progress, prize); - // 11. 组装返回结果 + // 12. 记录设备信息(首次抽奖时) + recordDeviceAfterDraw(activityId, userId); + + // 13. 组装返回结果 return convertToResultCO(record, prize); } + /** + * 校验设备唯一性(防止小号薅羊毛) + */ + private void checkDeviceUnique(Long activityId, Long userId) { + String fingerprint = latestMobileDeviceService.getEnhancedDeviceFingerprintByUserId(userId); + + if (com.red.circle.tool.core.text.StringUtils.isBlank(fingerprint)) { + log.warn("Failed to get device fingerprint, userId: {}", userId); + return; + } + + LotteryDeviceRecord existingRecord = lotteryDeviceRecordService + .getByActivityAndFingerprint(activityId, fingerprint); + + if (existingRecord != null && !existingRecord.getUserId().equals(userId)) { + log.warn("Duplicate device detected, activityId: {}, currentUser: {}, boundUser: {}, fingerprint: {}", + activityId, userId, existingRecord.getUserId(), fingerprint); + ResponseAssert.isTrue(OtherErrorCode.DEVICE_ALREADY_USED, false); + } + } + + /** + * 记录设备信息(抽奖成功后) + */ + private void recordDeviceAfterDraw(Long activityId, Long userId) { + try { + String fingerprint = latestMobileDeviceService.getEnhancedDeviceFingerprintByUserId(userId); + if (com.red.circle.tool.core.text.StringUtils.isNotBlank(fingerprint)) { + lotteryDeviceRecordService.recordDevice(activityId, userId, fingerprint); + } + } catch (Exception e) { + log.error("Failed to record device, activityId: {}, userId: {}", activityId, userId, e); + } + } + /** * 校验活动状态和时间. */ diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/LotteryDeviceRecordDAO.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/LotteryDeviceRecordDAO.java new file mode 100644 index 00000000..5492802b --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/dao/activity/LotteryDeviceRecordDAO.java @@ -0,0 +1,13 @@ +package com.red.circle.other.infra.database.rds.dao.activity; + +import com.red.circle.framework.mybatis.dao.BaseDAO; +import com.red.circle.other.infra.database.rds.entity.activity.LotteryDeviceRecord; + +/** + * 抽奖设备记录 DAO + * + * @author system + * @since 2025-12-30 + */ +public interface LotteryDeviceRecordDAO extends BaseDAO { +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/activity/LotteryDeviceRecord.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/activity/LotteryDeviceRecord.java new file mode 100644 index 00000000..61964d8f --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/activity/LotteryDeviceRecord.java @@ -0,0 +1,60 @@ +package com.red.circle.other.infra.database.rds.entity.activity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serial; +import java.io.Serializable; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 抽奖设备记录表 + * + * @author system + * @since 2025-12-30 + */ +@Data +@Accessors(chain = true) +@TableName("lottery_device_record") +public class LotteryDeviceRecord implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键标识 + */ + @TableId("id") + private Long id; + + /** + * 活动ID + */ + @TableField("activity_id") + private Long activityId; + + /** + * 用户ID + */ + @TableField("user_id") + private Long userId; + + /** + * 设备指纹MD5 + */ + @TableField("device_fingerprint") + private String deviceFingerprint; + + /** + * 创建时间 + */ + @TableField("create_time") + private Long createTime; + + /** + * 更新时间 + */ + @TableField("update_time") + private Long updateTime; +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/LotteryDeviceRecordService.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/LotteryDeviceRecordService.java new file mode 100644 index 00000000..c3ce8ef7 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/LotteryDeviceRecordService.java @@ -0,0 +1,31 @@ +package com.red.circle.other.infra.database.rds.service.activity; + +import com.red.circle.framework.mybatis.service.BaseService; +import com.red.circle.other.infra.database.rds.entity.activity.LotteryDeviceRecord; + +/** + * 抽奖设备记录 Service + * + * @author system + * @since 2025-12-30 + */ +public interface LotteryDeviceRecordService extends BaseService { + + /** + * 根据活动ID和设备指纹查询记录 + * + * @param activityId 活动ID + * @param fingerprint 设备指纹 + * @return 设备记录 + */ + LotteryDeviceRecord getByActivityAndFingerprint(Long activityId, String fingerprint); + + /** + * 记录设备信息(幂等操作) + * + * @param activityId 活动ID + * @param userId 用户ID + * @param fingerprint 设备指纹 + */ + void recordDevice(Long activityId, Long userId, String fingerprint); +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/impl/LotteryDeviceRecordServiceImpl.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/impl/LotteryDeviceRecordServiceImpl.java new file mode 100644 index 00000000..95994091 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/service/activity/impl/LotteryDeviceRecordServiceImpl.java @@ -0,0 +1,58 @@ +package com.red.circle.other.infra.database.rds.service.activity.impl; + +import com.red.circle.framework.mybatis.service.impl.BaseServiceImpl; +import com.red.circle.tool.core.text.StringUtils; +import com.red.circle.other.infra.database.rds.dao.activity.LotteryDeviceRecordDAO; +import com.red.circle.other.infra.database.rds.entity.activity.LotteryDeviceRecord; +import com.red.circle.other.infra.database.rds.service.activity.LotteryDeviceRecordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +/** + * 抽奖设备记录 Service实现 + * + * @author system + * @since 2025-12-30 + */ +@Slf4j +@Service +public class LotteryDeviceRecordServiceImpl + extends BaseServiceImpl + implements LotteryDeviceRecordService { + + @Override + public LotteryDeviceRecord getByActivityAndFingerprint(Long activityId, String fingerprint) { + if (activityId == null || StringUtils.isBlank(fingerprint)) { + return null; + } + + return query() + .eq(LotteryDeviceRecord::getActivityId, activityId) + .eq(LotteryDeviceRecord::getDeviceFingerprint, fingerprint) + .getOne(); + } + + @Override + public void recordDevice(Long activityId, Long userId, String fingerprint) { + LotteryDeviceRecord existing = query() + .eq(LotteryDeviceRecord::getActivityId, activityId) + .eq(LotteryDeviceRecord::getUserId, userId) + .getOne(); + + if (existing == null) { + LotteryDeviceRecord record = new LotteryDeviceRecord(); + record.setActivityId(activityId); + record.setUserId(userId); + record.setDeviceFingerprint(fingerprint); + record.setCreateTime(System.currentTimeMillis()); + record.setUpdateTime(System.currentTimeMillis()); + + try { + save(record); + } catch (DuplicateKeyException e) { + log.warn("Device record already exists, activityId:{}, userId:{}", activityId, userId); + } + } + } +} diff --git a/rc-service/rc-service-other/other-start/src/test/java/SpringTest.java b/rc-service/rc-service-other/other-start/src/test/java/SpringTest.java index 1bc31067..664b51da 100644 --- a/rc-service/rc-service-other/other-start/src/test/java/SpringTest.java +++ b/rc-service/rc-service-other/other-start/src/test/java/SpringTest.java @@ -32,6 +32,7 @@ import com.red.circle.other.domain.ranking.RankingCycleType; import com.red.circle.other.domain.ranking.RankingDimension; import com.red.circle.other.infra.database.cache.service.other.EnumConfigCacheService; import com.red.circle.other.infra.database.rds.entity.sys.GameListConfig; +import com.red.circle.other.infra.database.rds.entity.user.user.LatestMobileDevice; import com.red.circle.other.infra.database.rds.entity.user.user.OneTimeTask; import com.red.circle.other.infra.database.rds.service.activity.UserActivityRechargeService; import com.red.circle.other.infra.database.rds.service.sys.GameListConfigService; @@ -99,6 +100,11 @@ public class SpringTest { @Autowired private SingleGroupRewardPoolCommon singleGroupRewardPoolCommon; + @Test + public void testLatestDevice() { + String latestMobileDevice = latestMobileDeviceService.getEnhancedDeviceFingerprintByUserId(1957345312961527809L); + System.out.println(); + } @Test public void testActivityRuleConfig() {