From a610c22f4508ae3796eb5a7c16e2ffabf0e1b548 Mon Sep 17 00:00:00 2001 From: Pine Date: Sun, 30 Aug 2026 22:32:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(compute):=20=E7=AE=97=E5=8A=9B=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=85=85=E5=80=BC(=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=A0=81=E6=94=AF=E4=BB=98)=E4=B8=8E=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PineAgents 供应商按登录态注入 JWT(登出移除) - 本地后端新增充值转发(packages/orders/pay-params/status/orders)与算力余额路由 - console 充值弹窗:套餐/自定义金额→小程序码→轮询到账;余额卡加充值入口;i18n 文案 Co-Authored-By: Claude --- console/src/api/modules/auth.ts | 10 ++ console/src/locales/zh.json | 11 ++ console/src/pages/portal/Opc/Compute.tsx | 7 +- .../src/pages/portal/Opc/RechargeModal.tsx | 124 ++++++++++++++++++ src/pineagents/app/routers/opc.py | 38 ++++++ src/pineagents/providers/provider_manager.py | 27 ++++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 console/src/pages/portal/Opc/RechargeModal.tsx diff --git a/console/src/api/modules/auth.ts b/console/src/api/modules/auth.ts index e86ba4f..e946b34 100644 --- a/console/src/api/modules/auth.ts +++ b/console/src/api/modules/auth.ts @@ -327,6 +327,16 @@ export const authApi = { // ---- 计量:余额 / 本月用量(按模型) ---- getOpcComputeBalance: () => request<{ id: number; quota: number; used_quota: number }>("/opc/compute/balance"), getOpcComputeUsage: () => request<{ month: string; cost_quota: number; input_tokens: number; output_tokens: number; items: { model_name: string; requests: number; input_tokens: number; output_tokens: number; cost_quota: number }[] }>("/opc/compute/usage"), + // ---- 算力充值(微信支付,native 扫码) ---- + getOpcRechargePackages: () => request<{ enabled: boolean; items: { id: string; amount: number; bonus: number; label: string; quota_micro: number }[]; min_yuan: number; max_yuan: number }>("/opc/compute/recharge/packages"), + // client_type="mp":桌面端展示小程序码,微信扫码在小程序内完成支付(与扫码登录同构) + createOpcRechargeOrder: (body: { client_type: "mp" | "native" | "jsapi"; package_id?: string; amount_yuan?: number }) => + request<{ order_no: string; amount_fen: number; quota_micro: number; bonus_quota_micro: number; status: string; code_url?: string; qr_image?: string; pay_params?: Record; expires_at: string }>("/opc/compute/recharge/orders", { method: "POST", body: JSON.stringify(body) }), + getOpcRechargePayParams: (orderNo: string) => + request<{ order: { order_no: string; amount_fen: number; quota_micro: number; status: string }; pay_params: Record | null; finished: boolean }>(`/opc/compute/recharge/orders/${orderNo}/pay-params`), + getOpcRechargeStatus: (orderNo: string) => + request<{ order_no: string; status: string; paid: boolean; credited: boolean; amount_fen: number; quota_micro: number; transaction_id: string; expires_at: string }>(`/opc/compute/recharge/orders/${orderNo}/status`, { method: "POST", body: "{}" }), + getOpcRechargeOrders: () => request<{ items: { order_no: string; amount_fen: number; quota_micro: number; status: string; created_at: string; paid_at: string }[] }>("/opc/compute/recharge/orders"), // ---- 各业务端口工作台 ---- getEnterpriseDashboard: () => request("/enterprise/dashboard"), diff --git a/console/src/locales/zh.json b/console/src/locales/zh.json index 5775d41..50d948b 100644 --- a/console/src/locales/zh.json +++ b/console/src/locales/zh.json @@ -3758,6 +3758,17 @@ "computeRequests": "请求", "computeCost": "费用", "computeNoUsage": "本月暂无用量,使用模型后显示", + "computeRecharge": "充值", + "computeRechargeTitle": "算力充值(微信扫码 · 小程序支付)", + "computeRechargePackages": "充值套餐", + "computeRechargeCustom": "自定义金额(元)", + "computeRechargePay": "生成小程序支付码", + "computeRechargeScan": "请使用微信扫码,在小程序中确认并完成支付,余额自动到账", + "computeRechargeSuccess": "充值成功,算力已到账", + "computeRechargeFailed": "下单失败", + "computeRechargeDisabled": "支付暂未配置,请联系运营", + "computeRechargeDone": "我已完成支付", + "computeRechargeBonus": "送 {{bonus}} 元", "profile": "个人资料", "username": "用户名", "nickname": "昵称", diff --git a/console/src/pages/portal/Opc/Compute.tsx b/console/src/pages/portal/Opc/Compute.tsx index 7fbad49..2b06f41 100644 --- a/console/src/pages/portal/Opc/Compute.tsx +++ b/console/src/pages/portal/Opc/Compute.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button, Card, Col, List, Row, Space, Table, Tag, Typography, message } from "antd"; import { authApi } from "../../../api/modules/auth"; import { RequireRole } from "../../../auth/guards"; +import RechargeModal from "./RechargeModal"; import styles from "./OpcList.module.less"; interface Token { id: number; name: string; key: string; created_time: number } @@ -19,6 +20,7 @@ export default function OpcCompute() { const [balance, setBalance] = useState<{ quota: number; used_quota: number } | null>(null); const [usage, setUsage] = useState<{ month: string; cost_quota: number; input_tokens: number; output_tokens: number; items: any[] } | null>(null); const [loading, setLoading] = useState(false); + const [rechargeOpen, setRechargeOpen] = useState(false); const load = useCallback(() => { authApi.getOpcComputeBase().then(setBase).catch(() => {}); @@ -86,7 +88,8 @@ export default function OpcCompute() { /> - + setRechargeOpen(true)}>{t("role.opc.computeRecharge")}}>
{t("role.opc.computeBalance")}
¥{((balance?.quota ?? 0) / 1000000).toFixed(4)}
@@ -131,6 +134,8 @@ export default function OpcCompute() {
+ + setRechargeOpen(false)} onPaid={load} /> ); diff --git a/console/src/pages/portal/Opc/RechargeModal.tsx b/console/src/pages/portal/Opc/RechargeModal.tsx new file mode 100644 index 0000000..700d1d4 --- /dev/null +++ b/console/src/pages/portal/Opc/RechargeModal.tsx @@ -0,0 +1,124 @@ +// 算力充值对话框:套餐/自定义金额 → 展示小程序码 → 微信扫码在小程序内完成支付 → 轮询到账 +// (与「小程序扫码登录」同构:桌面出码,支付能力统一由小程序 wx.requestPayment 承载) +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Image, InputNumber, Modal, Radio, Space, Tag, Typography, message } from "antd"; +import { authApi } from "../../../api/modules/auth"; + +interface Pkg { id: string; amount: number; bonus: number; label: string; quota_micro: number } +interface Packages { enabled: boolean; items: Pkg[]; min_yuan: number; max_yuan: number } + +const MICRO = 1_000_000; + +export default function RechargeModal({ open, onClose, onPaid }: { + open: boolean; + onClose: () => void; + onPaid: () => void; +}) { + const { t } = useTranslation(); + const [pkgs, setPkgs] = useState(null); + const [pkgId, setPkgId] = useState(""); + const [custom, setCustom] = useState(null); + const [order, setOrder] = useState<{ order_no: string; qr_image: string; quota_micro: number } | null>(null); + const [busy, setBusy] = useState(false); + const timer = useRef | null>(null); + + const loadPkgs = () => { + authApi.getOpcRechargePackages() + .then((r) => { setPkgs(r); setPkgId(r.items?.[0]?.id ?? ""); }) + .catch(() => setPkgs({ enabled: false, items: [], min_yuan: 1, max_yuan: 5000 })); + }; + + const stopPoll = () => { if (timer.current) { clearInterval(timer.current); timer.current = null; } }; + + const poll = (orderNo: string) => { + stopPoll(); + timer.current = setInterval(async () => { + try { + const st = await authApi.getOpcRechargeStatus(orderNo); + if (st.paid) { + stopPoll(); + message.success(t("role.opc.computeRechargeSuccess")); + setOrder(null); + onPaid(); + onClose(); + } + } catch { /* 忽略瞬时错误,下一轮重试 */ } + }, 2000); + }; + + useEffect(() => { + if (open) { loadPkgs(); } else { stopPoll(); setOrder(null); setCustom(null); } + return stopPoll; + }, [open]); + + const submit = async () => { + if (!pkgId && !(custom && custom > 0)) return; + setBusy(true); + try { + const body = pkgId + ? { client_type: "mp" as const, package_id: pkgId } + : { client_type: "mp" as const, amount_yuan: custom ?? 0 }; + const res = await authApi.createOpcRechargeOrder(body); + if (!res.qr_image) throw new Error("小程序码生成失败,请稍后重试"); + setOrder(res); + poll(res.order_no); + } catch (err) { + message.error(err instanceof Error ? err.message : t("role.opc.computeRechargeFailed")); + } finally { setBusy(false); } + }; + + const min = pkgs?.min_yuan ?? 1; + const max = pkgs?.max_yuan ?? 5000; + + return ( + { stopPoll(); setOrder(null); onClose(); }} + footer={order ? [ + , + ] : [ + , + , + ]} + > + {pkgs && !pkgs.enabled && ( + {t("role.opc.computeRechargeDisabled")} + )} + {!order ? ( + <> + {pkgs?.items?.length ? ( + <> + {t("role.opc.computeRechargePackages")} + { setPkgId(e.target.value); setCustom(null); }} style={{ width: "100%" }}> + + {pkgs.items.map((p) => ( + + ¥{p.amount}{p.bonus > 0 ? {t("role.opc.computeRechargeBonus", { bonus: p.bonus })} : null} + + ))} + + + + ) : null} +
+ {t("role.opc.computeRechargeCustom")}({min} ~ {max}) + { setCustom(v); setPkgId(""); }} style={{ width: 200 }} /> +
+ + ) : ( +
+ + {t("role.opc.computeRechargeScan")} + {order.order_no} +
+ ¥{(order.quota_micro / MICRO).toFixed(2)} +
+
+ )} +
+ ); +} diff --git a/src/pineagents/app/routers/opc.py b/src/pineagents/app/routers/opc.py index c758e85..486784b 100644 --- a/src/pineagents/app/routers/opc.py +++ b/src/pineagents/app/routers/opc.py @@ -291,6 +291,16 @@ async def opc_compute_prices(request: Request): return await forward("GET", "/opc/compute/prices", auth_header=_auth(request)) +@router.get("/opc/compute/balance") +async def opc_compute_balance(request: Request): + return await forward("GET", "/opc/compute/balance", auth_header=_auth(request)) + + +@router.get("/opc/compute/usage") +async def opc_compute_usage(request: Request): + return await forward("GET", "/opc/compute/usage", auth_header=_auth(request)) + + @router.get("/opc/compute/tokens") async def opc_compute_tokens(request: Request): return await forward("GET", "/opc/compute/tokens", auth_header=_auth(request)) @@ -306,6 +316,34 @@ async def opc_compute_tokens_delete(token_id: int, request: Request): return await forward("DELETE", f"/opc/compute/tokens/{token_id}", auth_header=_auth(request)) +# --------------------------------------------------------------------------- +# 算力充值(转发 server-core /opc/compute/recharge;微信回调不经本地后端,直连公网) +# --------------------------------------------------------------------------- +@router.get("/opc/compute/recharge/packages") +async def opc_compute_recharge_packages(request: Request): + return await forward("GET", "/opc/compute/recharge/packages", auth_header=_auth(request)) + + +@router.post("/opc/compute/recharge/orders") +async def opc_compute_recharge_create(req: dict, request: Request): + return await forward("POST", "/opc/compute/recharge/orders", body=req, auth_header=_auth(request)) + + +@router.get("/opc/compute/recharge/orders/{order_no}/pay-params") +async def opc_compute_recharge_pay_params(order_no: str, request: Request): + return await forward("GET", f"/opc/compute/recharge/orders/{order_no}/pay-params", auth_header=_auth(request)) + + +@router.post("/opc/compute/recharge/orders/{order_no}/status") +async def opc_compute_recharge_status(order_no: str, request: Request): + return await forward("POST", f"/opc/compute/recharge/orders/{order_no}/status", body={}, auth_header=_auth(request)) + + +@router.get("/opc/compute/recharge/orders") +async def opc_compute_recharge_orders(request: Request): + return await forward("GET", "/opc/compute/recharge/orders", auth_header=_auth(request)) + + # --------------------------------------------------------------------------- # 各业务端口工作台 # --------------------------------------------------------------------------- diff --git a/src/pineagents/providers/provider_manager.py b/src/pineagents/providers/provider_manager.py index cde7588..9a20be3 100644 --- a/src/pineagents/providers/provider_manager.py +++ b/src/pineagents/providers/provider_manager.py @@ -1400,6 +1400,9 @@ class ProviderManager: # pylint: disable=too-many-public-methods logger.warning("Failed to migrate legacy providers: %s", e) self._init_from_storage() self._apply_default_annotations() + # PineAgents(算力中心)中继按登录用户归户计量:注入本地持久化的服务端 JWT + # (置于 _init_from_storage 之后,避免被持久化配置覆盖)。 + self._refresh_pineagents_auth() def _prepare_disk_storage(self): """Prepare directory structure""" @@ -1454,7 +1457,31 @@ class ProviderManager: # pylint: disable=too-many-public-methods def _add_builtin(self, provider: Provider): self.builtin_providers[provider.id] = provider + def _refresh_pineagents_auth(self) -> None: + """把本地持久化的服务端 JWT 注入 PineAgents provider 请求头。 + + server-core /v1 中继据 JWT 归户到该用户的算力令牌并按其余额计量/拦截; + 未登录时移除该头(引擎侧拒绝匿名调用)。 + """ + pine = self.builtin_providers.get("pineagents") + if pine is None: + return + try: + from ..app.auth_token_store import load_auth_token + + token = load_auth_token() + except Exception: # noqa: BLE001 + token = "" + headers = dict(pine.custom_headers or {}) + if token: + headers["Authorization"] = f"Bearer {token}" + else: + headers.pop("Authorization", None) + pine.custom_headers = headers + async def list_provider_info(self) -> List[ProviderInfo]: + # 登录态可能变化(登录/换发/登出),先刷新 PineAgents 鉴权头再拉模型。 + self._refresh_pineagents_auth() # PineAgents(算力中心):动态拉取 admin 模型(server-core /v1/models),失败回退静态 DEEPSEEK_MODELS。 pine = self.builtin_providers.get("pineagents") if pine is not None and hasattr(pine, "fetch_models"):