From ea932f27fbeb1b5f22ba92119f8db5ed1d4cd03e Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Wed, 26 Aug 2026 14:01:12 +0800 Subject: [PATCH 1/3] feat(avatar): rename Token display to points --- digital-avatar-app/backend/main.py | 8 ++++---- .../backend/services/token_billing.py | 6 +++--- digital-avatar-app/src/router/index.ts | 2 +- digital-avatar-app/src/views/AvatarManage.vue | 13 ++++++++----- digital-avatar-app/src/views/TokenCharge.vue | 10 +++++----- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/digital-avatar-app/backend/main.py b/digital-avatar-app/backend/main.py index 57beb2a..5b8349c 100644 --- a/digital-avatar-app/backend/main.py +++ b/digital-avatar-app/backend/main.py @@ -58,10 +58,10 @@ def seed(): db = SessionLocal() try: plan_specs = [ - {"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M Token"}, - {"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"}, - {"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"}, - {"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"}, + {"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"}, + {"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"}, + {"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"}, + {"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"}, ] for spec in plan_specs: plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first() diff --git a/digital-avatar-app/backend/services/token_billing.py b/digital-avatar-app/backend/services/token_billing.py index ab763a1..2d483c3 100644 --- a/digital-avatar-app/backend/services/token_billing.py +++ b/digital-avatar-app/backend/services/token_billing.py @@ -82,7 +82,7 @@ def reserve_avatar_tokens( ) -> TokenReservation: user = avatar_owner_user(db, avatar) if not user: - raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token") + raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分") account = get_or_create_account(db, user.id) reserved = estimate_request_tokens(messages, max_output_tokens) updated = ( @@ -95,7 +95,7 @@ def reserve_avatar_tokens( ) if updated != 1: db.rollback() - raise InsufficientTokensError("Token 余额不足,请充值后继续") + raise InsufficientTokensError("积分余额不足,请充值后继续") db.refresh(account) usage = TokenUsage( user_id=user.id, @@ -142,7 +142,7 @@ def settle_reservation( ) ) if updated != 1: - raise RuntimeError("Token 账户不存在") + raise RuntimeError("积分账户不存在") db.expire_all() account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first() record.prompt_tokens = prompt_tokens diff --git a/digital-avatar-app/src/router/index.ts b/digital-avatar-app/src/router/index.ts index 1edac7d..a2128d9 100644 --- a/digital-avatar-app/src/router/index.ts +++ b/digital-avatar-app/src/router/index.ts @@ -55,7 +55,7 @@ const routes: RouteRecordRaw[] = [ path: '/token/charge', name: 'TokenCharge', component: () => import('@/views/TokenCharge.vue'), - meta: { title: 'Token充值', requiresAuth: true } + meta: { title: '积分充值', requiresAuth: true } }, { path: '/avatar/card', diff --git a/digital-avatar-app/src/views/AvatarManage.vue b/digital-avatar-app/src/views/AvatarManage.vue index 28a5805..83a35ed 100644 --- a/digital-avatar-app/src/views/AvatarManage.vue +++ b/digital-avatar-app/src/views/AvatarManage.vue @@ -24,11 +24,11 @@ - -
+ +
- Token 余额 + 积分余额 {{ tokenBalance.toLocaleString() }} 累计使用 {{ tokenConsumed.toLocaleString() }}
@@ -56,7 +56,7 @@

{{ a.displayName || a.name }}

{{ statusText(a.status) }}

{{ a.description || '暂无描述' }}

- 累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token + 累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分
@@ -92,6 +92,9 @@ const router = useRouter() const avatarStore = useAvatarStore() const userStore = useUserStore() +// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。 +const SHOW_POINTS_BALANCE_CARD = false + // 当前登录会会用户的资料(头像 / 昵称) const me = computed(() => userStore.user) @@ -289,7 +292,7 @@ onMounted(() => { background: rgba(255, 255, 255, 0.3); } -/* Token 余额条 */ +/* 积分余额条 */ .token-section { padding: 16px 20px; } diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue index 5447e65..f518ad0 100644 --- a/digital-avatar-app/src/views/TokenCharge.vue +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -3,7 +3,7 @@ @@ -12,8 +12,8 @@
当前余额 {{ currentBalance.toLocaleString() }} - Token - 累计使用 {{ totalConsumed.toLocaleString() }} Token + 积分 + 累计使用 {{ totalConsumed.toLocaleString() }} 积分
@@ -30,7 +30,7 @@ >
{{ plan.badge }}
{{ formatTokenAmount(plan.amount) }}
-
Token
+
积分
¥{{ plan.price }}
{{ plan.desc }}
@@ -132,7 +132,7 @@ const doCharge = async () => { const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝' const res: any = await chargeToken(selectedPlan.value.id) currentBalance.value = res?.balance ?? currentBalance.value - alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`) + alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} 积分`) } catch (e) { alert('充值失败,请重试') } finally { -- 2.54.0 From 5360cac8ade095b6714efbef44f334c2eac781b5 Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Wed, 26 Aug 2026 14:02:38 +0800 Subject: [PATCH 2/3] fix(avatar): normalize legacy plan copy --- digital-avatar-app/src/views/TokenCharge.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue index f518ad0..bfa2525 100644 --- a/digital-avatar-app/src/views/TokenCharge.vue +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -32,7 +32,7 @@
{{ formatTokenAmount(plan.amount) }}
积分
¥{{ plan.price }}
-
{{ plan.desc }}
+
{{ formatPointsCopy(plan.desc) }}
@@ -125,6 +125,7 @@ const formatTokenAmount = (amount: number) => { if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M` return amount.toLocaleString() } +const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分') const doCharge = async () => { if (!selectedPlan.value || charging.value) return charging.value = true -- 2.54.0 From c37294be17002575cce07a6f938eb3d3960a59a9 Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Wed, 26 Aug 2026 14:41:21 +0800 Subject: [PATCH 3/3] feat(avatar): integrate Huihui payments --- digital-avatar-app/backend/models.py | 41 +++ digital-avatar-app/backend/routers/tokens.py | 285 +++++++++++++++++- .../backend/services/huihui_payment.py | 124 ++++++++ digital-avatar-app/backend/tests/conftest.py | 4 + .../backend/tests/test_huihui_payment.py | 50 +++ .../backend/tests/test_token_billing.py | 182 ++++++++++- .../docs/H5_PRODUCTION_DEPLOYMENT.md | 11 +- digital-avatar-app/src/api/index.ts | 28 +- digital-avatar-app/src/assets/alipay.svg | 4 + digital-avatar-app/src/assets/wechat-pay.svg | 4 + digital-avatar-app/src/utils/uniapp-bridge.ts | 32 +- digital-avatar-app/src/views/TokenCharge.vue | 232 +++++++++++++- uniapp-avatar/README.md | 4 + 13 files changed, 962 insertions(+), 39 deletions(-) create mode 100644 digital-avatar-app/backend/services/huihui_payment.py create mode 100644 digital-avatar-app/backend/tests/test_huihui_payment.py create mode 100644 digital-avatar-app/src/assets/alipay.svg create mode 100644 digital-avatar-app/src/assets/wechat-pay.svg diff --git a/digital-avatar-app/backend/models.py b/digital-avatar-app/backend/models.py index b4ae291..5376835 100644 --- a/digital-avatar-app/backend/models.py +++ b/digital-avatar-app/backend/models.py @@ -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): """会会用户 ↔ 本地用户体系映射(短信验证码登录落库)""" diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py index 3a447e9..bd23e41 100644 --- a/digital-avatar-app/backend/routers/tokens.py +++ b/digital-avatar-app/backend/routers/tokens.py @@ -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") diff --git a/digital-avatar-app/backend/services/huihui_payment.py b/digital-avatar-app/backend/services/huihui_payment.py new file mode 100644 index 0000000..3117fb7 --- /dev/null +++ b/digital-avatar-app/backend/services/huihui_payment.py @@ -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 diff --git a/digital-avatar-app/backend/tests/conftest.py b/digital-avatar-app/backend/tests/conftest.py index 53d78a8..ab45d48 100644 --- a/digital-avatar-app/backend/tests/conftest.py +++ b/digital-avatar-app/backend/tests/conftest.py @@ -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 ) diff --git a/digital-avatar-app/backend/tests/test_huihui_payment.py b/digital-avatar-app/backend/tests/test_huihui_payment.py new file mode 100644 index 0000000..fd1ffe1 --- /dev/null +++ b/digital-avatar-app/backend/tests/test_huihui_payment.py @@ -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 diff --git a/digital-avatar-app/backend/tests/test_token_billing.py b/digital-avatar-app/backend/tests/test_token_billing.py index 3860a9d..d3f1592 100644 --- a/digital-avatar-app/backend/tests/test_token_billing.py +++ b/digital-avatar-app/backend/tests/test_token_billing.py @@ -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() diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md index dad8c79..f694b22 100644 --- a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -39,6 +39,10 @@ HUIHUI_ACCESS_ID= HUIHUI_ACCESS_SECRET= HUIHUI_CLIENT_CODE= BOXIM_TIMEOUT_SECONDS=20 +HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3 +HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com +HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥> +HUIHUI_PAYMENT_TIMEOUT_SECONDS=30 DATABASE_URL=sqlite:////data/avatar.db UPLOAD_DIR=/data/uploads @@ -47,6 +51,8 @@ CHAT_MODEL_CONFIG_URL=http:///api/ai-models/runtime/digital-av 如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。 +积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。 + ## 3. 构建与发布 首次发布前备份数据: @@ -102,13 +108,14 @@ location /api/ { 1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。 2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。 -3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。 +3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。 4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。 5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。 -6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。 +6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。 7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。 8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。 9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。 +10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。 ## 6. 回滚 diff --git a/digital-avatar-app/src/api/index.ts b/digital-avatar-app/src/api/index.ts index 371155f..ebcbe83 100644 --- a/digital-avatar-app/src/api/index.ts +++ b/digital-avatar-app/src/api/index.ts @@ -146,6 +146,22 @@ export interface TokenUsageSummary { requestCount: number } +export interface TokenPaymentOrder { + id: string + orderNo: string + planId: string + paymentMethod: 'wechat' | 'alipay' + payType: 'WECHAT' | 'ALIPAY' + payWay: 'APP' | 'LITE' | 'JSAPI' + pointsAmount: number + price: number + status: 'pending' | 'paid' | 'failed' + providerStatus: string + payMessage: string + failureReason: string + balance: number +} + // 获取 Token 余额 export const getTokenBalance = () => request.get('/token/balance') @@ -154,9 +170,15 @@ export const getTokenBalance = () => export const getRechargePlans = () => request.get>('/token/plans') -// 执行充值 -export const chargeToken = (planId: string) => - request.post<{ balance: number; charged: number }>('/token/charge', { planId }) +// 创建会会支付订单;支付回调成功后积分到账。 +export const chargeToken = ( + planId: string, + paymentMethod: 'wechat' | 'alipay', + payScene: 'APP' | 'LITE' | 'JSAPI' +) => request.post('/token/charge', { planId, paymentMethod, payScene }) + +export const getTokenPaymentStatus = (orderId: string) => + request.get(`/token/payment/${orderId}`) // 按分身和使用场景汇总 Token 消耗 export const getTokenUsage = () => diff --git a/digital-avatar-app/src/assets/alipay.svg b/digital-avatar-app/src/assets/alipay.svg new file mode 100644 index 0000000..ab83b3a --- /dev/null +++ b/digital-avatar-app/src/assets/alipay.svg @@ -0,0 +1,4 @@ + + 支付宝 + + diff --git a/digital-avatar-app/src/assets/wechat-pay.svg b/digital-avatar-app/src/assets/wechat-pay.svg new file mode 100644 index 0000000..3c3e426 --- /dev/null +++ b/digital-avatar-app/src/assets/wechat-pay.svg @@ -0,0 +1,4 @@ + + 微信支付 + + diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts index 504ca13..2c17291 100644 --- a/digital-avatar-app/src/utils/uniapp-bridge.ts +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -4,6 +4,7 @@ // uni-webview bridge is bundled by main.ts; no external CDN is required. const BRIDGE_HANDLER = '__uniBridgeHandle__' +const nativeMessageHandlers = new Set<(message: any) => void>() export interface UniLaunchParams { token?: string @@ -25,7 +26,21 @@ function readParams(search: string, target: UniLaunchParams): void { // 是否运行在 uniapp web-view 环境中 export function isInUniWebView(): boolean { - return !!(window as any).uni?.webView + const runtime = window as any + const userAgent = navigator.userAgent + const isDCloudApp = Boolean( + runtime.plus || + runtime.__dcloud_weex_postMessage || + runtime.__dcloud_weex_ || + /uni-app|Html5Plus/i.test(userAgent) + ) + const isMiniProgram = Boolean( + (/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) || + runtime.my || + runtime.swan?.webView || + runtime.tt?.miniProgram + ) + return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram)) } // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) @@ -65,14 +80,18 @@ export function postToNative(message: Record): boolean { } // 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__) -export function onNativeMessage(handler: (message: any) => void): void { +export function onNativeMessage(handler: (message: any) => void): () => void { + nativeMessageHandlers.add(handler) ;(window as any)[BRIDGE_HANDLER] = (message: any) => { - try { - handler(message) - } catch (e) { - console.error('[uniBridge] handler error', e) + for (const listener of nativeMessageHandlers) { + try { + listener(message) + } catch (e) { + console.error('[uniBridge] handler error', e) + } } } + return () => nativeMessageHandlers.delete(handler) } // 便捷事件 @@ -81,5 +100,6 @@ export const UniEvents = { needLogin: () => postToNative({ type: 'needLogin' }), setTitle: (title: string) => postToNative({ type: 'setTitle', title }), navigate: (path: string) => postToNative({ type: 'navigate', path }), + payment: (payment: Record) => postToNative({ type: 'payment', payment }), back: () => postToNative({ type: 'back' }) } diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue index bfa2525..55eb2c4 100644 --- a/digital-avatar-app/src/views/TokenCharge.vue +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -46,7 +46,7 @@ :class="{ selected: paymentMethod === 'wechat' }" @click="paymentMethod = 'wechat'" > - 💚 + 微信支付 ✓ @@ -55,31 +55,41 @@ :class="{ selected: paymentMethod === 'alipay' }" @click="paymentMethod = 'alipay'" > - 💙 + 支付宝 ✓ +

{{ paymentNotice }}

@@ -334,7 +497,24 @@ onMounted(() => { } .payment-icon { - font-size: 24px; + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 11px; +} + +.payment-icon img { + width: 25px; + height: 25px; +} + +.wechat-icon { + background: #EAF9F0; +} + +.alipay-icon { + background: #EAF3FF; } .payment-name { @@ -357,6 +537,26 @@ onMounted(() => { font-weight: 600; } +.payment-notice { + margin: 12px 2px 0; + padding: 10px 12px; + border-radius: 10px; + color: #9A5B16; + background: #FFF5E8; + font-size: 13px; + line-height: 1.5; +} + +.payment-notice.success { + color: #08783E; + background: #EAF9F0; +} + +.payment-notice.error { + color: #B42318; + background: #FFF0EE; +} + /* 充值按钮 */ .checkout-section { padding: 0 20px; diff --git a/uniapp-avatar/README.md b/uniapp-avatar/README.md index dfb9a4b..e32a3bd 100644 --- a/uniapp-avatar/README.md +++ b/uniapp-avatar/README.md @@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用: | `needLogin` | — | token 失效,请求壳重新登录 | | `setTitle` | `title` | 设置原生导航栏标题 | | `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) | +| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` | | `back` | — | 请求原生返回 | ### 2.3 原生 → H5(壳主动推送) @@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用: | `context` | `platform, version` | 注入运行环境信息 | | `tokenRefresh` | `token` | 登录刷新后下发新 token | | `userUpdate` | `user` | 会会资料变更 | +| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` | > H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。 +壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。 + --- ## 3. 项目结构(uni CLI / src 布局,已验证可编译) -- 2.54.0