Merge pull request 'feat(avatar): 接入会会支付并统一积分展示' (#7) from codex/avatar-token-copy-to-points-20260826 into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -58,10 +58,10 @@ def seed():
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
plan_specs = [
|
plan_specs = [
|
||||||
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 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 Token"},
|
{"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 Token"},
|
{"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 Token"},
|
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
|
||||||
]
|
]
|
||||||
for spec in plan_specs:
|
for spec in plan_specs:
|
||||||
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
|
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
|
||||||
|
|||||||
@@ -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):
|
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 import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import TokenAccount, TokenPlan, TokenUsage, User
|
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||||
from responses import ok, fail
|
from responses import fail, ok
|
||||||
from services.token_billing import get_or_create_account
|
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||||
|
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
|
||||||
|
|
||||||
router = APIRouter(tags=["Token"])
|
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:
|
def _require_user(authorization: str | None, db: Session) -> User:
|
||||||
if not authorization:
|
if not authorization:
|
||||||
@@ -20,6 +35,97 @@ def _require_user(authorization: str | None, db: Session) -> User:
|
|||||||
return 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")
|
@router.get("/token/balance")
|
||||||
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
user = _require_user(authorization, 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])
|
return ok([p.to_dict() for p in items])
|
||||||
|
|
||||||
|
|
||||||
|
# 积分只会在会会支付回调确认成功后到账。
|
||||||
@router.post("/token/charge")
|
@router.post("/token/charge")
|
||||||
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
user = _require_user(authorization, db)
|
user = _require_user(authorization, db)
|
||||||
plan_id = payload.get("planId")
|
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
|
||||||
if not plan:
|
if not plan:
|
||||||
return fail("套餐不存在", 404)
|
return fail("套餐不存在", 404)
|
||||||
acc = get_or_create_account(db, user.id)
|
|
||||||
acc.balance += plan.amount
|
payment_method = str(payload.get("paymentMethod") or "").lower()
|
||||||
acc.total_granted = int(acc.total_granted or 0) + plan.amount
|
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()
|
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")
|
@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
|
||||||
@@ -82,7 +82,7 @@ def reserve_avatar_tokens(
|
|||||||
) -> TokenReservation:
|
) -> TokenReservation:
|
||||||
user = avatar_owner_user(db, avatar)
|
user = avatar_owner_user(db, avatar)
|
||||||
if not user:
|
if not user:
|
||||||
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
|
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
|
||||||
account = get_or_create_account(db, user.id)
|
account = get_or_create_account(db, user.id)
|
||||||
reserved = estimate_request_tokens(messages, max_output_tokens)
|
reserved = estimate_request_tokens(messages, max_output_tokens)
|
||||||
updated = (
|
updated = (
|
||||||
@@ -95,7 +95,7 @@ def reserve_avatar_tokens(
|
|||||||
)
|
)
|
||||||
if updated != 1:
|
if updated != 1:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise InsufficientTokensError("Token 余额不足,请充值后继续")
|
raise InsufficientTokensError("积分余额不足,请充值后继续")
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
usage = TokenUsage(
|
usage = TokenUsage(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -142,7 +142,7 @@ def settle_reservation(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if updated != 1:
|
if updated != 1:
|
||||||
raise RuntimeError("Token 账户不存在")
|
raise RuntimeError("积分账户不存在")
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
||||||
record.prompt_tokens = prompt_tokens
|
record.prompt_tokens = prompt_tokens
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from models import (
|
|||||||
TakeoverMessage,
|
TakeoverMessage,
|
||||||
TakeoverReplyTask,
|
TakeoverReplyTask,
|
||||||
TokenAccount,
|
TokenAccount,
|
||||||
|
TokenPaymentOrder,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -110,6 +111,9 @@ def authorization_context():
|
|||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
user_ids = [owner.id, other.id]
|
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(
|
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
|
||||||
synchronize_session=False
|
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 uuid
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from threading import Barrier
|
from threading import Barrier
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from main import app, seed
|
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 routers.chat import _resolve_reply, _stream_reply
|
||||||
from services.token_billing import (
|
from services.token_billing import (
|
||||||
DEFAULT_TOKEN_GRANT,
|
DEFAULT_TOKEN_GRANT,
|
||||||
@@ -22,6 +25,16 @@ from services.token_billing import (
|
|||||||
client = TestClient(app)
|
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):
|
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
|
||||||
context = authorization_context
|
context = authorization_context
|
||||||
owner = client.get("/api/token/balance", headers=context["owner_headers"])
|
owner = client.get("/api/token/balance", headers=context["owner_headers"])
|
||||||
@@ -52,6 +65,171 @@ def test_seed_synchronizes_requested_recharge_plans():
|
|||||||
db.close()
|
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():
|
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
|
||||||
suffix = uuid.uuid4().hex
|
suffix = uuid.uuid4().hex
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ HUIHUI_ACCESS_ID=<production-access-id>
|
|||||||
HUIHUI_ACCESS_SECRET=<production-access-secret>
|
HUIHUI_ACCESS_SECRET=<production-access-secret>
|
||||||
HUIHUI_CLIENT_CODE=<production-client-code>
|
HUIHUI_CLIENT_CODE=<production-client-code>
|
||||||
BOXIM_TIMEOUT_SECONDS=20
|
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
|
DATABASE_URL=sqlite:////data/avatar.db
|
||||||
UPLOAD_DIR=/data/uploads
|
UPLOAD_DIR=/data/uploads
|
||||||
@@ -47,6 +51,8 @@ CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-av
|
|||||||
|
|
||||||
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
|
如生产 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. 构建与发布
|
## 3. 构建与发布
|
||||||
|
|
||||||
首次发布前备份数据:
|
首次发布前备份数据:
|
||||||
@@ -102,13 +108,14 @@ location /api/ {
|
|||||||
|
|
||||||
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
|
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
|
||||||
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
|
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
|
||||||
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
|
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
|
||||||
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
|
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
|
||||||
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||||
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
|
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
||||||
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
|
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
|
||||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
||||||
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||||
|
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||||
|
|
||||||
## 6. 回滚
|
## 6. 回滚
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,22 @@ export interface TokenUsageSummary {
|
|||||||
requestCount: number
|
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 余额
|
// 获取 Token 余额
|
||||||
export const getTokenBalance = () =>
|
export const getTokenBalance = () =>
|
||||||
request.get<TokenBalance>('/token/balance')
|
request.get<TokenBalance>('/token/balance')
|
||||||
@@ -154,9 +170,15 @@ export const getTokenBalance = () =>
|
|||||||
export const getRechargePlans = () =>
|
export const getRechargePlans = () =>
|
||||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||||
|
|
||||||
// 执行充值
|
// 创建会会支付订单;支付回调成功后积分到账。
|
||||||
export const chargeToken = (planId: string) =>
|
export const chargeToken = (
|
||||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
planId: string,
|
||||||
|
paymentMethod: 'wechat' | 'alipay',
|
||||||
|
payScene: 'APP' | 'LITE' | 'JSAPI'
|
||||||
|
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||||
|
|
||||||
|
export const getTokenPaymentStatus = (orderId: string) =>
|
||||||
|
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||||
|
|
||||||
// 按分身和使用场景汇总 Token 消耗
|
// 按分身和使用场景汇总 Token 消耗
|
||||||
export const getTokenUsage = () =>
|
export const getTokenUsage = () =>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>支付宝</title>
|
||||||
|
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>微信支付</title>
|
||||||
|
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -55,7 +55,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/token/charge',
|
path: '/token/charge',
|
||||||
name: 'TokenCharge',
|
name: 'TokenCharge',
|
||||||
component: () => import('@/views/TokenCharge.vue'),
|
component: () => import('@/views/TokenCharge.vue'),
|
||||||
meta: { title: 'Token充值', requiresAuth: true }
|
meta: { title: '积分充值', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/avatar/card',
|
path: '/avatar/card',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||||
|
|
||||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||||
|
const nativeMessageHandlers = new Set<(message: any) => void>()
|
||||||
|
|
||||||
export interface UniLaunchParams {
|
export interface UniLaunchParams {
|
||||||
token?: string
|
token?: string
|
||||||
@@ -25,7 +26,21 @@ function readParams(search: string, target: UniLaunchParams): void {
|
|||||||
|
|
||||||
// 是否运行在 uniapp web-view 环境中
|
// 是否运行在 uniapp web-view 环境中
|
||||||
export function isInUniWebView(): boolean {
|
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 / 会会用户)
|
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||||
@@ -65,15 +80,19 @@ export function postToNative(message: Record<string, any>): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
// 原生 → 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) => {
|
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||||
|
for (const listener of nativeMessageHandlers) {
|
||||||
try {
|
try {
|
||||||
handler(message)
|
listener(message)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[uniBridge] handler error', e)
|
console.error('[uniBridge] handler error', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return () => nativeMessageHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
// 便捷事件
|
// 便捷事件
|
||||||
export const UniEvents = {
|
export const UniEvents = {
|
||||||
@@ -81,5 +100,6 @@ export const UniEvents = {
|
|||||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||||
|
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
|
||||||
back: () => postToNative({ type: 'back' })
|
back: () => postToNative({ type: 'back' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Token 余额条 -->
|
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
|
||||||
<section class="token-section">
|
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||||
<div class="token-card">
|
<div class="token-card">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
<span class="token-label">Token 余额</span>
|
<span class="token-label">积分余额</span>
|
||||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||||
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<div class="avatar-details">
|
<div class="avatar-details">
|
||||||
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
||||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||||
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
|
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-actions">
|
<div class="avatar-actions">
|
||||||
@@ -92,6 +92,9 @@ const router = useRouter()
|
|||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
|
|
||||||
// 当前登录会会用户的资料(头像 / 昵称)
|
// 当前登录会会用户的资料(头像 / 昵称)
|
||||||
const me = computed(() => userStore.user)
|
const me = computed(() => userStore.user)
|
||||||
|
|
||||||
@@ -289,7 +292,7 @@ onMounted(() => {
|
|||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Token 余额条 */
|
/* 积分余额条 */
|
||||||
.token-section {
|
.token-section {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">Token 充值</h1>
|
<h1 class="page-title">积分充值</h1>
|
||||||
<div class="header-right"></div>
|
<div class="header-right"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
<div class="balance-card">
|
<div class="balance-card">
|
||||||
<span class="balance-label">当前余额</span>
|
<span class="balance-label">当前余额</span>
|
||||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||||
<span class="balance-unit">Token</span>
|
<span class="balance-unit">积分</span>
|
||||||
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
|
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
>
|
>
|
||||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||||
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
|
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
|
||||||
<div class="plan-unit">Token</div>
|
<div class="plan-unit">积分</div>
|
||||||
<div class="plan-price">¥{{ plan.price }}</div>
|
<div class="plan-price">¥{{ plan.price }}</div>
|
||||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
<div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
:class="{ selected: paymentMethod === 'wechat' }"
|
:class="{ selected: paymentMethod === 'wechat' }"
|
||||||
@click="paymentMethod = 'wechat'"
|
@click="paymentMethod = 'wechat'"
|
||||||
>
|
>
|
||||||
<span class="payment-icon">💚</span>
|
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
|
||||||
<span class="payment-name">微信支付</span>
|
<span class="payment-name">微信支付</span>
|
||||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,31 +55,41 @@
|
|||||||
:class="{ selected: paymentMethod === 'alipay' }"
|
:class="{ selected: paymentMethod === 'alipay' }"
|
||||||
@click="paymentMethod = 'alipay'"
|
@click="paymentMethod = 'alipay'"
|
||||||
>
|
>
|
||||||
<span class="payment-icon">💙</span>
|
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
|
||||||
<span class="payment-name">支付宝</span>
|
<span class="payment-name">支付宝</span>
|
||||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 充值按钮 -->
|
<!-- 充值按钮 -->
|
||||||
<section class="checkout-section">
|
<section class="checkout-section">
|
||||||
<button
|
<button
|
||||||
class="checkout-btn"
|
class="checkout-btn"
|
||||||
:class="{ disabled: !selectedPlan }"
|
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
|
||||||
:disabled="!selectedPlan"
|
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
|
||||||
@click="doCharge"
|
@click="doCharge"
|
||||||
>
|
>
|
||||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
{{ checkoutLabel }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
import {
|
||||||
|
chargeToken,
|
||||||
|
getRechargePlans,
|
||||||
|
getTokenBalance,
|
||||||
|
getTokenPaymentStatus,
|
||||||
|
type TokenPaymentOrder
|
||||||
|
} from '@/api'
|
||||||
|
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||||
|
import wechatPayIcon from '@/assets/wechat-pay.svg'
|
||||||
|
import alipayIcon from '@/assets/alipay.svg'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -101,6 +111,12 @@ const selectedPlan = ref<any>(null)
|
|||||||
|
|
||||||
// 支付方式
|
// 支付方式
|
||||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||||
|
const paymentNotice = ref('')
|
||||||
|
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
||||||
|
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
let pollDeadline = 0
|
||||||
|
let removeNativeListener: (() => void) | undefined
|
||||||
|
|
||||||
// 从后端加载余额与套餐
|
// 从后端加载余额与套餐
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -119,22 +135,144 @@ const loadData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行充值(写入后端)
|
// 会会支付订单创建与到账确认
|
||||||
const charging = ref(false)
|
const charging = ref(false)
|
||||||
const formatTokenAmount = (amount: number) => {
|
const formatTokenAmount = (amount: number) => {
|
||||||
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
||||||
return amount.toLocaleString()
|
return amount.toLocaleString()
|
||||||
}
|
}
|
||||||
|
const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分')
|
||||||
|
|
||||||
|
const checkoutLabel = computed(() => {
|
||||||
|
if (!selectedPlan.value) return '请选择充值套餐'
|
||||||
|
if (charging.value) return '正在创建支付订单…'
|
||||||
|
if (pendingOrderId.value) return '等待支付结果…'
|
||||||
|
return `立即支付 ¥${selectedPlan.value.price}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const payScene = () => {
|
||||||
|
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||||
|
return 'APP' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePayMessage = (message: string) => {
|
||||||
|
const text = (message || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findPaymentUrl = (value: any): string => {
|
||||||
|
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
|
||||||
|
if (!value || typeof value !== 'object') return ''
|
||||||
|
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
|
||||||
|
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
|
||||||
|
const invoke = () => {
|
||||||
|
const bridge = (window as any).WeixinJSBridge
|
||||||
|
if (!bridge?.invoke) {
|
||||||
|
reject(new Error('当前微信环境无法拉起支付'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
|
||||||
|
const message = String(result?.err_msg || result?.errMsg || '')
|
||||||
|
if (/\bok\b/i.test(message)) resolve()
|
||||||
|
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ((window as any).WeixinJSBridge) invoke()
|
||||||
|
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const launchPayment = async (order: TokenPaymentOrder) => {
|
||||||
|
const parsed = parsePayMessage(order.payMessage)
|
||||||
|
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
|
||||||
|
await invokeWechatJsapi(parsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isInUniWebView() && UniEvents.payment({
|
||||||
|
orderId: order.id,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
paymentMethod: order.paymentMethod,
|
||||||
|
payType: order.payType,
|
||||||
|
payWay: order.payWay,
|
||||||
|
payMessage: order.payMessage,
|
||||||
|
paymentParams: parsed
|
||||||
|
})) return
|
||||||
|
|
||||||
|
const paymentUrl = findPaymentUrl(parsed)
|
||||||
|
if (paymentUrl) {
|
||||||
|
window.location.assign(paymentUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('请在会会 App 内完成支付')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPendingOrder = () => {
|
||||||
|
pendingOrderId.value = ''
|
||||||
|
sessionStorage.removeItem('hh_pending_payment_order')
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollPayment = async () => {
|
||||||
|
if (!pendingOrderId.value) return
|
||||||
|
try {
|
||||||
|
const order = await getTokenPaymentStatus(pendingOrderId.value)
|
||||||
|
if (order.status === 'paid') {
|
||||||
|
currentBalance.value = order.balance
|
||||||
|
paymentNoticeTone.value = 'success'
|
||||||
|
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (order.status === 'failed') {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询支付结果失败', error)
|
||||||
|
}
|
||||||
|
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
|
||||||
|
else {
|
||||||
|
paymentNoticeTone.value = 'pending'
|
||||||
|
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = (orderId: string) => {
|
||||||
|
pendingOrderId.value = orderId
|
||||||
|
sessionStorage.setItem('hh_pending_payment_order', orderId)
|
||||||
|
pollDeadline = Date.now() + 2 * 60 * 1000
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
void pollPayment()
|
||||||
|
}
|
||||||
|
|
||||||
const doCharge = async () => {
|
const doCharge = async () => {
|
||||||
if (!selectedPlan.value || charging.value) return
|
if (!selectedPlan.value || charging.value) return
|
||||||
charging.value = true
|
charging.value = true
|
||||||
|
paymentNotice.value = ''
|
||||||
try {
|
try {
|
||||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
|
||||||
const res: any = await chargeToken(selectedPlan.value.id)
|
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
||||||
currentBalance.value = res?.balance ?? currentBalance.value
|
paymentNoticeTone.value = 'pending'
|
||||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
||||||
} catch (e) {
|
startPolling(order.id)
|
||||||
alert('充值失败,请重试')
|
await launchPayment(order)
|
||||||
|
} catch (error: any) {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||||
|
clearPendingOrder()
|
||||||
} finally {
|
} finally {
|
||||||
charging.value = false
|
charging.value = false
|
||||||
}
|
}
|
||||||
@@ -146,7 +284,33 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
void loadData()
|
||||||
|
removeNativeListener = onNativeMessage((message) => {
|
||||||
|
if (message?.type !== 'paymentResult') return
|
||||||
|
if (message.orderId && message.orderId !== pendingOrderId.value) return
|
||||||
|
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = '支付已取消'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (String(message.status).toLowerCase() === 'failed') {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = message.message || '支付未完成,请重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void pollPayment()
|
||||||
|
})
|
||||||
|
if (pendingOrderId.value) {
|
||||||
|
paymentNotice.value = '正在确认上一笔支付结果'
|
||||||
|
startPolling(pendingOrderId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
removeNativeListener?.()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -333,7 +497,24 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.payment-icon {
|
.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 {
|
.payment-name {
|
||||||
@@ -356,6 +537,26 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
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 {
|
.checkout-section {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用:
|
|||||||
| `needLogin` | — | token 失效,请求壳重新登录 |
|
| `needLogin` | — | token 失效,请求壳重新登录 |
|
||||||
| `setTitle` | `title` | 设置原生导航栏标题 |
|
| `setTitle` | `title` | 设置原生导航栏标题 |
|
||||||
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
||||||
|
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
|
||||||
| `back` | — | 请求原生返回 |
|
| `back` | — | 请求原生返回 |
|
||||||
|
|
||||||
### 2.3 原生 → H5(壳主动推送)
|
### 2.3 原生 → H5(壳主动推送)
|
||||||
@@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用:
|
|||||||
| `context` | `platform, version` | 注入运行环境信息 |
|
| `context` | `platform, version` | 注入运行环境信息 |
|
||||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||||
| `userUpdate` | `user` | 会会资料变更 |
|
| `userUpdate` | `user` | 会会资料变更 |
|
||||||
|
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
|
||||||
|
|
||||||
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
> 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 布局,已验证可编译)
|
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||||
|
|||||||
Reference in New Issue
Block a user