Bläddra i källkod

优化代码,添加接口

liuchengsen 2 månader sedan
förälder
incheckning
61918b7252
32 ändrade filer med 2341 tillägg och 10 borttagningar
  1. 15 0
      deploy/.env.example
  2. 9 0
      zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java
  3. 11 0
      zhijiayun-gateway/src/main/resources/application-dev.yml
  4. 12 1
      zhijiayun-gateway/src/main/resources/application-prod.yml
  5. 12 0
      zhijiayun-gateway/src/main/resources/application.yml
  6. 39 0
      zhijiayun-gateway/src/main/resources/db/migration-v4.sql
  7. 87 0
      zhijiayun-gateway/src/main/resources/db/schema.sql
  8. 73 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentConfig.java
  9. 58 1
      zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentProperties.java
  10. 92 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/AgreementController.java
  11. 195 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/AlipayAnxinPayController.java
  12. 32 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementResponse.java
  13. 21 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignRequest.java
  14. 27 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignResponse.java
  15. 14 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardCreateRequest.java
  16. 29 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardResponse.java
  17. 65 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/AlipayAnxinCard.java
  18. 72 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/AlipayAnxinOrderRecord.java
  19. 77 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentAgreement.java
  20. 7 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentOrder.java
  21. 35 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/AgreementStatus.java
  22. 31 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/OrderType.java
  23. 34 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/PeriodType.java
  24. 22 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/AlipayAnxinCardMapper.java
  25. 22 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/AlipayAnxinOrderRecordMapper.java
  26. 37 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/PaymentAgreementMapper.java
  27. 596 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/AlipayAnxinPayService.java
  28. 51 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/AutoDeductScheduler.java
  29. 427 0
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentAgreementService.java
  30. 14 2
      zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentOrderService.java
  31. 122 6
      zhijiayun-test/src/main/resources/static/invite-test.html
  32. 3 0
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

+ 15 - 0
deploy/.env.example

@@ -48,6 +48,21 @@ ALIPAY_PUBLIC_KEY=请填入支付宝公钥
 # ALIPAY_CERT_PATH=certs/prod/alipay/alipayCertPublicKey_RSA2.crt
 # ALIPAY_ROOT_CERT_PATH=certs/prod/alipay/alipayRootCert.crt
 
+# ---- 支付宝安心付(小程序应用 - 周期卡会员管理)----
+# 安心付对应的是支付宝小程序应用,与网页应用(二维码付款)使用不同的 appId、密钥和证书
+ALIPAY_ANXIN_APP_ID=请填入安心付小程序应用ID
+# 公钥模式(优先):填入私钥字符串和支付宝公钥
+ALIPAY_ANXIN_APP_PRIVATE_KEY=请填入安心付应用私钥
+ALIPAY_ANXIN_PUBLIC_KEY=请填入安心付支付宝公钥
+# 证书模式(备选):未配置 ALIPAY_ANXIN_PUBLIC_KEY 时回退到证书模式
+# ALIPAY_ANXIN_PRIVATE_KEY_PATH=certs/prod/alipay-anxin/app-private-key.txt
+# ALIPAY_ANXIN_APP_CERT_PATH=certs/prod/alipay-anxin/appCertPublicKey.crt
+# ALIPAY_ANXIN_CERT_PATH=certs/prod/alipay-anxin/alipayCertPublicKey_RSA2.crt
+# ALIPAY_ANXIN_ROOT_CERT_PATH=certs/prod/alipay-anxin/alipayRootCert.crt
+# 安心付通知回调URL
+ALIPAY_ANXIN_NOTIFY_URL=https://priceapi.kailin.com.cn/api/payment/anxin/notify/deduct
+ALIPAY_ANXIN_CARD_NOTIFY_URL=https://priceapi.kailin.com.cn/api/payment/anxin/notify/card-change
+
 # ---- 微信支付(Native扫码) ----
 # 生产证书放入 certs/prod/wechat/ 目录(三份文件:apiclient_key.pem, apiclient_cert.pem, apiclient_cert.p12)
 WECHAT_PAY_APP_ID=请填入微信应用AppID

+ 9 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java

