feat(compute): 算力中心充值(小程序码支付)与转发端点

- PineAgents 供应商按登录态注入 JWT(登出移除)
- 本地后端新增充值转发(packages/orders/pay-params/status/orders)与算力余额路由
- console 充值弹窗:套餐/自定义金额→小程序码→轮询到账;余额卡加充值入口;i18n 文案

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-30 22:32:04 +08:00
parent a88cfebf8a
commit a610c22f45
6 changed files with 216 additions and 1 deletions
+10
View File
@@ -327,6 +327,16 @@ export const authApi = {
// ---- 计量:余额 / 本月用量(按模型) ---- // ---- 计量:余额 / 本月用量(按模型) ----
getOpcComputeBalance: () => request<{ id: number; quota: number; used_quota: number }>("/opc/compute/balance"), 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"), 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<string, string>; 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<string, string> | 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<PortDashboardData>("/enterprise/dashboard"), getEnterpriseDashboard: () => request<PortDashboardData>("/enterprise/dashboard"),
+11
View File
@@ -3758,6 +3758,17 @@
"computeRequests": "请求", "computeRequests": "请求",
"computeCost": "费用", "computeCost": "费用",
"computeNoUsage": "本月暂无用量,使用模型后显示", "computeNoUsage": "本月暂无用量,使用模型后显示",
"computeRecharge": "充值",
"computeRechargeTitle": "算力充值(微信扫码 · 小程序支付)",
"computeRechargePackages": "充值套餐",
"computeRechargeCustom": "自定义金额(元)",
"computeRechargePay": "生成小程序支付码",
"computeRechargeScan": "请使用微信扫码,在小程序中确认并完成支付,余额自动到账",
"computeRechargeSuccess": "充值成功,算力已到账",
"computeRechargeFailed": "下单失败",
"computeRechargeDisabled": "支付暂未配置,请联系运营",
"computeRechargeDone": "我已完成支付",
"computeRechargeBonus": "送 {{bonus}} 元",
"profile": "个人资料", "profile": "个人资料",
"username": "用户名", "username": "用户名",
"nickname": "昵称", "nickname": "昵称",
+6 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Button, Card, Col, List, Row, Space, Table, Tag, Typography, message } from "antd"; import { Button, Card, Col, List, Row, Space, Table, Tag, Typography, message } from "antd";
import { authApi } from "../../../api/modules/auth"; import { authApi } from "../../../api/modules/auth";
import { RequireRole } from "../../../auth/guards"; import { RequireRole } from "../../../auth/guards";
import RechargeModal from "./RechargeModal";
import styles from "./OpcList.module.less"; import styles from "./OpcList.module.less";
interface Token { id: number; name: string; key: string; created_time: number } 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 [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 [usage, setUsage] = useState<{ month: string; cost_quota: number; input_tokens: number; output_tokens: number; items: any[] } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [rechargeOpen, setRechargeOpen] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
authApi.getOpcComputeBase().then(setBase).catch(() => {}); authApi.getOpcComputeBase().then(setBase).catch(() => {});
@@ -86,7 +88,8 @@ export default function OpcCompute() {
/> />
</Card> </Card>
<Card title={t("role.opc.computeUsage")} style={{ marginTop: 12 }} size="small"> <Card title={t("role.opc.computeUsage")} style={{ marginTop: 12 }} size="small"
extra={<Button type="primary" size="small" onClick={() => setRechargeOpen(true)}>{t("role.opc.computeRecharge")}</Button>}>
<Space size={24} wrap> <Space size={24} wrap>
<div><Typography.Text type="secondary">{t("role.opc.computeBalance")}</Typography.Text> <div><Typography.Text type="secondary">{t("role.opc.computeBalance")}</Typography.Text>
<div className={styles.itemTitle}>¥{((balance?.quota ?? 0) / 1000000).toFixed(4)}</div></div> <div className={styles.itemTitle}>¥{((balance?.quota ?? 0) / 1000000).toFixed(4)}</div></div>
@@ -131,6 +134,8 @@ export default function OpcCompute() {
</Card> </Card>
</Col> </Col>
</Row> </Row>
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} onPaid={load} />
</div> </div>
</RequireRole> </RequireRole>
); );
@@ -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<Packages | null>(null);
const [pkgId, setPkgId] = useState<string>("");
const [custom, setCustom] = useState<number | null>(null);
const [order, setOrder] = useState<{ order_no: string; qr_image: string; quota_micro: number } | null>(null);
const [busy, setBusy] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | 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 (
<Modal
title={t("role.opc.computeRechargeTitle")}
open={open}
onCancel={() => { stopPoll(); setOrder(null); onClose(); }}
footer={order ? [
<Button key="done" onClick={() => { stopPoll(); setOrder(null); loadPkgs(); }}>{t("role.opc.computeRechargeDone")}</Button>,
] : [
<Button key="cancel" onClick={() => onClose()}>{t("common.cancel", "取消")}</Button>,
<Button key="pay" type="primary" loading={busy} disabled={!pkgs?.enabled || (!pkgId && !(custom && custom > 0))}
onClick={submit}>{t("role.opc.computeRechargePay")}</Button>,
]}
>
{pkgs && !pkgs.enabled && (
<Typography.Paragraph type="warning">{t("role.opc.computeRechargeDisabled")}</Typography.Paragraph>
)}
{!order ? (
<>
{pkgs?.items?.length ? (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>{t("role.opc.computeRechargePackages")}</Typography.Paragraph>
<Radio.Group value={pkgId} onChange={(e) => { setPkgId(e.target.value); setCustom(null); }} style={{ width: "100%" }}>
<Space wrap>
{pkgs.items.map((p) => (
<Radio.Button key={p.id} value={p.id}>
¥{p.amount}{p.bonus > 0 ? <Tag color="orange" style={{ marginLeft: 6 }}>{t("role.opc.computeRechargeBonus", { bonus: p.bonus })}</Tag> : null}
</Radio.Button>
))}
</Space>
</Radio.Group>
</>
) : null}
<div style={{ marginTop: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>{t("role.opc.computeRechargeCustom")}{min} ~ {max}</Typography.Paragraph>
<InputNumber min={min} max={max} precision={0} value={custom}
onChange={(v) => { setCustom(v); setPkgId(""); }} style={{ width: 200 }} />
</div>
</>
) : (
<div style={{ textAlign: "center" }}>
<Image src={order.qr_image} width={220} preview={false} />
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{t("role.opc.computeRechargeScan")}</Typography.Paragraph>
<Typography.Text code>{order.order_no}</Typography.Text>
<div style={{ marginTop: 8 }}>
<Typography.Text strong>¥{(order.quota_micro / MICRO).toFixed(2)}</Typography.Text>
</div>
</div>
)}
</Modal>
);
}
+38
View File
@@ -291,6 +291,16 @@ async def opc_compute_prices(request: Request):
return await forward("GET", "/opc/compute/prices", auth_header=_auth(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") @router.get("/opc/compute/tokens")
async def opc_compute_tokens(request: Request): async def opc_compute_tokens(request: Request):
return await forward("GET", "/opc/compute/tokens", auth_header=_auth(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)) 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))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 各业务端口工作台 # 各业务端口工作台
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1400,6 +1400,9 @@ class ProviderManager: # pylint: disable=too-many-public-methods
logger.warning("Failed to migrate legacy providers: %s", e) logger.warning("Failed to migrate legacy providers: %s", e)
self._init_from_storage() self._init_from_storage()
self._apply_default_annotations() self._apply_default_annotations()
# PineAgents(算力中心)中继按登录用户归户计量:注入本地持久化的服务端 JWT
# (置于 _init_from_storage 之后,避免被持久化配置覆盖)。
self._refresh_pineagents_auth()
def _prepare_disk_storage(self): def _prepare_disk_storage(self):
"""Prepare directory structure""" """Prepare directory structure"""
@@ -1454,7 +1457,31 @@ class ProviderManager: # pylint: disable=too-many-public-methods
def _add_builtin(self, provider: Provider): def _add_builtin(self, provider: Provider):
self.builtin_providers[provider.id] = 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]: async def list_provider_info(self) -> List[ProviderInfo]:
# 登录态可能变化(登录/换发/登出),先刷新 PineAgents 鉴权头再拉模型。
self._refresh_pineagents_auth()
# PineAgents(算力中心):动态拉取 admin 模型(server-core /v1/models),失败回退静态 DEEPSEEK_MODELS。 # PineAgents(算力中心):动态拉取 admin 模型(server-core /v1/models),失败回退静态 DEEPSEEK_MODELS。
pine = self.builtin_providers.get("pineagents") pine = self.builtin_providers.get("pineagents")
if pine is not None and hasattr(pine, "fetch_models"): if pine is not None and hasattr(pine, "fetch_models"):