Add user space quota purchase flow
This commit is contained in:
+33
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user