feat(avatar): integrate Huihui payments
This commit is contained in:
@@ -311,6 +311,47 @@ class TokenPlan(Base):
|
||||
}
|
||||
|
||||
|
||||
class TokenPaymentOrder(Base):
|
||||
__tablename__ = "token_payment_orders"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
order_no = Column(String, nullable=False, unique=True, index=True)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
plan_id = Column(String, nullable=False)
|
||||
payment_method = Column(String, nullable=False)
|
||||
pay_type = Column(String, nullable=False)
|
||||
pay_way = Column(String, nullable=False)
|
||||
points_amount = Column(BigInteger, nullable=False)
|
||||
price_cents = Column(Integer, nullable=False)
|
||||
status = Column(String, nullable=False, default="pending", index=True)
|
||||
provider_order_id = Column(String, default="")
|
||||
provider_order_no = Column(String, default="")
|
||||
provider_status = Column(String, default="")
|
||||
pay_message = Column(Text, default="")
|
||||
failure_reason = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
paid_at = Column(DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"orderNo": self.order_no,
|
||||
"planId": self.plan_id,
|
||||
"paymentMethod": self.payment_method,
|
||||
"payType": self.pay_type,
|
||||
"payWay": self.pay_way,
|
||||
"pointsAmount": self.points_amount,
|
||||
"price": self.price_cents / 100,
|
||||
"status": self.status,
|
||||
"providerStatus": self.provider_status,
|
||||
"payMessage": self.pay_message,
|
||||
"failureReason": self.failure_reason,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"paidAt": _iso(self.paid_at),
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
from fastapi import APIRouter, Depends, Body, Header, HTTPException
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TokenAccount, TokenPlan, TokenUsage, User
|
||||
from responses import ok, fail
|
||||
from services.token_billing import get_or_create_account
|
||||
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||
from responses import fail, ok
|
||||
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
|
||||
|
||||
router = APIRouter(tags=["Token"])
|
||||
|
||||
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
|
||||
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
|
||||
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
|
||||
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
|
||||
|
||||
|
||||
def _require_user(authorization: str | None, db: Session) -> User:
|
||||
if not authorization:
|
||||
@@ -20,6 +35,97 @@ def _require_user(authorization: str | None, db: Session) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def _payment_client() -> HuihuiPaymentClient:
|
||||
return HuihuiPaymentClient({
|
||||
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
|
||||
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
|
||||
),
|
||||
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
||||
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
||||
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
||||
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
|
||||
})
|
||||
|
||||
|
||||
def _callback_url(order_no: str) -> str:
|
||||
base = os.getenv(
|
||||
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
|
||||
).rstrip("/")
|
||||
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
||||
if len(secret) < 16:
|
||||
raise HuihuiPaymentError("会会支付回调密钥未配置")
|
||||
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
|
||||
|
||||
|
||||
def _price_cents(price: float) -> int:
|
||||
return int(
|
||||
(Decimal(str(price)) * Decimal("100")).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
|
||||
return {**order.to_dict(), "balance": account.balance}
|
||||
|
||||
|
||||
def _nested_payload(value):
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text[:1] in ("{", "["):
|
||||
try:
|
||||
return _nested_payload(json.loads(text))
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_nested_payload(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _nested_payload(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _find_value(payload, *names):
|
||||
expected = {name.lower() for name in names}
|
||||
if isinstance(payload, dict):
|
||||
for key, value in payload.items():
|
||||
if key.lower() in expected and value not in (None, ""):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = _find_value(value, *names)
|
||||
if found not in (None, ""):
|
||||
return found
|
||||
elif isinstance(payload, list):
|
||||
for value in payload:
|
||||
found = _find_value(value, *names)
|
||||
if found not in (None, ""):
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _callback_amount_cents(payload) -> int | None:
|
||||
value = _find_value(
|
||||
payload,
|
||||
"actualAmt",
|
||||
"payAmt",
|
||||
"masterOrderAmt",
|
||||
"orderAmt",
|
||||
"amount",
|
||||
"totalAmount",
|
||||
)
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(
|
||||
(Decimal(str(value)) * Decimal("100")).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/token/balance")
|
||||
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
@@ -38,18 +144,177 @@ def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
# 积分只会在会会支付回调确认成功后到账。
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
plan_id = payload.get("planId")
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
acc = get_or_create_account(db, user.id)
|
||||
acc.balance += plan.amount
|
||||
acc.total_granted = int(acc.total_granted or 0) + plan.amount
|
||||
|
||||
payment_method = str(payload.get("paymentMethod") or "").lower()
|
||||
pay_type = PAYMENT_METHODS.get(payment_method)
|
||||
if not pay_type:
|
||||
return fail("请选择正确的支付方式", 400)
|
||||
pay_way = str(payload.get("payScene") or "APP").upper()
|
||||
if pay_way not in PAYMENT_SCENES:
|
||||
return fail("当前支付场景不受支持", 400)
|
||||
|
||||
cents = _price_cents(plan.price)
|
||||
order = TokenPaymentOrder(
|
||||
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
payment_method=payment_method,
|
||||
pay_type=pay_type,
|
||||
pay_way=pay_way,
|
||||
points_amount=plan.amount,
|
||||
price_cents=cents,
|
||||
status="pending",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||
|
||||
try:
|
||||
callback_url = _callback_url(order.order_no)
|
||||
except HuihuiPaymentError as exc:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(exc)
|
||||
db.commit()
|
||||
return fail(str(exc), 503)
|
||||
|
||||
try:
|
||||
result = _payment_client().create_payment(
|
||||
huihui_token=user.huihui_token,
|
||||
huihui_user_id=user.huihui_user_id,
|
||||
real_name=user.nickname,
|
||||
order_no=order.order_no,
|
||||
amount=f"{cents / 100:.2f}",
|
||||
points_amount=plan.amount,
|
||||
pay_type=pay_type,
|
||||
pay_way=pay_way,
|
||||
callback_url=callback_url,
|
||||
)
|
||||
except HuihuiPaymentError as exc:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(exc)[:500]
|
||||
db.commit()
|
||||
return fail(str(exc), 502)
|
||||
|
||||
db.refresh(order)
|
||||
if order.status != "paid":
|
||||
order.provider_order_id = str(result.get("orderId") or "")
|
||||
order.provider_order_no = str(result.get("orderNo") or "")
|
||||
order.provider_status = str(result.get("status") or "pending")
|
||||
message = result.get("payMessage") or ""
|
||||
order.pay_message = (
|
||||
json.dumps(message, ensure_ascii=False)
|
||||
if isinstance(message, (dict, list))
|
||||
else str(message)
|
||||
)
|
||||
if order.provider_status.upper() in FAILED_STATUSES:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
|
||||
db.commit()
|
||||
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
|
||||
@router.get("/token/payment/{order_id}")
|
||||
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
order = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order_id,
|
||||
TokenPaymentOrder.user_id == user.id,
|
||||
).first()
|
||||
if not order:
|
||||
return fail("支付订单不存在", 404)
|
||||
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||
|
||||
|
||||
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
|
||||
async def payment_callback(
|
||||
order_no: str,
|
||||
callback_signature: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
||||
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
||||
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return fail("支付回调格式不正确", 400)
|
||||
else:
|
||||
raw = (await request.body()).decode("utf-8", errors="replace")
|
||||
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
|
||||
payload = _nested_payload(payload)
|
||||
|
||||
payload_order_no = str(_find_value(
|
||||
payload,
|
||||
"masterOrderNo",
|
||||
"master_order_no",
|
||||
"orderNo",
|
||||
"order_no",
|
||||
"bizOrderNo",
|
||||
) or "").strip()
|
||||
if payload_order_no and payload_order_no != order_no:
|
||||
return fail("支付回调订单号不匹配", 422)
|
||||
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||
if not order:
|
||||
return fail("支付订单不存在", 404)
|
||||
if order.status == "paid":
|
||||
return ok({"received": True, "duplicate": True})
|
||||
|
||||
provider_status = str(_find_value(
|
||||
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
|
||||
) or "").upper()
|
||||
order.provider_status = provider_status
|
||||
if provider_status not in SUCCESS_STATUSES:
|
||||
if provider_status in FAILED_STATUSES:
|
||||
order.status = "failed"
|
||||
order.failure_reason = str(
|
||||
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
|
||||
)[:500]
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": False})
|
||||
|
||||
paid_cents = _callback_amount_cents(payload)
|
||||
if paid_cents is None or paid_cents != order.price_cents:
|
||||
order.failure_reason = "支付回调金额不匹配"
|
||||
db.commit()
|
||||
return fail("支付金额不匹配", 422)
|
||||
|
||||
updated = db.query(TokenPaymentOrder).filter(
|
||||
TokenPaymentOrder.id == order.id,
|
||||
TokenPaymentOrder.status != "paid",
|
||||
).update({
|
||||
TokenPaymentOrder.status: "paid",
|
||||
TokenPaymentOrder.provider_status: provider_status,
|
||||
TokenPaymentOrder.paid_at: datetime.utcnow(),
|
||||
TokenPaymentOrder.failure_reason: "",
|
||||
}, synchronize_session=False)
|
||||
if updated:
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
|
||||
if account is None:
|
||||
account = TokenAccount(
|
||||
user_id=order.user_id,
|
||||
balance=DEFAULT_TOKEN_GRANT,
|
||||
total_granted=DEFAULT_TOKEN_GRANT,
|
||||
total_consumed=0,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
account.balance = int(account.balance or 0) + order.points_amount
|
||||
account.total_granted = int(account.total_granted or 0) + order.points_amount
|
||||
db.commit()
|
||||
return ok({"received": True, "paid": True})
|
||||
|
||||
|
||||
@router.get("/token/usage")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Signed client for Huihui's production payment-v3 service."""
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class HuihuiPaymentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HuihuiPaymentClient:
|
||||
def __init__(self, config: dict):
|
||||
self.base_url = config.get(
|
||||
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
|
||||
).rstrip("/")
|
||||
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||
self.timeout = float(config.get("HUIHUI_PAYMENT_TIMEOUT_SECONDS", 30))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.app_id and self.access_id and self.access_secret)
|
||||
|
||||
def _signed_params(self, user_id: str) -> dict:
|
||||
params = {
|
||||
"appId": self.app_id,
|
||||
"accessId": self.access_id,
|
||||
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
||||
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
|
||||
"signType": "MD5",
|
||||
"signVersion": "1.0",
|
||||
"userId": user_id,
|
||||
}
|
||||
source = "&".join(
|
||||
f"{key}={params[key]}"
|
||||
for key in sorted(params)
|
||||
if params[key] not in (None, "", [])
|
||||
)
|
||||
source += f"&accessSecret={self.access_secret}"
|
||||
params["signature"] = hashlib.md5(source.encode("utf-8")).hexdigest().upper()
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _json(response: httpx.Response) -> dict:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HuihuiPaymentError("会会支付返回了无效响应") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise HuihuiPaymentError("会会支付返回格式不正确")
|
||||
return payload
|
||||
|
||||
def create_payment(
|
||||
self,
|
||||
*,
|
||||
huihui_token: str,
|
||||
huihui_user_id: str,
|
||||
real_name: str,
|
||||
order_no: str,
|
||||
amount: str,
|
||||
points_amount: int,
|
||||
pay_type: str,
|
||||
pay_way: str,
|
||||
callback_url: str,
|
||||
) -> dict[str, Any]:
|
||||
if not self.configured:
|
||||
raise HuihuiPaymentError("会会支付服务未配置")
|
||||
if not huihui_token or not huihui_user_id:
|
||||
raise HuihuiPaymentError("当前会会登录凭证无法发起支付")
|
||||
|
||||
now = datetime.now(_CN_TZ)
|
||||
body = {
|
||||
"appId": self.app_id,
|
||||
"callbackUrl": callback_url,
|
||||
"chargeType": 4,
|
||||
"currency": "cny",
|
||||
"description": f"充值 {points_amount} 积分",
|
||||
"expend": {},
|
||||
"masterOrderAmt": amount,
|
||||
"masterOrderNo": order_no,
|
||||
"memberId": huihui_user_id,
|
||||
"orderDesc": "数字分身积分充值",
|
||||
"orderTime": now.isoformat(),
|
||||
"orderTitle": "数字分身积分充值",
|
||||
"payAmt": float(amount),
|
||||
"payType": pay_type,
|
||||
"payWay": pay_way,
|
||||
"realName": real_name or "会会用户",
|
||||
"timeExpire": (now + timedelta(hours=2)).strftime("%Y%m%d%H%M%S"),
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {huihui_token}",
|
||||
"appId": self.app_id,
|
||||
"windowAppId": self.app_id,
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}/payment/pay",
|
||||
headers=headers,
|
||||
params=self._signed_params(huihui_user_id),
|
||||
json=body,
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HuihuiPaymentError("会会支付连接失败,请稍后重试") from exc
|
||||
|
||||
payload = self._json(response)
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
|
||||
raise HuihuiPaymentError(payload.get("message") or "会会支付下单失败")
|
||||
data = payload.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
raise HuihuiPaymentError("会会支付未返回订单信息")
|
||||
return data
|
||||
@@ -9,6 +9,7 @@ from models import (
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
TokenAccount,
|
||||
TokenPaymentOrder,
|
||||
TokenUsage,
|
||||
User,
|
||||
)
|
||||
@@ -110,6 +111,9 @@ def authorization_context():
|
||||
synchronize_session=False
|
||||
)
|
||||
user_ids = [owner.id, other.id]
|
||||
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services.huihui_payment import HuihuiPaymentClient
|
||||
|
||||
|
||||
def test_create_payment_uses_huihui_payment_v3_contract():
|
||||
client = HuihuiPaymentClient({
|
||||
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
|
||||
"HUIHUI_APP_ID": "app-id",
|
||||
"HUIHUI_ACCESS_ID": "access-id",
|
||||
"HUIHUI_ACCESS_SECRET": "access-secret",
|
||||
})
|
||||
response = Mock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {
|
||||
"code": 0,
|
||||
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
|
||||
}
|
||||
|
||||
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
|
||||
result = client.create_payment(
|
||||
huihui_token="user-token",
|
||||
huihui_user_id="user-id",
|
||||
real_name="测试用户",
|
||||
order_no="AV202608260001",
|
||||
amount="10.00",
|
||||
points_amount=2_000_000,
|
||||
pay_type="WECHAT",
|
||||
pay_way="APP",
|
||||
callback_url="https://digital.example/api/token/payment/callback/secret",
|
||||
)
|
||||
|
||||
assert result["orderId"] == "provider-id"
|
||||
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
|
||||
assert post.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer user-token",
|
||||
"appId": "app-id",
|
||||
"windowAppId": "app-id",
|
||||
}
|
||||
params = post.call_args.kwargs["params"]
|
||||
assert params["appId"] == "app-id"
|
||||
assert params["accessId"] == "access-id"
|
||||
assert params["userId"] == "user-id"
|
||||
assert params["signature"]
|
||||
assert "accessSecret" not in params
|
||||
body = post.call_args.kwargs["json"]
|
||||
assert body["payType"] == "WECHAT"
|
||||
assert body["payWay"] == "APP"
|
||||
assert body["masterOrderAmt"] == "10.00"
|
||||
assert body["payAmt"] == 10.0
|
||||
@@ -1,14 +1,17 @@
|
||||
import uuid
|
||||
import os
|
||||
import hashlib
|
||||
import hmac
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app, seed
|
||||
from models import Avatar, TokenAccount, TokenPlan, TokenUsage, User
|
||||
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||
from routers.chat import _resolve_reply, _stream_reply
|
||||
from services.token_billing import (
|
||||
DEFAULT_TOKEN_GRANT,
|
||||
@@ -22,6 +25,16 @@ from services.token_billing import (
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _enable_huihui_payment_login(context):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
user.huihui_token = f"huihui-payment-{context['suffix']}"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
|
||||
context = authorization_context
|
||||
owner = client.get("/api/token/balance", headers=context["owner_headers"])
|
||||
@@ -52,6 +65,171 @@ def test_seed_synchronizes_requested_recharge_plans():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_charge_creates_huihui_order_without_early_points(authorization_context):
|
||||
context = authorization_context
|
||||
_enable_huihui_payment_login(context)
|
||||
payment_client = Mock()
|
||||
payment_client.create_payment.return_value = {
|
||||
"orderId": "huihui-payment-id",
|
||||
"orderNo": "huihui-payment-no",
|
||||
"payMessage": {"mock": "payment-params"},
|
||||
"payType": "WECHAT",
|
||||
"paySubType": "APP",
|
||||
"status": "pending",
|
||||
}
|
||||
env = {
|
||||
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||
"HUIHUI_PAYMENT_CALLBACK_SECRET": "test-callback-secret-123456",
|
||||
}
|
||||
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||
response = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()["data"]
|
||||
assert result["status"] == "pending"
|
||||
assert result["payType"] == "WECHAT"
|
||||
assert result["payWay"] == "APP"
|
||||
assert result["balance"] == DEFAULT_TOKEN_GRANT
|
||||
assert payment_client.create_payment.call_args.kwargs["amount"] == "10.00"
|
||||
callback_url = payment_client.create_payment.call_args.kwargs["callback_url"]
|
||||
assert callback_url.startswith("https://digital.example/api/token/payment/callback/AV")
|
||||
assert "test-callback-secret-123456" not in callback_url
|
||||
|
||||
|
||||
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
|
||||
context = authorization_context
|
||||
_enable_huihui_payment_login(context)
|
||||
payment_client = Mock()
|
||||
payment_client.create_payment.return_value = {
|
||||
"orderId": "huihui-payment-id",
|
||||
"orderNo": "huihui-payment-no",
|
||||
"payMessage": "payment-message",
|
||||
"status": "pending",
|
||||
}
|
||||
secret = "test-callback-secret-123456"
|
||||
env = {
|
||||
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||
}
|
||||
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
|
||||
).json()["data"]
|
||||
callback_body = {
|
||||
"data": {
|
||||
"masterOrderNo": created["orderNo"],
|
||||
"status": "succeeded",
|
||||
"payAmt": "10.00",
|
||||
}
|
||||
}
|
||||
signature = hmac.new(
|
||||
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
callback_path = f"/api/token/payment/callback/{created['orderNo']}/{signature}"
|
||||
first = client.post(callback_path, json=callback_body)
|
||||
second = client.post(callback_path, json=callback_body)
|
||||
|
||||
assert first.json()["data"] == {"received": True, "paid": True}
|
||||
assert second.json()["data"] == {"received": True, "duplicate": True}
|
||||
status = client.get(
|
||||
f"/api/token/payment/{created['id']}", headers=context["owner_headers"]
|
||||
).json()["data"]
|
||||
assert status["status"] == "paid"
|
||||
assert status["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||
assert client.get(
|
||||
f"/api/token/payment/{created['id']}", headers=context["other_headers"]
|
||||
).json()["code"] == 404
|
||||
|
||||
|
||||
def test_callback_amount_mismatch_never_credits_points(authorization_context):
|
||||
context = authorization_context
|
||||
_enable_huihui_payment_login(context)
|
||||
payment_client = Mock()
|
||||
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
|
||||
secret = "test-callback-secret-123456"
|
||||
env = {
|
||||
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||
}
|
||||
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||
).json()["data"]
|
||||
signature = hmac.new(
|
||||
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
callback = client.post(
|
||||
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
|
||||
json={
|
||||
"masterOrderNo": created["orderNo"],
|
||||
"status": "success",
|
||||
"actualAmt": "9.99",
|
||||
},
|
||||
)
|
||||
|
||||
assert callback.json()["code"] == 422
|
||||
db = SessionLocal()
|
||||
try:
|
||||
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.id == created["id"]).one()
|
||||
account = get_or_create_account(db, context["owner"].id)
|
||||
assert order.status == "pending"
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_payment_callback_creates_missing_account_in_same_settlement(authorization_context):
|
||||
context = authorization_context
|
||||
_enable_huihui_payment_login(context)
|
||||
payment_client = Mock()
|
||||
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
|
||||
secret = "test-callback-secret-123456"
|
||||
env = {
|
||||
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||
}
|
||||
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||
created = client.post(
|
||||
"/api/token/charge",
|
||||
headers=context["owner_headers"],
|
||||
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
|
||||
).json()["data"]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
signature = hmac.new(
|
||||
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
callback = client.post(
|
||||
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
|
||||
json={
|
||||
"masterOrderNo": created["orderNo"],
|
||||
"status": "success",
|
||||
"payAmt": "10.00",
|
||||
},
|
||||
)
|
||||
|
||||
assert callback.json()["data"] == {"received": True, "paid": True}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||
assert account.total_granted == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
|
||||
suffix = uuid.uuid4().hex
|
||||
db = SessionLocal()
|
||||
|
||||
Reference in New Issue
Block a user