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, 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: raise HTTPException(status_code=401, detail="未登录") token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() user = db.query(User).filter(User.app_token == token).first() if not user: raise HTTPException(status_code=401, detail="会话无效或已过期") 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) acc = get_or_create_account(db, user.id) return ok({ "balance": acc.balance, "totalGranted": acc.total_granted, "totalConsumed": acc.total_consumed, }) @router.get("/token/plans") def plans(authorization: str = Header(None), db: Session = Depends(get_db)): _require_user(authorization, db) items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all() 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 = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() if not plan: return fail("套餐不存在", 404) 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() 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") def usage(authorization: str = Header(None), db: Session = Depends(get_db)): user = _require_user(authorization, db) rows = ( db.query( TokenUsage.avatar_id, TokenUsage.source, func.sum(TokenUsage.prompt_tokens), func.sum(TokenUsage.completion_tokens), func.sum(TokenUsage.total_tokens), func.count(TokenUsage.id), ) .filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed") .group_by(TokenUsage.avatar_id, TokenUsage.source) .all() ) return ok([ { "avatarId": avatar_id, "source": source, "promptTokens": int(prompt_tokens or 0), "completionTokens": int(completion_tokens or 0), "totalTokens": int(total_tokens or 0), "requestCount": int(request_count or 0), } for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows ])