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(已确认,实施唯一依据)docs/superpowers/plans/2026-07-28-membership-price-tier-update.md外部依赖(不阻塞开发):
/api/internal/virtual-pay/notify(联调时配置,默认明文模式)@PostMapping;响应统一 Result<T>;DI 用 @Resource{"ErrCode":0,"ErrMsg":"success"} 格式 Map,不用 Result(微信协议要求)DatabaseInitializer.runMigrations(),迁移后必须同步 schema.sqljavax.crypto.Mac(项目无 commons-codec 依赖,零新依赖)?.、禁止 CSS Grid、禁止 :key 表达式| 文件 | 操作 | 职责 |
|---|---|---|
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 分支保留 |
| 文件 | 操作 | 职责 |
|---|---|---|
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 路径保留 |
| 文件 | 操作 | 职责 |
|---|---|---|
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) |
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) {} 幂等):
// 迁移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());
}
在 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),幂等"
wechat.appkey + virtual-pay-envapplication.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-envconfig/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}。
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沙箱)"
VirtualGoodsConfig 实体/Mapper/Service + 管理端 CRUD 接口entity/SchemaVersion.java —— @Data @TableName("表名") @TableId(type = IdType.AUTO),不加列注解(全局 map-underscore-to-camel-case: true)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接口"
VirtualPayService(签名 + 参数生成 + 推送处理)+ VirtualPayParamsDTOpaySig = 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.appid(wx5ba8038ef16fb245);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 标记 refunded(AssessmentOrderService.refundOrder 思路 / 订阅逻辑退款)。返回 {"ErrCode":0,"ErrMsg":"success"}。
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),前缀路由"
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协议"
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 指导师定价 + 邀请码关联价DanAssessmentController /order/pay(L273-279)现返回 Result<String>,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)
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 最近待确认预约关联(二选一,优先前端传参)。
paySuccess() 并补预约确认)VirtualPayService.handleDeliverNotify() 的 A 前缀分支调用(建议在 AssessmentOrderService 新增 handleVirtualPaySuccess(orderNo, transactionId)):
paySuccess(orderNo, transactionId, "virtual") 基础上:置 payType="virtual"(沿用 payType 字段)、写 transactionIdpaySuccess 已含 commissionService.settle)/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+预约确认+佣金"
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() 即可完成全部开通,改造量最小/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 尾部):
virtual_goods_config:goodsType=MEMBERSHIP, bizKey=order.levelCode → 查不到抛 46001total_fee = order.amount(服务端已算,无需重算;校验 ≥100)"membership:" + levelCode;product_info = 等级展示名VirtualPayService.generatePayParams(...)"FAMILY"processPaymentCallback L381-385:order.getMemberLevel() 或 order.getLevelCode() 替换硬编码 "FAMILY"(顺带修复 PREMIUM 单升级错等级的既有 bug;注意 member_upgrade_record.fromLevel/toLevel 同步)。
VirtualPayService.handleDeliverNotify() 的 ORD 分支 → 直接调 membershipService.processPaymentCallback(orderNo, transactionId, "virtual")(幂等已内置)。
/api/membership/orders 返回 VirtualPayParamsDTOMembershipController 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"
MemberSubscriptionService.subscribe()(L88-153):一体方法——算过期时间 → 置旧订阅 expired → 插 member_subscription → 插订单直接写 "paid"(L114-124,假定已支付)→ 分配管家 butlerService.assignMemberToButler(L130,L290-318)→ 受益日志(L133-135)→ 佣金 + 分享收益(L137-150)subscribe() 的 amount 由 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:
subscribe() 标已付,返回 {orderNo, amount, testMode:true})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 从订单查回)。
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"
@EnableScheduling 在 CfcApplication.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回调丢失"
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 新增封装
// 创建虚拟支付订单(返回 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 次,paid 则 loadData() + toast"开通成功"。
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: 提交
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,三个购买页假支付改真支付"
cfc-web/src/views/admin/KnowledgeTag.vue(184 行,表格+搜索+弹窗+增删改三件套 dialogVisible/isEdit/form)router/index.js children(参照 L329-333 knowledge-tags 格式),meta.perm 必须存在于 utils/permissions.jsLayout.vue menuItems(L165+),系统配置分组 L268-275API 模块:新文件 src/api/virtualGoods.js(import 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 结构,替换为:
提示文案: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模板,路由/菜单/权限注册"
[ ] 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 收尾。
https://cfc.bianwoyou.com.cn/api/internal/virtual-pay/notify(确认明文/加密模式)WECHAT_APPKEY(现网与沙箱分开)virtual_goods_config 表迁移 121 幂等可重复执行;schema.sql 已同步wechat.appkey/wechat.virtual-pay-env 三处 yml 已配置VirtualPayService 签名单测对照官方输出通过;JDK 原生 HMAC 零新依赖/api/internal/virtual-pay/notify 挂载成功(JwtInterceptor 已公开,无需改)xpay_refund_notify 更新订单状态;iOS 问询端点可应答payment.vue 实物 v3 未动mvn clean compile 通过;无路由重复;无 Bean 命名冲突docs/superpowers/PROJECT-OVERVIEW.md 已同步