fix: clamp negative hearts in bootstrap progress
All checks were successful
CI/CD Pipeline / Unit Tests (push) Successful in 19s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m15s

This commit is contained in:
Wang Zhuoxuan 2026-06-10 13:04:27 +08:00
parent 6cb282147e
commit c29599daaa
7 changed files with 3428 additions and 2 deletions

View File

@ -0,0 +1 @@
UPDATE `users` SET `hearts_remaining` = 0 WHERE `hearts_remaining` < 0;

View File

@ -0,0 +1 @@
ALTER TABLE `users` ADD CONSTRAINT `chk_users_hearts_remaining_nonnegative` CHECK (`users`.`hearts_remaining` >= 0);

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,20 @@
"when": 1780903327517, "when": 1780903327517,
"tag": "0004_user_region", "tag": "0004_user_region",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1781066680000,
"tag": "0005_clamp_user_hearts",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1781066751553,
"tag": "0006_unique_makkari",
"breakpoints": true
} }
] ]
} }

View File

@ -137,5 +137,33 @@ describe('hearts-service', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.remaining).toBe(0); expect(result.remaining).toBe(0);
}); });
it('treats negative stored hearts as 0 when deducting', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: -11 }]) as never)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result).toEqual({ success: false, remaining: 0 });
expect(db.update).not.toHaveBeenCalled();
});
});
describe('getHearts', () => {
it('clamps and repairs negative stored hearts before bootstrap can expose them', async () => {
vi.mocked(db.select).mockReturnValueOnce(
selectReturning([{ tier: 'free', heartsRemaining: -11, heartsLastRestore: null }]) as never,
);
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
const { getHearts } = await import('../../../services/progress/hearts-service.js');
const result = await getHearts('user-1');
expect(result).toEqual({ remaining: 0, max: 5, lastRestore: null });
expect(db.update).toHaveBeenCalledOnce();
expect(vi.mocked(db.update).mock.results[0]?.value.set).toHaveBeenCalledWith({ heartsRemaining: 0 });
});
}); });
}); });

View File

@ -14,6 +14,7 @@ import {
uniqueIndex, uniqueIndex,
foreignKey, foreignKey,
index, index,
check,
} from 'drizzle-orm/mysql-core'; } from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
@ -50,6 +51,7 @@ export const users = mysqlTable('users', {
}, (table) => [ }, (table) => [
uniqueIndex('uk_auth').on(table.authType, table.authId), uniqueIndex('uk_auth').on(table.authType, table.authId),
index('idx_users_region').on(table.regionCode), index('idx_users_region').on(table.regionCode),
check('chk_users_hearts_remaining_nonnegative', sql`${table.heartsRemaining} >= 0`),
]); ]);
// ── Categories ───────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────

View File

@ -21,6 +21,11 @@ function toMs(value: Date | string | null): number | null {
return value.getTime(); return value.getTime();
} }
function clampHearts(value: number | null | undefined, max: number): number {
const current = value ?? max;
return Math.min(Math.max(current, 0), max);
}
/** /**
* Get the user's current hearts, accounting for auto-restore. * Get the user's current hearts, accounting for auto-restore.
* Auto-restore: 1 heart per 30 minutes if below MAX_FREE_HEARTS. * Auto-restore: 1 heart per 30 minutes if below MAX_FREE_HEARTS.
@ -50,7 +55,8 @@ export async function getHearts(userId: string): Promise<HeartsInfo> {
}; };
} }
let remaining = user.heartsRemaining ?? MAX_FREE_HEARTS; const rawRemaining = user.heartsRemaining ?? MAX_FREE_HEARTS;
let remaining = clampHearts(rawRemaining, MAX_FREE_HEARTS);
const lastMs = toMs(user.heartsLastRestore); const lastMs = toMs(user.heartsLastRestore);
// Calculate auto-restore // Calculate auto-restore
@ -69,6 +75,14 @@ export async function getHearts(userId: string): Promise<HeartsInfo> {
} }
} }
// 历史脏数据或外部写入可能留下负数/超上限;读取时修正,避免 bootstrap 透传异常值。
if (rawRemaining !== remaining) {
await db
.update(users)
.set({ heartsRemaining: remaining })
.where(eq(users.id, userId));
}
return { return {
remaining, remaining,
max: MAX_FREE_HEARTS, max: MAX_FREE_HEARTS,
@ -113,7 +127,7 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r
return { success: true, remaining: PRO_HEARTS }; return { success: true, remaining: PRO_HEARTS };
} }
const current = user.heartsRemaining ?? MAX_FREE_HEARTS; const current = clampHearts(user.heartsRemaining, MAX_FREE_HEARTS);
// New-user protection: floor = 1 heart for accounts ≤3 days old // New-user protection: floor = 1 heart for accounts ≤3 days old
const protectedFloor = await isNewUserProtected(userId) const protectedFloor = await isNewUserProtected(userId)