|
|
@@ -19,10 +19,18 @@ import com.xuekairui.activity.mapper.ActivityLotteryRecordMapper;
|
|
|
import com.xuekairui.common.BusinessException;
|
|
|
import com.xuekairui.common.ErrorCode;
|
|
|
import com.xuekairui.common.event.ActivityEventRedisService;
|
|
|
+import com.xuekairui.user.entity.BusinessLicense;
|
|
|
+import com.xuekairui.user.entity.OperationAuditLog;
|
|
|
import com.xuekairui.user.entity.User;
|
|
|
+import com.xuekairui.user.enums.AuditResult;
|
|
|
+import com.xuekairui.user.enums.LicenseStatus;
|
|
|
import com.xuekairui.user.enums.MembershipLevel;
|
|
|
+import com.xuekairui.user.enums.OperatorRole;
|
|
|
+import com.xuekairui.user.enums.OperationType;
|
|
|
+import com.xuekairui.user.mapper.BusinessLicenseMapper;
|
|
|
import com.xuekairui.user.mapper.UserMapper;
|
|
|
import com.xuekairui.user.service.MembershipService;
|
|
|
+import com.xuekairui.user.service.OperationAuditLogService;
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
|
@@ -39,9 +47,12 @@ import java.util.concurrent.ThreadLocalRandom;
|
|
|
* 比价抽奖活动服务。
|
|
|
* <p>
|
|
|
* 规则:
|
|
|
- * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有高级会员(PRO及以上)可参加;
|
|
|
- * 2. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
|
|
|
- * 3. 抽奖按奖品权重随机中奖,中奖后即时发放对应奖励。
|
|
|
+ * 1. 活动时间窗 2026-07-13 ~ 2026-07-31;
|
|
|
+ * 2. 参与条件:高级会员(PRO及以上) + 入驻信息审核通过(APPROVED);
|
|
|
+ * 3. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
|
|
|
+ * 4. 每日仅限抽奖1次,重复抽奖不允许,返回"今日抽奖次数已用完"并记录违规审计日志;
|
|
|
+ * 5. 抽奖按奖品权重随机中奖,库存为0的奖品不参与抽奖;
|
|
|
+ * 6. 中奖后即时发放对应奖励(会员时长即刻到账,实物奖品需客服核实)。
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
@@ -52,14 +63,17 @@ public class ActivityLotteryService {
|
|
|
private static final String SOURCE_COMPARE = "COMPARE";
|
|
|
private static final String REWARD_TYPE_MEMBERSHIP = "MEMBERSHIP";
|
|
|
private static final String REWARD_TYPE_EMPTY = "EMPTY";
|
|
|
+ private static final String REWARD_TYPE_PHYSICAL = "PHYSICAL";
|
|
|
|
|
|
private final ActivityLotteryConfigMapper configMapper;
|
|
|
private final ActivityLotteryChanceMapper chanceMapper;
|
|
|
private final ActivityLotteryPrizeMapper prizeMapper;
|
|
|
private final ActivityLotteryRecordMapper recordMapper;
|
|
|
private final UserMapper userMapper;
|
|
|
+ private final BusinessLicenseMapper businessLicenseMapper;
|
|
|
private final MembershipService membershipService;
|
|
|
private final ActivityEventRedisService activityEventRedisService;
|
|
|
+ private final OperationAuditLogService operationAuditLogService;
|
|
|
|
|
|
// ==========================================
|
|
|
// 配置管理
|
|
|
@@ -92,7 +106,6 @@ public class ActivityLotteryService {
|
|
|
} else {
|
|
|
configMapper.updateById(config);
|
|
|
}
|
|
|
- // 同步每日上限到 Redis 缓存,供事件防重模块实时读取
|
|
|
int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
|
|
|
activityEventRedisService.updateCachedMaxChance(maxChance);
|
|
|
log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}, dailyMaxChance={}",
|
|
|
@@ -104,10 +117,6 @@ public class ActivityLotteryService {
|
|
|
// 事件处理:比价成功后发放抽奖机会
|
|
|
// ==========================================
|
|
|
|
|
|
- /**
|
|
|
- * 监听比价消耗事件:活动开启且用户具备资格时,每自然日首次比价发放一次抽奖机会。
|
|
|
- * 幂等:t_activity_lottery_chance 的 uk_user_date_source 保证每日仅一次。
|
|
|
- */
|
|
|
@Transactional
|
|
|
public void onCrawlerConsumed(Long userId, String usageDateStr) {
|
|
|
ActivityLotteryConfig config = getConfig();
|
|
|
@@ -140,18 +149,37 @@ public class ActivityLotteryService {
|
|
|
|
|
|
/**
|
|
|
* 执行抽奖:消耗一个当日未使用的抽奖机会,按权重随机中奖并发放奖励。
|
|
|
+ *
|
|
|
+ * @param userId 用户ID
|
|
|
+ * @param clientIp 客户端IP(用于违规审计记录)
|
|
|
*/
|
|
|
@Transactional
|
|
|
- public ActivityLotteryResultResponse draw(Long userId) {
|
|
|
+ public ActivityLotteryResultResponse draw(Long userId, String clientIp) {
|
|
|
ActivityLotteryConfig config = getConfig();
|
|
|
if (!isActive(config)) {
|
|
|
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束");
|
|
|
}
|
|
|
if (isNotQualified(userId, config)) {
|
|
|
- throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖");
|
|
|
+ throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员且入驻审核通过可参与比价抽奖");
|
|
|
}
|
|
|
|
|
|
LocalDate today = LocalDate.now();
|
|
|
+
|
|
|
+ // 检查今日是否已抽过(used=1 的机会记录存在即表示已抽奖)
|
|
|
+ Long usedCount = chanceMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<ActivityLotteryChance>()
|
|
|
+ .eq(ActivityLotteryChance::getUserId, userId)
|
|
|
+ .eq(ActivityLotteryChance::getChanceDate, today)
|
|
|
+ .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
|
|
|
+ .eq(ActivityLotteryChance::getUsed, 1));
|
|
|
+
|
|
|
+ if (usedCount != null && usedCount > 0) {
|
|
|
+ // 今日已抽过,记录违规审计日志并返回提示
|
|
|
+ recordViolation(userId, clientIp, today);
|
|
|
+ log.warn("重复抽奖违规: userId={}, ip={}, date={}", userId, clientIp, today);
|
|
|
+ throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完");
|
|
|
+ }
|
|
|
+
|
|
|
// 取一条未使用的机会
|
|
|
ActivityLotteryChance chance = chanceMapper.selectOne(
|
|
|
new LambdaQueryWrapper<ActivityLotteryChance>()
|
|
|
@@ -171,15 +199,28 @@ public class ActivityLotteryService {
|
|
|
.eq(ActivityLotteryChance::getUsed, 0)
|
|
|
.set(ActivityLotteryChance::getUsed, 1));
|
|
|
if (affected == 0) {
|
|
|
- throw new BusinessException(ErrorCode.BUSINESS_ERROR, "抽奖机会已被使用");
|
|
|
+ // 并发竞争失败,记录违规
|
|
|
+ recordViolation(userId, clientIp, today);
|
|
|
+ throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完");
|
|
|
}
|
|
|
|
|
|
- // 加权随机选中奖品
|
|
|
+ // 加权随机选中奖品(库存为0的奖品不参与)
|
|
|
ActivityLotteryPrize prize = pickPrize();
|
|
|
if (prize == null) {
|
|
|
- // 无可用奖品,兜底为未中奖
|
|
|
+ // 无可用奖品,兜底为谢谢惠顾
|
|
|
prize = ActivityLotteryPrize.builder()
|
|
|
- .name("谢谢参与").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
|
|
|
+ .name("谢谢惠顾").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
|
|
|
+ } else {
|
|
|
+ // 非谢谢惠顾奖品需要原子扣减库存
|
|
|
+ if (!REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
|
|
|
+ int stockAffected = prizeMapper.decrementStock(prize.getId());
|
|
|
+ if (stockAffected == 0) {
|
|
|
+ // 库存不足(并发竞争),兜底为谢谢惠顾
|
|
|
+ log.warn("奖品库存不足,兜底谢谢惠顾: prizeId={}, name={}", prize.getId(), prize.getName());
|
|
|
+ prize = ActivityLotteryPrize.builder()
|
|
|
+ .name("谢谢惠顾").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 发放奖励
|
|
|
@@ -195,10 +236,11 @@ public class ActivityLotteryService {
|
|
|
.rewardDays(prize.getRewardDays())
|
|
|
.rewardLevel(prize.getRewardLevel())
|
|
|
.chanceId(chance.getId())
|
|
|
+ .clientIp(clientIp)
|
|
|
.build();
|
|
|
recordMapper.insert(record);
|
|
|
|
|
|
- log.info("抽奖完成: userId={}, prize={}, type={}", userId, prize.getName(), prize.getRewardType());
|
|
|
+ log.info("抽奖完成: userId={}, prize={}, type={}, ip={}", userId, prize.getName(), prize.getRewardType(), clientIp);
|
|
|
|
|
|
boolean win = !REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType());
|
|
|
return ActivityLotteryResultResponse.builder()
|
|
|
@@ -208,6 +250,7 @@ public class ActivityLotteryService {
|
|
|
.rewardAmount(record.getRewardAmount())
|
|
|
.rewardDays(prize.getRewardDays())
|
|
|
.rewardLevel(prize.getRewardLevel())
|
|
|
+ .prizeTier(extractPrizeTier(prize.getName()))
|
|
|
.win(win)
|
|
|
.createTime(record.getCreateTime())
|
|
|
.build();
|
|
|
@@ -234,12 +277,14 @@ public class ActivityLotteryService {
|
|
|
.eq(ActivityLotteryChance::getUsed, 1));
|
|
|
|
|
|
MembershipLevel userLevel = getUserLevel(userId);
|
|
|
+ boolean licenseApproved = isLicenseApproved(userId);
|
|
|
String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
|
|
|
- boolean eligible = isLevelEnough(userLevel, requireLevel);
|
|
|
+ boolean levelEnough = isLevelEnough(userLevel, requireLevel);
|
|
|
+ boolean eligible = levelEnough && licenseApproved;
|
|
|
|
|
|
int avail = available != null ? available.intValue() : 0;
|
|
|
int usedCount = used != null ? used.intValue() : 0;
|
|
|
- String reason = computeReason(config, avail, usedCount, eligible, userLevel, requireLevel);
|
|
|
+ String reason = computeReason(config, avail, usedCount, eligible, levelEnough, licenseApproved, userLevel, requireLevel);
|
|
|
|
|
|
return ActivityLotteryStatusResponse.builder()
|
|
|
.enabled(isActive(config))
|
|
|
@@ -331,12 +376,15 @@ public class ActivityLotteryService {
|
|
|
|
|
|
@Transactional
|
|
|
public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) {
|
|
|
+ int stock = request.getStock() != null ? request.getStock() : 0;
|
|
|
ActivityLotteryPrize prize = ActivityLotteryPrize.builder()
|
|
|
.name(request.getName())
|
|
|
.rewardType(request.getRewardType())
|
|
|
.rewardAmount(request.getRewardAmount() != null ? request.getRewardAmount() : 0)
|
|
|
.rewardDays(request.getRewardDays())
|
|
|
.rewardLevel(request.getRewardLevel())
|
|
|
+ .stock(stock)
|
|
|
+ .remainingStock(stock)
|
|
|
.probabilityWeight(request.getProbabilityWeight())
|
|
|
.enabled(request.getEnabled() != null ? request.getEnabled() : 1)
|
|
|
.sort(request.getSort() != null ? request.getSort() : 0)
|
|
|
@@ -356,6 +404,14 @@ public class ActivityLotteryService {
|
|
|
Optional.ofNullable(request.getRewardAmount()).ifPresent(prize::setRewardAmount);
|
|
|
prize.setRewardDays(request.getRewardDays());
|
|
|
prize.setRewardLevel(request.getRewardLevel());
|
|
|
+ Optional.ofNullable(request.getStock()).ifPresent(stock -> {
|
|
|
+ prize.setStock(stock);
|
|
|
+ // 更新总份数时,剩余份数同步调整(已发放量保持不变)
|
|
|
+ int issued = (prize.getStock() != null ? prize.getStock() : 0)
|
|
|
+ - (prize.getRemainingStock() != null ? prize.getRemainingStock() : 0);
|
|
|
+ int newRemaining = Math.max(0, stock - issued);
|
|
|
+ prize.setRemainingStock(newRemaining);
|
|
|
+ });
|
|
|
Optional.ofNullable(request.getProbabilityWeight()).ifPresent(prize::setProbabilityWeight);
|
|
|
Optional.ofNullable(request.getEnabled()).ifPresent(prize::setEnabled);
|
|
|
Optional.ofNullable(request.getSort()).ifPresent(prize::setSort);
|
|
|
@@ -372,11 +428,62 @@ public class ActivityLotteryService {
|
|
|
// 私有辅助
|
|
|
// ==========================================
|
|
|
|
|
|
+ /**
|
|
|
+ * 记录重复抽奖违规审计日志。
|
|
|
+ * 复用通用操作审计日志服务,将违规操作记入 t_operation_audit_log,
|
|
|
+ * 包含用户ID、IP、操作时间等信息,便于后续追溯。
|
|
|
+ */
|
|
|
+ private void recordViolation(Long userId, String clientIp, LocalDate chanceDate) {
|
|
|
+ try {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ String phone = user != null ? user.getPhone() : null;
|
|
|
+ String operatorName = phone != null ? phone : String.valueOf(userId);
|
|
|
+
|
|
|
+ OperationAuditLog auditLog = OperationAuditLog.builder()
|
|
|
+ .operatorId(userId)
|
|
|
+ .operatorName(operatorName)
|
|
|
+ .operatorRole(OperatorRole.USER.getCode())
|
|
|
+ .operationType(OperationType.USER_UPDATE.getCode())
|
|
|
+ .module("LOTTERY")
|
|
|
+ .description("比价抽奖重复抽奖违规")
|
|
|
+ .targetType("LOTTERY")
|
|
|
+ .targetId(userId)
|
|
|
+ .targetIdentifier("LOTTERY:" + userId + ":" + chanceDate)
|
|
|
+ .result(AuditResult.FAILED.getCode())
|
|
|
+ .errorMessage("今日抽奖次数已用完,重复尝试已记录")
|
|
|
+ .ipAddress(clientIp)
|
|
|
+ .remark("重复抽奖违规,IP:" + clientIp + ", 日期:" + chanceDate)
|
|
|
+ .isSensitive(true)
|
|
|
+ .build();
|
|
|
+ operationAuditLogService.logOperation(auditLog);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("记录违规审计日志失败: userId={}, ip={}", userId, clientIp, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从奖品名称中提取奖项等级(特等奖/一等奖/.../谢谢惠顾)
|
|
|
+ */
|
|
|
+ private String extractPrizeTier(String prizeName) {
|
|
|
+ if (prizeName == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (prizeName.contains("特等奖")) return "特等奖";
|
|
|
+ if (prizeName.contains("一等奖")) return "一等奖";
|
|
|
+ if (prizeName.contains("二等奖")) return "二等奖";
|
|
|
+ if (prizeName.contains("三等奖")) return "三等奖";
|
|
|
+ if (prizeName.contains("四等奖")) return "四等奖";
|
|
|
+ if (prizeName.contains("五等奖")) return "五等奖";
|
|
|
+ if (prizeName.contains("谢谢")) return "谢谢惠顾";
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 按优先级计算抽奖状态原因。有机会时返回 null。
|
|
|
*/
|
|
|
private String computeReason(ActivityLotteryConfig config, int avail, int usedCount,
|
|
|
- boolean eligible, MembershipLevel userLevel, String requireLevel) {
|
|
|
+ boolean eligible, boolean levelEnough, boolean licenseApproved,
|
|
|
+ MembershipLevel userLevel, String requireLevel) {
|
|
|
if (avail > 0) {
|
|
|
return null;
|
|
|
}
|
|
|
@@ -388,10 +495,13 @@ public class ActivityLotteryService {
|
|
|
if (!inWin) {
|
|
|
return "不在活动时间窗内";
|
|
|
}
|
|
|
- if (!eligible) {
|
|
|
+ if (!levelEnough) {
|
|
|
String level = userLevel != null ? userLevel.getCode() : "无";
|
|
|
return "会员等级不足(当前" + level + ",需要" + requireLevel + ")";
|
|
|
}
|
|
|
+ if (!licenseApproved) {
|
|
|
+ return "入驻信息未审核通过,无法参与抽奖";
|
|
|
+ }
|
|
|
if (usedCount == 0) {
|
|
|
return "今日尚未比价,完成首次比价后自动获得抽奖机会";
|
|
|
}
|
|
|
@@ -407,9 +517,23 @@ public class ActivityLotteryService {
|
|
|
return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 检查用户是否不满足参与条件(会员等级不足 或 入驻未审核通过)
|
|
|
+ */
|
|
|
private boolean isNotQualified(Long userId, ActivityLotteryConfig config) {
|
|
|
String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
|
|
|
- return !isLevelEnough(getUserLevel(userId), requireLevel);
|
|
|
+ if (!isLevelEnough(getUserLevel(userId), requireLevel)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return !isLicenseApproved(userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查用户入驻信息是否审核通过
|
|
|
+ */
|
|
|
+ private boolean isLicenseApproved(Long userId) {
|
|
|
+ BusinessLicense license = businessLicenseMapper.selectByUserId(userId);
|
|
|
+ return license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus());
|
|
|
}
|
|
|
|
|
|
private MembershipLevel getUserLevel(Long userId) {
|
|
|
@@ -429,37 +553,55 @@ public class ActivityLotteryService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 按权重随机选择一个启用的奖品
|
|
|
+ * 按权重随机选择一个启用的奖品。
|
|
|
+ * 库存为0(remaining_stock=0)的奖品不参与抽奖。
|
|
|
*/
|
|
|
private ActivityLotteryPrize pickPrize() {
|
|
|
List<ActivityLotteryPrize> prizes = prizeMapper.listEnabled();
|
|
|
if (prizes.isEmpty()) {
|
|
|
return null;
|
|
|
}
|
|
|
- int totalWeight = prizes.stream()
|
|
|
+ // 过滤掉库存为0的奖品(EMPTY类型不受库存限制,始终参与)
|
|
|
+ List<ActivityLotteryPrize> available = prizes.stream()
|
|
|
+ .filter(p -> REWARD_TYPE_EMPTY.equalsIgnoreCase(p.getRewardType())
|
|
|
+ || (p.getRemainingStock() != null && p.getRemainingStock() > 0))
|
|
|
+ .toList();
|
|
|
+ if (available.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ int totalWeight = available.stream()
|
|
|
.mapToInt(p -> p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0)
|
|
|
.sum();
|
|
|
if (totalWeight <= 0) {
|
|
|
- return prizes.get(prizes.size() - 1);
|
|
|
+ // 权重全为0时,取最后一个(通常是谢谢惠顾)
|
|
|
+ return available.get(available.size() - 1);
|
|
|
}
|
|
|
int rand = ThreadLocalRandom.current().nextInt(totalWeight);
|
|
|
int acc = 0;
|
|
|
- for (ActivityLotteryPrize p : prizes) {
|
|
|
+ for (ActivityLotteryPrize p : available) {
|
|
|
acc += (p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0);
|
|
|
if (rand < acc) {
|
|
|
return p;
|
|
|
}
|
|
|
}
|
|
|
- return prizes.get(prizes.size() - 1);
|
|
|
+ return available.get(available.size() - 1);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 根据奖品类型发放奖励(MEMBERSHIP 或 EMPTY)
|
|
|
+ * 根据奖品类型发放奖励
|
|
|
+ * - MEMBERSHIP:会员时长即刻到账
|
|
|
+ * - PHYSICAL:实物奖品,记录中奖,需客服后续核实(不自动发放)
|
|
|
+ * - EMPTY:无奖励
|
|
|
*/
|
|
|
private void applyReward(Long userId, ActivityLotteryPrize prize) {
|
|
|
if (REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
|
|
|
return;
|
|
|
}
|
|
|
+ if (REWARD_TYPE_PHYSICAL.equalsIgnoreCase(prize.getRewardType())) {
|
|
|
+ // 实物奖品不自动发放,中奖记录已入库,需客服在活动结束后联系核实
|
|
|
+ log.info("实物奖品中奖,待客服核实: userId={}, prize={}", userId, prize.getName());
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) {
|
|
|
int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0;
|
|
|
if (days <= 0) {
|