Преглед изворни кода

优化代码,添加接口

liuchengsen пре 1 месец
родитељ
комит
ab26449efe
23 измењених фајлова са 576 додато и 98 уклоњено
  1. 2 2
      zhijiayun-gateway/src/main/resources/db/migration-v2.sql
  2. 22 3
      zhijiayun-gateway/src/main/resources/db/schema.sql
  3. 17 3
      zhijiayun-gateway/src/test/resources/test-schema.sql
  4. 28 8
      zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java
  5. 3 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteCodeResponse.java
  6. 37 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/MyInviterResponse.java
  7. 151 36
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  8. 17 6
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentConfig.java
  9. 4 4
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java
  10. 4 4
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentCallbackController.java
  11. 13 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentController.java
  12. 15 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/SwitchChannelRequest.java
  13. 3 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentOrder.java
  14. 45 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentOrderExtraPayment.java
  15. 12 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/PaymentOrderExtraPaymentMapper.java
  16. 143 24
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java
  17. 8 1
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentPlanService.java
  18. 15 4
      zhijiayun-test/src/main/resources/static/invite-test.html
  19. 27 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  20. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/MembershipResponse.java
  21. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java
  22. 1 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java
  23. 3 3
      zhijiayun-user/src/main/java/com/xuekairui/user/service/UserLevelService.java

+ 2 - 2
zhijiayun-gateway/src/main/resources/db/migration-v2.sql

