package com.xuekairui.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xuekairui.activity.dto.ActivityInviteConfigRequest; import com.xuekairui.activity.dto.ActivityInviteStatusResponse; import com.xuekairui.activity.dto.ActivityRewardRecordResponse; import com.xuekairui.activity.entity.ActivityInviteConfig; import com.xuekairui.activity.entity.ActivityInviteRewardRecord; import com.xuekairui.activity.mapper.ActivityInviteConfigMapper; import com.xuekairui.activity.mapper.ActivityInviteRewardRecordMapper; import com.xuekairui.common.activity.ActivityInviteGate; import com.xuekairui.invite.entity.InviteRelation; import com.xuekairui.invite.mapper.InviteRelationMapper; import com.xuekairui.user.entity.BusinessLicense; import com.xuekairui.user.entity.User; import com.xuekairui.user.enums.LicenseStatus; import com.xuekairui.user.enums.MembershipLevel; 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; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; /** * 邀请有礼活动服务(统一管理所有邀请奖励发放)。 *

* 通过实现 {@link ActivityInviteGate} 接管 InviteService 的奖励发放, * 根据活动配置决定发放策略: *

*

* 规则要点: * 1. 活动时间窗内有效; * 2. LICENSE_APPROVED 模式:被邀请人需上传入驻信息且审核通过后才发放; * 3. 邀请人最多获得 30 次邀请奖励(活动期间内); * 4. 每个被邀请人只触发一次邀请人奖励(DB 唯一索引兜底)。 */ @Slf4j @Service @RequiredArgsConstructor public class ActivityInviteService implements ActivityInviteGate { /** 奖励触发模式:入驻审核通过后发 */ public static final String TRIGGER_LICENSE_APPROVED = "LICENSE_APPROVED"; private static final Long CONFIG_ID = 1L; private final ActivityInviteConfigMapper configMapper; private final ActivityInviteRewardRecordMapper rewardRecordMapper; private final InviteRelationMapper inviteRelationMapper; private final UserMapper userMapper; private final BusinessLicenseMapper businessLicenseMapper; private final MembershipService membershipService; private final OperationAuditLogService auditLogService; // ========================================== // ActivityInviteGate 门面实现(供 InviteService 调用) // ========================================== /** * 发放邀请奖励(统一入口,根据 rewardTrigger 配置决定策略)。 *

* LICENSE_APPROVED:不发放,等待入驻审核通过后触发。 * REGISTRATION:立即发放,受活动时间窗和 30 次上限约束。 */ @Override public void grantInviteReward(Long inviterId, Long inviteeId) { ActivityInviteConfig config = getConfig(); // 活动未开启 → 不发放 if (!isActive(config)) { log.info("邀请有礼活动未开启,跳过发奖: inviter={}, invitee={}", inviterId, inviteeId); return; } // LICENSE_APPROVED 模式 → 不发放,等待入驻审核通过 if (TRIGGER_LICENSE_APPROVED.equalsIgnoreCase(config.getRewardTrigger())) { log.info("邀请有礼活动(LICENSE_APPROVED模式),注册环节暂不发奖: inviter={}, invitee={}", inviterId, inviteeId); return; } // REGISTRATION 模式 → 立即发放 grantWithLimit(inviterId, inviteeId, config, "REGISTRATION"); } /** * 已入驻用户补填邀请码时发放奖励(依赖活动配置)。 *

* 场景:被邀请人已通过入驻审核,后补填邀请码 → 邀请人应获得奖励。 * 活动未开启则不发放。幂等由 uk_invitee 唯一索引保障。 */ @Override public void grantRewardForApprovedUser(Long inviterId, Long inviteeId) { ActivityInviteConfig config = getConfig(); if (!isActive(config)) { log.info("邀请有礼活动未开启,跳过已入驻补填发奖: inviter={}, invitee={}", inviterId, inviteeId); return; } grantWithLimit(inviterId, inviteeId, config, "BIND_APPROVED"); log.info("已入驻用户补填邀请码,发放邀请奖励: inviter={}, invitee={}", inviterId, inviteeId); } /** * 运营补发:对活动时间窗内的邀请关系批量补发奖励。 *

* 场景:活动配置晚于实际邀请时间,运营配置活动后对窗口内的邀请进行补发。 * 仅对已入驻(审核通过)的被邀请人补发。 * 每个被邀请人只发一次(uk_invitee 兜底),邀请人受 30 次上限约束。 * * @param startTime 活动开始时间(选填,默认取当前配置) * @param endTime 活动结束时间(选填,默认取当前配置) * @return {granted: 已补发数, skipped: 跳过数, total: 窗口内邀请总数} */ @Transactional public Map retroactiveGrant(LocalDateTime startTime, LocalDateTime endTime) { ActivityInviteConfig config = getConfig(); if (startTime == null) { startTime = config.getStartTime(); } if (endTime == null) { endTime = config.getEndTime(); } // 查询活动时间窗内的所有邀请关系 List relations = inviteRelationMapper.selectList( new LambdaQueryWrapper() .ge(InviteRelation::getCreateTime, startTime) .le(InviteRelation::getCreateTime, endTime) .eq(InviteRelation::getStatus, 1)); int total = relations.size(); int granted = 0; int skipped = 0; for (InviteRelation r : relations) { Long inviteeId = r.getInviteeId(); Long inviterId = r.getInviterId(); // 跳过无效数据 if (inviterId == null || inviteeId == null) { skipped++; continue; } // 检查是否已发过奖(uk_invitee 唯一索引保障,此处提前过滤减少 DB 异常) Long alreadyRewarded = rewardRecordMapper.selectCount( new LambdaQueryWrapper() .eq(ActivityInviteRewardRecord::getInviteeId, inviteeId)); if (alreadyRewarded != null && alreadyRewarded > 0) { skipped++; continue; } // 检查被邀请人是否已入驻审核通过 Long approvedCount = businessLicenseMapper.selectCount( new LambdaQueryWrapper() .eq(BusinessLicense::getUserId, inviteeId) .eq(BusinessLicense::getReviewStatus, LicenseStatus.APPROVED.name())); if (approvedCount == null || approvedCount == 0) { skipped++; continue; } // 补发奖励 grantWithLimit(inviterId, inviteeId, config, "RETROACTIVE"); granted++; } Map result = new HashMap<>(); result.put("total", total); result.put("granted", granted); result.put("skipped", skipped); log.info("补发邀请奖励完成: total={}, granted={}, skipped={}", total, granted, skipped); return result; } // ========================================== // 配置管理 // ========================================== public ActivityInviteConfig getConfig() { ActivityInviteConfig config = configMapper.selectById(CONFIG_ID); if (config == null) { return ActivityInviteConfig.builder() .id(CONFIG_ID).enabled(0) .startTime(LocalDateTime.now()) .endTime(LocalDateTime.now()) .inviterRewardDays(30).maxInviterReward(30) .rewardTrigger(TRIGGER_LICENSE_APPROVED).requireMiniappInviter(0) .build(); } return config; } @Transactional public ActivityInviteConfig updateConfig(ActivityInviteConfigRequest request) { ActivityInviteConfig config = getConfig(); config.setEnabled(request.getEnabled()); config.setStartTime(request.getStartTime()); config.setEndTime(request.getEndTime()); Optional.ofNullable(request.getInviterRewardDays()).ifPresent(config::setInviterRewardDays); Optional.ofNullable(request.getMaxInviterReward()).ifPresent(config::setMaxInviterReward); Optional.ofNullable(request.getRewardTrigger()).ifPresent(config::setRewardTrigger); Optional.ofNullable(request.getRequireMiniappInviter()).ifPresent(config::setRequireMiniappInviter); Optional.ofNullable(request.getRemark()).ifPresent(config::setRemark); if (config.getId() == null) { config.setId(CONFIG_ID); configMapper.insert(config); } else { configMapper.updateById(config); } log.info("邀请有礼活动配置已更新: enabled={}, trigger={}, start={}, end={}", config.getEnabled(), config.getRewardTrigger(), config.getStartTime(), config.getEndTime()); return config; } // ========================================== // 事件处理:入驻审核通过后发放邀请人奖励 // ========================================== /** * 入驻审核通过后发放邀请人奖励(依赖活动配置,不再区分触发模式)。 *

* 活动未开启则不发放。幂等由 uk_invitee 唯一索引保障。 */ @Transactional public void onLicenseApproved(Long userId) { User invitee = userMapper.selectById(userId); if (invitee == null || invitee.getInviterId() == null) { log.debug("被邀请人无邀请人,跳过邀请奖励: inviteeId={}", userId); return; } ActivityInviteConfig config = getConfig(); if (!isActive(config)) { log.info("邀请有礼活动未开启,跳过入驻发奖: invitee={}", userId); return; } Long inviterId = invitee.getInviterId(); grantWithLimit(inviterId, userId, config, "LICENSE_APPROVED"); } // ========================================== // 用户端状态查询 // ========================================== public ActivityInviteStatusResponse getStatus(Long userId) { ActivityInviteConfig config = getConfig(); long rewarded = userId != null ? rewardRecordMapper.countByInviter(userId) : 0L; int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30; long remaining = Math.max(0, maxReward - rewarded); return ActivityInviteStatusResponse.builder() .enabled(isActive(config)) .inWindow(inWindow(config)) .rewardedCount(rewarded) .maxInviterReward(maxReward) .remainingCount(remaining) .build(); } // ========================================== // 运营端:分页查询邀请奖励记录 // ========================================== /** * 分页查询邀请有礼奖励记录(运营端),支持按邀请人ID筛选。 * 按创建时间倒序,转换为统一 DTO。 */ public Page listInviteRewardRecords( Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); if (userId != null) { wrapper.eq(ActivityInviteRewardRecord::getInviterId, userId); } wrapper.orderByDesc(ActivityInviteRewardRecord::getCreateTime); Page recordPage = rewardRecordMapper.selectPage(page, wrapper); Page resultPage = new Page<>(pageNum, pageSize, recordPage.getTotal()); List list = toRewardResponseList(recordPage.getRecords()); resultPage.setRecords(list); return resultPage; } private List toRewardResponseList(List records) { return records.stream() .map(r -> ActivityRewardRecordResponse.builder() .id(r.getId()) .userId(r.getInviterId()) .activityType("INVITE") .rewardName("邀请有礼奖励") .rewardDays(r.getRewardDays()) .rewardLevel("PRO") .rewardType("MEMBERSHIP") .inviterId(r.getInviterId()) .inviteeId(r.getInviteeId()) .status(r.getStatus()) .createTime(r.getCreateTime()) .build()) .toList(); } // ========================================== // 私有辅助 // ========================================== /** * 带活动限制的奖励发放(活动开启时使用)。 * 校验 30 次上限 + 幂等插入记录 + 发放会员。 * * @param triggerSource 触发来源,用于审计日志(如 REGISTRATION / LICENSE_APPROVED / BIND_APPROVED / RETROACTIVE) */ private void grantWithLimit(Long inviterId, Long inviteeId, ActivityInviteConfig config, String triggerSource) { // 1. 校验邀请人奖励次数上限 long rewarded = rewardRecordMapper.countByInviter(inviterId); int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30; if (rewarded >= maxReward) { log.warn("邀请人{}已达活动奖励上限{}次,不再发放: inviteeId={}", inviterId, maxReward, inviteeId); return; } // 2. 幂等插入奖励记录 int days = config.getInviterRewardDays() != null ? config.getInviterRewardDays() : 30; ActivityInviteRewardRecord record = ActivityInviteRewardRecord.builder() .inviterId(inviterId) .inviteeId(inviteeId) .rewardDays(days) .build(); try { rewardRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.info("被邀请人{}已触发过邀请奖励,跳过: inviterId={}", inviteeId, inviterId); return; } // 3. 发放会员奖励 doGrantReward(inviterId, inviteeId, days); // 4. 审计日志:记录邀请奖励发放 String inviterName = getUserNickname(inviterId); String inviteeName = getUserNickname(inviteeId); auditLogService.logSuccess( inviterId, inviterName, "USER", OperationType.INVITE_REWARD_GRANT, "INVITE_REWARD:" + inviteeId, inviterId, null, Map.of("triggerSource", triggerSource, "rewardDays", days, "rewardLevel", "PRO"), String.format("%s → 邀请人%s获赠%d天PRO会员,触发来源:%s", inviteeName, inviterName, days, triggerSource)); log.info("邀请有礼活动奖励已发放: inviter={}, invitee={}, days={}, trigger={}", inviterId, inviteeId, days, triggerSource); } /** * 直接发放会员奖励(不校验活动限制,活动关闭时兜底使用)。 */ private void doGrantReward(Long inviterId, Long inviteeId, int days) { membershipService.grantOrExtendMembership(inviterId, MembershipLevel.PRO, days, "ACTIVITY_INVITE", "邀请有礼:邀请用户" + inviteeId + "奖励" + days + "天高级会员"); } private boolean isActive(ActivityInviteConfig config) { return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config); } private boolean inWindow(ActivityInviteConfig config) { LocalDateTime now = LocalDateTime.now(); return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime()); } // ========================================== // 反作弊:撤销奖励 // ========================================== /** * 撤销指定被邀请人触发的邀请奖励(反作弊)。 * 将奖励记录标记为已撤销,运营可据此手动剥夺邀请人的相应权益。 * * @param inviteeId 被邀请人ID(作弊者) * @param reason 撤销原因 * @param revokerId 操作人ID(运营) * @return 撤销的记录数 */ @Transactional public int revokeReward(Long inviteeId, String reason, Long revokerId) { List records = rewardRecordMapper.findByInvitee(inviteeId); if (records.isEmpty()) { log.warn("未找到被邀请人{}的奖励记录,无需撤销", inviteeId); return 0; } int count = 0; for (ActivityInviteRewardRecord record : records) { record.setStatus(1); record.setRevokeReason(reason); record.setRevokerId(revokerId); record.setRevokeTime(LocalDateTime.now()); rewardRecordMapper.updateById(record); count++; // 审计日志:记录奖励撤销 String inviterName = getUserNickname(record.getInviterId()); String inviteeName = getUserNickname(inviteeId); auditLogService.logSuccess( revokerId, "运营(反作弊)", "ADMIN", OperationType.INVITE_REWARD_GRANT, "INVITE_REVOKE:" + inviteeId, record.getInviterId(), Map.of("rewardDays", record.getRewardDays(), "rewardLevel", "PRO"), null, String.format("撤销邀请奖励:邀请人%s(因被邀请人%s作弊,原因:%s)", inviterName, inviteeName, reason)); log.warn("反作弊:撤销邀请奖励记录 id={}, inviter={}, invitee={}, reason={}", record.getId(), record.getInviterId(), inviteeId, reason); } return count; } // ========================================== // 辅助方法 // ========================================== /** * 获取用户昵称(用于审计日志等场景) */ private String getUserNickname(Long userId) { User user = userMapper.selectById(userId); return user != null && user.getNickname() != null ? user.getNickname() : String.valueOf(userId); } }