diff --git a/db.mjs b/db.mjs index 92459c0..832f5a2 100644 --- a/db.mjs +++ b/db.mjs @@ -359,6 +359,18 @@ export async function migrateSchema(pool) { ); } + if (!(await columnExists(pool, 'h5_users', 'low_balance_gift_eligible'))) { + await pool.query( + `ALTER TABLE h5_users ADD COLUMN low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0 AFTER signup_source`, + ); + } + + if (!(await columnExists(pool, 'h5_users', 'low_balance_gift_granted_at'))) { + await pool.query( + `ALTER TABLE h5_users ADD COLUMN low_balance_gift_granted_at BIGINT NULL AFTER low_balance_gift_eligible`, + ); + } + if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) { await pool.query( `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`, diff --git a/schema.sql b/schema.sql index 485f37f..9db7a7f 100644 --- a/schema.sql +++ b/schema.sql @@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS h5_users ( status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active', plan_type VARCHAR(32) NOT NULL DEFAULT 'free', workspace_root VARCHAR(512) NOT NULL, + low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0, + low_balance_gift_granted_at BIGINT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE KEY uq_h5_users_slug (slug), diff --git a/user-auth.mjs b/user-auth.mjs index 98290a3..979f231 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -129,6 +129,8 @@ export function createUserAuth(pool, options = {}) { const publicBaseUrl = resolvePublicBaseUrl(env); const skillCatalog = listPlatformSkillCatalog(h5Root); const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); + const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100); + const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000); const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000); const loginMaxFailures = Number(options.loginMaxFailures ?? 5); const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000); @@ -340,6 +342,72 @@ export function createUserAuth(pool, options = {}) { ); }; + const shouldEnableLowBalanceGift = ({ isAdmin = false, initialBalanceCents }) => { + return !isAdmin && Number(initialBalanceCents) === defaultSignupBalanceCents && defaultSignupBalanceCents > 0; + }; + + const grantLowBalanceGiftIfNeeded = async (conn, { userId, currentBalance, nextBalance, now }) => { + if (lowBalanceGiftAmountCents <= 0 || lowBalanceGiftThresholdCents < 0 || nextBalance > lowBalanceGiftThresholdCents) { + return { gifted: false, balanceAfter: nextBalance }; + } + + const [rows] = await conn.query( + `SELECT low_balance_gift_eligible, low_balance_gift_granted_at + FROM h5_users + WHERE id = ? + FOR UPDATE`, + [userId], + ); + const user = rows[0]; + if (!user || !Boolean(user.low_balance_gift_eligible) || user.low_balance_gift_granted_at != null) { + return { gifted: false, balanceAfter: nextBalance }; + } + + const giftedBalance = nextBalance + lowBalanceGiftAmountCents; + await conn.query( + `UPDATE h5_user_wallets + SET balance_cents = ?, updated_at = ? + WHERE user_id = ?`, + [giftedBalance, now, userId], + ); + await conn.query( + `UPDATE h5_users + SET low_balance_gift_eligible = 0, + low_balance_gift_granted_at = ?, + status = CASE WHEN status = 'suspended' THEN 'active' ELSE status END, + updated_at = ? + WHERE id = ?`, + [now, now, userId], + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`, + [userId, lowBalanceGiftAmountCents, '新用户低余额自动赠送', 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', 'low_balance_gift', ?, ?, ?, 'unread', NULL, ?, ?)`, + [ + crypto.randomUUID(), + userId, + '新用户额度已自动补送', + `检测到你的余额已低于 ¥${(lowBalanceGiftThresholdCents / 100).toFixed(2)},系统已自动赠送 ¥${(lowBalanceGiftAmountCents / 100).toFixed(2)} 新用户额度。本福利仅可领取一次。`, + JSON.stringify({ + triggerBalanceCents: nextBalance, + previousBalanceCents: currentBalance, + giftAmountCents: lowBalanceGiftAmountCents, + thresholdCents: lowBalanceGiftThresholdCents, + }), + now, + now, + ], + ); + + return { gifted: true, balanceAfter: giftedBalance }; + }; + const register = async ({ username, password, displayName, email }) => { const normalized = normalizeUsername(username); if (!isValidUsername(normalized)) { @@ -364,8 +432,9 @@ export function createUserAuth(pool, options = {}) { await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, - role, status, plan_type, workspace_root, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`, + role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`, [ userId, normalized, @@ -815,13 +884,15 @@ export function createUserAuth(pool, options = {}) { const now = Date.now(); const conn = await pool.getConnection(); + const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents); try { await conn.beginTransaction(); await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, - role, status, plan_type, workspace_root, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`, + role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`, [ userId, normalized, @@ -833,6 +904,7 @@ export function createUserAuth(pool, options = {}) { passwordAlgorithm, isAdmin ? 'admin' : 'user', root, + shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0, now, now, ], @@ -840,7 +912,7 @@ export function createUserAuth(pool, options = {}) { await conn.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?)`, - [userId, Number(balanceCents ?? defaultSignupBalanceCents), now], + [userId, initialBalanceCents, now], ); await conn.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, @@ -1315,7 +1387,11 @@ export function createUserAuth(pool, options = {}) { let tokensUsedAfter = null; if (costCents > 0) { const [walletRows] = await conn.query( - `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + `SELECT w.balance_cents, w.tokens_used, u.status + FROM h5_user_wallets w + JOIN h5_users u ON u.id = w.user_id + WHERE w.user_id = ? + FOR UPDATE`, [userId], ); const wallet = walletRows[0]; @@ -1366,7 +1442,15 @@ export function createUserAuth(pool, options = {}) { ], ); - if (nextBalance <= 0) { + const lowBalanceGift = await grantLowBalanceGiftIfNeeded(conn, { + userId, + currentBalance, + nextBalance, + now, + }); + balanceAfter = lowBalanceGift.balanceAfter; + + if (balanceAfter <= 0) { await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [ now, userId, @@ -2425,8 +2509,9 @@ export function createUserAuth(pool, options = {}) { await conn.query( `INSERT INTO h5_users (id, username, slug, email, display_name, salt, password_hash, password_algorithm, - role, status, plan_type, workspace_root, signup_source, created_at, updated_at) - VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`, + role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible, + low_balance_gift_granted_at, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`, [ userId, normalized, diff --git a/user-auth.test.mjs b/user-auth.test.mjs index a0bc16e..c547215 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -431,6 +431,130 @@ test('purchaseSpaceQuota deducts balance and increases quota', async () => { assert.equal(result.quota.availableBytes, 15 * 1024 * 1024 - 1024 - 2048); }); +test('billSessionUsage auto gifts low-balance bonus once for eligible new users', async () => { + const userRow = { + id: 'user-3', + username: 'bonus_user', + slug: 'bonus_user', + email: 'bonus@example.com', + display_name: 'Bonus User', + role: 'user', + status: 'active', + plan_type: 'free', + workspace_root: '/tmp/bonus-user', + balance_cents: 150, + tokens_used: 0, + spent_cents: 0, + }; + const stateBySession = new Map(); + let walletBalance = 150; + let tokensUsed = 0; + let lowBalanceGiftEligible = 1; + let lowBalanceGiftGrantedAt = null; + let ledgerAdjustCount = 0; + let notificationTypes = []; + + const pool = { + async query(sql) { + if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) { + return [[{ ...userRow, balance_cents: walletBalance, tokens_used: tokensUsed }]]; + } + throw new Error(`unexpected pool query: ${sql}`); + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1')) return [[]]; + if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('agent_session_id = agent_session_id')) { + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM h5_session_billing_state') && sql.includes('FOR UPDATE')) { + const row = stateBySession.get(params[0]); + return [row ? [row] : []]; + } + if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('ON DUPLICATE KEY UPDATE')) { + stateBySession.set(params[0], { + last_accumulated_cost: params[2], + last_input_tokens: params[3], + last_output_tokens: params[4], + }); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('SELECT w.balance_cents, w.tokens_used, u.status')) { + return [[{ balance_cents: walletBalance, tokens_used: tokensUsed, status: userRow.status }]]; + } + if (sql.includes('UPDATE h5_user_wallets') && sql.includes('tokens_used = tokens_used + ?')) { + walletBalance = params[0]; + tokensUsed += params[1]; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('INSERT INTO h5_usage_records')) return [{ affectedRows: 1 }, []]; + if (sql.includes("INSERT INTO h5_billing_ledger") && sql.includes("'deduct'")) return [{ affectedRows: 1 }, []]; + if (sql.includes('SELECT low_balance_gift_eligible, low_balance_gift_granted_at')) { + return [[{ + low_balance_gift_eligible: lowBalanceGiftEligible, + low_balance_gift_granted_at: lowBalanceGiftGrantedAt, + }]]; + } + if (sql.includes('UPDATE h5_user_wallets') && sql.includes('SET balance_cents = ?, updated_at = ?')) { + walletBalance = params[0]; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('UPDATE h5_users') && sql.includes('low_balance_gift_eligible = 0')) { + lowBalanceGiftEligible = 0; + lowBalanceGiftGrantedAt = params[0]; + userRow.status = 'active'; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes("INSERT INTO h5_billing_ledger") && sql.includes("'adjust'")) { + ledgerAdjustCount += 1; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('INSERT INTO h5_user_notifications')) { + notificationTypes.push('low_balance_gift'); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('UPDATE h5_users SET status = \'suspended\'')) { + userRow.status = 'suspended'; + return [{ affectedRows: 1 }, []]; + } + throw new Error(`unexpected connection query: ${sql}`); + }, + }; + }, + }; + + const auth = createUserAuth(pool, { persistSessions: false }); + + const first = await auth.billSessionUsage( + userRow.id, + 'session-bonus-1', + { accumulatedOutputTokens: 10_000 }, + 'req-bonus-1', + ); + assert.equal(first.ok, true); + assert.equal(first.costCents, 60); + assert.equal(first.balanceCents, 1090); + assert.equal(ledgerAdjustCount, 1); + assert.deepEqual(notificationTypes, ['low_balance_gift']); + + const second = await auth.billSessionUsage( + userRow.id, + 'session-bonus-2', + { accumulatedOutputTokens: 10_000 }, + 'req-bonus-2', + ); + assert.equal(second.ok, true); + assert.equal(second.costCents, 60); + assert.equal(second.balanceCents, 1030); + assert.equal(ledgerAdjustCount, 1); + assert.deepEqual(notificationTypes, ['low_balance_gift']); +}); + test('updateUser rejects quota smaller than occupied bytes', async () => { const userRow = { id: 'user-3',