ActivityInviteService.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. package com.xuekairui.activity.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  4. import com.xuekairui.activity.dto.ActivityInviteConfigRequest;
  5. import com.xuekairui.activity.dto.ActivityInviteStatusResponse;
  6. import com.xuekairui.activity.dto.ActivityRewardRecordResponse;
  7. import com.xuekairui.activity.entity.ActivityInviteConfig;
  8. import com.xuekairui.activity.entity.ActivityInviteRewardRecord;
  9. import com.xuekairui.activity.mapper.ActivityInviteConfigMapper;
  10. import com.xuekairui.activity.mapper.ActivityInviteRewardRecordMapper;
  11. import com.xuekairui.common.activity.ActivityInviteGate;
  12. import com.xuekairui.invite.entity.InviteRelation;
  13. import com.xuekairui.invite.mapper.InviteRelationMapper;
  14. import com.xuekairui.user.entity.BusinessLicense;
  15. import com.xuekairui.user.entity.User;
  16. import com.xuekairui.user.enums.LicenseStatus;
  17. import com.xuekairui.user.enums.MembershipLevel;
  18. import com.xuekairui.user.enums.OperationType;
  19. import com.xuekairui.user.mapper.BusinessLicenseMapper;
  20. import com.xuekairui.user.mapper.UserMapper;
  21. import com.xuekairui.user.service.MembershipService;
  22. import com.xuekairui.user.service.OperationAuditLogService;
  23. import lombok.RequiredArgsConstructor;
  24. import lombok.extern.slf4j.Slf4j;
  25. import org.springframework.dao.DuplicateKeyException;
  26. import org.springframework.stereotype.Service;
  27. import org.springframework.transaction.annotation.Transactional;
  28. import java.time.LocalDateTime;
  29. import java.util.HashMap;
  30. import java.util.List;
  31. import java.util.Map;
  32. import java.util.Optional;
  33. /**
  34. * 邀请有礼活动服务(统一管理所有邀请奖励发放)。
  35. * <p>
  36. * 通过实现 {@link ActivityInviteGate} 接管 InviteService 的奖励发放,
  37. * 根据活动配置决定发放策略:
  38. * <ul>
  39. * <li>活动未开启 → 按原逻辑发放 30 天高级会员(无活动限制)</li>
  40. * <li>活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员(受活动时间窗、30 次上限约束)</li>
  41. * <li>活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人入驻审核通过后由事件触发</li>
  42. * </ul>
  43. * <p>
  44. * 规则要点:
  45. * 1. 活动时间窗内有效;
  46. * 2. LICENSE_APPROVED 模式:被邀请人需上传入驻信息且审核通过后才发放;
  47. * 3. 邀请人最多获得 30 次邀请奖励(活动期间内);
  48. * 4. 每个被邀请人只触发一次邀请人奖励(DB 唯一索引兜底)。
  49. */
  50. @Slf4j
  51. @Service
  52. @RequiredArgsConstructor
  53. public class ActivityInviteService implements ActivityInviteGate {
  54. /** 奖励触发模式:入驻审核通过后发 */
  55. public static final String TRIGGER_LICENSE_APPROVED = "LICENSE_APPROVED";
  56. private static final Long CONFIG_ID = 1L;
  57. private final ActivityInviteConfigMapper configMapper;
  58. private final ActivityInviteRewardRecordMapper rewardRecordMapper;
  59. private final InviteRelationMapper inviteRelationMapper;
  60. private final UserMapper userMapper;
  61. private final BusinessLicenseMapper businessLicenseMapper;
  62. private final MembershipService membershipService;
  63. private final OperationAuditLogService auditLogService;
  64. // ==========================================
  65. // ActivityInviteGate 门面实现(供 InviteService 调用)
  66. // ==========================================
  67. /**
  68. * 发放邀请奖励(统一入口,根据 rewardTrigger 配置决定策略)。
  69. * <p>
  70. * LICENSE_APPROVED:不发放,等待入驻审核通过后触发。
  71. * REGISTRATION:立即发放,受活动时间窗和 30 次上限约束。
  72. */
  73. @Override
  74. public void grantInviteReward(Long inviterId, Long inviteeId) {
  75. ActivityInviteConfig config = getConfig();
  76. // 活动未开启 → 不发放
  77. if (!isActive(config)) {
  78. log.info("邀请有礼活动未开启,跳过发奖: inviter={}, invitee={}", inviterId, inviteeId);
  79. return;
  80. }
  81. // LICENSE_APPROVED 模式 → 不发放,等待入驻审核通过
  82. if (TRIGGER_LICENSE_APPROVED.equalsIgnoreCase(config.getRewardTrigger())) {
  83. log.info("邀请有礼活动(LICENSE_APPROVED模式),注册环节暂不发奖: inviter={}, invitee={}", inviterId, inviteeId);
  84. return;
  85. }
  86. // REGISTRATION 模式 → 立即发放
  87. grantWithLimit(inviterId, inviteeId, config, "REGISTRATION");
  88. }
  89. /**
  90. * 已入驻用户补填邀请码时发放奖励(依赖活动配置)。
  91. * <p>
  92. * 场景:被邀请人已通过入驻审核,后补填邀请码 → 邀请人应获得奖励。
  93. * 活动未开启则不发放。幂等由 uk_invitee 唯一索引保障。
  94. */
  95. @Override
  96. public void grantRewardForApprovedUser(Long inviterId, Long inviteeId) {
  97. ActivityInviteConfig config = getConfig();
  98. if (!isActive(config)) {
  99. log.info("邀请有礼活动未开启,跳过已入驻补填发奖: inviter={}, invitee={}", inviterId, inviteeId);
  100. return;
  101. }
  102. grantWithLimit(inviterId, inviteeId, config, "BIND_APPROVED");
  103. log.info("已入驻用户补填邀请码,发放邀请奖励: inviter={}, invitee={}", inviterId, inviteeId);
  104. }
  105. /**
  106. * 运营补发:对活动时间窗内的邀请关系批量补发奖励。
  107. * <p>
  108. * 场景:活动配置晚于实际邀请时间,运营配置活动后对窗口内的邀请进行补发。
  109. * 仅对已入驻(审核通过)的被邀请人补发。
  110. * 每个被邀请人只发一次(uk_invitee 兜底),邀请人受 30 次上限约束。
  111. *
  112. * @param startTime 活动开始时间(选填,默认取当前配置)
  113. * @param endTime 活动结束时间(选填,默认取当前配置)
  114. * @return {granted: 已补发数, skipped: 跳过数, total: 窗口内邀请总数}
  115. */
  116. @Transactional
  117. public Map<String, Integer> retroactiveGrant(LocalDateTime startTime, LocalDateTime endTime) {
  118. ActivityInviteConfig config = getConfig();
  119. if (startTime == null) {
  120. startTime = config.getStartTime();
  121. }
  122. if (endTime == null) {
  123. endTime = config.getEndTime();
  124. }
  125. // 查询活动时间窗内的所有邀请关系
  126. List<InviteRelation> relations = inviteRelationMapper.selectList(
  127. new LambdaQueryWrapper<InviteRelation>()
  128. .ge(InviteRelation::getCreateTime, startTime)
  129. .le(InviteRelation::getCreateTime, endTime)
  130. .eq(InviteRelation::getStatus, 1));
  131. int total = relations.size();
  132. int granted = 0;
  133. int skipped = 0;
  134. for (InviteRelation r : relations) {
  135. Long inviteeId = r.getInviteeId();
  136. Long inviterId = r.getInviterId();
  137. // 跳过无效数据
  138. if (inviterId == null || inviteeId == null) {
  139. skipped++;
  140. continue;
  141. }
  142. // 检查是否已发过奖(uk_invitee 唯一索引保障,此处提前过滤减少 DB 异常)
  143. Long alreadyRewarded = rewardRecordMapper.selectCount(
  144. new LambdaQueryWrapper<ActivityInviteRewardRecord>()
  145. .eq(ActivityInviteRewardRecord::getInviteeId, inviteeId));
  146. if (alreadyRewarded != null && alreadyRewarded > 0) {
  147. skipped++;
  148. continue;
  149. }
  150. // 检查被邀请人是否已入驻审核通过
  151. Long approvedCount = businessLicenseMapper.selectCount(
  152. new LambdaQueryWrapper<BusinessLicense>()
  153. .eq(BusinessLicense::getUserId, inviteeId)
  154. .eq(BusinessLicense::getReviewStatus, LicenseStatus.APPROVED.name()));
  155. if (approvedCount == null || approvedCount == 0) {
  156. skipped++;
  157. continue;
  158. }
  159. // 补发奖励
  160. grantWithLimit(inviterId, inviteeId, config, "RETROACTIVE");
  161. granted++;
  162. }
  163. Map<String, Integer> result = new HashMap<>();
  164. result.put("total", total);
  165. result.put("granted", granted);
  166. result.put("skipped", skipped);
  167. log.info("补发邀请奖励完成: total={}, granted={}, skipped={}", total, granted, skipped);
  168. return result;
  169. }
  170. // ==========================================
  171. // 配置管理
  172. // ==========================================
  173. public ActivityInviteConfig getConfig() {
  174. ActivityInviteConfig config = configMapper.selectById(CONFIG_ID);
  175. if (config == null) {
  176. return ActivityInviteConfig.builder()
  177. .id(CONFIG_ID).enabled(0)
  178. .startTime(LocalDateTime.now())
  179. .endTime(LocalDateTime.now())
  180. .inviterRewardDays(30).maxInviterReward(30)
  181. .rewardTrigger(TRIGGER_LICENSE_APPROVED).requireMiniappInviter(0)
  182. .build();
  183. }
  184. return config;
  185. }
  186. @Transactional
  187. public ActivityInviteConfig updateConfig(ActivityInviteConfigRequest request) {
  188. ActivityInviteConfig config = getConfig();
  189. config.setEnabled(request.getEnabled());
  190. config.setStartTime(request.getStartTime());
  191. config.setEndTime(request.getEndTime());
  192. Optional.ofNullable(request.getInviterRewardDays()).ifPresent(config::setInviterRewardDays);
  193. Optional.ofNullable(request.getMaxInviterReward()).ifPresent(config::setMaxInviterReward);
  194. Optional.ofNullable(request.getRewardTrigger()).ifPresent(config::setRewardTrigger);
  195. Optional.ofNullable(request.getRequireMiniappInviter()).ifPresent(config::setRequireMiniappInviter);
  196. Optional.ofNullable(request.getRemark()).ifPresent(config::setRemark);
  197. if (config.getId() == null) {
  198. config.setId(CONFIG_ID);
  199. configMapper.insert(config);
  200. } else {
  201. configMapper.updateById(config);
  202. }
  203. log.info("邀请有礼活动配置已更新: enabled={}, trigger={}, start={}, end={}",
  204. config.getEnabled(), config.getRewardTrigger(), config.getStartTime(), config.getEndTime());
  205. return config;
  206. }
  207. // ==========================================
  208. // 事件处理:入驻审核通过后发放邀请人奖励
  209. // ==========================================
  210. /**
  211. * 入驻审核通过后发放邀请人奖励(依赖活动配置,不再区分触发模式)。
  212. * <p>
  213. * 活动未开启则不发放。幂等由 uk_invitee 唯一索引保障。
  214. */
  215. @Transactional
  216. public void onLicenseApproved(Long userId) {
  217. User invitee = userMapper.selectById(userId);
  218. if (invitee == null || invitee.getInviterId() == null) {
  219. log.debug("被邀请人无邀请人,跳过邀请奖励: inviteeId={}", userId);
  220. return;
  221. }
  222. ActivityInviteConfig config = getConfig();
  223. if (!isActive(config)) {
  224. log.info("邀请有礼活动未开启,跳过入驻发奖: invitee={}", userId);
  225. return;
  226. }
  227. Long inviterId = invitee.getInviterId();
  228. grantWithLimit(inviterId, userId, config, "LICENSE_APPROVED");
  229. }
  230. // ==========================================
  231. // 用户端状态查询
  232. // ==========================================
  233. public ActivityInviteStatusResponse getStatus(Long userId) {
  234. ActivityInviteConfig config = getConfig();
  235. long rewarded = userId != null ? rewardRecordMapper.countByInviter(userId) : 0L;
  236. int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30;
  237. long remaining = Math.max(0, maxReward - rewarded);
  238. return ActivityInviteStatusResponse.builder()
  239. .enabled(isActive(config))
  240. .inWindow(inWindow(config))
  241. .rewardedCount(rewarded)
  242. .maxInviterReward(maxReward)
  243. .remainingCount(remaining)
  244. .build();
  245. }
  246. // ==========================================
  247. // 运营端:分页查询邀请奖励记录
  248. // ==========================================
  249. /**
  250. * 分页查询邀请有礼奖励记录(运营端),支持按邀请人ID筛选。
  251. * 按创建时间倒序,转换为统一 DTO。
  252. */
  253. public Page<ActivityRewardRecordResponse> listInviteRewardRecords(
  254. Long userId, int pageNum, int pageSize) {
  255. Page<ActivityInviteRewardRecord> page = new Page<>(pageNum, pageSize);
  256. LambdaQueryWrapper<ActivityInviteRewardRecord> wrapper = new LambdaQueryWrapper<>();
  257. if (userId != null) {
  258. wrapper.eq(ActivityInviteRewardRecord::getInviterId, userId);
  259. }
  260. wrapper.orderByDesc(ActivityInviteRewardRecord::getCreateTime);
  261. Page<ActivityInviteRewardRecord> recordPage = rewardRecordMapper.selectPage(page, wrapper);
  262. Page<ActivityRewardRecordResponse> resultPage = new Page<>(pageNum, pageSize, recordPage.getTotal());
  263. List<ActivityRewardRecordResponse> list = toRewardResponseList(recordPage.getRecords());
  264. resultPage.setRecords(list);
  265. return resultPage;
  266. }
  267. private List<ActivityRewardRecordResponse> toRewardResponseList(List<ActivityInviteRewardRecord> records) {
  268. return records.stream()
  269. .map(r -> ActivityRewardRecordResponse.builder()
  270. .id(r.getId())
  271. .userId(r.getInviterId())
  272. .activityType("INVITE")
  273. .rewardName("邀请有礼奖励")
  274. .rewardDays(r.getRewardDays())
  275. .rewardLevel("PRO")
  276. .rewardType("MEMBERSHIP")
  277. .inviterId(r.getInviterId())
  278. .inviteeId(r.getInviteeId())
  279. .status(r.getStatus())
  280. .createTime(r.getCreateTime())
  281. .build())
  282. .toList();
  283. }
  284. // ==========================================
  285. // 私有辅助
  286. // ==========================================
  287. /**
  288. * 带活动限制的奖励发放(活动开启时使用)。
  289. * 校验 30 次上限 + 幂等插入记录 + 发放会员。
  290. *
  291. * @param triggerSource 触发来源,用于审计日志(如 REGISTRATION / LICENSE_APPROVED / BIND_APPROVED / RETROACTIVE)
  292. */
  293. private void grantWithLimit(Long inviterId, Long inviteeId, ActivityInviteConfig config, String triggerSource) {
  294. // 1. 校验邀请人奖励次数上限
  295. long rewarded = rewardRecordMapper.countByInviter(inviterId);
  296. int maxReward = config.getMaxInviterReward() != null ? config.getMaxInviterReward() : 30;
  297. if (rewarded >= maxReward) {
  298. log.warn("邀请人{}已达活动奖励上限{}次,不再发放: inviteeId={}", inviterId, maxReward, inviteeId);
  299. return;
  300. }
  301. // 2. 幂等插入奖励记录
  302. int days = config.getInviterRewardDays() != null ? config.getInviterRewardDays() : 30;
  303. ActivityInviteRewardRecord record = ActivityInviteRewardRecord.builder()
  304. .inviterId(inviterId)
  305. .inviteeId(inviteeId)
  306. .rewardDays(days)
  307. .build();
  308. try {
  309. rewardRecordMapper.insert(record);
  310. } catch (DuplicateKeyException e) {
  311. log.info("被邀请人{}已触发过邀请奖励,跳过: inviterId={}", inviteeId, inviterId);
  312. return;
  313. }
  314. // 3. 发放会员奖励
  315. doGrantReward(inviterId, inviteeId, days);
  316. // 4. 审计日志:记录邀请奖励发放
  317. String inviterName = getUserNickname(inviterId);
  318. String inviteeName = getUserNickname(inviteeId);
  319. auditLogService.logSuccess(
  320. inviterId,
  321. inviterName,
  322. "USER",
  323. OperationType.INVITE_REWARD_GRANT,
  324. "INVITE_REWARD:" + inviteeId,
  325. inviterId,
  326. null,
  327. Map.of("triggerSource", triggerSource, "rewardDays", days, "rewardLevel", "PRO"),
  328. String.format("%s → 邀请人%s获赠%d天PRO会员,触发来源:%s", inviteeName, inviterName, days, triggerSource));
  329. log.info("邀请有礼活动奖励已发放: inviter={}, invitee={}, days={}, trigger={}", inviterId, inviteeId, days, triggerSource);
  330. }
  331. /**
  332. * 直接发放会员奖励(不校验活动限制,活动关闭时兜底使用)。
  333. */
  334. private void doGrantReward(Long inviterId, Long inviteeId, int days) {
  335. membershipService.grantOrExtendMembership(inviterId, MembershipLevel.PRO, days,
  336. "ACTIVITY_INVITE", "邀请有礼:邀请用户" + inviteeId + "奖励" + days + "天高级会员");
  337. }
  338. private boolean isActive(ActivityInviteConfig config) {
  339. return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
  340. }
  341. private boolean inWindow(ActivityInviteConfig config) {
  342. LocalDateTime now = LocalDateTime.now();
  343. return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
  344. }
  345. // ==========================================
  346. // 反作弊:撤销奖励
  347. // ==========================================
  348. /**
  349. * 撤销指定被邀请人触发的邀请奖励(反作弊)。
  350. * 将奖励记录标记为已撤销,运营可据此手动剥夺邀请人的相应权益。
  351. *
  352. * @param inviteeId 被邀请人ID(作弊者)
  353. * @param reason 撤销原因
  354. * @param revokerId 操作人ID(运营)
  355. * @return 撤销的记录数
  356. */
  357. @Transactional
  358. public int revokeReward(Long inviteeId, String reason, Long revokerId) {
  359. List<ActivityInviteRewardRecord> records = rewardRecordMapper.findByInvitee(inviteeId);
  360. if (records.isEmpty()) {
  361. log.warn("未找到被邀请人{}的奖励记录,无需撤销", inviteeId);
  362. return 0;
  363. }
  364. int count = 0;
  365. for (ActivityInviteRewardRecord record : records) {
  366. record.setStatus(1);
  367. record.setRevokeReason(reason);
  368. record.setRevokerId(revokerId);
  369. record.setRevokeTime(LocalDateTime.now());
  370. rewardRecordMapper.updateById(record);
  371. count++;
  372. // 审计日志:记录奖励撤销
  373. String inviterName = getUserNickname(record.getInviterId());
  374. String inviteeName = getUserNickname(inviteeId);
  375. auditLogService.logSuccess(
  376. revokerId,
  377. "运营(反作弊)",
  378. "ADMIN",
  379. OperationType.INVITE_REWARD_GRANT,
  380. "INVITE_REVOKE:" + inviteeId,
  381. record.getInviterId(),
  382. Map.of("rewardDays", record.getRewardDays(), "rewardLevel", "PRO"),
  383. null,
  384. String.format("撤销邀请奖励:邀请人%s(因被邀请人%s作弊,原因:%s)", inviterName, inviteeName, reason));
  385. log.warn("反作弊:撤销邀请奖励记录 id={}, inviter={}, invitee={}, reason={}",
  386. record.getId(), record.getInviterId(), inviteeId, reason);
  387. }
  388. return count;
  389. }
  390. // ==========================================
  391. // 辅助方法
  392. // ==========================================
  393. /**
  394. * 获取用户昵称(用于审计日志等场景)
  395. */
  396. private String getUserNickname(Long userId) {
  397. User user = userMapper.selectById(userId);
  398. return user != null && user.getNickname() != null ? user.getNickname() : String.valueOf(userId);
  399. }
  400. }