2026-08-02-virtual-payment-implementation.md 42 KB

小程序虚拟支付接入实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement plan. Steps use checkbox (- [ ]) syntax.

Goal: 将测评订单(assessment_orders,前缀 A)、会员订单(payment_orders,前缀 ORD)、订阅订单(member_subscription_order,前缀 SUB)三条链路从假支付/手动回调/假定已付,改为微信官方小程序虚拟支付(道具直购模式):下单返回支付参数 → 前端 wx.requestVirtualPayment 拉起支付 → 发货推送(xpay_goods_deliver_notify)标记已付并解锁权益。

架构: 后端新增虚拟支付基础设施(VirtualPayService / VirtualPayController / virtual_goods_config 映射表)→ 三条业务链路改造(下单返回 VirtualPayParamsDTO,保持 pending;发货推送按前缀路由解锁)→ 前端三个购买页接 wx.requestVirtualPayment → Web 管理端新增道具映射 CRUD。

Tech Stack: Spring Boot 2.7.18 + MyBatis-Plus (Java 8) / uni-app Vue 2 小程序 / Vue 2 + Element UI 管理端 / MySQL 8.0

参考:

外部依赖(不阻塞开发):

  • 微信商户后台道具(ProductId)尚未配置 → 映射表先建,上线前配置
  • mp.weixin.qq.com 服务器推送 URL 需指向 /api/internal/virtual-pay/notify(联调时配置,默认明文模式)

关键约束(AGENTS.md 强制)

  • 接口统一 @PostMapping;响应统一 Result<T>;DI 用 @Resource
  • 推送回调端点返回 {"ErrCode":0,"ErrMsg":"success"} 格式 Map,不用 Result(微信协议要求)
  • 新增类前 grep 检查 Bean 命名冲突;新增路由后跑路由重复检查
  • 数据库迁移唯一入口 DatabaseInitializer.runMigrations(),迁移后必须同步 schema.sql
  • HMAC 签名用 JDK 原生 javax.crypto.Mac(项目无 commons-codec 依赖,零新依赖)
  • 前端禁止可选链 ?.、禁止 CSS Grid、禁止 :key 表达式

文件结构

后端(cfc-backend)

