Kaynağa Gözat

小程序鉴权调整

liuchengsen 1 ay önce
ebeveyn
işleme
6bbcbc6b52
19 değiştirilmiş dosya ile 451 ekleme ve 65 silme
  1. 3 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java
  2. 5 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryPrizeRequest.java
  3. 4 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryResultResponse.java
  4. 9 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryPrize.java
  5. 3 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryRecord.java
  6. 10 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryPrizeMapper.java
  7. 168 26
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java
  8. 1 1
      zhijiayun-gateway/src/main/resources/application-prod.yml
  9. 65 0
      zhijiayun-gateway/src/main/resources/db/migration-v16.sql
  10. 0 8
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java
  11. 25 0
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/PaymentControllerTest.java
  12. 35 0
      zhijiayun-gateway/src/test/resources/test-schema.sql
  13. 20 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentController.java
  14. 6 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/CreateOrderRequest.java
  15. 6 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/SwitchChannelRequest.java
  16. 72 14
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java
  17. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java
  18. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/SmsLoginRequest.java
  19. 12 12
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

+ 3 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java

@@ -14,6 +14,7 @@ import com.xuekairui.activity.service.ActivityCheckinCycleService;
 import com.xuekairui.activity.service.ActivityInviteService;
 import com.xuekairui.activity.service.ActivityLotteryService;
 import com.xuekairui.common.Result;
