Files
gongxue-base/docs/refactor/auth-payment-provider-plan.md
2026-07-03 22:36:58 +08:00

21 KiB
Raw Blame History

国内认证与支付接入方案

Supabase 边界

Supabase 适合承担 PostgreSQL、RLS、Auth、Edge Functions、Webhook/Hooks 等底座能力,但它不是中国大陆支付网关,也不会内置微信支付、支付宝、阿里云短信、腾讯云短信这一整套商用配置。

对本项目更稳妥的落位是:

  • Supabase/PostgreSQL保存多租户、订单、支付事件、权益、审计、登录事件。
  • apps/api:实现业务 API、短信 provider、OAuth provider、支付 provider、回调验签和幂等。
  • app_private.tenant_secrets 或生产 Vault/KMS保存租户级密钥。
  • tenant_auth_providerstenant_payment_accounts:只保存非敏感公开配置。

Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先用 app_private.auth_sessions 签发 tk_ session保证 Web/Taro 能跑通端到端流程。

本地验收边界

当前阶段没有真实阿里云/腾讯云/微信/QQ/支付宝密钥时,不阻塞后端编码和本地联调。验收重点放在 provider adapter 之外的商用关键链路:

  • 短信:使用 AUTH_SMS_PROVIDER=mock 或本地 fake endpoint 验证验证码生成、HMAC 存储、冷却、过期、登录事件和 session 签发。生产环境仍为 mock 会 fail-fast。
  • 微信小程序/微信网页/QQ 登录:使用本地 fake endpoint 或测试桩覆盖 code 换身份之后的账号创建/合并、user_identities、登录审计和 session 签发;真实 code2Session、授权回调域名和开放平台错误码到云服务器解析完成后再验收。
  • 微信/支付宝支付:本地优先验证下单参数生成、回调验签/解密的错误处理、幂等键、payment_events、订单状态、权益开通、退款状态机、补偿 worker 和对账工单。真实商户号、API v3 key、证书、支付宝公私钥和回调域名上线后再做官方联调。
  • 密钥:本地可以只写测试密钥或 fake secret但必须走 app_private.tenant_secrets / app_private.platform_secrets 引用链;前端和 public schema 不允许出现明文密钥。

因此本地“跑通”不等于真实平台验收完成。上线门禁必须再补真实生产账号、真实回调域名、官方账单抽样和生产 provider 配置证据。

当前已实现

  • POST /api/auth/sms/send:手机号验证码发送。传统短信 provider 的验证码只保存 HMAC hash阿里云 PNVS 短信认证 provider 由阿里云生成并核验验证码,本地只保存发送流水、outId、频控和审计。
  • POST /api/auth/sms/verify:验证码登录,自动创建或复用 platform_users
  • GET /api/auth/me:通过 Bearer token 获取当前用户。
  • POST /api/auth/logout:吊销迁移期 session。
  • POST /api/auth/oauth/wechat-miniapp:微信小程序 code2Session 登录,后端换取 openid/unionid签发 tk_ session。
  • POST /api/auth/oauth/wechat:微信网页登录,后端用 code 换 access_token/openid再拉取 userinfo按 unionid 合并微信身份并签发 tk_ session。
  • POST /api/auth/oauth/qqQQ 登录,后端完成 code 换 token、token 换 openid、拉取 userinfo并签发 tk_ session。
  • tenant_auth_providers:租户级公开认证配置。
  • sms_verification_codes:验证码审计表,不保存明文 code。
  • auth_login_events:登录事件审计。
  • app_private.auth_sessions:迁移期 session token hash。
  • 阿里云短信 SendSms provider使用租户级 AccessKey、签名、模板发送。
  • 阿里云 PNVS 短信认证 SendSmsVerifyCode / CheckSmsVerifyCode provider使用租户级 AccessKey、签名、模板发送和核验验证码适合不想走传统短信模板资质链路的手机号登录场景。
  • 腾讯云短信 SendSms provider使用租户级 SecretId/SecretKey、SdkAppId、签名、模板发送。
  • POST /api/commerce/payments/create:按订单创建微信支付 JSAPI 或支付宝 WAP 支付参数。
  • POST /api/commerce/payments/notify/wechat_pay:微信支付 API v3 通知验签、AES-GCM 解密、幂等落库和权益开通。
  • POST /api/commerce/payments/notify/alipay:支付宝 RSA2 通知验签、幂等落库和权益开通。

