integrate sub2api as upstream for auth/keys/usage via FastAPI BFF

Preserve local user table for superDream-specific features while syncing
user lifecycle, API key CRUD and usage queries through sub2api. Admin token
handles reads and user lifecycle; per-user tokens (Fernet-encrypted in DB)
handle key writes that admin endpoints do not expose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuyong
2026-04-17 21:23:08 +08:00
parent 20e842a60a
commit 35c0b7de16
30 changed files with 1707 additions and 803 deletions

View File

@@ -1,139 +1,27 @@
import { useState, useEffect } from "react";
import { walletService, TransactionInfo } from "../../services/walletService";
const TYPE_LABELS: Record<string, string> = {
topup: "充值",
consume: "消费",
refund: "退款",
};
import { authService, UserInfo } from "../../services/authService";
export default function Wallet() {
const [balance, setBalance] = useState("0");
const [code, setCode] = useState("");
const [transactions, setTransactions] = useState<TransactionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const fetchData = async () => {
try {
const [b, txns] = await Promise.all([
walletService.getBalance(),
walletService.transactions(),
]);
setBalance(b.balance);
setTransactions(txns);
} catch (err: any) {
setError(err.message);
}
};
const [user, setUser] = useState<UserInfo | null>(null);
useEffect(() => {
fetchData();
authService.me().then(setUser).catch(() => {});
}, []);
const handleRedeem = async () => {
setError("");
setSuccess("");
if (!code.trim()) return;
setLoading(true);
try {
const txn = await walletService.redeem(code.trim());
setSuccess(`充值成功!+¥${txn.amount}`);
setCode("");
await fetchData();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Balance */}
<div className="bg-superdream-panel rounded-xl p-6 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
<p className="text-gray-600 dark:text-gray-400 text-sm"> (USD)</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
${(user?.balance ?? 0).toFixed(4)}
</p>
</div>
{/* Redeem */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2"></label>
{error && (
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-3 p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm">
{success}
</div>
)}
<div className="flex gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="输入兑换码"
className="flex-1 px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
<button
onClick={handleRedeem}
disabled={loading || !code.trim()}
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "兑换中..." : "兑换"}
</button>
</div>
</div>
{/* Transactions */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{transactions.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
</td>
</tr>
) : (
transactions.map((txn) => (
<tr key={txn.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-900 dark:text-white">
{TYPE_LABELS[txn.type] || txn.type}
</td>
<td className={`px-4 py-3 font-medium ${txn.type === "consume" ? "text-red-400" : "text-green-400"}`}>
{txn.type === "consume" ? "-" : "+"}¥{txn.amount}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">¥{txn.balance_after}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{txn.reference_id || "-"}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(txn.created_at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="bg-superdream-panel rounded-xl p-4 text-sm text-gray-600 dark:text-gray-400">
sub2api <code>/api/v1/redeem</code>
</div>
</div>
);
}
}