# 小程序虚拟支付接入实施计划 > **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 **参考:** - 设计文档 `docs/superpowers/specs/2026-08-02-virtual-payment-design.md`(已确认,实施唯一依据) - 官方文档 https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html - 计划格式参考 `docs/superpowers/plans/2026-07-28-membership-price-tier-update.md` **外部依赖(不阻塞开发):** - 微信商户后台道具(ProductId)尚未配置 → 映射表先建,上线前配置 - mp.weixin.qq.com 服务器推送 URL 需指向 `/api/internal/virtual-pay/notify`(联调时配置,默认明文模式) --- ## 关键约束(AGENTS.md 强制) - 接口统一 `@PostMapping`;响应统一 `Result`;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` | | `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` | | `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.java` 的 `runMigrations()` 起始于 L112,最新迁移 `// 迁移120` 在 **L7277**(文件末尾区域) - `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) {}` 幂等): ```java // 迁移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: 编译验证** ```bash cd cfc-backend && mvn clean compile -q ``` - [ ] **Step 4: 提交** ```bash 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` 后)追加** ```yaml 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: 提交** ```bash 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` - 管理端控制器范式:`controller/admin/` 子包,`@RequestAttribute("role")` 手动校验 admin - [ ] **Step 1: 实体 `entity/VirtualGoodsConfig.java`** ```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`** ```java @Mapper public interface VirtualGoodsConfigMapper extends BaseMapper { } ``` - [ ] **Step 3: Service `service/VirtualGoodsConfigService.java`** 方法集(继承 `ServiceImpl`): - `VirtualGoodsConfig getEnabled(String goodsType, String bizKey)` —— `status=1` + 按 `(goods_type, biz_key)` 查;查不到返回 null(调用方转 46001) - `List 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>` - `POST /save`(body: 实体字段,id 空=新增/非空=更新)→ `Result` - `POST /delete`(body: id)→ `Result` - 控制器内 `@RequestAttribute("role")` 校验 `"admin"`(照现有 admin 控制器) - [ ] **Step 5: 编译验证 + 冲突检查** ```bash cd cfc-backend && mvn clean compile -q grep -rn "class VirtualGoodsConfig" cfc-backend/src --include=*.java # 确认无重名 Bean ``` - [ ] **Step 6: 提交** ```bash 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`** ```java @Data public class VirtualPayParamsDTO { private Map orderInfo; // {mch_id, appid, out_trade_no, total_fee, product_info, attach, mode, offerId, ...} private Map 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`): ```java 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()` 核心方法** ```java 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.appid`(`wx5ba8038ef16fb245`);`mch_id` 为**虚拟支付商户号**(非现有 v3 商户号,从配置或联调确认);`sessionKey` 从用户登录态取(`wx.login → jscode2session` 时后端已保存,需确认存储位置——UserService 或 Redis;若未保存需在登录流程补充)。 - [ ] **Step 4: 发货推送处理 `handleDeliverNotify()`** ```java public Map handleDeliverNotify(Map 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 标记 `refunded`(`AssessmentOrderService.refundOrder` 思路 / 订阅逻辑退款)。返回 `{"ErrCode":0,"ErrMsg":"success"}`。 - [ ] **Step 6: 签名单测 `VirtualPayServiceTest`** 对照官方 Python 脚本的固定输入输出做断言(`tests/` 分层策略下放 unit 层,参照 `tests/AGENTS.md`): ```java @Test public void paySig_matchesOfficialExample() { // 用官方文档示例的 appKey/signData 断言 hmacSha256Hex 输出 == 官方值 } ``` - [ ] **Step 7: 提交** ```bash 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`** ```java @RestController @RequestMapping("/api/internal/virtual-pay") public class VirtualPayController { @Resource private VirtualPayService virtualPayService; /** 微信虚拟支付发货/退款等消息推送 */ @PostMapping("/notify") public Map 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 iosRefundQuery(HttpServletRequest request) { // 校验 payload → 查订阅状态返回 {"IsAgree": true/false, "Reason": "..."}(按官方问询应答协议) // 未接入 Apple 服务端通知时先返回协议规定的最低响应 } } ``` - [ ] **Step 2: 编译验证 + 路由重复检查** ```bash 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: 提交** ```bash 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`,body 为 `Map{orderNo, payType}` - 报告解锁:`TeacherService.createAssessmentResult()`(L159-205)L196-202 按 appointmentId 查订单,`paid → results_ready`(`markResultsReady()` 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)** ```java 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`(body 增加 `sessionKey` 来源说明:后端从登录态取,不由前端传)。testMode 分支:`wechat.test-mode=true` 时保持原"直接标记已付"逻辑(mock)。 - [ ] **Step 6: 编译验证** ```bash cd cfc-backend && mvn clean compile -q ``` - [ ] **Step 7: 提交** ```bash 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_memberships` → **L382 硬编码升级为 `"FAMILY"`**(PREMIUM 单也会升 FAMILY,属既有 bug)→ `member_upgrade_record` → 佣金 - **发货推送直接复用 `processPaymentCallback()` 即可完成全部开通**,改造量最小 - Controller:`/api/membership/orders`(L199-215)返回 `Result`;手动回调 `/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`(兼容返回 `VirtualPayParamsDTO`);保留 `/api/membership/notify`(L258-266)作 testMode/手动兜底。trial 单不变(直接 activateTrialMembership,无支付)。 - [ ] **Step 5: 编译验证** ```bash cd cfc-backend && mvn clean compile -q ``` - [ ] **Step 6: 提交** ```bash 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`;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: 编译验证** ```bash cd cfc-backend && mvn clean compile -q ``` - [ ] **Step 6: 提交** ```bash 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 + 超时关单) ### 现状(范式) - `@EnableScheduling` 在 `CfcApplication.java` L12;任务类放 `com.etotem.cfc.task` 包,`@Component` + `@Scheduled` - 参照:`OrderTimeoutCancelTask`(fixedRate=60000 + LambdaQueryWrapper + `@Value("${...:30}")`) - [ ] **Step 1: 新建 `task/VirtualPayTimeoutTask.java`** ```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: 编译验证** ```bash cd cfc-backend && mvn clean compile -q ``` - [ ] **Step 3: 提交** ```bash 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.js`:`request()` 基座 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.vue`:`doUpgrade()` 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` 新增封装** ```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`** ```js 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 次,`paid` 则 `loadData()` + toast"开通成功"。 - [ ] **Step 3: `pages/membership/plans.vue` 订阅流程** 订阅档位购买(L1/L2)接 `/api/subscription/create` → `createVirtualPayOrder(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: 提交** ```bash 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.js`(`import request from '@/utils/request'` + 命名导出,参照 `api/dimension.js`) - [ ] **Step 1: `src/api/virtualGoods.js`** ```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: 提交** ```bash 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: 后端编译 + 冲突检查** ```bash 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: 提交** ```bash 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` 已同步