短信 Provider

本地默认是 AUTH_SMS_PROVIDER=mock,仅开发环境返回 debugCode。生产环境只允许 aliyun/aliyun-smsaliyun-pnvs/aliyun-sms-authtencent/tencent-sms;如果仍为 mock 或写成未知 providerAPI 启动和 readiness:production 都会直接失败。

真实 provider

  • aliyun / aliyun-sms:阿里云短信 SendSms,需要 AccessKey、签名、模板 ID。
  • aliyun-pnvs / aliyun-sms-auth:阿里云 PNVS 短信认证服务,调用 SendSmsVerifyCode 发送,调用 CheckSmsVerifyCode 核验;验证码由阿里云生成并校验,本地不保存明文验证码。
  • tencent / tencent-sms:腾讯云短信 SendSms,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。

密钥策略:

  • AccessKey/SecretKey 不进入 tenant_settings.public_config
  • 租户级密钥写 app_private.tenant_secrets(secret_scope='sms') 或生产 Vault。
  • 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。
  • provider endpoint 默认使用官方域名,生产环境只允许 HTTPS 官方域名;本地测试可使用 localhost/127.0.0.1 fake server。
  • readiness:production:db 会检查 active/testing 短信 provider 的 signName/templateCodesmsSdkAppId/signName/templateId,并阻断公开配置里的 secret-like 字段。

阿里云 PNVS 短信认证配置示例

AUTH_SMS_PROVIDER 可设为 aliyun-pnvs,租户公开配置写 tenant_auth_providers.config_publicAccessKey 写 app_private.tenant_secrets。PNVS 模板参数推荐使用阿里云占位 ##code##,如 {"code":"##code##","min":"5"};后端会用 AUTH_CODE_TTL_SECONDS 自动补 min

{
  "provider": "aliyun-pnvs",
  "status": "active",
  "configPublic": {
    "signName": "工学教育",
    "templateCode": "SMS_123456789",
    "endpoint": "https://dypnsapi.aliyuncs.com",
    "regionId": "cn-hangzhou",
    "templateParam": {
      "code": "##code##",
      "min": "5"
    },
    "codeLength": "6",
    "validTime": "300",
    "interval": "60",
    "duplicatePolicy": "1",
    "secretRef": "app_private.tenant_secrets:sms:aliyun-pnvs"
  },
  "secret": {
    "secretScope": "sms",
    "secretKey": "aliyun-pnvs",
    "secretJson": {
      "accessKeyId": "LTAI********",
      "accessKeySecret": "********"
    }
  }
}

生产 bootstrap SQL 参考:

insert into app_private.tenant_secrets (
  tenant_id, secret_scope, secret_key, provider, secret_json, last_rotated_at
)
values (
  :'tenant_id'::uuid,
  'sms',
  'aliyun-pnvs',
  'aliyun-pnvs',
  jsonb_build_object('accessKeyId', :'access_key_id', 'accessKeySecret', :'access_key_secret'),
  now()
)
on conflict (tenant_id, secret_scope, secret_key)
do update set provider = excluded.provider,
              secret_json = excluded.secret_json,
              last_rotated_at = now(),
              updated_at = now();

insert into public.tenant_auth_providers (
  tenant_id, provider, status, display_name, config_public
)
values (
  :'tenant_id'::uuid,
  'aliyun-pnvs',
  'active',
  '阿里云短信认证',
  jsonb_build_object(
    'signName', :'sign_name',
    'templateCode', :'template_code',
    'endpoint', 'https://dypnsapi.aliyuncs.com',
    'regionId', 'cn-hangzhou',
    'templateParam', jsonb_build_object('code', '##code##', 'min', '5'),
    'codeLength', '6',
    'validTime', '300',
    'interval', '60',
    'duplicatePolicy', '1',
    'secretRef', 'app_private.tenant_secrets:sms:aliyun-pnvs'
  )
)
on conflict (tenant_id, provider)
do update set status = excluded.status,
              display_name = excluded.display_name,
              config_public = excluded.config_public,
              updated_at = now();

阿里云短信配置示例

