| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- 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;
- /**
- * 邀请有礼活动服务(统一管理所有邀请奖励发放)。
- * <p>
- * 通过实现 {@link ActivityInviteGate} 接管 InviteService 的奖励发放,
- * 根据活动配置决定发放策略:
- * <ul>
- * <li>活动未开启 → 按原逻辑发放 30 天高级会员(无活动限制)</li>
- * <li>活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员(受活动时间窗、30 次上限约束)</li>
- * <li>活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人入驻审核通过后由事件触发</li>
- * </ul>
- * <p>
- * 规则要点:
- * 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 配置决定策略)。
- * <p>
- * 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");
- }
- /**
- * 已入驻用户补填邀请码时发放奖励(依赖活动配置)。
- * <p>
- * 场景:被邀请人已通过入驻审核,后补填邀请码 → 邀请人应获得奖励。
- * 活动未开启则不发放。幂等由 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);
- }
- /**
- * 运营补发:对活动时间窗内的邀请关系批量补发奖励。
- * <p>
- * 场景:活动配置晚于实际邀请时间,运营配置活动后对窗口内的邀请进行补发。
- * 仅对已入驻(审核通过)的被邀请人补发。
- * 每个被邀请人只发一次(uk_invitee 兜底),邀请人受 30 次上限约束。
- *
- * @param startTime 活动开始时间(选填,默认取当前配置)
- * @param endTime 活动结束时间(选填,默认取当前配置)
- * @return {granted: 已补发数, skipped: 跳过数, total: 窗口内邀请总数}
- */
- @Transactional
- public Map<String, Integer> retroactiveGrant(LocalDateTime startTime, LocalDateTime endTime) {
- ActivityInviteConfig config = getConfig();
- if (startTime == null) {
- startTime = config.getStartTime();
- }
- if (endTime == null) {
- endTime = config.getEndTime();
- }
- // 查询活动时间窗内的所有邀请关系
- List<InviteRelation> relations = inviteRelationMapper.selectList(
- new LambdaQueryWrapper<InviteRelation>()
- .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<ActivityInviteRewardRecord>()
- .eq(ActivityInviteRewardRecord::getInviteeId, inviteeId));
- if (alreadyRewarded != null && alreadyRewarded > 0) {
- skipped++;
- continue;
- }
- // 检查被邀请人是否已入驻审核通过
- Long approvedCount = businessLicenseMapper.selectCount(
- new LambdaQueryWrapper<BusinessLicense>()
- .eq(BusinessLicense::getUserId, inviteeId)
- .eq(BusinessLicense::getReviewStatus, LicenseStatus.APPROVED.name()));
- if (approvedCount == null || approvedCount == 0) {
- skipped++;
- continue;
- }
- // 补发奖励
- grantWithLimit(inviterId, inviteeId, config, "RETROACTIVE");
- granted++;
- }
- Map<String, Integer> 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;
- }
- // ==========================================
- // 事件处理:入驻审核通过后发放邀请人奖励
- // ==========================================
- /**
- * 入驻审核通过后发放邀请人奖励(依赖活动配置,不再区分触发模式)。
- * <p>
- * 活动未开启则不发放。幂等由 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<ActivityRewardRecordResponse> listInviteRewardRecords(
- Long userId, int pageNum, int pageSize) {
- Page<ActivityInviteRewardRecord> page = new Page<>(pageNum, pageSize);
- LambdaQueryWrapper<ActivityInviteRewardRecord> wrapper = new LambdaQueryWrapper<>();
- if (userId != null) {
- wrapper.eq(ActivityInviteRewardRecord::getInviterId, userId);
- }
- wrapper.orderByDesc(ActivityInviteRewardRecord::getCreateTime);
- Page<ActivityInviteRewardRecord> recordPage = rewardRecordMapper.selectPage(page, wrapper);
- Page<ActivityRewardRecordResponse> resultPage = new Page<>(pageNum, pageSize, recordPage.getTotal());
- List<ActivityRewardRecordResponse> list = toRewardResponseList(recordPage.getRecords());
- resultPage.setRecords(list);
- return resultPage;
- }
- private List<ActivityRewardRecordResponse> toRewardResponseList(List<ActivityInviteRewardRecord> 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<ActivityInviteRewardRecord> 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);
- }
- }
|