Compare commits

..
5 changed files with 15 additions and 16 deletions
+2 -3
View File
@@ -455,7 +455,6 @@ def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header
db.add(order) db.add(order)
db.commit() db.commit()
result = _payment_payload(order, get_or_create_account(db, user.id)) result = _payment_payload(order, get_or_create_account(db, user.id))
if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""):
result["checkoutToken"] = user.huihui_token result["checkoutToken"] = user.huihui_token
return ok(result) return ok(result)
@@ -1087,12 +1086,12 @@ async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(
callback_env = int(payload.get("env")) callback_env = int(payload.get("env"))
except (TypeError, ValueError, WechatVirtualPaymentError): except (TypeError, ValueError, WechatVirtualPaymentError):
raise HTTPException(status_code=422, detail="invalid virtual payment details") raise HTTPException(status_code=422, detail="invalid virtual payment details")
callback_open_id = str(payload.get("openId") or "").strip()
if ( if (
isinstance(payload.get("amountCents"), bool) isinstance(payload.get("amountCents"), bool)
or isinstance(payload.get("env"), bool) or isinstance(payload.get("env"), bool)
or not user or not user
or not user.wechat_mp_openid or (callback_open_id and callback_open_id != user.wechat_mp_openid)
or str(payload.get("openId") or "") != user.wechat_mp_openid
or not expected_product_id or not expected_product_id
or str(payload.get("productId") or "") != expected_product_id or str(payload.get("productId") or "") != expected_product_id
or amount_cents != order.price_cents or amount_cents != order.price_cents
@@ -163,7 +163,7 @@ def test_org_recharge_only_credits_after_verified_provider_order(authorization_c
provider.get_order.assert_not_called() provider.get_order.assert_not_called()
def test_org_recharge_does_not_return_checkout_token_outside_test_environment(authorization_context): def test_org_recharge_returns_checkout_token_for_production_cashier(authorization_context):
seed() seed()
context = authorization_context context = authorization_context
_enable_huihui_payment_login(context) _enable_huihui_payment_login(context)
@@ -180,7 +180,7 @@ def test_org_recharge_does_not_return_checkout_token_outside_test_environment(au
) )
assert response.status_code == 200 assert response.status_code == 200
assert "checkoutToken" not in response.json()["data"] assert response.json()["data"]["checkoutToken"] == f"huihui-payment-{context['suffix']}"
def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context): def test_expired_pending_payment_orders_are_closed_without_touching_recent_orders(authorization_context):
@@ -55,7 +55,6 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
"status": "SUCCESS", "status": "SUCCESS",
"amountCents": 1000, "amountCents": 1000,
"productId": "Basic", "productId": "Basic",
"openId": "openid-huihui-callback",
"env": 1, "env": 1,
} }
path = "/api/token/payment/huihui/virtual/notify" path = "/api/token/payment/huihui/virtual/notify"
@@ -64,6 +63,8 @@ def test_huihui_virtual_callback_requires_signature_and_exact_order_details(auth
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401 assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode() wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422 assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
wrong_openid = json.dumps({**payload, "openId": "different-user"}, separators=(",", ":")).encode()
assert client.post(path, content=wrong_openid, headers=_huihui_virtual_headers(secret, wrong_openid)).status_code == 422
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
@@ -91,7 +91,7 @@ CHAT_ATTACHMENT_CLEANUP_MINUTES=60
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。 `EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&evn=<入口环境>`;测试环境额外透传会会登录 token,生产环境不传。`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。 当前 H5 积分充值调用 `/api/token/charge/org` 创建会会组织充值订单。数字分身后端请求会会 `/order/tokenRecharge` 时,把所选套餐 ID 作为字符串 `productId` 传入(`Basic`、`Standard`、`Professional`、`Enterprise`),再用会会返回的订单号打开 `#/payOrder?id=<会会订单号>&isOrgUrl=true&token=<会会登录 token>&evn=<入口环境>`。测试和生产收银台都需要此 token 查询订单金额;`id` 是收银台查询订单的编号,不能替换为套餐/道具 ID。普通微信 H5 走 JSAPI,App 和其他 H5 沿用会会普通收银台。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。 微信小程序虚拟支付的服务端接口为:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数;其中 `productId` 直接采用套餐 ID(`Basic`、`Standard`、`Professional`、`Enterprise`)。小程序原生支付页仍需调用 `wx.requestVirtualPayment`,不能仅打开上述 H5 收银台 URL 来完成虚拟支付。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
@@ -1,13 +1,12 @@
# 会会支付回调(待双方确认) # 会会组织充值支付回调
本项目为会会服务端预留两条固定回调地址: 当前 H5 收银台组织充值订单,对会会提供的测试环境回调地址只有:
- 会会组织充值订单(当前 H5 收银台):`POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`(测试环境) `POST https://fat-digital.99hui.com/api/token/payment/huihui/org/notify`
- 微信直连虚拟支付订单:`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`(生产环境示例)
测试环境兼容提示:如果会会仍将上述组织充值订单发往 `/api/token/payment/huihui/virtual/notify`,而该环境未配置独立的虚拟支付密钥,旧地址会使用组织充值密钥验签并走相同的组织订单复核流程。该兼容入口不会处理微信直连虚拟支付订单;新接入仍应使用 `/org/notify`,生产环境不依赖此兼容行为。 其他历史接收路由仅供服务端兼容旧请求,不作为提供给会会的回调地址,也不应配置为新订单的通知地址。
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,会会服务端必须按此格式发送才能完成联调。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。 回调报文及签名协议仍须按双方联调结果确认。以下是本项目实现的接收契约;接口部署与完整支付联调通过是两个独立状态。微信直连虚拟支付回调属于另一条支付链路,不使用本文的组织充值回调地址。
请求体为 JSON,成功支付示例: 请求体为 JSON,成功支付示例:
@@ -21,12 +20,12 @@
} }
``` ```
`amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。对于微信直连订单,仍需 `openId` 和 `env`(0 正式、1 沙箱)一致。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。 `amountCents` 单位为分。对于经 `/order/tokenRecharge` 创建的会会组织订单,`orderNo` 可以是会会订单号或数字分身 `AV` 订单号;成功通知必须提供 `amountCents`、`productId` 和 `paymentNo`,无需 `openId`、`env`。服务端会再次向会会查单,要求订单号、支付流水、商品 ID、用户、收费类型、金额一致,且会会支付状态为已付款,才发放积分。非成功结果可发送 `status: "FAILED"`,但不会入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
请求头: 请求头:
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。 - `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符),微信直连虚拟支付订单使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。 - `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。组织充值订单使用服务端配置的 `HUIHUI_ORG_CALLBACK_SECRET`(至少 16 字符)。不要重排 JSON 后签名,也不要把密钥发给前端。
成功通知经签名及本地订单信息核对后,先持久保存并立即返回 HTTP 200、`{"received":true,"paid":false,"awaitingProvider":true}`;回调入口**不等待会会查单**,也**不立即发放积分**。后台仅针对已保存的通知做有上限的延迟复核,待会会查单确认已支付、订单号与支付流水等字段一致后才入账。已入账订单的重复通知返回 `duplicate: true`,不会重复发放积分。验签、字段、订单或金额不符返回 HTTP 4xx。服务端未配置密钥时返回 HTTP 503。 成功通知经签名及本地订单信息核对后,先持久保存并立即返回 HTTP 200、`{"received":true,"paid":false,"awaitingProvider":true}`;回调入口**不等待会会查单**,也**不立即发放积分**。后台仅针对已保存的通知做有上限的延迟复核,待会会查单确认已支付、订单号与支付流水等字段一致后才入账。已入账订单的重复通知返回 `duplicate: true`,不会重复发放积分。验签、字段、订单或金额不符返回 HTTP 4xx。服务端未配置密钥时返回 HTTP 503。