文件 操作 职责
config/DatabaseInitializer.java 修改 迁移 121:建 virtual_goods_config 表(幂等)
src/main/resources/schema.sql 修改 同步追加 virtual_goods_config CREATE TABLE
src/main/resources/application.yml 修改 wechat 块加 appkey + virtual-pay-env
config/application.yml 修改 模板同步(占位符)
config/application-prod.yml 修改 生产覆盖同步
entity/VirtualGoodsConfig.java 新增 道具映射实体(@Data @TableName @TableId(AUTO)
mapper/VirtualGoodsConfigMapper.java 新增 @Mapper extends BaseMapper<VirtualGoodsConfig>
service/VirtualGoodsConfigService.java 新增 映射查询(启用态)+ 管理端 CRUD
controller/admin/VirtualGoodsConfigAdminController.java 新增 管理端 CRUD 接口(/api/admin/virtual-goods/*
service/VirtualPayService.java 新增 paySig 签名、下单参数生成、发货/退款推送处理、query_order 兜底
controller/VirtualPayController.java 新增 POST /api/internal/virtual-pay/notify + ios-refund-query
dto/VirtualPayParamsDTO.java 新增 支付参数返回体(设计文档 §5.2)
task/VirtualPayTimeoutTask.java 新增 @Scheduled:超时 pending 关单 + query_order 兜底
service/AssessmentOrderService.java 修改 payOrder() 改返回支付参数;抽出预约确认公共方法;补 appointmentId 关联
controller/DanAssessmentController.java 修改 /order/pay 返回 Result<VirtualPayParamsDTO>
service/MembershipService.java 修改 下单后生成支付参数;修正 L382 硬编码 "FAMILY"
controller/MembershipController.java 修改 下单接口返回支付参数;保留 /notify 作 testMode/兜底
service/MemberSubscriptionService.java 修改 拆分 subscribe():建 pending 单 + 提取 activateSubscription()
controller/subscription/SubscriptionController.java 修改 /create 返回支付参数;testMode 分支保留

前端(cfc-frontend)

文件 操作 职责
utils/api.js 修改 新增 createVirtualPayOrder 封装 + getVirtualPaymentEnv() helper
pages/assessment/purchase.vue(及测评购买入口) 修改 接虚拟支付流程,修复自跳转 bug(L270-272)
pages/membership/upgrade.vue 修改 doUpgrade/doRenew(L336-344/L358-366)假成功改虚拟支付
pages/membership/plans.vue 修改 订阅购买接虚拟支付(入口 purchasePlan L128 不动)
pages/shop/payment/payment.vue 不动 实物商品 v3 路径保留

管理端(cfc-web)

文件 操作 职责
src/views/admin/VirtualGoodsConfig.vue 新增 道具映射 CRUD(照抄 KnowledgeTag.vue
src/api/virtualGoods.js 新增 API 模块(import request + 命名导出)
src/router/index.js 修改 children 加路由(参照 knowledge-tags L329-333,perm 须在 permissions.js 存在)
src/views/Layout.vue 修改 menuItems(L165+)加菜单项(系统配置分组 L268-275)

Task 1: 建表迁移 virtual_goods_config(迁移 121)

现状

  • DatabaseInitializer.javarunMigrations() 起始于 L112,最新迁移 // 迁移120L7277(文件末尾区域)
  • schema.sql 共 3686 行,最新表 recommendation_logs(L3669),含 UNIQUE KEY 的参照表 butler_assignments(L2432)

  • [ ] Step 1: DatabaseInitializer 追加迁移 121

// 迁移120 块(L7277-7283)之后追加(照抄项目建表范式:try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ...") } catch (Exception e) {} 幂等):

// 迁移121: 创建小程序虚拟支付道具映射表
try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS virtual_goods_config (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION', " +
            "biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code', " +
            "product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID', " +
            "goods_name VARCHAR(128) NOT NULL COMMENT '道具名称', " +
            "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用', " +
            "create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序虚拟支付道具映射'");
    log.info("已创建virtual_goods_config表");
} catch (Exception e) {
    log.warn("创建virtual_goods_config表失败: {}", e.getMessage());
}
  • Step 2: 同步 schema.sql

schema.sql 末尾(L3686 后)追加同构 CREATE TABLE IF NOT EXISTS virtual_goods_config (...)(同上 SQL,含 UNIQUE KEY + ENGINE 子句)。

  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 4: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat: 迁移121 创建virtual_goods_config道具映射表
    测评/会员/订阅虚拟支付道具映射,UNIQUE(goods_type,biz_key),幂等"
    

Task 2: 配置 wechat.appkey + virtual-pay-env

现状

  • 主配置 application.yml wechat 块 L67-83:有 appid/secret/mch-id/mch-key/api-v3-key/mch-serial-no/private-key-path/notify-url/test-mode 等,appkey、无 virtual-pay-env
  • config/application.yml wechat 块 L43-56(模板占位符)、config/application-prod.yml wechat 块 L31-32(仅 test-mode: false
  • 环境变量插值风格:${WECHAT_APPKEY:}(带默认空值)

  • [ ] Step 1: 主配置 src/main/resources/application.yml wechat 块(L78 test-mode 后)追加

    appkey: ${WECHAT_APPKEY:}                # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开)
    virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱
    
  • [ ] Step 2: config/application.yml 同步(模板占位符)

同样位置追加 appkey: ${WECHAT_APPKEY:}virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}

  • Step 3: config/application-prod.yml 同步

wechat 块追加 appkey: ${WECHAT_APPKEY:}(生产用环境变量注入真实值)。

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/resources/application.yml cfc-backend/config/application.yml cfc-backend/config/application-prod.yml
    git commit -m "feat: 新增wechat.appkey与virtual-pay-env配置
    小程序虚拟支付paySig签名密钥与环境(0现网/1沙箱)"
    

Task 3: VirtualGoodsConfig 实体/Mapper/Service + 管理端 CRUD 接口

现状(照抄范式)

  • 实体范式:entity/SchemaVersion.java —— @Data @TableName("表名") @TableId(type = IdType.AUTO),不加列注解(全局 map-underscore-to-camel-case: true
  • Mapper 范式:mapper/SchemaVersionMapper.java —— @Mapper interface XxxMapper extends BaseMapper<Xxx>
  • 管理端控制器范式:controller/admin/ 子包,@RequestAttribute("role") 手动校验 admin

  • [ ] Step 1: 实体 entity/VirtualGoodsConfig.java

    @Data
    @TableName("virtual_goods_config")
    public class VirtualGoodsConfig {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String goodsType;    // ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION
    private String bizKey;       // 业务SKU标识
    private String productId;    // 微信商户后台道具ID
    private String goodsName;
    private Integer status;      // 1=启用 0=停用
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
    }
    
  • [ ] Step 2: Mapper mapper/VirtualGoodsConfigMapper.java

    @Mapper
    public interface VirtualGoodsConfigMapper extends BaseMapper<VirtualGoodsConfig> {
    }
    
  • [ ] Step 3: Service service/VirtualGoodsConfigService.java

方法集(继承 ServiceImpl<VirtualGoodsConfigMapper, VirtualGoodsConfig>):

  • VirtualGoodsConfig getEnabled(String goodsType, String bizKey) —— status=1 + 按 (goods_type, biz_key) 查;查不到返回 null(调用方转 46001)
  • List<VirtualGoodsConfig> list(String goodsType) —— 管理端列表(可按类型筛选)
  • save/update/delete —— 管理端 CRUD(删除建议软删置 status=0 或直接删,简单配置类参照 admin.js 中 ZodiacConfigs 先例)

  • [ ] Step 4: 管理端控制器 controller/admin/VirtualGoodsConfigAdminController.java

@RequestMapping("/api/admin/virtual-goods"),全 @PostMapping

  • POST /list(body: goodsType 可选)→ Result<List<VirtualGoodsConfig>>
  • POST /save(body: 实体字段,id 空=新增/非空=更新)→ Result<Boolean>
  • POST /delete(body: id)→ Result<Boolean>
  • 控制器内 @RequestAttribute("role") 校验 "admin"(照现有 admin 控制器)

  • [ ] Step 5: 编译验证 + 冲突检查

    cd cfc-backend && mvn clean compile -q
    grep -rn "class VirtualGoodsConfig" cfc-backend/src --include=*.java   # 确认无重名 Bean
    
  • [ ] Step 6: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/VirtualGoodsConfig.java cfc-backend/src/main/java/com/etotem/cfc/mapper/VirtualGoodsConfigMapper.java cfc-backend/src/main/java/com/etotem/cfc/service/VirtualGoodsConfigService.java cfc-backend/src/main/java/com/etotem/cfc/controller/admin/VirtualGoodsConfigAdminController.java
    git commit -m "feat: 虚拟支付道具映射 实体/Mapper/Service/管理端CRUD接口"
    

Task 4: VirtualPayService(签名 + 参数生成 + 推送处理)+ VirtualPayParamsDTO

签名要点(官方规范 + 踩坑验证)

  • paySig = to_hex(hmac_sha256(appKey, uri + "&" + signData))uri 固定 "requestVirtualPayment"
  • signData = orderInfo + extInfo 按官方规则拼接;signData 中不得包含 platform 字段(否则 -15005)
  • 道具直购需两个签名paySig(订单参数签名,后端用 appKey 算)+ signature(用户身份签名,用 sessionKey 算 HMAC-SHA256,直接字符串编码,不先 base64 解码 sessionKey
  • 错误码:1001 参数错误、-15005 签名无效
  • mode 固定 "short_series_goods"offerId 传微信平台道具 ID(即 product_id,非业务 productId);env 必须与 offerId 道具版本对应
  • ⚠️ 以上 signData 精确拼接串在实现时对照官方 Python 签名脚本做断言单测(文档示例输出 c37809f27c...),联调再以沙箱实测为准

  • [ ] Step 1: DTO dto/VirtualPayParamsDTO.java

    @Data
    public class VirtualPayParamsDTO {
    private Map<String, Object> orderInfo;   // {mch_id, appid, out_trade_no, total_fee, product_info, attach, mode, offerId, ...}
    private Map<String, Object> extInfo;     // 空对象即可
    private String sign;                     // paySig(appKey 签名)
    private String signature;                // 用户身份签名(sessionKey 签名,道具直购必需)
    private Integer env;                     // 0=现网 1=沙箱(配置 wechat.virtual-pay-env)
    private String signType;                 // "HMAC-SHA256"
    }
    
  • [ ] Step 2: HMAC-SHA256 工具(JDK 原生,零新依赖)

VirtualPayService 内(或 common/ 新增 HmacSignUtil):

private static String hmacSha256Hex(String key, String data) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
    StringBuilder sb = new StringBuilder();
    for (byte b : raw) sb.append(String.format("%02x", b));
    return sb.toString();
}
  • [ ] Step 3: generatePayParams() 核心方法

    public VirtualPayParamsDTO generatePayParams(VirtualGoodsConfig config, String outTradeNo,
        Integer totalFee, String productInfo, String attach, String sessionKey) {
    // 1. orderInfo 组装(mch_id/appid 从 wechat 配置取;total_fee 服务端计算传入;mode="short_series_goods";offerId=config.productId)
    // 2. signData 按官方规则拼接(不含 platform)
    // 3. paySig = hmacSha256Hex(appKey, "requestVirtualPayment&" + signData)
    // 4. signature = hmacSha256Hex(sessionKey, <官方定义的拼接串>)  // 直接字符串编码
    // 5. 组装 DTO 返回
    }
    

appid 复用现有 wechat.appidwx5ba8038ef16fb245);mch_id虚拟支付商户号(非现有 v3 商户号,从配置或联调确认);sessionKey 从用户登录态取(wx.login → jscode2session 时后端已保存,需确认存储位置——UserService 或 Redis;若未保存需在登录流程补充)。

  • [ ] Step 4: 发货推送处理 handleDeliverNotify()

    public Map<String, Object> handleDeliverNotify(Map<String, Object> payload) {
    // 1. 校验 payload 格式(OutTradeNo / TransactionId / 可选金额)
    // 2. 按 OutTradeNo 前缀路由:A→assessment  ORD→membership  SUB→subscription
    //    (参照 PaymentService.handleWechatNotify() L306-348 前缀路由模式)
    // 3. 分别调对应 Service 的"标记已付+解锁"方法(Task 6/7/8 实现),幂等
    // 4. 返回 {"ErrCode":0,"ErrMsg":"success"}
    }
    
  • [ ] Step 5: 退款推送处理 handleRefundNotify()

按前缀路由 → 调对应订单 Service 标记 refundedAssessmentOrderService.refundOrder 思路 / 订阅逻辑退款)。返回 {"ErrCode":0,"ErrMsg":"success"}

  • Step 6: 签名单测 VirtualPayServiceTest

对照官方 Python 脚本的固定输入输出做断言(tests/ 分层策略下放 unit 层,参照 tests/AGENTS.md):

@Test
public void paySig_matchesOfficialExample() {
    // 用官方文档示例的 appKey/signData 断言 hmacSha256Hex 输出 == 官方值
}
  • [ ] Step 7: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/dto/VirtualPayParamsDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java
    git commit -m "feat: VirtualPayService paySig签名/参数生成/发货退款推送处理
    JDK原生HMAC-SHA256零新依赖,双签名(paySig+signature),前缀路由"
    

Task 5: VirtualPayController(推送接收端点)

现状

  • /api/internal/ 已在 JwtInterceptor.PUBLIC_PATHS(L39)前缀公开,无需改拦截器
  • FamilyAccessInterceptor 默认 logging 模式、匿名请求放行,不会拦截
  • 回调端点范式:ProductOrderController.notify() L120-155(getReader() 读原始 body)
  • 注意:返回 Map(微信协议 {"ErrCode":0,"ErrMsg":"success"}),不用 Result

  • [ ] Step 1: controller/VirtualPayController.java

    @RestController
    @RequestMapping("/api/internal/virtual-pay")
    public class VirtualPayController {
    
    @Resource
    private VirtualPayService virtualPayService;
    
    /** 微信虚拟支付发货/退款等消息推送 */
    @PostMapping("/notify")
    public Map<String, Object> notify(HttpServletRequest request) {
        // 读原始 body(getReader()),解析 JSON → virtualPayService.handleDeliverNotify / handleRefundNotify
        // 解析失败/未知事件 → 记日志 + 返回 {"ErrCode":0,"ErrMsg":"success"}(微信重试最多15次,避免死循环)
    }
    
    /** iOS App Store 退款问询应答(3秒内应答;3次未应答默认"不确定") */
    @PostMapping("/ios-refund-query")
    public Map<String, Object> iosRefundQuery(HttpServletRequest request) {
        // 校验 payload → 查订阅状态返回 {"IsAgree": true/false, "Reason": "..."}(按官方问询应答协议)
        // 未接入 Apple 服务端通知时先返回协议规定的最低响应
    }
    }
    
  • [ ] Step 2: 编译验证 + 路由重复检查

    cd cfc-backend && mvn clean compile -q
    grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
    
  • [ ] Step 3: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/VirtualPayController.java
    git commit -m "feat: VirtualPayController 接收虚拟支付消息推送
    /api/internal/virtual-pay/notify(公开) + ios-refund-query,返回微信ErrCode协议"
    

Task 6: 测评订单链路改造(后端)

现状(勘察结论)

  • AssessmentOrderService.payOrder()(L149-176):假支付——直接置 paid + 内联预约确认(L161-173,appointment.status 0→1),不设 transactionId、不结佣金
  • paySuccess()(L62-78):已存在零调用——置 paid + transactionId + commissionService.settle无预约确认
  • createOrder()(L35-56):订单号前缀 A(L58-60 generateOrderNo());金额 totalPrice/discountAmount 由前端传参,后端不校验(安全缺口,需服务端重算)
  • 链路断点:AssessmentOrder.appointmentId 字段存在(L29)但 createOrder() 从不赋值,预约确认依赖它
  • 服务端计价参照:PackagePurchaseService.getPackagePrice()(L50-121)——task_template_packages 基础价 + guide_packages 指导师定价 + 邀请码关联价
  • Controller:DanAssessmentController /order/pay(L273-279)现返回 Result<String>,body 为 Map{orderNo, payType}
  • 报告解锁:TeacherService.createAssessmentResult()(L159-205)L196-202 按 appointmentId 查订单,paid → results_readymarkResultsReady() L121-129);家长 getPendingOrdersByFamily()(L141-147)按 status IN (paid, results_ready) 过滤可见

  • [ ] Step 1: 抽出预约确认公共方法

