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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user