@@ -22,8 +22,8 @@ INSERT INTO `t_payment_plan` (`plan_code`, `plan_name`, `membership_level`, `pri
 VALUES
 VALUES
     ('MONTHLY_PLUS', '普通会员(免费)',  'PLUS', 0,     NULL,   0,   1, 0, '普通会员免费开放,注册即拥有'),
     ('MONTHLY_PLUS', '普通会员(免费)',  'PLUS', 0,     NULL,   0,   1, 0, '普通会员免费开放,注册即拥有'),
     ('YEARLY_PLUS',  '普通会员(免费)',  'PLUS', 0,     NULL,   0,   2, 0, '普通会员免费开放,注册即拥有'),
     ('YEARLY_PLUS',  '普通会员(免费)',  'PLUS', 0,     NULL,   0,   2, 0, '普通会员免费开放,注册即拥有'),
-    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量(最高999次)· 1个月29.9(原价59.9)'),
-    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量(最高999次)· 1年299.9(原价599.9)'),
+    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量· 1个月29.9(原价59.9)'),
+    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量· 1年299.9(原价599.9)'),
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待')
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待')
 ON DUPLICATE KEY UPDATE
 ON DUPLICATE KEY UPDATE
     `plan_name`        = VALUES(`plan_name`),
     `plan_name`        = VALUES(`plan_name`),

+ 22 - 3
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS `t_user` (
     `password`              VARCHAR(128) DEFAULT NULL COMMENT '密码(加密存储)',
     `password`              VARCHAR(128) DEFAULT NULL COMMENT '密码(加密存储)',
     `wechat_open_id`        VARCHAR(64)  DEFAULT NULL COMMENT '微信OpenID',
     `wechat_open_id`        VARCHAR(64)  DEFAULT NULL COMMENT '微信OpenID',
     `wechat_union_id`       VARCHAR(64)  DEFAULT NULL COMMENT '微信UnionID',
     `wechat_union_id`       VARCHAR(64)  DEFAULT NULL COMMENT '微信UnionID',
+    `wechat_nickname`       VARCHAR(128) DEFAULT NULL COMMENT '微信昵称',
     `pharmacy_name`         VARCHAR(200) DEFAULT NULL COMMENT '药店名称',
     `pharmacy_name`         VARCHAR(200) DEFAULT NULL COMMENT '药店名称',
     `province`              VARCHAR(50)  DEFAULT NULL COMMENT '省',
     `province`              VARCHAR(50)  DEFAULT NULL COMMENT '省',
     `city`                  VARCHAR(50)  DEFAULT NULL COMMENT '市',
     `city`                  VARCHAR(50)  DEFAULT NULL COMMENT '市',
@@ -224,7 +225,7 @@ VALUES
     ('PLUS', 'DAILY_QUOTA', 10, '普通会员:每日有效查询10次'),
     ('PLUS', 'DAILY_QUOTA', 10, '普通会员:每日有效查询10次'),
     ('PLUS', 'MONTHLY_QUOTA', 300, '普通会员:每月查询300次'),
     ('PLUS', 'MONTHLY_QUOTA', 300, '普通会员:每月查询300次'),
     ('PLUS', 'YEARLY_QUOTA', 3650, '普通会员:每年查询3650次(10*365)'),
     ('PLUS', 'YEARLY_QUOTA', 3650, '普通会员:每年查询3650次(10*365)'),
-    ('PRO', 'DAILY_QUOTA', 999, '高级会员:每日不限量(最高999次)'),
+    ('PRO', 'DAILY_QUOTA', 999, '高级会员:每日不限量'),
     ('PRO', 'MONTHLY_QUOTA', 29970, '高级会员:每月查询29970次(999*30)'),
     ('PRO', 'MONTHLY_QUOTA', 29970, '高级会员:每月查询29970次(999*30)'),
     ('PRO', 'YEARLY_QUOTA', 364635, '高级会员:每年查询364635次(999*365)'),
     ('PRO', 'YEARLY_QUOTA', 364635, '高级会员:每年查询364635次(999*365)'),
     ('PRO', 'MAX_CONCURRENT', 5, '高级会员最大并发数'),
     ('PRO', 'MAX_CONCURRENT', 5, '高级会员最大并发数'),
@@ -270,6 +271,7 @@ CREATE TABLE IF NOT EXISTS `t_payment_order` (
     `channel`               VARCHAR(20)   NOT NULL COMMENT '支付渠道:WECHAT/ALIPAY',
     `channel`               VARCHAR(20)   NOT NULL COMMENT '支付渠道:WECHAT/ALIPAY',
     `status`                VARCHAR(20)   NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/PAID/EXPIRED/REFUNDED',
     `status`                VARCHAR(20)   NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/PAID/EXPIRED/REFUNDED',
     `qr_code_url`           VARCHAR(2048) DEFAULT NULL COMMENT '二维码内容/URL(支付宝电脑网站支付为收银台跳转URL)',
     `qr_code_url`           VARCHAR(2048) DEFAULT NULL COMMENT '二维码内容/URL(支付宝电脑网站支付为收银台跳转URL)',
+    `paid_channel`          VARCHAR(20)   DEFAULT NULL COMMENT '实际支付渠道:第一次支付成功时的渠道(可能与当前channel不同)',
     `trade_no`              VARCHAR(64)   DEFAULT NULL COMMENT '第三方交易号',
     `trade_no`              VARCHAR(64)   DEFAULT NULL COMMENT '第三方交易号',
     `paid_time`             DATETIME      DEFAULT NULL COMMENT '支付时间',
     `paid_time`             DATETIME      DEFAULT NULL COMMENT '支付时间',
     `expire_time`           DATETIME      NOT NULL COMMENT '订单过期时间',
     `expire_time`           DATETIME      NOT NULL COMMENT '订单过期时间',
@@ -294,6 +296,23 @@ SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_S
 SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_payment_order` ADD COLUMN `agreement_id` BIGINT DEFAULT NULL COMMENT '关联签约协议ID' AFTER `order_type`", 'SELECT 1');
 SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_payment_order` ADD COLUMN `agreement_id` BIGINT DEFAULT NULL COMMENT '关联签约协议ID' AFTER `order_type`", 'SELECT 1');
 PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
 
 
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_payment_order' AND COLUMN_NAME = 'paid_channel');
+SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_payment_order` ADD COLUMN `paid_channel` VARCHAR(20) DEFAULT NULL COMMENT '实际支付渠道:第一次支付成功时的渠道' AFTER `qr_code_url`", 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 支付订单重复支付记录表(切换渠道后多个渠道都支付成功时保留证据)
+CREATE TABLE IF NOT EXISTS `t_payment_order_extra_payment` (
+    `id`          BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `order_no`    VARCHAR(32)   NOT NULL COMMENT '订单号',
+    `channel`     VARCHAR(20)   NOT NULL COMMENT '实际支付渠道:WECHAT/ALIPAY',
+    `trade_no`    VARCHAR(64)   NOT NULL COMMENT '第三方交易号',
+    `amount`      DECIMAL(10,2) NOT NULL COMMENT '支付金额(元)',
+    `paid_time`   DATETIME      NOT NULL COMMENT '支付时间',
+    `create_time` DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_order_no` (`order_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单重复支付记录表';
+
 -- 签约协议表(周期扣款/委托代扣)
 -- 签约协议表(周期扣款/委托代扣)
 CREATE TABLE IF NOT EXISTS `t_payment_agreement` (
 CREATE TABLE IF NOT EXISTS `t_payment_agreement` (
     `id`                        BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
     `id`                        BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -326,8 +345,8 @@ INSERT INTO `t_payment_plan` (`plan_code`, `plan_name`, `membership_level`, `pri
 VALUES
 VALUES
     ('MONTHLY_PLUS', '普通会员(免费)',  'PLUS', 0,     NULL,   0,   1, 0, '普通会员免费开放,注册即拥有'),
     ('MONTHLY_PLUS', '普通会员(免费)',  'PLUS', 0,     NULL,   0,   1, 0, '普通会员免费开放,注册即拥有'),
     ('YEARLY_PLUS',  '普通会员(免费)',  'PLUS', 0,     NULL,   0,   2, 0, '普通会员免费开放,注册即拥有'),
     ('YEARLY_PLUS',  '普通会员(免费)',  'PLUS', 0,     NULL,   0,   2, 0, '普通会员免费开放,注册即拥有'),
-    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量(最高999次)· 1个月29.9(原价59.9)'),
-    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量(最高999次)· 1年299.9(原价599.9)'),
+    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量· 1个月29.9(原价59.9)'),
+    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量· 1年299.9(原价599.9)'),
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待')
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待')
 ON DUPLICATE KEY UPDATE
 ON DUPLICATE KEY UPDATE
     `plan_name`        = VALUES(`plan_name`),
     `plan_name`        = VALUES(`plan_name`),

+ 17 - 3
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS t_user (
     password        VARCHAR(128)  DEFAULT NULL,
     password        VARCHAR(128)  DEFAULT NULL,
     wechat_open_id  VARCHAR(64)   DEFAULT NULL,
     wechat_open_id  VARCHAR(64)   DEFAULT NULL,
     wechat_union_id VARCHAR(64)   DEFAULT NULL,
     wechat_union_id VARCHAR(64)   DEFAULT NULL,
+    wechat_nickname VARCHAR(128)  DEFAULT NULL,
     pharmacy_name   VARCHAR(200)  DEFAULT NULL,
     pharmacy_name   VARCHAR(200)  DEFAULT NULL,
     province        VARCHAR(50)   DEFAULT NULL,
     province        VARCHAR(50)   DEFAULT NULL,
     city            VARCHAR(50)   DEFAULT NULL,
     city            VARCHAR(50)   DEFAULT NULL,
@@ -394,7 +395,7 @@ VALUES
     ('PLUS', 'DAILY_QUOTA', 10, '普通会员:每日有效查询10次', 1),
     ('PLUS', 'DAILY_QUOTA', 10, '普通会员:每日有效查询10次', 1),
     ('PLUS', 'MONTHLY_QUOTA', 300, '普通会员:每月查询300次', 1),
     ('PLUS', 'MONTHLY_QUOTA', 300, '普通会员:每月查询300次', 1),
     ('PLUS', 'YEARLY_QUOTA', 3650, '普通会员:每年查询3650次(10*365)', 1),
     ('PLUS', 'YEARLY_QUOTA', 3650, '普通会员:每年查询3650次(10*365)', 1),
-    ('PRO', 'DAILY_QUOTA', 999, '高级会员:每日不限量(最高999次)', 1),
+    ('PRO', 'DAILY_QUOTA', 999, '高级会员:每日不限量', 1),
     ('PRO', 'MONTHLY_QUOTA', 29970, '高级会员:每月查询29970次(999*30)', 1),
     ('PRO', 'MONTHLY_QUOTA', 29970, '高级会员:每月查询29970次(999*30)', 1),
     ('PRO', 'YEARLY_QUOTA', 364635, '高级会员:每年查询364635次(999*365)', 1),
     ('PRO', 'YEARLY_QUOTA', 364635, '高级会员:每年查询364635次(999*365)', 1),
     ('PRO', 'MAX_CONCURRENT', 5, '高级会员最大并发数', 1),
     ('PRO', 'MAX_CONCURRENT', 5, '高级会员最大并发数', 1),
@@ -462,6 +463,7 @@ CREATE TABLE IF NOT EXISTS t_payment_order (
     channel              VARCHAR(20)   NOT NULL,
     channel              VARCHAR(20)   NOT NULL,
     status               VARCHAR(20)   NOT NULL DEFAULT 'PENDING',
     status               VARCHAR(20)   NOT NULL DEFAULT 'PENDING',
     qr_code_url          VARCHAR(512)  DEFAULT NULL,
     qr_code_url          VARCHAR(512)  DEFAULT NULL,
+    paid_channel         VARCHAR(20)   DEFAULT NULL,
     trade_no             VARCHAR(64)   DEFAULT NULL,
     trade_no             VARCHAR(64)   DEFAULT NULL,
     paid_time            TIMESTAMP     DEFAULT NULL,
     paid_time            TIMESTAMP     DEFAULT NULL,
     expire_time          TIMESTAMP     NOT NULL,
     expire_time          TIMESTAMP     NOT NULL,
@@ -476,10 +478,22 @@ CREATE TABLE IF NOT EXISTS t_payment_order (
     INDEX idx_expire_time (expire_time)
     INDEX idx_expire_time (expire_time)
 );
 );
 
 
+-- 支付订单重复支付记录表
+CREATE TABLE IF NOT EXISTS t_payment_order_extra_payment (
+    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_no    VARCHAR(32)   NOT NULL,
+    channel     VARCHAR(20)   NOT NULL,
+    trade_no    VARCHAR(64)   NOT NULL,
+    amount      DECIMAL(10,2) NOT NULL,
+    paid_time   TIMESTAMP     NOT NULL,
+    create_time TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_order_no (order_no)
+);
+
 -- 初始化支付方案(含原价)
 -- 初始化支付方案(含原价)
 INSERT IGNORE INTO t_payment_plan (plan_code, plan_name, membership_level, price, original_price, duration_days, sort_order, status, description) VALUES
 INSERT IGNORE INTO t_payment_plan (plan_code, plan_name, membership_level, price, original_price, duration_days, sort_order, status, description) VALUES
     ('MONTHLY_PLUS', '普通会员·月卡',  'PLUS', 9.90,  19.90,  30,  1, 1, '每天有效查询10次 · 1个月9.9(原价19.9)'),
     ('MONTHLY_PLUS', '普通会员·月卡',  'PLUS', 9.90,  19.90,  30,  1, 1, '每天有效查询10次 · 1个月9.9(原价19.9)'),
     ('YEARLY_PLUS',  '普通会员·年卡',  'PLUS', 99.00, 199.90, 365, 2, 1, '每天有效查询10次 · 1年99(原价199.9)'),
     ('YEARLY_PLUS',  '普通会员·年卡',  'PLUS', 99.00, 199.90, 365, 2, 1, '每天有效查询10次 · 1年99(原价199.9)'),
-    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量(最高999次)· 1个月29.9(原价59.9)'),
-    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量(最高999次)· 1年299.9(原价599.9)'),
+    ('MONTHLY_PRO',  '高级会员·月卡',  'PRO',  29.90, 59.90,  30,  3, 1, '每天不限量· 1个月29.9(原价59.9)'),
+    ('YEARLY_PRO',   '高级会员·年卡',  'PRO',  299.90,599.90, 365, 4, 1, '每天不限量· 1年299.9(原价599.9)'),
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待');
     ('ULTRA',        '超级会员·连锁专享','ULTRA',0,     NULL,   0,   5, 0, '连锁专用,多店铺管理,敬请期待');

+ 28 - 8
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java

@@ -5,8 +5,11 @@ import com.xuekairui.invite.dto.*;
 import com.xuekairui.invite.service.InviteService;
 import com.xuekairui.invite.service.InviteService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
+import java.net.URI;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
@@ -50,6 +53,16 @@ public class InviteController {
         return Result.success(inviteService.getInviteRewards(userId));
         return Result.success(inviteService.getInviteRewards(userId));
     }
     }
 
 
+    /**
+     * 查询是谁邀请了我(被邀请人视角)
+     * GET /api/invite/my-inviter
+     */
+    @GetMapping("/my-inviter")
+    public Result<MyInviterResponse> getMyInviter(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(inviteService.getMyInviter(userId));
+    }
+
     /**
     /**
      * 获取可用额外爬虫次数(邀请奖励 + 运营发放)
      * 获取可用额外爬虫次数(邀请奖励 + 运营发放)
      * GET /api/invite/extra-quota
      * GET /api/invite/extra-quota
@@ -86,21 +99,28 @@ public class InviteController {
     @GetMapping("/page/{code}")
     @GetMapping("/page/{code}")
     public Result<InvitePageResponse> getInvitePage(
     public Result<InvitePageResponse> getInvitePage(
             @PathVariable String code,
             @PathVariable String code,
-            @RequestParam(required = false) String channel) {
-        return Result.success(inviteService.getInvitePage(code, channel));
+            @RequestParam(required = false) String channel,
+            HttpServletRequest request) {
+        String userAgent = request.getHeader("User-Agent");
+        return Result.success(inviteService.getInvitePage(code, channel, userAgent));
     }
     }
 
 
     /**
     /**
      * 浏览器直接访问邀请链接入口(公开,无需登录)
      * 浏览器直接访问邀请链接入口(公开,无需登录)
-     * 自动计入点击数,返回落地页数据
-     * GET /invite/{code}              (默认 windows 渠道)
-     * GET /invite/{code}?channel=wechat
+     * 自动计入点击数,并根据访问设备直接重定向到对应桌面客户端下载包
+     * GET /api/invite/{code}
+     * Windows x64 -> /desktop-updates/智价云-x64.exe
+     * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
      */
      */
     @GetMapping("/{code}")
     @GetMapping("/{code}")
-    public Result<InvitePageResponse> openInviteLink(
+    public ResponseEntity<Void> openInviteLink(
             @PathVariable String code,
             @PathVariable String code,
-            @RequestParam(required = false) String channel) {
-        return Result.success(inviteService.getInvitePage(code, channel));
+            HttpServletRequest request) {
+        String userAgent = request.getHeader("User-Agent");
+        String downloadUrl = inviteService.resolveDownloadUrl(code, userAgent);
+        return ResponseEntity.status(HttpStatus.FOUND)
+                .location(URI.create(downloadUrl))
+                .build();
     }
     }
 
 
     /**
     /**

+ 3 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteCodeResponse.java

@@ -52,4 +52,7 @@ public class InviteCodeResponse {
 
 
     /** 奖励说明 */
     /** 奖励说明 */
     private String rewardDescription;
     private String rewardDescription;
+
+    /** 下载链接(与 inviteLink 一致,专门用于转发下载) */
+    private String downloadUrl;
 }
 }

+ 37 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/MyInviterResponse.java

@@ -0,0 +1,37 @@
+package com.xuekairui.invite.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 我的邀请人响应
+ * 被邀请人查询是谁邀请了自己
+ */
+@Data
+@Builder
+public class MyInviterResponse {
+
+    /** 是否被邀请过 */
+    private Boolean invited;
+
+    /** 邀请人ID */
+    private Long inviterId;
+
+    /** 邀请人昵称 */
+    private String inviterNickname;
+
+    /** 邀请人手机号(脱敏) */
+    private String inviterPhone;
+
+    /** 邀请人头像 */
+    private String inviterAvatar;
+
+    /** 邀请人药店名称 */
+    private String pharmacyName;
+
+    /** 邀请码 */
+    private String inviteCode;
+
+    /** 邀请时间 */
+    private String inviteTime;
+}

+ 151 - 36
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -49,6 +49,10 @@ public class InviteService {
     @Value("${invite.base-url:https://app.zhijiayun.com}")
     @Value("${invite.base-url:https://app.zhijiayun.com}")
     private String inviteBaseUrl;
     private String inviteBaseUrl;
 
 
+    /** 桌面客户端下载基础URL,为空时自动从 inviteBaseUrl 推断域名 */
+    @Value("${invite.desktop-download-base-url:}")
+    private String desktopDownloadBaseUrl;
+
     /** 补填邀请码有效窗口(天),默认30天 */
     /** 补填邀请码有效窗口(天),默认30天 */
     @Value("${invite.bind-window-days:30}")
     @Value("${invite.bind-window-days:30}")
     private int bindWindowDays;
     private int bindWindowDays;
@@ -104,10 +108,11 @@ public class InviteService {
      * 浏览器打开邀请链接时调用,展示邀请人信息和下载链接
      * 浏览器打开邀请链接时调用,展示邀请人信息和下载链接
      * 支持多渠道:app、wechat、miniapp、dingtalk、feishu
      * 支持多渠道:app、wechat、miniapp、dingtalk、feishu
      *
      *
-     * @param code    邀请码
-     * @param channel 渠道(可选,默认app)
+     * @param code       邀请码
+     * @param channel    渠道(可选,默认app)
+     * @param userAgent  浏览器 User-Agent,用于判断桌面客户端下载包
      */
      */
-    public InvitePageResponse getInvitePage(String code, String channel) {
+    public InvitePageResponse getInvitePage(String code, String channel, String userAgent) {
         InviteCode inviteCode = inviteCodeMapper.selectOne(
         InviteCode inviteCode = inviteCodeMapper.selectOne(
                 new LambdaQueryWrapper<InviteCode>()
                 new LambdaQueryWrapper<InviteCode>()
                         .eq(InviteCode::getCode, code));
                         .eq(InviteCode::getCode, code));
@@ -151,7 +156,15 @@ public class InviteService {
         
         
         // 根据渠道生成提示文案
         // 根据渠道生成提示文案
         String instructionText = generateInstructionText(code, channel, config);
         String instructionText = generateInstructionText(code, channel, config);
-        
+
+        // 落地页下载按钮指向 /api/invite/{code},访问时会再次记录点击并自动重定向到对应安装包
+        String inviteDownloadUrl = inviteBaseUrl + "/invite/" + code;
+
+        // Windows/app 渠道使用邀请链接作为下载入口(自动识别设备并重定向),其他渠道保持配置中的下载地址
+        String appDownloadUrl = ("windows".equals(channel) || "app".equals(channel))
+                ? inviteDownloadUrl
+                : config.getAppDownloadUrl();
+
         // 处理落地页标题:支持 {inviter} 和 {pharmacy} 占位符
         // 处理落地页标题:支持 {inviter} 和 {pharmacy} 占位符
         String landingTitle = config.getLandingTitle();
         String landingTitle = config.getLandingTitle();
         if (landingTitle == null || landingTitle.isBlank()) {
         if (landingTitle == null || landingTitle.isBlank()) {
@@ -183,7 +196,7 @@ public class InviteService {
                 .landingTitle(landingTitle)
                 .landingTitle(landingTitle)
                 .landingDesc(config.getLandingDesc())
                 .landingDesc(config.getLandingDesc())
                 // 渠道特定字段
                 // 渠道特定字段
-                .appDownloadUrl(config.getAppDownloadUrl())
+                .appDownloadUrl(appDownloadUrl)
                 .miniappPath(miniappPath)
                 .miniappPath(miniappPath)
                 .miniappAppId(config.getMiniappAppId())
                 .miniappAppId(config.getMiniappAppId())
                 .wechatRedirectUrl(config.getWechatRedirectUrl())
                 .wechatRedirectUrl(config.getWechatRedirectUrl())
@@ -232,6 +245,60 @@ public class InviteService {
         };
         };
     }
     }
 
 
+    /**
+     * 解析桌面客户端下载完整URL(公开,无需登录)
+     * 根据 User-Agent 判断系统架构,返回对应下载包地址
+     */
+    public String resolveDownloadUrl(String code, String userAgent) {
+        // 校验邀请码存在性(可选:不存在也允许下载,这里只做简单校验并记录点击)
+        InviteCode inviteCode = inviteCodeMapper.selectOne(
+                new LambdaQueryWrapper<InviteCode>()
+                        .eq(InviteCode::getCode, code));
+        if (inviteCode != null) {
+            inviteCode.setClickCount(inviteCode.getClickCount() + 1);
+            inviteCodeMapper.updateById(inviteCode);
+            log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
+        }
+        return resolveDesktopDownloadUrl(userAgent);
+    }
+
+    /**
+     * 根据 User-Agent 判断系统并返回桌面客户端下载地址
+     * Windows x64 -> /desktop-updates/智价云-x64.exe
+     * macOS ARM64 -> /desktop-updates/智价云-arm64.dmg
+     * 其他默认返回 Windows x64 包
+     */
+    private String resolveDesktopDownloadUrl(String userAgent) {
+        String baseUrl = getDesktopDownloadBaseUrl();
+        String path = "/desktop-updates/智价云-x64.exe";
+        if (userAgent != null) {
+            String ua = userAgent.toLowerCase();
+            boolean isMac = ua.contains("macintosh") || ua.contains("mac os");
+            boolean isArm = ua.contains("arm64") || ua.contains("aarch64");
+            if (isMac && isArm) {
+                path = "/desktop-updates/智价云-arm64.dmg";
+            }
+        }
+        return baseUrl + path;
+    }
+
+    /**
+     * 获取桌面客户端下载基础URL
+     * 优先使用配置 invite.desktop-download-base-url,未配置时从 inviteBaseUrl 推断域名根地址
+     */
+    private String getDesktopDownloadBaseUrl() {
+        if (desktopDownloadBaseUrl != null && !desktopDownloadBaseUrl.isBlank()) {
+            return desktopDownloadBaseUrl.replaceAll("/$", "");
+        }
+        try {
+            java.net.URL url = new java.net.URL(inviteBaseUrl);
+            return url.getProtocol() + "://" + url.getAuthority();
+        } catch (Exception e) {
+            log.warn("无法从 inviteBaseUrl 推断下载域名: {}", inviteBaseUrl, e);
+            return inviteBaseUrl.replaceAll("/$", "");
+        }
+    }
+
     /**
     /**
      * 追踪邀请链接被打开(公开,浏览器打开时调用)
      * 追踪邀请链接被打开(公开,浏览器打开时调用)
      * 记录到 invite_code.click_count
      * 记录到 invite_code.click_count
@@ -414,24 +481,25 @@ public class InviteService {
             return;
             return;
         }
         }
 
 
-        // 5.1 检查邀请人总邀请人数上限
+        // 5.1 检查邀请人总邀请奖励上限(仍可继续邀请,但超过上限后不再发放奖励)
         Long totalInvited = inviteRelationMapper.selectCount(
         Long totalInvited = inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
                 new LambdaQueryWrapper<InviteRelation>()
                         .eq(InviteRelation::getInviterId, inviterId)
                         .eq(InviteRelation::getInviterId, inviterId)
                         .eq(InviteRelation::getRegistered, 1));
                         .eq(InviteRelation::getRegistered, 1));
         int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
         int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
-        if (totalInvited >= maxTotal) {
-            log.warn("邀请人{}已达邀请上限{}人", inviterId, maxTotal);
-            return;
+        boolean rewardAllowed = totalInvited < maxTotal;
+        if (!rewardAllowed) {
+            log.warn("邀请人{}已达会员时长奖励上限{}人,继续邀请不再发放奖励", inviterId, maxTotal);
         }
         }
 
 
         // 6. 创建邀请关系(直接标记为已注册,奖励类型为赠送会员时长)
         // 6. 创建邀请关系(直接标记为已注册,奖励类型为赠送会员时长)
+        Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
         InviteRelation relation = InviteRelation.builder()
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
                 .inviterId(inviterId)
                 .inviteeId(inviteeId)
                 .inviteeId(inviteeId)
                 .inviteCodeId(inviteCode.getId())
                 .inviteCodeId(inviteCode.getId())
                 .registered(1)
                 .registered(1)
-                .rewardGranted(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
+                .rewardGranted(rewardAllowed ? rewardMonths : 0)
                 .status(1)
                 .status(1)
                 .build();
                 .build();
         inviteRelationMapper.insert(relation);
         inviteRelationMapper.insert(relation);
@@ -440,10 +508,13 @@ public class InviteService {
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCodeMapper.updateById(inviteCode);
         inviteCodeMapper.updateById(inviteCode);
 
 
-        // 8. 给邀请人发放奖励(赠送30天高级会员)
-        grantInviteReward(inviterId, inviteeId);
-
-        log.info("邀请成功: 用户 {} 邀请了用户 {},邀请人获赠30天高级会员", inviterId, inviteeId);
+        // 8. 给邀请人发放奖励(在奖励上限内赠送30天会员时长)
+        if (rewardAllowed) {
+            grantInviteReward(inviterId, inviteeId);
+            log.info("邀请成功: 用户 {} 邀请了用户 {},邀请人获赠30天会员时长", inviterId, inviteeId);
+        } else {
+            log.info("邀请成功: 用户 {} 邀请了用户 {},已达奖励上限,不发放会员时长", inviterId, inviteeId);
+        }
     }
     }
 
 
     /**
     /**
@@ -517,24 +588,22 @@ public class InviteService {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
         }
         }
 
 
-        // 7.1 检查邀请人总邀请人数上限
+        // 7.1 检查邀请人总邀请奖励上限(仍可继续绑定,但超过上限后不再发放奖励)
         Long totalInvited = inviteRelationMapper.selectCount(
         Long totalInvited = inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
                 new LambdaQueryWrapper<InviteRelation>()
                         .eq(InviteRelation::getInviterId, inviterId)
                         .eq(InviteRelation::getInviterId, inviterId)
                         .eq(InviteRelation::getRegistered, 1));
                         .eq(InviteRelation::getRegistered, 1));
         int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
         int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
-        if (totalInvited >= maxTotal) {
-            throw new BusinessException(ErrorCode.INVITE_LIMIT_REACHED,
-                    String.format("邀请人已达邀请上限%d人", maxTotal));
-        }
+        boolean rewardAllowed = totalInvited < maxTotal;
 
 
         // 8. 创建邀请关系
         // 8. 创建邀请关系
+        Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
         InviteRelation relation = InviteRelation.builder()
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
                 .inviterId(inviterId)
                 .inviteeId(userId)
                 .inviteeId(userId)
                 .inviteCodeId(inviteCode.getId())
                 .inviteCodeId(inviteCode.getId())
                 .registered(1)
                 .registered(1)
-                .rewardGranted(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
+                .rewardGranted(rewardAllowed ? rewardMonths : 0)
                 .status(1)
                 .status(1)
                 .build();
                 .build();
         inviteRelationMapper.insert(relation);
         inviteRelationMapper.insert(relation);
@@ -543,13 +612,17 @@ public class InviteService {
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
         inviteCodeMapper.updateById(inviteCode);
         inviteCodeMapper.updateById(inviteCode);
 
 
-        // 10. 发放奖励:邀请人30天高级会员
-        membershipService.grantInviteRewardMembership(inviterId, userId);
-
-        log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},邀请人获赠30天高级会员",
-                userId, inviteCodeStr, inviterId);
-
-        return "补填邀请码成功!邀请人获赠1个月高级会员时长";
+        // 10. 发放奖励:邀请人30天会员时长(在奖励上限内)
+        if (rewardAllowed) {
+            membershipService.grantInviteRewardMembership(inviterId, userId);
+            log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},邀请人获赠30天会员时长",
+                    userId, inviteCodeStr, inviterId);
+            return "补填邀请码成功!邀请人获赠1个月会员时长";
+        } else {
+            log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},已达奖励上限,不发放会员时长",
+                    userId, inviteCodeStr, inviterId);
+            return "补填邀请码成功!邀请人已达奖励上限,本次不发放会员时长";
+        }
     }
     }
 
 
     /**
     /**
@@ -667,6 +740,41 @@ public class InviteService {
         }).collect(Collectors.toList());
         }).collect(Collectors.toList());
     }
     }
 
 
+    /**
+     * 查询是谁邀请了我(被邀请人视角)
+     * 从 t_invite_relation 中查找 inviteeId = 当前用户的记录
+     */
+    public MyInviterResponse getMyInviter(Long userId) {
+        InviteRelation relation = inviteRelationMapper.selectOne(
+                new LambdaQueryWrapper<InviteRelation>()
+                        .eq(InviteRelation::getInviteeId, userId)
+                        .orderByDesc(InviteRelation::getCreateTime)
+                        .last("LIMIT 1"));
+
+        if (relation == null) {
+            return MyInviterResponse.builder()
+                    .invited(false)
+                    .build();
+        }
+
+        User inviter = userMapper.selectById(relation.getInviterId());
+        InviteCode inviteCode = relation.getInviteCodeId() != null
+                ? inviteCodeMapper.selectById(relation.getInviteCodeId())
+                : null;
+
+        return MyInviterResponse.builder()
+                .invited(true)
+                .inviterId(relation.getInviterId())
+                .inviterNickname(getDisplayName(inviter))
+                .inviterPhone(inviter != null ? ValidateUtil.maskPhone(inviter.getPhone()) : null)
+                .inviterAvatar(inviter != null ? inviter.getAvatar() : null)
+                .pharmacyName(inviter != null ? inviter.getPharmacyName() : null)
+                .inviteCode(inviteCode != null ? inviteCode.getCode() : null)
+                .inviteTime(relation.getCreateTime() != null ?
+                        relation.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null)
+                .build();
+    }
+
     /**
     /**
      * 获取用户可用的额外爬虫次数
      * 获取用户可用的额外爬虫次数
      */
      */
@@ -711,12 +819,16 @@ public class InviteService {
      * 获取用户昵称
      * 获取用户昵称
      */
      */
     /**
     /**
-     * 获取用户对外展示名:优先用昵称,其次用手机尾号,都没有则用兜底文案
+     * 获取用户对外展示名:
+     * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案
      */
      */
     private String getDisplayName(User user) {
     private String getDisplayName(User user) {
         if (user == null) {
         if (user == null) {
             return "一位药店用户";
             return "一位药店用户";
         }
         }
+        if (user.getWechatNickname() != null && !user.getWechatNickname().isBlank()) {
+            return user.getWechatNickname();
+        }
         if (user.getNickname() != null && !user.getNickname().isBlank()) {
         if (user.getNickname() != null && !user.getNickname().isBlank()) {
             return user.getNickname();
             return user.getNickname();
         }
         }
@@ -745,26 +857,29 @@ public class InviteService {
 
 
         // 生成分享文案(邀请人 + 产品价值 + 操作指引)
         // 生成分享文案(邀请人 + 产品价值 + 操作指引)
         // 分享标题:让接收方一眼知道是谁邀请的
         // 分享标题:让接收方一眼知道是谁邀请的
-        String shareTitle = inviterNickname + "邀请你加入" + config.getAppName();
+        String shareTitle = inviterNickname + " 邀请你加入" + config.getAppName();
         // 分享描述:说明产品核心价值(绑定B2B平台→聚合查价)
         // 分享描述:说明产品核心价值(绑定B2B平台→聚合查价)
         String shareDescription = config.getLandingDesc() != null
         String shareDescription = config.getLandingDesc() != null
                 && !config.getLandingDesc().isBlank()
                 && !config.getLandingDesc().isBlank()
-                ? config.getLandingDesc() : "登录智价云(药店端)平台,聚合比价查低价";
+                ? config.getLandingDesc() : "注册登录" + config.getAppName() + "平台,聚合比价查低价";
         String rewardDesc = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长";
         String rewardDesc = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长";
 
 
-        // 复制文本
+        // 邀请链接即下载入口,访问后根据对方设备自动重定向到对应安装包
+        String downloadLink = inviteLink;
+
+        // 复制文本:与示例文案保持一致,便于用户一键转发
         String copyText = String.format(
         String copyText = String.format(
-            "%s\n%s\n\n邀请码:%s\n%s\n\n点击链接下载:\n%s",
-            shareTitle,
-            shareDescription,
+            "%s,我在这里发现了一个药店采购神器——%s,咱们一起用起来吧!它聚合比价功能特别方便,能快速查到低价,帮你节省采购成本。用我的邀请码 %s 注册,你会获取到会员权益,咱们平台上见!下载链接%s",
+            inviterNickname,
+            config.getAppName(),
             code.getCode(),
             code.getCode(),
-            rewardDesc,
-            inviteLink
+            downloadLink
         );
         );
 
 
         return InviteCodeResponse.builder()
         return InviteCodeResponse.builder()
                 .code(code.getCode())
                 .code(code.getCode())
                 .inviteLink(inviteLink)
                 .inviteLink(inviteLink)
+                .downloadUrl(inviteLink)
                 .inviterNickname(inviterNickname)
                 .inviterNickname(inviterNickname)
                 .clickCount(code.getClickCount() != null ? code.getClickCount() : 0)
                 .clickCount(code.getClickCount() != null ? code.getClickCount() : 0)
                 .usedCount(code.getUsedCount())
                 .usedCount(code.getUsedCount())

+ 17 - 6
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentConfig.java

@@ -101,6 +101,11 @@ public class PaymentConfig {
     public AlipayClient alipayClient() throws Exception {
     public AlipayClient alipayClient() throws Exception {
         PaymentProperties.Alipay al = paymentProperties.getAlipay();
         PaymentProperties.Alipay al = paymentProperties.getAlipay();
 
 
+        if (al.getAppId() == null || al.getAppId().isBlank()) {
+            log.warn("未配置 payment.alipay.app-id,跳过支付宝客户端初始化");
+            return null;
+        }
+
         // 公钥模式(优先):配置了支付宝公钥即走公钥模式
         // 公钥模式(优先):配置了支付宝公钥即走公钥模式
         if (al.getAlipayPublicKey() != null && !al.getAlipayPublicKey().isBlank()) {
         if (al.getAlipayPublicKey() != null && !al.getAlipayPublicKey().isBlank()) {
             log.info("初始化支付宝客户端(公钥模式): appId={}, serverUrl={}, notifyUrl={}",
             log.info("初始化支付宝客户端(公钥模式): appId={}, serverUrl={}, notifyUrl={}",
@@ -152,8 +157,8 @@ public class PaymentConfig {
             return new DefaultAlipayClient(certRequest);
             return new DefaultAlipayClient(certRequest);
         }
         }
 
 
-        throw new IllegalStateException(
-                "支付宝配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key)");
+        log.warn("支付宝配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key),跳过支付宝客户端初始化");
+        return null;
     }
     }
 
 
     // ======================== 安心付支付宝客户端(小程序应用) ========================
     // ======================== 安心付支付宝客户端(小程序应用) ========================
@@ -173,8 +178,8 @@ public class PaymentConfig {
         PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
         PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
 
 
         if (ax.getAppId() == null || ax.getAppId().isBlank()) {
         if (ax.getAppId() == null || ax.getAppId().isBlank()) {
-            throw new IllegalStateException(
-                    "安心付配置不完整:需配置 payment.anxin-alipay.app-id(支付宝小程序应用ID)");
+            log.warn("未配置 payment.anxin-alipay.app-id,跳过安心付支付宝客户端初始化");
+            return null;
         }
         }
 
 
         // 公钥模式(优先)
         // 公钥模式(优先)
@@ -225,8 +230,8 @@ public class PaymentConfig {
             return new DefaultAlipayClient(certRequest);
             return new DefaultAlipayClient(certRequest);
         }
         }
 
 
-        throw new IllegalStateException(
-                "安心付配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key)");
+        log.warn("安心付配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key),跳过安心付支付宝客户端初始化");
+        return null;
     }
     }
 
 
     /**
     /**
@@ -249,6 +254,12 @@ public class PaymentConfig {
     @ConditionalOnProperty(prefix = "payment", name = "mock", havingValue = "false")
     @ConditionalOnProperty(prefix = "payment", name = "mock", havingValue = "false")
     public WxPayService wxPayService() {
     public WxPayService wxPayService() {
         PaymentProperties.Wechat wc = paymentProperties.getWechat();
         PaymentProperties.Wechat wc = paymentProperties.getWechat();
+
+        if (wc.getAppId() == null || wc.getAppId().isBlank() || wc.getMchId() == null || wc.getMchId().isBlank()) {
+            log.warn("未配置 payment.wechat.app-id 或 payment.wechat.mch-id,跳过微信支付客户端初始化");
+            return null;
+        }
+
         log.info("初始化微信支付客户端: appId={}, mchId={}, notifyUrl={}",
         log.info("初始化微信支付客户端: appId={}, mchId={}, notifyUrl={}",
                 wc.getAppId(), wc.getMchId(), wc.getNotifyUrl());
                 wc.getAppId(), wc.getMchId(), wc.getNotifyUrl());
 
 

+ 4 - 4
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java

@@ -50,9 +50,9 @@ public class PaymentProperties {
     @Data
     @Data
     public static class Wechat {
     public static class Wechat {
         /** 微信公众号/小程序 appId */
         /** 微信公众号/小程序 appId */
-        private String appId;
+        private String appId = "";
         /** 商户号 mchId */
         /** 商户号 mchId */
-        private String mchId;
+        private String mchId = "";
         /** API v3 密钥 */
         /** API v3 密钥 */
         private String apiV3Key;
         private String apiV3Key;
         /** 商户私钥文件路径(apiclient_key.pem) */
         /** 商户私钥文件路径(apiclient_key.pem) */
@@ -89,7 +89,7 @@ public class PaymentProperties {
     @Data
     @Data
     public static class Alipay {
     public static class Alipay {
         /** 支付宝应用 appId */
         /** 支付宝应用 appId */
-        private String appId;
+        private String appId = "";
         /** 应用私钥(RSA2),公钥模式时填写应用私钥字符串 */
         /** 应用私钥(RSA2),公钥模式时填写应用私钥字符串 */
         private String appPrivateKey;
         private String appPrivateKey;
         /** 应用私钥文件路径,证书模式时填写(设置后自动读取文件内容作为 appPrivateKey) */
         /** 应用私钥文件路径,证书模式时填写(设置后自动读取文件内容作为 appPrivateKey) */
@@ -137,7 +137,7 @@ public class PaymentProperties {
     @Data
     @Data
     public static class AnxinAlipay {
     public static class AnxinAlipay {
         /** 安心付小程序 appId(支付宝小程序应用ID) */
         /** 安心付小程序 appId(支付宝小程序应用ID) */
-        private String appId;
+        private String appId = "";
         /** 应用私钥(RSA2),公钥模式时填写应用私钥字符串 */
         /** 应用私钥(RSA2),公钥模式时填写应用私钥字符串 */
         private String appPrivateKey;
         private String appPrivateKey;
         /** 应用私钥文件路径,证书模式时填写(设置后自动读取文件内容作为 appPrivateKey) */
         /** 应用私钥文件路径,证书模式时填写(设置后自动读取文件内容作为 appPrivateKey) */

+ 4 - 4
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentCallbackController.java

@@ -80,7 +80,7 @@ public class PaymentCallbackController {
 
 
         try {
         try {
             paymentOrderService.processPaidCallback(orderNo,
             paymentOrderService.processPaidCallback(orderNo,
-                    tradeNo != null ? tradeNo : "WECHAT_MOCK");
+                    tradeNo != null ? tradeNo : "WECHAT_MOCK", "WECHAT");
             return "SUCCESS";
             return "SUCCESS";
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("处理微信支付回调失败: {}", e.getMessage());
             log.error("处理微信支付回调失败: {}", e.getMessage());
@@ -140,7 +140,7 @@ public class PaymentCallbackController {
             }
             }
 
 
             paymentOrderService.processPaidCallback(orderNo,
             paymentOrderService.processPaidCallback(orderNo,
-                    tradeNo != null ? tradeNo : "WECHAT_" + orderNo);
+                    tradeNo != null ? tradeNo : "WECHAT_" + orderNo, "WECHAT");
             return "SUCCESS";
             return "SUCCESS";
 
 
         } catch (BusinessException e) {
         } catch (BusinessException e) {
@@ -190,7 +190,7 @@ public class PaymentCallbackController {
 
 
         try {
         try {
             paymentOrderService.processPaidCallback(orderNo,
             paymentOrderService.processPaidCallback(orderNo,
-                    tradeNo != null ? tradeNo : "ALIPAY_MOCK");
+                    tradeNo != null ? tradeNo : "ALIPAY_MOCK", "ALIPAY");
             return "success";
             return "success";
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("处理支付宝回调失败: {}", e.getMessage());
             log.error("处理支付宝回调失败: {}", e.getMessage());
@@ -236,7 +236,7 @@ public class PaymentCallbackController {
             }
             }
 
 
             paymentOrderService.processPaidCallback(orderNo,
             paymentOrderService.processPaidCallback(orderNo,
-                    tradeNo != null ? tradeNo : "ALIPAY_" + orderNo);
+                    tradeNo != null ? tradeNo : "ALIPAY_" + orderNo, "ALIPAY");
             return "success";
             return "success";
 
 
         } catch (BusinessException e) {
         } catch (BusinessException e) {

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

@@ -51,6 +51,19 @@ public class PaymentController {
         return Result.success(paymentOrderService.queryOrder(orderNo));
         return Result.success(paymentOrderService.queryOrder(orderNo));
     }
     }
 
 
+    /**
+     * 切换支付渠道并重新生成二维码
+     * 用户可在订单详情页切换为其他支付方式(如微信->支付宝),后端重新调用预下单接口生成新二维码
+     */
+    @PostMapping("/order/{orderNo}/switch-channel")
+    public Result<OrderResponse> switchChannel(
+            @PathVariable String orderNo,
+            @Valid @RequestBody SwitchChannelRequest request,
+            HttpServletRequest httpReq) {
+        Long userId = (Long) httpReq.getAttribute("userId");
+        return Result.success(paymentOrderService.switchChannel(userId, orderNo, request));
+    }
+
     /**
     /**
      * 查询用户的所有订单
      * 查询用户的所有订单
      */
      */

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

@@ -0,0 +1,15 @@
+package com.xuekairui.payment.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 切换支付渠道请求
+ * 用于已创建订单重新生成指定渠道的二维码
+ */
+@Data
+public class SwitchChannelRequest {
+
+    @NotBlank(message = "支付渠道不能为空")
+    private String channel;
+}

+ 3 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentOrder.java

@@ -50,6 +50,9 @@ public class PaymentOrder {
     /** 关联的签约协议ID(仅 AUTO_DEDUCT 订单有值) */
     /** 关联的签约协议ID(仅 AUTO_DEDUCT 订单有值) */
     private Long agreementId;
     private Long agreementId;
 
 
+    /** 实际支付渠道:第一次支付成功时的渠道(可能与订单当前 channel 不同) */
+    private String paidChannel;
+
     /** 第三方交易号 */
     /** 第三方交易号 */
     private String tradeNo;
     private String tradeNo;
 
 

+ 45 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentOrderExtraPayment.java

@@ -0,0 +1,45 @@
+package com.xuekairui.payment.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 支付订单重复支付记录
+ *
+ * <p>同一订单在有效期内发生多次支付时(用户切换了支付渠道),
+ * 第一次支付用于激活会员并作为订单主交易,后续支付记录到本表用于后续追溯和对账。</p>
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_payment_order_extra_payment")
+public class PaymentOrderExtraPayment {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 订单号 */
+    private String orderNo;
+
+    /** 实际支付渠道:WECHAT/ALIPAY */
+    private String channel;
+
+    /** 第三方交易号 */
+    private String tradeNo;
+
+    /** 支付金额(元) */
+    private BigDecimal amount;
+
+    /** 支付时间 */
+    private LocalDateTime paidTime;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 12 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/PaymentOrderExtraPaymentMapper.java

@@ -0,0 +1,12 @@
+package com.xuekairui.payment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.payment.entity.PaymentOrderExtraPayment;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 支付订单重复支付记录 Mapper
+ */
+@Mapper
+public interface PaymentOrderExtraPaymentMapper extends BaseMapper<PaymentOrderExtraPayment> {
+}

+ 143 - 24
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java

@@ -14,11 +14,14 @@ import com.xuekairui.payment.config.PaymentProperties;
 import com.xuekairui.payment.dto.CreateOrderRequest;
 import com.xuekairui.payment.dto.CreateOrderRequest;
 import com.xuekairui.payment.dto.OrderResponse;
 import com.xuekairui.payment.dto.OrderResponse;
 import com.xuekairui.payment.dto.PaymentResultResponse;
 import com.xuekairui.payment.dto.PaymentResultResponse;
+import com.xuekairui.payment.dto.SwitchChannelRequest;
 import com.xuekairui.payment.entity.PaymentOrder;
 import com.xuekairui.payment.entity.PaymentOrder;
+import com.xuekairui.payment.entity.PaymentOrderExtraPayment;
 import com.xuekairui.payment.entity.PaymentPlan;
 import com.xuekairui.payment.entity.PaymentPlan;
 import com.xuekairui.payment.enums.OrderType;
 import com.xuekairui.payment.enums.OrderType;
 import com.xuekairui.payment.enums.PaymentChannel;
 import com.xuekairui.payment.enums.PaymentChannel;
 import com.xuekairui.payment.enums.PaymentStatus;
 import com.xuekairui.payment.enums.PaymentStatus;
+import com.xuekairui.payment.mapper.PaymentOrderExtraPaymentMapper;
 import com.xuekairui.payment.mapper.PaymentOrderMapper;
 import com.xuekairui.payment.mapper.PaymentOrderMapper;
 import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.entity.UserMembership;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.enums.MembershipLevel;
@@ -43,6 +46,7 @@ import java.util.stream.Collectors;
 public class PaymentOrderService {
 public class PaymentOrderService {
 
 
     private final PaymentOrderMapper paymentOrderMapper;
     private final PaymentOrderMapper paymentOrderMapper;
+    private final PaymentOrderExtraPaymentMapper extraPaymentMapper;
     private final PaymentPlanService paymentPlanService;
     private final PaymentPlanService paymentPlanService;
     private final QRCodeService qrCodeService;
     private final QRCodeService qrCodeService;
     private final MembershipService membershipService;
     private final MembershipService membershipService;
@@ -74,19 +78,10 @@ public class PaymentOrderService {
                 .plusMinutes(paymentProperties.getOrderExpireMinutes());
                 .plusMinutes(paymentProperties.getOrderExpireMinutes());
 
 
         // 生成支付二维码/链接
         // 生成支付二维码/链接
-        String qrContent;
-        String payUrl = null;
-        if (paymentProperties.isMock()) {
-            qrContent = "MOCK_PAY:" + orderNo;
-        } else {
-            // 生产模式:调用对应渠道预下单接口获取支付链接
-            PrepayResult prepayResult = createPrepay(channel, orderNo, plan.getPrice(), plan.getPlanName(), plan.getDescription());
-            qrContent = prepayResult.getQrContent();
-            payUrl = prepayResult.getPayUrl();
-        }
-        // 仅在有二维码内容时生成二维码图片(微信Native/支付宝precreate均返回qrContent,由后端统一生成base64)
-        String qrCodeBase64 = (qrContent != null && !qrContent.isEmpty())
-                ? qrCodeService.generateBase64(qrContent) : null;
+        QrCodeResult qrCodeResult = generateQrCode(channel, orderNo, plan.getPrice(), plan.getPlanName(), plan.getDescription());
+        String qrContent = qrCodeResult.getQrContent();
+        String payUrl = qrCodeResult.getPayUrl();
+        String qrCodeBase64 = qrCodeResult.getQrCodeBase64();
 
 
         // 保存订单
         // 保存订单
         PaymentOrder order = PaymentOrder.builder()
         PaymentOrder order = PaymentOrder.builder()
@@ -118,6 +113,60 @@ public class PaymentOrderService {
                 .build();
                 .build();
     }
     }
 
 
+    /**
+     * 切换支付渠道并重新生成二维码
+     * <p>
+     * 用户创建订单后,可在订单详情页切换为其他支付渠道,后端根据新渠道重新调用预下单接口生成二维码。
+     */
+    @Transactional
+    public OrderResponse switchChannel(Long userId, String orderNo, SwitchChannelRequest request) {
+        PaymentChannel newChannel = PaymentChannel.fromCode(request.getChannel());
+        if (newChannel == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
+                    "支持的渠道:WECHAT, ALIPAY");
+        }
+
+        PaymentOrder order = paymentOrderMapper.selectByOrderNo(orderNo);
+        if (order == null) {
+            throw new BusinessException(ErrorCode.PAY_ORDER_NOT_FOUND);
+        }
+        if (!order.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作该订单");
+        }
+
+        checkExpired(order);
+        if (!PaymentStatus.PENDING.name().equals(order.getStatus())) {
+            throw new BusinessException(ErrorCode.PAY_FAILED, "订单已支付或已过期,无法切换渠道");
+        }
+
+        PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
+
+        // 不关闭原渠道订单:15分钟内允许用户在多个渠道间重复切换,原渠道二维码仍可被扫描支付
+        // 以第一个支付成功渠道为准,后续支付记录到 t_payment_order_extra_payment
+        QrCodeResult qrCodeResult = generateQrCode(newChannel, orderNo, order.getAmount(),
+                plan.getPlanName(), plan.getDescription());
+
+        // 更新订单渠道和二维码
+        order.setChannel(request.getChannel());
+        order.setQrCodeUrl(qrCodeResult.getQrContent());
+        paymentOrderMapper.updateById(order);
+
+        log.info("切换支付渠道: orderNo={} 新渠道={}", orderNo, newChannel.getDisplayName());
+
+        return OrderResponse.builder()
+                .orderNo(order.getOrderNo())
+                .planName(plan.getPlanName())
+                .amount(order.getAmount())
+                .channel(order.getChannel())
+                .status(order.getStatus())
+                .qrCodeBase64(qrCodeResult.getQrCodeBase64())
+                .payUrl(qrCodeResult.getPayUrl())
+                .expireTime(order.getExpireTime())
+                .expireHint("请在" + paymentProperties.getOrderExpireMinutes() + "分钟内完成付款,超时订单将自动过期")
+                .createTime(order.getCreateTime())
+                .build();
+    }
+
     /**
     /**
      * 查询单个订单(自动检查过期)
      * 查询单个订单(自动检查过期)
      */
      */
@@ -160,46 +209,49 @@ public class PaymentOrderService {
         }
         }
         String prefix = paymentProperties.isMock() ? "MOCK_" : "MANUAL_";
         String prefix = paymentProperties.isMock() ? "MOCK_" : "MANUAL_";
         String tradeNo = prefix + IdUtil.fastSimpleUUID().substring(0, 16).toUpperCase();
         String tradeNo = prefix + IdUtil.fastSimpleUUID().substring(0, 16).toUpperCase();
-        return processPaidCallback(orderNo, tradeNo);
+        // 手动确认以订单当前渠道作为实际支付渠道
+        return processPaidCallback(orderNo, tradeNo, order.getChannel());
     }
     }
 
 
     /**
     /**
      * 支付回调处理(微信/支付宝回调统一入口)
      * 支付回调处理(微信/支付宝回调统一入口)
-     * @param orderNo  订单号
-     * @param tradeNo  第三方交易号
+     *
+     * @param orderNo     订单号
+     * @param tradeNo     第三方交易号
+     * @param paidChannel 实际支付渠道(WECHAT/ALIPAY)
      * @return 支付结果
      * @return 支付结果
      */
      */
     @Transactional
     @Transactional
-    public PaymentResultResponse processPaidCallback(String orderNo, String tradeNo) {
+    public PaymentResultResponse processPaidCallback(String orderNo, String tradeNo, String paidChannel) {
         PaymentOrder order = paymentOrderMapper.selectByOrderNo(orderNo);
         PaymentOrder order = paymentOrderMapper.selectByOrderNo(orderNo);
         if (order == null) {
         if (order == null) {
             throw new BusinessException(ErrorCode.PAY_ORDER_NOT_FOUND);
             throw new BusinessException(ErrorCode.PAY_ORDER_NOT_FOUND);
         }
         }
 
 
-        // 幂等:已支付直接返回
+        // 已支付:说明是切换渠道后另一个渠道也回调了,记录重复支付但不重复激活会员
         if (PaymentStatus.PAID.name().equals(order.getStatus())) {
         if (PaymentStatus.PAID.name().equals(order.getStatus())) {
-            PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
-            return buildResultResponse(order, plan);
+            return handleDuplicatePayment(order, tradeNo, paidChannel);
         }
         }
 
 
         if (PaymentStatus.EXPIRED.name().equals(order.getStatus())) {
         if (PaymentStatus.EXPIRED.name().equals(order.getStatus())) {
             throw new BusinessException(ErrorCode.PAY_ORDER_EXPIRED);
             throw new BusinessException(ErrorCode.PAY_ORDER_EXPIRED);
         }
         }
 
 
-        return handlePaymentSuccess(order, tradeNo);
+        return handlePaymentSuccess(order, tradeNo, paidChannel);
     }
     }
 
 
     /**
     /**
-     * 支付成功处理
+     * 支付成功处理(第一次支付)
      */
      */
-    private PaymentResultResponse handlePaymentSuccess(PaymentOrder order, String tradeNo) {
+    private PaymentResultResponse handlePaymentSuccess(PaymentOrder order, String tradeNo, String paidChannel) {
         PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
         PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
         MembershipLevel level = MembershipLevel.fromCode(plan.getMembershipLevel());
         MembershipLevel level = MembershipLevel.fromCode(plan.getMembershipLevel());
 
 
-        // 更新订单状态
+        // 更新订单状态,以第一次支付成功的渠道为准
         LocalDateTime now = LocalDateTime.now();
         LocalDateTime now = LocalDateTime.now();
         order.setStatus(PaymentStatus.PAID.name());
         order.setStatus(PaymentStatus.PAID.name());
         order.setPaidTime(now);
         order.setPaidTime(now);
+        order.setPaidChannel(paidChannel);
         order.setTradeNo(tradeNo);
         order.setTradeNo(tradeNo);
         paymentOrderMapper.updateById(order);
         paymentOrderMapper.updateById(order);
 
 
@@ -241,6 +293,36 @@ public class PaymentOrderService {
                 .build();
                 .build();
     }
     }
 
 
+    /**
+     * 处理重复支付(切换渠道后多个渠道都支付成功)
+     * <p>
+     * 以第一次支付为准,后续支付记录到 {@code t_payment_order_extra_payment} 表,
+     * 不重复激活会员,但保留交易记录用于后续追溯和对账。
+     */
+    private PaymentResultResponse handleDuplicatePayment(PaymentOrder order, String tradeNo, String paidChannel) {
+        PaymentPlan plan = paymentPlanService.getPlanById(order.getPlanId());
+
+        // 幂等:同一交易号不重复记录
+        Long existCount = extraPaymentMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PaymentOrderExtraPayment>()
+                        .eq(PaymentOrderExtraPayment::getOrderNo, order.getOrderNo())
+                        .eq(PaymentOrderExtraPayment::getTradeNo, tradeNo));
+        if (existCount == null || existCount == 0) {
+            PaymentOrderExtraPayment extra = PaymentOrderExtraPayment.builder()
+                    .orderNo(order.getOrderNo())
+                    .channel(paidChannel)
+                    .tradeNo(tradeNo)
+                    .amount(order.getAmount())
+                    .paidTime(LocalDateTime.now())
+                    .build();
+            extraPaymentMapper.insert(extra);
+            log.warn("订单重复支付已记录: orderNo={}, paidChannel={}, tradeNo={}, 主交易渠道={}",
+                    order.getOrderNo(), paidChannel, tradeNo, order.getPaidChannel());
+        }
+
+        return buildResultResponse(order, plan);
+    }
+
     /**
     /**
      * 检查并自动过期订单
      * 检查并自动过期订单
      */
      */
@@ -284,6 +366,43 @@ public class PaymentOrderService {
         private String payUrl;
         private String payUrl;
     }
     }
 
 
+    /**
+     * 二维码生成结果(含 base64 图片)
+     */
+    @lombok.Data
+    @lombok.Builder
+    @lombok.NoArgsConstructor
+    @lombok.AllArgsConstructor
+    private static class QrCodeResult {
+        private String qrContent;
+        private String payUrl;
+        private String qrCodeBase64;
+    }
+
+    /**
+     * 统一生成支付二维码
+     */
+    private QrCodeResult generateQrCode(PaymentChannel channel, String orderNo,
+                                        java.math.BigDecimal amount, String subject, String body) {
+        String qrContent;
+        String payUrl = null;
+        if (paymentProperties.isMock()) {
+            qrContent = "MOCK_PAY:" + orderNo;
+        } else {
+            PrepayResult prepayResult = createPrepay(channel, orderNo, amount, subject, body);
+            qrContent = prepayResult.getQrContent();
+            payUrl = prepayResult.getPayUrl();
+        }
+        String qrCodeBase64 = (qrContent != null && !qrContent.isEmpty())
+                ? qrCodeService.generateBase64(qrContent) : null;
+
+        return QrCodeResult.builder()
+                .qrContent(qrContent)
+                .payUrl(payUrl)
+                .qrCodeBase64(qrCodeBase64)
+                .build();
+    }
+
     /**
     /**
      * 调用第三方支付预下单接口
      * 调用第三方支付预下单接口
      *
      *

+ 8 - 1
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentPlanService.java

@@ -125,6 +125,13 @@ public class PaymentPlanService {
     }
     }
 
 
     private PaymentPlanResponse toResponse(PaymentPlan plan) {
     private PaymentPlanResponse toResponse(PaymentPlan plan) {
+        // 方案描述中去除后端兜底上限文案,仅做记录和控制,不在前端展示
+        String description = plan.getDescription();
+        if (description != null) {
+            description = description.replace("(最高999次)", "")
+                    .replace("(最高999次)", "");
+        }
+
         String tag = null;
         String tag = null;
         if (plan.getOriginalPrice() != null
         if (plan.getOriginalPrice() != null
                 && plan.getOriginalPrice().compareTo(plan.getPrice()) > 0) {
                 && plan.getOriginalPrice().compareTo(plan.getPrice()) > 0) {
@@ -145,7 +152,7 @@ public class PaymentPlanService {
                 .price(plan.getPrice())
                 .price(plan.getPrice())
                 .originalPrice(plan.getOriginalPrice())
                 .originalPrice(plan.getOriginalPrice())
                 .durationDays(plan.getDurationDays())
                 .durationDays(plan.getDurationDays())
-                .description(plan.getDescription())
+                .description(description)
                 .tag(tag)
                 .tag(tag)
                 .sortOrder(plan.getSortOrder())
                 .sortOrder(plan.getSortOrder())
                 .build();
                 .build();

+ 15 - 4
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -780,7 +780,18 @@ const FIELD_DESC = {
         ['shareTitle', 'String', '分享标题'],
         ['shareTitle', 'String', '分享标题'],
         ['shareDescription', 'String', '分享描述'],
         ['shareDescription', 'String', '分享描述'],
         ['copyText', 'String', '一键复制文本'],
         ['copyText', 'String', '一键复制文本'],
-        ['rewardDescription', 'String', '奖励说明']
+        ['rewardDescription', 'String', '奖励说明'],
+        ['downloadUrl', 'String', '下载链接(与 inviteLink 一致,访问后根据设备自动重定向到对应安装包)']
+    ],
+    'myInviter': [
+        ['invited', 'Boolean', '是否被邀请过'],
+        ['inviterId', 'Long', '邀请人ID(未邀请时为null)'],
+        ['inviterNickname', 'String', '邀请人昵称'],
+        ['inviterPhone', 'String', '邀请人手机号(脱敏)'],
+        ['inviterAvatar', 'String', '邀请人头像'],
+        ['pharmacyName', 'String', '邀请人药店名称'],
+        ['inviteCode', 'String', '邀请码'],
+        ['inviteTime', 'DateTime', '邀请时间']
     ],
     ],
     'inviteStats': [
     'inviteStats': [
         ['myInviteCode', 'String', '我的邀请码'],
         ['myInviteCode', 'String', '我的邀请码'],
@@ -813,7 +824,7 @@ const FIELD_DESC = {
         ['appName', 'String', '应用名称'],
         ['appName', 'String', '应用名称'],
         ['landingTitle', 'String', '落地页标题'],
         ['landingTitle', 'String', '落地页标题'],
         ['landingDesc', 'String', '落地页描述'],
         ['landingDesc', 'String', '落地页描述'],
-        ['appDownloadUrl', 'String', 'Windows下载地址(channel=windows/app)'],
+        ['appDownloadUrl', 'String', 'Windows下载入口URL(channel=windows/app),访问 /api/invite/{code} 会根据设备自动重定向到对应安装包'],
         ['miniappPath', 'String', '小程序路径'],
         ['miniappPath', 'String', '小程序路径'],
         ['miniappAppId', 'String', '小程序AppID'],
         ['miniappAppId', 'String', '小程序AppID'],
         ['wechatRedirectUrl', 'String', '微信跳转URL'],
         ['wechatRedirectUrl', 'String', '微信跳转URL'],
@@ -1258,8 +1269,8 @@ function renderInviteModule() {
     ${testSection('invite-page', '落地页', 'GET', 'get', '/api/invite/page/{code}?channel=windows', '公开', 'auth-public',
     ${testSection('invite-page', '落地页', 'GET', 'get', '/api/invite/page/{code}?channel=windows', '公开', 'auth-public',
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''},
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''},
          {label:'渠道(channel)', name:'channel', placeholder:'windows/app/wechat/miniapp/dingtalk/feishu', default:'windows'}], true, 'invitePage', '浏览器打开邀请链接时展示的落地页数据,默认渠道为windows(Windows桌面客户端)')}
          {label:'渠道(channel)', name:'channel', placeholder:'windows/app/wechat/miniapp/dingtalk/feishu', default:'windows'}], true, 'invitePage', '浏览器打开邀请链接时展示的落地页数据,默认渠道为windows(Windows桌面客户端)')}
-    ${testSection('invite-link-entry', '邀请链接入口', 'GET', 'get', '/api/invite/{code}?channel=windows', '公开', 'auth-public',
-        [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], true, 'invitePage', '浏览器直接访问邀请链接入口,默认windows渠道,自动计入点击数')}
+    ${testSection('invite-link-entry', '邀请链接入口(直接下载)', 'GET', 'get', '/api/invite/{code}', '公开', 'auth-public',
+        [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], true, 'invitePage', '浏览器直接访问邀请链接,根据User-Agent自动重定向到对应桌面客户端安装包(Windows x64 -> .exe / macOS ARM64 -> .dmg),并自动计入点击数')}
     ${testSection('invite-click', '点击追踪', 'POST', 'post', '/api/invite/click/{code}', '公开', 'auth-public',
     ${testSection('invite-click', '点击追踪', 'POST', 'post', '/api/invite/click/{code}', '公开', 'auth-public',
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], false, null, '追踪邀请链接被打开事件')}
         [{label:'邀请码(code)', name:'code', placeholder:'输入邀请码', default:''}], false, null, '追踪邀请链接被打开事件')}
     ${testSection('invite-resolve', '解析链接', 'GET', 'get', '/api/invite/resolve-link?link=', '公开', 'auth-public',
     ${testSection('invite-resolve', '解析链接', 'GET', 'get', '/api/invite/resolve-link?link=', '公开', 'auth-public',

+ 27 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java

@@ -5,6 +5,8 @@ import com.xuekairui.common.Result;
 import com.xuekairui.user.entity.BusinessLicense;
 import com.xuekairui.user.entity.BusinessLicense;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.service.MembershipService;
 import com.xuekairui.user.service.OperationAuditLogService;
 import com.xuekairui.user.service.OperationAuditLogService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import jakarta.validation.Valid;
@@ -29,6 +31,7 @@ public class AdminLicenseController {
 
 
     private final BusinessLicenseMapper businessLicenseMapper;
     private final BusinessLicenseMapper businessLicenseMapper;
     private final OperationAuditLogService auditLogService;
     private final OperationAuditLogService auditLogService;
+    private final MembershipService membershipService;
 
 
     @GetMapping("/review")
     @GetMapping("/review")
     public Result<List<BusinessLicense>> listPendingReviews(
     public Result<List<BusinessLicense>> listPendingReviews(
@@ -59,6 +62,18 @@ public class AdminLicenseController {
         if ("APPROVED".equals(request.getAction())) {
         if ("APPROVED".equals(request.getAction())) {
             license.setReviewStatus("APPROVED");
             license.setReviewStatus("APPROVED");
             license.setShowVerifiedBadge(true);
             license.setShowVerifiedBadge(true);
+
+            // 营业执照首次审核通过,赠送 30 天高级会员
+            Long userId = license.getUserId();
+            if (userId != null && !hasApprovedLicense(userId, license.getId())) {
+                membershipService.grantOrExtendMembership(
+                        userId,
+                        MembershipLevel.PRO,
+                        30,
+                        "LICENSE",
+                        "营业执照审核通过赠送30天高级会员");
+                log.info("营业执照审核通过,赠送用户{} 30天高级会员", userId);
+            }
         } else if ("REJECTED".equals(request.getAction())) {
         } else if ("REJECTED".equals(request.getAction())) {
             license.setReviewStatus("REJECTED");
             license.setReviewStatus("REJECTED");
             license.setShowVerifiedBadge(false);
             license.setShowVerifiedBadge(false);
@@ -87,6 +102,18 @@ public class AdminLicenseController {
         return role != null ? role : "ADMIN";
         return role != null ? role : "ADMIN";
     }
     }
 
 
+    /**
+     * 检查用户是否已有其他审核通过的营业执照(避免重复赠送会员)
+     */
+    private boolean hasApprovedLicense(Long userId, Long excludeLicenseId) {
+        Long count = businessLicenseMapper.selectCount(
+                new LambdaQueryWrapper<BusinessLicense>()
+                        .eq(BusinessLicense::getUserId, userId)
+                        .eq(BusinessLicense::getReviewStatus, "APPROVED")
+                        .ne(BusinessLicense::getId, excludeLicenseId));
+        return count != null && count > 0;
+    }
+
     @Data
     @Data
     public static class ReviewRequest {
     public static class ReviewRequest {
         @NotNull(message = "营业执照ID不能为空")
         @NotNull(message = "营业执照ID不能为空")

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

@@ -43,6 +43,9 @@ public class MembershipResponse {
     /** 生效结束时间 */
     /** 生效结束时间 */
     private LocalDateTime effectiveTo;
     private LocalDateTime effectiveTo;
 
 
+    /** 会员过期时间(与 effectiveTo 一致,便于前端统一使用) */
+    private LocalDateTime membershipExpireAt;
+
     /** 状态:1-有效 0-过期/停用 */
     /** 状态:1-有效 0-过期/停用 */
     private Integer status;
     private Integer status;
 
 

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java

@@ -42,6 +42,9 @@ public class User {
     /** 微信UnionID(开放平台) */
     /** 微信UnionID(开放平台) */
     private String wechatUnionId;
     private String wechatUnionId;
 
 
+    /** 微信昵称 */
+    private String wechatNickname;
+
     /** 药店名称 */
     /** 药店名称 */
     private String pharmacyName;
     private String pharmacyName;
 
 

+ 1 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -291,6 +291,7 @@ public class MembershipService {
                 .source(membership.getSource())
                 .source(membership.getSource())
                 .effectiveFrom(membership.getEffectiveFrom())
                 .effectiveFrom(membership.getEffectiveFrom())
                 .effectiveTo(membership.getEffectiveTo())
                 .effectiveTo(membership.getEffectiveTo())
+                .membershipExpireAt(membership.getEffectiveTo())
                 .status(membership.getStatus())
                 .status(membership.getStatus())
                 .remark(membership.getRemark())
                 .remark(membership.getRemark())
                 .createTime(membership.getCreateTime())
                 .createTime(membership.getCreateTime())

+ 3 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/service/UserLevelService.java

@@ -64,7 +64,7 @@ public class UserLevelService {
                     .monthlyQuota(29970)
                     .monthlyQuota(29970)
                     .yearlyQuota(364635)
                     .yearlyQuota(364635)
                     .maxConcurrent(5)
                     .maxConcurrent(5)
-                    .description("高级会员:每日不限量(最高999次)")
+                    .description("高级会员:每日不限量")
                     .status(1)
                     .status(1)
                     .build(),
                     .build(),
             MembershipLevel.ULTRA, UserLevel.builder()
             MembershipLevel.ULTRA, UserLevel.builder()
@@ -254,12 +254,12 @@ public class UserLevelService {
                     LevelPricingPlan.builder()
                     LevelPricingPlan.builder()
                             .planCode("MONTHLY_PRO").planName("高级会员·月卡")
                             .planCode("MONTHLY_PRO").planName("高级会员·月卡")
                             .price(new BigDecimal("29.90")).originalPrice(new BigDecimal("59.90"))
                             .price(new BigDecimal("29.90")).originalPrice(new BigDecimal("59.90"))
-                            .durationDays(30).description("每天不限量(最高999次)· 1个月29.9(原价59.9)")
+                            .durationDays(30).description("每天不限量· 1个月29.9(原价59.9)")
                             .build(),
                             .build(),
                     LevelPricingPlan.builder()
                     LevelPricingPlan.builder()
                             .planCode("YEARLY_PRO").planName("高级会员·年卡")
                             .planCode("YEARLY_PRO").planName("高级会员·年卡")
                             .price(new BigDecimal("299.90")).originalPrice(new BigDecimal("599.90"))
                             .price(new BigDecimal("299.90")).originalPrice(new BigDecimal("599.90"))
-                            .durationDays(365).description("每天不限量(最高999次)· 1年299.9(原价599.9)")
+                            .durationDays(365).description("每天不限量· 1年299.9(原价599.9)")
                             .build()
                             .build()
             );
             );
             case "ULTRA" -> List.of(
             case "ULTRA" -> List.of(