AssessmentAppointmentService(或 AssessmentOrderService)新增 confirmAppointmentByOrderNo(String orderNo):按 orderNo 查订单 → getByAppointmentId 语义查预约 → status 0→1(复用 confirmAppointment() L54-63 的校验逻辑或 payOrder L161-173 内联逻辑),失败仅 warn 不阻断。

  • [ ] Step 2: payOrder() 改为生成支付参数(保持 pending)

    public VirtualPayParamsDTO payOrder(String orderNo, String payType, String sessionKey) {
    // 1. 查订单 + 校验 pending(原逻辑保留)
    // 2. 查 virtual_goods_config:goodsType=ASSESSMENT_PACKAGE, bizKey=订单packageId
    //    → 查不到/停用 → 抛 BusinessException(46001, "该商品暂未开放购买")
    // 3. 服务端重算金额:total_fee = PackagePurchaseService.getPackagePrice(packageId, guideId, inviteCode)(服务端计价,不信任前端)
    //    → 校验 total_fee >= 100(1元)
    // 4. 组装 attach = "assessment:" + packageId;orderInfo.product_info = packageName
    // 5. 调 VirtualPayService.generatePayParams(...) 返回 DTO
    // 6. 订单保持 pending(不置 paid!)
    }
    
  • [ ] Step 3: 建立 appointmentId 关联

