From 08a7a00cd9fedd8cef195a13d33270cdc7d51ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Mon, 19 Oct 2026 23:29:55 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BE=AE=E4=BF=A1=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=85=AC=E4=BC=97=E5=8F=B7=E5=BF=85=E5=A1=AB?= =?UTF-8?q?=E3=80=81=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=80=89=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商户池变更把授权完整性实现为公众号与小程序四项凭证都必填, 生产只用公众号 H5、未配置小程序,迁移后授权表为空会导致 C 端微信登录与支付全部失败,后台也无法补建。 - SaveAuthorization 启用时只校验公众号 AppID/AppSecret; 小程序选填,但 AppID 与 AppSecret 须同时填写或同时留空 - 新增迁移 000240:无授权配置且唯一生效综合配置公众号凭证 完整时补建启用授权配置;down 不删除数据 - 新增 OpenSpec Change allow-wechat-authorization-without-miniapp Co-Authored-By: Claude Opus 5.5 --- .../application/merchantpayment/management.go | 8 +- ...fill_wechat_authorization_oa_only.down.sql | 4 + ...ckfill_wechat_authorization_oa_only.up.sql | 25 ++++ .../.openspec.yaml | 2 + .../proposal.md | 26 +++++ .../specs/merchant-payment-routing/spec.md | 108 ++++++++++++++++++ .../tasks.md | 13 +++ 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 migrations/000240_backfill_wechat_authorization_oa_only.down.sql create mode 100644 migrations/000240_backfill_wechat_authorization_oa_only.up.sql create mode 100644 openspec/changes/allow-wechat-authorization-without-miniapp/.openspec.yaml create mode 100644 openspec/changes/allow-wechat-authorization-without-miniapp/proposal.md create mode 100644 openspec/changes/allow-wechat-authorization-without-miniapp/specs/merchant-payment-routing/spec.md create mode 100644 openspec/changes/allow-wechat-authorization-without-miniapp/tasks.md diff --git a/internal/application/merchantpayment/management.go b/internal/application/merchantpayment/management.go index 029b8d4d..479ce7bf 100644 --- a/internal/application/merchantpayment/management.go +++ b/internal/application/merchantpayment/management.go @@ -688,8 +688,12 @@ func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.Wecha if err := requireManager(ctx); err != nil { return nil, err } - if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") { - return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整") + // 公众号必填;小程序选填,但 AppID 与 AppSecret 必须同时填写或同时留空。 + if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "") { + return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号 AppID 和 AppSecret 必须填写") + } + if (strings.TrimSpace(req.MiniappAppID) == "") != (strings.TrimSpace(req.MiniappAppSecret) == "") { + return nil, errors.New(errors.CodeInvalidParam, "小程序 AppID 和 AppSecret 需同时填写或同时留空") } var authorization model.WechatAuthorization err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/migrations/000240_backfill_wechat_authorization_oa_only.down.sql b/migrations/000240_backfill_wechat_authorization_oa_only.down.sql new file mode 100644 index 00000000..54ed3d17 --- /dev/null +++ b/migrations/000240_backfill_wechat_authorization_oa_only.down.sql @@ -0,0 +1,4 @@ +-- 回滚 000240:不删除任何微信授权配置。 +-- 回填行与后台手工创建或编辑后的行无法可靠区分,删除会误删运营配置并使 C 端微信功能失效; +-- 需要撤销时由维护者在后台停用或编辑该配置。 +SELECT 1; diff --git a/migrations/000240_backfill_wechat_authorization_oa_only.up.sql b/migrations/000240_backfill_wechat_authorization_oa_only.up.sql new file mode 100644 index 00000000..2b54c671 --- /dev/null +++ b/migrations/000240_backfill_wechat_authorization_oa_only.up.sql @@ -0,0 +1,25 @@ +-- 补建只含公众号的微信授权配置。 +-- 背景:000210 只在当前生效综合支付配置的公众号与小程序凭证都完整时才创建微信授权配置。 +-- 生产只使用公众号 H5,从未配置小程序,迁移后 tb_wechat_authorization 为空, +-- C 端微信登录、OpenID、JSSDK 与微信支付 AppID 会全部返回「微信授权未配置」。 +-- 小程序在 8 月之前一直是选填项,本迁移按「公众号必填、小程序选填」补齐授权配置。 +-- +-- 条件(全部满足才插入,否则不做任何事): +-- 1. 尚无任何未删除的微信授权配置(已存在则视为已由 000210 或后台建立,不覆盖、不新增); +-- 2. 恰好一条当前生效综合支付配置(000210 已保证不超过一条); +-- 3. 该配置公众号 AppID 与 AppSecret 均非空。 +-- 小程序字段按原值复制,可以为空;未配置小程序时只影响小程序登录与小程序支付。 + +INSERT INTO tb_wechat_authorization ( + oa_app_id, oa_app_secret, oa_token, oa_aes_key, oa_oauth_redirect_url, + miniapp_app_id, miniapp_app_secret, credential_version, status +) +SELECT c.oa_app_id, c.oa_app_secret, c.oa_token, c.oa_aes_key, c.oa_oauth_redirect_url, + c.miniapp_app_id, c.miniapp_app_secret, 1, 1 +FROM tb_wechat_config AS c +WHERE c.is_active = TRUE + AND c.deleted_at IS NULL + AND c.oa_app_id <> '' + AND c.oa_app_secret <> '' + AND NOT EXISTS (SELECT 1 FROM tb_wechat_authorization AS a WHERE a.deleted_at IS NULL) + AND (SELECT COUNT(*) FROM tb_wechat_config WHERE is_active = TRUE AND deleted_at IS NULL) = 1; diff --git a/openspec/changes/allow-wechat-authorization-without-miniapp/.openspec.yaml b/openspec/changes/allow-wechat-authorization-without-miniapp/.openspec.yaml new file mode 100644 index 00000000..c1bc9027 --- /dev/null +++ b/openspec/changes/allow-wechat-authorization-without-miniapp/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/allow-wechat-authorization-without-miniapp/proposal.md b/openspec/changes/allow-wechat-authorization-without-miniapp/proposal.md new file mode 100644 index 00000000..07993e11 --- /dev/null +++ b/openspec/changes/allow-wechat-authorization-without-miniapp/proposal.md @@ -0,0 +1,26 @@ +## Why + +商户池变更(`add-payment-merchant-pools`)把公众号与小程序参数拆入独立的微信授权配置,并把“授权字段完整”实现为公众号与小程序四项凭证都必填:迁移 `000210` 只在四项齐全时复制授权,后台保存也要求四项齐全。8 月之前小程序一直是选填项。生产只使用公众号 H5、从未配置小程序(2026-10-19 只读核查),迁移后授权表为空,C 端微信登录、OpenID、JSSDK 与微信支付 AppID 会全部返回“微信授权未配置”,后台也无法补建。 + +## What Changes + +- 后台保存微信授权配置:启用时只要求公众号 AppID 与 AppSecret;小程序 AppID 与 AppSecret 选填,但必须同时填写或同时留空。 +- 新增成对迁移 `000240`:尚无未删除的授权配置、存在唯一当前生效综合支付配置且其公众号凭证完整时,补建一条启用的授权配置(小程序字段按原值复制,可以为空);down 不删除数据。 +- 未配置小程序时,小程序登录与小程序支付沿用现有判定,明确返回配置不可用;公众号相关功能不受影响。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `merchant-payment-routing`:微信授权配置启用条件改为公众号必填、小程序选填;上线迁移按公众号凭证完整性创建授权配置。 + +## Impact + +- 代码:`internal/application/merchantpayment/management.go`(`SaveAuthorization` 校验)。 +- 数据库:新增 `migrations/000240_backfill_wechat_authorization_oa_only.{up,down}.sql`,只插入数据,不改 Schema;测试环境已有授权配置,执行后无变化;生产执行 `000210` 后补建一条只含公众号的授权配置。 +- 接口:请求与响应结构不变,仅保存校验与错误文案变化。 +- 回滚:覆盖二进制安全;补建的授权配置保留,可在后台停用或编辑。 diff --git a/openspec/changes/allow-wechat-authorization-without-miniapp/specs/merchant-payment-routing/spec.md b/openspec/changes/allow-wechat-authorization-without-miniapp/specs/merchant-payment-routing/spec.md new file mode 100644 index 00000000..3a9f8640 --- /dev/null +++ b/openspec/changes/allow-wechat-authorization-without-miniapp/specs/merchant-payment-routing/spec.md @@ -0,0 +1,108 @@ +## MODIFIED Requirements + +### Requirement: 商户与微信授权配置管理 + +系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat` 或 `alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。创建或更新商户时 `credentials` MUST 为扁平 JSON 对象,必填键由支付方式与服务商类型组合确定:`wechat/wechat` 为 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url`;`wechat/wechat_v2` 为 `wx_mch_id`、`wx_api_v2_key`、`wx_notify_url`,并可选 `wx_client_cert_content`、`wx_client_key_content`(API 客户端证书与私钥,为 v2 原路退款的双向证书所需);`wechat/fuiou` 为 `fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url`;`alipay/alipay` 为 `ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url`。凭证值 MUST 为字符串,仅可选的 `ali_production`(布尔)与 `ali_pay_expire_minutes`(整数)例外;`merchant_identity` MUST 分别等于 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id`。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数,C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。启用微信授权配置时公众号 AppID 与 AppSecret MUST 填写;小程序 AppID 与 AppSecret 为选填,但 MUST 同时填写或同时留空。未配置小程序时,公众号登录、OpenID、JSSDK 与公众号支付不受影响,仅小程序登录与小程序支付明确返回配置不可用。 + +超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。仅上述角色的专用管理列表和详情响应可返回完整凭证;日志、审计快照、错误、导出、支付快照和其他业务响应 MUST NOT 保存或返回敏感凭证。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和原路退款仍使用该商户当前凭证;原路退款的实际渠道调用由 `order-refund-exchange` 能力按该商户当前凭证执行,本能力 MUST NOT 保存渠道退款流水或推进退款状态。可退性只依据服务商类型与该服务商类型退款必需凭证的完整性判定,本能力 MUST NOT 提供人工退款能力开关。`wechat/wechat_v2` 的 `wx_client_cert_content` 与 `wx_client_key_content` 为可选键:不填不影响该商户的支付、查单与回调,只使原路退款按其凭证完整性判定为不可用。 + +#### Scenario: 受引用商户停用 + +- **WHEN** 管理员停用已被支付单命中的商户 +- **THEN** 新支付单不再选择该商户,已命中支付单的回调、查询和原路退款仍按该商户处理 + +#### Scenario: 非管理角色读取配置 + +- **WHEN** 不具备超级管理员或平台用户身份的账号请求商户或微信授权配置 +- **THEN** 系统拒绝访问且不返回任何凭证或身份字段 + +#### Scenario: 只配置公众号启用微信授权 + +- **WHEN** 管理员保存并启用只填写公众号 AppID 与 AppSecret、小程序字段留空的微信授权配置 +- **THEN** 系统保存成功;公众号登录、OpenID、JSSDK 与公众号支付使用该配置,小程序登录与小程序支付明确返回配置不可用 + +#### Scenario: 启用授权缺少公众号凭证或小程序字段只填一半被拒 + +- **WHEN** 管理员启用微信授权配置时公众号 AppID 或 AppSecret 为空,或保存时小程序 AppID 与 AppSecret 只填写其中一项 +- **THEN** 系统拒绝写入并返回参数错误,不创建或修改授权配置 + +#### Scenario: 凭证轮换后处理历史支付 + +- **GIVEN** 已被支付单引用的商户或当前微信授权配置完成凭证更新 +- **WHEN** 更新提交后创建支付、处理回调、查单或原路退款 +- **THEN** 系统只使用更新后的当前有效凭证,不得继续使用更新前的缓存凭证 + +#### Scenario: 凭证键不完整或身份不一致被拒 + +- **WHEN** 管理员创建或更新商户时提交缺少该支付方式与服务商类型必填键的 `credentials`,或 `merchant_identity` 与凭证中的商户号或应用标识不一致 +- **THEN** 系统拒绝写入并返回参数错误,不创建或修改商户记录 + +#### Scenario: 商户凭证不足以执行退款 + +- **GIVEN** 已被支付单命中的商户缺少其服务商类型执行原路退款所需的凭证 +- **WHEN** 系统判定该支付单原路退款的可退性 +- **THEN** 判定结果为不可用且只允许客户收款信息退款,系统不因该结果新增商户凭证键或人工退款开关 + +### Requirement: 新支付商户快照与历史兼容 + +C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于原路退款路径的可退性判定,不提供人工开关;实际渠道退款调用与退款终态由 `order-refund-exchange` 能力实现,本能力 MUST NOT 发起渠道退款请求。 + +上线迁移在存在唯一当前生效综合支付配置时复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,公众号 AppID 与 AppSecret 完整时创建全局微信授权配置,小程序字段按原值复制且可以为空;不完整的支付方式不建池,公众号凭证不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据;新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker;三类后续新支付 MUST 立即只走商户池并冻结路由,无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径。 + +#### Scenario: 新支付冻结实际商户 + +- **WHEN** 客户以微信或支付宝创建覆盖范围内的新线上支付单 +- **THEN** 系统选择并冻结一个实际商户和商户池路由快照,并使用该商户的服务商凭证发起支付 + +#### Scenario: 迁移后的新支付只走商户池 + +- **GIVEN** 迁移完成且已部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker +- **WHEN** 客户创建覆盖范围内的后续新线上支付 +- **THEN** 系统立即经启用商户池选择并冻结实际商户;无可用池、成员或池已停用时明确失败,不得走旧综合支付配置创建 + +#### Scenario: 历史支付保留旧读取路径 + +- **GIVEN** 支付单 merchant ID 为空 +- **WHEN** 该支付经过回调、查单或原路退款路径 +- **THEN** 系统仅按其 `payment_config_id` 处理,不因当前商户池推断或改写其商户,直到独立 Change 按数据留存期删除旧读取路径 + +#### Scenario: 旧支付单回调 + +- **WHEN** 商户池切换后收到未带新商户快照的历史支付单回调 +- **THEN** 系统按既有综合支付配置兼容处理该历史单,不将其改挂到任何新商户 + +#### Scenario: 微信授权配置迁移缺失 + +- **GIVEN** 当前生效综合支付配置的公众号 AppID 或 AppSecret 为空 +- **WHEN** 执行商户池配置迁移后访问 C 端微信登录、OpenID、JSSDK 或微信支付 AppID 功能 +- **THEN** 系统明确返回微信授权未配置,不得回退旧综合支付配置 + +#### Scenario: 只有公众号的综合支付配置迁移 + +- **GIVEN** 唯一当前生效综合支付配置的公众号 AppID 与 AppSecret 完整、小程序字段为空,且尚无未删除的微信授权配置 +- **WHEN** 执行商户池配置迁移 +- **THEN** 系统创建一条启用的全局微信授权配置,公众号字段取自该配置、小程序字段为空;已存在未删除的微信授权配置时不覆盖、不新增 + +#### Scenario: 多个当前生效综合支付配置 + +- **GIVEN** 存在多条当前生效综合支付配置 +- **WHEN** 执行商户池配置迁移 +- **THEN** 迁移失败且不选择任一配置作为复制来源 + +#### Scenario: 富友双读兼容基线 + +- **GIVEN** 富友 `CommonQuery` 未经第三方实测 +- **WHEN** 系统以商户当前凭证完成富友支付的本地双读配置来源和商户加载接线,且不改变现有 `CommonQuery` 请求格式、签名算法、验签、状态映射或恢复语义 +- **THEN** 系统保留该兼容基线;富友原路退款由 `order-refund-exchange` 能力按 `/commonRefund` 与 `/refundQuery` 契约单独实现,未第三方实测可以记录,但不得作为实施、验证或归档阻塞 + +#### Scenario: 维护者指定测试环境的配置迁移验证 + +- **GIVEN** 维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 与当前 Change fixture +- **WHEN** 本地工作区以明确 `DB_*` 完成 migration up/down/up 和数据行为验证后提交推送 Iteration/8-11 +- **THEN** Gitea 只构建/部署 `cmp-test` 测试镜像并检查迁移版本;测试日志保存在 `/opt/junhong_cmp/logs`,不得自动执行迁移或重置整库 + +#### Scenario: 历史支付路径保留 + +- **GIVEN** 存在 merchant ID 为空的历史支付 +- **WHEN** 商户池功能已上线且独立 Change 尚未按数据留存期删除旧读取路径 +- **THEN** 系统仍按该支付的 `payment_config_id` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户 diff --git a/openspec/changes/allow-wechat-authorization-without-miniapp/tasks.md b/openspec/changes/allow-wechat-authorization-without-miniapp/tasks.md new file mode 100644 index 00000000..302c8239 --- /dev/null +++ b/openspec/changes/allow-wechat-authorization-without-miniapp/tasks.md @@ -0,0 +1,13 @@ +## 1. 保存校验 + +- [x] 1.1 `SaveAuthorization` 启用时只校验公众号 AppID 与 AppSecret 非空;小程序 AppID 与 AppSecret 同时填写或同时留空,否则返回参数错误。 + +## 2. 迁移 + +- [x] 2.1 新增成对迁移 `000240_backfill_wechat_authorization_oa_only`:无未删除授权配置、唯一当前生效综合支付配置且公众号凭证完整时补建启用授权配置,小程序字段按原值复制;down 不删除数据。 + +## 3. 验证 + +- [x] 3.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`openspec validate allow-wechat-authorization-without-miniapp`。 +- [x] 3.2 在隔离库以显式 `DB_*` 执行 `000240` up/down/up:已有授权配置时无变化;构造“生效配置只有公众号、授权表为空”时补建一条 `status=1`、小程序为空的授权配置。 +- [ ] 3.3 测试环境:后台只填公众号保存并启用成功;小程序只填一项被拒;公众号 H5 登录与下单正常;小程序登录返回配置不可用。