diff --git a/backend/app/api/endpoints/finance.py b/backend/app/api/endpoints/finance.py index ee6edbb..1581876 100644 --- a/backend/app/api/endpoints/finance.py +++ b/backend/app/api/endpoints/finance.py @@ -29,6 +29,30 @@ def summary(): db.close() +@router.get("/plans") +def token_plans(): + db = _session() + try: + return ApiResponse(data=finance_service.list_token_plans(db)) + finally: + db.close() + + +@router.patch("/plans/{plan_id}") +def update_token_plan(plan_id: str, body: dict = Body(...)): + db = _session() + try: + finance_service.update_token_plan( + db, plan_id, amount=int(body.get("amount") or 0), price=float(body.get("price") or 0), + name=str(body.get("name") or ""), badge=str(body.get("badge") or ""), desc=str(body.get("desc") or ""), + ) + return ApiResponse(message="充值套餐已更新") + except (ValueError, FinanceServiceError) as exc: + _raise(exc if isinstance(exc, FinanceServiceError) else FinanceServiceError("积分和价格格式不正确")) + finally: + db.close() + + @router.get("/orders") def orders( page: int = Query(1, ge=1), @@ -61,42 +85,6 @@ def update_order_status(order_no: str, body: dict = Body(...)): db.close() -@router.post("/orders/{order_no}/refund") -def request_refund(order_no: str, body: dict = Body(...)): - try: - data = finance_service.request_refund( - order_no, - reason=str(body.get("reason") or "").strip(), - operator=str(body.get("operator") or "后台管理员").strip(), - ) - return ApiResponse(data=data, message="退款申请已提交") - except FinanceServiceError as exc: - _raise(exc) - - -@router.get("/refunds") -def refunds( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - status: str = Query(""), -): - db = _session() - try: - total, items = finance_service.list_refunds(db, page=page, page_size=page_size, status=status) - return ApiResponse(data={"total": total, "page": page, "page_size": page_size, "items": items}) - finally: - db.close() - - -@router.post("/refunds/{refund_no}/confirm") -def confirm_refund(refund_no: str, body: dict = Body(...)): - try: - data = finance_service.confirm_refund(refund_no, body) - return ApiResponse(data=data, message="退款结果已登记") - except FinanceServiceError as exc: - _raise(exc) - - @router.get("/invoices") def invoices( page: int = Query(1, ge=1), diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py index 519629c..479dbc8 100644 --- a/backend/app/services/finance_service.py +++ b/backend/app/services/finance_service.py @@ -28,6 +28,23 @@ def _money(cents): class FinanceService: + @staticmethod + def list_token_plans(db): + rows = db.execute(text('SELECT id,name,amount,price,badge,"desc",virtual_product_id FROM token_plans ORDER BY price ASC')).fetchall() + return [dict(row._mapping) for row in rows] + + @staticmethod + def update_token_plan(db, plan_id: str, *, amount: int, price: float, name: str, badge: str, desc: str): + if amount <= 0 or price < 0 or not name.strip(): + raise FinanceServiceError("套餐名称、正数积分和非负价格不正确") + result = db.execute(text('''UPDATE token_plans SET name=:name,amount=:amount,price=:price,badge=:badge,"desc"=:desc WHERE id=:id'''), { + "id": plan_id, "name": name.strip()[:80], "amount": amount, "price": price, + "badge": badge.strip()[:80], "desc": desc.strip()[:200], + }) + if result.rowcount == 0: + raise FinanceServiceError("充值套餐不存在", 404) + db.commit() + @staticmethod def _tables_ready(db) -> bool: names = { diff --git a/digital-avatar-app/backend/models.py b/digital-avatar-app/backend/models.py index 2b5a0b7..dbf539d 100644 --- a/digital-avatar-app/backend/models.py +++ b/digital-avatar-app/backend/models.py @@ -395,6 +395,7 @@ class TokenPaymentOrder(Base): "price": self.price_cents / 100, "status": self.status, "provider": self.provider, + "providerOrderNo": self.provider_order_no, "providerStatus": self.provider_status, "payMessage": self.pay_message, "failureReason": self.failure_reason, diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py index ff4fbd4..4efa01d 100644 --- a/digital-avatar-app/backend/routers/tokens.py +++ b/digital-avatar-app/backend/routers/tokens.py @@ -25,6 +25,7 @@ from models import ( ) from responses import fail, ok from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError +from services.huihui_org_payment import HuihuiOrgPaymentClient from services.token_billing import get_or_create_account from services.wechat_virtual_payment import ( PAYMENT_EVENTS as WECHAT_PAYMENT_EVENTS, @@ -270,6 +271,7 @@ def _callback_amount_cents(payload) -> int | None: "orderAmt", "amount", "totalAmount", + "chargeAmount", ) if value in (None, ""): return None @@ -405,6 +407,49 @@ def charge(payload: dict = Body(...), authorization: str = Header(None), db: Ses return ok(_payment_payload(order, get_or_create_account(db, user.id))) +@router.post("/token/charge/org") +def charge_via_huihui_org(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): + user = _require_user(authorization, db) + plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first() + if not plan: + return fail("套餐不存在", 404) + method = str(payload.get("paymentMethod") or "").lower() + if method not in PAYMENT_METHODS: + return fail("支付方式不正确", 400) + scene = str(payload.get("payScene") or "APP").upper() + if scene not in PAYMENT_SCENES: + return fail("支付场景不正确", 400) + if scene == "LITE" and method != "wechat": + return fail("微信小程序仅支持微信支付", 400) + cents = _price_cents(plan.price) + try: + result = HuihuiOrgPaymentClient().create_order( + token=user.huihui_token, user_id=user.huihui_user_id, + name=user.nickname, phone=user.phone, avatar=user.avatar_url, + amount=f"{cents / 100:.2f}", + ) + except HuihuiPaymentError as exc: + return fail(str(exc), 502) + provider_no = str(result.get("orderNo") or "") + if not provider_no.isdigit() or int(result.get("payStatus") or 0) != 1 or int(result.get("chargeType") or 0) != 9: + return fail("会会返回的充值订单不正确", 502) + if _callback_amount_cents({"chargeAmount": result.get("chargeAmount")}) != cents: + return fail("会会返回的充值金额不匹配", 502) + 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=method, + pay_type=PAYMENT_METHODS[method], pay_way=scene, points_amount=plan.amount, + price_cents=cents, status="pending", provider="huihui_org", + provider_order_no=provider_no, provider_status="1", + ) + db.add(order) + db.commit() + result = _payment_payload(order, get_or_create_account(db, user.id)) + if "fat-open.99hui.com" in os.getenv("HUIHUI_ORG_BASE_URL", ""): + result["checkoutToken"] = user.huihui_token + return ok(result) + + @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) @@ -414,6 +459,7 @@ def payment_status(order_id: str, authorization: str = Header(None), db: Session ).first() if not order: return fail("支付订单不存在", 404) + # 会会组织支付以回调为准;查询此接口只返回本地订单,不向会会轮询查单。 if order.provider == "wechat_virtual" and order.status == "pending" and user.wechat_mp_openid: try: provider_data = query_wechat_virtual_order( @@ -560,6 +606,8 @@ def admin_request_refund( return fail("订单不存在", 404) if order.status != "paid" or order.refund_status not in {"", "none", "failed"}: return fail("该订单当前不可退款", 409) + if order.provider == "huihui_org": + return fail("会会组织订单的原路退款接口尚未接入,请先在会会后台处理", 409) account = get_or_create_account(db, order.user_id) if int(account.balance or 0) < int(order.points_amount or 0): return fail("该订单发放的积分已使用,不能执行全额退款", 409) diff --git a/digital-avatar-app/backend/services/huihui_org_payment.py b/digital-avatar-app/backend/services/huihui_org_payment.py new file mode 100644 index 0000000..c981b6f --- /dev/null +++ b/digital-avatar-app/backend/services/huihui_org_payment.py @@ -0,0 +1,48 @@ +"""Huihui organization token-recharge order API.""" +import os + +import httpx + +from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError + + +class HuihuiOrgPaymentClient(HuihuiPaymentClient): + def __init__(self): + super().__init__({ + "HUIHUI_PAYMENT_BASE_URL": os.getenv("HUIHUI_ORG_BASE_URL", ""), + "HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""), + "HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""), + "HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""), + }) + + def _request(self, method: str, path: str, *, token: str, user_id: str, body=None) -> dict: + if not self.configured or not token or not user_id: + raise HuihuiPaymentError("会会订单服务或登录凭证未配置") + try: + response = httpx.request( + method, f"{self.base_url}{path}", + headers={"Authorization": f"Bearer {token}", "appId": self.app_id}, + params=self._signed_params(user_id), json=body, + timeout=self.timeout, follow_redirects=True, + ) + except httpx.HTTPError as exc: + raise HuihuiPaymentError("会会订单服务连接失败") from exc + payload = self._json(response) + if response.status_code >= 400 or payload.get("code") not in (0, 200, "0", "200"): + raise HuihuiPaymentError(str(payload.get("message") or "会会订单请求失败")) + data = payload.get("data") + if not isinstance(data, dict): + raise HuihuiPaymentError("会会订单信息缺失") + return data + + def create_order(self, *, token: str, user_id: str, name: str, phone: str, + avatar: str, amount: str) -> dict: + body = {"chargeAmount": float(amount), + "commodityName": "数字分身积分充值", "userId": user_id, + "name": name or "", "phoneNumber": phone or "", "avatar": avatar or ""} + return self._request("POST", "/order/tokenRecharge", token=token, user_id=user_id, body=body) + + def get_order(self, *, token: str, user_id: str, order_no: str) -> dict: + if not order_no.isdigit(): + raise HuihuiPaymentError("会会订单号格式不正确") + return self._request("GET", f"/order/get/{order_no}", token=token, user_id=user_id) diff --git a/digital-avatar-app/backend/tests/test_token_billing.py b/digital-avatar-app/backend/tests/test_token_billing.py index d3f1592..ae9ba97 100644 --- a/digital-avatar-app/backend/tests/test_token_billing.py +++ b/digital-avatar-app/backend/tests/test_token_billing.py @@ -100,6 +100,37 @@ def test_charge_creates_huihui_order_without_early_points(authorization_context) assert "test-callback-secret-123456" not in callback_url +def test_org_recharge_only_credits_after_verified_provider_order(authorization_context): + seed() + context = authorization_context + _enable_huihui_payment_login(context) + provider_no = "261016111200090123456" + created_data = {"orderNo": provider_no, "chargeType": 9, + "chargeAmount": 10, "payStatus": 1} + with patch("routers.tokens.HuihuiOrgPaymentClient") as client_factory: + provider = client_factory.return_value + provider.create_order.return_value = created_data + created = client.post( + "/api/token/charge/org", headers=context["owner_headers"], + json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"}, + ).json()["data"] + assert created["providerOrderNo"] == provider_no + assert created["status"] == "pending" + assert created["balance"] == DEFAULT_TOKEN_GRANT + assert provider.create_order.call_args.kwargs["pay_type"] == 1 + + details = {**created_data, "payStatus": 2, + "userId": context["owner"].huihui_user_id} + provider.get_order.return_value = {**details, "chargeAmount": 9} + assert client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"]["status"] == "pending" + provider.get_order.return_value = details + paid = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"] + assert paid["status"] == "paid" + assert paid["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000 + duplicate = client.get(f"/api/token/payment/{created['id']}", headers=context["owner_headers"]).json()["data"] + assert duplicate["balance"] == paid["balance"] + + def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context): context = authorization_context _enable_huihui_payment_login(context) diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md index 7341b7f..2881fcb 100644 --- a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -1,5 +1,7 @@ # 数字分身 H5 生产接入与部署 +测试环境入口为 `https://fat-digital.99hui.com/`;测试支付收银台使用 `https://fat-open.99hui.com/huihui-h5/`,生产支付收银台使用 `https://99hui.com/huihui-h5/`。两套环境的订单和验签配置必须隔离,密钥只保存在各自后端配置中,不写入前端或仓库。 + ## 1. 接入方式 生产会会在用户已登录后打开以下地址: diff --git a/digital-avatar-app/src/api/index.ts b/digital-avatar-app/src/api/index.ts index a3926a9..0901ecf 100644 --- a/digital-avatar-app/src/api/index.ts +++ b/digital-avatar-app/src/api/index.ts @@ -156,7 +156,9 @@ export interface TokenPaymentOrder { pointsAmount: number price: number status: 'pending' | 'paid' | 'failed' | 'closed' | 'refunded' - provider: 'huihui' | 'wechat_virtual' + provider: 'huihui' | 'wechat_virtual' | 'huihui_org' + providerOrderNo?: string + checkoutToken?: string providerStatus: string payMessage: string failureReason: string @@ -196,6 +198,12 @@ export const chargeToken = ( payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI' ) => request.post('/token/charge', { planId, paymentMethod, payScene }) +export const chargeTokenOrg = ( + planId: string, + paymentMethod: 'wechat' | 'alipay', + payScene: 'APP' | 'H5' | 'LITE' | 'JSAPI' +) => request.post('/token/charge/org', { planId, paymentMethod, payScene }) + export const getTokenPaymentStatus = (orderId: string) => request.get(`/token/payment/${orderId}`) diff --git a/digital-avatar-app/src/main.ts b/digital-avatar-app/src/main.ts index c6910e9..ddd732d 100644 --- a/digital-avatar-app/src/main.ts +++ b/digital-avatar-app/src/main.ts @@ -15,6 +15,8 @@ app.use(pinia) // —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— const params = getLaunchParams() +if (params.evn !== undefined) sessionStorage.setItem('hh_launch_evn', params.evn) +else if (params.token) sessionStorage.removeItem('hh_launch_evn') const avatarStore = useAvatarStore(pinia) const userStore = useUserStore(pinia) diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts index 2c17291..7b78f59 100644 --- a/digital-avatar-app/src/utils/uniapp-bridge.ts +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -12,15 +12,16 @@ export interface UniLaunchParams { nickname?: string avatar?: string ts?: string + evn?: string } -const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts'] +const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'evn'] function readParams(search: string, target: UniLaunchParams): void { const sp = new URLSearchParams(search) for (const key of PARAM_KEYS) { const value = sp.get(key) - if (value) target[key] = value + if (value || (key === 'evn' && sp.has(key))) target[key] = value ?? '' } } diff --git a/digital-avatar-app/src/views/AvatarManage.vue b/digital-avatar-app/src/views/AvatarManage.vue index 2ec8398..4e005ec 100644 --- a/digital-avatar-app/src/views/AvatarManage.vue +++ b/digital-avatar-app/src/views/AvatarManage.vue @@ -20,15 +20,15 @@ - -
+ +
积分余额 {{ tokenBalance.toLocaleString() }} 累计使用 {{ tokenConsumed.toLocaleString() }}
- +
@@ -95,9 +95,6 @@ const avatarStore = useAvatarStore() const userStore = useUserStore() const isEmbedded = isHuihuiEmbeddedMode() -// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。 -const SHOW_POINTS_BALANCE_CARD = false - // 当前登录会会用户的资料(头像 / 昵称) const me = computed(() => userStore.user) diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue index 4a4c33e..a664d34 100644 --- a/digital-avatar-app/src/views/TokenCharge.vue +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -37,29 +37,7 @@
-
-

支付方式

-
-
- - 微信支付 - ✓ -
-
- - 支付宝 - ✓ -
-

{{ paymentNotice }}

@@ -67,8 +45,8 @@