Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5df72956d | ||
|
|
48f3b9bddf |
@@ -2,6 +2,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
@@ -753,6 +754,83 @@ async def payment_callback(
|
|||||||
return ok({"received": True, "paid": True})
|
return ok({"received": True, "paid": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token/payment/huihui/virtual/notify")
|
||||||
|
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
|
||||||
|
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()
|
||||||
|
if len(secret) < 32:
|
||||||
|
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
|
||||||
|
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
|
||||||
|
signature = request.headers.get("x-huihui-signature", "").strip().lower()
|
||||||
|
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
try:
|
||||||
|
issued_at = int(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
if abs(int(time.time()) - issued_at) > 300:
|
||||||
|
raise HTTPException(status_code=401, detail="callback timestamp expired")
|
||||||
|
body = await request.body()
|
||||||
|
if not body or len(body) > 65536:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback body")
|
||||||
|
expected = hmac.new(
|
||||||
|
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(signature, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||||
|
|
||||||
|
order_no = str(payload.get("orderNo") or "").strip()
|
||||||
|
payment_no = str(payload.get("paymentNo") or "").strip()
|
||||||
|
status = str(payload.get("status") or "").upper()
|
||||||
|
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
|
||||||
|
raise HTTPException(status_code=400, detail="missing callback fields")
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||||
|
if not order or order.provider != "wechat_virtual":
|
||||||
|
raise HTTPException(status_code=404, detail="virtual payment order not found")
|
||||||
|
if status == "FAILED":
|
||||||
|
return ok({"received": True, "paid": False})
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == order.user_id).first()
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
|
||||||
|
try:
|
||||||
|
expected_product_id = product_id_for_plan(plan) if plan else ""
|
||||||
|
amount_cents = int(payload.get("amountCents"))
|
||||||
|
callback_env = int(payload.get("env"))
|
||||||
|
except (TypeError, ValueError, WechatVirtualPaymentError):
|
||||||
|
raise HTTPException(status_code=422, detail="invalid virtual payment details")
|
||||||
|
if (
|
||||||
|
isinstance(payload.get("amountCents"), bool)
|
||||||
|
or isinstance(payload.get("env"), bool)
|
||||||
|
or not user
|
||||||
|
or not user.wechat_mp_openid
|
||||||
|
or str(payload.get("openId") or "") != user.wechat_mp_openid
|
||||||
|
or not expected_product_id
|
||||||
|
or str(payload.get("productId") or "") != expected_product_id
|
||||||
|
or amount_cents != order.price_cents
|
||||||
|
or callback_env != wechat_virtual_env()
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
|
||||||
|
existing_payment = db.query(PaymentTransaction).filter(
|
||||||
|
PaymentTransaction.provider == "wechat_virtual",
|
||||||
|
PaymentTransaction.event_type == "payment",
|
||||||
|
PaymentTransaction.transaction_no == payment_no,
|
||||||
|
).first()
|
||||||
|
if existing_payment and existing_payment.order_no != order_no:
|
||||||
|
raise HTTPException(status_code=409, detail="payment number belongs to another order")
|
||||||
|
if order.status in {"paid", "refunded"}:
|
||||||
|
return ok({"received": True, "duplicate": True})
|
||||||
|
|
||||||
|
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
|
||||||
|
db.commit()
|
||||||
|
return ok({"received": True, "paid": True})
|
||||||
|
|
||||||
|
|
||||||
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
||||||
code = 0 if success else 1
|
code = 0 if success else 1
|
||||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
|
|||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _huihui_virtual_headers(secret, body, timestamp=None):
|
||||||
|
timestamp = str(timestamp if timestamp is not None else int(time.time()))
|
||||||
|
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
|
||||||
|
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context):
|
||||||
|
seed()
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||||
|
user.wechat_mp_openid = "openid-huihui-callback"
|
||||||
|
user.wechat_mp_session_key = "session-huihui-callback"
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
|
||||||
|
plan.virtual_product_id = "points_plan_1"
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
secret = "huihui-virtual-callback-test-secret-123456"
|
||||||
|
env = {
|
||||||
|
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||||
|
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||||
|
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||||
|
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
|
||||||
|
).json()["data"]
|
||||||
|
payload = {
|
||||||
|
"orderNo": created["orderNo"],
|
||||||
|
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"amountCents": 1000,
|
||||||
|
"productId": "points_plan_1",
|
||||||
|
"openId": "openid-huihui-callback",
|
||||||
|
"env": 1,
|
||||||
|
}
|
||||||
|
path = "/api/token/payment/huihui/virtual/notify"
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).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()
|
||||||
|
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
|
||||||
|
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||||
|
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||||
|
|
||||||
|
assert first.json()["data"] == {"received": True, "paid": True}
|
||||||
|
assert second.json()["data"] == {"received": True, "duplicate": True}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||||
|
assert order.status == "paid"
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _signature(token, timestamp, nonce):
|
def _signature(token, timestamp, nonce):
|
||||||
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
|
|||||||
|
|
||||||
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `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` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
||||||
|
|
||||||
|
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
|
||||||
|
|
||||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||||
|
|
||||||
## 3. 构建与发布
|
## 3. 构建与发布
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 会会微信小程序虚拟支付回调(待双方确认)
|
||||||
|
|
||||||
|
本项目为会会服务端预留固定回调地址:
|
||||||
|
|
||||||
|
`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`
|
||||||
|
|
||||||
|
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
|
||||||
|
|
||||||
|
请求体为 JSON,成功支付示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderNo": "AV202610150000000000000001",
|
||||||
|
"paymentNo": "会会侧唯一支付流水号",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"amountCents": 1000,
|
||||||
|
"productId": "points_plan_1",
|
||||||
|
"openId": "微信小程序用户 OpenID",
|
||||||
|
"env": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
|
||||||
|
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。
|
||||||
|
|
||||||
|
成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。
|
||||||
|
|
||||||
|
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||||
Reference in New Issue
Block a user