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 的奖励发放,
* 根据活动配置决定发放策略:
*
* - 活动未开启 → 按原逻辑发放 30 天高级会员(无活动限制)
* - 活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员(受活动时间窗、30 次上限约束)
* - 活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人入驻审核通过后由事件触发
*
*
* 规则要点:
* 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);
}
}