{
  "provider": "aliyun",
  "status": "active",
  "configPublic": {
    "signName": "工学教育",
    "templateCode": "SMS_123456789",
    "regionId": "cn-hangzhou"
  },
  "secret": {
    "secretScope": "sms",
    "secretKey": "aliyun",
    "secretJson": {
      "accessKeyId": "LTAI...",
      "accessKeySecret": "..."
    }
  }
}

腾讯云短信配置示例

{
  "provider": "tencent",
  "status": "active",
  "configPublic": {
    "smsSdkAppId": "1400000000",
    "signName": "工学教育",
    "templateId": "123456",
    "region": "ap-guangzhou",
    "templateParamSet": ["{code}"]
  },
  "secret": {
    "secretScope": "sms",
    "secretKey": "tencent",
    "secretJson": {
      "secretId": "AKID...",
      "secretKey": "..."
    }
  }
}

微信/QQ 登录

微信小程序登录应由前端传 wx.login code 到 /api/auth/oauth/wechat-miniapp,后端调用微信 code2Session 换取 openid/session_key/unionid再落 user_identities

已实现微信小程序登录主链路:

  • 前端传 code 和可选 profile
  • 后端读取租户 wechat-miniapp / wechat_miniapp provider 配置。
  • appId 存在 config_publicappSecret 存在 app_private.tenant_secrets(secret_scope='oauth')
  • provider_subject 使用 appId:openid,避免不同小程序 openid 碰撞。
  • session_key 不返回前端,不写入公开 raw_profile;当前只记录 hasSessionKey 和更新时间标记。
  • 登录成功写 auth_login_events,并签发 tk_ session。

配置示例:

{
  "provider": "wechat-miniapp",
  "displayName": "微信小程序登录",
  "status": "active",
  "configPublic": {
    "appId": "wx...",
    "envVersion": "release"
  },
  "secret": {
    "secretScope": "oauth",
    "secretKey": "wechat-miniapp",
    "secretValue": "小程序 AppSecret"
  }
}

微信网页登录和 QQ 登录已按同样原则落地:前端只提交授权 code后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。响应不会返回 access_tokenrefresh_tokensession_key 或租户密钥。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 referral feature不继续堆在 auth 模块里。

微信网页登录配置示例:

{
  "provider": "wechat-web",
  "displayName": "微信网页登录",
  "status": "active",
  "configPublic": {
    "appId": "wx...",
    "endpoint": "https://api.weixin.qq.com/sns/oauth2/access_token",
    "userInfoEndpoint": "https://api.weixin.qq.com/sns/userinfo"
  },
  "secret": {
    "secretScope": "oauth",
    "secretKey": "wechat-web",
    "secretValue": "网站应用 AppSecret"
  }
}

QQ 登录配置示例:

{
  "provider": "qq-oauth",
  "displayName": "QQ登录",
  "status": "active",
  "configPublic": {
    "appId": "101xxxx",
    "redirectUri": "https://h5.example.com/auth/qq/callback",
    "endpoint": "https://graph.qq.com/oauth2.0/token",
    "openIdEndpoint": "https://graph.qq.com/oauth2.0/me",
    "userInfoEndpoint": "https://graph.qq.com/user/get_user_info"
  },
  "secret": {
    "secretScope": "oauth",
    "secretKey": "qq-oauth",
    "secretValue": "QQ互联 AppKey"
  }
}

前端调用:

POST /api/auth/oauth/wechat
body: { "code": "<wechat oauth code>", "lang": "zh_CN" }

POST /api/auth/oauth/qq
body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/qq/callback" }

生产注意:

  • endpointuserInfoEndpointopenIdEndpoint 默认使用官方 HTTPS 域名;生产环境不允许本地 HTTP fake endpoint。
  • appSecret/clientSecret/AppKey 必须存入 app_private.tenant_secrets(secret_scope='oauth'),不能进入 configPublic
  • QQ 的 redirectUri 必须与 QQ 互联后台登记的一致;多租户自定义域名上线前需要逐个配置或设计统一授权中转域名。
  • readiness:production:db 会阻断 OAuth endpoint/redirectUri 为 HTTP、localhost 或非官方 provider endpoint也会阻断 active/testing provider 缺少对应私密 tenant_secrets

手机号绑定/换绑

已实现 POST /api/auth/phone/bind。适用场景包括微信/QQ 登录后强制绑定手机号,以及个人中心更换手机号。

