Add user space quota purchase flow
This commit is contained in:
+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