Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5df72956d | ||
|
|
48f3b9bddf | ||
|
|
2122615725 | ||
|
|
cd0c6e3162 |
@@ -2,6 +2,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
@@ -753,6 +754,83 @@ async def payment_callback(
|
||||
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 = ""):
|
||||
code = 0 if success else 1
|
||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
|
||||
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):
|
||||
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 均不得下发前端或写日志。
|
||||
|
||||
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
|
||||
|
||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||
|
||||
## 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。
|
||||
|
||||
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||
@@ -54,7 +54,7 @@
|
||||
</template>
|
||||
<template v-else>{{ message.content }}</template>
|
||||
</div>
|
||||
<div v-if="message.source || message.references?.length" class="message-source">
|
||||
<div v-if="sourceLabel(message.source) || message.references?.length" class="message-source">
|
||||
{{ sourceLabel(message.source) }}
|
||||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||||
</div>
|
||||
@@ -174,7 +174,8 @@ const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
vision: '图片理解',
|
||||
qwen: '智能回答',
|
||||
// qwen 来源不再在聊天气泡下显示“智能回答/知能回答”标注。
|
||||
qwen: '',
|
||||
public: ''
|
||||
}
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||
|
||||
Reference in New Issue
Block a user