流程:

  1. 前端调用 POST /api/auth/sms/sendpurpose 必须是 bind_phone
  2. 前端在登录态下调用 POST /api/auth/phone/bind,提交 phone 和验证码。
  3. 后端校验当前 session/JWT 属于当前租户,不信任 x-user-id
  4. 后端校验手机号未被其它账号占用,成功后更新 platform_users.phoneuser_identities(provider='phone')
  5. 换绑成功会删除当前用户旧手机号 identity并撤销其它迁移期 tk_ session当前 session 保持可用。

前端不能用 login 用途验证码绑定手机号;接口会返回 PHONE_BIND_PURPOSE_REQUIRED。手机号已被其它账号占用时返回 PHONE_ALREADY_BOUND

支付 Provider

支付不走 Supabase 内置能力。推荐继续扩展 commerce

  • POST /api/commerce/orders 只负责创建订单,金额以后端套餐为准。
  • POST /api/commerce/payments/create 按 provider 创建支付参数或收银台地址。
  • POST /api/commerce/payments/notify/<provider> 统一落 payment_events,先验签、再幂等、再更新订单和权益。
  • 支付成功继续复用 grantSvipEntitlement,避免微信/支付宝/XPay 各写一套开通逻辑。

当前支持:

  • wechat_pay:微信支付 API v3 JSAPI 下单;通知验签后用 API v3 key 解密 resource
  • alipay:支付宝 WAP/H5 支付参数生成;通知按 RSA2 验签。
  • manual:仅本地/运营手工确认,不作为生产自动支付。

微信支付配置示例

{
  "provider": "wechat_pay",
  "mode": "tenant_collect",
  "status": "active",
  "configPublic": {
    "appId": "wx...",
    "merchantId": "1900000001",
    "merchantSerialNo": "商户证书序列号",
    "notifyUrl": "https://api.example.com/api/commerce/payments/notify/wechat_pay?tenantId=<tenantId>",
    "wechatpayPublicKey": "微信支付平台证书公钥或平台公钥"
  },
  "secret": {
    "secretScope": "payment",
    "secretKey": "wechat_pay",
    "secretJson": {
      "privateKey": "商户 API 证书私钥 PEM",
      "apiV3Key": "32位 API v3 key"
    }
  }
}

支付宝配置示例

{
  "provider": "alipay",
  "mode": "tenant_collect",
  "status": "active",
  "configPublic": {
    "appId": "2021000000000000",
    "notifyUrl": "https://api.example.com/api/commerce/payments/notify/alipay?tenantId=<tenantId>",
    "returnUrl": "https://h5.example.com/pay/success"
  },
  "secret": {
    "secretScope": "payment",
    "secretKey": "alipay",
    "secretJson": {
      "privateKey": "应用私钥 PEM",
      "alipayPublicKey": "支付宝公钥 PEM"
    }
  }
}

支付回调处理规则:

  • payment_events(provider,event_id) 幂等。
  • 通知验签/解密失败直接拒绝,不更新订单。
  • 通知金额必须等于后端订单金额。
  • 订单已支付时重复通知只返回幂等成功,不重复开通权益。
  • 支付成功事务内更新 orderspaymentspayment_eventsentitlements
  • 支付密钥只允许放在 app_private.tenant_secrets 或生产 KMS/Vault。
  • readiness:production:db 会阻断微信支付缺 appId/merchantId/merchantSerialNo/notifyUrl、支付宝缺 appId/notifyUrl、notifyUrl/returnUrl/退款回调为 HTTP 或 localhost、provider endpoint 非官方域名、公开配置混入 privateKey/apiV3Key/secret/token 等密钥字段。

资金对账

