Add user space quota purchase flow

This commit is contained in:
john
2026-06-25 23:48:29 +08:00
parent e133f9a3a4
commit d80dcf430a
5 changed files with 347 additions and 2 deletions
+33
View File
@@ -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',
+12
View File
@@ -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<MindSpace> {
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
return result.data;
+86 -2
View File
@@ -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<MindSpaceAgentJob['status'], string> = {
queued: '排队中',
@@ -441,6 +443,9 @@ export function MindSpaceView({
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [spacePurchaseMb, setSpacePurchaseMb] = useState('');
const [spacePurchasePending, setSpacePurchasePending] = useState(false);
const [spacePurchaseMessage, setSpacePurchaseMessage] = useState<string | null>(null);
const [cleanupItems, setCleanupItems] = useState<MindSpaceCleanupItem[]>([]);
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<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) {
@@ -1417,7 +1455,7 @@ export function MindSpaceView({
{space && (
<aside className="mindspace-hero-quota">
<div className="mindspace-hero-quota-top">
<span></span>
<span></span>
<strong>
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
</strong>
@@ -1433,6 +1471,7 @@ export function MindSpaceView({
<span style={{ width: `${usedPercent}%` }} />
</div>
<p className="mindspace-hero-quota-meta">
{formatBytes(space.quota.availableBytes)} ·{' '}
{space.quota.reservedBytes > 0 && (
<>
{formatBytes(space.quota.reservedBytes)} ·{' '}
@@ -1442,6 +1481,51 @@ export function MindSpaceView({
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · AI{' '}
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
</p>
<div className="mindspace-hero-purchase">
<div className="mindspace-hero-purchase-row">
{SPACE_PURCHASE_PRESETS_MB.map((presetMb) => (
<button
key={presetMb}
type="button"
className="mindspace-hero-purchase-chip"
disabled={spacePurchasePending}
onClick={() => void submitSpacePurchase(presetMb)}
>
{presetMb}M
</button>
))}
</div>
<div className="mindspace-hero-purchase-row">
<input
className="mindspace-hero-purchase-input"
type="number"
min="1"
step="1"
placeholder="自定义 MB"
value={spacePurchaseMb}
onChange={(event) => setSpacePurchaseMb(event.target.value)}
/>
<button
type="button"
className="mindspace-hero-purchase-btn"
disabled={spacePurchasePending}
onClick={() => void submitSpacePurchase()}
>
{spacePurchasePending
? '购买中…'
: `按 MB 购买(¥${(
(Math.max(1, Math.floor(Number(spacePurchaseMb) || 0)) * 200) /
100
).toFixed(2)}`}
</button>
</div>
<p className="mindspace-hero-purchase-meta">
5M / 10M / 20M 1 MB = ¥2
</p>
{spacePurchaseMessage && (
<p className="mindspace-hero-purchase-message">{spacePurchaseMessage}</p>
)}
</div>
<button
type="button"
className="mindspace-hero-quota-cleanup"
+60
View File
@@ -4055,6 +4055,66 @@ body,
color: rgba(255, 255, 255, 0.58);
}
.mindspace-hero-purchase {
margin-top: 10px;
}
.mindspace-hero-purchase-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.mindspace-hero-purchase-chip,
.mindspace-hero-purchase-btn {
border: 1px solid rgba(238, 176, 78, 0.35);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #fff3d7;
cursor: pointer;
}
.mindspace-hero-purchase-chip {
padding: 6px 10px;
font-size: 12px;
}
.mindspace-hero-purchase-btn {
padding: 8px 12px;
font-size: 12px;
}
.mindspace-hero-purchase-chip:disabled,
.mindspace-hero-purchase-btn:disabled {
opacity: 0.6;
cursor: default;
}
.mindspace-hero-purchase-input {
min-width: 96px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.mindspace-hero-purchase-meta,
.mindspace-hero-purchase-message {
margin: 8px 0 0;
font-size: 10px;
line-height: 1.45;
}
.mindspace-hero-purchase-meta {
color: rgba(255, 255, 255, 0.58);
}
.mindspace-hero-purchase-message {
color: #eeb04e;
}
.mindspace-hero-quota-cleanup {
margin-top: 6px;
padding: 0;
+156
View File
@@ -182,11 +182,13 @@ export function createUserAuth(pool, options = {}) {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
w.balance_cents, w.tokens_used,
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
FROM h5_billing_ledger l
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
FROM h5_users u
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
WHERE u.id = ?`,
[userId],
@@ -210,6 +212,13 @@ export function createUserAuth(pool, options = {}) {
balanceCents,
totalCreditCents: balanceCents + spentCents,
tokensUsed: Number(row.tokens_used ?? 0),
spaceQuotaBytes: Number(row.quota_bytes ?? 0),
spaceUsedBytes: Number(row.used_bytes ?? 0),
spaceReservedBytes: Number(row.reserved_bytes ?? 0),
spaceAvailableBytes: Math.max(
0,
Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0),
),
};
if (row.role === 'admin') return base;
const publishKey = row.id;
@@ -709,8 +718,10 @@ export function createUserAuth(pool, options = {}) {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
FROM h5_users u
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
${where}
ORDER BY u.created_at DESC
@@ -854,10 +865,154 @@ export function createUserAuth(pool, options = {}) {
);
}
if (patch.spaceQuotaBytes !== undefined) {
const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes));
if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) {
return { ok: false, message: '空间大小无效' };
}
const [spaceRows] = await pool.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1`,
[userId],
);
const currentSpace = spaceRows[0];
const occupiedBytes =
Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0);
if (quotaBytes < occupiedBytes) {
return {
ok: false,
message: `空间不能小于已使用容量 ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`,
};
}
if (currentSpace?.id) {
await pool.query(
`UPDATE h5_user_spaces
SET quota_bytes = ?, updated_at = ?
WHERE user_id = ?`,
[quotaBytes, now, userId],
);
} else {
await initializeDefaultSpace(pool, userId, {
quotaBytes,
now,
});
}
}
const updated = await getUserById(userId);
return { ok: true, user: publicUser(updated) };
};
const purchaseSpaceQuota = async (userId, sizeMb) => {
const purchaseMb = Math.floor(Number(sizeMb));
if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) {
return { ok: false, message: '扩容大小无效' };
}
const deltaBytes = purchaseMb * 1024 * 1024;
const costCents = purchaseMb * 200;
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [spaceRows] = await conn.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1
FOR UPDATE`,
[userId],
);
if (!spaceRows[0]) {
await initializeDefaultSpace(conn, userId, { now });
}
const [walletRows] = await conn.query(
`SELECT balance_cents
FROM h5_user_wallets
WHERE user_id = ?
FOR UPDATE`,
[userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < costCents) {
await conn.rollback();
return {
ok: false,
code: 'INSUFFICIENT_BALANCE',
message: '余额不足,请先充值后再购买空间',
balanceCents,
minRechargeCents: Math.max(500, costCents - balanceCents),
suggestedTiers: loadRechargeConfig().tiersCents,
};
}
await conn.query(
`UPDATE h5_user_wallets
SET balance_cents = balance_cents - ?, updated_at = ?
WHERE user_id = ?`,
[costCents, now, userId],
);
await conn.query(
`UPDATE h5_user_spaces
SET quota_bytes = quota_bytes + ?, updated_at = ?
WHERE user_id = ?`,
[deltaBytes, now, userId],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[userId, costCents, `space_purchase:${purchaseMb}MB`, now],
);
await conn.query(
`INSERT INTO h5_user_notifications
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`,
[
crypto.randomUUID(),
userId,
'空间扩容成功',
`已购买 ${purchaseMb} MB 空间,支付 ¥${(costCents / 100).toFixed(2)}`,
JSON.stringify({ purchaseMb, deltaBytes, costCents }),
now,
now,
],
);
await conn.commit();
const [updatedSpaceRows] = await pool.query(
`SELECT quota_bytes, used_bytes, reserved_bytes
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1`,
[userId],
);
const updatedSpace = updatedSpaceRows[0] ?? {};
const updatedUser = await getUserById(userId);
return {
ok: true,
balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)),
quota: {
quotaBytes: Number(updatedSpace.quota_bytes ?? 0),
usedBytes: Number(updatedSpace.used_bytes ?? 0),
reservedBytes: Number(updatedSpace.reserved_bytes ?? 0),
availableBytes: Math.max(
0,
Number(updatedSpace.quota_bytes ?? 0) -
Number(updatedSpace.used_bytes ?? 0) -
Number(updatedSpace.reserved_bytes ?? 0),
),
},
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const recharge = async (userId, amountCents, operatorId, note = '', options = {}) => {
const amount = Number(amountCents);
if (!Number.isFinite(amount) || amount <= 0) {
@@ -2338,6 +2493,7 @@ export function createUserAuth(pool, options = {}) {
listUsers,
createUser,
updateUser,
purchaseSpaceQuota,
recharge,
billSessionUsage,
listUsageRecords,