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 });
|
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({
|
const wechatNotifyBody = express.raw({
|
||||||
type: ['application/json', 'text/xml', 'application/xml'],
|
type: ['application/json', 'text/xml', 'application/xml'],
|
||||||
limit: '64kb',
|
limit: '64kb',
|
||||||
|
|||||||
@@ -405,6 +405,18 @@ export async function getRechargeOrder(orderId: string): Promise<{
|
|||||||
return portalFetch(`/auth/billing/recharge-orders/${orderId}`);
|
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> {
|
export async function getMindSpace(): Promise<MindSpace> {
|
||||||
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
|
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
retryMindSpaceAgentJob,
|
retryMindSpaceAgentJob,
|
||||||
runMindSpaceAgentJob,
|
runMindSpaceAgentJob,
|
||||||
runMindSpaceCleanup,
|
runMindSpaceCleanup,
|
||||||
|
purchaseSpaceQuota,
|
||||||
uploadMindSpaceAsset,
|
uploadMindSpaceAsset,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
@@ -98,6 +99,7 @@ const UPLOAD_FILE_EXTENSIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
const UPLOAD_ACCEPT = UPLOAD_FILE_EXTENSIONS.join(',');
|
const UPLOAD_ACCEPT = UPLOAD_FILE_EXTENSIONS.join(',');
|
||||||
const UPLOAD_FILE_TYPE_LABEL = 'Word、Excel、PDF、PPT、图片、Markdown、CSV、TXT 和 HTML';
|
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> = {
|
const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
@@ -441,6 +443,9 @@ export function MindSpaceView({
|
|||||||
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
|
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
|
||||||
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
|
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
|
||||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
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 [cleanupItems, setCleanupItems] = useState<MindSpaceCleanupItem[]>([]);
|
||||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||||
const [cleanupRunning, setCleanupRunning] = useState(false);
|
const [cleanupRunning, setCleanupRunning] = useState(false);
|
||||||
@@ -477,7 +482,7 @@ export function MindSpaceView({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
|
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
|
||||||
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
|
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
|
||||||
const { chatState, messages, session } = useChat();
|
const { chatState, messages, session, completeRecharge, openRecharge } = useChat();
|
||||||
const prevChatStateRef = useRef(chatState);
|
const prevChatStateRef = useRef(chatState);
|
||||||
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
|
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
|
||||||
const location = useLocation();
|
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 handleUploadFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0] ?? null;
|
const file = event.target.files?.[0] ?? null;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -1417,7 +1455,7 @@ export function MindSpaceView({
|
|||||||
{space && (
|
{space && (
|
||||||
<aside className="mindspace-hero-quota">
|
<aside className="mindspace-hero-quota">
|
||||||
<div className="mindspace-hero-quota-top">
|
<div className="mindspace-hero-quota-top">
|
||||||
<span>免费版空间</span>
|
<span>空间容量</span>
|
||||||
<strong>
|
<strong>
|
||||||
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
|
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
|
||||||
</strong>
|
</strong>
|
||||||
@@ -1433,6 +1471,7 @@ export function MindSpaceView({
|
|||||||
<span style={{ width: `${usedPercent}%` }} />
|
<span style={{ width: `${usedPercent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mindspace-hero-quota-meta">
|
<p className="mindspace-hero-quota-meta">
|
||||||
|
剩余 {formatBytes(space.quota.availableBytes)} ·{' '}
|
||||||
{space.quota.reservedBytes > 0 && (
|
{space.quota.reservedBytes > 0 && (
|
||||||
<>
|
<>
|
||||||
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
|
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
|
||||||
@@ -1442,6 +1481,51 @@ export function MindSpaceView({
|
|||||||
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
|
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
|
||||||
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
|
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
|
||||||
</p>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mindspace-hero-quota-cleanup"
|
className="mindspace-hero-quota-cleanup"
|
||||||
|
|||||||
@@ -4055,6 +4055,66 @@ body,
|
|||||||
color: rgba(255, 255, 255, 0.58);
|
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 {
|
.mindspace-hero-quota-cleanup {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
+156
@@ -182,11 +182,13 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||||
u.plan_type, u.workspace_root,
|
u.plan_type, u.workspace_root,
|
||||||
|
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||||
w.balance_cents, w.tokens_used,
|
w.balance_cents, w.tokens_used,
|
||||||
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
|
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
|
||||||
FROM h5_billing_ledger l
|
FROM h5_billing_ledger l
|
||||||
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
|
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
|
||||||
FROM h5_users u
|
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
|
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
||||||
WHERE u.id = ?`,
|
WHERE u.id = ?`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -210,6 +212,13 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
balanceCents,
|
balanceCents,
|
||||||
totalCreditCents: balanceCents + spentCents,
|
totalCreditCents: balanceCents + spentCents,
|
||||||
tokensUsed: Number(row.tokens_used ?? 0),
|
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;
|
if (row.role === 'admin') return base;
|
||||||
const publishKey = row.id;
|
const publishKey = row.id;
|
||||||
@@ -709,8 +718,10 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||||
u.plan_type, u.workspace_root,
|
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
|
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
||||||
FROM h5_users u
|
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
|
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
||||||
${where}
|
${where}
|
||||||
ORDER BY u.created_at DESC
|
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);
|
const updated = await getUserById(userId);
|
||||||
return { ok: true, user: publicUser(updated) };
|
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 recharge = async (userId, amountCents, operatorId, note = '', options = {}) => {
|
||||||
const amount = Number(amountCents);
|
const amount = Number(amountCents);
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
@@ -2338,6 +2493,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
listUsers,
|
listUsers,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
purchaseSpaceQuota,
|
||||||
recharge,
|
recharge,
|
||||||
billSessionUsage,
|
billSessionUsage,
|
||||||
listUsageRecords,
|
listUsageRecords,
|
||||||
|
|||||||
Reference in New Issue
Block a user