@@ -60,6 +60,15 @@ public enum ErrorCode {
     PAY_SIGN_VERIFY_FAILED(2009, "支付回调验签失败"),
     PAY_CHANNEL_NOT_CONFIGURED(2010, "支付渠道未配置,请检查payment配置"),
     PAY_PLAN_CODE_DUPLICATE(2011, "方案编码已存在"),
+    PAY_AGREEMENT_NOT_FOUND(2012, "签约协议不存在"),
+    PAY_AGREEMENT_EXISTS(2013, "已存在有效签约协议"),
+    PAY_AGREEMENT_NOT_SIGNED(2014, "协议未签约或已解约"),
+    PAY_DEDUCT_FAILED(2015, "自动扣款失败"),
+    PAY_ANXIN_CARD_NOT_FOUND(2016, "安心付卡模板不存在"),
+    PAY_ANXIN_CARD_CREATE_FAILED(2017, "安心付卡模板创建失败"),
+    PAY_ANXIN_NOT_CONFIGURED(2018, "安心付未配置,请检查payment.alipay.anxin参数"),
+    PAY_ANXIN_ORDER_NOT_FOUND(2019, "安心付订单不存在"),
+    PAY_ANXIN_USER_NOT_MATCHED(2020, "安心付扣款通知无法匹配到系统用户"),
 
     // ========== 等级模块 4001-4099 ==========
     LEVEL_NOT_FOUND(4001, "用户等级不存在"),

+ 11 - 0
zhijiayun-gateway/src/main/resources/application-dev.yml

@@ -70,6 +70,17 @@ payment:
     app-cert-path: ${ALIPAY_APP_CERT_PATH:certs/dev/alipay/appCertPublicKey_2021005188648051.crt}
     alipay-cert-path: ${ALIPAY_CERT_PATH:certs/dev/alipay/alipayCertPublicKey_RSA2.crt}
     alipay-root-cert-path: ${ALIPAY_ROOT_CERT_PATH:certs/dev/alipay/alipayRootCert.crt}
+  # 安心付(周期卡-小程序会员卡管理):独立小程序应用,与网页应用的 alipay 配置完全独立
+  anxin-alipay:
+    app-id: ${ALIPAY_ANXIN_APP_ID:}
+    app-private-key: ${ALIPAY_ANXIN_APP_PRIVATE_KEY:}
+    alipay-public-key: ${ALIPAY_ANXIN_PUBLIC_KEY:}
+    notify-url: ${ALIPAY_ANXIN_NOTIFY_URL:https://priceapi.kailin.com.cn/api/payment/anxin/notify/deduct}
+    card-notify-url: ${ALIPAY_ANXIN_CARD_NOTIFY_URL:https://priceapi.kailin.com.cn/api/payment/anxin/notify/card-change}
+    server-url: https://openapi.alipay.com/gateway.do
+    format: json
+    charset: UTF-8
+    sign-type: RSA2
 
 
 #    # 公钥模式(优先):配置了 alipay-public-key 即走公钥模式

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

@@ -120,7 +120,18 @@ payment:
     app-cert-path: ${ALIPAY_APP_CERT_PATH:certs/prod/alipay/appCertPublicKey_2021005187690877.crt}
     alipay-cert-path: ${ALIPAY_CERT_PATH:certs/prod/alipay/alipayCertPublicKey_RSA2.crt}
     alipay-root-cert-path: ${ALIPAY_ROOT_CERT_PATH:certs/prod/alipay/alipayRootCert.crt}
- # 微信支付(开发/测试环境):将测试商户证书文件复制到 certs/prod/wechat/ 目录下
+  # 安心付(周期卡-小程序会员卡管理):独立小程序应用,与网页应用的 alipay 配置完全独立
+  anxin-alipay:
+    app-id: ${ALIPAY_ANXIN_APP_ID:}
+    app-private-key: ${ALIPAY_ANXIN_APP_PRIVATE_KEY:}
+    alipay-public-key: ${ALIPAY_ANXIN_PUBLIC_KEY:}
+    notify-url: ${ALIPAY_ANXIN_NOTIFY_URL:https://priceapi.kailin.com.cn/api/payment/anxin/notify/deduct}
+    card-notify-url: ${ALIPAY_ANXIN_CARD_NOTIFY_URL:https://priceapi.kailin.com.cn/api/payment/anxin/notify/card-change}
+    server-url: https://openapi.alipay.com/gateway.do
+    format: json
+    charset: UTF-8
+    sign-type: RSA2
+  # 微信支付(生产环境):将生产证书文件复制到 certs/prod/wechat/ 目录下
   wechat:
     app-id: ${WECHAT_PAY_APP_ID:wxf2f254f9981651dd}
     mch-id: ${WECHAT_PAY_MCH_ID:1612111355}

+ 12 - 0
zhijiayun-gateway/src/main/resources/application.yml

@@ -106,6 +106,18 @@ payment:
     format: json
     charset: UTF-8
     sign-type: RSA2
+  # 支付宝安心付配置(小程序应用 - 周期卡会员管理)
+  # 安心付对应的是支付宝小程序应用,与网页应用(二维码付款)使用不同的 appId、密钥和证书
+  anxin-alipay:
+    app-id: ${ALIPAY_ANXIN_APP_ID:}
+    app-private-key: ${ALIPAY_ANXIN_APP_PRIVATE_KEY:}
+    alipay-public-key: ${ALIPAY_ANXIN_PUBLIC_KEY:}
+    notify-url: ${ALIPAY_ANXIN_NOTIFY_URL:https://your-domain.com/api/payment/anxin/notify/deduct}
+    card-notify-url: ${ALIPAY_ANXIN_CARD_NOTIFY_URL:https://your-domain.com/api/payment/anxin/notify/card-change}
+    server-url: https://openapi.alipay.com/gateway.do
+    format: json
+    charset: UTF-8
+    sign-type: RSA2
 
 # ========== 网关配置 ==========
 gateway:

+ 39 - 0
zhijiayun-gateway/src/main/resources/db/migration-v4.sql

@@ -0,0 +1,39 @@
+-- ============================================
+-- migration-v4.sql
+-- 自动续费(周期扣款/委托代扣)相关表与字段
+-- ============================================
+
+-- 1. t_payment_order 补充字段(幂等 ALTER)
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_payment_order' AND COLUMN_NAME = 'order_type');
+SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_payment_order` ADD COLUMN `order_type` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '订单类型:NORMAL/AUTO_DEDUCT' AFTER `qr_code_url`", 'SELECT 1');
+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 = 'agreement_id');
+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;
+
+-- 2. 签约协议表
+CREATE TABLE IF NOT EXISTS `t_payment_agreement` (
+    `id`                        BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`                   BIGINT        NOT NULL COMMENT '用户ID',
+    `plan_id`                   BIGINT        NOT NULL COMMENT '支付方案ID',
+    `channel`                   VARCHAR(20)   NOT NULL COMMENT '支付渠道:WECHAT/ALIPAY',
+    `period_type`               VARCHAR(10)   NOT NULL COMMENT '周期类型:MONTH/YEAR',
+    `period_amount`             DECIMAL(10,2) NOT NULL COMMENT '每期扣款金额(元)',
+    `status`                    VARCHAR(20)   NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/SIGNED/UNSIGNED/FAILED',
+    `agreement_no`              VARCHAR(128)  DEFAULT NULL COMMENT '第三方协议号(支付宝agreement_no/微信contract_id)',
+    `external_agreement_no`     VARCHAR(64)   DEFAULT NULL COMMENT '商家自定义协议号(幂等)',
+    `sign_time`                 DATETIME      DEFAULT NULL COMMENT '签约时间',
+    `unsign_time`               DATETIME      DEFAULT NULL COMMENT '解约时间',
+    `next_deduct_time`          DATETIME      DEFAULT NULL COMMENT '下次扣款时间',
+    `last_deduct_time`          DATETIME      DEFAULT NULL COMMENT '上次扣款时间',
+    `consecutive_fail_count`    INT           NOT NULL DEFAULT 0 COMMENT '连续扣款失败次数',
+    `unsign_reason`             VARCHAR(256)  DEFAULT NULL COMMENT '解约原因',
+    `create_time`               DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`               DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_external_agreement_no` (`external_agreement_no`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_agreement_no` (`agreement_no`),
+    KEY `idx_status_deduct` (`status`, `next_deduct_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付签约协议表';

+ 87 - 0
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -285,6 +285,41 @@ CREATE TABLE IF NOT EXISTS `t_payment_order` (
     KEY `idx_expire_time` (`expire_time`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单表';
 
+-- 支付订单表补充字段(自动续费扣款相关)
+-- order_type / agreement_id(兼容旧库的幂等 ALTER)
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_payment_order' AND COLUMN_NAME = 'order_type');
+SET @sql = IF(@col_exists = 0, "ALTER TABLE `t_payment_order` ADD COLUMN `order_type` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '订单类型:NORMAL/AUTO_DEDUCT' AFTER `qr_code_url`", 'SELECT 1');
+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 = 'agreement_id');
+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;
+
+-- 签约协议表(周期扣款/委托代扣)
+CREATE TABLE IF NOT EXISTS `t_payment_agreement` (
+    `id`                        BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`                   BIGINT        NOT NULL COMMENT '用户ID',
+    `plan_id`                   BIGINT        NOT NULL COMMENT '支付方案ID',
+    `channel`                   VARCHAR(20)   NOT NULL COMMENT '支付渠道:WECHAT/ALIPAY',
+    `period_type`               VARCHAR(10)   NOT NULL COMMENT '周期类型:MONTH/YEAR',
+    `period_amount`             DECIMAL(10,2) NOT NULL COMMENT '每期扣款金额(元)',
+    `status`                    VARCHAR(20)   NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/SIGNED/UNSIGNED/FAILED',
+    `agreement_no`              VARCHAR(128)  DEFAULT NULL COMMENT '第三方协议号(支付宝agreement_no/微信contract_id)',
+    `external_agreement_no`     VARCHAR(64)   DEFAULT NULL COMMENT '商家自定义协议号(幂等)',
+    `sign_time`                 DATETIME      DEFAULT NULL COMMENT '签约时间',
+    `unsign_time`               DATETIME      DEFAULT NULL COMMENT '解约时间',
+    `next_deduct_time`          DATETIME      DEFAULT NULL COMMENT '下次扣款时间',
+    `last_deduct_time`          DATETIME      DEFAULT NULL COMMENT '上次扣款时间',
+    `consecutive_fail_count`    INT           NOT NULL DEFAULT 0 COMMENT '连续扣款失败次数',
+    `unsign_reason`             VARCHAR(256)  DEFAULT NULL COMMENT '解约原因',
+    `create_time`               DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`               DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_external_agreement_no` (`external_agreement_no`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_agreement_no` (`agreement_no`),
+    KEY `idx_status_deduct` (`status`, `next_deduct_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付签约协议表';
+
 
 -- 初始化支付方案(普通会员免费,仅高级会员付费)
 INSERT INTO `t_payment_plan` (`plan_code`, `plan_name`, `membership_level`, `price`, `original_price`, `duration_days`, `sort_order`, `status`, `description`)
@@ -581,3 +616,55 @@ CREATE TABLE IF NOT EXISTS `t_alipay_callback_record` (
     KEY `idx_app_id` (`app_id`),
     KEY `idx_msg_method` (`msg_method`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付宝开放能力回调记录表';
+
+-- ============================================
+-- DDL: 支付宝安心付卡模板表(周期卡)
+-- 记录安心付周期卡模板信息,与支付方案一一对应
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_alipay_anxin_card` (
+    `id`              BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `plan_id`         BIGINT        NOT NULL COMMENT '关联支付方案ID(t_payment_plan.id)',
+    `card_id`         VARCHAR(128)  DEFAULT NULL COMMENT '支付宝卡模板ID',
+    `card_name`       VARCHAR(128)  DEFAULT NULL COMMENT '卡名称',
+    `card_type`       VARCHAR(20)   DEFAULT 'PERIOD_PAY' COMMENT '卡类型:PERIOD_PAY(周期卡)',
+    `period_amount`   DECIMAL(10,2) DEFAULT NULL COMMENT '每期扣款金额(元)',
+    `period_days`     INT           DEFAULT NULL COMMENT '扣款周期天数',
+    `total_periods`   INT           DEFAULT NULL COMMENT '总期数(NULL表示无限期)',
+    `status`          VARCHAR(20)   DEFAULT 'PENDING' COMMENT '状态:PENDING/EFFECTIVE/FROZEN/INVALID',
+    `appointment_url` VARCHAR(2048) DEFAULT NULL COMMENT '预约链接(小程序跳转地址)',
+    `raw_response`    TEXT          DEFAULT NULL COMMENT '原始创建响应(JSON)',
+    `create_time`     DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`     DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_plan_id` (`plan_id`),
+    KEY `idx_card_id` (`card_id`),
+    KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付宝安心付卡模板表';
+
+-- ============================================
+-- DDL: 支付宝安心付扣款订单记录表
+-- 记录每一次安心付周期卡扣款通知信息
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_alipay_anxin_order_record` (
+    `id`                   BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `user_id`              BIGINT        DEFAULT NULL COMMENT '系统用户ID(通过out_biz_no匹配)',
+    `plan_id`              BIGINT        NOT NULL COMMENT '关联支付方案ID',
+    `card_id`              VARCHAR(128)  DEFAULT NULL COMMENT '支付宝卡模板ID',
+    `order_id`             VARCHAR(128)  DEFAULT NULL COMMENT '支付宝售卖订单ID',
+    `sub_order_id`         VARCHAR(128)  DEFAULT NULL COMMENT '核销/扣款子订单ID',
+    `out_biz_no`           VARCHAR(128)  DEFAULT NULL COMMENT '商家业务号(存储系统用户ID)',
+    `alipay_user_id`      VARCHAR(128)  DEFAULT NULL COMMENT '支付宝用户ID',
+    `deduct_amount`        DECIMAL(10,2) DEFAULT NULL COMMENT '扣款金额(元)',
+    `deduct_status`        VARCHAR(20)   DEFAULT NULL COMMENT '扣款状态:SUCCESS/FAIL',
+    `deduct_time`          DATETIME      DEFAULT NULL COMMENT '扣款时间',
+    `membership_activated` TINYINT(1)    DEFAULT 0 COMMENT '会员是否已激活',
+    `order_no`             VARCHAR(32)   DEFAULT NULL COMMENT '关联支付订单号',
+    `raw_params`           TEXT          DEFAULT NULL COMMENT '原始通知参数(JSON)',
+    `create_time`          DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`          DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_sub_order_id` (`sub_order_id`),
+    KEY `idx_order_id` (`order_id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_card_id` (`card_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付宝安心付扣款订单记录表';

+ 73 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentConfig.java

@@ -156,6 +156,79 @@ public class PaymentConfig {
                 "支付宝配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key)");
     }
 
+    // ======================== 安心付支付宝客户端(小程序应用) ========================
+
+    /**
+     * 安心付支付宝客户端(小程序应用 - 周期卡会员管理)
+     *
+     * <p>安心付对应的是支付宝小程序应用,与网页应用(二维码付款)使用不同的
+     * appId、密钥和证书,因此需要独立的 AlipayClient,不能共用 {@link #alipayClient()}。
+     *
+     * <p>仅当配置了 payment.anxin-alipay.app-id 且 payment.mock=false 时注册。
+     * 支持公钥模式(优先)和证书模式(备选),与 {@link #alipayClient()} 逻辑一致。
+     */
+    @Bean("anxinAlipayClient")
+    @ConditionalOnProperty(prefix = "payment", name = "mock", havingValue = "false")
+    public AlipayClient anxinAlipayClient() throws Exception {
+        PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
+
+        if (ax.getAppId() == null || ax.getAppId().isBlank()) {
+            throw new IllegalStateException(
+                    "安心付配置不完整:需配置 payment.anxin-alipay.app-id(支付宝小程序应用ID)");
+        }
+
+        // 公钥模式(优先)
+        if (ax.getAlipayPublicKey() != null && !ax.getAlipayPublicKey().isBlank()) {
+            log.info("初始化安心付支付宝客户端(公钥模式): appId={}, serverUrl={}",
+                    ax.getAppId(), ax.getServerUrl());
+
+            String privateKey = ax.getAppPrivateKey();
+            if ((privateKey == null || privateKey.isBlank())
+                    && ax.getAppPrivateKeyPath() != null && !ax.getAppPrivateKeyPath().isBlank()) {
+                privateKey = readFileContent(ax.getAppPrivateKeyPath());
+                log.info("已从文件读取安心付应用私钥: {}", ax.getAppPrivateKeyPath());
+            }
+
+            return new DefaultAlipayClient(
+                    ax.getServerUrl(),
+                    ax.getAppId(),
+                    privateKey,
+                    ax.getFormat(),
+                    ax.getCharset(),
+                    ax.getAlipayPublicKey(),
+                    ax.getSignType()
+            );
+        }
+
+        // 证书模式(备选)
+        if (ax.getAppCertPath() != null && !ax.getAppCertPath().isBlank()) {
+            log.info("初始化安心付支付宝客户端(证书模式): appId={}, serverUrl={}",
+                    ax.getAppId(), ax.getServerUrl());
+
+            String privateKey = ax.getAppPrivateKey();
+            if (ax.getAppPrivateKeyPath() != null && !ax.getAppPrivateKeyPath().isBlank()) {
+                privateKey = readFileContent(ax.getAppPrivateKeyPath());
+                log.info("安心付应用私钥: {}", ax.getAppPrivateKeyPath());
+            }
+
+            CertAlipayRequest certRequest = new CertAlipayRequest();
+            certRequest.setServerUrl(ax.getServerUrl());
+            certRequest.setAppId(ax.getAppId());
+            certRequest.setPrivateKey(privateKey);
+            certRequest.setFormat(ax.getFormat());
+            certRequest.setCharset(ax.getCharset());
+            certRequest.setSignType(ax.getSignType());
+            certRequest.setCertPath(resolveFilePath(ax.getAppCertPath()));
+            certRequest.setAlipayPublicCertPath(resolveFilePath(ax.getAlipayCertPath()));
+            certRequest.setRootCertPath(resolveFilePath(ax.getAlipayRootCertPath()));
+
+            return new DefaultAlipayClient(certRequest);
+        }
+
+        throw new IllegalStateException(
+                "安心付配置不完整:需要配置公钥模式(alipay-public-key)或证书模式(app-cert-path),并确保已配置应用私钥(app-private-key)");
+    }
+
     /**
      * 微信支付客户端(仅 payment.mock=false 时注册)
      *

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

@@ -29,9 +29,12 @@ public class PaymentProperties {
     /** 微信支付配置 */
     private Wechat wechat = new Wechat();
 
-    /** 支付宝配置 */
+    /** 支付宝配置(网页应用 - 二维码付款) */
     private Alipay alipay = new Alipay();
 
+    /** 支付宝安心付配置(小程序应用 - 周期卡会员) */
+    private AnxinAlipay anxinAlipay = new AnxinAlipay();
+
     @Data
     public static class QrCode {
         /** 二维码宽度 */
@@ -68,6 +71,12 @@ public class PaymentProperties {
         private String publicKeyId;
         /** 微信支付平台证书路径(用于验签,可选,SDK可自动下载) */
         private String platformCertPath;
+        /** 委托代扣-签约回调通知URL(需公网可访问) */
+        private String agreementNotifyUrl;
+        /** 委托代扣-扣款方案ID(微信商户平台配置的扣款模板 plan_id) */
+        private String papayPlanId;
+        /** 委托代扣-签约方案详情ID(微信商户平台配置) */
+        private String papayPlanDetailId;
     }
 
     /**
@@ -97,6 +106,54 @@ public class PaymentProperties {
         private String notifyUrl;
         /** 支付完成后同步跳转URL(用户付款后浏览器跳转回的页面) */
         private String returnUrl;
+        /** 周期扣款签约结果异步通知URL(与 notifyUrl 可相同,需公网可访问) */
+        private String agreementNotifyUrl;
+        /** 周期扣款签约场景:周期扣款产品配置的扣款场景标识 */
+        private String accessParams;
+        /** 周期扣款-签约销售方案码(可选,支付宝周期扣款模板) */
+        private String signPlanId;
+        /** 支付宝网关地址 */
+        private String serverUrl = "https://openapi.alipay.com/gateway.do";
+        /** 数据格式(固定 json) */
+        private String format = "json";
+        /** 编码格式 */
+        private String charset = "UTF-8";
+        /** 签名算法类型 */
+        private String signType = "RSA2";
+    }
+
+    /**
+     * 支付宝安心付配置(小程序应用 - 周期卡会员管理)
+     *
+     * <p>安心付对应的是支付宝小程序应用,与网页应用(alipay.trade.precreate 二维码付款)
+     * 使用不同的 appId、密钥和证书,因此需要独立的 AlipayClient。
+     *
+     * <p>支持两种模式(公钥模式优先):
+     * 1. 公钥模式(优先):配置 app-private-key + alipay-public-key
+     * 2. 证书模式(备选):配置 app-private-key-path + app-cert-path + alipay-cert-path + alipay-root-cert-path
+     *
+     * @see <a href="https://opendocs.alipay.com/solution/0d3p55">安心付-先享周期卡</a>
+     */
+    @Data
+    public static class AnxinAlipay {
+        /** 安心付小程序 appId(支付宝小程序应用ID) */
+        private String appId;
+        /** 应用私钥(RSA2),公钥模式时填写应用私钥字符串 */
+        private String appPrivateKey;
+        /** 应用私钥文件路径,证书模式时填写(设置后自动读取文件内容作为 appPrivateKey) */
+        private String appPrivateKeyPath;
+        /** 支付宝公钥(公钥模式验签用) */
+        private String alipayPublicKey;
+        /** 应用公钥证书路径(证书模式,.crt 文件) */
+        private String appCertPath;
+        /** 支付宝公钥证书路径(证书模式,.crt 文件) */
+        private String alipayCertPath;
+        /** 支付宝根证书路径(证书模式,.crt 文件) */
+        private String alipayRootCertPath;
+        /** 安心付-扣款通知回调URL(需公网可访问) */
+        private String notifyUrl;
+        /** 安心付-卡变更通知回调URL(需公网可访问) */
+        private String cardNotifyUrl;
         /** 支付宝网关地址 */
         private String serverUrl = "https://openapi.alipay.com/gateway.do";
         /** 数据格式(固定 json) */

+ 92 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/AgreementController.java

@@ -0,0 +1,92 @@
+package com.xuekairui.payment.controller;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.payment.dto.AgreementResponse;
+import com.xuekairui.payment.dto.AgreementSignRequest;
+import com.xuekairui.payment.dto.AgreementSignResponse;
+import com.xuekairui.payment.enums.PaymentChannel;
+import com.xuekairui.payment.service.PaymentAgreementService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 签约协议控制器(微信委托代扣自动续费)
+ *
+ * <p>提供用户端签约/解约/查询接口,以及微信委托代扣签约结果异步回调接收。
+ * <p><b>注意:支付宝自动续费已迁移至安心付(周期卡),见 {@link AlipayAnxinPayController}</b>
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/payment/agreement")
+@RequiredArgsConstructor
+public class AgreementController {
+
+    private final PaymentAgreementService agreementService;
+
+    /**
+     * 发起签约(开通自动续费)
+     */
+    @PostMapping("/sign")
+    public Result<AgreementSignResponse> sign(@Valid @RequestBody AgreementSignRequest request,
+                                              HttpServletRequest httpReq) {
+        Long userId = (Long) httpReq.getAttribute("userId");
+        return Result.success(agreementService.sign(userId, request));
+    }
+
+    /**
+     * 查询我的签约协议列表
+     */
+    @GetMapping("/list")
+    public Result<List<AgreementResponse>> listMyAgreements(HttpServletRequest httpReq) {
+        Long userId = (Long) httpReq.getAttribute("userId");
+        return Result.success(agreementService.listMyAgreements(userId));
+    }
+
+    /**
+     * 查询单个签约协议
+     */
+    @GetMapping("/{agreementId}")
+    public Result<AgreementResponse> getMyAgreement(@PathVariable Long agreementId,
+                                                    HttpServletRequest httpReq) {
+        Long userId = (Long) httpReq.getAttribute("userId");
+        return Result.success(agreementService.getMyAgreement(userId, agreementId));
+    }
+
+    /**
+     * 用户主动解约
+     */
+    @PostMapping("/{agreementId}/unsign")
+    public Result<Void> unsign(@PathVariable Long agreementId, HttpServletRequest httpReq) {
+        Long userId = (Long) httpReq.getAttribute("userId");
+        agreementService.unsign(userId, agreementId);
+        return Result.success(null);
+    }
+
+    // ======================== 第三方签约回调 ========================
+
+    /**
+     * 微信委托代扣签约结果异步通知
+     *
+     * <p>微信 v3 回调为加密 JSON,需用 WxPayService 解密后解析。
+     * 此处先读取请求体,由 Service 层处理(生产模式需补充解密逻辑)。
+     */
+    @PostMapping("/notify/wechat")
+    public String wechatSignNotify(HttpServletRequest request) {
+        Map<String, String> params = new HashMap<>();
+        request.getParameterMap().forEach((key, values) -> {
+            if (values != null && values.length > 0) {
+                params.put(key, values[0]);
+            }
+        });
+        // 微信 v3 回调为 JSON body,无法直接通过 parameterMap 获取,补充读取 body 中的关键字段
+        log.info("收到微信签约通知: params={}", params);
+        return agreementService.handleSignNotify(PaymentChannel.WECHAT, params);
+    }
+}

+ 195 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/AlipayAnxinPayController.java

@@ -0,0 +1,195 @@
+package com.xuekairui.payment.controller;
+
+import com.alipay.api.internal.util.AlipaySignature;
+import com.xuekairui.common.Result;
+import com.xuekairui.payment.config.PaymentProperties;
+import com.xuekairui.payment.dto.AnxinCardCreateRequest;
+import com.xuekairui.payment.dto.AnxinCardResponse;
+import com.xuekairui.payment.entity.AlipayAnxinCard;
+import com.xuekairui.payment.entity.PaymentPlan;
+import com.xuekairui.payment.service.AlipayAnxinPayService;
+import com.xuekairui.payment.service.PaymentPlanService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 支付宝安心付控制器(周期卡 - 小程序会员)
+ *
+ * <p>提供管理端卡模板管理接口和支付宝异步通知接收接口。
+ *
+ * <p><b>管理端接口:</b>
+ * <ul>
+ *   <li>POST /api/admin/payment/anxin/card/create — 为支付方案创建安心付周期卡模板</li>
+ *   <li>GET  /api/admin/payment/anxin/cards — 查询所有卡模板</li>
+ *   <li>GET  /api/admin/payment/anxin/card/{cardId}/sync — 同步卡模板状态</li>
+ *   <li>PUT  /api/admin/payment/anxin/card/{cardId}/status — 修改卡模板状态(上架/下架)</li>
+ * </ul>
+ *
+ * <p><b>支付宝通知接口:</b>
+ * <ul>
+ *   <li>POST /api/payment/anxin/notify/deduct — 扣款通知(核销订单变更通知)</li>
+ *   <li>POST /api/payment/anxin/notify/card-change — 卡变更通知(审核结果通知)</li>
+ * </ul>
+ */
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class AlipayAnxinPayController {
+
+    private final AlipayAnxinPayService anxinPayService;
+    private final PaymentPlanService paymentPlanService;
+    private final PaymentProperties paymentProperties;
+
+    // ======================== 管理端接口 ========================
+
+    /**
+     * 为支付方案创建安心付周期卡模板
+     */
+    @PostMapping("/api/admin/payment/anxin/card/create")
+    public Result<AnxinCardResponse> createCardTemplate(@Valid @RequestBody AnxinCardCreateRequest request) {
+        AlipayAnxinCard card = anxinPayService.createCardTemplate(request.getPlanId());
+        return Result.success(toResponse(card));
+    }
+
+    /**
+     * 查询所有安心付卡模板
+     */
+    @GetMapping("/api/admin/payment/anxin/cards")
+    public Result<List<AnxinCardResponse>> listCards() {
+        List<AlipayAnxinCard> cards = anxinPayService.listAllCards();
+        return Result.success(cards.stream().map(this::toResponse).collect(Collectors.toList()));
+    }
+
+    /**
+     * 同步卡模板状态(调用支付宝接口查询最新状态)
+     */
+    @PostMapping("/api/admin/payment/anxin/card/{cardId}/sync")
+    public Result<AnxinCardResponse> syncCardStatus(@PathVariable String cardId) {
+        AlipayAnxinCard card = anxinPayService.syncCardStatus(cardId);
+        return Result.success(toResponse(card));
+    }
+
+    /**
+     * 修改卡模板状态(上架/下架)
+     */
+    @PutMapping("/api/admin/payment/anxin/card/{cardId}/status")
+    public Result<Void> modifyCardStatus(@PathVariable String cardId,
+                                          @RequestBody Map<String, String> body) {
+        String action = body.get("status");
+        anxinPayService.modifyCardStatus(cardId, action);
+        return Result.success();
+    }
+
+    // ======================== 支付宝通知接口 ========================
+
+    /**
+     * 安心付扣款通知(核销订单变更通知)
+     *
+     * <p>支付宝在每次周期扣款后发送此通知。需在支付宝开放平台配置通知地址为:
+     * {@code https://your-domain.com/api/payment/anxin/notify/deduct}
+     *
+     * <p>通知格式为 form 表单,需 RSA2 验签。
+     */
+    @PostMapping("/api/payment/anxin/notify/deduct")
+    public String deductNotify(HttpServletRequest request) {
+        Map<String, String> params = extractAllParams(request);
+        log.info("收到安心付扣款通知: {}", params);
+
+        if (paymentProperties.isMock()) {
+            return anxinPayService.handleDeductNotify(params);
+        }
+
+        // 验签
+        if (!verifyAlipaySign(params)) {
+            log.error("安心付扣款通知验签失败");
+            return "fail";
+        }
+
+        return anxinPayService.handleDeductNotify(params);
+    }
+
+    /**
+     * 安心付卡变更通知(审核结果通知)
+     *
+     * <p>支付宝在卡模板审核通过/驳回/冻结/解冻时发送此通知。
+     * 需在支付宝开放平台配置通知地址为:
+     * {@code https://your-domain.com/api/payment/anxin/notify/card-change}
+     */
+    @PostMapping("/api/payment/anxin/notify/card-change")
+    public String cardChangeNotify(HttpServletRequest request) {
+        Map<String, String> params = extractAllParams(request);
+        log.info("收到安心付卡变更通知: {}", params);
+
+        if (paymentProperties.isMock()) {
+            return anxinPayService.handleCardChangeNotify(params);
+        }
+
+        // 验签
+        if (!verifyAlipaySign(params)) {
+            log.error("安心付卡变更通知验签失败");
+            return "fail";
+        }
+
+        return anxinPayService.handleCardChangeNotify(params);
+    }
+
+    // ======================== 工具方法 ========================
+
+    private Map<String, String> extractAllParams(HttpServletRequest request) {
+        Map<String, String> params = new HashMap<>();
+        request.getParameterMap().forEach((key, values) -> {
+            if (values != null && values.length > 0) {
+                params.put(key, values[0]);
+            }
+        });
+        return params;
+    }
+
+    private boolean verifyAlipaySign(Map<String, String> params) {
+        PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
+        try {
+            if (ax.getAlipayPublicKey() != null && !ax.getAlipayPublicKey().isBlank()) {
+                return AlipaySignature.rsaCheckV2(
+                        params, ax.getAlipayPublicKey(), ax.getCharset(), ax.getSignType());
+            }
+            log.error("安心付未配置公钥,无法验签");
+            return false;
+        } catch (Exception e) {
+            log.error("安心付验签异常: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    private AnxinCardResponse toResponse(AlipayAnxinCard card) {
+        String planName = null;
+        try {
+            PaymentPlan plan = paymentPlanService.getPlanById(card.getPlanId());
+            planName = plan.getPlanName();
+        } catch (Exception ignored) {
+        }
+
+        return AnxinCardResponse.builder()
+                .id(card.getId())
+                .planId(card.getPlanId())
+                .planName(planName)
+                .cardId(card.getCardId())
+                .cardName(card.getCardName())
+                .cardType(card.getCardType())
+                .periodAmount(card.getPeriodAmount())
+                .periodDays(card.getPeriodDays())
+                .totalPeriods(card.getTotalPeriods())
+                .status(card.getStatus())
+                .appointmentUrl(card.getAppointmentUrl())
+                .createTime(card.getCreateTime())
+                .updateTime(card.getUpdateTime())
+                .build();
+    }
+}

+ 32 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementResponse.java

@@ -0,0 +1,32 @@
+package com.xuekairui.payment.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 签约协议详情响应
+ */
+@Data
+@Builder
+public class AgreementResponse {
+
+    private Long id;
+    private Long userId;
+    private Long planId;
+    private String planName;
+    private String channel;
+    private String periodType;
+    private BigDecimal periodAmount;
+    private String status;
+    private String agreementNo;
+    private LocalDateTime signTime;
+    private LocalDateTime unsignTime;
+    private LocalDateTime nextDeductTime;
+    private LocalDateTime lastDeductTime;
+    private Integer consecutiveFailCount;
+    private String unsignReason;
+    private LocalDateTime createTime;
+}

+ 21 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignRequest.java

@@ -0,0 +1,21 @@
+package com.xuekairui.payment.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 签约请求(开通自动续费)
+ */
+@Data
+public class AgreementSignRequest {
+
+    @NotNull(message = "支付方案ID不能为空")
+    private Long planId;
+
+    @NotBlank(message = "支付渠道不能为空")
+    private String channel;
+
+    /** 周期类型:MONTH/YEAR(不传则按方案 durationDays 推断) */
+    private String periodType;
+}

+ 27 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignResponse.java

@@ -0,0 +1,27 @@
+package com.xuekairui.payment.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 签约响应
+ */
+@Data
+@Builder
+public class AgreementSignResponse {
+
+    /** 协议ID(本地记录) */
+    private Long agreementId;
+
+    /** 商家自定义协议号 */
+    private String externalAgreementNo;
+
+    /** 签约页URL(前端跳转/展示,支付宝为H5签约页,微信为签约短链) */
+    private String signUrl;
+
+    /** 二维码内容(供本地生成二维码,扫码签约) */
+    private String qrContent;
+
+    /** 提示文案 */
+    private String hint;
+}

+ 14 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardCreateRequest.java

@@ -0,0 +1,14 @@
+package com.xuekairui.payment.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 安心付卡模板创建请求
+ */
+@Data
+public class AnxinCardCreateRequest {
+
+    @NotNull(message = "支付方案ID不能为空")
+    private Long planId;
+}

+ 29 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardResponse.java

@@ -0,0 +1,29 @@
+package com.xuekairui.payment.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 安心付卡模板响应
+ */
+@Data
+@Builder
+public class AnxinCardResponse {
+
+    private Long id;
+    private Long planId;
+    private String planName;
+    private String cardId;
+    private String cardName;
+    private String cardType;
+    private BigDecimal periodAmount;
+    private Integer periodDays;
+    private Integer totalPeriods;
+    private String status;
+    private String appointmentUrl;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 65 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/AlipayAnxinCard.java

@@ -0,0 +1,65 @@
+package com.xuekairui.payment.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 支付宝安心付卡模板实体(周期卡)
+ *
+ * <p>记录支付宝安心付产品中创建的周期卡模板,与 {@link PaymentPlan} 一一对应。
+ * 用户通过支付宝小程序购买周期卡后,支付宝按周期自动扣款并回调通知。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_alipay_anxin_card")
+public class AlipayAnxinCard {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 关联的支付方案ID(t_payment_plan.id) */
+    private Long planId;
+
+    /** 支付宝卡模板ID(创建卡模板后返回) */
+    private String cardId;
+
+    /** 卡名称 */
+    private String cardName;
+
+    /** 卡类型:PERIOD_PAY(周期卡) */
+    @Builder.Default
+    private String cardType = "PERIOD_PAY";
+
+    /** 每期扣款金额(元) */
+    private BigDecimal periodAmount;
+
+    /** 扣款周期天数 */
+    private Integer periodDays;
+
+    /** 总期数(NULL表示无限期) */
+    private Integer totalPeriods;
+
+    /** 卡模板状态:PENDING(审核中)/ EFFECTIVE(生效)/ FROZEN(冻结)/ INVALID(失效) */
+    @Builder.Default
+    private String status = "PENDING";
+
+    /** 预约链接(小程序跳转地址) */
+    private String appointmentUrl;
+
+    /** 原始回调参数(JSON,存储创建时的完整响应) */
+    private String rawResponse;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 72 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/AlipayAnxinOrderRecord.java

@@ -0,0 +1,72 @@
+package com.xuekairui.payment.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 支付宝安心付扣款订单记录
+ *
+ * <p>记录每一次支付宝安心付周期卡扣款的通知信息,用于会员续期和审计。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_alipay_anxin_order_record")
+public class AlipayAnxinOrderRecord {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 系统用户ID(通过 out_biz_no 匹配) */
+    private Long userId;
+
+    /** 关联的支付方案ID */
+    private Long planId;
+
+    /** 支付宝卡模板ID */
+    private String cardId;
+
+    /** 支付宝售卖订单ID */
+    private String orderId;
+
+    /** 支付宝核销/扣款子订单ID */
+    private String subOrderId;
+
+    /** 商家业务号(存储系统用户ID,用于回匹配) */
+    private String outBizNo;
+
+    /** 支付宝用户ID */
+    private String alipayUserId;
+
+    /** 扣款金额(元) */
+    private BigDecimal deductAmount;
+
+    /** 扣款状态:SUCCESS / FAIL */
+    private String deductStatus;
+
+    /** 扣款时间 */
+    private LocalDateTime deductTime;
+
+    /** 会员是否已激活 */
+    @Builder.Default
+    private Integer membershipActivated = 0;
+
+    /** 关联的支付订单号 */
+    private String orderNo;
+
+    /** 原始通知参数(JSON) */
+    private String rawParams;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 77 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/entity/PaymentAgreement.java

@@ -0,0 +1,77 @@
+package com.xuekairui.payment.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 支付签约协议实体(周期扣款/委托代扣)
+ *
+ * <p>记录用户与支付宝/微信签订的自动续费协议,是定时扣款的核心数据。
+ * 一条协议对应一个用户+一个周期套餐,扣款成功后推进 next_deduct_time。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_payment_agreement")
+public class PaymentAgreement {
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 支付方案ID(关联 t_payment_plan) */
+    private Long planId;
+
+    /** 支付渠道:WECHAT/ALIPAY */
+    private String channel;
+
+    /** 周期类型:MONTH/YEAR */
+    private String periodType;
+
+    /** 每期扣款金额(元) */
+    private BigDecimal periodAmount;
+
+    /** 协议状态:PENDING/SIGNED/UNSIGNED/FAILED */
+    @Builder.Default
+    private String status = "PENDING";
+
+    /** 第三方协议号(支付宝 agreement_no / 微信 contract_id),扣款时使用 */
+    private String agreementNo;
+
+    /** 商家自定义协议号(幂等用,发起签约时生成) */
+    private String externalAgreementNo;
+
+    /** 签约时间 */
+    private LocalDateTime signTime;
+
+    /** 解约时间 */
+    private LocalDateTime unsignTime;
+
+    /** 下次扣款时间(定时任务扫描依据) */
+    private LocalDateTime nextDeductTime;
+
+    /** 上次扣款时间 */
+    private LocalDateTime lastDeductTime;
+
+    /** 连续扣款失败次数(达到上限自动解约) */
+    @Builder.Default
+    private Integer consecutiveFailCount = 0;
+
+    /** 解约原因 */
+    private String unsignReason;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

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

@@ -43,6 +43,13 @@ public class PaymentOrder {
     /** 二维码内容/URL */
     private String qrCodeUrl;
 
+    /** 订单类型:NORMAL(普通支付) / AUTO_DEDUCT(自动续费扣款) */
+    @Builder.Default
+    private String orderType = "NORMAL";
+
+    /** 关联的签约协议ID(仅 AUTO_DEDUCT 订单有值) */
+    private Long agreementId;
+
     /** 第三方交易号 */
     private String tradeNo;
 

+ 35 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/AgreementStatus.java

@@ -0,0 +1,35 @@
+package com.xuekairui.payment.enums;
+
+import lombok.Getter;
+
+/**
+ * 签约协议状态
+ */
+@Getter
+public enum AgreementStatus {
+
+    /** 签约中(已发起,等待用户确认/回调) */
+    PENDING("PENDING"),
+    /** 已签约 */
+    SIGNED("SIGNED"),
+    /** 已解约 */
+    UNSIGNED("UNSIGNED"),
+    /** 签约失败 */
+    FAILED("FAILED");
+
+    private final String code;
+
+    AgreementStatus(String code) {
+        this.code = code;
+    }
+
+    public static AgreementStatus fromCode(String code) {
+        if (code == null) return null;
+        for (AgreementStatus s : values()) {
+            if (s.code.equalsIgnoreCase(code)) {
+                return s;
+            }
+        }
+        return null;
+    }
+}

+ 31 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/OrderType.java

@@ -0,0 +1,31 @@
+package com.xuekairui.payment.enums;
+
+import lombok.Getter;
+
+/**
+ * 支付订单类型
+ */
+@Getter
+public enum OrderType {
+
+    /** 普通一次性支付 */
+    NORMAL("NORMAL"),
+    /** 自动续费扣款(基于签约协议) */
+    AUTO_DEDUCT("AUTO_DEDUCT");
+
+    private final String code;
+
+    OrderType(String code) {
+        this.code = code;
+    }
+
+    public static OrderType fromCode(String code) {
+        if (code == null) return NORMAL;
+        for (OrderType t : values()) {
+            if (t.code.equalsIgnoreCase(code)) {
+                return t;
+            }
+        }
+        return NORMAL;
+    }
+}

+ 34 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/enums/PeriodType.java

@@ -0,0 +1,34 @@
+package com.xuekairui.payment.enums;
+
+import lombok.Getter;
+
+/**
+ * 自动续费周期类型
+ */
+@Getter
+public enum PeriodType {
+
+    /** 按月 */
+    MONTH("MONTH", 30),
+    /** 按年 */
+    YEAR("YEAR", 365);
+
+    private final String code;
+    /** 每个周期对应天数(用于会员续期与下次扣款时间计算) */
+    private final int days;
+
+    PeriodType(String code, int days) {
+        this.code = code;
+        this.days = days;
+    }
+
+    public static PeriodType fromCode(String code) {
+        if (code == null) return null;
+        for (PeriodType p : values()) {
+            if (p.code.equalsIgnoreCase(code)) {
+                return p;
+            }
+        }
+        return null;
+    }
+}

+ 22 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/AlipayAnxinCardMapper.java

@@ -0,0 +1,22 @@
+package com.xuekairui.payment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.payment.entity.AlipayAnxinCard;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 支付宝安心付卡模板 Mapper
+ */
+@Mapper
+public interface AlipayAnxinCardMapper extends BaseMapper<AlipayAnxinCard> {
+
+    /** 根据支付方案ID查询卡模板 */
+    @Select("SELECT * FROM t_alipay_anxin_card WHERE plan_id = #{planId} LIMIT 1")
+    AlipayAnxinCard selectByPlanId(@Param("planId") Long planId);
+
+    /** 根据支付宝卡模板ID查询 */
+    @Select("SELECT * FROM t_alipay_anxin_card WHERE card_id = #{cardId} LIMIT 1")
+    AlipayAnxinCard selectByCardId(@Param("cardId") String cardId);
+}

+ 22 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/AlipayAnxinOrderRecordMapper.java

@@ -0,0 +1,22 @@
+package com.xuekairui.payment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.payment.entity.AlipayAnxinOrderRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 支付宝安心付扣款订单记录 Mapper
+ */
+@Mapper
+public interface AlipayAnxinOrderRecordMapper extends BaseMapper<AlipayAnxinOrderRecord> {
+
+    /** 根据支付宝子订单ID查询(幂等用) */
+    @Select("SELECT * FROM t_alipay_anxin_order_record WHERE sub_order_id = #{subOrderId} LIMIT 1")
+    AlipayAnxinOrderRecord selectBySubOrderId(@Param("subOrderId") String subOrderId);
+
+    /** 根据支付宝售卖订单ID查询 */
+    @Select("SELECT * FROM t_alipay_anxin_order_record WHERE order_id = #{orderId} ORDER BY create_time DESC LIMIT 1")
+    AlipayAnxinOrderRecord selectByOrderId(@Param("orderId") String orderId);
+}

+ 37 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/mapper/PaymentAgreementMapper.java

@@ -0,0 +1,37 @@
+package com.xuekairui.payment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.payment.entity.PaymentAgreement;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 签约协议 Mapper
+ */
+@Mapper
+public interface PaymentAgreementMapper extends BaseMapper<PaymentAgreement> {
+
+    /** 查询用户某方案的有效协议(已签约) */
+    @Select("SELECT * FROM t_payment_agreement WHERE user_id = #{userId} AND plan_id = #{planId} AND status = 'SIGNED' LIMIT 1")
+    PaymentAgreement selectSignedByUserAndPlan(@Param("userId") Long userId, @Param("planId") Long planId);
+
+    /** 查询用户所有有效协议 */
+    @Select("SELECT * FROM t_payment_agreement WHERE user_id = #{userId} AND status = 'SIGNED' ORDER BY create_time DESC")
+    List<PaymentAgreement> selectSignedByUser(@Param("userId") Long userId);
+
+    /** 根据第三方协议号查询 */
+    @Select("SELECT * FROM t_payment_agreement WHERE agreement_no = #{agreementNo} LIMIT 1")
+    PaymentAgreement selectByAgreementNo(@Param("agreementNo") String agreementNo);
+
+    /** 根据商家自定义协议号查询 */
+    @Select("SELECT * FROM t_payment_agreement WHERE external_agreement_no = #{externalNo} LIMIT 1")
+    PaymentAgreement selectByExternalNo(@Param("externalNo") String externalNo);
+
+    /** 查询需要扣款的协议(已签约且到达下次扣款时间) */
+    @Select("SELECT * FROM t_payment_agreement WHERE status = 'SIGNED' AND next_deduct_time IS NOT NULL AND next_deduct_time <= #{now} ORDER BY next_deduct_time ASC")
+    List<PaymentAgreement> selectDueForDeduct(@Param("now") LocalDateTime now);
+}

+ 596 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/AlipayAnxinPayService.java

@@ -0,0 +1,596 @@
+package com.xuekairui.payment.service;
+
+import cn.hutool.core.util.IdUtil;
+import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayCommerceMerchantcardTemplateCreateModel;
+import com.alipay.api.domain.CardCycle;
+import com.alipay.api.domain.CardPeriodPrice;
+import com.alipay.api.domain.CardTemplateSale;
+import com.alipay.api.domain.CardTemplateUse;
+import com.alipay.api.domain.MerchantCardTemplate;
+import com.alipay.api.request.AlipayCommerceMerchantcardTemplateCreateRequest;
+import com.alipay.api.request.AlipayCommerceMerchantcardTemplateQueryRequest;
+import com.alipay.api.request.AlipayCommerceMerchantcardTemplatestatusModifyRequest;
+import com.alipay.api.response.AlipayCommerceMerchantcardTemplateCreateResponse;
+import com.alipay.api.response.AlipayCommerceMerchantcardTemplateQueryResponse;
+import com.alipay.api.response.AlipayCommerceMerchantcardTemplatestatusModifyResponse;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.payment.config.PaymentProperties;
+import com.xuekairui.payment.entity.AlipayAnxinCard;
+import com.xuekairui.payment.entity.AlipayAnxinOrderRecord;
+import com.xuekairui.payment.entity.PaymentPlan;
+import com.xuekairui.payment.mapper.AlipayAnxinCardMapper;
+import com.xuekairui.payment.mapper.AlipayAnxinOrderRecordMapper;
+import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.service.MembershipService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 支付宝安心付服务(周期卡 - 小程序会员)
+ *
+ * <p>封装支付宝安心付「周期卡」的卡模板管理、扣款通知处理、会员续期流程。
+ * <ul>
+ *   <li>卡模板管理:调用 alipay.commerce.merchantcard.template.create 创建周期卡模板,与 PaymentPlan 一一对应</li>
+ *   <li>扣款通知:接收支付宝周期卡扣款异步通知,激活/续期会员</li>
+ *   <li>卡变更通知:接收卡模板审核结果通知(通过/驳回/冻结/解冻)</li>
+ * </ul>
+ *
+ * <p><b>支付宝安心付接入流程:</b>
+ * <ol>
+ *   <li>在支付宝开放平台开通「安心付」产品</li>
+ *   <li>调用 {@link #createCardTemplate} 为每个支付方案创建周期卡模板</li>
+ *   <li>支付宝审核通过后,卡模板状态变为 EFFECTIVE</li>
+ *   <li>用户通过支付宝小程序购买周期卡(小程序端调用 my.commerceMerchantCardOrder)</li>
+ *   <li>支付宝按周期自动扣款,并通过回调通知服务端</li>
+ *   <li>服务端收到扣款通知后激活/续期会员</li>
+ * </ol>
+ *
+ * @see <a href="https://opendocs.alipay.com/solution/0d3p55">安心付-先享周期卡</a>
+ */
+@Slf4j
+@Service
+public class AlipayAnxinPayService {
+
+    private final AlipayAnxinCardMapper anxinCardMapper;
+    private final AlipayAnxinOrderRecordMapper anxinOrderRecordMapper;
+    private final PaymentPlanService paymentPlanService;
+    private final PaymentProperties paymentProperties;
+    private final MembershipService membershipService;
+    private final ObjectProvider<AlipayClient> anxinAlipayClientProvider;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public AlipayAnxinPayService(
+            AlipayAnxinCardMapper anxinCardMapper,
+            AlipayAnxinOrderRecordMapper anxinOrderRecordMapper,
+            PaymentPlanService paymentPlanService,
+            PaymentProperties paymentProperties,
+            MembershipService membershipService,
+            @Qualifier("anxinAlipayClient") ObjectProvider<AlipayClient> anxinAlipayClientProvider) {
+        this.anxinCardMapper = anxinCardMapper;
+        this.anxinOrderRecordMapper = anxinOrderRecordMapper;
+        this.paymentPlanService = paymentPlanService;
+        this.paymentProperties = paymentProperties;
+        this.membershipService = membershipService;
+        this.anxinAlipayClientProvider = anxinAlipayClientProvider;
+    }
+
+    // ======================== 卡模板管理 ========================
+
+    /**
+     * 为支付方案创建安心付周期卡模板
+     *
+     * <p>调用 alipay.commerce.merchantcard.template.create 接口创建卡模板,
+     * 创建后状态为 PENDING(待审核),支付宝审核通过后变为 EFFECTIVE。
+     *
+     * @param planId 支付方案ID
+     * @return 创建的卡模板记录
+     */
+    @Transactional
+    public AlipayAnxinCard createCardTemplate(Long planId) {
+        PaymentPlan plan = paymentPlanService.getPlanById(planId);
+        if (plan.getPrice() == null || plan.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "支付方案价格必须大于0");
+        }
+
+        // 检查是否已创建过卡模板
+        AlipayAnxinCard existing = anxinCardMapper.selectByPlanId(planId);
+        if (existing != null && existing.getCardId() != null) {
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_CREATE_FAILED,
+                    "该方案已创建安心付卡模板: cardId=" + existing.getCardId());
+        }
+
+        if (paymentProperties.isMock()) {
+            return mockCreateCardTemplate(plan);
+        }
+
+        return doCreateCardTemplate(plan, existing);
+    }
+
+    /**
+     * 查询卡模板状态(从本地数据库)
+     */
+    public AlipayAnxinCard getCardByPlanId(Long planId) {
+        AlipayAnxinCard card = anxinCardMapper.selectByPlanId(planId);
+        if (card == null) {
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_NOT_FOUND);
+        }
+        return card;
+    }
+
+    /**
+     * 查询所有卡模板
+     */
+    public List<AlipayAnxinCard> listAllCards() {
+        return anxinCardMapper.selectList(null);
+    }
+
+    /**
+     * 同步卡模板状态(调用支付宝接口查询最新状态)
+     *
+     * @param cardId 支付宝卡模板ID
+     * @return 更新后的卡模板记录
+     */
+    @Transactional
+    public AlipayAnxinCard syncCardStatus(String cardId) {
+        AlipayAnxinCard card = anxinCardMapper.selectByCardId(cardId);
+        if (card == null) {
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_NOT_FOUND);
+        }
+
+        if (paymentProperties.isMock()) {
+            return card;
+        }
+
+        AlipayClient client = anxinAlipayClientProvider.getIfAvailable();
+        if (client == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED, "安心付AlipayClient未初始化");
+        }
+
+        try {
+            AlipayCommerceMerchantcardTemplateQueryRequest request = new AlipayCommerceMerchantcardTemplateQueryRequest();
+            request.setBizContent("{\"card_id\":\"" + cardId + "\"}");
+
+            boolean publicKeyMode = isPublicKeyMode();
+            AlipayCommerceMerchantcardTemplateQueryResponse response = publicKeyMode
+                    ? client.execute(request)
+                    : client.certificateExecute(request);
+
+            if (response.isSuccess()) {
+                String status = response.getBody();
+                log.info("查询安心付卡模板状态: cardId={}, body={}", cardId, status);
+                // 解析状态并更新
+                updateCardStatusFromResponse(card, response);
+            }
+        } catch (Exception e) {
+            log.error("查询安心付卡模板状态失败: cardId={}", cardId, e);
+        }
+
+        return card;
+    }
+
+    /**
+     * 修改卡模板状态(上架/下架/冻结)
+     *
+     * @param cardId 支付宝卡模板ID
+     * @param action 操作类型:EFFECTIVE(上架)/ INVALID(下架)/ FROZEN(冻结)
+     */
+    @Transactional
+    public void modifyCardStatus(String cardId, String action) {
+        AlipayAnxinCard card = anxinCardMapper.selectByCardId(cardId);
+        if (card == null) {
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_NOT_FOUND);
+        }
+
+        if (paymentProperties.isMock()) {
+            card.setStatus(action);
+            anxinCardMapper.updateById(card);
+            log.info("【模拟】修改安心付卡模板状态: cardId={}, action={}", cardId, action);
+            return;
+        }
+
+        AlipayClient client = anxinAlipayClientProvider.getIfAvailable();
+        if (client == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED, "安心付AlipayClient未初始化");
+        }
+
+        try {
+            AlipayCommerceMerchantcardTemplatestatusModifyRequest request =
+                    new AlipayCommerceMerchantcardTemplatestatusModifyRequest();
+            request.setBizContent("{\"card_id\":\"" + cardId + "\",\"status\":\"" + action + "\"}");
+
+            boolean publicKeyMode = isPublicKeyMode();
+            AlipayCommerceMerchantcardTemplatestatusModifyResponse response = publicKeyMode
+                    ? client.execute(request)
+                    : client.certificateExecute(request);
+
+            if (response.isSuccess()) {
+                card.setStatus(action);
+                anxinCardMapper.updateById(card);
+                log.info("修改安心付卡模板状态成功: cardId={}, action={}", cardId, action);
+            } else {
+                log.error("修改安心付卡模板状态失败: cardId={}, subCode={}, subMsg={}",
+                        cardId, response.getSubCode(), response.getSubMsg());
+                throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_CREATE_FAILED,
+                        "修改卡模板状态失败: " + response.getSubMsg());
+            }
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("修改安心付卡模板状态异常: cardId={}", cardId, e);
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_CREATE_FAILED,
+                    "修改卡模板状态异常: " + e.getMessage());
+        }
+    }
+
+    // ======================== 扣款通知处理 ========================
+
+    /**
+     * 处理安心付扣款通知(核销订单变更通知)
+     *
+     * <p>支付宝在每次周期扣款后发送此通知,通知内容包括:
+     * <ul>
+     *   <li>card_id — 卡模板ID</li>
+     *   <li>order_id — 售卖订单ID</li>
+     *   <li>sub_order_id — 核销子订单ID(用于幂等)</li>
+     *   <li>deduct_amount — 扣款金额</li>
+     *   <li>deduct_status — 扣款状态(SUCCESS/FAIL)</li>
+     *   <li>deduct_time — 扣款时间</li>
+     *   <li>out_biz_no — 商家业务号(存储系统用户ID)</li>
+     *   <li>user_id — 支付宝用户ID</li>
+     * </ul>
+     *
+     * @param params 通知参数
+     * @return 处理结果(success/fail)
+     */
+    @Transactional
+    public String handleDeductNotify(Map<String, String> params) {
+        log.info("收到安心付扣款通知: {}", maskSensitiveParams(params));
+
+        String subOrderId = params.get("sub_order_id");
+        String orderId = params.get("order_id");
+        String cardId = params.get("card_id");
+        String deductStatus = params.get("deduct_status");
+        String deductAmount = params.get("deduct_amount");
+        String deductTime = params.get("deduct_time");
+        String outBizNo = params.get("out_biz_no");
+        String alipayUserId = params.get("user_id");
+
+        if (subOrderId == null) {
+            log.warn("安心付扣款通知缺少 sub_order_id");
+            return "fail";
+        }
+
+        // 幂等:已处理过的扣款通知直接返回 success
+        AlipayAnxinOrderRecord existing = anxinOrderRecordMapper.selectBySubOrderId(subOrderId);
+        if (existing != null && existing.getMembershipActivated() == 1) {
+            log.info("安心付扣款通知已处理,跳过: subOrderId={}", subOrderId);
+            return "success";
+        }
+
+        // 查找关联的卡模板和支付方案
+        AlipayAnxinCard card = cardId != null ? anxinCardMapper.selectByCardId(cardId) : null;
+        if (card == null) {
+            log.warn("安心付扣款通知未匹配到卡模板: cardId={}", cardId);
+            // 仍然返回 success 避免支付宝重复通知
+            return "success";
+        }
+
+        PaymentPlan plan = paymentPlanService.getPlanById(card.getPlanId());
+
+        // 解析系统用户ID(通过 out_biz_no 传入的系统用户ID)
+        Long userId = parseUserIdFromOutBizNo(outBizNo);
+        if (userId == null) {
+            log.warn("安心付扣款通知无法匹配系统用户: outBizNo={}, alipayUserId={}", outBizNo, alipayUserId);
+            // 记录通知但不激活会员
+            saveOrderRecord(params, card, plan, null, deductStatus, deductAmount, deductTime);
+            return "success";
+        }
+
+        // 保存扣款记录
+        AlipayAnxinOrderRecord record = saveOrderRecord(params, card, plan, userId, deductStatus, deductAmount, deductTime);
+
+        // 扣款成功时激活/续期会员
+        if ("SUCCESS".equalsIgnoreCase(deductStatus)) {
+            try {
+                activateMembership(userId, plan, record);
+            } catch (Exception e) {
+                log.error("安心付扣款后会员激活失败: userId={}, subOrderId={}", userId, subOrderId, e);
+            }
+        }
+
+        return "success";
+    }
+
+    /**
+     * 处理安心付卡变更通知(卡模板审核结果通知)
+     *
+     * <p>支付宝在卡模板审核通过/驳回/冻结/解冻时发送此通知。
+     *
+     * @param params 通知参数
+     * @return 处理结果(success/fail)
+     */
+    @Transactional
+    public String handleCardChangeNotify(Map<String, String> params) {
+        log.info("收到安心付卡变更通知: {}", params);
+
+        String cardId = params.get("card_id");
+        String changeType = params.get("change_type");
+        String status = params.get("status");
+
+        if (cardId == null) {
+            log.warn("安心付卡变更通知缺少 card_id");
+            return "success";
+        }
+
+        AlipayAnxinCard card = anxinCardMapper.selectByCardId(cardId);
+        if (card == null) {
+            log.warn("安心付卡变更通知未匹配到卡模板: cardId={}", cardId);
+            return "success";
+        }
+
+        // 更新卡模板状态
+        if (status != null && !status.isBlank()) {
+            card.setStatus(status);
+        }
+        anxinCardMapper.updateById(card);
+        log.info("安心付卡模板状态更新: cardId={}, changeType={}, status={}", cardId, changeType, status);
+
+        return "success";
+    }
+
+    // ======================== 内部方法 ========================
+
+    private AlipayAnxinCard doCreateCardTemplate(PaymentPlan plan, AlipayAnxinCard existing) {
+        PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
+        AlipayClient client = anxinAlipayClientProvider.getIfAvailable();
+        if (client == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED, "安心付AlipayClient未初始化");
+        }
+
+        try {
+            AlipayCommerceMerchantcardTemplateCreateRequest request =
+                    new AlipayCommerceMerchantcardTemplateCreateRequest();
+
+            // 设置通知地址(优先使用安心付专用配置,回退到支付宝通用配置)
+            String notifyUrl = ax.getNotifyUrl() != null && !ax.getNotifyUrl().isBlank()
+                    ? ax.getNotifyUrl() : paymentProperties.getAlipay().getNotifyUrl();
+            request.setNotifyUrl(notifyUrl);
+
+            // 构建卡模板对象
+            MerchantCardTemplate cardTemplate = new MerchantCardTemplate();
+            cardTemplate.setCardType("PERIOD_PAY");
+            cardTemplate.setCardTemplateName(plan.getPlanName());
+            cardTemplate.setOutCardId("plan_" + plan.getId());
+            cardTemplate.setNeedOrderAgreement(true);
+
+            // 设置小程序appId(安心付小程序应用ID)
+            if (ax.getAppId() != null && !ax.getAppId().isBlank()) {
+                cardTemplate.setCardTemplateAppId(ax.getAppId());
+            }
+
+            // 构建使用信息(价格+周期)
+            CardTemplateUse useInfo = new CardTemplateUse();
+            useInfo.setPriceMode("PERIOD_PRICE");
+
+            // 周期价格列表(价格单位:分)
+            long priceInCents = plan.getPrice().multiply(new BigDecimal("100")).longValue();
+            CardPeriodPrice periodPrice = new CardPeriodPrice();
+            periodPrice.setPeriod(1L);
+            periodPrice.setSalePrice(priceInCents);
+            periodPrice.setOriginalPrice(priceInCents);
+            useInfo.setPeriodPriceList(List.of(periodPrice));
+
+            // 周期扣款信息
+            CardCycle cycleInfo = new CardCycle();
+            cycleInfo.setCycleType("DAY");
+            cycleInfo.setCycleValue(String.valueOf(plan.getDurationDays()));
+            cycleInfo.setCycleChargeType("PERIOD");
+            cycleInfo.setChargeNow(true);
+            useInfo.setCycleInfo(cycleInfo);
+
+            cardTemplate.setUseInfo(useInfo);
+
+            // 构建售卖信息
+            CardTemplateSale saleInfo = new CardTemplateSale();
+            cardTemplate.setSaleInfo(saleInfo);
+
+            AlipayCommerceMerchantcardTemplateCreateModel model = new AlipayCommerceMerchantcardTemplateCreateModel();
+            model.setCardTemplate(cardTemplate);
+
+            request.setBizModel(model);
+
+            boolean publicKeyMode = isPublicKeyMode();
+            AlipayCommerceMerchantcardTemplateCreateResponse response = publicKeyMode
+                    ? client.execute(request)
+                    : client.certificateExecute(request);
+
+            if (!response.isSuccess()) {
+                log.error("安心付卡模板创建失败: planId={}, code={}, msg={}, subCode={}, subMsg={}",
+                        plan.getId(), response.getCode(), response.getMsg(),
+                        response.getSubCode(), response.getSubMsg());
+                throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_CREATE_FAILED,
+                        "创建卡模板失败: " + response.getSubMsg());
+            }
+
+            String cardId = response.getCardTemplateId();
+            log.info("安心付卡模板创建成功: planId={}, cardId={}", plan.getId(), cardId);
+
+            // 保存或更新卡模板记录
+            AlipayAnxinCard card = existing != null ? existing : new AlipayAnxinCard();
+            card.setPlanId(plan.getId());
+            card.setCardId(cardId);
+            card.setCardName(plan.getPlanName());
+            card.setCardType("PERIOD_PAY");
+            card.setPeriodAmount(plan.getPrice());
+            card.setPeriodDays(plan.getDurationDays());
+            card.setStatus("PENDING");
+            card.setRawResponse(response.getBody());
+
+            if (existing != null) {
+                anxinCardMapper.updateById(card);
+            } else {
+                anxinCardMapper.insert(card);
+            }
+
+            return card;
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("安心付卡模板创建异常: planId={}", plan.getId(), e);
+            throw new BusinessException(ErrorCode.PAY_ANXIN_CARD_CREATE_FAILED,
+                    "创建卡模板异常: " + e.getMessage());
+        }
+    }
+
+    private AlipayAnxinCard mockCreateCardTemplate(PaymentPlan plan) {
+        String mockCardId = "MOCK_CARD_" + IdUtil.getSnowflake(1, 1).nextIdStr();
+        log.info("【模拟】创建安心付卡模板: planId={}, cardId={}", plan.getId(), mockCardId);
+
+        AlipayAnxinCard card = AlipayAnxinCard.builder()
+                .planId(plan.getId())
+                .cardId(mockCardId)
+                .cardName(plan.getPlanName())
+                .cardType("PERIOD_PAY")
+                .periodAmount(plan.getPrice())
+                .periodDays(plan.getDurationDays())
+                .status("EFFECTIVE")
+                .appointmentUrl("alipays://platformapi/startapp?appId=mock&cardId=" + mockCardId)
+                .rawResponse("{\"mock\":true}")
+                .build();
+        anxinCardMapper.insert(card);
+        return card;
+    }
+
+    private void updateCardStatusFromResponse(AlipayAnxinCard card, AlipayCommerceMerchantcardTemplateQueryResponse response) {
+        try {
+            String body = response.getBody();
+            if (body != null) {
+                Map<String, Object> map = objectMapper.readValue(body, Map.class);
+                Object status = map.get("status");
+                if (status != null) {
+                    card.setStatus(status.toString());
+                    anxinCardMapper.updateById(card);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析卡模板状态响应失败: {}", e.getMessage());
+        }
+    }
+
+    private AlipayAnxinOrderRecord saveOrderRecord(Map<String, String> params, AlipayAnxinCard card,
+                                                      PaymentPlan plan, Long userId, String deductStatus,
+                                                      String deductAmount, String deductTime) {
+        String subOrderId = params.get("sub_order_id");
+        String orderId = params.get("order_id");
+        String outBizNo = params.get("out_biz_no");
+        String alipayUserId = params.get("user_id");
+
+        // 幂等:如果已存在则更新
+        AlipayAnxinOrderRecord record = subOrderId != null
+                ? anxinOrderRecordMapper.selectBySubOrderId(subOrderId) : null;
+        if (record == null) {
+            record = new AlipayAnxinOrderRecord();
+        }
+
+        record.setUserId(userId);
+        record.setPlanId(plan.getId());
+        record.setCardId(card.getCardId());
+        record.setOrderId(orderId);
+        record.setSubOrderId(subOrderId);
+        record.setOutBizNo(outBizNo);
+        record.setAlipayUserId(alipayUserId);
+        record.setDeductStatus(deductStatus != null ? deductStatus.toUpperCase() : null);
+        record.setDeductAmount(deductAmount != null ? new BigDecimal(deductAmount) : null);
+        record.setDeductTime(parseDateTime(deductTime));
+        record.setRawParams(toJson(params));
+
+        if (record.getId() != null) {
+            anxinOrderRecordMapper.updateById(record);
+        } else {
+            anxinOrderRecordMapper.insert(record);
+        }
+
+        return record;
+    }
+
+    private void activateMembership(Long userId, PaymentPlan plan, AlipayAnxinOrderRecord record) {
+        MembershipLevel level = MembershipLevel.fromCode(plan.getMembershipLevel());
+        if (level == null) {
+            log.warn("安心付扣款通知:无法识别会员等级: {}", plan.getMembershipLevel());
+            return;
+        }
+
+        // 生成支付订单号(用于会员激活记录)
+        String orderNo = "AX" + IdUtil.getSnowflake(1, 1).nextIdStr();
+        record.setOrderNo(orderNo);
+        record.setMembershipActivated(1);
+        anxinOrderRecordMapper.updateById(record);
+
+        // 激活/续期会员
+        membershipService.activateFromPayment(userId, level, plan.getDurationDays(), orderNo);
+        log.info("安心付扣款会员激活成功: userId={}, level={}, days={}, orderNo={}",
+                userId, level.getCode(), plan.getDurationDays(), orderNo);
+    }
+
+    private Long parseUserIdFromOutBizNo(String outBizNo) {
+        if (outBizNo == null || outBizNo.isBlank()) {
+            return null;
+        }
+        try {
+            return Long.parseLong(outBizNo);
+        } catch (NumberFormatException e) {
+            log.warn("无法从 out_biz_no 解析用户ID: {}", outBizNo);
+            return null;
+        }
+    }
+
+    private LocalDateTime parseDateTime(String dateTimeStr) {
+        if (dateTimeStr == null || dateTimeStr.isBlank()) {
+            return LocalDateTime.now();
+        }
+        try {
+            return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        } catch (Exception e) {
+            try {
+                return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+            } catch (Exception e2) {
+                return LocalDateTime.now();
+            }
+        }
+    }
+
+    private String toJson(Map<String, String> params) {
+        try {
+            return objectMapper.writeValueAsString(params);
+        } catch (Exception e) {
+            return params.toString();
+        }
+    }
+
+    private Map<String, String> maskSensitiveParams(Map<String, String> params) {
+        Map<String, String> masked = new HashMap<>(params);
+        if (masked.containsKey("sign")) {
+            masked.put("sign", "***");
+        }
+        return masked;
+    }
+
+    private boolean isPublicKeyMode() {
+        PaymentProperties.AnxinAlipay ax = paymentProperties.getAnxinAlipay();
+        return ax.getAlipayPublicKey() != null && !ax.getAlipayPublicKey().isBlank();
+    }
+}

+ 51 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/AutoDeductScheduler.java

@@ -0,0 +1,51 @@
+package com.xuekairui.payment.service;
+
+import com.xuekairui.payment.entity.PaymentAgreement;
+import com.xuekairui.payment.enums.AgreementStatus;
+import com.xuekairui.payment.mapper.PaymentAgreementMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 自动续费定时扣款任务
+ *
+ * <p>每 10 分钟扫描一次到达扣款时间的已签约协议,逐条发起扣款。
+ * 单条扣款失败不影响其他协议,失败计数累计达上限自动解约。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AutoDeductScheduler {
+
+    private final PaymentAgreementMapper agreementMapper;
+    private final PaymentAgreementService agreementService;
+
+    /**
+     * 每 10 分钟扫描需要扣款的协议
+     */
+    @Scheduled(fixedDelay = 600_000, initialDelay = 60_000)
+    public void scanAndDeduct() {
+        List<PaymentAgreement> dueList = agreementMapper.selectDueForDeduct(LocalDateTime.now());
+        if (dueList.isEmpty()) {
+            return;
+        }
+        log.info("自动扣款任务启动,待扣款协议数: {}", dueList.size());
+        for (PaymentAgreement agreement : dueList) {
+            // 二次校验状态(避免并发/已解约)
+            if (!AgreementStatus.SIGNED.getCode().equals(agreement.getStatus())) {
+                continue;
+            }
+            try {
+                agreementService.triggerDeduct(agreement);
+            } catch (Exception e) {
+                log.error("扣款任务异常: agreement={}, error={}", agreement.getExternalAgreementNo(), e.getMessage());
+            }
+        }
+        log.info("自动扣款任务完成,处理协议数: {}", dueList.size());
+    }
+}

+ 427 - 0
zhijiayun-payment/src/main/java/com/xuekairui/payment/service/PaymentAgreementService.java

@@ -0,0 +1,427 @@
+package com.xuekairui.payment.service;
+
+import cn.hutool.core.util.IdUtil;
+import com.github.binarywang.wxpay.service.WxPayService;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.payment.config.PaymentProperties;
+import com.xuekairui.payment.dto.AgreementResponse;
+import com.xuekairui.payment.dto.AgreementSignRequest;
+import com.xuekairui.payment.dto.AgreementSignResponse;
+import com.xuekairui.payment.entity.PaymentAgreement;
+import com.xuekairui.payment.entity.PaymentOrder;
+import com.xuekairui.payment.entity.PaymentPlan;
+import com.xuekairui.payment.enums.AgreementStatus;
+import com.xuekairui.payment.enums.OrderType;
+import com.xuekairui.payment.enums.PaymentChannel;
+import com.xuekairui.payment.enums.PaymentStatus;
+import com.xuekairui.payment.enums.PeriodType;
+import com.xuekairui.payment.mapper.PaymentAgreementMapper;
+import com.xuekairui.payment.mapper.PaymentOrderMapper;
+import com.xuekairui.user.service.MembershipService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 支付签约协议服务(微信委托代扣)
+ *
+ * <p>封装微信「委托代扣(预约扣费)」的签约、解约、定时扣款流程。
+ * <ul>
+ *   <li>微信:委托代扣需在商户平台开通并申请扣费模板,签约后通过预约扣费/即时扣费接口扣款</li>
+ *   <li>支付宝安心付:已迁移至 {@link AlipayAnxinPayService},通过小程序会员周期卡模式接入</li>
+ *   <li>mock 模式:全流程模拟,便于开发测试</li>
+ * </ul>
+ * <p><b>注意:支付宝周期扣款(商家扣款)功能已移除,改为使用支付宝安心付(周期卡)接入小程序会员。</b>
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PaymentAgreementService {
+
+    private final PaymentAgreementMapper agreementMapper;
+    private final PaymentOrderMapper paymentOrderMapper;
+    private final PaymentPlanService paymentPlanService;
+    private final PaymentProperties paymentProperties;
+    private final MembershipService membershipService;
+    private final ObjectProvider<WxPayService> wxPayServiceProvider;
+
+    /** 连续扣款失败上限,达到后自动解约 */
+    private static final int MAX_CONSECUTIVE_FAIL = 3;
+
+    // ======================== 签约 ========================
+
+    /**
+     * 发起签约(开通自动续费)
+     *
+     * @return 签约响应,含签约页URL/二维码内容,前端引导用户完成签约
+     */
+    @Transactional
+    public AgreementSignResponse sign(Long userId, AgreementSignRequest request) {
+        PaymentChannel channel = PaymentChannel.fromCode(request.getChannel());
+        if (channel == null || channel != PaymentChannel.WECHAT) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
+                    "自动续费签约仅支持微信委托代扣(WECHAT);支付宝请使用安心付(小程序会员周期卡)");
+        }
+
+        PaymentPlan plan = paymentPlanService.getEnabledPlanById(request.getPlanId());
+        PeriodType periodType = resolvePeriodType(request.getPeriodType(), plan);
+
+        // 同用户同方案已有有效协议则拒绝重复签约
+        PaymentAgreement existing = agreementMapper.selectSignedByUserAndPlan(userId, plan.getId());
+        if (existing != null) {
+            throw new BusinessException(ErrorCode.PAY_AGREEMENT_EXISTS, "已开通该方案的自动续费,请勿重复签约");
+        }
+
+        // 生成商家自定义协议号(幂等)
+        String externalNo = "AG" + IdUtil.getSnowflake(1, 1).nextIdStr();
+
+        // 落库一条 PENDING 协议
+        PaymentAgreement agreement = PaymentAgreement.builder()
+                .userId(userId)
+                .planId(plan.getId())
+                .channel(channel.name())
+                .periodType(periodType.getCode())
+                .periodAmount(plan.getPrice())
+                .status(AgreementStatus.PENDING.getCode())
+                .externalAgreementNo(externalNo)
+                .build();
+        agreementMapper.insert(agreement);
+
+        // 调用微信委托代扣签约接口获取签约链接
+        SignResult signResult = paymentProperties.isMock()
+                ? mockSign(agreement, plan)
+                : wechatSign(agreement, plan);
+
+        log.info("发起签约: userId={} plan={} channel={} externalNo={} status={}",
+                userId, plan.getPlanName(), channel, externalNo, agreement.getStatus());
+
+        return AgreementSignResponse.builder()
+                .agreementId(agreement.getId())
+                .externalAgreementNo(externalNo)
+                .signUrl(signResult.signUrl)
+                .qrContent(signResult.qrContent)
+                .hint(signResult.hint)
+                .build();
+    }
+
+    /**
+     * 签约结果异步通知处理(微信签约成功/解约回调)
+     *
+     * @param channel 渠道
+     * @param params  回调参数
+     * @return 处理结果文案(success/fail)
+     */
+    @Transactional
+    public String handleSignNotify(PaymentChannel channel, Map<String, String> params) {
+        if (paymentProperties.isMock()) {
+            return mockSignNotify(params);
+        }
+        if (channel != PaymentChannel.WECHAT) {
+            log.warn("不支持的签约通知渠道: {}", channel);
+            return "fail";
+        }
+        return wechatSignNotify(params);
+    }
+
+    // ======================== 解约 ========================
+
+    /**
+     * 用户主动解约
+     */
+    @Transactional
+    public void unsign(Long userId, Long agreementId) {
+        PaymentAgreement agreement = getAgreementOrFail(agreementId);
+        if (!agreement.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作该协议");
+        }
+        if (!AgreementStatus.SIGNED.getCode().equals(agreement.getStatus())) {
+            throw new BusinessException(ErrorCode.PAY_AGREEMENT_NOT_SIGNED, "协议未签约或已解约");
+        }
+
+        // 调用微信解约接口(mock 模式跳过)
+        if (!paymentProperties.isMock()) {
+            PaymentChannel channel = PaymentChannel.fromCode(agreement.getChannel());
+            if (channel != PaymentChannel.WECHAT) {
+                throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
+                        "仅支持微信渠道解约;支付宝请通过安心付小程序管理");
+            }
+            wechatUnsign(agreement);
+        }
+
+        agreement.setStatus(AgreementStatus.UNSIGNED.getCode());
+        agreement.setUnsignTime(LocalDateTime.now());
+        agreement.setUnsignReason("用户主动解约");
+        agreementMapper.updateById(agreement);
+        log.info("用户{}解约协议{} ({}), 渠道={}", userId, agreementId, agreement.getExternalAgreementNo(), agreement.getChannel());
+    }
+
+    // ======================== 查询 ========================
+
+    public List<AgreementResponse> listMyAgreements(Long userId) {
+        List<PaymentAgreement> list = agreementMapper.selectSignedByUser(userId);
+        return list.stream().map(a -> toResponse(a, paymentPlanService.getPlanById(a.getPlanId()))).collect(Collectors.toList());
+    }
+
+    public AgreementResponse getMyAgreement(Long userId, Long agreementId) {
+        PaymentAgreement agreement = getAgreementOrFail(agreementId);
+        if (!agreement.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作该协议");
+        }
+        return toResponse(agreement, paymentPlanService.getPlanById(agreement.getPlanId()));
+    }
+
+    // ======================== 定时扣款 ========================
+
+    /**
+     * 对单条协议执行扣款(由定时任务调用)
+     *
+     * <p>创建一笔 AUTO_DEDUCT 订单并调用第三方扣款接口。
+     * 扣款成功/失败由支付回调异步推进,此处仅发起。
+     */
+    @Transactional
+    public void triggerDeduct(PaymentAgreement agreement) {
+        PaymentPlan plan = paymentPlanService.getPlanById(agreement.getPlanId());
+        PaymentChannel channel = PaymentChannel.fromCode(agreement.getChannel());
+
+        String orderNo = "AD" + IdUtil.getSnowflake(1, 1).nextIdStr();
+        LocalDateTime expireTime = LocalDateTime.now().plusHours(1);
+
+        PaymentOrder order = PaymentOrder.builder()
+                .orderNo(orderNo)
+                .userId(agreement.getUserId())
+                .planId(plan.getId())
+                .amount(agreement.getPeriodAmount())
+                .channel(agreement.getChannel())
+                .status(PaymentStatus.PENDING.name())
+                .orderType(OrderType.AUTO_DEDUCT.getCode())
+                .agreementId(agreement.getId())
+                .expireTime(expireTime)
+                .build();
+        paymentOrderMapper.insert(order);
+
+        try {
+            if (paymentProperties.isMock()) {
+                mockDeduct(agreement, order, plan);
+            } else if (channel == PaymentChannel.WECHAT) {
+                wechatDeduct(agreement, order, plan);
+            } else {
+                throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_SUPPORT,
+                        "自动扣款仅支持微信渠道;支付宝请使用安心付");
+            }
+            // 更新上次扣款时间
+            agreement.setLastDeductTime(LocalDateTime.now());
+            agreementMapper.updateById(agreement);
+            log.info("发起自动扣款: agreement={} orderNo={} amount={} channel={}",
+                    agreement.getExternalAgreementNo(), orderNo, agreement.getPeriodAmount(), agreement.getChannel());
+        } catch (Exception e) {
+            log.error("自动扣款发起失败: agreement={} orderNo={}", agreement.getExternalAgreementNo(), orderNo, e);
+            // 发起失败:订单置为过期,失败计数+1
+            order.setStatus(PaymentStatus.EXPIRED.name());
+            paymentOrderMapper.updateById(order);
+            incrementFailCount(agreement, e.getMessage());
+        }
+    }
+
+    /**
+     * 扣款成功后推进协议(由 PaymentOrderService.processPaidCallback 在 AUTO_DEDUCT 订单成功时回调)
+     */
+    @Transactional
+    public void onDeductSuccess(PaymentOrder order) {
+        if (order.getAgreementId() == null) {
+            return;
+        }
+        PaymentAgreement agreement = agreementMapper.selectById(order.getAgreementId());
+        if (agreement == null) {
+            return;
+        }
+        // 重置失败计数
+        agreement.setConsecutiveFailCount(0);
+        // 推进下次扣款时间(当前 next_deduct_time + 一个周期)
+        PeriodType period = PeriodType.fromCode(agreement.getPeriodType());
+        LocalDateTime base = agreement.getNextDeductTime() != null
+                && agreement.getNextDeductTime().isAfter(LocalDateTime.now())
+                ? agreement.getNextDeductTime()
+                : LocalDateTime.now();
+        agreement.setNextDeductTime(base.plusDays(period.getDays()));
+        agreementMapper.updateById(agreement);
+        log.info("扣款成功推进协议: agreement={} nextDeductTime={}",
+                agreement.getExternalAgreementNo(), agreement.getNextDeductTime());
+    }
+
+    /**
+     * 扣款失败处理(连续失败达上限自动解约)
+     */
+    @Transactional
+    public void incrementFailCount(PaymentAgreement agreement, String reason) {
+        agreement.setConsecutiveFailCount(agreement.getConsecutiveFailCount() + 1);
+        if (agreement.getConsecutiveFailCount() >= MAX_CONSECUTIVE_FAIL) {
+            agreement.setStatus(AgreementStatus.UNSIGNED.getCode());
+            agreement.setUnsignTime(LocalDateTime.now());
+            agreement.setUnsignReason("连续扣款失败" + MAX_CONSECUTIVE_FAIL + "次,自动解约");
+            log.warn("协议{}连续扣款失败达上限,自动解约", agreement.getExternalAgreementNo());
+        }
+        agreementMapper.updateById(agreement);
+    }
+
+    // ======================== 微信实现 ========================
+    //
+    // 微信「委托代扣(预约扣费)」需在商户平台开通产品、申请扣费模板(plan_id),
+    // 并申请 H5/小程序签约权限。签约流程涉及 OAuth 获取 openid → 预签约 → 拉起签约页。
+    // 下方为结构化集成点;在产品开通并配置 payment.wechat.papay-plan-id 前,
+    // 生产模式抛出明确错误,mock 模式完整可用。
+
+    private SignResult wechatSign(PaymentAgreement agreement, PaymentPlan plan) {
+        PaymentProperties.Wechat wc = paymentProperties.getWechat();
+        if (wc.getPapayPlanId() == null || wc.getPapayPlanId().isBlank()) {
+            agreement.setStatus(AgreementStatus.FAILED.getCode());
+            agreementMapper.updateById(agreement);
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED,
+                    "微信委托代扣未配置:请在商户平台开通并申请扣费模板,配置 payment.wechat.papay-plan-id");
+        }
+        WxPayService wxPayService = wxPayServiceProvider.getIfAvailable();
+        if (wxPayService == null) {
+            throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED, "WxPayService未初始化");
+        }
+        // TODO: 产品开通后调用 WxJava 委托代扣预签约接口(/v3/papay/scheduled-deduct-sign/contracts/pre-entrust-sign/jsapi)
+        //       传入 appid、openid、plan_id、out_contract_code=externalAgreementNo、contract_notify_url
+        //       返回 redirect_url 供前端拉起微信签约页。
+        String mockUrl = "https://payapp.weixin.qq.com/sign/mock?out_contract_code=" + agreement.getExternalAgreementNo();
+        return new SignResult(mockUrl, mockUrl, "请用微信扫码或打开链接完成签约(待委托代扣产品开通后生效)");
+    }
+
+    private String wechatSignNotify(Map<String, String> params) {
+        // 微信委托代扣签约结果通知为加密 JSON,此处由 Controller 解密后传入关键字段
+        String externalNo = params.get("out_contract_code");
+        String contractId = params.get("contract_id");
+        String changeType = params.get("change_type");
+        log.info("收到微信签约通知: externalNo={}, contractId={}, changeType={}", externalNo, contractId, changeType);
+
+        PaymentAgreement agreement = externalNo != null
+                ? agreementMapper.selectByExternalNo(externalNo)
+                : (contractId != null ? agreementMapper.selectByAgreementNo(contractId) : null);
+        if (agreement == null) {
+            log.warn("微信签约通知未匹配到协议: externalNo={}, contractId={}", externalNo, contractId);
+            return "SUCCESS";
+        }
+
+        if ("ADD".equalsIgnoreCase(changeType)) {
+            agreement.setStatus(AgreementStatus.SIGNED.getCode());
+            agreement.setAgreementNo(contractId);
+            agreement.setSignTime(LocalDateTime.now());
+            agreement.setNextDeductTime(LocalDateTime.now());
+            agreementMapper.updateById(agreement);
+            log.info("微信签约成功: externalNo={}, contractId={}", externalNo, contractId);
+        } else if ("DELETE".equalsIgnoreCase(changeType)) {
+            agreement.setStatus(AgreementStatus.UNSIGNED.getCode());
+            agreement.setUnsignTime(LocalDateTime.now());
+            agreementMapper.updateById(agreement);
+            log.info("微信解约通知: externalNo={}, contractId={}", externalNo, contractId);
+        }
+        return "SUCCESS";
+    }
+
+    private void wechatUnsign(PaymentAgreement agreement) {
+        // TODO: 产品开通后调用 WxJava 委托代扣解约接口(DELETE /v3/papay/contracts/{contract_id})
+        log.info("微信解约请求(待产品开通后接入): contractId={}", agreement.getAgreementNo());
+    }
+
+    private void wechatDeduct(PaymentAgreement agreement, PaymentOrder order, PaymentPlan plan) {
+        // TODO: 产品开通后调用微信预约扣费/即时扣费接口(/v3/papay/scheduled-deduct/transactions)
+        throw new BusinessException(ErrorCode.PAY_CHANNEL_NOT_CONFIGURED,
+                "微信委托代扣扣款待产品开通后联调,请在 mock 模式下测试自动续费流程");
+    }
+
+    // ======================== Mock 实现 ========================
+
+    private SignResult mockSign(PaymentAgreement agreement, PaymentPlan plan) {
+        String url = "https://mock.pay.com/sign?channel=" + agreement.getChannel()
+                + "&externalNo=" + agreement.getExternalAgreementNo();
+        // mock 模式直接标记为已签约,便于端到端测试
+        agreement.setStatus(AgreementStatus.SIGNED.getCode());
+        agreement.setAgreementNo("MOCK_" + agreement.getExternalAgreementNo());
+        agreement.setSignTime(LocalDateTime.now());
+        agreement.setNextDeductTime(LocalDateTime.now().plusSeconds(10));
+        agreementMapper.updateById(agreement);
+        return new SignResult(url, url, "【模拟模式】已自动签约,10秒后可触发扣款");
+    }
+
+    private String mockSignNotify(Map<String, String> params) {
+        log.info("【模拟】收到签约通知: {}", params);
+        return "success";
+    }
+
+    private void mockDeduct(PaymentAgreement agreement, PaymentOrder order, PaymentPlan plan) {
+        // mock 模式直接标记订单为已支付并触发业务处理
+        order.setStatus(PaymentStatus.PAID.name());
+        order.setPaidTime(LocalDateTime.now());
+        order.setTradeNo("MOCK_DEDUCT_" + IdUtil.fastSimpleUUID().substring(0, 12).toUpperCase());
+        paymentOrderMapper.updateById(order);
+        // 续期会员(叠加模式)
+        com.xuekairui.user.enums.MembershipLevel level =
+                com.xuekairui.user.enums.MembershipLevel.fromCode(plan.getMembershipLevel());
+        PeriodType period = PeriodType.fromCode(agreement.getPeriodType());
+        membershipService.activateFromPayment(order.getUserId(), level, period.getDays(), order.getOrderNo());
+        // 推进协议下次扣款时间
+        onDeductSuccess(order);
+        log.info("【模拟】自动扣款成功: orderNo={}, 会员续期{}天", order.getOrderNo(), period.getDays());
+    }
+
+    // ======================== 工具方法 ========================
+
+    private PeriodType resolvePeriodType(String periodType, PaymentPlan plan) {
+        PeriodType type = PeriodType.fromCode(periodType);
+        if (type != null) {
+            return type;
+        }
+        // 按方案天数推断
+        if (plan.getDurationDays() >= 300) {
+            return PeriodType.YEAR;
+        }
+        return PeriodType.MONTH;
+    }
+
+    private PaymentAgreement getAgreementOrFail(Long id) {
+        PaymentAgreement agreement = agreementMapper.selectById(id);
+        if (agreement == null) {
+            throw new BusinessException(ErrorCode.PAY_AGREEMENT_NOT_FOUND);
+        }
+        return agreement;
+    }
+
+    private AgreementResponse toResponse(PaymentAgreement a, PaymentPlan plan) {
+        return AgreementResponse.builder()
+                .id(a.getId())
+                .userId(a.getUserId())
+                .planId(a.getPlanId())
+                .planName(plan != null ? plan.getPlanName() : null)
+                .channel(a.getChannel())
+                .periodType(a.getPeriodType())
+                .periodAmount(a.getPeriodAmount())
+                .status(a.getStatus())
+                .agreementNo(a.getAgreementNo())
+                .signTime(a.getSignTime())
+                .unsignTime(a.getUnsignTime())
+                .nextDeductTime(a.getNextDeductTime())
+                .lastDeductTime(a.getLastDeductTime())
+                .consecutiveFailCount(a.getConsecutiveFailCount())
+                .unsignReason(a.getUnsignReason())
+                .createTime(a.getCreateTime())
+                .build();
+    }
+
+    /** 签约结果内部载体 */
+    private record SignResult(String signUrl, String qrContent, String hint) {}
+
+    /** BigDecimal 兼容(避免未使用导入告警) */
+    @SuppressWarnings("unused")
+    private static final BigDecimal DUMMY = BigDecimal.ZERO;
+}

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

@@ -16,6 +16,7 @@ import com.xuekairui.payment.dto.OrderResponse;
 import com.xuekairui.payment.dto.PaymentResultResponse;
 import com.xuekairui.payment.entity.PaymentOrder;
 import com.xuekairui.payment.entity.PaymentPlan;
+import com.xuekairui.payment.enums.OrderType;
 import com.xuekairui.payment.enums.PaymentChannel;
 import com.xuekairui.payment.enums.PaymentStatus;
 import com.xuekairui.payment.mapper.PaymentOrderMapper;
@@ -48,6 +49,7 @@ public class PaymentOrderService {
     private final PaymentProperties paymentProperties;
     private final ObjectProvider<AlipayClient> alipayClientProvider;
     private final ObjectProvider<WxPayService> wxPayServiceProvider;
+    private final PaymentAgreementService agreementService;
 
     /**
      * 创建支付订单
@@ -209,9 +211,19 @@ public class PaymentOrderService {
         order.setMembershipRecordId(membership.getId());
         paymentOrderMapper.updateById(order);
 
-        log.info("支付成功: {} 用户={} 方案={} 金额={}元 会员={} 交易号={}",
+        // 自动续费扣款订单:推进协议下次扣款时间
+        if (OrderType.AUTO_DEDUCT.getCode().equals(order.getOrderType())) {
+            try {
+                agreementService.onDeductSuccess(order);
+            } catch (Exception e) {
+                log.warn("推进协议扣款时间失败(不影响会员激活): orderNo={}, error={}", order.getOrderNo(), e.getMessage());
+            }
+        }
+
+        log.info("支付成功: {} 用户={} 方案={} 金额={}元 会员={} 交易号={} 类型={}",
                 order.getOrderNo(), order.getUserId(),
-                plan.getPlanName(), order.getAmount(), level.getCode(), tradeNo);
+                plan.getPlanName(), order.getAmount(), level.getCode(), tradeNo,
+                order.getOrderType());
 
         return buildResultResponse(order, plan);
     }

+ 122 - 6
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -253,10 +253,11 @@
         <div class="module-tab" data-module="admin-coupon">4.优惠券发放</div>
         <div class="module-tab" data-module="admin-license">5.执照审核</div>
         <div class="module-tab" data-module="admin-payment">6.支付方案</div>
-        <div class="module-tab" data-module="admin-trial-quota">7.体验配额</div>
-        <div class="module-tab" data-module="admin-user">8.用户管理</div>
-        <div class="module-tab" data-module="admin-audit">9.审计日志</div>
-        <div class="module-tab" data-module="upload">10.文件上传</div>
+        <div class="module-tab" data-module="admin-anxin">7.安心付管理</div>
+        <div class="module-tab" data-module="admin-trial-quota">8.体验配额</div>
+        <div class="module-tab" data-module="admin-user">9.用户管理</div>
+        <div class="module-tab" data-module="admin-audit">10.审计日志</div>
+        <div class="module-tab" data-module="upload">11.文件上传</div>
     </div>
 
     <div id="panels"></div>
@@ -352,6 +353,28 @@ async function runTest(sectionId, method, path, body, requireAuth, validatorName
             .forEach(fid => { const el = document.getElementById(fid); if (el) el.value = orderNo; });
     }
 
+    // 自动回填协议ID到相关输入框
+    const agreementId = result.data?.data?.agreementId;
+    if (agreementId) {
+        ['payment-agreement-detail-agreementId', 'payment-agreement-unsign-agreementId']
+            .forEach(fid => { const el = document.getElementById(fid); if (el) el.value = agreementId; });
+    }
+
+    // 自动回填签约协议号到微信回调通知
+    const externalAgreementNo = result.data?.data?.externalAgreementNo;
+    if (externalAgreementNo) {
+        const el = document.getElementById('payment-agreement-notify-wechat-out_contract_code');
+        if (el) el.value = externalAgreementNo;
+    }
+
+    // 自动回填安心付卡模板ID到相关输入框
+    const anxinCardId = result.data?.data?.cardId;
+    if (anxinCardId) {
+        ['admin-anxin-sync-cardId', 'admin-anxin-status-cardId',
+         'payment-anxin-notify-deduct-card_id', 'payment-anxin-notify-card-change-card_id']
+            .forEach(fid => { const el = document.getElementById(fid); if (el) el.value = anxinCardId; });
+    }
+
     if (channel === 'ALIPAY' && qrBase64) {
         // 支付宝:DeepSeek 风格自定义弹窗,展示二维码
         showAlipayModal(qrBase64, amount, orderNo);
@@ -616,6 +639,7 @@ function renderModule(module) {
     case 'admin-coupon': html = renderAdminCouponModule(); break;
     case 'admin-license': html = renderAdminLicenseModule(); break;
     case 'admin-payment': html = renderAdminPaymentModule(); break;
+    case 'admin-anxin': html = renderAdminAnxinModule(); break;
     case 'admin-trial-quota': html = renderAdminTrialQuotaModule(); break;
     case 'admin-user': html = renderAdminUserModule(); break;
     case 'admin-audit': html = renderAdminAuditModule(); break;
@@ -1024,6 +1048,46 @@ const FIELD_DESC = {
         ['landingTitle', 'String', '落地页标题'],
         ['landingDesc', 'String', '落地页描述']
     ],
+    'anxinCard': [
+        ['id', 'Long', '本地记录ID'],
+        ['planId', 'Long', '关联支付方案ID'],
+        ['planName', 'String', '支付方案名称'],
+        ['cardId', 'String', '支付宝卡模板ID'],
+        ['cardName', 'String', '卡模板名称'],
+        ['cardType', 'String', '卡类型(PERIOD_PAY=周期卡)'],
+        ['periodAmount', 'BigDecimal', '每期金额(元)'],
+        ['periodDays', 'Integer', '周期天数'],
+        ['totalPeriods', 'Integer', '总期数'],
+        ['status', 'String', '状态(PENDING/EFFECTIVE/INVALID/FROZEN)'],
+        ['appointmentUrl', 'String', '预约链接(小程序购买入口)'],
+        ['createTime', 'DateTime', '创建时间'],
+        ['updateTime', 'DateTime', '更新时间']
+    ],
+    'agreementSignResponse': [
+        ['agreementId', 'Long', '本地协议ID'],
+        ['externalAgreementNo', 'String', '商家自定义协议号(AG开头)'],
+        ['signUrl', 'String', '签约页URL(前端跳转/展示)'],
+        ['qrContent', 'String', '二维码内容(扫码签约)'],
+        ['hint', 'String', '提示文案']
+    ],
+    'agreementResponse': [
+        ['id', 'Long', '协议ID'],
+        ['userId', 'Long', '用户ID'],
+        ['planId', 'Long', '支付方案ID'],
+        ['planName', 'String', '方案名称'],
+        ['channel', 'String', '渠道(WECHAT)'],
+        ['periodType', 'String', '周期类型(MONTH/YEAR)'],
+        ['periodAmount', 'BigDecimal', '每期扣款金额'],
+        ['status', 'String', '状态(PENDING/SIGNED/UNSIGNED/FAILED)'],
+        ['agreementNo', 'String', '第三方协议号'],
+        ['signTime', 'DateTime', '签约时间'],
+        ['unsignTime', 'DateTime', '解约时间'],
+        ['nextDeductTime', 'DateTime', '下次扣款时间'],
+        ['lastDeductTime', 'DateTime', '上次扣款时间'],
+        ['consecutiveFailCount', 'Integer', '连续失败次数(达3次自动解约)'],
+        ['unsignReason', 'String', '解约原因'],
+        ['createTime', 'DateTime', '创建时间']
+    ],
 
 
 };
@@ -1240,7 +1304,33 @@ function renderPaymentModule() {
          {label:'交易号(transaction_id,可选)', name:'transaction_id', placeholder:'wx_', default:''}], false, null, '微信支付回调通知。mock模式从参数取订单号,真实支付模式需验签+解密(API v3)')}
     ${testSection('payment-callback-alipay', '支付宝回调', 'POST', 'post', '/api/payment/callback/alipay', '公开', 'auth-public',
         [{label:'订单号(out_trade_no)', name:'out_trade_no', placeholder:'OP...', default:''},
-         {label:'交易号(trade_no,可选)', name:'trade_no', placeholder:'2024...', default:''}], false, null, '支付宝回调通知。mock模式从参数取订单号,真实支付模式需RSA2验签')}`;
+         {label:'交易号(trade_no,可选)', name:'trade_no', placeholder:'2024...', default:''}], false, null, '支付宝回调通知。mock模式从参数取订单号,真实支付模式需RSA2验签')}
+    ${testSection('payment-agreement-sign', '开通微信委托代扣(签约)', 'POST', 'post', '/api/payment/agreement/sign', '需认证', 'auth-required',
+        [{label:'方案ID(planId)', name:'planId', placeholder:'1', default:'1'},
+         {label:'支付渠道(channel)', name:'channel', placeholder:'WECHAT', default:'WECHAT'},
+         {label:'周期类型(periodType,可选)', name:'periodType', placeholder:'MONTH/YEAR', default:'MONTH'}], true, 'agreementSignResponse', '微信委托代扣签约。返回签约链接(signUrl)和二维码内容(qrContent),引导用户完成签约。mock模式自动签约成功,10秒后触发首次扣款。仅支持微信渠道(WECHAT),支付宝请使用安心付(小程序会员周期卡)')}
+    ${testSection('payment-agreement-list', '我的自动续费协议', 'GET', 'get', '/api/payment/agreement/list', '需认证', 'auth-required', null, true, 'agreementResponse', '查询当前用户所有签约协议列表,含签约状态、下次扣款时间、连续失败次数等')}
+    ${testSection('payment-agreement-detail', '协议详情', 'GET', 'get', '/api/payment/agreement/{agreementId}', '需认证', 'auth-required',
+        [{label:'协议ID(agreementId)', name:'agreementId', placeholder:'签约后返回', default:''}], true, 'agreementResponse', '查询单个签约协议详情')}
+    ${testSection('payment-agreement-unsign', '取消自动续费(解约)', 'POST', 'post', '/api/payment/agreement/{agreementId}/unsign', '需认证', 'auth-required',
+        [{label:'协议ID(agreementId)', name:'agreementId', placeholder:'签约后返回', default:''}], false, null, '用户主动取消自动续费。会同步调用第三方解约接口(mock模式直接本地解约),解约后不再自动扣款')}
+    ${testSection('payment-agreement-notify-wechat', '微信签约回调通知', 'POST', 'post', '/api/payment/agreement/notify/wechat?out_contract_code=AGxxx&contract_id=MOCK_xxx&change_type=ADD', '公开', 'auth-public',
+        [{label:'协议号(out_contract_code)', name:'out_contract_code', placeholder:'签约后返回的externalAgreementNo', default:''},
+         {label:'微信协议号(contract_id)', name:'contract_id', placeholder:'微信合同ID', default:'MOCK_AG_test'},
+         {label:'变更类型(change_type)', name:'change_type', placeholder:'ADD/DELETE', default:'ADD'}], true, null, '微信委托代扣签约结果异步通知。mock模式返回success。真实模式为加密JSON,需WxPayService解密。ADD=签约成功,DELETE=解约')}
+    ${testSection('payment-anxin-notify-deduct', '安心付扣款通知', 'POST', 'post', '/api/payment/anxin/notify/deduct?sub_order_id=AX_SUB_xxx&card_id=MOCK_CARD_xxx&deduct_status=SUCCESS&deduct_amount=9.90&deduct_time=2026-06-30 10:00:00&out_biz_no=1&user_id=2088xxx&order_id=AX_ORDER_xxx', '公开', 'auth-public',
+        [{label:'核销子订单号(sub_order_id)', name:'sub_order_id', placeholder:'AX_SUB_...', default:'AX_SUB_TEST001'},
+         {label:'卡模板ID(card_id)', name:'card_id', placeholder:'创建卡模板后返回', default:''},
+         {label:'扣款状态(deduct_status)', name:'deduct_status', placeholder:'SUCCESS/FAIL', default:'SUCCESS'},
+         {label:'扣款金额(deduct_amount)', name:'deduct_amount', placeholder:'9.90', default:'9.90'},
+         {label:'扣款时间(deduct_time)', name:'deduct_time', placeholder:'yyyy-MM-dd HH:mm:ss', default:'2026-06-30 10:00:00'},
+         {label:'系统用户ID(out_biz_no)', name:'out_biz_no', placeholder:'用户ID', default:'1'},
+         {label:'支付宝用户ID(user_id)', name:'user_id', placeholder:'2088...', default:'2088000000000001'},
+         {label:'售卖订单ID(order_id)', name:'order_id', placeholder:'AX_ORDER_...', default:'AX_ORDER_TEST001'}], true, null, '安心付周期扣款异步通知。支付宝每次周期扣款后发送,服务端收到后激活/续期会员。mock模式直接处理扣款并激活会员。需先创建卡模板')}
+    ${testSection('payment-anxin-notify-card-change', '安心付卡变更通知', 'POST', 'post', '/api/payment/anxin/notify/card-change?card_id=MOCK_CARD_xxx&change_type=AUDIT&status=EFFECTIVE', '公开', 'auth-public',
+        [{label:'卡模板ID(card_id)', name:'card_id', placeholder:'创建卡模板后返回', default:''},
+         {label:'变更类型(change_type)', name:'change_type', placeholder:'AUDIT/STATUS_CHANGE', default:'AUDIT'},
+         {label:'新状态(status)', name:'status', placeholder:'PENDING/EFFECTIVE/INVALID/FROZEN', default:'EFFECTIVE'}], true, null, '安心付卡模板审核结果通知。支付宝在卡模板审核通过/驳回/冻结/解冻时发送。mock模式直接更新本地状态')}`;
 }
 
 function renderUploadModule() {
@@ -1441,6 +1531,19 @@ function renderAdminPaymentModule() {
          {label:'状态(status)', name:'status', placeholder:'0下架/1上架', default:'0'}], false, null, '切换支付方案上架/下架状态')}`;
 }
 
+function renderAdminAnxinModule() {
+    return `
+    <div class="batch-buttons"><button class="btn btn-warning btn-sm" onclick="runAdminAnxinTests()">运行全部安心付管理测试</button></div>
+    ${testSection('admin-anxin-create', '创建安心付卡模板', 'POST', 'post', '/api/admin/payment/anxin/card/create', '需认证', 'auth-required',
+        [{label:'支付方案ID(planId)', name:'planId', placeholder:'1', default:'1'}], true, 'anxinCard', '为支付方案创建支付宝安心付周期卡模板。创建后状态为PENDING(待审核),支付宝审核通过后变为EFFECTIVE。mock模式直接返回EFFECTIVE状态。每个支付方案只能创建一次')}
+    ${testSection('admin-anxin-list', '卡模板列表', 'GET', 'get', '/api/admin/payment/anxin/cards', '需认证', 'auth-required', null, true, 'anxinCard', '查询所有安心付卡模板列表,含卡模板ID、状态、周期金额等')}
+    ${testSection('admin-anxin-sync', '同步卡模板状态', 'POST', 'post', '/api/admin/payment/anxin/card/{cardId}/sync', '需认证', 'auth-required',
+        [{label:'卡模板ID(cardId)', name:'cardId', placeholder:'创建后返回', default:''}], true, 'anxinCard', '调用支付宝接口查询卡模板最新状态(审核结果等)。mock模式直接返回本地状态')}
+    ${testSection('admin-anxin-status', '修改卡模板状态', 'PUT', 'put', '/api/admin/payment/anxin/card/{cardId}/status', '需认证', 'auth-required',
+        [{label:'卡模板ID(cardId)', name:'cardId', placeholder:'创建后返回', default:''},
+         {label:'状态(status)', name:'status', placeholder:'EFFECTIVE/INVALID/FROZEN', default:'EFFECTIVE'}], false, null, '修改卡模板状态:EFFECTIVE(上架)/ INVALID(下架)/ FROZEN(冻结)')}`;
+}
+
 
 
 function renderAdminTrialQuotaModule() {
@@ -1500,7 +1603,7 @@ function getSectionsInModule(modulePrefix) {
 }
 
 const USER_MODULES = ['auth','crawler','invite','coupon','membership','payment','upload','search','platform-account','business-license','platform-config'];
-const ADMIN_MODULES = ['admin-membership','admin-invite','admin-crawler','admin-coupon','admin-license','admin-payment','admin-trial-quota','admin-user','admin-audit','upload'];
+const ADMIN_MODULES = ['admin-membership','admin-invite','admin-crawler','admin-coupon','admin-license','admin-payment','admin-anxin','admin-trial-quota','admin-user','admin-audit','upload'];
 
 function countTestsInCurrentPanel() {
     return document.querySelectorAll('.test-section button[onclick*="runTest"]').length;
@@ -1683,6 +1786,7 @@ function runAdminCrawlerTests() { runBatchTests('admin-crawler'); }
 function runAdminCouponTests() { runBatchTests('admin-coupon'); }
 function runAdminLicenseTests() { runBatchTests('admin-license'); }
 function runAdminPaymentTests() { runBatchTests('admin-payment'); }
+function runAdminAnxinTests() { runBatchTests('admin-anxin'); }
 function runAdminTrialQuotaTests() { runBatchTests('admin-tq'); }
 function runAdminUserTests() { runBatchTests('admin-user'); }
 function runAdminAuditTests() { runBatchTests('admin-audit'); }
@@ -1759,6 +1863,18 @@ function validate_adminAuditStats(result) { return result.data?.code === 200; }
 // 支付模块验证器
 function validate_paymentPlans(result) { return result.data?.code === 200 && Array.isArray(result.data?.data) && result.data?.data?.length >= 2; }
 function validate_paymentOrders(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+// 签约协议验证器
+function validate_paymentAgreementSign(result) { return result.data?.code === 200 && result.data?.data?.agreementId; }
+function validate_paymentAgreementList(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_paymentAgreementDetail(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_paymentAgreementNotifyWechat(result) { const raw = result.data?._raw || result.data; return result.ok && (raw === 'success' || raw === 'SUCCESS'); }
+function validate_paymentAnxinNotifyDeduct(result) { const raw = result.data?._raw || result.data; return result.ok && (raw === 'success' || raw === 'SUCCESS'); }
+function validate_paymentAnxinNotifyCardChange(result) { const raw = result.data?._raw || result.data; return result.ok && (raw === 'success' || raw === 'SUCCESS'); }
+// 安心付管理验证器
+function validate_adminAnxinCreate(result) { return result.data?.code === 200 && result.data?.data?.cardId; }
+function validate_adminAnxinList(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_adminAnxinSync(result) { return result.data?.code === 200 && result.data?.data?.cardId; }
+function validate_adminAnxinStatus(result) { return result.data?.code === 200; }
 function validate_searchQuery(result) { return result.data?.code === 200 && result.data?.data?.prices !== undefined; }
 function validate_searchSuggestions(result) { return result.data?.code === 200 && Array.isArray(result.data?.data) && result.data?.data?.length > 0; }
 function validate_searchHistory(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

@@ -52,6 +52,9 @@ public class SecurityConfig {
             "/api/trial-quota/**",       // 体验配额配置查询(公开)
             "/api/search/suggestions",   // 搜索建议(公开)
             "/api/payment/callback/**",  // 支付回调(公开,供微信/支付宝调用)
+            "/api/payment/anxin/notify/**",  // 安心付通知(公开,供支付宝调用)
+            "/api/payment/agreement/notify/**",  // 签约回调通知(公开,供微信调用)
+            "/api/payment/alipay/**",  // 支付宝开放能力回调(公开)
             "/api/payment/plans",        // 支付方案列表(公开)
             "/error",                    // 错误页面
             "/actuator/health"           // 健康检查