From 8fc667daee04673bdc10b7edf2f762e67c626d0f Mon Sep 17 00:00:00 2001 From: break Date: Thu, 30 Jul 2026 11:41:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=E4=BB=A3=E7=90=86=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=8E=B0=E6=9C=89=E7=BD=91=E9=A1=B5=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 微信按当前 v2/v3 配置分别生成 MWEB/H5 链接,支付宝复用 C 端 WAP 链接,并让可用支付方式基于生效配置判断。 Constraint: 支付链接统一通过 qr_content 返回,由前端渲染二维码;按要求不运行测试 Rejected: 微信 Native 与支付宝当面付 | 会引入非当前商户配置所需的额外产品开通 Confidence: high Scope-risk: moderate Directive: 微信 H5/MWEB 二维码仅承诺系统相机或外部浏览器扫码链路 Tested: 相关 Go 包编译通过;gofmt 与 git diff --check 通过 Not-tested: 按用户要求未运行自动化测试及真实支付联调 --- .scratch/ur34-agent-recharge/PRD.md | 16 +- README.md | 2 +- cmd/worker/main.go | 4 +- docs/7月迭代/7月迭代前后端任务工时表.md | 2 +- docs/7月迭代/7月迭代技术方案-标准评审稿.md | 10 +- docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md | 2 +- docs/7月迭代/七月迭代实现与接口对接说明.md | 4 +- .../原需求/需求15-16-18-19-20-21-复杂需求.md | 2 +- .../独立方案/新增需求/05-代理钱包扫码充值.md | 64 ++--- .../代理钱包扫码充值接口对接说明.md | 8 +- .../功能总结.md | 4 +- .../微信扫码支付产品边界核对.md | 103 ++++++++ .../agentrecharge/online_creation.go | 26 +- .../agentrecharge/online_payment.go | 23 +- .../agentrecharge/recover_online_payment.go | 25 +- internal/bootstrap/services.go | 4 +- internal/domain/agentrecharge/online.go | 4 +- internal/handler/admin/agent_recharge.go | 2 +- .../{alipay_precreate.go => alipay_wap.go} | 100 +++----- .../infrastructure/payment/wechat_native.go | 171 ------------- internal/infrastructure/payment/wechat_web.go | 208 ++++++++++++++++ openspec/specs/agent-recharge/spec.md | 26 +- pkg/payment/loader.go | 10 +- pkg/wechat/payment_v2.go | 224 +++++++++++++++++- 24 files changed, 692 insertions(+), 352 deletions(-) create mode 100644 docs/feature-034-agent-wallet-qr-recharge/微信扫码支付产品边界核对.md rename internal/infrastructure/payment/{alipay_precreate.go => alipay_wap.go} (51%) delete mode 100644 internal/infrastructure/payment/wechat_native.go create mode 100644 internal/infrastructure/payment/wechat_web.go diff --git a/.scratch/ur34-agent-recharge/PRD.md b/.scratch/ur34-agent-recharge/PRD.md index e2648ed..f772da9 100644 --- a/.scratch/ur34-agent-recharge/PRD.md +++ b/.scratch/ur34-agent-recharge/PRD.md @@ -6,7 +6,7 @@ Status: ready-for-agent ## Problem Statement -当前代理充值接口只创建本地充值记录,没有真正创建微信 Native 或支付宝 PreCreate 支付单并返回可供前端渲染的付款内容。在线回调直接在一次事务中尝试完成钱包入账,支付事实与钱包处理结果无法独立表达;回调也没有完整校验支付配置、金额、第三方交易号和业务关联。网络超时、回调丢失、重复回调、钱包事务失败和第三方迟到成功都缺少可靠恢复边界。 +当前代理充值接口只创建本地充值记录,没有按当前配置复用微信 H5/MWEB 与支付宝 WAP 支付能力返回可供前端渲染的支付 URL。在线回调直接在一次事务中尝试完成钱包入账,支付事实与钱包处理结果无法独立表达;回调也没有完整校验支付配置、金额、第三方交易号和业务关联。网络超时、回调丢失、重复回调、钱包事务失败和第三方迟到成功都缺少可靠恢复边界。 当前同一个创建接口还允许代理或平台提交目标 `shop_id`,平台可以替任意代理创建在线支付,容易混淆真实付款人和受益钱包。线下代充值仍通过本地 `offline-pay`、`reject` 和全局操作密码完成人工入账,没有接入已经冻结的企业微信审批公共能力;附件仍是字符串 Key 列表,审批结论、充值状态和钱包入账状态也未独立建模。 @@ -16,7 +16,7 @@ Status: ready-for-agent 保留代理充值资源,建立两条严格隔离的创建路径:代理只为当前店铺主钱包选择 `wechat` 或 `alipay` 发起在线充值,不提交目标店铺,也不进入审批;平台或超级管理员只为指定代理店铺发起 `offline` 线下代充值,提交固定金额、备注和 1~5 个结构化付款凭证,并接入 UR#37 的真实企业微信审批。 -在线充值为每次用户主动创建生成新的充值单和支付单。微信 Native 或支付宝 PreCreate 返回的字符串或 HTTPS URL 通过 `qr_content` 原样返回,前端自行渲染二维码,后端不生成二维码图片。前端支付状态轮询只读取本地状态;后端通过支付回调和受控查单任务同步第三方真实状态。支付成功先可靠固化收款事实,再由独立 Worker 幂等增加代理主钱包并写唯一流水,钱包失败不回滚支付成功事实。 +在线充值为每次用户主动创建生成新的充值单和支付单。微信 H5/MWEB 或支付宝 WAP 返回的 HTTPS URL 通过 `qr_content` 原样返回,前端自行渲染二维码,后端不生成二维码图片。前端支付状态轮询只读取本地状态;后端通过支付回调和受控查单任务同步第三方真实状态。支付成功先可靠固化收款事实,再由独立 Worker 幂等增加代理主钱包并写唯一流水,钱包失败不回滚支付成功事实。 线下充值创建时把充值单、唯一企微审批实例和提交 Outbox 原子落库。企微只能同意或拒绝固定金额;同意后复用与在线充值相同的钱包入账 Worker,拒绝后原单终结。无论在线还是线下,只要主钱包实际入账成功,都向目标代理发送防重的站内到账通知。 @@ -79,7 +79,7 @@ Status: ready-for-agent - `data` 固定包含:`methods:string[]`、`min_amount:int64=10000`、`max_amount:int64=100000000`。没有可用方式时 `methods=[]`,接口本身仍成功。 - 接口不返回商户号、应用私钥、支付通道、支付配置 ID或具体缺失的敏感配置。支付方式顺序固定为 `wechat`、`alipay`,仅保留可用项。 - 创建接口必须再次校验所选支付方式。配置在查询列表后失效时拒绝创建,不能只信任前端先前取得的列表。 -- 微信只有在当前微信支付配置支持本需求所需的扫码预下单、回调验签和查单能力时才视为可用;支付宝只有在 PreCreate 所需参数完整时才视为可用。 +- 微信只有在当前微信支付配置支持对应协议的 H5/MWEB 下单、回调验签和查单能力时才视为可用;支付宝只有在 WAP 支付链接签名、回调验签和查单所需参数完整时才视为可用。 ### 创建接口与请求幂等 @@ -94,7 +94,7 @@ Status: ready-for-agent ### 在线充值创建与付款内容 - 在线创建先在同一 PostgreSQL 事务写充值单和 `tb_payment`。支付单增加稳定 `order_type=agent_recharge`,关联充值 ID、支付方式、金额、创建时支付配置 ID和支付单号;充值单初始 `status=1`,支付单初始 `status=0`,处理状态为 0。 -- 微信使用 Native 扫码预下单;支付宝使用 `alipay.trade.precreate`。支付宝不再返回 WAP 支付链接作为本需求的扫码实现。 +- 微信按 `provider_type` 使用 v3 H5 返回 `h5_url` 或 v2 MWEB 返回 `mweb_url`;支付宝复用 C 端 `alipay.trade.wap.pay` 签名 URL。前端统一将 HTTPS URL 渲染为二维码,不要求开通微信 Native 或支付宝当面付。 - 微信或支付宝返回的二维码码串、协议字符串或 HTTPS URL 原样保存为 `qr_content` 并返回。前端使用二维码组件渲染;后端不生成、上传或返回二维码图片,也不新增“生成二维码”“重新生成二维码”接口。 - 创建成功的在线 `data` 至少包含:`recharge_id`、`recharge_no`、`amount`、`payment_method`、`qr_content`、`status/status_name`、`payment_status/payment_status_name`、`processing_status/processing_status_name`、`approval_source=none`。不返回 `expires_at`、`payment_channel` 或支付配置 ID。 - 本地计算时间不能代表第三方支付订单真实有效期。后台在线充值接口不返回 `expires_at`,前端不展示精确倒计时,也不根据本地时间判定二维码仍然有效。 @@ -235,7 +235,7 @@ Status: ready-for-agent - HTTP 创建覆盖:代理成功、平台/超管在线拒绝、企业拒绝、请求携带 `shop_id` 拒绝、微信/支付宝可用性、配置在列表后失效、金额 9999/10000/100000000/100000001、支付方式非法和支付方式创建后不可切换。 - 幂等覆盖:同账号同 `request_id` 同载荷返回原单,不同载荷冲突,并发重复只有一张充值单和支付单;新 `request_id` 在金额相同且旧单待支付时仍创建新单。 -- Adapter 预下单覆盖微信 Native 与支付宝 PreCreate 成功、明确失败、超时但未创建、超时后第三方已创建、响应丢失、恢复仍未知和重复恢复;断言不会因同一请求再建支付单。 +- Adapter 支付链接覆盖微信 v3 H5/v2 MWEB 成功、明确失败、结果未知和支付宝 WAP 本地签名成功/失败;同一请求不得重复创建支付单。 - 响应覆盖原样 `qr_content`、前端可渲染字符串/HTTPS URL、不返回 `expires_at`、支付通道或配置,不创建二维码图片文件。 - 回调覆盖签名/验签失败、订单不存在、业务类型错误、支付方式不符、配置不符、金额不符、第三方交易号冲突、业务关联错误、成功、重复成功和乱序通知。 - 查单覆盖明确待支付、已支付、已关闭/失效、不存在、未知、超时和限流;证明前端状态接口不调用 Adapter,查单与回调并发只固化一次支付成功。 @@ -263,7 +263,7 @@ Status: ready-for-agent - 信息投影分别以平台和代理读取同一线下充值:平台在具备业务权限时可见完整审批资料,代理只能看到业务资料和最小审批摘要;任何响应不返回支付配置、通道或内部错误。 - 迁移演练覆盖支付状态盘点、默认值/注释/约束不一致、历史终态 legacy、可迁移待处理线下单、缺少平台绑定或附件的异常清单、重复执行幂等,以及旧 `offline-pay`、`reject`、`resubmit` 路由确实不存在。 - 前端验收覆盖可用方式加载、每次主动新建、二维码渲染、无倒计时、3 秒本地轮询、页面隐藏暂停、支付成功/入账处理中/失败、余额刷新、线下表单、企微只读详情、审批与到账两类通知、加载/空/失败状态。 -- 本地自动化不要求真实微信或支付宝。部署到测试环境后由用户手工完成一笔微信 Native 和一笔支付宝 PreCreate 最低金额扫码,验证真实付款内容、回调/查单、本地支付状态、钱包入账和到账通知;该人工联调不阻塞本地实现完成门禁,但属于上线前验收。 +- 本地自动化不要求真实微信或支付宝。部署到测试环境后按生效配置手工完成一笔微信 H5/MWEB 和一笔支付宝 WAP 最低金额扫码,验证真实支付 URL、回调/查单、本地支付状态、钱包入账和到账通知;该人工联调不阻塞本地实现完成门禁,但属于上线前验收。 - 完成门禁至少包括目标单元/集成测试、相关包测试、全量 Go 测试、并发/竞态专项、静态检查、迁移演练、OpenAPI 生成校验、真实 S3和真实企微线下充值。新增 Handler 时同步两个接口文档生成入口。 ## Out of Scope @@ -284,8 +284,8 @@ Status: ready-for-agent ## Further Notes -- 当前代码已经有代理充值表、主钱包和流水、统一 `tb_payment`、微信回调基础、支付宝 WAP 支付基础及支付配置,但代理充值创建只支持 `wechat/offline`,未创建支付单或返回付款内容;支付宝 PreCreate、微信 Native 后台充值、支付查单补偿和两阶段入账需要补齐。 +- 当前代码已经有代理充值表、主钱包和流水、统一 `tb_payment`、微信 H5/MWEB/回调基础、支付宝 WAP 支付基础及支付配置,但代理充值创建只支持 `wechat/offline`,未创建支付单或返回付款内容;代理端复用现有网页支付 URL、支付查单补偿和两阶段入账需要补齐。 - 当前支付配置模型把微信提供方和支付宝参数放在同一条全局生效配置中。UR#34 按现状复用,不把它扩成多通道路由系统。 -- 当前微信 v3 已有查单/关单能力,微信 v2适配器缺少查单;支付宝 SDK具备 PreCreate、TradeQuery和TradeClose。支付方式可用性必须以本需求实际需要的 Adapter能力为准,不能只判断某个字段非空。 +- 当前微信 v3 已有查单/关单能力,微信 v2 已补 MWEB 下单与查单;支付宝 SDK 具备 WAP、TradeQuery 和 TradeClose。支付方式可用性必须以本需求实际需要的 Adapter 能力为准,不能只判断某个字段非空。 - 当前 `tb_payment` 迁移写的是 `1~4`,运行代码和 DTO使用 `0~3`;这是实施前必须通过存量核查解决的历史一致性问题,不是新 Agent可以忽略的文档差异。 - 当前旧线下充值使用字符串 Key列表、操作密码和本地 `offline-pay/reject`。新实现以本 PRD 和 UR#37公共企微契约为准,旧实现仅用于迁移事实核对,不代表目标行为。 diff --git a/README.md b/README.md index 7c5d4f8..74b61aa 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ default: - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md) - **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射,可按已知模板 ID 实时读取控件,并按业务类型查询系统可映射字段;通讯录按应用可见部门同步,不依赖根部门权限。管理接口和数据库均使用明文连接凭据。Adapter 支持成员显式绑定、异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) -- **代理钱包桌面扫码充值**:代理可使用微信 Native 或支付宝当面付为当前店铺主钱包在线自充;充值接口通过 `recharge_source` 区分平台线下代充与代理在线自充,支付确认后由 Outbox/Worker 幂等入账,并保留创建支付商户身份供未来对账。详见 [功能总结](docs/feature-034-agent-wallet-qr-recharge/功能总结.md) +- **代理钱包桌面扫码充值**:代理可按当前配置使用微信 v3 H5、v2 MWEB 或支付宝 WAP 支付 URL 为当前店铺主钱包在线自充,前端自行渲染二维码且支付宝无需开通当面付;充值接口通过 `recharge_source` 区分平台线下代充与代理在线自充,支付确认后由 Outbox/Worker 幂等入账,并保留创建支付商户身份供未来对账。详见 [功能总结](docs/feature-034-agent-wallet-qr-recharge/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 5788665..255ab01 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -417,8 +417,8 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti ) recovery := agentrechargeApp.NewRecoverOnlinePaymentService( runtime.db, - paymentInfra.NewWechatNativeAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger), - paymentInfra.NewAlipayPreCreateAdapter(integration), + paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger), + paymentInfra.NewAlipayWapAdapter(integration, appLogger), confirm, ) handler := paymentInfra.NewAgentRechargeRecoveryTaskHandler(recovery) diff --git a/docs/7月迭代/7月迭代前后端任务工时表.md b/docs/7月迭代/7月迭代前后端任务工时表.md index 546437a..b4217f0 100644 --- a/docs/7月迭代/7月迭代前后端任务工时表.md +++ b/docs/7月迭代/7月迭代前后端任务工时表.md @@ -40,7 +40,7 @@ |新增02:企业微信审批接入|企微配置、模板映射、扫码绑定、审批运行和业务详情|Token、模板版本、上传提交、回调解密、轮询补偿和异常恢复|5~7小时|8~10小时|企微可信域名、模板 ID 变化、回调网络和真实账号权限会增加 4~8 小时| |新增03:站内通知|顶部铃铛、通知抽屉、通知中心和受控跳转|通知表、模板注册、接收人解析、未读/已读 API|3~4小时|3~4小时|前端多端布局差异、接收人关系不完整会增加 2~3 小时| |新增04:全局多视角审计|审计中心七个视角、详情抽屉和敏感字段展示|Audit Event、Integration Log、旧写入口切换、历史投影和脱敏|6~8小时|8~11小时|旧审计调用点遗漏、查询性能和历史字段差异会增加 5~10 小时| -|新增05:代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 Native、支付宝 PreCreate、支付单分发和钱包入账恢复|3~4小时|5~7小时|支付渠道配置、真实回调、微信 v2/富友差异会增加 3~6 小时| +|新增05:代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 v3 H5/v2 MWEB、支付宝 WAP 支付 URL、支付单分发和钱包入账恢复|3~4小时|5~7小时|支付渠道配置、真实回调、富友差异会增加 3~6 小时| |全链路联调与发布|联调全部页面状态、修复交互、准备发布版本|数据核对、存量回填、旧入口清理、停机发布和恢复检查|3~4小时|4~5小时|生产数据与预期差异、外部回调不可达会增加 4~8 小时| |**合计**|**前端约 59~89 小时**|**后端约 93~128 小时**|**约 8~12 人日**|**约 12~16 人日**|**1 后端 + 1 前端并行时,正常目标 12~15 个工作日;历史数据或换货迁移超预期时可能到 16 个工作日**| diff --git a/docs/7月迭代/7月迭代技术方案-标准评审稿.md b/docs/7月迭代/7月迭代技术方案-标准评审稿.md index 2cba88e..262a62b 100644 --- a/docs/7月迭代/7月迭代技术方案-标准评审稿.md +++ b/docs/7月迭代/7月迭代技术方案-标准评审稿.md @@ -65,7 +65,7 @@ | D-16 | 数据同步保留轮询兜底,关键业务事件按立即、3 分钟、5 分钟触发;超频不建立退避状态 | 数据同步 | | D-17 | 企微发起身份按账号类型分流:平台/超级管理员必须扫码绑定并使用本人 `userid`,代理使用部署配置中的固定企微账号代提交;真实业务提交人始终独立进入审批表单、通知和审计 | 企微审批 | | D-18 | 全系统审计本次一次性切换到 Audit Event + Integration Log,多视角 API 和前端同时发布 | 全局 | -| D-19 | 代理在线充值最低 100 元,支持微信 Native 和支付宝 PreCreate,支付成功直接入主钱包且不审批 | 21、新增充值 | +| D-19 | 代理在线充值最低 100 元,按配置使用微信 v3 H5/v2 MWEB 和支付宝 WAP 支付 URL,支付成功直接入主钱包且不审批 | 21、新增充值 | | D-20 | 资产层只展示一个预计最终到期时间;当前套餐和全部排队主套餐共同参与推算,临期也使用同一结果 | 06、11、22 | | D-21 | 换货完成时新资产自动继承旧资产店铺;旧资产保留原店铺用于历史查询和权限追踪 | 禅道 #98 | | D-22 | 店铺可选绑定一个平台业务员;该字段只表达业务归属,不恢复需求16的分销和佣金关系 | 禅道 #96 | @@ -1179,7 +1179,7 @@ CHECK ( |------|------| | 修改角色默认额度 | 只更新角色模板和审计,不扫描、不修改任何已有店铺钱包 | | 创建店铺 | 在创建事务中读取默认角色模板并初始化代理主钱包实际额度 | -| 修改额度 | 拒绝代理账号;校验平台独立权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 | +| 修改额度 | 拒绝代理账号;校验平台独立权限、加载主钱包、校验新可用金额、按服务端读取的 `version` 条件更新、写信用变更审计 | | 钱包扣款 | 使用有效额度计算可用金额,同事务更新余额、版本和资金流水 | | 查询/导出 | Query 返回 `credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` | @@ -1190,7 +1190,7 @@ PUT /api/admin/shops/{id}/credit-limit 独立调整信用额度 GET /api/admin/shops/fund-summary 返回信用和可用金额 ``` -角色页面显示“新建代理默认信用额度”,并明确提示“修改后不会影响已有店铺”。店铺资金页面独立显示和修改实际信用额度。前端只展示接口返回的可用金额,不自行重新计算;额度调整弹框显示修改前后金额预览,并发冲突时刷新最新钱包版本。 +角色页面显示“新建代理默认信用额度”,并明确提示“修改后不会影响已有店铺”。店铺资金页面独立显示和修改实际信用额度。前端只展示接口返回的可用金额,不自行重新计算;额度调整弹框显示修改前后金额预览,乐观锁版本由后端读取和校验。 资金概况中的 `is_in_debt` 表示 `balance < 0`,`debt_amount = max(-balance, 0)`;冻结金额只影响现金可用金额和总可用金额,不直接记为欠款。额度调整只改变资金边界,不伪造一条金额为零的钱包交易流水;变更前后值进入全局 Audit Event。 @@ -1344,7 +1344,7 @@ sequenceDiagram #### 代理在线扫码充值 - 代理只能为当前店铺主钱包充值,最低 `10000` 分(100 元)。 -- 支持微信 Native 和支付宝 `alipay.trade.precreate`,创建本地充值单和 `tb_payment(order_type=agent_recharge)` 后再预下单。 +- 按当前配置使用微信 v3 H5 或 v2 MWEB,并复用支付宝 `alipay.trade.wap.pay` 支付 URL,创建本地充值单和 `tb_payment(order_type=agent_recharge)` 后生成链接。 - 每次主动创建都生成新的充值单和支付单;`request_id` 只防同一次 HTTP 提交重试,不按金额或已有待支付单复用。后端原样返回第三方 `qr_content`,前端使用二维码组件渲染,不生成后端图片文件。 - 不返回 `expires_at`,也不展示本地推算的精确倒计时;支付是否成功、关闭或失效以第三方回调和受控查单结果为准。前端每 3 秒轮询的轻量接口只读取本地状态,不直接触发第三方查单。 - 微信/支付宝回调按支付单类型分发,校验渠道、支付配置、金额、第三方交易号和业务单关联。 @@ -1426,7 +1426,7 @@ POST /api/admin/agent-recharges/{id}/reject - 统计待审批退款、平台员工线下充值和历史终态记录数量。 - 轮换用户 demo 中泄露的企微 Secret、Token 和 EncodingAESKey,配置可信域名、应用可见范围和回调地址。 - 发布并验证退款、线下充值企微模板控件映射,验证代理固定代提交成员可用,并要求会发起审批的平台/超级管理员完成扫码绑定。 -- 验证微信 Native、支付宝 PreCreate 配置和回调地址,确认代理充值支付渠道可用。 +- 验证微信 v3 H5/v2 MWEB、支付宝 WAP 配置和回调地址,确认代理充值支付渠道可用且支付宝无需开通当面付。 - 盘点旧账号、资产、轮询日志的写入口和查询入口,确认统一审计切换清单。 - 停机窗口内由业务使用超级管理员配置普通角色导出字段并抽样验证;不运行默认授权迁移,永久禁止导出的字段不进入代码目录。 diff --git a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md index ff00382..7ba06b2 100644 --- a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md +++ b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md @@ -1514,7 +1514,7 @@ 接口:GET /api/admin/agent-recharges/payment-methods;POST /api/admin/agent-recharges;GET /api/admin/agent-recharges/{id}/payment-status;GET /api/admin/agent-recharges/{id};下线offline-pay和reject旧接口。 -在线规则:代理只充当前店铺且不提交shop_id,最低10000分;支持微信Native和支付宝PreCreate;每次主动创建都是全新充值单和支付单;统一返回qr_content且不返回expires_at。支付回调和受控查单进入同一幂等确认用例并校验金额、配置、交易号和业务单;先固化支付成功与入账Outbox,再由可靠Worker独立事务更新主钱包、版本、唯一流水、充值完成状态和审计。重复回调或任务不重复入账,迟到成功不能吞掉已付资金。 +在线规则:代理只充当前店铺且不提交shop_id,最低10000分;按当前配置使用微信v3 H5或v2 MWEB,并复用支付宝WAP支付URL,前端自行渲染二维码,支付宝无需开通当面付;每次主动创建都是全新充值单和支付单;统一返回qr_content且不返回expires_at。支付回调和受控查单进入同一幂等确认用例并校验金额、配置、交易号和业务单;先固化支付成功与入账Outbox,再由可靠Worker独立事务更新主钱包、版本、唯一流水、充值完成状态和审计。重复回调或任务不重复入账,迟到成功不能吞掉已付资金。 线下规则:仅平台/超管创建,金额大于0,目标店铺和1~5个结构化付款凭证必填,金额提交后固定;创建后提交企微,审批只能同意或拒绝。通过后自动增加代理主钱包并写流水,无操作密码;recharge:{recharge_no}防重;驳回终结原单且不支持退回/重提,撤销/删除为已关闭;通过后撤销且已入账不自动扣回。 diff --git a/docs/7月迭代/七月迭代实现与接口对接说明.md b/docs/7月迭代/七月迭代实现与接口对接说明.md index aefe730..1f47274 100644 --- a/docs/7月迭代/七月迭代实现与接口对接说明.md +++ b/docs/7月迭代/七月迭代实现与接口对接说明.md @@ -50,7 +50,7 @@ | #55 套餐分配生效条件 | 套餐和分配接口已支持默认值、覆盖值和最终生效值 | 创建/编辑套餐传 `expiry_base`;分配时传 `expiry_base_override`,展示 `effective_expiry_base` | | #60 店铺联系电话搜索 | 店铺列表已支持 11 位联系电话精确查询 | 将输入值作为 `contact_phone` 查询参数传给店铺列表接口 | | #86 资产换货标识和跳转 | 资产解析接口已返回 `exchange_trace.previous_asset/next_asset` 和 `can_view` | 仅 `can_view=true` 且存在资产 ID 时允许跳转;该需求前端已对接可保持现状 | -| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;更新时携带钱包 `version`;余额为负数时正常展示 | +| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;调额不传钱包 `version`,并发控制由后端负责;余额为负数时正常展示 | | #94 状态同步和运营商回调 | 后端回调、定时触发和原轮询链路已装配 | 通常无前端新调用;状态页面继续读取现有资产状态字段 | | #96 店铺业务员 | 店铺创建/更新、候选人、列表/详情和筛选都已支持业务员 | 创建/编辑店铺选择 `business_owner_account_id`;列表可按该 ID 筛选并展示名称 | | #98 换货新资产继承旧店铺 | 换货完成时后端自动继承旧资产店铺归属 | 前端继续调用原换货完成接口,不新增分配步骤 | @@ -89,7 +89,7 @@ | --- | --- | --- | | #60 联系电话搜索 | `GET /api/admin/shops?contact_phone=11位号码` | 精确查询;可与店铺名称、编号等条件组合 | | #96 店铺业务员 | `GET /api/admin/shops/business-owner-candidates`;`POST /api/admin/shops`;`PUT /api/admin/shops/:id`;`GET /api/admin/shops` | 创建/更新传 `business_owner_account_id`;列表可用同名参数筛选,响应展示账号 ID、名称和可用状态 | -| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit`;`PUT /api/admin/shops/:id/credit-limit`;`GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口传 `credit_enabled + credit_limit + version`;金额单位均为分 | +| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit`;`PUT /api/admin/shops/:id/credit-limit`;`GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口只传 `credit_enabled + credit_limit`,乐观锁由后端管理;金额单位均为分 | | #55 套餐默认生效条件 | `POST /api/admin/packages`;`PUT /api/admin/packages/:id`;`GET /api/admin/packages/:id` | 请求使用 `expiry_base=from_activation|from_purchase`;响应展示默认生效条件名称 | | #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations`;`PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override`;`null` 表示跟随套餐默认值 | | #43 系列套餐多选 | `POST /api/admin/shop-series-grants`;`PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1~100 项数组,每项包含 `package_id`、`cost_price`,删除时传 `remove=true` | diff --git a/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md b/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md index 3e919dc..64c1956 100644 --- a/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md +++ b/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md @@ -426,7 +426,7 @@ GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50 ### 在线支付与入账 - 继续使用现有支付配置,只按 `wechat` 或 `alipay` 选择当前可用配置,不建设多通道自动路由、优先级或故障转移。 -- 微信使用 Native,支付宝使用 `alipay.trade.precreate`。后端把第三方返回的字符串或 HTTPS URL 原样映射为 `qr_content`,前端渲染二维码;后端不生成二维码图片或新增二维码生成接口。 +- 微信复用 C 端 H5,支付宝复用 `alipay.trade.wap.pay`。后端把支付 HTTPS URL 原样映射为 `qr_content`,前端渲染二维码;后端不生成二维码图片或新增二维码生成接口,支付宝无需开通当面付。 - 创建接口不返回 `expires_at`,前端不展示本地推算的精确倒计时。本地时间不能判定第三方支付单是否失效,支付成功或关闭以回调和后端受控查单为准。 - `request_id` 只防止同一次提交重试。代理每次主动创建或再次拉起支付都使用新 `request_id` 并产生新的充值单和支付单,旧单等待第三方自然收敛,不复用、不主动取消。 - 支付回调或查单先在事务中固化真实收款事实:支付单已支付、充值单 `2=已支付`、`processing_status=1`并可靠写入钱包入账 Outbox;随后 Worker 在独立事务中更新钱包和版本、创建唯一流水、将充值单改为 `3=已完成`和 `processing_status=2`并写资金审计。入账失败使用 `processing_status=3`可靠重试,不回滚支付事实。 diff --git a/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md b/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md index fb44d1b..6cec754 100644 --- a/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md +++ b/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md @@ -1,6 +1,6 @@ # 新增需求 05:代理钱包扫码充值 -> 状态:已冻结,本文保留为实施明细;如有冲突,以标准评审稿和 UR#34 PRD 为准。 +> 状态:2026-07-30 修正支付产品选择,本文保留为实施明细;如有冲突,以标准评审稿和 UR#34 PRD 为准。 > 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 实施 PRD:`../../../../.scratch/ur34-agent-recharge/PRD.md` > 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。 @@ -12,8 +12,8 @@ 3. 单笔最低充值金额为 100 元,即 `10000` 分。 4. 代理在线充值不进入企业微信审批;支付成功事实先落库,再由可靠 Worker 幂等增加代理主钱包余额。 5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。 -6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。 -7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。 +6. 后端返回支付 URL,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。 +7. 微信按当前配置使用 v3 H5 或 v2 MWEB,支付宝复用 C 端 `alipay.trade.wap.pay` 手机网站支付;不要求开通 Native 或当面付产品。 8. 支付回调、钱包入账、钱包流水和审计必须幂等,重复回调不能重复加钱。 9. 当前代理充值复杂写逻辑迁移到 Application/Domain,旧 Service 不再保留另一套在线入账逻辑。 @@ -46,9 +46,9 @@ sequenceDiagram Agent->>Web: 输入金额并选择支付方式 Web->>API: 创建扫码充值单 API->>DB: 创建充值单和支付单 - API->>Pay: Native/PreCreate 预下单 - Pay-->>API: 二维码内容 - API-->>Web: 原样返回 qr_content + API->>Pay: 生成 H5/WAP 支付链接 + Pay-->>API: HTTPS URL + API-->>Web: URL 写入 qr_content 返回 Web-->>Agent: 展示二维码并轮询支付状态 Agent->>Pay: 扫码完成支付 Pay->>Callback: 异步支付通知 @@ -87,9 +87,9 @@ internal/ │ ├── post_wallet.go 可靠任务执行钱包入账 │ ├── sync_pending.go 受控查询待支付第三方订单 │ └── get_payment_status.go 轻量支付状态查询 -├── infrastructure/adapter/payment/ -│ ├── wechat_native.go 微信 Native 预下单 -│ └── alipay_precreate.go 支付宝当面付预创建 +├── infrastructure/payment/ +│ ├── wechat_web.go 微信 H5/MWEB 支付链接与查单 +│ └── alipay_wap.go 支付宝 WAP 支付链接与查单 ├── infrastructure/persistence/ │ └── agent_recharge_repository.go └── query/agentrecharge/ @@ -123,7 +123,7 @@ internal/ - 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。 - 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。 -## 五、支付预下单 +## 五、支付链接 ### 5.1 统一支付单 @@ -147,35 +147,35 @@ tb_payment.amount = recharge_amount tb_payment.payment_config_id = 创建时使用的配置ID ``` -先完成本地事务,再调用第三方预下单。预下单失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧二维码。 +先完成本地事务,再生成支付链接。链接生成失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧链接。 ### 5.2 微信扫码 -微信使用 Native 下单: +微信按当前配置选择 H5/MWEB 下单: ```text -微信支付 v3 TransactionNative - -> 返回 code_url +provider_type=wechat -> 微信 v3 TransactionH5 -> h5_url +provider_type=wechat_v2 -> 微信 v2 MWEB -> mweb_url ``` -现有微信 SDK 已包含 `TransactionNative`,需要在项目支付 Adapter 中封装,不在 Handler 直接调用 SDK。 +代理充值 Adapter 复用统一 `CreateH5Order` 入口,不在 Handler 判断协议或重复调用 SDK。 若当前生效支付配置为: -- `wechat`:使用微信 v3 Native。 -- `wechat_v2`:补充 v2 Native 统一下单实现。 -- `fuiou`:只有现有富友配置明确支持后台扫码产品时才返回微信可用;不支持时前端隐藏微信扫码入口,不擅自用 JSAPI 代替。 +- `wechat`:使用微信 v3 H5。 +- `wechat_v2`:使用微信 v2 MWEB,并复用 v2 查单与回调验签。 +- `fuiou`:不在本需求扩展富友后台扫码产品,不返回微信可用。 ### 5.3 支付宝扫码 -支付宝使用当前 SDK 已提供的: +支付宝复用 C 端现有手机网站支付链接: ```text -alipay.trade.precreate - -> 返回 qr_code +alipay.trade.wap.pay + -> 返回签名 HTTPS URL ``` -不复用现有 WAP 支付 URL。创建时校验当前支付配置中的: +该方式不依赖支付宝当面付。创建时校验当前支付配置中的: ```text ali_app_id @@ -184,11 +184,11 @@ ali_public_key ali_notify_url ``` -配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效二维码的充值单。 +配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效支付链接的充值单。 ### 5.4 二维码响应 -后端统一返回二维码内容,不返回二维码图片: +后端统一返回支付 URL,不返回二维码图片: ```json { @@ -197,7 +197,7 @@ ali_notify_url "payment_no": "ARCH20260715143000000001", "payment_method": "wechat", "amount": 10000, - "qr_content": "weixin://wxpay/bizpayurl?...", + "qr_content": "https://pay.example.com/...", "status": 1, "status_name": "待支付", "payment_status": 0, @@ -207,7 +207,7 @@ ali_notify_url } ``` -微信返回 `code_url`、支付宝返回 `qr_code`,Application 统一映射为 `qr_content`。 +微信返回 `h5_url`、支付宝返回签名 WAP URL,Application 统一映射为 `qr_content`,前端自行渲染二维码。 本地无法准确知道第三方订单的真实失效时间,因此接口不返回 `expires_at`,前端不展示本地推算的精确倒计时。第三方支付成功或关闭以回调和后端受控查单为准。 @@ -404,14 +404,14 @@ GET /api/admin/agent-recharges/{id}/payment-status ```text 代理创建充值单 -支付预下单成功/失败 +支付链接生成成功/失败 微信/支付宝支付回调成功/失败 钱包入账成功/失败 重复回调被幂等忽略 第三方查单确认支付关闭或失效 ``` -支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。 +微信 H5/MWEB 下单与支付渠道查单写 `tb_integration_log`;支付宝 WAP URL 本地签名不伪造外部调用日志。钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。 在线充值不产生审批通知。目标代理主钱包实际入账后必须生成“充值到账”站内通知;在线实际提交账号与目标代理主账号不同时,两者分别通知并按充值单与接收人防重。平台线下代充值的真实提交人只接收 UR#37 的审批结果通知,除非其本身也是到账通知接收人。 @@ -423,8 +423,8 @@ GET /api/admin/agent-recharges/{id}/payment-status - `CreateAgentRechargeRequest.payment_method` 增加 `alipay`,金额校验改为 `min=10000`。 - 创建代理在线充值时同时创建 `tb_payment` 记录。 - 新增 `PaymentOrderTypeAgentRecharge`。 -- 微信支付 Adapter 增加 Native 预下单。 -- 支付宝 Adapter 增加 `TradePreCreate`。 +- 微信支付 Adapter 复用 C 端 H5 下单并返回 `h5_url`。 +- 支付宝 Adapter 复用 C 端 `BuildWapPayURL`。 - 支付宝回调增加代理充值分发。 - 微信/富友代理充值回调统一改为按支付单分发,不只依赖 `ARCH` 前缀。 - 原 `agent_recharge.Service.HandlePaymentCallback` 迁入 `ConfirmAgentRechargePayment` 用例。 @@ -444,8 +444,8 @@ GET /api/admin/agent-recharges/{id}/payment-status 1. 验证 99.99 元被后端拒绝,100 元可以创建充值单。 2. 验证代理只能为自己的店铺创建微信或支付宝充值。 -3. 验证微信 Native 返回有效 `code_url`,前端能够扫码支付。 -4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。 +3. 分别验证微信 v3 H5 与 v2 MWEB 返回有效 HTTPS URL,前端能够渲染二维码并通过外部浏览器扫码支付。 +4. 验证支付宝 WAP 返回有效签名 HTTPS URL,前端能够渲染二维码并扫码支付,且商户无需开通当面付。 5. 验证支付回调通过支付单类型分发到代理充值用例。 6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。 7. 验证支付成功后不创建企微审批实例,先固化支付事实,再由可靠 Worker 完成钱包入账。 diff --git a/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md b/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md index d9cac24..0efaecb 100644 --- a/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md +++ b/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md @@ -9,7 +9,7 @@ 1. **同一个创建接口有两条业务路径**:代理账号使用 `wechat|alipay` 在线自充;平台或超级管理员使用 `offline` 线下代充。 2. **前端必须使用 `recharge_source` 区分来源**:`platform_offline` 是平台线下代充,`agent_online` 是代理在线自充;不要根据账号名称、备注或审批字段猜测。 3. **代理不能选择充值店铺**:在线充值的店铺和主钱包由登录上下文确定,请求不得发送 `shop_id`、支付凭证或备注。 -4. **二维码由前端渲染**:后端返回支付渠道原始 `qr_content`,不返回二维码图片。 +4. **二维码由前端渲染**:后端在 `qr_content` 返回支付 HTTPS URL,不返回二维码图片。 5. **网络重试不能创建新请求 ID**:同一次提交重试复用原 `request_id`;用户主动发起下一笔充值时生成新的 `request_id`。 6. **支付和钱包到账是两个阶段**:`status=2` 表示第三方已收款、钱包入账处理中;只有 `status=3` 才表示钱包到账完成。 7. **页面轮询只调用本地状态接口**:前端不得直接调用微信、支付宝查单,也不要反复调用创建接口查询状态。 @@ -20,7 +20,7 @@ | 能力 | 后端实现 | 前端要做什么 | | --- | --- | --- | | 可用支付方式 | 根据当前有效支付配置返回真正可用的 `wechat`、`alipay` | 打开充值弹窗时先查询;只展示返回数组中的方式 | -| 代理在线自充 | 从登录账号取得当前店铺和主钱包,创建充值单、支付单并向第三方预下单 | 只提交金额、支付方式和请求 ID;用 `qr_content` 渲染二维码 | +| 代理在线自充 | 从登录账号取得当前店铺和主钱包,创建充值单、支付单并按配置生成微信 v3 H5/v2 MWEB 或支付宝 WAP 支付 URL | 只提交金额、支付方式和请求 ID;用 `qr_content` 渲染二维码 | | 平台线下代充 | 保留既有目标店铺、凭证和企业微信审批流程 | 平台页面继续提交 `offline` 请求并只读展示审批状态 | | 充值来源 | 根据受控创建方式返回稳定来源枚举 | 列表、详情和支付状态统一展示 `recharge_source_name` | | 支付确认 | 微信/支付宝回调确认第三方收款事实 | 前端无需调用回调接口 | @@ -124,7 +124,7 @@ Content-Type: application/json "recharge_source": "agent_online", "recharge_source_name": "代理在线自充", "amount": 10000, - "qr_content": "weixin://wxpay/bizpayurl?pr=...", + "qr_content": "https://pay.example.com/...", "status": 1, "status_name": "待支付" }, @@ -320,7 +320,7 @@ GET /api/admin/agent-recharges/{id} ## 十、联调和验收边界 - OpenAPI 已包含 `/payment-methods`、在线创建、列表来源筛选、详情来源字段和 `/payment-status`。 -- 后端已完成微信 Native、支付宝 PreCreate、支付确认、异步钱包入账和回调丢失恢复的代码装配。 +- 后端已完成微信 v3 H5/v2 MWEB、支付宝 WAP 支付 URL、支付确认、异步钱包入账和回调丢失恢复的代码装配。 - 当前交付已通过 `go build ./...`、`go vet ./...` 和 OpenSpec 严格校验。 - 按需求方要求,本次未启动 API/Worker,未连接 PostgreSQL/Redis,未调用真实支付渠道,未发送真实回调,也未运行自动化测试。 - 前端联调环境需具备完整支付配置;至少确认微信和支付宝创建响应分别返回非空 `qr_content`。 diff --git a/docs/feature-034-agent-wallet-qr-recharge/功能总结.md b/docs/feature-034-agent-wallet-qr-recharge/功能总结.md index 6d7fba4..3d5800e 100644 --- a/docs/feature-034-agent-wallet-qr-recharge/功能总结.md +++ b/docs/feature-034-agent-wallet-qr-recharge/功能总结.md @@ -4,7 +4,7 @@ ## 功能范围 -代理账号可在后台为当前所属店铺主钱包创建微信 Native 或支付宝当面付扫码充值。平台与超级管理员继续使用既有线下代充和企业微信审批,两个创建路径不共用权限。 +代理账号可在后台为当前所属店铺主钱包创建微信 H5/MWEB 或支付宝 WAP 支付链接扫码充值。微信按当前配置选择 v3 H5 或 v2 MWEB,前端自行渲染二维码,支付宝无需开通当面付。平台与超级管理员继续使用既有线下代充和企业微信审批,两个创建路径不共用权限。 充值订单相关响应统一返回: @@ -35,7 +35,7 @@ } ``` -响应包含 `recharge_id`、`payment_no`、`qr_content`、`recharge_source` 和充值状态。前端直接使用 `qr_content` 渲染二维码,不展示或记录支付配置、商户身份和密钥。 +响应包含 `recharge_id`、`payment_no`、`qr_content`、`recharge_source` 和充值状态。`qr_content` 是支付 HTTPS URL,前端直接渲染二维码,不展示或记录支付配置、商户身份和密钥。 平台线下代充使用 `payment_method=offline`,并按既有契约提交目标 `shop_id`、支付凭证和备注;响应来源为 `platform_offline`。 diff --git a/docs/feature-034-agent-wallet-qr-recharge/微信扫码支付产品边界核对.md b/docs/feature-034-agent-wallet-qr-recharge/微信扫码支付产品边界核对.md new file mode 100644 index 0000000..922bbb2 --- /dev/null +++ b/docs/feature-034-agent-wallet-qr-recharge/微信扫码支付产品边界核对.md @@ -0,0 +1,103 @@ +# 微信扫码支付产品边界核对 + +## 结论 + +1. 微信支付产品和接口协议版本是两个不同维度:`JSAPI`、`Native`、`H5` 是支付产品/用户场景;API v2、API v3 是商户后台调用微信支付的协议版本。 +2. 桌面代理后台展示二维码、用户拿手机微信扫码支付,应使用 **Native 支付**。Native 下单返回 `code_url`,前端把它生成二维码。这正是微信官方定义的扫码支付场景。 +3. JSAPI 确实可以正常支付,但它要求支付页面运行在微信内置浏览器中,并取得当前用户 `openid`,然后由页面调用微信支付控件;它不是“PC 页面展示二维码供另一台手机扫码”的产品。 +4. H5 支付用于手机浏览器(非微信客户端内)跳转拉起微信,v2 返回 `mweb_url`,v3 返回 `h5_url`。这两个 URL 在技术上当然都能编码成二维码;但能否支付取决于扫码后打开它的浏览器环境。微信“扫一扫”会在微信内置浏览器打开,而官方 H5 支付明确面向微信客户端外的浏览器,因此不能把这条路径当作可用的微信扫码支付流程;系统相机或其他扫码工具若把链接交给手机外部浏览器,则可能按 H5 流程拉起微信,但仍须满足支付域名、`Referer` 等官方校验。 +5. 微信支付的能力并非天然“只接受 v3”。官方 API v2 的统一下单用 `trade_type=JSAPI/NATIVE/MWEB`,API v3 则分别提供 JSAPI、Native、H5 下单接口;两代协议都覆盖这三类产品。实际能否调用还取决于商户是否开通对应产品、AppID 与商户号绑定等平台条件。 +6. 项目已按最终产品决定支持两种当前配置:`provider_type=wechat` 调用 v3 H5,`provider_type=wechat_v2` 调用 v2 MWEB;两者分别返回 `h5_url`、`mweb_url`,统一映射为 `qr_content`。 + +## 产品边界 + +| 产品 | 官方适用场景 | 下单主要返回物 | 客户端完成支付方式 | 是否适合本需求 | +| --- | --- | --- | --- | --- | +| JSAPI | 用户已经在微信客户端内打开商户网页 | `prepay_id`;商户再生成前端调起支付所需参数 | 页面调用微信支付控件,且需传用户 `openid` | 否。除非把代理充值页改成微信内页面并增加 OAuth/OpenID 链路 | +| Native | PC 网站、实体物料等展示二维码,用户使用微信“扫一扫” | `code_url` | 商户把 `code_url` 生成二维码,用户扫码 | **是,和当前需求完全匹配** | +| H5(v2 名称 MWEB) | 用户在手机系统浏览器等微信客户端外的移动网页发起支付 | `h5_url`(v2 返回字段为 `mweb_url`) | 当前手机浏览器跳转该 URL 拉起微信 | 有条件可用:系统相机/外部扫码工具进入外部浏览器时可能完成;微信“扫一扫”进入微信内置浏览器时不符合官方 H5 场景 | + +因此,“JSAPI 能正常支付”和“它适合桌面扫码”并不矛盾:前者描述支付能力,后者描述用户入口。选型依据应是入口场景,而不是某一种产品是否能够完成扣款。 + +## `mweb_url` / `h5_url` 生成二维码后的准确边界 + +先区分两件事:前端二维码组件可以渲染任意 URL,这只证明二维码可以被识别;是否能完成支付由微信支付产品规则和扫码后的浏览器环境决定。API v2 的 `mweb_url` 与 API v3 的 `h5_url` 在这一点上没有产品语义差异,都是 H5 支付跳转地址。 + +| 扫码入口 | 实际打开环境 | 官方产品边界 | 结论 | +| --- | --- | --- | --- | +| 微信“扫一扫” | 微信内置浏览器 | 微信官方将 H5 支付定义为在微信客户端外的移动浏览器中调起微信支付 | 二维码能识别、URL 也能打开,但不能据此认定 H5 支付可完成;这不是官方支持的 H5 入口 | +| 系统相机、系统扫码器或其他把链接交给浏览器的工具 | Safari、Chrome 等微信外部移动浏览器 | 符合 H5 支付的浏览器场景;浏览器跳转 `mweb_url`/`h5_url` 后拉起微信 | 可以作为 H5 跳转方式,但必须满足商户已开通 H5、支付域名配置、请求来源等校验 | + +还有一个容易遗漏的限制:官方开发指引要求 H5 调起链路携带符合配置的 `Referer`,微信支付中间页会进行 H5 权限和安全校验。因此,将下单返回的裸 `mweb_url`/`h5_url` 直接编码进二维码,会让最终行为依赖扫码工具如何打开链接、是否保留合法来源;它不像 Native 的 `code_url` 那样是官方专门定义的“生成二维码后由微信扫码”凭据。更稳妥的 H5 二维码做法是二维码指向商户自己的已配置 H5 页面,再由该页面在外部浏览器中跳转微信返回的 H5 地址。 + +所以用户提出的说法应修正为:**`mweb_url`/`h5_url` 可以由前端渲染成二维码,系统相机扫码后进入外部浏览器时有条件可支付;但微信“扫一扫”打开的是微信内置浏览器,不满足官方 H5 支付场景。若产品要求用户明确使用微信扫一扫,仍应使用 Native `code_url`。** + +## API v2 与 API v3 + +### 官方能力 + +- API v2 使用统一下单接口,通过 `trade_type` 区分产品:`JSAPI`、`NATIVE`、`MWEB`。对应返回分别围绕 `prepay_id`、`code_url`、`mweb_url`。 +- API v3 将三类下单拆成独立接口: + - JSAPI:`POST /v3/pay/transactions/jsapi`,返回 `prepay_id`; + - Native:`POST /v3/pay/transactions/native`,返回 `code_url`; + - H5:`POST /v3/pay/transactions/h5`,返回 `h5_url`。 + +所以正确表述是:**v2 和 v3 都可以承载 JSAPI、Native、H5/MWEB;项目是否支持,取决于对应协议分支有没有实现该产品的下单、签名、查单、关单和回调验签。** + +### 本项目当前实现 + +1. `WechatConfig.ProviderType` 明确把 `wechat` 定义为 v3、`wechat_v2` 定义为 v2;同一条配置模型同时有 `wx_api_v3_key`、证书/私钥/序列号和 `wx_api_v2_key` 字段。 +2. 通用加载器按 `provider_type` 选择实现: + - `wechat` 构建 PowerWeChat v3 服务; + - `wechat_v2` 构建本地 XML+MD5 v2 服务。 +3. v3 服务目前实现 JSAPI 和 H5;仓库曾有/SDK具备 Native 调用能力,但当前代理充值 Adapter 调的是 `CreateH5Order -> TransactionH5`,把返回的 `H5URL` 放入 `QRContent`。 +4. v2 服务保留 JSAPI,并已增加 `trade_type=MWEB` 下单、`mweb_url` 解析和 v2 查单;通用 v2 Adapter 同步开放 H5/MWEB 与查单能力。 +5. `GET /api/admin/agent-recharges/payment-methods` 读取唯一生效配置,再调用 Adapter 的 `Available`:v3 校验 v3 Key、证书和序列号,v2 校验 APIv2Key;两种配置均可返回 `wechat`。 + +因此测试环境生效配置为 `provider_type=wechat_v2` 且 APIv2Key、商户号、AppID 和回调地址完整时,接口会返回 `wechat`,创建时使用 MWEB。 + +## “基于当前支付配置”的准确含义 + +“基于当前支付配置”不等于“看到哪组密钥非空就自动尝试哪套协议”。当前代码的配置解析规则是: + +```text +唯一 is_active=true 的 WechatConfig + │ + ├─ provider_type=wechat → 选择 v3 实现 → 校验 v3 凭据 + ├─ provider_type=wechat_v2 → 选择 v2 实现 → 校验 APIv2Key + └─ provider_type=fuiou → 选择富友实现 +``` + +也就是说,字段值提供凭据,`provider_type` 决定协议和 Adapter。即使同一行同时填了 v2、v3 字段,代码也不会自动降级或跨协议尝试。支付宝字段则是同一配置行中的并存能力,当前可用性判断不通过 `provider_type=alipay` 分流。 + +## 本项目最终采用方式 + +用户已确认接受“系统相机或外部扫码工具打开手机外部浏览器”的 H5/MWEB 使用方式,项目据此采用: + +1. `provider_type=wechat`:调用 v3 H5 下单和查单,返回 `h5_url`。 +2. `provider_type=wechat_v2`:调用 v2 MWEB 下单和查单,返回 `mweb_url`。 +3. 两个分支继续使用各自协议的回调验签;支付 URL 统一保存并返回为 `qr_content`。 +4. 前端负责渲染二维码,同时明确提示使用系统相机或外部浏览器扫码;本方案不承诺微信“扫一扫”入口。 + +## 仓库证据 + +- `internal/model/wechat_config.go`:`wechat`/`wechat_v2` 的协议含义及两套密钥字段。 +- `pkg/payment/loader.go`:按 `provider_type` 构建 v3 或 v2 服务;v2 Adapter 转发 JSAPI、H5/MWEB 与查单。 +- `pkg/wechat/payment_v2.go`:v2 XML+MD5 的 JSAPI、MWEB 下单与查单实现。 +- `pkg/wechat/payment.go`:v3 JSAPI 返回 `prepay_id/pay_config`,H5 返回 `h5_url`。 +- `internal/infrastructure/payment/wechat_web.go`:代理充值按 `provider_type` 调用 v3 H5 或 v2 MWEB,并把支付 URL 写入 `QRContent`。 +- `internal/application/agentrecharge/online_creation.go`:支付方式接口读取 `is_active=true` 配置,并只返回 Adapter 判定可用的方式。 + +## 微信支付官方来源 + +- [JSAPI 支付产品/开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012791856) +- [JSAPI 下单 API v3](https://pay.weixin.qq.com/doc/v3/merchant/4012791858) +- [Native 支付产品/开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012791874) +- [Native 下单 API v3](https://pay.weixin.qq.com/doc/v3/merchant/4012791875) +- [H5 支付产品/开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012791897) +- [H5 下单 API v3](https://pay.weixin.qq.com/doc/v3/merchant/4012791902) +- [API v2 统一下单(官方旧版文档)](https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1) +- [API v2 Native 支付(官方旧版文档)](https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_1) +- [API v2 H5 支付(官方旧版文档)](https://pay.weixin.qq.com/wiki/doc/api/H5.php?chapter=15_1) + +> 核对日期:2026-07-30。上述来源均为微信支付官方域名;旧版 API v2 文档可能由官方站点重定向到新版文档中心,但其统一下单字段和产品语义也可由仓库现有 v2 实现交叉核对。 diff --git a/internal/application/agentrecharge/online_creation.go b/internal/application/agentrecharge/online_creation.go index 61571c9..bad530a 100644 --- a/internal/application/agentrecharge/online_creation.go +++ b/internal/application/agentrecharge/online_creation.go @@ -28,9 +28,10 @@ type CreateOnlineCommand struct { Amount int64 PaymentMethod string RequestID string + PayerClientIP string } -// CreateOnlineResult 返回在线充值单、支付单及原始付款内容。 +// CreateOnlineResult 返回在线充值单、支付单及支付链接。 type CreateOnlineResult struct { Recharge *model.AgentRechargeRecord Payment *model.Payment @@ -55,7 +56,7 @@ func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *On return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay} } -// Execute 以短事务建单,事务外预下单,再条件保存付款内容或关闭失败订单。 +// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。 func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) { if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil { return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置") @@ -90,14 +91,15 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin } return nil, err } - paymentResult, err := adapter.PreCreate(ctx, OnlinePaymentRequest{ - PaymentNo: result.Payment.PaymentNo, Description: "代理主钱包充值", Amount: command.Amount, - ExpireAt: *result.Payment.ExpireAt, Config: config, + paymentResult, err := adapter.CreatePaymentURL(ctx, OnlinePaymentRequest{ + PaymentID: result.Payment.ID, PaymentNo: result.Payment.PaymentNo, CorrelationID: result.Payment.PaymentNo, + Description: "代理主钱包充值", Amount: command.Amount, + ExpireAt: *result.Payment.ExpireAt, PayerClientIP: command.PayerClientIP, Config: config, }) if err != nil { if !isUnknownPaymentResult(err) { if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil { - return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付预下单失败且关闭本地订单失败") + return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付链接生成失败且关闭本地订单失败") } } return nil, err @@ -106,22 +108,22 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil { return nil, closeErr } - return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回付款内容") + return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回支付链接") } update := s.db.WithContext(ctx).Model(&model.Payment{}). Where("id = ? AND status = ? AND qr_content = ''", result.Payment.ID, model.PaymentRecordStatusPending). Update("qr_content", paymentResult.QRContent) if update.Error != nil { - return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存扫码付款内容失败") + return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存支付链接失败") } if update.RowsAffected != 1 { - return nil, apperrors.New(apperrors.CodeConflict, "在线充值付款内容已变化") + return nil, apperrors.New(apperrors.CodeConflict, "在线充值支付链接已变化") } result.Payment.QRContent = paymentResult.QRContent return result, nil } -// AvailablePaymentMethods 按固定顺序返回配置完整的扫码支付方式。 +// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。 func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) { result := AvailablePaymentMethodsResult{ Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount, @@ -275,7 +277,7 @@ func (s *OnlineCreationService) loadReplay( } if payment.QRContent == "" { if record.Status == constants.RechargeStatusClosed || payment.Status == model.PaymentRecordStatusFailed { - return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求预下单失败") + return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求支付链接生成失败") } return nil, true, apperrors.New(apperrors.CodeConflict, "在线充值请求正在处理中,请稍后重试") } @@ -283,7 +285,7 @@ func (s *OnlineCreationService) loadReplay( } func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result *CreateOnlineResult) error { - if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPreCreateFailure(result.Recharge.Status) { + if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPaymentURLFailure(result.Recharge.Status) { return nil } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/internal/application/agentrecharge/online_payment.go b/internal/application/agentrecharge/online_payment.go index 71cfa58..b643750 100644 --- a/internal/application/agentrecharge/online_payment.go +++ b/internal/application/agentrecharge/online_payment.go @@ -18,16 +18,19 @@ const ( OnlinePaymentStateUnknown = "unknown" ) -// OnlinePaymentRequest 描述扫码预下单所需的最小事实。 +// OnlinePaymentRequest 描述生成支付链接与主动查单所需的最小事实。 type OnlinePaymentRequest struct { - PaymentNo string - Description string - Amount int64 - ExpireAt time.Time - Config *model.WechatConfig + PaymentID uint + PaymentNo string + CorrelationID string + Description string + Amount int64 + ExpireAt time.Time + PayerClientIP string + Config *model.WechatConfig } -// OnlinePaymentResult 描述渠道返回的扫码付款内容。 +// OnlinePaymentResult 描述渠道返回的支付链接。 type OnlinePaymentResult struct { QRContent string } @@ -40,9 +43,9 @@ type OnlinePaymentQueryResult struct { PaidAt *time.Time } -// OnlinePaymentPort 定义代理扫码充值需要的最小渠道能力。 +// OnlinePaymentPort 定义代理在线充值需要的最小渠道能力。 type OnlinePaymentPort interface { Available(config *model.WechatConfig) bool - PreCreate(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error) - Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (OnlinePaymentQueryResult, error) + CreatePaymentURL(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error) + Query(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentQueryResult, error) } diff --git a/internal/application/agentrecharge/recover_online_payment.go b/internal/application/agentrecharge/recover_online_payment.go index e13a490..bd29c1f 100644 --- a/internal/application/agentrecharge/recover_online_payment.go +++ b/internal/application/agentrecharge/recover_online_payment.go @@ -11,7 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// RecoverOnlinePaymentService 批量收敛长期待预下单或待支付的代理在线充值。 +// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。 type RecoverOnlinePaymentService struct { db *gorm.DB wechat OnlinePaymentPort @@ -75,18 +75,23 @@ func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *m } if payment.QRContent == "" { if !adapter.Available(config) { - return errors.New(errors.CodeNoPaymentConfig, "代理充值预下单配置不可用") + return errors.New(errors.CodeNoPaymentConfig, "代理充值支付配置不可用") + } + // 支付宝 WAP 链接由本地签名生成,可以安全重建;微信 H5 下单结果未知时只允许查单。 + if payment.PaymentMethod != constants.RechargeMethodAlipay { + return s.queryPayment(ctx, adapter, payment, recharge, config) } expireAt := now.Add(30 * time.Minute) if payment.ExpireAt != nil && payment.ExpireAt.After(now) { expireAt = *payment.ExpireAt } - result, err := adapter.PreCreate(ctx, OnlinePaymentRequest{ - PaymentNo: payment.PaymentNo, Description: "代理主钱包充值", Amount: payment.Amount, + result, err := adapter.CreatePaymentURL(ctx, OnlinePaymentRequest{ + PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, + Description: "代理主钱包充值", Amount: payment.Amount, ExpireAt: expireAt, Config: config, }) if err != nil { - // 恢复阶段不能仅凭预下单错误推断未收款,保留本地状态等待下次查单。 + // 恢复阶段不能仅凭链接生成错误推断未收款,保留本地状态等待下次查单。 return nil } if result.QRContent == "" { @@ -96,11 +101,17 @@ func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *m Where("id = ? AND status = ? AND qr_content = ''", payment.ID, model.PaymentRecordStatusPending). Update("qr_content", result.QRContent) if update.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值扫码付款内容失败") + return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值支付链接失败") } return nil } - queryResult, err := adapter.Query(ctx, payment.PaymentNo, config) + return s.queryPayment(ctx, adapter, payment, recharge, config) +} + +func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter OnlinePaymentPort, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig) error { + queryResult, err := adapter.Query(ctx, OnlinePaymentRequest{ + PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Config: config, + }) if err != nil { return nil } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index eccace1..d3fdd60 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -272,8 +272,8 @@ func initServices(s *stores, deps *Dependencies) *services { paymentIntegration := integrationlog.NewRepository(deps.DB) agentRechargeOnline := agentrechargeApp.NewOnlineCreationService( deps.DB, - paymentInfra.NewWechatNativeAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger), - paymentInfra.NewAlipayPreCreateAdapter(paymentIntegration), + paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger), + paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger), ) agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService( deps.DB, diff --git a/internal/domain/agentrecharge/online.go b/internal/domain/agentrecharge/online.go index 74b1743..9d384c9 100644 --- a/internal/domain/agentrecharge/online.go +++ b/internal/domain/agentrecharge/online.go @@ -24,7 +24,7 @@ func ValidateOnlineCreation(userType int, amount int64, paymentMethod string) er } } -// CanCloseAfterPreCreateFailure 判断预下单明确失败后能否关闭充值单。 -func CanCloseAfterPreCreateFailure(status int) bool { +// CanCloseAfterPaymentURLFailure 判断支付链接生成明确失败后能否关闭充值单。 +func CanCloseAfterPaymentURLFailure(status int) bool { return status == constants.RechargeStatusPending } diff --git a/internal/handler/admin/agent_recharge.go b/internal/handler/admin/agent_recharge.go index c752648..ab9c59e 100644 --- a/internal/handler/admin/agent_recharge.go +++ b/internal/handler/admin/agent_recharge.go @@ -79,7 +79,7 @@ func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRec result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{ AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()), CurrentShopID: middleware.GetShopIDFromContext(c.UserContext()), Amount: req.Amount, - PaymentMethod: req.PaymentMethod, RequestID: req.RequestID, + PaymentMethod: req.PaymentMethod, RequestID: req.RequestID, PayerClientIP: c.IP(), }) if err != nil { return err diff --git a/internal/infrastructure/payment/alipay_precreate.go b/internal/infrastructure/payment/alipay_wap.go similarity index 51% rename from internal/infrastructure/payment/alipay_precreate.go rename to internal/infrastructure/payment/alipay_wap.go index f3daa65..a0d1c9a 100644 --- a/internal/infrastructure/payment/alipay_precreate.go +++ b/internal/infrastructure/payment/alipay_wap.go @@ -2,7 +2,7 @@ package payment import ( "context" - "strings" + "strconv" "time" sdkalipay "github.com/smartwalle/alipay/v3" @@ -13,82 +13,47 @@ import ( alipaypkg "github.com/break/junhong_cmp_fiber/pkg/alipay" "github.com/break/junhong_cmp_fiber/pkg/constants" apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "go.uber.org/zap" ) -// AlipayPreCreateAdapter 使用现有 smartwalle/alipay 实现当面付预下单与查单。 -type AlipayPreCreateAdapter struct { +// AlipayWapAdapter 使用现有 C 端支付宝能力生成 WAP 支付链接并主动查单。 +type AlipayWapAdapter struct { integration *integrationlog.Repository + logger *zap.Logger } -// NewAlipayPreCreateAdapter 创建支付宝当面付适配器。 -func NewAlipayPreCreateAdapter(integration *integrationlog.Repository) *AlipayPreCreateAdapter { - return &AlipayPreCreateAdapter{integration: integration} +// NewAlipayWapAdapter 创建支付宝 WAP 支付适配器。 +func NewAlipayWapAdapter(integration *integrationlog.Repository, logger *zap.Logger) *AlipayWapAdapter { + return &AlipayWapAdapter{integration: integration, logger: logger} } -// Available 判断配置是否完整支持支付宝预下单、验签与查单。 -func (a *AlipayPreCreateAdapter) Available(config *model.WechatConfig) bool { +// Available 判断配置是否完整支持支付宝 WAP 支付、验签与查单。 +func (a *AlipayWapAdapter) Available(config *model.WechatConfig) bool { return alipayConfigComplete(config, true) } -// PreCreate 创建支付宝当面付扫码订单。 -func (a *AlipayPreCreateAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { - client, err := a.client(request.Config) +// CreatePaymentURL 使用与 C 端相同的手机网站支付能力生成签名 URL。 +func (a *AlipayWapAdapter) CreatePaymentURL(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { + payment := &model.Payment{PaymentNo: request.PaymentNo, Amount: request.Amount, ExpireAt: &request.ExpireAt} + payURL, err := alipaypkg.BuildWapPayURL(ctx, request.Config, payment, request.Description) if err != nil { return agentrecharge.OnlinePaymentResult{}, err } - attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount) - if err != nil { - return agentrecharge.OnlinePaymentResult{}, err - } - startedAt := time.Now() - response, callErr := client.TradePreCreate(ctx, sdkalipay.TradePreCreate{Trade: sdkalipay.Trade{ - NotifyURL: request.Config.AliNotifyURL, Subject: request.Description, OutTradeNo: request.PaymentNo, - TotalAmount: alipaypkg.FenToYuan(request.Amount), ProductCode: "FACE_TO_FACE_PAYMENT", - TimeExpire: request.ExpireAt.Format("2006-01-02 15:04:05"), - }}) - if callErr != nil { - return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) - } - if response == nil || response.IsFailure() || strings.TrimSpace(response.QRCode) == "" { - providerCode, providerMessage := "empty_qr_code", "支付宝预下单未返回付款内容" - responseSummary := map[string]any{"success": false} - if response != nil && response.IsFailure() { - providerCode, providerMessage = string(response.Code), response.SubMsg - if response.SubCode != "" { - providerCode += ":" + response.SubCode - responseSummary["sub_code"] = response.SubCode - } - } - _, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ - Result: constants.IntegrationResultFailed, ProviderCode: providerCode, ProviderMessage: providerMessage, - ResponseSummary: responseSummary, DurationMS: time.Since(startedAt).Milliseconds(), - }) - if completeErr != nil { - return agentrecharge.OnlinePaymentResult{}, completeErr - } - return agentrecharge.OnlinePaymentResult{}, apperrors.New(apperrors.CodeServiceUnavailable, "支付宝预下单失败") - } - if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ - Result: constants.IntegrationResultSuccess, ProviderCode: string(response.Code), - ResponseSummary: map[string]any{"success": true}, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true, - }); err != nil { - return agentrecharge.OnlinePaymentResult{}, err - } - return agentrecharge.OnlinePaymentResult{QRContent: response.QRCode}, nil + return agentrecharge.OnlinePaymentResult{QRContent: payURL}, nil } -// Query 查询支付宝当面付订单状态。 -func (a *AlipayPreCreateAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) { - client, err := a.queryClient(config) +// Query 查询支付宝 WAP 支付单状态。 +func (a *AlipayWapAdapter) Query(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentQueryResult, error) { + client, err := a.queryClient(request.Config) if err != nil { return agentrecharge.OnlinePaymentQueryResult{}, err } - attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0) + attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentQuery, request.Config.ID, 0) if err != nil { return agentrecharge.OnlinePaymentQueryResult{}, err } startedAt := time.Now() - response, callErr := client.TradeQuery(ctx, sdkalipay.TradeQuery{OutTradeNo: paymentNo}) + response, callErr := client.TradeQuery(ctx, sdkalipay.TradeQuery{OutTradeNo: request.PaymentNo}) if callErr != nil { return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) } @@ -123,14 +88,7 @@ func (a *AlipayPreCreateAdapter) Query(ctx context.Context, paymentNo string, co return result, nil } -func (a *AlipayPreCreateAdapter) client(config *model.WechatConfig) (*sdkalipay.Client, error) { - if !a.Available(config) { - return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝扫码支付配置不可用") - } - return alipaypkg.NewClientFromConfig(config) -} - -func (a *AlipayPreCreateAdapter) queryClient(config *model.WechatConfig) (*sdkalipay.Client, error) { +func (a *AlipayWapAdapter) queryClient(config *model.WechatConfig) (*sdkalipay.Client, error) { if !alipayConfigComplete(config, false) { return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝查单配置不可用") } @@ -142,19 +100,25 @@ func alipayConfigComplete(config *model.WechatConfig, requireActive bool) bool { config.AliPublicKey != "" && config.AliNotifyURL != "" } -func (a *AlipayPreCreateAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { - resourceID := paymentNo +func (a *AlipayWapAdapter) startAttempt(ctx context.Context, request agentrecharge.OnlinePaymentRequest, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { + resourceID, resourceKey := strconv.FormatUint(uint64(request.PaymentID), 10), request.PaymentNo + series := "agent-recharge-payment:" + resourceID + ":" + operation + correlationID := request.CorrelationID return a.integration.Start(ctx, integrationlog.Attempt{ Provider: constants.IntegrationProviderAlipay, Direction: constants.IntegrationDirectionOutbound, Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, - ResourceID: &resourceID, ExternalID: &resourceID, + ResourceID: &resourceID, ResourceKey: &resourceKey, ExternalID: &resourceKey, + TriggerSeries: &series, CorrelationID: &correlationID, RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount}, }) } -func (a *AlipayPreCreateAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { +func (a *AlipayWapAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { + if a.logger != nil { + a.logger.Warn("支付宝支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause)) + } _, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{ - Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(), + Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "支付宝支付请求结果未知", ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", }) diff --git a/internal/infrastructure/payment/wechat_native.go b/internal/infrastructure/payment/wechat_native.go deleted file mode 100644 index caf5c5f..0000000 --- a/internal/infrastructure/payment/wechat_native.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package payment 提供代理在线充值使用的支付渠道薄适配器。 -package payment - -import ( - "context" - "strings" - "time" - - "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" - sdkpayment "github.com/ArtisanCloud/PowerWeChat/v3/src/payment" - orderRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request" - "go.uber.org/zap" - - agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" - "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/pkg/constants" - apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" - wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat" -) - -// WechatNativeAdapter 使用现有 PowerWeChat 实现 Native 预下单与查单。 -type WechatNativeAdapter struct { - cache kernel.CacheInterface - integration *integrationlog.Repository - logger *zap.Logger -} - -// NewWechatNativeAdapter 创建微信 Native 支付适配器。 -func NewWechatNativeAdapter(cache kernel.CacheInterface, integration *integrationlog.Repository, logger *zap.Logger) *WechatNativeAdapter { - return &WechatNativeAdapter{cache: cache, integration: integration, logger: logger} -} - -// Available 判断配置是否完整支持微信 Native 预下单、验签与查单。 -func (a *WechatNativeAdapter) Available(config *model.WechatConfig) bool { - return wechatConfigComplete(config, true) -} - -// PreCreate 创建微信 Native 扫码支付单。 -func (a *WechatNativeAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { - app, err := a.paymentApp(request.Config) - if err != nil { - return agentrecharge.OnlinePaymentResult{}, err - } - attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount) - if err != nil { - return agentrecharge.OnlinePaymentResult{}, err - } - startedAt := time.Now() - response, callErr := app.Order.TransactionNative(ctx, &orderRequest.RequestNativePrepay{ - Description: request.Description, - OutTradeNo: request.PaymentNo, - TimeExpire: request.ExpireAt.Format(time.RFC3339), - Amount: &orderRequest.NativeAmount{Total: int(request.Amount), Currency: "CNY"}, - }) - if callErr != nil { - return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) - } - if response == nil || strings.TrimSpace(response.CodeURL) == "" { - err = apperrors.New(apperrors.CodeWechatPayFailed, "微信 Native 预下单未返回付款内容") - _, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ - Result: constants.IntegrationResultFailed, ProviderCode: "empty_code_url", ProviderMessage: err.Error(), - ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), - }) - if completeErr != nil { - return agentrecharge.OnlinePaymentResult{}, completeErr - } - return agentrecharge.OnlinePaymentResult{}, err - } - if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ - Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true}, - DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true, - }); err != nil { - return agentrecharge.OnlinePaymentResult{}, err - } - return agentrecharge.OnlinePaymentResult{QRContent: response.CodeURL}, nil -} - -// Query 查询微信 Native 支付单状态。 -func (a *WechatNativeAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) { - app, err := a.queryPaymentApp(config) - if err != nil { - return agentrecharge.OnlinePaymentQueryResult{}, err - } - attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0) - if err != nil { - return agentrecharge.OnlinePaymentQueryResult{}, err - } - startedAt := time.Now() - info, callErr := wechatpay.NewPaymentService(app, a.logger).QueryOrder(ctx, paymentNo) - if callErr != nil { - return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) - } - result := agentrecharge.OnlinePaymentQueryResult{ - State: mapWechatTradeState(info.TradeState), ThirdPartyTradeNo: info.TransactionID, Amount: info.TotalAmount, - } - if paidAt, parseErr := time.Parse(time.RFC3339, info.SuccessTime); parseErr == nil { - result.PaidAt = &paidAt - } - if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ - Result: constants.IntegrationResultSuccess, ProviderCode: info.TradeState, - ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""}, - DurationMS: time.Since(startedAt).Milliseconds(), - }); err != nil { - return agentrecharge.OnlinePaymentQueryResult{}, err - } - return result, nil -} - -func (a *WechatNativeAdapter) paymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { - if !a.Available(config) { - return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信 Native 支付配置不可用") - } - app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) - if err != nil { - return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 Native 支付配置不可用") - } - return app, nil -} - -func (a *WechatNativeAdapter) queryPaymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { - if !wechatConfigComplete(config, false) { - return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信查单配置不可用") - } - app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) - if err != nil { - return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信查单配置不可用") - } - return app, nil -} - -func wechatConfigComplete(config *model.WechatConfig, requireActive bool) bool { - return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeWechat && - config.OaAppID != "" && config.WxMchID != "" && config.WxAPIV3Key != "" && - config.WxCertContent != "" && config.WxKeyContent != "" && config.WxSerialNo != "" && config.WxNotifyURL != "" -} - -func (a *WechatNativeAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { - resourceID := paymentNo - return a.integration.Start(ctx, integrationlog.Attempt{ - Provider: constants.IntegrationProviderWechatPay, Direction: constants.IntegrationDirectionOutbound, - Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, - ResourceID: &resourceID, ExternalID: &resourceID, - RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount}, - }) -} - -func (a *WechatNativeAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { - _, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{ - Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(), - ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), - RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", - }) - if err != nil { - return err - } - return apperrors.Wrap(apperrors.CodeTimeout, cause, "微信支付请求结果未知") -} - -func mapWechatTradeState(state string) string { - switch state { - case "SUCCESS": - return agentrecharge.OnlinePaymentStatePaid - case "CLOSED", "REVOKED", "PAYERROR": - return agentrecharge.OnlinePaymentStateClosed - case "NOTPAY", "USERPAYING": - return agentrecharge.OnlinePaymentStatePending - default: - return agentrecharge.OnlinePaymentStateUnknown - } -} diff --git a/internal/infrastructure/payment/wechat_web.go b/internal/infrastructure/payment/wechat_web.go new file mode 100644 index 0000000..6c65915 --- /dev/null +++ b/internal/infrastructure/payment/wechat_web.go @@ -0,0 +1,208 @@ +// Package payment 提供代理在线充值使用的支付渠道薄适配器。 +package payment + +import ( + "context" + "strconv" + "time" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + sdkpayment "github.com/ArtisanCloud/PowerWeChat/v3/src/payment" + "go.uber.org/zap" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat" +) + +// WechatWebAdapter 按当前支付配置生成微信 H5/MWEB 支付链接并查单。 +type WechatWebAdapter struct { + cache kernel.CacheInterface + integration *integrationlog.Repository + logger *zap.Logger +} + +// NewWechatWebAdapter 创建微信 H5/MWEB 支付适配器。 +func NewWechatWebAdapter(cache kernel.CacheInterface, integration *integrationlog.Repository, logger *zap.Logger) *WechatWebAdapter { + return &WechatWebAdapter{cache: cache, integration: integration, logger: logger} +} + +// Available 判断当前协议配置是否完整支持 H5/MWEB 下单、验签与查单。 +func (a *WechatWebAdapter) Available(config *model.WechatConfig) bool { + return wechatConfigComplete(config, true) || wechatV2ConfigComplete(config, true) +} + +// CreatePaymentURL 按当前协议生成微信 H5 或 MWEB 支付链接。 +func (a *WechatWebAdapter) CreatePaymentURL(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { + attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount) + if err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + startedAt := time.Now() + response, callErr := a.createH5Order(ctx, request, &wechatpay.H5SceneInfo{ + PayerClientIP: request.PayerClientIP, + H5Type: "Wap", + }) + if callErr != nil { + return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true}, + DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true, + }); err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + return agentrecharge.OnlinePaymentResult{QRContent: response.H5URL}, nil +} + +// Query 按创建支付单时的协议查询微信支付状态。 +func (a *WechatWebAdapter) Query(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentQueryResult, error) { + attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentQuery, request.Config.ID, 0) + if err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + startedAt := time.Now() + info, callErr := a.queryOrder(ctx, request.Config, request.PaymentNo) + if callErr != nil { + return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + result := agentrecharge.OnlinePaymentQueryResult{ + State: mapWechatTradeState(info.TradeState), ThirdPartyTradeNo: info.TransactionID, Amount: info.TotalAmount, + } + if paidAt, ok := parseWechatPaidAt(info.SuccessTime); ok { + result.PaidAt = &paidAt + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ProviderCode: info.TradeState, + ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""}, + DurationMS: time.Since(startedAt).Milliseconds(), + }); err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + return result, nil +} + +func parseWechatPaidAt(value string) (time.Time, bool) { + for _, layout := range []string{time.RFC3339, "20060102150405"} { + paidAt, err := time.ParseInLocation(layout, value, time.Local) + if err == nil { + return paidAt, true + } + } + return time.Time{}, false +} + +func (a *WechatWebAdapter) paymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { + if !wechatConfigComplete(config, false) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信 H5 支付配置不可用") + } + app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 H5 支付配置不可用") + } + return app, nil +} + +func (a *WechatWebAdapter) createH5Order(ctx context.Context, request agentrecharge.OnlinePaymentRequest, sceneInfo *wechatpay.H5SceneInfo) (*wechatpay.H5PayResult, error) { + switch request.Config.ProviderType { + case model.ProviderTypeWechat: + app, err := a.paymentApp(request.Config) + if err != nil { + return nil, err + } + return wechatpay.NewPaymentService(app, a.logger).CreateH5Order(ctx, request.PaymentNo, request.Description, int(request.Amount), sceneInfo) + case model.ProviderTypeWechatV2: + service, err := wechatpay.NewPaymentV2ServiceFromConfig(request.Config, request.Config.OaAppID, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 MWEB 支付配置不可用") + } + return service.CreateH5Order(ctx, request.PaymentNo, request.Description, int(request.Amount), sceneInfo) + default: + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付渠道不支持微信网页支付") + } +} + +func (a *WechatWebAdapter) queryOrder(ctx context.Context, config *model.WechatConfig, paymentNo string) (*wechatpay.OrderInfo, error) { + switch config.ProviderType { + case model.ProviderTypeWechat: + app, err := a.queryPaymentApp(config) + if err != nil { + return nil, err + } + return wechatpay.NewPaymentService(app, a.logger).QueryOrder(ctx, paymentNo) + case model.ProviderTypeWechatV2: + service, err := wechatpay.NewPaymentV2ServiceFromConfig(config, config.OaAppID, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 v2 查单配置不可用") + } + return service.QueryOrder(ctx, paymentNo) + default: + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付渠道不支持微信查单") + } +} + +func (a *WechatWebAdapter) queryPaymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { + if !wechatConfigComplete(config, false) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信查单配置不可用") + } + app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信查单配置不可用") + } + return app, nil +} + +func wechatConfigComplete(config *model.WechatConfig, requireActive bool) bool { + return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeWechat && + config.OaAppID != "" && config.WxMchID != "" && config.WxAPIV3Key != "" && + config.WxCertContent != "" && config.WxKeyContent != "" && config.WxSerialNo != "" && config.WxNotifyURL != "" +} + +func wechatV2ConfigComplete(config *model.WechatConfig, requireActive bool) bool { + return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeWechatV2 && + config.OaAppID != "" && config.WxMchID != "" && config.WxAPIV2Key != "" && config.WxNotifyURL != "" +} + +func (a *WechatWebAdapter) startAttempt(ctx context.Context, request agentrecharge.OnlinePaymentRequest, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { + resourceID, resourceKey := strconv.FormatUint(uint64(request.PaymentID), 10), request.PaymentNo + series := "agent-recharge-payment:" + resourceID + ":" + operation + correlationID := request.CorrelationID + return a.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWechatPay, Direction: constants.IntegrationDirectionOutbound, + Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, + ResourceID: &resourceID, ResourceKey: &resourceKey, ExternalID: &resourceKey, + TriggerSeries: &series, CorrelationID: &correlationID, + RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount}, + }) +} + +func (a *WechatWebAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { + if a.logger != nil { + a.logger.Warn("微信支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause)) + } + _, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "微信支付请求结果未知", + ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), + RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", + }) + if err != nil { + return err + } + return apperrors.Wrap(apperrors.CodeTimeout, cause, "微信支付请求结果未知") +} + +func mapWechatTradeState(state string) string { + switch state { + case "SUCCESS": + return agentrecharge.OnlinePaymentStatePaid + case "CLOSED", "REVOKED", "PAYERROR": + return agentrecharge.OnlinePaymentStateClosed + case "NOTPAY", "USERPAYING": + return agentrecharge.OnlinePaymentStatePending + default: + return agentrecharge.OnlinePaymentStateUnknown + } +} diff --git a/openspec/specs/agent-recharge/spec.md b/openspec/specs/agent-recharge/spec.md index 25c8fd6..0d626f9 100644 --- a/openspec/specs/agent-recharge/spec.md +++ b/openspec/specs/agent-recharge/spec.md @@ -22,22 +22,22 @@ 平台或超级管理员的 `offline` 线下代充 MUST 继续使用现有目标 `shop_id`、付款凭证和企业微信审批契约,在线充值 100 元最低金额 MUST NOT 改变线下代充金额规则。平台、超级管理员和企业账号 MUST NOT 创建代理在线扫码充值单。 -在线创建 MUST 在同一 GORM 事务中保存充值单、支付单及请求幂等事实,再调用创建时选定的支付 Adapter:微信 MUST 使用 Native 支付,支付宝 MUST 使用 `alipay.trade.precreate`。成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并在 `data` 中至少返回 `recharge_id`、`recharge_no`、`payment_no`、`payment_method`、`recharge_source`、`recharge_source_name`、`amount`、`qr_content`、`status` 与 `status_name`。后端 MUST 返回支付渠道原始付款字符串,不得生成或保存二维码图片。 +在线创建 MUST 在同一 GORM 事务中保存充值单、支付单及请求幂等事实,再按当前生效支付配置调用选定 Adapter:`provider_type=wechat` 使用微信 v3 H5 并返回 `h5_url`,`provider_type=wechat_v2` 使用微信 v2 MWEB 并返回 `mweb_url`,支付宝复用 C 端 `alipay.trade.wap.pay` 手机网站支付链接能力。成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并在 `data` 中至少返回 `recharge_id`、`recharge_no`、`payment_no`、`payment_method`、`recharge_source`、`recharge_source_name`、`amount`、`qr_content`、`status` 与 `status_name`。`qr_content` MUST 为支付渠道 HTTPS URL,前端自行渲染二维码;后端不得生成或保存二维码图片。 充值订单创建、列表、详情和在线支付状态响应 MUST 使用稳定来源枚举区分创建路径:`platform_offline` 表示平台线下代充,`agent_online` 表示代理在线自充。列表 MUST 支持使用 `recharge_source` 筛选;来源可由受控创建方式推导,不要求新增重复数据库字段。 支付单 MUST 同时保存创建时的收款身份快照:微信记录商户号,支付宝记录应用 ID,并保留支付方式与 `payment_config_id`,供后续导出对账。创建响应 MUST NOT 返回该内部收款身份快照。 -第三方预下单失败时,系统 MUST 记录 Integration Log,并将本次支付单标记为失败、充值单标记为已关闭;不得返回缺少有效 `qr_content` 的成功响应。 +支付链接生成失败时,系统 MUST 将本次支付单标记为失败、充值单标记为已关闭;微信 H5/MWEB 外部下单尝试 MUST 记录 Integration Log,支付宝本地签名生成 WAP URL 不得伪造外部调用日志。系统不得返回缺少有效 `qr_content` 的成功响应。 -#### Scenario: 代理创建微信 Native 扫码充值 +#### Scenario: 代理创建微信 H5/MWEB 链接扫码充值 - **WHEN** 代理提交 `amount=10000`、`payment_method=wechat` 和新的 `request_id` -- **THEN** 系统从登录上下文确定当前店铺主钱包,创建充值单和支付单,调用微信 Native 预下单,并将 `code_url` 映射为 `qr_content` +- **THEN** 系统从登录上下文确定当前店铺主钱包,创建充值单和支付单,按当前配置调用 v3 H5 或 v2 MWEB,并将 `h5_url` 或 `mweb_url` 映射为 `qr_content` - **THEN** 响应不得包含支付配置 ID、商户密钥或其他店铺信息 -#### Scenario: 代理创建支付宝当面付扫码充值 +#### Scenario: 代理创建支付宝 WAP 链接扫码充值 - **WHEN** 代理提交有效金额、`payment_method=alipay` 和新的 `request_id` -- **THEN** 系统创建充值单和支付单,调用 `alipay.trade.precreate`,并将 `qr_code` 映射为 `qr_content` +- **THEN** 系统创建充值单和支付单,使用 `alipay.trade.wap.pay` 生成签名 HTTPS URL,并将 URL 映射为 `qr_content` #### Scenario: 区分平台代充与代理自充 - **WHEN** 调用方查询充值订单列表、详情或在线支付状态 @@ -53,14 +53,14 @@ #### Scenario: 相同请求重放 - **WHEN** 同一提交账号使用相同 `request_id` 和相同业务字段重试 -- **THEN** 系统 MUST 返回首次创建的充值单、支付单和付款内容,不得再次创建业务单或再次向第三方预下单 +- **THEN** 系统 MUST 返回首次创建的充值单、支付单和支付链接,不得再次创建业务单或再次调用支付渠道生成链接 #### Scenario: 幂等请求载荷冲突 - **WHEN** 同一提交账号使用已有 `request_id` 但改变金额或支付方式 - **THEN** 系统 MUST 返回 `CodeConflict`,不得改变原充值单或创建新单 #### Scenario: 支付方式不可用 -- **WHEN** 所选支付方式缺少完整配置、扫码预下单能力或回调验签能力 +- **WHEN** 所选支付方式缺少完整配置、支付链接生成能力或回调验签能力 - **THEN** 系统 MUST 返回统一支付配置不可用错误,且不得创建只有本地记录而无法付款的待支付订单 --- @@ -491,12 +491,12 @@ Outbox 消费者 MUST 在独立事务中复用统一代理主钱包入账能力 ### Requirement: 查询代理在线充值可用支付方式 -系统 SHALL 提供 `GET /api/admin/agent-recharges/payment-methods`,仅根据当前生效支付配置返回真正具备扫码预下单、回调验签和查单能力的在线支付方式。路由 MUST 注册在 `/:id` 动态路由之前。 +系统 SHALL 提供 `GET /api/admin/agent-recharges/payment-methods`,仅根据当前生效支付配置返回真正具备支付链接生成、回调验签和查单能力的在线支付方式。路由 MUST 注册在 `/:id` 动态路由之前。 成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式;`data.methods` MUST 为按 `wechat`、`alipay` 固定顺序排列的字符串数组,并同时返回 `min_amount=10000` 与 `max_amount=100000000`。接口不得返回支付配置 ID、商户号、应用 ID、密钥或具体缺失的敏感配置。 #### Scenario: 微信与支付宝均可用 -- **WHEN** 当前支付配置完整支持微信 Native 和支付宝 PreCreate +- **WHEN** 当前支付配置完整支持对应协议的微信 H5/MWEB 和支付宝 WAP 支付 - **THEN** 接口 MUST 返回 `methods=["wechat","alipay"]` 及在线金额上下限 #### Scenario: 没有可用扫码支付方式 @@ -558,14 +558,14 @@ Outbox 消费者 MUST 在独立事务中复用统一代理主钱包入账能力 | 1 | 待支付(订单已创建,等待支付) | | 2 | 已支付(第三方收款已确认,钱包入账处理中) | | 3 | 已完成(钱包余额和唯一流水已提交) | -| 4 | 已关闭(第三方预下单失败或确认订单已关闭) | +| 4 | 已关闭(支付链接生成失败或确认订单已关闭) | **支付方式枚举** | 值 | 含义 | |----|------| -| wechat | 微信 Native 扫码支付 | -| alipay | 支付宝当面付扫码支付 | +| wechat | 微信 H5/MWEB 支付链接扫码支付 | +| alipay | 支付宝 WAP 支付链接扫码支付 | | offline | 线下转账(仅平台账号可用) | **支付通道枚举** diff --git a/pkg/payment/loader.go b/pkg/payment/loader.go index db1857d..2504732 100644 --- a/pkg/payment/loader.go +++ b/pkg/payment/loader.go @@ -115,7 +115,7 @@ func (l *paymentConfigLoader) buildPaymentService(config *model.WechatConfig) (w // v2PaymentAdapter 微信支付 v2 适配器 // 将 PaymentV2Service 适配为完整的 PaymentServiceInterface -// v2 仅支持 JSAPI 支付,其余方法返回不支持错误 +// v2 支持 JSAPI、H5/MWEB 与查单,其余方法返回不支持错误。 type v2PaymentAdapter struct { svc *wechat.PaymentV2Service } @@ -124,12 +124,12 @@ func (a *v2PaymentAdapter) CreateJSAPIOrder(ctx context.Context, orderNo, descri return a.svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount) } -func (a *v2PaymentAdapter) CreateH5Order(_ context.Context, _ string, _ string, _ int, _ *wechat.H5SceneInfo) (*wechat.H5PayResult, error) { - return nil, errors.New(errors.CodeWechatPayFailed, "微信支付 v2 不支持 H5 支付") +func (a *v2PaymentAdapter) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *wechat.H5SceneInfo) (*wechat.H5PayResult, error) { + return a.svc.CreateH5Order(ctx, orderNo, description, amount, sceneInfo) } -func (a *v2PaymentAdapter) QueryOrder(_ context.Context, _ string) (*wechat.OrderInfo, error) { - return nil, errors.New(errors.CodeWechatPayFailed, "微信支付 v2 暂不支持查单接口") +func (a *v2PaymentAdapter) QueryOrder(ctx context.Context, orderNo string) (*wechat.OrderInfo, error) { + return a.svc.QueryOrder(ctx, orderNo) } func (a *v2PaymentAdapter) CloseOrder(_ context.Context, _ string) error { diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go index 87260ee..7329da3 100644 --- a/pkg/wechat/payment_v2.go +++ b/pkg/wechat/payment_v2.go @@ -1,21 +1,29 @@ package wechat import ( + "bytes" "context" "crypto/md5" + "crypto/subtle" "encoding/xml" "fmt" "io" + "net" "net/http" + "net/url" "sort" "strings" "time" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/bytedance/sonic" "go.uber.org/zap" ) -const wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder" +const ( + wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder" + wechatPayV2OrderQueryURL = "https://api.mch.weixin.qq.com/pay/orderquery" +) // PaymentV2Service 微信支付 v2 服务 // 适用于仅配置 APIv2Key 的商户(无需 v3 证书序列号) @@ -51,7 +59,8 @@ type v2UnifiedOrderRequest struct { SpbillIP string `xml:"spbill_create_ip"` NotifyURL string `xml:"notify_url"` TradeType string `xml:"trade_type"` - OpenID string `xml:"openid"` + OpenID string `xml:"openid,omitempty"` + SceneInfo string `xml:"scene_info,omitempty"` } // v2UnifiedOrderResponse 统一下单响应(v2 XML 格式) @@ -63,6 +72,51 @@ type v2UnifiedOrderResponse struct { ErrCode string `xml:"err_code"` ErrCodeDes string `xml:"err_code_des"` PrepayID string `xml:"prepay_id"` + MwebURL string `xml:"mweb_url"` +} + +// v2OrderQueryRequest 商户订单号查单请求(v2 XML 格式) +type v2OrderQueryRequest struct { + XMLName xml.Name `xml:"xml"` + AppID string `xml:"appid"` + MchID string `xml:"mch_id"` + OutTradeNo string `xml:"out_trade_no"` + NonceStr string `xml:"nonce_str"` + Sign string `xml:"sign"` +} + +// v2OrderQueryResponse 商户订单号查单响应(v2 XML 格式) +type v2OrderQueryResponse struct { + XMLName xml.Name `xml:"xml"` + ReturnCode string `xml:"return_code"` + ReturnMsg string `xml:"return_msg"` + ResultCode string `xml:"result_code"` + ErrCode string `xml:"err_code"` + ErrCodeDes string `xml:"err_code_des"` + OpenID string `xml:"openid"` + TradeType string `xml:"trade_type"` + TradeState string `xml:"trade_state"` + BankType string `xml:"bank_type"` + TotalFee int64 `xml:"total_fee"` + CashFee int64 `xml:"cash_fee"` + FeeType string `xml:"fee_type"` + TransactionID string `xml:"transaction_id"` + OutTradeNo string `xml:"out_trade_no"` + Attach string `xml:"attach"` + TimeEnd string `xml:"time_end"` + TradeStateDesc string `xml:"trade_state_desc"` +} + +// v2H5SceneInfo v2 MWEB 支付场景信息 +type v2H5SceneInfo struct { + H5Info v2H5Info `json:"h5_info"` +} + +// v2H5Info v2 H5 场景明细 +type v2H5Info struct { + Type string `json:"type"` + WapURL string `json:"wap_url"` + WapName string `json:"wap_name"` } // CreateJSAPIOrder 创建 v2 JSAPI 支付订单 @@ -158,6 +212,172 @@ func (s *PaymentV2Service) CreateJSAPIOrder(ctx context.Context, orderNo, descri }, nil } +// CreateH5Order 创建 v2 MWEB 支付订单 +func (s *PaymentV2Service) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) { + if orderNo == "" || description == "" || amount <= 0 || sceneInfo == nil || net.ParseIP(strings.TrimSpace(sceneInfo.PayerClientIP)) == nil { + return nil, errors.New(errors.CodeInvalidParam, "订单号、订单描述、金额和客户端 IP 不能为空") + } + + websiteURL, err := v2WebsiteURL(s.notifyURL) + if err != nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "微信 H5 支付站点配置不可用") + } + sceneJSON, err := sonic.MarshalString(v2H5SceneInfo{H5Info: v2H5Info{ + Type: "Wap", WapURL: websiteURL, WapName: description, + }}) + if err != nil { + return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建 H5 支付场景失败") + } + + nonceStr := v2GenerateNonceStr() + params := map[string]string{ + "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, + "body": description, "out_trade_no": orderNo, "total_fee": fmt.Sprintf("%d", amount), + "spbill_create_ip": strings.TrimSpace(sceneInfo.PayerClientIP), "notify_url": s.notifyURL, + "trade_type": "MWEB", "scene_info": sceneJSON, + } + params["sign"] = v2SignMD5(params, s.apiKey) + reqBody := &v2UnifiedOrderRequest{ + AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"], + Body: params["body"], OutTradeNo: params["out_trade_no"], TotalFee: amount, + SpbillIP: params["spbill_create_ip"], NotifyURL: params["notify_url"], TradeType: params["trade_type"], SceneInfo: params["scene_info"], + } + + respBytes, err := s.postV2XML(ctx, wechatPayV2UnifiedOrderURL, reqBody) + if err != nil { + s.logger.Error("调用微信 v2 MWEB 统一下单接口失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败") + } + var resp v2UnifiedOrderResponse + if err = xml.Unmarshal(respBytes, &resp); err != nil { + s.logger.Error("解析微信 v2 MWEB 统一下单响应失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败") + } + if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" || resp.MwebURL == "" { + s.logger.Error("微信 v2 MWEB 统一下单失败", zap.String("order_no", orderNo), zap.String("return_code", resp.ReturnCode), + zap.String("return_msg", resp.ReturnMsg), zap.String("result_code", resp.ResultCode), zap.String("err_code", resp.ErrCode), zap.String("err_code_des", resp.ErrCodeDes)) + return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败") + } + if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { + s.logger.Error("微信 v2 MWEB 统一下单响应验签失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败") + } + + s.logger.Info("创建 v2 MWEB 支付订单成功", zap.String("order_no", orderNo)) + return &H5PayResult{H5URL: resp.MwebURL}, nil +} + +// QueryOrder 按商户订单号查询 v2 支付订单 +func (s *PaymentV2Service) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) { + if orderNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空") + } + + nonceStr := v2GenerateNonceStr() + params := map[string]string{ + "appid": s.appID, "mch_id": s.mchID, "out_trade_no": orderNo, "nonce_str": nonceStr, + } + params["sign"] = v2SignMD5(params, s.apiKey) + reqBody := &v2OrderQueryRequest{ + AppID: params["appid"], MchID: params["mch_id"], OutTradeNo: params["out_trade_no"], NonceStr: params["nonce_str"], Sign: params["sign"], + } + + respBytes, err := s.postV2XML(ctx, wechatPayV2OrderQueryURL, reqBody) + if err != nil { + s.logger.Error("调用微信 v2 查单接口失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败") + } + var resp v2OrderQueryResponse + if err = xml.Unmarshal(respBytes, &resp); err != nil { + s.logger.Error("解析微信 v2 查单响应失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败") + } + if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" { + s.logger.Error("微信 v2 查单失败", zap.String("order_no", orderNo), zap.String("return_code", resp.ReturnCode), + zap.String("return_msg", resp.ReturnMsg), zap.String("result_code", resp.ResultCode), zap.String("err_code", resp.ErrCode), zap.String("err_code_des", resp.ErrCodeDes)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败") + } + if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { + s.logger.Error("微信 v2 查单响应验签失败", zap.String("order_no", orderNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败") + } + + info := &OrderInfo{ + TransactionID: resp.TransactionID, OutTradeNo: resp.OutTradeNo, TradeState: resp.TradeState, + TradeStateDesc: resp.TradeStateDesc, SuccessTime: resp.TimeEnd, TradeType: resp.TradeType, + BankType: resp.BankType, Attach: resp.Attach, PayerOpenID: resp.OpenID, + TotalAmount: resp.TotalFee, PayerTotal: resp.CashFee, Currency: resp.FeeType, + } + if info.Currency == "" { + info.Currency = "CNY" + } + s.logger.Debug("查询微信 v2 订单成功", zap.String("order_no", orderNo), zap.String("trade_state", resp.TradeState)) + return info, nil +} + +// postV2XML 发送微信支付 v2 XML 请求 +func (s *PaymentV2Service) postV2XML(ctx context.Context, endpoint string, body any) ([]byte, error) { + xmlBytes, err := xml.Marshal(body) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(xmlBytes))) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/xml") + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("微信支付接口返回 HTTP 状态码 %d", httpResp.StatusCode) + } + return io.ReadAll(io.LimitReader(httpResp.Body, 1<<20)) +} + +func v2WebsiteURL(notifyURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(notifyURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("微信支付回调地址无效") + } + return parsed.Scheme + "://" + parsed.Host, nil +} + +func verifyV2ResponseSign(body []byte, apiKey string) error { + decoder := xml.NewDecoder(bytes.NewReader(body)) + params := make(map[string]string) + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local == "xml" { + continue + } + var value string + if err = decoder.DecodeElement(&value, &start); err != nil { + return err + } + params[start.Name.Local] = value + } + received := strings.ToUpper(params["sign"]) + if received == "" { + return fmt.Errorf("微信支付响应缺少签名") + } + delete(params, "sign") + expected := v2SignMD5(params, apiKey) + if subtle.ConstantTimeCompare([]byte(received), []byte(expected)) != 1 { + return fmt.Errorf("微信支付响应签名不匹配") + } + return nil +} + // buildJSAPIPayConfig 构建 JSAPI 唤起支付所需的签名参数 func (s *PaymentV2Service) buildJSAPIPayConfig(prepayID, nonceStr string) map[string]string { timestamp := fmt.Sprintf("%d", time.Now().Unix())