MembershipService.java 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. package com.xuekairui.user.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  4. import com.xuekairui.common.BusinessException;
  5. import com.xuekairui.common.ErrorCode;
  6. import com.xuekairui.user.dto.GrantMembershipRequest;
  7. import com.xuekairui.user.dto.LevelPricingPlan;
  8. import com.xuekairui.user.dto.MembershipBenefitResponse;
  9. import com.xuekairui.user.dto.MembershipResponse;
  10. import com.xuekairui.user.dto.MembershipUserResponse;
  11. import com.xuekairui.user.dto.UserLevelResponse;
  12. import com.xuekairui.user.entity.MembershipBenefit;
  13. import com.xuekairui.user.entity.User;
  14. import com.xuekairui.user.entity.UserLevel;
  15. import com.xuekairui.user.entity.UserMembership;
  16. import com.xuekairui.user.enums.MembershipLevel;
  17. import com.xuekairui.user.enums.OperationType;
  18. import com.xuekairui.user.enums.OperatorRole;
  19. import com.xuekairui.user.mapper.MembershipBenefitMapper;
  20. import com.xuekairui.user.mapper.UserMapper;
  21. import com.xuekairui.user.mapper.UserMembershipMapper;
  22. import lombok.RequiredArgsConstructor;
  23. import lombok.extern.slf4j.Slf4j;
  24. import org.springframework.scheduling.annotation.Scheduled;
  25. import org.springframework.stereotype.Service;
  26. import org.springframework.transaction.annotation.Transactional;
  27. import java.math.BigDecimal;
  28. import java.time.LocalDateTime;
  29. import java.time.temporal.ChronoUnit;
  30. import java.util.Collections;
  31. import java.util.Comparator;
  32. import java.util.HashMap;
  33. import java.util.HashSet;
  34. import java.util.List;
  35. import java.util.Map;
  36. import java.util.Set;
  37. import java.util.stream.Collectors;
  38. /**
  39. * 会员体系服务
  40. */
  41. @Slf4j
  42. @Service
  43. @RequiredArgsConstructor
  44. public class MembershipService {
  45. private final UserMembershipMapper userMembershipMapper;
  46. private final MembershipBenefitMapper membershipBenefitMapper;
  47. private final UserMapper userMapper;
  48. private final OperationAuditLogService auditLogService;
  49. /**
  50. * 获取用户当前生效的会员信息
  51. * <p>无活跃会员记录时,回退到用户表的当前等级(默认PLUS普通会员)
  52. */
  53. public MembershipResponse getMyMembership(Long userId) {
  54. UserMembership membership = userMembershipMapper.selectActiveByUserId(userId);
  55. if (membership != null) {
  56. return toResponse(membership);
  57. }
  58. // 无活跃会员记录,默认为免费普通会员(PLUS)
  59. return MembershipResponse.builder()
  60. .userId(userId)
  61. .level(MembershipLevel.PLUS.getCode())
  62. .levelName(MembershipLevel.PLUS.getDescription())
  63. .source("SYSTEM")
  64. .effectiveFrom(null)
  65. .effectiveTo(null)
  66. .status(1)
  67. .remark("免费会员")
  68. .build();
  69. }
  70. /**
  71. * 用户注册时写入一条 PLUS(免费)会员记录,确保会员历史可追溯
  72. */
  73. public void initPlusMembership(Long userId) {
  74. UserMembership exists = userMembershipMapper.selectOne(
  75. new LambdaQueryWrapper<UserMembership>()
  76. .eq(UserMembership::getUserId, userId)
  77. .eq(UserMembership::getLevel, MembershipLevel.PLUS)
  78. .last("LIMIT 1"));
  79. if (exists != null) {
  80. return; // 已有 PLUS 记录,不重复写入
  81. }
  82. UserMembership membership = UserMembership.builder()
  83. .userId(userId)
  84. .level(MembershipLevel.PLUS)
  85. .source("SYSTEM")
  86. .effectiveFrom(LocalDateTime.now())
  87. .effectiveTo(null) // 永久有效
  88. .status(1)
  89. .remark("注册即得免费会员")
  90. .build();
  91. userMembershipMapper.insert(membership);
  92. }
  93. /**
  94. * 查询指定会员等级的权益列表
  95. */
  96. public List<MembershipBenefitResponse> getBenefits(String levelCode) {
  97. MembershipLevel level = MembershipLevel.fromCode(levelCode);
  98. if (level == null) {
  99. throw new BusinessException(ErrorCode.PARAM_ERROR, "会员等级无效");
  100. }
  101. List<MembershipBenefit> benefits = membershipBenefitMapper.selectEnabledByLevel(level);
  102. return benefits.stream()
  103. .map(this::toBenefitResponse)
  104. .collect(Collectors.toList());
  105. }
  106. /**
  107. * 发放/调整用户会员等级(运营/超管)
  108. */
  109. @Transactional
  110. public MembershipResponse grantMembership(GrantMembershipRequest request, Long operatorId) {
  111. User user;
  112. if (request.getUserId() != null) {
  113. user = userMapper.selectById(request.getUserId());
  114. } else if (request.getPhone() != null && !request.getPhone().isBlank()) {
  115. user = userMapper.selectOne(
  116. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  117. } else {
  118. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  119. }
  120. if (user == null) {
  121. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  122. }
  123. MembershipLevel level = MembershipLevel.fromCode(request.getLevel());
  124. if (level == null) {
  125. throw new BusinessException(ErrorCode.PARAM_ERROR, "会员等级无效");
  126. }
  127. // 停用该用户其他有效会员记录
  128. List<UserMembership> activeList = userMembershipMapper.selectList(
  129. new LambdaQueryWrapper<UserMembership>()
  130. .eq(UserMembership::getUserId, request.getUserId())
  131. .eq(UserMembership::getStatus, 1));
  132. for (UserMembership active : activeList) {
  133. active.setStatus(0);
  134. userMembershipMapper.updateById(active);
  135. }
  136. // 创建新会员记录
  137. UserMembership membership = UserMembership.builder()
  138. .userId(request.getUserId())
  139. .level(level)
  140. .source(request.getSource() != null ? request.getSource() : "ADMIN_GRANT")
  141. .effectiveFrom(request.getEffectiveFrom() != null ? request.getEffectiveFrom() : LocalDateTime.now())
  142. .effectiveTo(request.getEffectiveTo())
  143. .status(1)
  144. .remark(request.getRemark())
  145. .build();
  146. userMembershipMapper.insert(membership);
  147. // 同步到用户表当前会员等级与过期时间
  148. user.setMembershipLevel(level);
  149. user.setMembershipExpireAt(membership.getEffectiveTo());
  150. userMapper.updateById(user);
  151. // 审计日志
  152. auditLogService.logAdmin(operatorId, OperationType.USER_UPDATE,
  153. "MEMBERSHIP:" + user.getId() + "->" + level.getCode(), user.getId());
  154. log.info("运营人员{}为用户{}发放会员等级{}", operatorId, request.getUserId(), level.getCode());
  155. return toResponse(membership);
  156. }
  157. /**
  158. * 统一发放或续期会员(互斥模式)
  159. * <p>
  160. * 如果用户已有同等级活跃会员,续期(叠加天数);
  161. * 否则停用所有旧会员,创建新记录。
  162. */
  163. @Transactional
  164. public void grantOrExtendMembership(Long userId, MembershipLevel level, int days,
  165. String source, String remark) {
  166. User user = userMapper.selectById(userId);
  167. if (user == null) {
  168. return;
  169. }
  170. LocalDateTime newExpireAt;
  171. MembershipLevel effectiveLevel = level;
  172. UserMembership existing = userMembershipMapper.selectActiveByUserId(userId);
  173. if (existing != null) {
  174. if (existing.getLevel() == level) {
  175. // 同等级续期:在原到期时间基础上追加
  176. LocalDateTime base = existing.getEffectiveTo() != null
  177. && existing.getEffectiveTo().isAfter(LocalDateTime.now())
  178. ? existing.getEffectiveTo()
  179. : LocalDateTime.now();
  180. existing.setEffectiveTo(base.plusDays(days));
  181. userMembershipMapper.updateById(existing);
  182. newExpireAt = existing.getEffectiveTo();
  183. } else if (existing.getLevel().getWeight() > level.getWeight()) {
  184. // 已有更高等级:不降级,直接续期更高等级(如ULTRA用户邀请奖励仍保持ULTRA)
  185. LocalDateTime base = existing.getEffectiveTo() != null
  186. && existing.getEffectiveTo().isAfter(LocalDateTime.now())
  187. ? existing.getEffectiveTo()
  188. : LocalDateTime.now();
  189. existing.setEffectiveTo(base.plusDays(days));
  190. userMembershipMapper.updateById(existing);
  191. newExpireAt = existing.getEffectiveTo();
  192. effectiveLevel = existing.getLevel();
  193. } else {
  194. // 升级:停用所有低等级旧会员,创建新记录
  195. deactivateAllActive(userId);
  196. LocalDateTime now = LocalDateTime.now();
  197. UserMembership membership = UserMembership.builder()
  198. .userId(userId)
  199. .level(level)
  200. .source(source)
  201. .effectiveFrom(now)
  202. .effectiveTo(now.plusDays(days))
  203. .status(1)
  204. .remark(remark)
  205. .build();
  206. userMembershipMapper.insert(membership);
  207. newExpireAt = membership.getEffectiveTo();
  208. }
  209. } else {
  210. // 无活跃会员:创建新记录
  211. deactivateAllActive(userId);
  212. LocalDateTime now = LocalDateTime.now();
  213. UserMembership membership = UserMembership.builder()
  214. .userId(userId)
  215. .level(level)
  216. .source(source)
  217. .effectiveFrom(now)
  218. .effectiveTo(now.plusDays(days))
  219. .status(1)
  220. .remark(remark)
  221. .build();
  222. userMembershipMapper.insert(membership);
  223. newExpireAt = membership.getEffectiveTo();
  224. }
  225. // 同步用户表:等级 + 过期时间(仅当等级变化或过期时间变化时更新)
  226. boolean needUpdate = false;
  227. if (user.getMembershipLevel() == null
  228. || user.getMembershipLevel().getWeight() < effectiveLevel.getWeight()) {
  229. user.setMembershipLevel(effectiveLevel);
  230. needUpdate = true;
  231. }
  232. if (user.getMembershipExpireAt() == null
  233. || !user.getMembershipExpireAt().equals(newExpireAt)) {
  234. user.setMembershipExpireAt(newExpireAt);
  235. needUpdate = true;
  236. }
  237. if (needUpdate) {
  238. userMapper.updateById(user);
  239. }
  240. }
  241. /**
  242. * 支付购买激活会员(互斥模式:同等级续期,不同等级替换)
  243. * @param userId 用户ID
  244. * @param level 会员等级
  245. * @param durationDays 有效期天数(月卡30天,年卡365天)
  246. * @param orderNo 关联支付订单号
  247. * @return 创建或续期的会员记录
  248. */
  249. @Transactional
  250. public UserMembership activateFromPayment(Long userId, MembershipLevel level,
  251. int durationDays, String orderNo) {
  252. User user = userMapper.selectById(userId);
  253. if (user == null) {
  254. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  255. }
  256. grantOrExtendMembership(userId, level, durationDays, "RECHARGE", "支付购买: " + orderNo);
  257. // 审计日志
  258. auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
  259. OperationType.USER_UPDATE,
  260. "PAY:" + orderNo + "->" + level.getCode(), userId);
  261. log.info("用户{}通过支付{}激活会员等级{}, 有效期{}天", userId, orderNo, level.getCode(), durationDays);
  262. // 返回当前活跃会员记录
  263. return userMembershipMapper.selectActiveByUserId(userId);
  264. }
  265. /**
  266. * 分页查询会员记录(支持按用户/等级/来源/手机号/昵称筛选)
  267. */
  268. public Page<MembershipResponse> listMemberships(Integer pageNum, Integer pageSize,
  269. Long userId, String level,
  270. String source, String phone, String nickname) {
  271. Page<UserMembership> page = new Page<>(pageNum, pageSize);
  272. LambdaQueryWrapper<UserMembership> wrapper = new LambdaQueryWrapper<>();
  273. if (userId != null) {
  274. wrapper.eq(UserMembership::getUserId, userId);
  275. }
  276. if (level != null && !level.isBlank()) {
  277. MembershipLevel membershipLevel = MembershipLevel.fromCode(level);
  278. if (membershipLevel != null) {
  279. wrapper.eq(UserMembership::getLevel, membershipLevel);
  280. }
  281. }
  282. if (source != null && !source.isBlank()) {
  283. wrapper.eq(UserMembership::getSource, source);
  284. }
  285. // 昵称筛选:先按昵称模糊查用户ID列表,再过滤会员记录
  286. if (nickname != null && !nickname.isBlank()) {
  287. List<Long> matchedUserIds = userMapper.selectList(
  288. new LambdaQueryWrapper<User>()
  289. .select(User::getId)
  290. .like(User::getNickname, nickname))
  291. .stream().map(User::getId).collect(Collectors.toList());
  292. if (matchedUserIds.isEmpty()) {
  293. return new Page<>(pageNum, pageSize, 0);
  294. }
  295. wrapper.in(UserMembership::getUserId, matchedUserIds);
  296. }
  297. // 手机号筛选:先按手机号模糊查用户ID列表,再过滤会员记录
  298. if (phone != null && !phone.isBlank()) {
  299. List<Long> matchedUserIds = userMapper.selectList(
  300. new LambdaQueryWrapper<User>()
  301. .select(User::getId)
  302. .like(User::getPhone, phone))
  303. .stream().map(User::getId).collect(Collectors.toList());
  304. if (matchedUserIds.isEmpty()) {
  305. return new Page<>(pageNum, pageSize, 0);
  306. }
  307. wrapper.in(UserMembership::getUserId, matchedUserIds);
  308. }
  309. wrapper.orderByDesc(UserMembership::getCreateTime);
  310. Page<UserMembership> result = userMembershipMapper.selectPage(page, wrapper);
  311. Page<MembershipResponse> responsePage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal());
  312. // 批量查询用户,避免N+1
  313. List<Long> userIds = result.getRecords().stream()
  314. .map(UserMembership::getUserId)
  315. .distinct()
  316. .collect(Collectors.toList());
  317. Map<Long, User> userMap = userIds.isEmpty() ? new HashMap<>()
  318. : userMapper.selectByIds(userIds).stream()
  319. .collect(Collectors.toMap(User::getId, u -> u));
  320. responsePage.setRecords(result.getRecords().stream()
  321. .map(m -> toResponse(m, userMap.get(m.getUserId())))
  322. .collect(Collectors.toList()));
  323. return responsePage;
  324. }
  325. /**
  326. * 会员用户快照列表(用户维度,查 t_user + 当前会员)
  327. * <p>当前会员等级以 t_user_membership 表的活跃记录为准(取最高等级),
  328. * 若无活跃记录则回退到 t_user.membership_level,再回退到 PLUS。
  329. * 这样可兼容历史数据(t_user.membership_level 未同步更新的情况)。
  330. */
  331. public Page<MembershipUserResponse> listMembershipUsers(Integer pageNum, Integer pageSize,
  332. String phone, String level) {
  333. MembershipLevel filterLevel = null;
  334. if (level != null && !level.isBlank()) {
  335. filterLevel = MembershipLevel.fromCode(level);
  336. }
  337. // 如果按等级筛选,需要先从会员记录表找出符合条件的用户ID,再查用户表
  338. if (filterLevel != null) {
  339. Page<MembershipUserResponse> result = listMembershipUsersByLevel(pageNum, pageSize, phone, filterLevel);
  340. return result;
  341. }
  342. Page<User> page = new Page<>(pageNum, pageSize);
  343. LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
  344. if (phone != null && !phone.isBlank()) {
  345. wrapper.like(User::getPhone, phone);
  346. }
  347. wrapper.orderByDesc(User::getCreateTime);
  348. Page<User> result = userMapper.selectPage(page, wrapper);
  349. // 批量查活跃会员记录,取最高等级
  350. List<Long> userIds = result.getRecords().stream().map(User::getId).toList();
  351. Map<Long, UserMembership> activeMap = loadActiveMemberships(userIds);
  352. List<MembershipUserResponse> list = result.getRecords().stream()
  353. .map(u -> toMembershipUserResponse(u, activeMap.get(u.getId())))
  354. .collect(Collectors.toList());
  355. Page<MembershipUserResponse> responsePage = new Page<>(pageNum, pageSize, result.getTotal());
  356. responsePage.setRecords(list);
  357. return responsePage;
  358. }
  359. /**
  360. * 按等级筛选会员用户列表
  361. * <p>筛选逻辑:用户当前最高等级活跃记录等于筛选等级时才命中。
  362. * 例如筛选 PRO 时,若用户同时有活跃 ULTRA 记录,则该用户归为 ULTRA 而非 PRO。
  363. */
  364. private Page<MembershipUserResponse> listMembershipUsersByLevel(Integer pageNum, Integer pageSize,
  365. String phone, MembershipLevel filterLevel) {
  366. // 查出所有活跃会员记录
  367. LambdaQueryWrapper<UserMembership> mWrapper = new LambdaQueryWrapper<>();
  368. mWrapper.eq(UserMembership::getStatus, 1)
  369. .and(w -> w.isNull(UserMembership::getEffectiveTo)
  370. .or().gt(UserMembership::getEffectiveTo, LocalDateTime.now()));
  371. List<UserMembership> memberships = userMembershipMapper.selectList(mWrapper);
  372. if (memberships.isEmpty()) {
  373. Page<MembershipUserResponse> emptyPage = new Page<>(pageNum, pageSize, 0);
  374. emptyPage.setRecords(Collections.emptyList());
  375. return emptyPage;
  376. }
  377. // 按 userId 分组,取每个用户最高等级的活跃记录
  378. Map<Long, UserMembership> topMap = new HashMap<>();
  379. for (UserMembership m : memberships) {
  380. UserMembership existing = topMap.get(m.getUserId());
  381. if (existing == null || m.getLevel().getWeight() > existing.getLevel().getWeight()) {
  382. topMap.put(m.getUserId(), m);
  383. }
  384. }
  385. // 只保留最高等级等于筛选等级的用户
  386. Map<Long, UserMembership> matchedMap = new HashMap<>();
  387. for (Map.Entry<Long, UserMembership> entry : topMap.entrySet()) {
  388. if (entry.getValue().getLevel() == filterLevel) {
  389. matchedMap.put(entry.getKey(), entry.getValue());
  390. }
  391. }
  392. if (matchedMap.isEmpty()) {
  393. Page<MembershipUserResponse> emptyPage = new Page<>(pageNum, pageSize, 0);
  394. emptyPage.setRecords(Collections.emptyList());
  395. return emptyPage;
  396. }
  397. List<Long> userIds = new java.util.ArrayList<>(matchedMap.keySet());
  398. // 按手机号筛选用户
  399. LambdaQueryWrapper<User> uWrapper = new LambdaQueryWrapper<>();
  400. uWrapper.in(User::getId, userIds);
  401. if (phone != null && !phone.isBlank()) {
  402. uWrapper.like(User::getPhone, phone);
  403. }
  404. uWrapper.orderByDesc(User::getCreateTime);
  405. Page<User> page = new Page<>(pageNum, pageSize);
  406. Page<User> userPage = userMapper.selectPage(page, uWrapper);
  407. List<MembershipUserResponse> list = userPage.getRecords().stream()
  408. .map(u -> toMembershipUserResponse(u, matchedMap.get(u.getId())))
  409. .collect(Collectors.toList());
  410. Page<MembershipUserResponse> responsePage = new Page<>(pageNum, pageSize, userPage.getTotal());
  411. responsePage.setRecords(list);
  412. return responsePage;
  413. }
  414. /**
  415. * 批量加载用户的活跃会员记录,返回 userId -> 最高等级会员记录 的映射
  416. */
  417. private Map<Long, UserMembership> loadActiveMemberships(List<Long> userIds) {
  418. if (userIds == null || userIds.isEmpty()) {
  419. return Collections.emptyMap();
  420. }
  421. List<UserMembership> actives = userMembershipMapper.selectActiveByUserIds(userIds);
  422. Map<Long, UserMembership> map = new HashMap<>();
  423. for (UserMembership m : actives) {
  424. UserMembership existing = map.get(m.getUserId());
  425. if (existing == null || m.getLevel().getWeight() > existing.getLevel().getWeight()) {
  426. map.put(m.getUserId(), m);
  427. }
  428. }
  429. return map;
  430. }
  431. /**
  432. * 将用户和活跃会员记录组装为响应对象
  433. * <p>优先使用会员记录表的等级和过期时间;若无活跃记录,回退到用户表字段。
  434. * 高级会员过期后(effective_to 早于当前时间)降级显示为普通会员。
  435. */
  436. private MembershipUserResponse toMembershipUserResponse(User u, UserMembership active) {
  437. MembershipLevel currentLevel;
  438. LocalDateTime expireAt;
  439. String source;
  440. LocalDateTime now = LocalDateTime.now();
  441. if (active != null && active.getLevel() != MembershipLevel.PLUS
  442. && active.getEffectiveTo() != null && !active.getEffectiveTo().isAfter(now)) {
  443. // 高级会员已过期:降级为普通会员
  444. currentLevel = MembershipLevel.PLUS;
  445. expireAt = null;
  446. source = "SYSTEM";
  447. } else if (active != null) {
  448. currentLevel = active.getLevel();
  449. expireAt = active.getEffectiveTo();
  450. source = active.getSource();
  451. } else {
  452. currentLevel = u.getMembershipLevel() != null ? u.getMembershipLevel() : MembershipLevel.PLUS;
  453. // 用户表等级为高级但无活跃记录,说明已过期,降级为普通会员
  454. if (currentLevel != MembershipLevel.PLUS) {
  455. currentLevel = MembershipLevel.PLUS;
  456. }
  457. expireAt = null;
  458. source = "SYSTEM";
  459. }
  460. String levelCode = currentLevel.getCode();
  461. boolean isPlus = currentLevel == MembershipLevel.PLUS;
  462. if (isPlus) {
  463. expireAt = null;
  464. }
  465. int daysRemaining;
  466. if (isPlus) {
  467. daysRemaining = -1;
  468. } else if (expireAt != null && expireAt.isAfter(now)) {
  469. daysRemaining = (int) ChronoUnit.DAYS.between(now, expireAt);
  470. } else {
  471. daysRemaining = 0;
  472. }
  473. return MembershipUserResponse.builder()
  474. .userId(u.getId())
  475. .phone(u.getPhone())
  476. .nickname(u.getNickname())
  477. .wechatNickname(u.getWechatNickname())
  478. .level(levelCode)
  479. .levelName(currentLevel.getDescription())
  480. .source(source)
  481. .expireAt(expireAt)
  482. .daysRemaining(daysRemaining)
  483. .createTime(u.getCreateTime())
  484. .build();
  485. }
  486. private MembershipResponse toResponse(UserMembership membership) {
  487. if (membership == null) {
  488. return null;
  489. }
  490. User user = membership.getUserId() != null ? userMapper.selectById(membership.getUserId()) : null;
  491. return toResponse(membership, user);
  492. }
  493. /**
  494. * 停用用户所有活跃会员记录
  495. */
  496. private void deactivateAllActive(Long userId) {
  497. List<UserMembership> activeList = userMembershipMapper.selectList(
  498. new LambdaQueryWrapper<UserMembership>()
  499. .eq(UserMembership::getUserId, userId)
  500. .eq(UserMembership::getStatus, 1));
  501. for (UserMembership active : activeList) {
  502. active.setStatus(0);
  503. userMembershipMapper.updateById(active);
  504. }
  505. }
  506. private MembershipResponse toResponse(UserMembership membership, User user) {
  507. if (membership == null) {
  508. return null;
  509. }
  510. MembershipLevel level = membership.getLevel();
  511. return MembershipResponse.builder()
  512. .id(membership.getId())
  513. .userId(membership.getUserId())
  514. .phone(user != null ? user.getPhone() : null)
  515. .nickname(user != null ? user.getNickname() : null)
  516. .level(level != null ? level.getCode() : null)
  517. .levelName(level != null ? level.getDescription() : null)
  518. .source(membership.getSource())
  519. .effectiveFrom(membership.getEffectiveFrom())
  520. .effectiveTo(membership.getEffectiveTo())
  521. .membershipExpireAt(membership.getEffectiveTo())
  522. .status(membership.getStatus())
  523. .remark(membership.getRemark())
  524. .wechatNickname(user != null ? user.getWechatNickname() : null)
  525. .createTime(membership.getCreateTime())
  526. .build();
  527. }
  528. private MembershipBenefitResponse toBenefitResponse(MembershipBenefit benefit) {
  529. return MembershipBenefitResponse.builder()
  530. .benefitType(benefit.getBenefitType())
  531. .benefitTypeName(toBenefitTypeName(benefit.getBenefitType()))
  532. .benefitValue(benefit.getBenefitValue())
  533. .description(benefit.getDescription())
  534. .build();
  535. }
  536. private String toBenefitTypeName(String type) {
  537. return switch (type) {
  538. case "DAILY_QUOTA" -> "每日查询配额";
  539. case "MONTHLY_QUOTA" -> "每月爬虫加成";
  540. case "MAX_CONCURRENT" -> "最大并发加成";
  541. default -> type;
  542. };
  543. }
  544. /**
  545. * 定时任务:每5分钟检查过期会员,自动降级
  546. * <p>
  547. * 高级会员全部时长到期后,账号降级为免费普通会员(PLUS)。
  548. */
  549. @Scheduled(cron = "0 */5 * * * ?")
  550. @Transactional
  551. public void expireMemberships() {
  552. // 查找所有已过期但仍标记为有效的会员记录
  553. List<UserMembership> expired = userMembershipMapper.selectList(
  554. new LambdaQueryWrapper<UserMembership>()
  555. .eq(UserMembership::getStatus, 1)
  556. .isNotNull(UserMembership::getEffectiveTo)
  557. .lt(UserMembership::getEffectiveTo, LocalDateTime.now()));
  558. if (!expired.isEmpty()) {
  559. Set<Long> affectedUserIds = new HashSet<>();
  560. for (UserMembership m : expired) {
  561. m.setStatus(0);
  562. userMembershipMapper.updateById(m);
  563. affectedUserIds.add(m.getUserId());
  564. }
  565. // 对每个受影响的用户,检查是否还有其他有效会员
  566. for (Long userId : affectedUserIds) {
  567. UserMembership active = userMembershipMapper.selectActiveByUserId(userId);
  568. if (active == null) {
  569. // 无有效会员,降级为普通会员
  570. User user = userMapper.selectById(userId);
  571. if (user != null && user.getMembershipLevel() != MembershipLevel.PLUS) {
  572. user.setMembershipLevel(MembershipLevel.PLUS);
  573. user.setMembershipExpireAt(null);
  574. userMapper.updateById(user);
  575. log.info("用户{}会员已到期,降级为普通会员", userId);
  576. }
  577. }
  578. }
  579. log.info("处理过期会员记录: {}条,涉及用户: {}个", expired.size(), affectedUserIds.size());
  580. }
  581. // 修复历史数据:t_user_membership 有活跃记录但 t_user.membership_level 不一致
  582. fixInconsistentUserMembershipLevel();
  583. }
  584. /**
  585. * 修复历史数据:当 t_user_membership 有活跃记录但 t_user.membership_level 不一致时,
  586. * 将 t_user.membership_level 同步为活跃记录中的最高等级。
  587. * 若无活跃的高级会员记录,则降级为普通会员。
  588. */
  589. private void fixInconsistentUserMembershipLevel() {
  590. // 查出所有有活跃会员记录的用户ID
  591. List<UserMembership> actives = userMembershipMapper.selectList(
  592. new LambdaQueryWrapper<UserMembership>()
  593. .eq(UserMembership::getStatus, 1)
  594. .and(w -> w.isNull(UserMembership::getEffectiveTo)
  595. .or().gt(UserMembership::getEffectiveTo, LocalDateTime.now())));
  596. // 按 userId 分组,取每个用户最高等级
  597. Map<Long, UserMembership> topMap = new HashMap<>();
  598. for (UserMembership m : actives) {
  599. UserMembership existing = topMap.get(m.getUserId());
  600. if (existing == null || m.getLevel().getWeight() > existing.getLevel().getWeight()) {
  601. topMap.put(m.getUserId(), m);
  602. }
  603. }
  604. // 查出 t_user.membership_level 为高级会员的所有用户,检查是否需要降级
  605. List<User> highLevelUsers = userMapper.selectList(
  606. new LambdaQueryWrapper<User>()
  607. .isNotNull(User::getMembershipLevel)
  608. .ne(User::getMembershipLevel, MembershipLevel.PLUS));
  609. int fixed = 0;
  610. for (User user : highLevelUsers) {
  611. UserMembership top = topMap.get(user.getId());
  612. boolean needUpdate = false;
  613. if (top == null) {
  614. // 无活跃高级会员记录,降级为普通会员
  615. user.setMembershipLevel(MembershipLevel.PLUS);
  616. user.setMembershipExpireAt(null);
  617. needUpdate = true;
  618. } else {
  619. // 等级不一致(无论升降级)都需要同步
  620. if (!java.util.Objects.equals(user.getMembershipLevel(), top.getLevel())) {
  621. user.setMembershipLevel(top.getLevel());
  622. needUpdate = true;
  623. }
  624. LocalDateTime expectedExpire = top.getEffectiveTo();
  625. if (top.getLevel() == MembershipLevel.PLUS) {
  626. expectedExpire = null;
  627. }
  628. if (!java.util.Objects.equals(user.getMembershipExpireAt(), expectedExpire)) {
  629. user.setMembershipExpireAt(expectedExpire);
  630. needUpdate = true;
  631. }
  632. }
  633. if (needUpdate) {
  634. userMapper.updateById(user);
  635. fixed++;
  636. log.info("修复用户{}会员等级不一致: -> {}",
  637. user.getId(), user.getMembershipLevel());
  638. }
  639. }
  640. if (fixed > 0) {
  641. log.info("修复历史会员等级不一致数据: {}个用户", fixed);
  642. }
  643. }
  644. // ==================== 等级查询(原 UserLevelService,已合并) ====================
  645. /** 会员等级与兼容 UserLevel 对象的映射 */
  646. private static final Map<MembershipLevel, UserLevel> LEVEL_MAP = Map.of(
  647. MembershipLevel.PLUS, UserLevel.builder()
  648. .id(1L).levelName("普通会员").levelCode("PLUS").levelWeight(1)
  649. .crawlerQuota(10).monthlyQuota(300).yearlyQuota(3650).maxConcurrent(1)
  650. .description("普通会员:每日有效查询10次").status(1).build(),
  651. MembershipLevel.PRO, UserLevel.builder()
  652. .id(2L).levelName("高级会员").levelCode("PRO").levelWeight(2)
  653. .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(5)
  654. .description("高级会员:每日不限量").status(1).build(),
  655. MembershipLevel.ULTRA, UserLevel.builder()
  656. .id(3L).levelName("超级会员").levelCode("ULTRA").levelWeight(3)
  657. .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(10)
  658. .description("超级会员:连锁专用").status(1).build());
  659. public List<UserLevelResponse> listLevels() {
  660. return LEVEL_MAP.keySet().stream()
  661. .sorted(Comparator.comparingInt(l -> LEVEL_MAP.get(l).getLevelWeight()))
  662. .map(this::buildUserLevelWithBenefits)
  663. .map(this::toLevelResponse)
  664. .collect(Collectors.toList());
  665. }
  666. public UserLevelResponse getLevel(Long levelId) {
  667. return LEVEL_MAP.values().stream()
  668. .filter(l -> l.getId().equals(levelId)).findFirst()
  669. .map(l -> buildUserLevelWithBenefits(l.getLevelCode() != null
  670. ? MembershipLevel.fromCode(l.getLevelCode()) : MembershipLevel.PLUS))
  671. .map(this::toLevelResponse)
  672. .orElseThrow(() -> new BusinessException(ErrorCode.LEVEL_NOT_FOUND));
  673. }
  674. public UserLevelResponse getUserLevel(Long userId) {
  675. User user = userMapper.selectById(userId);
  676. if (user == null) {
  677. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  678. }
  679. UserLevelResponse resp = toLevelResponse(resolveUserLevel(user));
  680. fillMembershipExpiration(resp, user);
  681. return resp;
  682. }
  683. public UserLevel getUserLevelEntity(Long userId) {
  684. return resolveUserLevel(userMapper.selectById(userId));
  685. }
  686. private UserLevel resolveUserLevel(User user) {
  687. if (user == null) {
  688. return buildUserLevelWithBenefits(MembershipLevel.PLUS);
  689. }
  690. if (user.getMembershipExpireAt() != null
  691. && user.getMembershipExpireAt().isBefore(LocalDateTime.now())) {
  692. return buildUserLevelWithBenefits(MembershipLevel.PLUS);
  693. }
  694. UserMembership active = userMembershipMapper.selectActiveByUserId(user.getId());
  695. return buildUserLevelWithBenefits(active != null && active.getLevel() != null
  696. ? active.getLevel() : MembershipLevel.PLUS);
  697. }
  698. private UserLevel buildUserLevelWithBenefits(MembershipLevel level) {
  699. UserLevel base = LEVEL_MAP.getOrDefault(level,
  700. LEVEL_MAP.get(MembershipLevel.PLUS));
  701. var builder = UserLevel.builder()
  702. .id(base.getId()).levelName(base.getLevelName()).levelCode(base.getLevelCode())
  703. .levelWeight(base.getLevelWeight()).description(base.getDescription())
  704. .status(base.getStatus()).crawlerQuota(base.getCrawlerQuota())
  705. .monthlyQuota(base.getMonthlyQuota()).yearlyQuota(base.getYearlyQuota())
  706. .maxConcurrent(base.getMaxConcurrent());
  707. for (MembershipBenefit b : membershipBenefitMapper.selectEnabledByLevel(level)) {
  708. if (b.getBenefitValue() == null) {
  709. continue;
  710. }
  711. switch (b.getBenefitType()) {
  712. case "DAILY_QUOTA" -> builder.crawlerQuota(b.getBenefitValue());
  713. case "MONTHLY_QUOTA" -> builder.monthlyQuota(b.getBenefitValue());
  714. case "YEARLY_QUOTA" -> builder.yearlyQuota(b.getBenefitValue());
  715. case "MAX_CONCURRENT" -> builder.maxConcurrent(b.getBenefitValue());
  716. }
  717. }
  718. return builder.build();
  719. }
  720. private UserLevelResponse toLevelResponse(
  721. UserLevel level) {
  722. return UserLevelResponse.builder()
  723. .id(level.getId()).levelName(level.getLevelName())
  724. .levelCode(level.getLevelCode()).levelWeight(level.getLevelWeight())
  725. .crawlerQuota(level.getCrawlerQuota()).monthlyQuota(level.getMonthlyQuota())
  726. .yearlyQuota(level.getYearlyQuota()).maxConcurrent(level.getMaxConcurrent())
  727. .description(level.getDescription()).version(null)
  728. .pricingPlans(buildPricingPlans(level.getLevelCode())).build();
  729. }
  730. private void fillMembershipExpiration(UserLevelResponse resp, User user) {
  731. if (user == null || resp == null) {
  732. return;
  733. }
  734. boolean premium = resp.getLevelCode() != null && !"PLUS".equals(resp.getLevelCode());
  735. if (!premium) { resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(-1); return; }
  736. // 以会员记录表的活跃记录为准获取过期时间
  737. UserMembership active = userMembershipMapper.selectActiveByUserId(user.getId());
  738. LocalDateTime expire = active != null ? active.getEffectiveTo() : user.getMembershipExpireAt();
  739. if (expire != null && expire.isAfter(LocalDateTime.now())) {
  740. resp.setMembershipExpireAt(expire);
  741. resp.setMembershipDaysRemaining((int) ChronoUnit.DAYS.between(LocalDateTime.now(), expire));
  742. } else if (expire != null) {
  743. resp.setMembershipExpireAt(expire); resp.setMembershipDaysRemaining(0);
  744. } else {
  745. log.warn("高级会员缺少过期时间: userId={}", user.getId());
  746. resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(0);
  747. }
  748. }
  749. private List<LevelPricingPlan> buildPricingPlans(String levelCode) {
  750. return switch (levelCode) {
  751. case "PRO" -> List.of(
  752. LevelPricingPlan.builder()
  753. .planCode("MONTHLY_PRO").planName("高级会员·月卡")
  754. .price(new BigDecimal("29.90"))
  755. .originalPrice(new BigDecimal("59.90"))
  756. .durationDays(30).description("每天不限量· 1个月29.9").build(),
  757. LevelPricingPlan.builder()
  758. .planCode("YEARLY_PRO").planName("高级会员·年卡")
  759. .price(new BigDecimal("299.90"))
  760. .originalPrice(new BigDecimal("599.90"))
  761. .durationDays(365).description("每天不限量· 1年299.9").build());
  762. case "ULTRA" -> List.of(
  763. LevelPricingPlan.builder()
  764. .planCode("ULTRA").planName("超级会员·连锁专享")
  765. .price(BigDecimal.ZERO).originalPrice(null)
  766. .durationDays(0).description("连锁专用,多店铺管理,敬请期待").build());
  767. default -> Collections.emptyList();
  768. };
  769. }
  770. }