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
+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,