feat: auto grant one-time low balance gift for new users
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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),
|
||||
|
||||
+94
-9
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user