fix: clamp negative hearts in bootstrap progress
This commit is contained in:
parent
6cb282147e
commit
c29599daaa
1
db/migrations/0005_clamp_user_hearts.sql
Normal file
1
db/migrations/0005_clamp_user_hearts.sql
Normal file
@ -0,0 +1 @@
|
||||
UPDATE `users` SET `hearts_remaining` = 0 WHERE `hearts_remaining` < 0;
|
||||
1
db/migrations/0006_unique_makkari.sql
Normal file
1
db/migrations/0006_unique_makkari.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD CONSTRAINT `chk_users_hearts_remaining_nonnegative` CHECK (`users`.`hearts_remaining` >= 0);
|
||||
3366
db/migrations/meta/0006_snapshot.json
Normal file
3366
db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,20 @@
|
||||
"when": 1780903327517,
|
||||
"tag": "0004_user_region",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -137,5 +137,33 @@ describe('hearts-service', () => {
|
||||
expect(result.success).toBe(false);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
index,
|
||||
check,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
@ -50,6 +51,7 @@ export const users = mysqlTable('users', {
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_auth').on(table.authType, table.authId),
|
||||
index('idx_users_region').on(table.regionCode),
|
||||
check('chk_users_hearts_remaining_nonnegative', sql`${table.heartsRemaining} >= 0`),
|
||||
]);
|
||||
|
||||
// ── Categories ─────────────────────────────────────────────────────
|
||||
|
||||
@ -21,6 +21,11 @@ function toMs(value: Date | string | null): number | null {
|
||||
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.
|
||||
* 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);
|
||||
|
||||
// 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 {
|
||||
remaining,
|
||||
max: MAX_FREE_HEARTS,
|
||||
@ -113,7 +127,7 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r
|
||||
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
|
||||
const protectedFloor = await isNewUserProtected(userId)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user