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'))) {
|
if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`,
|
`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',
|
status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active',
|
||||||
plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
|
plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||||
workspace_root VARCHAR(512) NOT NULL,
|
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,
|
created_at BIGINT NOT NULL,
|
||||||
updated_at BIGINT NOT NULL,
|
updated_at BIGINT NOT NULL,
|
||||||
UNIQUE KEY uq_h5_users_slug (slug),
|
UNIQUE KEY uq_h5_users_slug (slug),
|
||||||
|
|||||||
+94
-9
@@ -129,6 +129,8 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
const publicBaseUrl = resolvePublicBaseUrl(env);
|
const publicBaseUrl = resolvePublicBaseUrl(env);
|
||||||
const skillCatalog = listPlatformSkillCatalog(h5Root);
|
const skillCatalog = listPlatformSkillCatalog(h5Root);
|
||||||
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
|
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 sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
|
||||||
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
|
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
|
||||||
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
|
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 register = async ({ username, password, displayName, email }) => {
|
||||||
const normalized = normalizeUsername(username);
|
const normalized = normalizeUsername(username);
|
||||||
if (!isValidUsername(normalized)) {
|
if (!isValidUsername(normalized)) {
|
||||||
@@ -364,8 +432,9 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_users
|
`INSERT INTO h5_users
|
||||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||||
role, status, plan_type, workspace_root, created_at, updated_at)
|
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`,
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
normalized,
|
normalized,
|
||||||
@@ -815,13 +884,15 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
|
const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents);
|
||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_users
|
`INSERT INTO h5_users
|
||||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||||
role, status, plan_type, workspace_root, created_at, updated_at)
|
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`,
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
normalized,
|
normalized,
|
||||||
@@ -833,6 +904,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
passwordAlgorithm,
|
passwordAlgorithm,
|
||||||
isAdmin ? 'admin' : 'user',
|
isAdmin ? 'admin' : 'user',
|
||||||
root,
|
root,
|
||||||
|
shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
@@ -840,7 +912,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
|
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
|
||||||
VALUES (?, ?, 0, ?)`,
|
VALUES (?, ?, 0, ?)`,
|
||||||
[userId, Number(balanceCents ?? defaultSignupBalanceCents), now],
|
[userId, initialBalanceCents, now],
|
||||||
);
|
);
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
|
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
|
||||||
@@ -1315,7 +1387,11 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
let tokensUsedAfter = null;
|
let tokensUsedAfter = null;
|
||||||
if (costCents > 0) {
|
if (costCents > 0) {
|
||||||
const [walletRows] = await conn.query(
|
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],
|
[userId],
|
||||||
);
|
);
|
||||||
const wallet = walletRows[0];
|
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 = ?`, [
|
await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
|
||||||
now,
|
now,
|
||||||
userId,
|
userId,
|
||||||
@@ -2425,8 +2509,9 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_users
|
`INSERT INTO h5_users
|
||||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||||
role, status, plan_type, workspace_root, signup_source, created_at, updated_at)
|
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
|
||||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`,
|
low_balance_gift_granted_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
normalized,
|
normalized,
|
||||||
|
|||||||
@@ -431,6 +431,130 @@ test('purchaseSpaceQuota deducts balance and increases quota', async () => {
|
|||||||
assert.equal(result.quota.availableBytes, 15 * 1024 * 1024 - 1024 - 2048);
|
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 () => {
|
test('updateUser rejects quota smaller than occupied bytes', async () => {
|
||||||
const userRow = {
|
const userRow = {
|
||||||
id: 'user-3',
|
id: 'user-3',
|
||||||
|
|||||||
Reference in New Issue
Block a user