当前后端已提供租户级资金对账基础能力:

  • POST /api/commerce/reconciliation/preview:预览供应商账单行和本地订单/支付/退款的匹配结果。
  • POST /api/commerce/reconciliation/import:确认导入对账批次和明细,写入审计。
  • GET /api/commerce/reconciliation/batches:查询对账批次。
  • GET /api/commerce/reconciliation/items:查询逐行结果。
  • GET /api/commerce/reconciliation/anomalies:查询金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行等异常。
  • POST /api/commerce/reconciliation/provider-bills/request:创建微信/支付宝官方账单下载任务body 为 { provider, billDate, billType }
  • GET /api/commerce/reconciliation/provider-bills/jobs查看官方账单下载任务状态、下载域名、hash、行数和生成的对账批次 ID。
  • POST /api/commerce/reconciliation/issues/create:从异常明细创建差错工单,重复创建同一未关闭明细会返回现有工单。
  • GET /api/commerce/reconciliation/issues:按状态、严重级别、负责人、批次、订单号筛选差错工单。
  • POST /api/commerce/reconciliation/issues/status:执行 start/assign/resolve/ignore/escalate/reopen 状态流转。
  • GET /api/commerce/reconciliation/issues/events:查看工单创建、分配、处理、解决、忽略、重开等事件轨迹。
  • GET /api/commerce/operations/anomalies:聚合未关闭对账工单、失败官方账单任务、支付事件错误、长时间 pending 支付/退款,供租户财务/售后运营台使用。
  • GET /api/commerce/adjustment-vouchers:查询人工调整凭证。
  • POST /api/commerce/adjustment-vouchers:创建人工调整凭证,可关联差错工单、对账明细、订单、支付或退款。
  • POST /api/commerce/adjustment-vouchers/status:执行 submitted/approved/rejected/voided 复核流转。
  • GET /api/commerce/adjustment-vouchers/events:查看凭证事件轨迹。
  • GET /api/commerce/adjustment-vouchers/report:按日期输出凭证状态、类型、来源和日趋势统计。

权限点:

  • tenant:reconciliation:read:查看/预览对账。
  • tenant:reconciliation:write:导入对账批次、创建/处理差错工单。
  • tenant:reconciliation:review:审批、驳回或作废人工调整凭证。
  • tenant:reconciliation:download:创建微信/支付宝官方账单下载任务。

官方账单下载由 apps/worker --job provider-bills 执行。API 只创建 commerce_bill_download_jobs,不会在前端返回供应商 download_url、商户私钥、微信 API v3 key 或支付宝应用私钥。worker 使用租户 tenant_payment_accounts.config_public.secretRef 找到 app_private.tenant_secrets(secret_scope='payment'),在后端签名申请下载 URL校验微信返回的 hash_type/hash_value,解析 JSON/CSV/ZIP 账单后复用 importReconciliationBatch 写入 commerce_reconciliation_batches/itemssource='provider_download'

对账、差错工单和人工调整凭证只生成差异台账、处理记录、凭证附件引用和审计,不自动修改订单、支付、退款和权益。resolve/ignore 只是财务审核结论,例如 manual_adjustmentprovider_confirmedfalse_positive;调整凭证审批也只表示财务复核通过。最终落账仍必须走退款状态机、支付补偿、手工支付确认或后续专门的落账命令。真实生产账单格式抽样验收和前端财务操作台仍需要继续补。

B 端合作商年费、服务费、服务器资源费不走学生端 orders,而是走平台账务:

  • platform_saas_plans:平台售卖给合作商的 SaaS 套餐。
  • tenant_subscriptions:合作商当前订阅状态。
  • tenant_invoicestenant_invoice_items:合作商账单与明细。
  • tenant_invoice_payments:合作商账单收款记录。
  • tenant_usage_records:学生数、题量、存储等用量指标。

支持策略:

  • 平台代收:平台商户号收款,再给租户结算。
  • 租户自收:每个租户配置自己的商户号和密钥。
  • 服务商模式:平台服务商统一管理子商户。

真实接入前需要先明确微信支付、支付宝或聚合支付是否允许你们销售的题库会员形态,以及小程序端是否涉及虚拟支付限制。

本地开发可以先把这些配置留空或配置为 inactive。租户后台保存 provider 配置时只写非敏感公开字段和 secretRef;等云服务器域名、备案、官方应用/商户申请完成后,再在租户后台或平台后台写入真实密钥并执行生产联调。

需要准备的资料

  • 阿里云或腾讯云短信:签名、模板 ID、AccessKey/SecretKey、短信用途文案。
  • 微信小程序AppID、AppSecret、主体信息、合法域名、用户手机号授权能力。
  • 微信网页/公众号AppID、AppSecret、授权回调域名。
  • QQ 互联AppID、AppKey、回调域名。
  • 微信支付商户号、API v3 key、商户证书/平台证书、回调域名、AppID 绑定关系。
  • 支付宝AppID、应用私钥、支付宝公钥、回调地址、网页/手机网站/当面付产品开通情况。
  • 每个合作商租户的收款模式:平台代收、租户自收或服务商子商户。

参考资料