package com.xuekairui.user.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.user.dto.GrantMembershipRequest; import com.xuekairui.user.dto.LevelPricingPlan; import com.xuekairui.user.dto.MembershipBenefitResponse; import com.xuekairui.user.dto.MembershipResponse; import com.xuekairui.user.dto.UserLevelResponse; import com.xuekairui.user.entity.MembershipBenefit; import com.xuekairui.user.entity.User; import com.xuekairui.user.entity.UserLevel; import com.xuekairui.user.entity.UserMembership; import com.xuekairui.user.enums.MembershipLevel; import com.xuekairui.user.enums.OperationType; import com.xuekairui.user.mapper.MembershipBenefitMapper; import com.xuekairui.user.mapper.UserMapper; import com.xuekairui.user.mapper.UserMembershipMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; /** * 会员体系服务 */ @Slf4j @Service @RequiredArgsConstructor public class MembershipService { private final UserMembershipMapper userMembershipMapper; private final MembershipBenefitMapper membershipBenefitMapper; private final UserMapper userMapper; private final OperationAuditLogService auditLogService; /** * 获取用户当前生效的会员信息 *

无活跃会员记录时,回退到用户表的当前等级(默认PLUS普通会员) */ public MembershipResponse getMyMembership(Long userId) { UserMembership membership = userMembershipMapper.selectActiveByUserId(userId); if (membership != null) { return toResponse(membership); } // 无活跃会员记录,默认为免费普通会员(PLUS) return MembershipResponse.builder() .userId(userId) .level(MembershipLevel.PLUS.getCode()) .levelName(MembershipLevel.PLUS.getDescription()) .source("SYSTEM") .effectiveFrom(null) .effectiveTo(null) .status(1) .remark("免费会员") .build(); } /** * 查询指定会员等级的权益列表 */ public List getBenefits(String levelCode) { MembershipLevel level = MembershipLevel.fromCode(levelCode); if (level == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "会员等级无效"); } List benefits = membershipBenefitMapper.selectEnabledByLevel(level); return benefits.stream() .map(this::toBenefitResponse) .collect(Collectors.toList()); } /** * 发放/调整用户会员等级(运营/超管) */ @Transactional public MembershipResponse grantMembership(GrantMembershipRequest request, Long operatorId, String operatorRole) { User user = userMapper.selectById(request.getUserId()); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } MembershipLevel level = MembershipLevel.fromCode(request.getLevel()); if (level == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "会员等级无效"); } // 停用该用户其他有效会员记录 List activeList = userMembershipMapper.selectList( new LambdaQueryWrapper() .eq(UserMembership::getUserId, request.getUserId()) .eq(UserMembership::getStatus, 1)); for (UserMembership active : activeList) { active.setStatus(0); userMembershipMapper.updateById(active); } // 创建新会员记录 UserMembership membership = UserMembership.builder() .userId(request.getUserId()) .level(level) .source(request.getSource() != null ? request.getSource() : "ADMIN_GRANT") .effectiveFrom(request.getEffectiveFrom() != null ? request.getEffectiveFrom() : LocalDateTime.now()) .effectiveTo(request.getEffectiveTo()) .status(1) .remark(request.getRemark()) .build(); userMembershipMapper.insert(membership); // 同步到用户表当前会员等级与过期时间 user.setMembershipLevel(level); user.setMembershipExpireAt(membership.getEffectiveTo()); userMapper.updateById(user); // 审计日志 auditLogService.logSuccess(operatorId, "管理员", operatorRole, OperationType.USER_UPDATE, "MEMBERSHIP:" + request.getUserId() + "->" + level.getCode(), request.getUserId()); log.info("运营人员{}为用户{}发放会员等级{}", operatorId, request.getUserId(), level.getCode()); return toResponse(membership); } /** * 统一发放或续期会员(互斥模式) *

* 如果用户已有同等级活跃会员,续期(叠加天数); * 否则停用所有旧会员,创建新记录。 */ @Transactional public void grantOrExtendMembership(Long userId, MembershipLevel level, int days, String source, String remark) { User user = userMapper.selectById(userId); if (user == null) { return; } LocalDateTime newExpireAt; MembershipLevel effectiveLevel = level; UserMembership existing = userMembershipMapper.selectActiveByUserId(userId); if (existing != null) { if (existing.getLevel() == level) { // 同等级续期:在原到期时间基础上追加 LocalDateTime base = existing.getEffectiveTo() != null && existing.getEffectiveTo().isAfter(LocalDateTime.now()) ? existing.getEffectiveTo() : LocalDateTime.now(); existing.setEffectiveTo(base.plusDays(days)); userMembershipMapper.updateById(existing); newExpireAt = existing.getEffectiveTo(); } else if (existing.getLevel().getWeight() > level.getWeight()) { // 已有更高等级:不降级,直接续期更高等级(如ULTRA用户邀请奖励仍保持ULTRA) LocalDateTime base = existing.getEffectiveTo() != null && existing.getEffectiveTo().isAfter(LocalDateTime.now()) ? existing.getEffectiveTo() : LocalDateTime.now(); existing.setEffectiveTo(base.plusDays(days)); userMembershipMapper.updateById(existing); newExpireAt = existing.getEffectiveTo(); effectiveLevel = existing.getLevel(); } else { // 升级:停用所有低等级旧会员,创建新记录 deactivateAllActive(userId); LocalDateTime now = LocalDateTime.now(); UserMembership membership = UserMembership.builder() .userId(userId) .level(level) .source(source) .effectiveFrom(now) .effectiveTo(now.plusDays(days)) .status(1) .remark(remark) .build(); userMembershipMapper.insert(membership); newExpireAt = membership.getEffectiveTo(); } } else { // 无活跃会员:创建新记录 deactivateAllActive(userId); LocalDateTime now = LocalDateTime.now(); UserMembership membership = UserMembership.builder() .userId(userId) .level(level) .source(source) .effectiveFrom(now) .effectiveTo(now.plusDays(days)) .status(1) .remark(remark) .build(); userMembershipMapper.insert(membership); newExpireAt = membership.getEffectiveTo(); } // 同步用户表:等级 + 过期时间(仅当等级变化或过期时间变化时更新) boolean needUpdate = false; if (user.getMembershipLevel() == null || user.getMembershipLevel().getWeight() < effectiveLevel.getWeight()) { user.setMembershipLevel(effectiveLevel); needUpdate = true; } if (user.getMembershipExpireAt() == null || !user.getMembershipExpireAt().equals(newExpireAt)) { user.setMembershipExpireAt(newExpireAt); needUpdate = true; } if (needUpdate) { userMapper.updateById(user); } } /** * 邀请人奖励:赠送1个月(30天)高级会员(PRO) *

* 无论邀请人当前是什么等级,统一赠送高级会员时长。 * 同等级续期叠加,不同等级则停用旧会员并启用新的PRO会员。 */ @Transactional public void grantInviteRewardMembership(Long inviterId, Long inviteeId) { grantOrExtendMembership(inviterId, MembershipLevel.PRO, 30, "INVITE", "邀请奖励:邀请用户" + inviteeId + "注册"); log.info("邀请奖励:用户{}获得30天高级会员(PRO)", inviterId); } /** * 支付购买激活会员(互斥模式:同等级续期,不同等级替换) * @param userId 用户ID * @param level 会员等级 * @param durationDays 有效期天数(月卡30天,年卡365天) * @param orderNo 关联支付订单号 * @return 创建或续期的会员记录 */ @Transactional public UserMembership activateFromPayment(Long userId, MembershipLevel level, int durationDays, String orderNo) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } grantOrExtendMembership(userId, level, durationDays, "RECHARGE", "支付购买: " + orderNo); // 审计日志 auditLogService.logSuccess(userId, user.getNickname(), "USER", OperationType.USER_UPDATE, "PAY:" + orderNo + "->" + level.getCode(), userId); log.info("用户{}通过支付{}激活会员等级{}, 有效期{}天", userId, orderNo, level.getCode(), durationDays); // 返回当前活跃会员记录 return userMembershipMapper.selectActiveByUserId(userId); } /** * 分页查询会员记录(支持按用户/等级/来源/手机号/昵称筛选) */ public Page listMemberships(Integer pageNum, Integer pageSize, Long userId, String level, String source, String phone, String nickname) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); if (userId != null) { wrapper.eq(UserMembership::getUserId, userId); } if (level != null && !level.isBlank()) { MembershipLevel membershipLevel = MembershipLevel.fromCode(level); if (membershipLevel != null) { wrapper.eq(UserMembership::getLevel, membershipLevel); } } if (source != null && !source.isBlank()) { wrapper.eq(UserMembership::getSource, source); } // 昵称筛选:先按昵称模糊查用户ID列表,再过滤会员记录 if (nickname != null && !nickname.isBlank()) { List matchedUserIds = userMapper.selectList( new LambdaQueryWrapper() .select(User::getId) .like(User::getNickname, nickname)) .stream().map(User::getId).collect(Collectors.toList()); if (matchedUserIds.isEmpty()) { return new Page<>(pageNum, pageSize, 0); } wrapper.in(UserMembership::getUserId, matchedUserIds); } // 手机号筛选:先按手机号模糊查用户ID列表,再过滤会员记录 if (phone != null && !phone.isBlank()) { List matchedUserIds = userMapper.selectList( new LambdaQueryWrapper() .select(User::getId) .like(User::getPhone, phone)) .stream().map(User::getId).collect(Collectors.toList()); if (matchedUserIds.isEmpty()) { return new Page<>(pageNum, pageSize, 0); } wrapper.in(UserMembership::getUserId, matchedUserIds); } wrapper.orderByDesc(UserMembership::getCreateTime); Page result = userMembershipMapper.selectPage(page, wrapper); Page responsePage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); // 批量查询用户,避免N+1 List userIds = result.getRecords().stream() .map(UserMembership::getUserId) .distinct() .collect(Collectors.toList()); Map userMap = userIds.isEmpty() ? new HashMap<>() : userMapper.selectByIds(userIds).stream() .collect(Collectors.toMap(User::getId, u -> u)); responsePage.setRecords(result.getRecords().stream() .map(m -> toResponse(m, userMap.get(m.getUserId()))) .collect(Collectors.toList())); return responsePage; } private MembershipResponse toResponse(UserMembership membership) { if (membership == null) { return null; } User user = membership.getUserId() != null ? userMapper.selectById(membership.getUserId()) : null; return toResponse(membership, user); } /** * 停用用户所有活跃会员记录 */ private void deactivateAllActive(Long userId) { List activeList = userMembershipMapper.selectList( new LambdaQueryWrapper() .eq(UserMembership::getUserId, userId) .eq(UserMembership::getStatus, 1)); for (UserMembership active : activeList) { active.setStatus(0); userMembershipMapper.updateById(active); } } private MembershipResponse toResponse(UserMembership membership, User user) { if (membership == null) { return null; } MembershipLevel level = membership.getLevel(); return MembershipResponse.builder() .id(membership.getId()) .userId(membership.getUserId()) .phone(user != null ? user.getPhone() : null) .nickname(user != null ? user.getNickname() : null) .level(level != null ? level.getCode() : null) .levelName(level != null ? level.getDescription() : null) .source(membership.getSource()) .effectiveFrom(membership.getEffectiveFrom()) .effectiveTo(membership.getEffectiveTo()) .membershipExpireAt(membership.getEffectiveTo()) .status(membership.getStatus()) .remark(membership.getRemark()) .createTime(membership.getCreateTime()) .build(); } private MembershipBenefitResponse toBenefitResponse(MembershipBenefit benefit) { return MembershipBenefitResponse.builder() .benefitType(benefit.getBenefitType()) .benefitTypeName(toBenefitTypeName(benefit.getBenefitType())) .benefitValue(benefit.getBenefitValue()) .description(benefit.getDescription()) .build(); } private String toBenefitTypeName(String type) { return switch (type) { case "DAILY_QUOTA" -> "每日查询配额"; case "MONTHLY_QUOTA" -> "每月爬虫加成"; case "MAX_CONCURRENT" -> "最大并发加成"; default -> type; }; } /** * 定时任务:每5分钟检查过期会员,自动降级 *

* 高级会员全部时长到期后,账号降级为免费普通会员(PLUS)。 */ @Scheduled(cron = "0 */5 * * * ?") @Transactional public void expireMemberships() { // 查找所有已过期但仍标记为有效的会员记录 List expired = userMembershipMapper.selectList( new LambdaQueryWrapper() .eq(UserMembership::getStatus, 1) .isNotNull(UserMembership::getEffectiveTo) .lt(UserMembership::getEffectiveTo, LocalDateTime.now())); if (expired.isEmpty()) { return; } Set affectedUserIds = new HashSet<>(); for (UserMembership m : expired) { m.setStatus(0); userMembershipMapper.updateById(m); affectedUserIds.add(m.getUserId()); } // 对每个受影响的用户,检查是否还有其他有效会员 for (Long userId : affectedUserIds) { UserMembership active = userMembershipMapper.selectActiveByUserId(userId); if (active == null) { // 无有效会员,降级为普通会员 User user = userMapper.selectById(userId); if (user != null && user.getMembershipLevel() != MembershipLevel.PLUS) { user.setMembershipLevel(MembershipLevel.PLUS); user.setMembershipExpireAt(null); userMapper.updateById(user); log.info("用户{}会员已到期,降级为普通会员", userId); } } } log.info("处理过期会员记录: {}条,涉及用户: {}个", expired.size(), affectedUserIds.size()); } // ==================== 等级查询(原 UserLevelService,已合并) ==================== /** 会员等级与兼容 UserLevel 对象的映射 */ private static final Map LEVEL_MAP = Map.of( MembershipLevel.PLUS, UserLevel.builder() .id(1L).levelName("普通会员").levelCode("PLUS").levelWeight(1) .crawlerQuota(10).monthlyQuota(300).yearlyQuota(3650).maxConcurrent(1) .description("普通会员:每日有效查询10次").status(1).build(), MembershipLevel.PRO, UserLevel.builder() .id(2L).levelName("高级会员").levelCode("PRO").levelWeight(2) .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(5) .description("高级会员:每日不限量").status(1).build(), MembershipLevel.ULTRA, UserLevel.builder() .id(3L).levelName("超级会员").levelCode("ULTRA").levelWeight(3) .crawlerQuota(999).monthlyQuota(29970).yearlyQuota(364635).maxConcurrent(10) .description("超级会员:连锁专用").status(1).build()); public List listLevels() { return LEVEL_MAP.keySet().stream() .sorted(Comparator.comparingInt(l -> LEVEL_MAP.get(l).getLevelWeight())) .map(this::buildUserLevelWithBenefits) .map(this::toLevelResponse) .collect(Collectors.toList()); } public UserLevelResponse getLevel(Long levelId) { return LEVEL_MAP.values().stream() .filter(l -> l.getId().equals(levelId)).findFirst() .map(l -> buildUserLevelWithBenefits(l.getLevelCode() != null ? MembershipLevel.fromCode(l.getLevelCode()) : MembershipLevel.PLUS)) .map(this::toLevelResponse) .orElseThrow(() -> new BusinessException(ErrorCode.LEVEL_NOT_FOUND)); } public UserLevelResponse getUserLevel(Long userId) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } UserLevelResponse resp = toLevelResponse(resolveUserLevel(user)); fillMembershipExpiration(resp, user); return resp; } public UserLevel getUserLevelEntity(Long userId) { return resolveUserLevel(userMapper.selectById(userId)); } private UserLevel resolveUserLevel(User user) { if (user == null) { return buildUserLevelWithBenefits(MembershipLevel.PLUS); } if (user.getMembershipExpireAt() != null && user.getMembershipExpireAt().isBefore(LocalDateTime.now())) { return buildUserLevelWithBenefits(MembershipLevel.PLUS); } UserMembership active = userMembershipMapper.selectActiveByUserId(user.getId()); return buildUserLevelWithBenefits(active != null && active.getLevel() != null ? active.getLevel() : MembershipLevel.PLUS); } private UserLevel buildUserLevelWithBenefits(MembershipLevel level) { UserLevel base = LEVEL_MAP.getOrDefault(level, LEVEL_MAP.get(MembershipLevel.PLUS)); var builder = UserLevel.builder() .id(base.getId()).levelName(base.getLevelName()).levelCode(base.getLevelCode()) .levelWeight(base.getLevelWeight()).description(base.getDescription()) .status(base.getStatus()).crawlerQuota(base.getCrawlerQuota()) .monthlyQuota(base.getMonthlyQuota()).yearlyQuota(base.getYearlyQuota()) .maxConcurrent(base.getMaxConcurrent()); for (MembershipBenefit b : membershipBenefitMapper.selectEnabledByLevel(level)) { if (b.getBenefitValue() == null) { continue; } switch (b.getBenefitType()) { case "DAILY_QUOTA" -> builder.crawlerQuota(b.getBenefitValue()); case "MONTHLY_QUOTA" -> builder.monthlyQuota(b.getBenefitValue()); case "YEARLY_QUOTA" -> builder.yearlyQuota(b.getBenefitValue()); case "MAX_CONCURRENT" -> builder.maxConcurrent(b.getBenefitValue()); } } return builder.build(); } private UserLevelResponse toLevelResponse( UserLevel level) { return UserLevelResponse.builder() .id(level.getId()).levelName(level.getLevelName()) .levelCode(level.getLevelCode()).levelWeight(level.getLevelWeight()) .crawlerQuota(level.getCrawlerQuota()).monthlyQuota(level.getMonthlyQuota()) .yearlyQuota(level.getYearlyQuota()).maxConcurrent(level.getMaxConcurrent()) .description(level.getDescription()).version(null) .pricingPlans(buildPricingPlans(level.getLevelCode())).build(); } private void fillMembershipExpiration(UserLevelResponse resp, User user) { if (user == null || resp == null) { return; } boolean premium = resp.getLevelCode() != null && !"PLUS".equals(resp.getLevelCode()); if (!premium) { resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(-1); return; } LocalDateTime expire = user.getMembershipExpireAt(); if (expire != null && expire.isAfter(LocalDateTime.now())) { resp.setMembershipExpireAt(expire); resp.setMembershipDaysRemaining((int) ChronoUnit.DAYS.between(LocalDateTime.now(), expire)); } else if (expire != null) { resp.setMembershipExpireAt(expire); resp.setMembershipDaysRemaining(0); } else { log.warn("高级会员缺少过期时间: userId={}", user.getId()); resp.setMembershipExpireAt(null); resp.setMembershipDaysRemaining(0); } } private List buildPricingPlans(String levelCode) { return switch (levelCode) { case "PRO" -> List.of( LevelPricingPlan.builder() .planCode("MONTHLY_PRO").planName("高级会员·月卡") .price(new BigDecimal("29.90")) .originalPrice(new BigDecimal("59.90")) .durationDays(30).description("每天不限量· 1个月29.9").build(), LevelPricingPlan.builder() .planCode("YEARLY_PRO").planName("高级会员·年卡") .price(new BigDecimal("299.90")) .originalPrice(new BigDecimal("599.90")) .durationDays(365).description("每天不限量· 1年299.9").build()); case "ULTRA" -> List.of( LevelPricingPlan.builder() .planCode("ULTRA").planName("超级会员·连锁专享") .price(BigDecimal.ZERO).originalPrice(null) .durationDays(0).description("连锁专用,多店铺管理,敬请期待").build()); default -> Collections.emptyList(); }; } }