+import com.xuekairui.common.ValidateUtil;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -100,7 +101,8 @@ public class ActivityController {
     @PostMapping("/lottery/draw")
     public Result<ActivityLotteryResultResponse> draw(HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(activityLotteryService.draw(userId));
+        String clientIp = ValidateUtil.getClientIp(request);
+        return Result.success(activityLotteryService.draw(userId, clientIp));
     }
 
     /**

+ 5 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryPrizeRequest.java

@@ -23,6 +23,11 @@ public class ActivityLotteryPrizeRequest {
     private Integer rewardDays;
     private String rewardLevel;
 
+    /** 奖品总份数(0表示不限或仅展示) */
+    @NotNull(message = "奖品总份数不能为空")
+    @Min(value = 0, message = "奖品总份数不能小于0")
+    private Integer stock;
+
     @NotNull(message = "概率权重不能为空")
     @Min(value = 0, message = "概率权重不能小于0")
     private Integer probabilityWeight;

+ 4 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryResultResponse.java

@@ -23,7 +23,7 @@ public class ActivityLotteryResultResponse {
 
     private String prizeName;
 
-    /** MEMBERSHIP / CRAWLER / EMPTY */
+    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL */
     private String rewardType;
 
     private Integer rewardAmount;
@@ -32,6 +32,9 @@ public class ActivityLotteryResultResponse {
 
     private String rewardLevel;
 
+    /** 奖品等级名称(特等奖/一等奖/.../谢谢惠顾) */
+    private String prizeTier;
+
     /** 是否中奖(EMPTY 为未中奖) */
     private Boolean win;
 

+ 9 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryPrize.java

@@ -23,7 +23,7 @@ public class ActivityLotteryPrize {
 
     private String name;
 
-    /** MEMBERSHIP / CRAWLER / EMPTY */
+    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL */
     private String rewardType;
 
     @Builder.Default
@@ -33,6 +33,14 @@ public class ActivityLotteryPrize {
 
     private String rewardLevel;
 
+    /** 奖品总份数(0表示不限或仅展示,如特等奖0份) */
+    @Builder.Default
+    private Integer stock = 0;
+
+    /** 剩余可发份数(0表示已发完或不参与抽奖,库存为0的奖品不参与加权随机) */
+    @Builder.Default
+    private Integer remainingStock = 0;
+
     @Builder.Default
     private Integer probabilityWeight = 1;
 

+ 3 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryRecord.java

@@ -38,6 +38,9 @@ public class ActivityLotteryRecord {
 
     private Long chanceId;
 
+    /** 抽奖时客户端IP */
+    private String clientIp;
+
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;
 }

+ 10 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/ActivityLotteryPrizeMapper.java

@@ -3,7 +3,9 @@ package com.xuekairui.activity.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.xuekairui.activity.entity.ActivityLotteryPrize;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 
 import java.util.List;
 
@@ -13,4 +15,12 @@ public interface ActivityLotteryPrizeMapper extends BaseMapper<ActivityLotteryPr
     /** 查询所有启用的奖品,按 sort 升序 */
     @Select("SELECT * FROM t_activity_lottery_prize WHERE enabled = 1 ORDER BY sort ASC, id ASC")
     List<ActivityLotteryPrize> listEnabled();
+
+    /**
+     * 原子扣减奖品库存(remaining_stock > 0 时减1)。
+     * @return 影响行数,1=扣减成功,0=库存不足
+     */
+    @Update("UPDATE t_activity_lottery_prize SET remaining_stock = remaining_stock - 1 " +
+            "WHERE id = #{id} AND remaining_stock > 0")
+    int decrementStock(@Param("id") Long id);
 }

+ 168 - 26
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java

@@ -19,10 +19,18 @@ import com.xuekairui.activity.mapper.ActivityLotteryRecordMapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.event.ActivityEventRedisService;
+import com.xuekairui.user.entity.BusinessLicense;
+import com.xuekairui.user.entity.OperationAuditLog;
 import com.xuekairui.user.entity.User;
+import com.xuekairui.user.enums.AuditResult;
+import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.enums.OperatorRole;
+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;
@@ -39,9 +47,12 @@ import java.util.concurrent.ThreadLocalRandom;
  * 比价抽奖活动服务。
  * <p>
  * 规则:
- * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有高级会员(PRO及以上)可参加;
- * 2. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
- * 3. 抽奖按奖品权重随机中奖,中奖后即时发放对应奖励。
+ * 1. 活动时间窗 2026-07-13 ~ 2026-07-31;
+ * 2. 参与条件:高级会员(PRO及以上) + 入驻信息审核通过(APPROVED);
+ * 3. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
+ * 4. 每日仅限抽奖1次,重复抽奖不允许,返回"今日抽奖次数已用完"并记录违规审计日志;
+ * 5. 抽奖按奖品权重随机中奖,库存为0的奖品不参与抽奖;
+ * 6. 中奖后即时发放对应奖励(会员时长即刻到账,实物奖品需客服核实)。
  */
 @Slf4j
 @Service
@@ -52,14 +63,17 @@ public class ActivityLotteryService {
     private static final String SOURCE_COMPARE = "COMPARE";
     private static final String REWARD_TYPE_MEMBERSHIP = "MEMBERSHIP";
     private static final String REWARD_TYPE_EMPTY = "EMPTY";
+    private static final String REWARD_TYPE_PHYSICAL = "PHYSICAL";
 
     private final ActivityLotteryConfigMapper configMapper;
     private final ActivityLotteryChanceMapper chanceMapper;
     private final ActivityLotteryPrizeMapper prizeMapper;
     private final ActivityLotteryRecordMapper recordMapper;
     private final UserMapper userMapper;
+    private final BusinessLicenseMapper businessLicenseMapper;
     private final MembershipService membershipService;
     private final ActivityEventRedisService activityEventRedisService;
+    private final OperationAuditLogService operationAuditLogService;
 
     // ==========================================
     // 配置管理
@@ -92,7 +106,6 @@ public class ActivityLotteryService {
         } else {
             configMapper.updateById(config);
         }
-        // 同步每日上限到 Redis 缓存,供事件防重模块实时读取
         int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
         activityEventRedisService.updateCachedMaxChance(maxChance);
         log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}, dailyMaxChance={}",
@@ -104,10 +117,6 @@ public class ActivityLotteryService {
     // 事件处理:比价成功后发放抽奖机会
     // ==========================================
 
-    /**
-     * 监听比价消耗事件:活动开启且用户具备资格时,每自然日首次比价发放一次抽奖机会。
-     * 幂等:t_activity_lottery_chance 的 uk_user_date_source 保证每日仅一次。
-     */
     @Transactional
     public void onCrawlerConsumed(Long userId, String usageDateStr) {
         ActivityLotteryConfig config = getConfig();
@@ -140,18 +149,37 @@ public class ActivityLotteryService {
 
     /**
      * 执行抽奖:消耗一个当日未使用的抽奖机会,按权重随机中奖并发放奖励。
+     *
+     * @param userId   用户ID
+     * @param clientIp 客户端IP(用于违规审计记录)
      */
     @Transactional
-    public ActivityLotteryResultResponse draw(Long userId) {
+    public ActivityLotteryResultResponse draw(Long userId, String clientIp) {
         ActivityLotteryConfig config = getConfig();
         if (!isActive(config)) {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束");
         }
         if (isNotQualified(userId, config)) {
-            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖");
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员且入驻审核通过可参与比价抽奖");
         }
 
         LocalDate today = LocalDate.now();
+
+        // 检查今日是否已抽过(used=1 的机会记录存在即表示已抽奖)
+        Long usedCount = chanceMapper.selectCount(
+                new LambdaQueryWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getUserId, userId)
+                        .eq(ActivityLotteryChance::getChanceDate, today)
+                        .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
+                        .eq(ActivityLotteryChance::getUsed, 1));
+
+        if (usedCount != null && usedCount > 0) {
+            // 今日已抽过,记录违规审计日志并返回提示
+            recordViolation(userId, clientIp, today);
+            log.warn("重复抽奖违规: userId={}, ip={}, date={}", userId, clientIp, today);
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完");
+        }
+
         // 取一条未使用的机会
         ActivityLotteryChance chance = chanceMapper.selectOne(
                 new LambdaQueryWrapper<ActivityLotteryChance>()
@@ -171,15 +199,28 @@ public class ActivityLotteryService {
                         .eq(ActivityLotteryChance::getUsed, 0)
                         .set(ActivityLotteryChance::getUsed, 1));
         if (affected == 0) {
-            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "抽奖机会已被使用");
+            // 并发竞争失败,记录违规
+            recordViolation(userId, clientIp, today);
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完");
         }
 
-        // 加权随机选中奖品
+        // 加权随机选中奖品(库存为0的奖品不参与)
         ActivityLotteryPrize prize = pickPrize();
         if (prize == null) {
-            // 无可用奖品,兜底为未中奖
+            // 无可用奖品,兜底为谢谢惠顾
             prize = ActivityLotteryPrize.builder()
-                    .name("谢谢参与").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
+                    .name("谢谢惠顾").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
+        } else {
+            // 非谢谢惠顾奖品需要原子扣减库存
+            if (!REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
+                int stockAffected = prizeMapper.decrementStock(prize.getId());
+                if (stockAffected == 0) {
+                    // 库存不足(并发竞争),兜底为谢谢惠顾
+                    log.warn("奖品库存不足,兜底谢谢惠顾: prizeId={}, name={}", prize.getId(), prize.getName());
+                    prize = ActivityLotteryPrize.builder()
+                            .name("谢谢惠顾").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
+                }
+            }
         }
 
         // 发放奖励
@@ -195,10 +236,11 @@ public class ActivityLotteryService {
                 .rewardDays(prize.getRewardDays())
                 .rewardLevel(prize.getRewardLevel())
                 .chanceId(chance.getId())
+                .clientIp(clientIp)
                 .build();
         recordMapper.insert(record);
 
-        log.info("抽奖完成: userId={}, prize={}, type={}", userId, prize.getName(), prize.getRewardType());
+        log.info("抽奖完成: userId={}, prize={}, type={}, ip={}", userId, prize.getName(), prize.getRewardType(), clientIp);
 
         boolean win = !REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType());
         return ActivityLotteryResultResponse.builder()
@@ -208,6 +250,7 @@ public class ActivityLotteryService {
                 .rewardAmount(record.getRewardAmount())
                 .rewardDays(prize.getRewardDays())
                 .rewardLevel(prize.getRewardLevel())
+                .prizeTier(extractPrizeTier(prize.getName()))
                 .win(win)
                 .createTime(record.getCreateTime())
                 .build();
@@ -234,12 +277,14 @@ public class ActivityLotteryService {
                         .eq(ActivityLotteryChance::getUsed, 1));
 
         MembershipLevel userLevel = getUserLevel(userId);
+        boolean licenseApproved = isLicenseApproved(userId);
         String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
-        boolean eligible = isLevelEnough(userLevel, requireLevel);
+        boolean levelEnough = isLevelEnough(userLevel, requireLevel);
+        boolean eligible = levelEnough && licenseApproved;
 
         int avail = available != null ? available.intValue() : 0;
         int usedCount = used != null ? used.intValue() : 0;
-        String reason = computeReason(config, avail, usedCount, eligible, userLevel, requireLevel);
+        String reason = computeReason(config, avail, usedCount, eligible, levelEnough, licenseApproved, userLevel, requireLevel);
 
         return ActivityLotteryStatusResponse.builder()
                 .enabled(isActive(config))
@@ -331,12 +376,15 @@ public class ActivityLotteryService {
 
     @Transactional
     public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) {
+        int stock = request.getStock() != null ? request.getStock() : 0;
         ActivityLotteryPrize prize = ActivityLotteryPrize.builder()
                 .name(request.getName())
                 .rewardType(request.getRewardType())
                 .rewardAmount(request.getRewardAmount() != null ? request.getRewardAmount() : 0)
                 .rewardDays(request.getRewardDays())
                 .rewardLevel(request.getRewardLevel())
+                .stock(stock)
+                .remainingStock(stock)
                 .probabilityWeight(request.getProbabilityWeight())
                 .enabled(request.getEnabled() != null ? request.getEnabled() : 1)
                 .sort(request.getSort() != null ? request.getSort() : 0)
@@ -356,6 +404,14 @@ public class ActivityLotteryService {
         Optional.ofNullable(request.getRewardAmount()).ifPresent(prize::setRewardAmount);
         prize.setRewardDays(request.getRewardDays());
         prize.setRewardLevel(request.getRewardLevel());
+        Optional.ofNullable(request.getStock()).ifPresent(stock -> {
+            prize.setStock(stock);
+            // 更新总份数时,剩余份数同步调整(已发放量保持不变)
+            int issued = (prize.getStock() != null ? prize.getStock() : 0)
+                    - (prize.getRemainingStock() != null ? prize.getRemainingStock() : 0);
+            int newRemaining = Math.max(0, stock - issued);
+            prize.setRemainingStock(newRemaining);
+        });
         Optional.ofNullable(request.getProbabilityWeight()).ifPresent(prize::setProbabilityWeight);
         Optional.ofNullable(request.getEnabled()).ifPresent(prize::setEnabled);
         Optional.ofNullable(request.getSort()).ifPresent(prize::setSort);
@@ -372,11 +428,62 @@ public class ActivityLotteryService {
     // 私有辅助
     // ==========================================
 
+    /**
+     * 记录重复抽奖违规审计日志。
+     * 复用通用操作审计日志服务,将违规操作记入 t_operation_audit_log,
+     * 包含用户ID、IP、操作时间等信息,便于后续追溯。
+     */
+    private void recordViolation(Long userId, String clientIp, LocalDate chanceDate) {
+        try {
+            User user = userMapper.selectById(userId);
+            String phone = user != null ? user.getPhone() : null;
+            String operatorName = phone != null ? phone : String.valueOf(userId);
+
+            OperationAuditLog auditLog = OperationAuditLog.builder()
+                    .operatorId(userId)
+                    .operatorName(operatorName)
+                    .operatorRole(OperatorRole.USER.getCode())
+                    .operationType(OperationType.USER_UPDATE.getCode())
+                    .module("LOTTERY")
+                    .description("比价抽奖重复抽奖违规")
+                    .targetType("LOTTERY")
+                    .targetId(userId)
+                    .targetIdentifier("LOTTERY:" + userId + ":" + chanceDate)
+                    .result(AuditResult.FAILED.getCode())
+                    .errorMessage("今日抽奖次数已用完,重复尝试已记录")
+                    .ipAddress(clientIp)
+                    .remark("重复抽奖违规,IP:" + clientIp + ", 日期:" + chanceDate)
+                    .isSensitive(true)
+                    .build();
+            operationAuditLogService.logOperation(auditLog);
+        } catch (Exception e) {
+            log.error("记录违规审计日志失败: userId={}, ip={}", userId, clientIp, e);
+        }
+    }
+
+    /**
+     * 从奖品名称中提取奖项等级(特等奖/一等奖/.../谢谢惠顾)
+     */
+    private String extractPrizeTier(String prizeName) {
+        if (prizeName == null) {
+            return null;
+        }
+        if (prizeName.contains("特等奖")) return "特等奖";
+        if (prizeName.contains("一等奖")) return "一等奖";
+        if (prizeName.contains("二等奖")) return "二等奖";
+        if (prizeName.contains("三等奖")) return "三等奖";
+        if (prizeName.contains("四等奖")) return "四等奖";
+        if (prizeName.contains("五等奖")) return "五等奖";
+        if (prizeName.contains("谢谢")) return "谢谢惠顾";
+        return null;
+    }
+
     /**
      * 按优先级计算抽奖状态原因。有机会时返回 null。
      */
     private String computeReason(ActivityLotteryConfig config, int avail, int usedCount,
-                                  boolean eligible, MembershipLevel userLevel, String requireLevel) {
+                                  boolean eligible, boolean levelEnough, boolean licenseApproved,
+                                  MembershipLevel userLevel, String requireLevel) {
         if (avail > 0) {
             return null;
         }
@@ -388,10 +495,13 @@ public class ActivityLotteryService {
         if (!inWin) {
             return "不在活动时间窗内";
         }
-        if (!eligible) {
+        if (!levelEnough) {
             String level = userLevel != null ? userLevel.getCode() : "无";
             return "会员等级不足(当前" + level + ",需要" + requireLevel + ")";
         }
+        if (!licenseApproved) {
+            return "入驻信息未审核通过,无法参与抽奖";
+        }
         if (usedCount == 0) {
             return "今日尚未比价,完成首次比价后自动获得抽奖机会";
         }
@@ -407,9 +517,23 @@ public class ActivityLotteryService {
         return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
     }
 
+    /**
+     * 检查用户是否不满足参与条件(会员等级不足 或 入驻未审核通过)
+     */
     private boolean isNotQualified(Long userId, ActivityLotteryConfig config) {
         String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
-        return !isLevelEnough(getUserLevel(userId), requireLevel);
+        if (!isLevelEnough(getUserLevel(userId), requireLevel)) {
+            return true;
+        }
+        return !isLicenseApproved(userId);
+    }
+
+    /**
+     * 检查用户入驻信息是否审核通过
+     */
+    private boolean isLicenseApproved(Long userId) {
+        BusinessLicense license = businessLicenseMapper.selectByUserId(userId);
+        return license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus());
     }
 
     private MembershipLevel getUserLevel(Long userId) {
@@ -429,37 +553,55 @@ public class ActivityLotteryService {
     }
 
     /**
-     * 按权重随机选择一个启用的奖品
+     * 按权重随机选择一个启用的奖品。
+     * 库存为0(remaining_stock=0)的奖品不参与抽奖。
      */
     private ActivityLotteryPrize pickPrize() {
         List<ActivityLotteryPrize> prizes = prizeMapper.listEnabled();
         if (prizes.isEmpty()) {
             return null;
         }
-        int totalWeight = prizes.stream()
+        // 过滤掉库存为0的奖品(EMPTY类型不受库存限制,始终参与)
+        List<ActivityLotteryPrize> available = prizes.stream()
+                .filter(p -> REWARD_TYPE_EMPTY.equalsIgnoreCase(p.getRewardType())
+                        || (p.getRemainingStock() != null && p.getRemainingStock() > 0))
+                .toList();
+        if (available.isEmpty()) {
+            return null;
+        }
+        int totalWeight = available.stream()
                 .mapToInt(p -> p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0)
                 .sum();
         if (totalWeight <= 0) {
-            return prizes.get(prizes.size() - 1);
+            // 权重全为0时,取最后一个(通常是谢谢惠顾)
+            return available.get(available.size() - 1);
         }
         int rand = ThreadLocalRandom.current().nextInt(totalWeight);
         int acc = 0;
-        for (ActivityLotteryPrize p : prizes) {
+        for (ActivityLotteryPrize p : available) {
             acc += (p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0);
             if (rand < acc) {
                 return p;
             }
         }
-        return prizes.get(prizes.size() - 1);
+        return available.get(available.size() - 1);
     }
 
     /**
-     * 根据奖品类型发放奖励(MEMBERSHIP 或 EMPTY)
+     * 根据奖品类型发放奖励
+     * - MEMBERSHIP:会员时长即刻到账
+     * - PHYSICAL:实物奖品,记录中奖,需客服后续核实(不自动发放)
+     * - EMPTY:无奖励
      */
     private void applyReward(Long userId, ActivityLotteryPrize prize) {
         if (REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
             return;
         }
+        if (REWARD_TYPE_PHYSICAL.equalsIgnoreCase(prize.getRewardType())) {
+            // 实物奖品不自动发放,中奖记录已入库,需客服在活动结束后联系核实
+            log.info("实物奖品中奖,待客服核实: userId={}, prize={}", userId, prize.getName());
+            return;
+        }
         if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) {
             int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0;
             if (days <= 0) {

+ 1 - 1
zhijiayun-gateway/src/main/resources/application-prod.yml

@@ -167,4 +167,4 @@ payment:
   # 不配置时自动回退到 payment.wechat.app-id(当两者相同时可省略)
   miniapp:
     app-id: ${WECHAT_PAY_MINIAPP_APP_ID:wx24a9669e84ba1f0b}
-    app-secret: ${WECHAT_PAY_MINIAPP_APP_SECRET:}
+    app-secret: ${WECHAT_PAY_MINIAPP_APP_SECRET:dd9bfde339632c26eeb00c7cb1725696}

+ 65 - 0
zhijiayun-gateway/src/main/resources/db/migration-v16.sql

@@ -0,0 +1,65 @@
+-- ============================================
+-- 迁移 v16:比价抽奖活动规则调整
+-- 说明:
+--   1. 活动时间调整为 2026-07-13 ~ 2026-07-31
+--   2. 参与条件:高级会员(PRO) + 入驻信息审核通过(APPROVED)
+--   3. 奖品池新增 stock(总份数)和 remaining_stock(剩余份数)字段,支持库存扣减
+--   4. 奖品池新增 reward_type=PHYSICAL(实物奖品)类型
+--   5. 抽奖记录表新增 client_ip 字段,记录抽奖时客户端IP
+--   6. 重复抽奖违规记录复用通用审计日志表 t_operation_audit_log
+--   7. 更新默认奖品池为7档奖品(特等奖~谢谢惠顾)
+-- ============================================
+
+-- ========== 一、更新活动配置时间窗 ==========
+UPDATE `t_activity_lottery_config`
+SET `start_time` = '2026-07-13 00:00:00',
+    `end_time` = '2026-07-31 23:59:59',
+    `require_membership_level` = 'PRO',
+    `remark` = '比价抽奖:高级会员且入驻审核通过,每自然日首次成功比价获得1次抽奖机会'
+WHERE `id` = 1;
+
+-- ========== 二、奖品池表结构升级 ==========
+-- 新增 stock 字段(总份数)
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_activity_lottery_prize' AND COLUMN_NAME = 'stock');
+SET @sql = IF(@col_exists = 0,
+    "ALTER TABLE `t_activity_lottery_prize` ADD COLUMN `stock` INT NOT NULL DEFAULT 0 COMMENT '奖品总份数(0表示不限/仅展示)' AFTER `reward_level`",
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 新增 remaining_stock 字段(剩余份数)
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_activity_lottery_prize' AND COLUMN_NAME = 'remaining_stock');
+SET @sql = IF(@col_exists = 0,
+    "ALTER TABLE `t_activity_lottery_prize` ADD COLUMN `remaining_stock` INT NOT NULL DEFAULT 0 COMMENT '剩余可发份数(0表示已发完或不参与抽奖)' AFTER `stock`",
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 更新 reward_type 注释,新增 PHYSICAL 类型
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_activity_lottery_prize' AND COLUMN_NAME = 'reward_type');
+SET @sql = IF(@col_exists > 0,
+    "ALTER TABLE `t_activity_lottery_prize` MODIFY COLUMN `reward_type` VARCHAR(20) NOT NULL COMMENT '奖励类型:MEMBERSHIP-会员 CRAWLER-爬虫次数 EMPTY-未中奖 PHYSICAL-实物奖品'",
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ========== 三、清空旧奖品池,写入新7档奖品 ==========
+DELETE FROM `t_activity_lottery_prize`;
+
+INSERT INTO `t_activity_lottery_prize` (`name`, `reward_type`, `reward_amount`, `reward_days`, `reward_level`, `stock`, `remaining_stock`, `probability_weight`, `enabled`, `sort`)
+VALUES
+('特等奖 iPhone 17', 'PHYSICAL',  0, NULL, NULL,   0,    0,   0, 1, 1),
+('一等奖 30天高级会员', 'MEMBERSHIP', 0, 30, 'PRO',  20,   20,   2, 1, 2),
+('二等奖 15天高级会员', 'MEMBERSHIP', 0, 15, 'PRO', 100,  100,  10, 1, 3),
+('三等奖 7天高级会员',  'MEMBERSHIP', 0,  7, 'PRO', 150,  150,  15, 1, 4),
+('四等奖 3天高级会员',  'MEMBERSHIP', 0,  3, 'PRO', 250,  250,  25, 1, 5),
+('五等奖 1天高级会员',  'MEMBERSHIP', 0,  1, 'PRO', 1080, 1080, 108, 1, 6),
+('谢谢惠顾',           'EMPTY',     0, NULL, NULL, 6400, 6400, 640, 1, 7);
+
+-- ========== 四、抽奖记录表新增 client_ip 字段 ==========
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_activity_lottery_record' AND COLUMN_NAME = 'client_ip');
+SET @sql = IF(@col_exists = 0,
+    "ALTER TABLE `t_activity_lottery_record` ADD COLUMN `client_ip` VARCHAR(64) DEFAULT NULL COMMENT '抽奖时客户端IP' AFTER `chance_id`",
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 0 - 8
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java

@@ -17,7 +17,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.datasource.init.ScriptUtils;
 import org.springframework.test.context.ActiveProfiles;
 import org.springframework.test.web.servlet.MockMvc;
-import lombok.extern.slf4j.Slf4j;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
@@ -31,7 +30,6 @@ import java.util.Map;
  * 提供:MockMvc、JWT Token 生成、数据库初始化/清理、Redis Mock
  * </p>
  */
-@Slf4j
 @SpringBootTest
 @AutoConfigureMockMvc
 @ActiveProfiles("test")
@@ -163,12 +161,6 @@ public abstract class GatewayBaseTest {
                 .expiration(new Date(System.currentTimeMillis() + 86400000))
                 .signWith(key)
                 .compact();
-        try {
-            jwtUtil.parseToken(token);
-            log.info("[DEBUG] generateAccessToken: parseToken OK, userId={}, token={}", userId, token);
-        } catch (Exception e) {
-            log.warn("[DEBUG] generateAccessToken: parseToken FAILED: userId={}, error={}, token={}", userId, e.getMessage(), token);
-        }
         return token;
     }
 

+ 25 - 0
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/PaymentControllerTest.java

@@ -271,6 +271,31 @@ class PaymentControllerTest extends GatewayBaseTest {
                 .andExpect(status().isForbidden());
     }
 
+    // ==================== 微信小程序 OpenId ====================
+
+    @Test
+    @Order(28)
+    @DisplayName("POST /api/payment/wechat/openid - 缺少jsCode应返回400")
+    void getOpenId_missingJsCode_shouldReturn400() throws Exception {
+        mockMvc.perform(post("/api/payment/wechat/openid")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+    }
+
+    @Test
+    @Order(29)
+    @DisplayName("POST /api/payment/wechat/openid - 无效jsCode应返回小程序授权码无效错误")
+    void getOpenId_invalidJsCode_shouldReturnError() throws Exception {
+        mockMvc.perform(post("/api/payment/wechat/openid")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"jsCode\":\"invalid_test_code\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1204))
+                .andExpect(jsonPath("$.message").value("小程序授权码无效"));
+    }
+
     // ==================== 支付回调 ====================
 
     @Test

+ 35 - 0
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -206,6 +206,41 @@ PREPARE stmt FROM @sql;
 EXECUTE stmt;
 DEALLOCATE PREPARE stmt;
 
+-- 兼容升级:为 t_user 添加 wechat_mini_open_id 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'wechat_mini_open_id');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN wechat_mini_open_id VARCHAR(64) DEFAULT NULL AFTER wechat_open_id', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 external_user_id 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'external_user_id');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN external_user_id VARCHAR(64) DEFAULT NULL AFTER wechat_union_id', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 external_sync_time 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'external_sync_time');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN external_sync_time TIMESTAMP NULL DEFAULT NULL AFTER external_user_id', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 pharmacy_address 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'pharmacy_address');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN pharmacy_address VARCHAR(500) DEFAULT NULL AFTER district', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 contact_person 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'contact_person');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN contact_person VARCHAR(64) DEFAULT NULL AFTER pharmacy_address', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 contact_phone 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'contact_phone');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN contact_phone VARCHAR(20) DEFAULT NULL AFTER contact_person', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 兼容升级:为 t_user 添加 business_license_no 列
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_user' AND COLUMN_NAME = 'business_license_no');
+SET @sql = IF(@col_exists = 0, 'ALTER TABLE t_user ADD COLUMN business_license_no VARCHAR(64) DEFAULT NULL AFTER contact_phone', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
 -- 搜索记录表(完整比价快照)
 CREATE TABLE IF NOT EXISTS t_search_record (
     id                BIGINT        NOT NULL COMMENT '主键ID(雪花算法)',

+ 20 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentController.java

@@ -2,6 +2,7 @@ package com.xuekairui.payment.controller;
 
 import com.xuekairui.common.Result;
 import com.xuekairui.payment.dto.*;
+import com.xuekairui.payment.enums.PaymentChannel;
 import com.xuekairui.payment.service.PaymentOrderService;
 import com.xuekairui.payment.service.PaymentPlanService;
 import com.xuekairui.user.service.WechatMiniProgramService;
@@ -11,8 +12,10 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 支付控制器(用户端)
@@ -35,6 +38,23 @@ public class PaymentController {
         return Result.success(paymentPlanService.getActivePlans());
     }
 
+    /**
+     * 获取可用支付渠道列表(公开)
+     * <p>
+     * 小程序端应展示:微信支付(WECHAT_MINIAPP) + 支付宝(ALIPAY)
+     * PC端应展示:微信支付(WECHAT) + 支付宝(ALIPAY)
+     */
+    @GetMapping("/channels")
+    public Result<List<PaymentChannelResponse>> getChannels() {
+        List<PaymentChannelResponse> channels = Arrays.stream(PaymentChannel.values())
+                .map(c -> PaymentChannelResponse.builder()
+                        .code(c.name())
+                        .name(c.getDisplayName())
+                        .build())
+                .collect(Collectors.toList());
+        return Result.success(channels);
+    }
+
     /**
      * 创建支付订单
      */

+ 6 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/CreateOrderRequest.java

@@ -16,4 +16,10 @@ public class CreateOrderRequest {
 
     @NotBlank(message = "支付渠道不能为空")
     private String channel;
+
+    /** 微信小程序 openId(可选;数据库无记录时作为兜底使用) */
+    private String openid;
+
+    /** 微信小程序授权码(可选;通过 wx.login() 获取,传入后自动解析并保存 openId/unionId 到数据库) */
+    private String code;
 }

+ 6 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/SwitchChannelRequest.java

@@ -12,4 +12,10 @@ public class SwitchChannelRequest {
 
     @NotBlank(message = "支付渠道不能为空")
     private String channel;
+
+    /** 微信小程序 openId(可选;数据库无记录时作为兜底使用) */
+    private String openid;
+
+    /** 微信小程序授权码(可选;通过 wx.login() 获取,传入后自动解析并保存 openId/unionId 到数据库) */
+    private String code;
 }

+ 72 - 14
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java

@@ -28,6 +28,7 @@ import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.service.MembershipService;
+import com.xuekairui.user.service.WechatMiniProgramService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -57,6 +58,7 @@ public class PaymentOrderService {
     private final ObjectProvider<WxPayService> wxPayServiceProvider;
     private final PaymentAgreementService agreementService;
     private final PaymentRefundService refundService;
+    private final WechatMiniProgramService wechatMiniProgramService;
 
     public PaymentOrderService(
             PaymentOrderMapper paymentOrderMapper,
@@ -69,7 +71,8 @@ public class PaymentOrderService {
             @Qualifier("alipayClient") ObjectProvider<AlipayClient> alipayClientProvider,
             ObjectProvider<WxPayService> wxPayServiceProvider,
             PaymentAgreementService agreementService,
-            PaymentRefundService refundService) {
+            PaymentRefundService refundService,
+            WechatMiniProgramService wechatMiniProgramService) {
         this.paymentOrderMapper = paymentOrderMapper;
         this.extraPaymentMapper = extraPaymentMapper;
         this.paymentPlanService = paymentPlanService;
@@ -81,6 +84,7 @@ public class PaymentOrderService {
         this.wxPayServiceProvider = wxPayServiceProvider;
         this.agreementService = agreementService;
         this.refundService = refundService;
+        this.wechatMiniProgramService = wechatMiniProgramService;
     }
 
     /**
@@ -107,7 +111,7 @@ public class PaymentOrderService {
 
         // 生成支付二维码/链接/小程序支付参数
         QrCodeResult qrCodeResult = generateQrCode(channel, orderNo, plan.getPrice(),
-                plan.getPlanName(), plan.getDescription(), userId);
+                plan.getPlanName(), plan.getDescription(), userId, request.getOpenid(), request.getCode());
         String qrContent = qrCodeResult.getQrContent();
         String payUrl = qrCodeResult.getPayUrl();
         String qrCodeBase64 = qrCodeResult.getQrCodeBase64();
@@ -176,7 +180,7 @@ public class PaymentOrderService {
         // 不关闭原渠道订单:15分钟内允许用户在多个渠道间重复切换,原渠道二维码仍可被扫描支付
         // 以第一个支付成功渠道为准,后续支付记录到 t_payment_order_extra_payment
         QrCodeResult qrCodeResult = generateQrCode(newChannel, orderNo, order.getAmount(),
-                plan.getPlanName(), plan.getDescription(), userId);
+                plan.getPlanName(), plan.getDescription(), userId, request.getOpenid(), request.getCode());
 
         // 更新订单渠道和凭证:小程序渠道存储 prepay_id,扫码渠道存储 qrContent
         order.setChannel(request.getChannel());
@@ -446,10 +450,26 @@ public class PaymentOrderService {
      */
     private QrCodeResult generateQrCode(PaymentChannel channel, String orderNo,
                                         java.math.BigDecimal amount, String subject, String body,
-                                        Long userId) {
+                                        Long userId, String openidFallback, String code) {
         if (channel == PaymentChannel.WECHAT_MINIAPP) {
-            // 微信小程序支付:从用户记录读取已存储的 openId(登录时通过 code2Session 获取并持久化)
-            String openid = getMiniAppOpenId(userId);
+            // 微信小程序支付 openId 获取优先级:code 解析 > 数据库 > 前端 openid 兜底
+            String openid = null;
+            // 1) 优先走 code:调用 code2Session 解析并持久化 openId/unionId 到 t_user
+            if (code != null && !code.isBlank()) {
+                openid = resolveOpenIdViaJsCode(userId, code);
+            }
+            // 2) 数据库兜底
+            if (openid == null || openid.isBlank()) {
+                openid = getMiniAppOpenIdOrNull(userId);
+            }
+            // 3) 前端 openId 兜底
+            if (openid == null || openid.isBlank()) {
+                openid = (openidFallback != null && !openidFallback.isBlank()) ? openidFallback : null;
+            }
+            if (openid == null || openid.isBlank()) {
+                throw new BusinessException(ErrorCode.PARAM_ERROR,
+                        "未获取到小程序 openId,请先通过小程序登录");
+            }
             java.util.Map<String, String> miniAppParams = createWechatJsapiPrepay(
                     orderNo, amount, subject, body, openid);
             return QrCodeResult.builder()
@@ -729,21 +749,59 @@ public class PaymentOrderService {
     }
 
     /**
-     * 从用户记录中读取小程序 openId
+     * 通过 jsCode 解析并持久化小程序 openId 到用户记录
+     * <p>调用微信 code2Session 获取 openId + unionId,写入 t_user 后返回 openId。
+     * 仅当用户记录中原值为空时才覆盖写入(不覆盖已存在的值)。
+     *
+     * @param userId 用户ID
+     * @param jsCode 小程序 wx.login() 返回的临时授权码
+     * @return 解析到的 openId
+     */
+    private String resolveOpenIdViaJsCode(Long userId, String jsCode) {
+        WechatMiniProgramService.MiniProgramSessionInfo session =
+                wechatMiniProgramService.code2Session(jsCode);
+        String openId = session.getOpenId();
+        String unionId = session.getUnionId();
+
+        if (openId == null || openId.isBlank()) {
+            throw new BusinessException(ErrorCode.WECHAT_MINIAPP_CODE_INVALID,
+                    "code2Session 未返回 openId");
+        }
+
+        // 持久化到 t_user(仅补全空值,不覆盖已有值)
+        User user = userMapper.selectById(userId);
+        if (user != null) {
+            boolean changed = false;
+            if (user.getWechatMiniOpenId() == null || user.getWechatMiniOpenId().isBlank()) {
+                user.setWechatMiniOpenId(openId);
+                changed = true;
+            }
+            if (unionId != null && !unionId.isBlank()
+                    && (user.getWechatUnionId() == null || user.getWechatUnionId().isBlank())) {
+                user.setWechatUnionId(unionId);
+                changed = true;
+            }
+            if (changed) {
+                userMapper.updateById(user);
+                log.info("支付时通过 jsCode 补全用户小程序标识: userId={}, openId={}, unionId={}",
+                        userId, openId, unionId);
+            }
+        }
+        return openId;
+    }
+
+    /**
+     * 从用户记录中读取小程序 openId(无记录时返回 null,不抛异常)
      * <p>登录时通过 code2Session 获取并持久化到 t_user.wechat_mini_open_id。
-     * 支付时直接读取,避免重复 wx.login 授权。
+     * 支付时优先读取数据库,无记录时由调用方用前端传入的 openId 兜底
      */
-    private String getMiniAppOpenId(Long userId) {
+    private String getMiniAppOpenIdOrNull(Long userId) {
         User user = userMapper.selectById(userId);
         if (user == null) {
             throw new BusinessException(ErrorCode.USER_NOT_FOUND);
         }
         String openid = user.getWechatMiniOpenId();
-        if (openid == null || openid.isBlank()) {
-            throw new BusinessException(ErrorCode.PARAM_ERROR,
-                    "未获取到小程序 openId,请先通过小程序登录");
-        }
-        return openid;
+        return (openid != null && !openid.isBlank()) ? openid : null;
     }
 
     /**

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

@@ -49,6 +49,7 @@ public class SecurityConfig {
             "/api/invite/resolve-link",  // 解析邀请链接/邀请码(公开)
             "/invite/**",                // 浏览器直接访问邀请链接(公开)
             "/api/platform-config/**",   // 平台配置查询(公开)
+            "/api/trial-quota/**",       // 体验配额查询(公开)
             "/api/search/suggestions",   // 搜索建议(公开)
             "/api/content/help",          // 帮助FAQ(公开)
             "/api/content/download",      // 下载链接(公开)
@@ -57,6 +58,8 @@ public class SecurityConfig {
             "/api/payment/agreement/notify/**",  // 签约回调通知(公开,供微信调用)
             "/api/payment/alipay/**",  // 支付宝开放能力回调(公开)
             "/api/payment/plans",        // 支付方案列表(公开)
+            "/api/payment/channels",     // 支付渠道列表(公开)
+            "/api/payment/wechat/openid", // 微信小程序获取OpenId(公开)
             "/api/membership/levels",    // 会员等级列表(公开)
             "/api/membership/levels/*",  // 会员等级详情(公开)
             "/error",                    // 错误页面
@@ -121,4 +124,4 @@ public class SecurityConfig {
     public PasswordEncoder passwordEncoder() {
         return new BCryptPasswordEncoder();
     }
-}
+}

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/SmsLoginRequest.java

@@ -22,4 +22,7 @@ public class SmsLoginRequest {
 
     /** 邀请码(可选,新用户注册时传入) */
     private String inviteCode;
+
+    /** 登录来源:PC / MINIAPP(可选,默认PC;小程序调用时传入MINIAPP以获取30天有效期Token) */
+    private String loginSource;
 }

+ 12 - 12
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -115,11 +115,14 @@ public class AuthService {
         // 3. 更新登录信息
         user.setLastLoginTime(LocalDateTime.now());
         user.setLastLoginIp(ip);
-        user.setLoginSource("PC");
+        String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank()
+                ? request.getLoginSource() : "PC";
+        user.setLoginSource(loginSource);
         userMapper.updateById(user);
 
-        // 4. 生成Token
-        LoginResponse response = buildLoginResponse(user);
+        // 4. 生成Token(小程序不递增tokenVersion,不踢PC)
+        boolean incrementVersion = !"MINIAPP".equals(loginSource);
+        LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource);
         response.setUserId(user.getId());
         response.setNewUser(isNewUser);
 
@@ -1172,8 +1175,11 @@ public class AuthService {
             userMapper.updateById(wechatUser);
             wechatUser = existUser;
         } else if (existUser == null) {
-            // 绑定手机号(完成注册)
+            // 绑定手机号 + 补全微信身份标识(完成注册)
             wechatUser.setPhone(phone);
+            if (unionId != null && wechatUser.getWechatUnionId() == null) {
+                wechatUser.setWechatUnionId(unionId);
+            }
             wechatUser.setLoginSource("MINIAPP");
             wechatUser.setLastLoginTime(LocalDateTime.now());
             wechatUser.setLastLoginIp(ip);
@@ -1239,28 +1245,22 @@ public class AuthService {
         return user;
     }
 
-    /** 更新已有用户的微信小程序信息 */
+    /** 更新已有用户的微信小程序信息(始终写入 DB 保存登录时间戳和身份信息) */
     private void updateWechatMiniInfo(User user, String miniOpenId, String unionId,
                                       String phone, String ip) {
-        boolean changed = false;
         if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) {
             user.setWechatMiniOpenId(miniOpenId);
-            changed = true;
         }
         if (unionId != null && !unionId.equals(user.getWechatUnionId())) {
             user.setWechatUnionId(unionId);
-            changed = true;
         }
         if (phone != null && user.getPhone() == null) {
             user.setPhone(phone);
-            changed = true;
         }
         user.setLastLoginTime(LocalDateTime.now());
         user.setLastLoginIp(ip);
         user.setLoginSource("MINIAPP");
-        if (changed) {
-            userMapper.updateById(user);
-        }
+        userMapper.updateById(user);
     }
 
     /** 异步在外部系统注册用户(不阻塞主流程) */