From d80dcf430a344030637ca85ffff9a55928f6ffdd Mon Sep 17 00:00:00 2001 From: john Date: Thu, 25 Jun 2026 23:48:29 +0800 Subject: [PATCH] Add user space quota purchase flow --- server.mjs | 33 +++++++ src/api/client.ts | 12 +++ src/components/MindSpaceView.tsx | 88 ++++++++++++++++- src/index.css | 60 ++++++++++++ user-auth.mjs | 156 +++++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 2 deletions(-) diff --git a/server.mjs b/server.mjs index e144d9e..0a4b8d8 100644 --- a/server.mjs +++ b/server.mjs @@ -930,6 +930,39 @@ app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => { return res.json({ order, balanceCents }); }); +app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth || !mindSpace) { + return res.status(503).json({ message: '空间服务未启用' }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const sizeMb = Math.floor(Number(req.body?.sizeMb)); + const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb); + if (!result?.ok) { + if (result?.code === 'INSUFFICIENT_BALANCE') { + return res.status(402).json({ + message: result.message, + code: result.code, + details: { + code: 'INSUFFICIENT_BALANCE', + balanceCents: result.balanceCents, + minRechargeCents: result.minRechargeCents, + suggestedTiers: result.suggestedTiers, + }, + }); + } + return res.status(400).json({ message: result?.message ?? '购买空间失败' }); + } + const quota = await mindSpace.getQuota(me.id); + return res.json({ + quota: quota ?? result.quota, + balanceCents: result.balanceCents, + purchasedMb: sizeMb, + costCents: sizeMb * 200, + }); +}); + const wechatNotifyBody = express.raw({ type: ['application/json', 'text/xml', 'application/xml'], limit: '64kb', diff --git a/src/api/client.ts b/src/api/client.ts index 2de6f0b..3e6f206 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -405,6 +405,18 @@ export async function getRechargeOrder(orderId: string): Promise<{ return portalFetch(`/auth/billing/recharge-orders/${orderId}`); } +export async function purchaseSpaceQuota(sizeMb: number): Promise<{ + quota: MindSpaceQuota; + balanceCents: number; + purchasedMb: number; + costCents: number; +}> { + return portalFetch('/auth/billing/space-purchase', { + method: 'POST', + body: JSON.stringify({ sizeMb }), + }); +} + export async function getMindSpace(): Promise { const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space'); return result.data; diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 5362c71..7291d32 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -20,6 +20,7 @@ import { retryMindSpaceAgentJob, runMindSpaceAgentJob, runMindSpaceCleanup, + purchaseSpaceQuota, uploadMindSpaceAsset, ApiError, } from '../api/client'; @@ -98,6 +99,7 @@ const UPLOAD_FILE_EXTENSIONS = [ ] as const; const UPLOAD_ACCEPT = UPLOAD_FILE_EXTENSIONS.join(','); const UPLOAD_FILE_TYPE_LABEL = 'Word、Excel、PDF、PPT、图片、Markdown、CSV、TXT 和 HTML'; +const SPACE_PURCHASE_PRESETS_MB = [5, 10, 20] as const; const JOB_STATUS_LABELS: Record = { queued: '排队中', @@ -441,6 +443,9 @@ export function MindSpaceView({ const [previewAssetId, setPreviewAssetId] = useState(null); const [editingAssetId, setEditingAssetId] = useState(null); const [cleanupOpen, setCleanupOpen] = useState(false); + const [spacePurchaseMb, setSpacePurchaseMb] = useState(''); + const [spacePurchasePending, setSpacePurchasePending] = useState(false); + const [spacePurchaseMessage, setSpacePurchaseMessage] = useState(null); const [cleanupItems, setCleanupItems] = useState([]); const [cleanupLoading, setCleanupLoading] = useState(false); const [cleanupRunning, setCleanupRunning] = useState(false); @@ -477,7 +482,7 @@ export function MindSpaceView({ } | null>(null); const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0); const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false); - const { chatState, messages, session } = useChat(); + const { chatState, messages, session, completeRecharge, openRecharge } = useChat(); const prevChatStateRef = useRef(chatState); const h5ApiBase = useMemo(() => resolveH5ApiBase(), []); const location = useLocation(); @@ -985,6 +990,39 @@ export function MindSpaceView({ } }; + const submitSpacePurchase = async (requestedMb?: number) => { + const sizeMb = Math.floor(Number(requestedMb ?? spacePurchaseMb)); + if (!Number.isFinite(sizeMb) || sizeMb <= 0) { + setSpacePurchaseMessage('请输入大于 0 的扩容大小'); + return; + } + if (previewMode) { + setError(previewBlocked().message); + return; + } + setSpacePurchasePending(true); + setError(null); + setSpacePurchaseMessage(null); + try { + const result = await purchaseSpaceQuota(sizeMb); + completeRecharge(result.balanceCents); + await refreshMindSpaceSnapshot({ quota: result.quota }); + setSpacePurchaseMb(''); + setSpacePurchaseMessage( + `已购买 ${result.purchasedMb} MB,支付 ¥${(result.costCents / 100).toFixed(2)}。`, + ); + } catch (err) { + if (err instanceof ApiError && err.code === 'INSUFFICIENT_BALANCE') { + setSpacePurchaseMessage('余额不足,已为你打开充值入口。'); + openRecharge(false); + } else { + setError(err instanceof Error ? err.message : '购买空间失败'); + } + } finally { + setSpacePurchasePending(false); + } + }; + const handleUploadFileChange = (event: ChangeEvent) => { const file = event.target.files?.[0] ?? null; if (!file) { @@ -1417,7 +1455,7 @@ export function MindSpaceView({ {space && (