/order/create/order/pay 请求中接收前端传的 appointmentId 并回填订单(修复链路断点);若无则发货推送时按 familyId+childId 最近待确认预约关联(二选一,优先前端传参)。

  • Step 4: 发货推送解锁(复用 paySuccess() 并补预约确认)

VirtualPayService.handleDeliverNotify()A 前缀分支调用(建议在 AssessmentOrderService 新增 handleVirtualPaySuccess(orderNo, transactionId)):

  1. 幂等:已 paid 直接返回 true
  2. paySuccess(orderNo, transactionId, "virtual") 基础上:置 payType="virtual"(沿用 payType 字段)、写 transactionId
  3. 调 Step 1 的预约确认公共方法
  4. 佣金结算(paySuccess 已含 commissionService.settle
  • Step 5: Controller /order/pay 返回类型改造

DanAssessmentController L273-279:返回 Result<VirtualPayParamsDTO>(body 增加 sessionKey 来源说明:后端从登录态取,不由前端传)。testMode 分支:wechat.test-mode=true 时保持原"直接标记已付"逻辑(mock)。

  • [ ] Step 6: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 7: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java
    git commit -m "feat: 测评订单改虚拟支付
    payOrder返回VirtualPayParams保持pending,服务端计价,发货推送markPaid+预约确认+佣金"
    

Task 7: 会员订单链路改造(后端)

现状(勘察结论)

  • MembershipService.createOrder()(L226-320):非 trial 单已是 pending(L300),订单号 "ORD"+timestamp(L557-559),金额 sys_config/membership_levels 表 + 兜底常量(L236-274,单位分)
  • processPaymentCallback(orderNo, transactionId, payMethod)(L325-411):幂等(L341-343),标记已付 → 优惠券核销 → 插 family_membershipsL382 硬编码升级为 "FAMILY"(PREMIUM 单也会升 FAMILY,属既有 bug)→ member_upgrade_record → 佣金
  • 发货推送直接复用 processPaymentCallback() 即可完成全部开通,改造量最小
  • Controller:/api/membership/orders(L199-215)返回 Result<PaymentOrderDTO>;手动回调 /api/membership/notify(L258-266)保留作 testMode/兜底
  • ⚠️ PaymentOrder 实体userId 字段(只有 familyId);无 product 字段,attach 走映射表反查

  • [ ] Step 1: 下单接口生成支付参数

MembershipService 新增 payOrder(PaymentOrder order, String sessionKey)(或改造 createOrder 尾部):

  1. virtual_goods_config:goodsType=MEMBERSHIP, bizKey=order.levelCode → 查不到抛 46001
  2. total_fee = order.amount(服务端已算,无需重算;校验 ≥100)
  3. attach = "membership:" + levelCode;product_info = 等级展示名
  4. VirtualPayService.generatePayParams(...)
  5. 订单保持 pending(现状已是)
  • Step 2: 修正 L382 硬编码 "FAMILY"

processPaymentCallback L381-385:order.getMemberLevel()order.getLevelCode() 替换硬编码 "FAMILY"(顺带修复 PREMIUM 单升级错等级的既有 bug;注意 member_upgrade_record.fromLevel/toLevel 同步)。

  • Step 3: 发货推送解锁

VirtualPayService.handleDeliverNotify()ORD 分支 → 直接调 membershipService.processPaymentCallback(orderNo, transactionId, "virtual")(幂等已内置)。

  • Step 4: Controller /api/membership/orders 返回 VirtualPayParamsDTO

MembershipController L199-215:改为 Result<Object>(兼容返回 VirtualPayParamsDTO);保留 /api/membership/notify(L258-266)作 testMode/手动兜底。trial 单不变(直接 activateTrialMembership,无支付)。

  • [ ] Step 5: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 6: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
    git commit -m "feat: 会员订单改虚拟支付
    createOrder后返回VirtualPayParams,发货推送复用processPaymentCallback,修正等级硬编码FAMILY"
    

Task 8: 订阅订单链路改造(后端)

现状(勘察结论)

  • MemberSubscriptionService.subscribe()(L88-153):一体方法——算过期时间 → 置旧订阅 expired → 插 member_subscription插订单直接写 "paid"(L114-124,假定已支付)→ 分配管家 butlerService.assignMemberToButler(L130,L290-318)→ 受益日志(L133-135)→ 佣金 + 分享收益(L137-150)
  • 金额 L1=36500 / L2=131400 硬编码在 SubscriptionController(L75-93 plans、L105-111 create);subscribe() 的 amount 由 Controller 传入
  • testMode 分支在 Controller/create L115-123),非 testMode 走 v3 PaymentService.createWechatPrepay,失败回退直接 subscribe(L138)
  • MemberSubscriptionOrder:status 字段 schema 默认 pending(支持 pending/paid/cancelled/refunded),实体/表结构无需改
  • 订阅无回调端点,需新建(Task 5 已建 /api/internal/virtual-pay/notify

  • [ ] Step 1: 拆分 subscribe()

  • 新方法 MemberSubscription createPendingOrder(Long familyId, String level, Integer amount, String paymentType):只建 pending 订单 + 返回(含 orderNo),不开通权益

  • 提取开通段为新方法 activateSubscription(Long familyId, String level, Integer amount, String paymentType, String transactionId, String orderNo):内容 = 原 subscribe() L102-150(置旧订阅 expired → 插 subscription → 订单置 paid + transactionId → 分配管家 → 受益日志 → 佣金分享收益)

  • subscribe() 保留为 createPendingOrder + activateSubscription 的组合(供 testMode/兼容旧调用)

  • [ ] Step 2: Controller /api/subscription/create 改返回支付参数

SubscriptionController L98-143:

  • testMode=true:保持原逻辑(直接 subscribe() 标已付,返回 {orderNo, amount, testMode:true}
  • testMode=false:createPendingOrder 建 pending 单 → 查 virtual_goods_config(goodsType=SUBSCRIPTION, bizKey=level)→ 校验金额 ≥100 → VirtualPayService.generatePayParams(...) → 返回 Result<VirtualPayParamsDTO>;attach="subscription:"+level不再走 v3 createWechatPrepay,不再失败回退直接开通
  • /api/subscription/subscribe(L148-157,完全无支付)→ 保留但标注仅 testMode 使用(或加 testMode 门禁)

  • [ ] Step 3: 发货推送解锁

VirtualPayService.handleDeliverNotify()SUB 分支 → memberSubscriptionService.activateSubscription(familyId, level, amount, "virtual", transactionId, orderNo)(familyId/level/amount 从订单查回)。

  • Step 4: 退款推送(逻辑退款自动处理)

handleRefundNotify()SUB 分支 → 置对应 member_subscription 过期 + 订单 refunded(iOS 用户 App Store 退款场景)。

  • [ ] Step 5: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 6: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java
    git commit -m "feat: 订阅订单改虚拟支付
    subscribe拆分为createPendingOrder+activateSubscription,下单返回VirtualPayParams"
    

Task 9: 定时任务兜底(query_order + 超时关单)

现状(范式)

  • @EnableSchedulingCfcApplication.java L12;任务类放 com.etotem.cfc.task 包,@Component + @Scheduled
  • 参照:OrderTimeoutCancelTask(fixedRate=60000 + LambdaQueryWrapper + @Value("${...:30}")

  • [ ] Step 1: 新建 task/VirtualPayTimeoutTask.java

    @Component
    public class VirtualPayTimeoutTask {
    // @Scheduled(fixedRate = 60000) 每分钟
    // 1. 扫描三张订单表 status=pending 且 created_at < now-30min(配置 virtual-pay.payment-timeout-minutes:30)
    // 2. 调 VirtualPayService.queryOrder(outTradeNo) 确认实际支付状态(兜底 success 回调丢失)
    //    → 已支付则触发对应解锁逻辑(幂等)
    //    → 未支付则置 status=cancelled(测评/会员),订阅置 cancelled
    // 3. query_order 调微信接口需 appKey 签名,参照 generatePayParams 同款签名逻辑
    }
    
  • [ ] Step 2: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 3: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/task/VirtualPayTimeoutTask.java
    git commit -m "feat: 虚拟支付超时兜底任务
    pending订单query_order确认+超时关单,防success回调丢失"
    

Task 10: 前端改造(utils/api.js + 三个购买页)

现状(勘察结论)

  • utils/api.jsrequest() 基座 L25-76;payAssessmentOrder(orderNo, payType) L835-837(零页面调用,仅测试);createOrder(levelCode, paymentType, period) L304-310(upgrade.vue 用);getOrderStatus(orderNo) L790-792(零调用,可复用于有限轮询)
  • config.js L24-45:uni.getAccountInfoSync().miniProgram.envVersion 映射 develop/trial/release → API 地址
  • upgrade.vuedoUpgrade() L336-344 / doRenew() L358-366 —— createOrder → showToast('开通成功') → loadData 假成功
  • plans.vue purchasePlan L128-130:跳 upgrade 页
  • purchase.vue L256 调 createPackageOrder(package 订单,非 assessment 订单);L270-272 跳转到自身的自跳转 bug;测评订单前端入口缺失(payAssessmentOrder 无调用者)
  • 实物 payment.vue L237-267:uni.requestPayment 成功后调后端确认接口 → toast → redirectTo(无轮询,模式可沿用)

  • [ ] Step 1: utils/api.js 新增封装

    // 创建虚拟支付订单(返回 VirtualPayParamsDTO)
    export const createVirtualPayOrder = (orderNo, goodsType) => {
    return request('/api/payment/virtual/create', 'POST', { orderNo, goodsType })
    }
    
    // 虚拟支付环境映射(复用 config.js 同一 envVersion 来源)
    export const getVirtualPaymentEnv = () => {
    try {
    var info = uni.getAccountInfoSync()
    var env = info.miniProgram && info.miniProgram.envVersion
    if (env === 'develop') return 2   // 测试环境
    if (env === 'trial') return 1     // 沙箱
    return 0                          // release 现网
    } catch (e) {
    return 0
    }
    }
    
    // 虚拟支付拉起(封装 wx.requestVirtualPayment)
    export const requestVirtualPayment = (params, callbacks) => {
    wx.requestVirtualPayment({
    orderInfo: params.orderInfo,
    extInfo: params.extInfo,
    signature: params.signature,   // 用户身份签名(后端返回)
    mode: 'short_series_goods',
    env: params.env !== undefined ? params.env : getVirtualPaymentEnv(),
    success: function(res) { if (callbacks && callbacks.success) callbacks.success(res) },
    fail: function(err) { if (callbacks && callbacks.fail) callbacks.fail(err) }
    })
    }
    

说明:sign(paySig)由后端在 orderInfo 构造时按官方规则使用(官方 wx.requestVirtualPayment 参数以联调实测为准,paySig 的放置位置在联调时对照官方工具确认——signData 传参与支付参数结构对齐)。页面不直接拼签名。

  • [ ] Step 2: pages/membership/upgrade.vue 改造 doUpgrade/doRenew

    doUpgrade: function() {
    var self = this
    createOrder(self.selectedLevel, 'pay', self.selectedPeriod).then(function(res) {
    var orderNo = res.data && res.data.orderNo
    if (!orderNo) { uni.showToast({ title: '下单失败', icon: 'none' }); return }
    return createVirtualPayOrder(orderNo, 'MEMBERSHIP')   // 后端返回 VirtualPayParamsDTO
    }).then(function(params) {
    requestVirtualPayment(params, {
      success: function() {
        uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
        self.pollOrderStatus()   // 有限轮询订单状态(复用 getOrderStatus,最多 N 次)
      },
      fail: function() { uni.showToast({ title: '支付未完成', icon: 'none' }) }
    })
    }).catch(function(e) {
    if (e.code === 46001) uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
    else uni.showToast({ title: e.message || '开通失败', icon: 'none' })
    })
    },
    

doRenew 同构替换。新增 pollOrderStatus()getOrderStatus(orderNo) 每 2s 查一次,最多 5 次,paidloadData() + toast"开通成功"。

  • Step 3: pages/membership/plans.vue 订阅流程

订阅档位购买(L1/L2)接 /api/subscription/createcreateVirtualPayOrder(orderNo, 'SUBSCRIPTION')requestVirtualPayment;testMode 响应 testMode:true 时沿用旧直接成功逻辑。

  • [ ] Step 4: 测评购买入口

  • 确认并接通 payAssessmentOrder(api.js L835)调用链:下单(/api/dan-assessment/order/create)→ 支付(/order/pay 返回 VirtualPayParamsDTO,直接前端拉起,无需再走 createVirtualPayOrder——后端接口已内联生成支付参数)

  • 修复 purchase.vue L270-272 自跳转 bug(改为创建订单后进入支付拉起)

  • 前端 totalPrice 计算(purchase.vue L198-220)仅作展示,实际金额以服务端重算为准

  • 支付成功后:requestVirtualPayment success 提示"支付成功" + 轮询订单状态(复用 getOrderStatus)→ 跳转测评预约页

  • [ ] Step 5: 提交

    git add cfc-frontend/utils/api.js cfc-frontend/pages/membership/upgrade.vue cfc-frontend/pages/membership/plans.vue cfc-frontend/pages/assessment/purchase.vue
    git commit -m "feat: 前端接入小程序虚拟支付
    api.js新增createVirtualPayOrder/requestVirtualPayment/getVirtualPaymentEnv,三个购买页假支付改真支付"
    

Task 11: Web 管理端道具映射 CRUD

现状(勘察结论)

  • 标准 CRUD 模板:cfc-web/src/views/admin/KnowledgeTag.vue(184 行,表格+搜索+弹窗+增删改三件套 dialogVisible/isEdit/form
  • 路由注册:router/index.js children(参照 L329-333 knowledge-tags 格式),meta.perm 必须存在于 utils/permissions.js
  • 菜单注册:Layout.vue menuItems(L165+),系统配置分组 L268-275
  • API 模块:新文件 src/api/virtualGoods.jsimport request from '@/utils/request' + 命名导出,参照 api/dimension.js

  • [ ] Step 1: src/api/virtualGoods.js

    import request from '@/utils/request'
    
    export function getVirtualGoodsList(data) {
    return request({ url: '/api/admin/virtual-goods/list', method: 'post', data })
    }
    export function saveVirtualGoods(data) {
    return request({ url: '/api/admin/virtual-goods/save', method: 'post', data })
    }
    export function deleteVirtualGoods(data) {
    return request({ url: '/api/admin/virtual-goods/delete', method: 'post', data })
    }
    
  • [ ] Step 2: src/views/admin/VirtualGoodsConfig.vue

照抄 KnowledgeTag.vue 结构,替换为:

  • 筛选:goods_type 下拉(ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION)+ 状态
  • 表格列:id / goods_type / biz_key / product_id / goods_name / status(开关)/ create_time / update_time
  • 表单弹窗:goods_type(select)、biz_key、product_id、goods_name、status(switch)
  • 提示文案:product_id 需与微信商户后台道具 ID 一致;上线前必须配置

  • [ ] Step 3: 路由 + 菜单 + 权限注册

  • router/index.js children 加:{ path: 'virtual-goods-config', name: 'VirtualGoodsConfig', component: () => import('@/views/admin/VirtualGoodsConfig.vue'), meta: { title: '虚拟支付道具', perm: 'system:config' } }

  • Layout.vue menuItems 系统配置组(L268-275)加:{ path: '/virtual-goods-config', label: '虚拟支付道具', icon: 'el-icon-goods', perm: 'system:config' }

  • 确认 system:config 已存在于 utils/permissions.js(若没有则新增或改用现有 admin 权限串)

  • [ ] Step 4: 提交

    git add cfc-web/src/api/virtualGoods.js cfc-web/src/views/admin/VirtualGoodsConfig.vue cfc-web/src/router/index.js cfc-web/src/views/Layout.vue
    git commit -m "feat: 管理端虚拟支付道具映射CRUD页面
    VirtualGoodsConfig.vue照KnowledgeTag模板,路由/菜单/权限注册"
    

Task 12: 全量验证 + 文档同步

  • [ ] Step 1: 后端编译 + 冲突检查

    cd cfc-backend && mvn clean compile -q
    grep -rn "class VirtualPay\|class VirtualGoods" cfc-backend/src/main/java --include=*.java | sort   # 无重名
    grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u   # 无路由重复
    
  • [ ] Step 2: 测试执行

  • 签名单测通过(Task 4 Step 6)

  • 推送幂等测试:重复发货推送同一 OutTradeNo 只解锁一次

  • testMode 回归:wechat.test-mode=true 下测评/订阅走 mock 直付

  • 实物商品 v3 支付回归:payment.vue / PaymentService / /api/payment/notify 不受影响

  • [ ] Step 3: 更新 docs/superpowers/PROJECT-OVERVIEW.md

按 AGENTS.md 约定,本计划文件创建后更新 PROJECT-OVERVIEW.md 的 plans/ 索引条目(版本号、状态、文件链接)。

  • [ ] Step 4: 提交

    git add docs/superpowers/PROJECT-OVERVIEW.md docs/superpowers/plans/2026-08-02-virtual-payment-implementation.md
    git commit -m "docs: 虚拟支付实施计划 + PROJECT-OVERVIEW 索引更新"
    

依赖与执行顺序

Task 1 (建表) ──┐
Task 2 (配置) ──┼──→ Task 3 (映射Service) ──→ Task 4 (VirtualPayService) ──→ Task 5 (Controller)
Task 3 (CRUD) ─┘                                          │
                                                          ▼
                        ┌──────────────┬──────────────────┴──────────────┐
                        ▼              ▼                                 ▼
                Task 6 (测评)   Task 7 (会员)                     Task 8 (订阅)
                        └──────────────┴──────────────────┬──────────────┘
                                                          ▼
                                                Task 9 (定时任务兜底)
                                                          ▼
                                            Task 10 (前端) ── 依赖 Task 6/7/8 接口
                                            Task 11 (管理端) ── 依赖 Task 3 接口
                                                          ▼
                                                Task 12 (全量验证)

可并行:Task 1/2 可并行;Task 6/7/8 依赖 Task 4,彼此独立可并行;Task 10/11 依赖各自后端接口,可并行;Task 12 收尾。

联调前置清单(上线前需人工完成,不阻塞开发)

  • 微信商户后台配置道具(ProductId)并发布至现网,映射表按 product_id 填入
  • mp.weixin.qq.com 配置服务器推送 URL → https://cfc.bianwoyou.com.cn/api/internal/virtual-pay/notify(确认明文/加密模式)
  • 配置 WECHAT_APPKEY(现网与沙箱分开)
  • iOS 真机验证(沙箱不支持 iOS):iOS 15+ / 微信 8.0.68+
  • 沙箱联调:env=1 全链路;对照官方签名工具核对 paySig/signature

自检清单

  • virtual_goods_config 表迁移 121 幂等可重复执行;schema.sql 已同步
  • wechat.appkey/wechat.virtual-pay-env 三处 yml 已配置
  • VirtualPayService 签名单测对照官方输出通过;JDK 原生 HMAC 零新依赖
  • /api/internal/virtual-pay/notify 挂载成功(JwtInterceptor 已公开,无需改)
  • 测评:下单 → 返回 VirtualPayParams → 支付 → 发货推送 → paid + 预约确认 + 佣金
  • 会员:下单 → VirtualPayParams → 推送 → processPaymentCallback 开通(等级已修正)
  • 订阅:下单建 pending → VirtualPayParams → 推送 → activateSubscription 开通 + 分配管家
  • 退款:xpay_refund_notify 更新订单状态;iOS 问询端点可应答
  • 道具未配置/停用 → 46001 提示不可购买
  • 幂等:重复发货推送不重复解锁;成功回调丢失由定时任务 query_order 兜底
  • 前端三个购买页无假支付残留;payment.vue 实物 v3 未动
  • 管理端 VirtualGoodsConfig.vue CRUD 可用
  • mvn clean compile 通过;无路由重复;无 Bean 命名冲突
  • docs/superpowers/PROJECT-OVERVIEW.md 已同步