feat(compute): 算力中心充值(小程序码支付)与转发端点
- PineAgents 供应商按登录态注入 JWT(登出移除) - 本地后端新增充值转发(packages/orders/pay-params/status/orders)与算力余额路由 - console 充值弹窗:套餐/自定义金额→小程序码→轮询到账;余额卡加充值入口;i18n 文案 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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": "昵称",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"):
|
||||||
|
|||||||
Reference in New Issue
Block a user