实现游戏化金币发放服务

This commit is contained in:
Wang Zhuoxuan 2026-05-13 13:01:00 +08:00
parent 1ad26d0fe8
commit 18865e17ca
5 changed files with 417 additions and 9 deletions

View File

@ -72,7 +72,7 @@
| # | 任务 | 状态 | 验收标准 |
|---|------|------|----------|
| G3-1 | 实现金币发放服务 | [ ] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
| G3-2 | 实现宝箱奖励服务 | [ ] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
@ -80,6 +80,8 @@
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
验证记录2026-05-13G3-1 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。
## Phase G4广告恢复与订阅权益对齐
| # | 任务 | 状态 | 验收标准 |

View File

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { createCoinReward, getCoinRewardAmount, grantCoins } from '../../../services/gamification/coin-service.js';
function selectRows(rows: unknown[]) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
};
}
function mockSelectQueue(queue: unknown[][]) {
let index = 0;
vi.mocked(db.select).mockImplementation((() => {
const rows = index < queue.length ? queue[index]! : [];
index += 1;
return selectRows(rows);
}) as never);
}
function mockInsert(valuesSpy: ReturnType<typeof vi.fn>) {
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
}
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
}
describe('coin-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates configured coin rewards and clamps bounded sources', () => {
expect(createCoinReward('first_daily_challenge')).toEqual({
type: 'coin',
source: 'first_daily_challenge',
amount: 20,
title: '每日首组挑战 +20 金币',
});
expect(getCoinRewardAmount('daily_task', 10)).toBe(30);
expect(getCoinRewardAmount('daily_task', 90)).toBe(80);
expect(getCoinRewardAmount('level_up')).toBe(100);
expect(getCoinRewardAmount('theme_node')).toBe(50);
expect(getCoinRewardAmount('chest', 5)).toBe(20);
expect(getCoinRewardAmount('chest', 260)).toBe(200);
});
it('grants coins with wallet, inventory transaction, reward ledger, and daily progress updates', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[], // no existing idempotency transaction
[{ coinsBalance: 40 }], // wallet before
[{ id: 'daily-1' }], // daily progress row to aggregate earned coins
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await grantCoins({
userId: 'user-1',
source: 'daily_task',
sourceId: 'task-1',
amount: 45,
idempotencyKey: 'daily_task:2026-05-13:task-1',
snapshot: { taskId: 'task-1' },
});
expect(result).toEqual({
reward: {
type: 'coin',
source: 'daily_task',
amount: 45,
title: '每日任务 +45 金币',
},
granted: true,
balanceBefore: 40,
balanceAfter: 85,
});
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
coinsBalance: expect.any(Object),
lifetimeCoinsEarned: expect.any(Object),
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
itemId: 'coins',
direction: 'grant',
quantityDelta: 45,
balanceAfter: 85,
sourceType: 'daily_task',
sourceId: 'task-1',
idempotencyKey: 'daily_task:2026-05-13:task-1',
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
sourceType: 'daily_task',
sourceId: 'task-1',
idempotencyKey: 'daily_task:2026-05-13:task-1',
status: 'completed',
resourceDeltas: { coins: 45 },
}));
});
it('returns the reward without side effects when the idempotency key already exists', async () => {
mockSelectQueue([
[{ id: 'tx-1' }],
[{ coinsBalance: 120 }],
]);
const result = await grantCoins({
userId: 'user-1',
source: 'level_up',
sourceId: 'level-2',
});
expect(result).toEqual({
reward: {
type: 'coin',
source: 'level_up',
amount: 100,
title: '升级奖励 +100 金币',
},
granted: false,
balanceBefore: 120,
balanceAfter: 120,
});
expect(db.insert).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
});
});

View File

@ -428,12 +428,15 @@ describe('challenge-service', () => {
[], // getSubscriptionStatus
[freeUserRow], // getDailyAttempts
[{ used: 0, restored: 0 }], // getHighRewardQuota
[], // no existing daily progress
// updateChapterProgress
[{ id: 'chapter-1', passThreshold: 3 }],
[], // no existing chapter progress
[], // no existing daily progress
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
[], // no existing chapter progress
[], // no existing streak milestone reward
[], // no existing first daily challenge coin transaction
[{ coinsBalance: 40 }], // current wallet balance
[{ id: 'daily-1' }], // daily progress row for coin aggregation
[knowledgeCardRow],
// getProgressSummary (final)
[userAfterXp],
@ -457,6 +460,7 @@ describe('challenge-service', () => {
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
expect.objectContaining({ type: 'coin', source: 'first_daily_challenge', amount: 20, title: '每日首组挑战 +20 金币' }),
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
]),
);
@ -480,11 +484,14 @@ describe('challenge-service', () => {
[],
[userBefore],
[{ used: 0, restored: 0 }],
[], // updateDailyProgress
// updateChapterProgress
[{ id: 'chapter-1', passThreshold: 3 }],
[],
[], // updateDailyProgress
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
[], // no existing chapter progress
[], // no existing first daily challenge coin transaction
[{ coinsBalance: 20 }], // current wallet balance
[{ id: 'daily-1' }], // daily progress row for coin aggregation
[knowledgeCardRow],
// getProgressSummary (final)
[userFinal],
@ -503,6 +510,7 @@ describe('challenge-service', () => {
expect(result.xpDelta).toBe(20);
const rewardTitles = result.rewards.map((r) => r.title);
expect(rewardTitles).toContain('完成挑战 +20 XP');
expect(rewardTitles).toContain('每日首组挑战 +20 金币');
expect(rewardTitles).not.toContain(expect.stringContaining('全对'));
});

View File

@ -0,0 +1,251 @@
import { db } from '../../db/client.js';
import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js';
import { and, eq, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { COIN_RULES } from './rules.js';
export type CoinRewardSource =
| 'first_daily_challenge'
| 'daily_task'
| 'level_up'
| 'theme_node'
| 'chest';
type CoinLedgerSource = 'challenge_completion' | 'daily_task' | 'level_up' | 'theme_node' | 'chest';
type CoinInventorySource = 'challenge' | 'daily_task' | 'level_up' | 'theme_node' | 'chest';
export interface CoinReward {
type: 'coin';
source: CoinRewardSource;
amount: number;
title: string;
}
export interface GrantCoinsInput {
userId: string;
source: CoinRewardSource;
sourceId: string;
idempotencyKey?: string;
amount?: number;
snapshot?: Record<string, unknown>;
}
export interface GrantCoinsResult {
reward: CoinReward;
granted: boolean;
balanceBefore: number;
balanceAfter: number;
}
const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.freeze({
first_daily_challenge: '每日首组挑战',
daily_task: '每日任务',
level_up: '升级奖励',
theme_node: '主题节点',
chest: '宝箱奖励',
});
const REWARD_LEDGER_SOURCE: Readonly<Record<CoinRewardSource, CoinLedgerSource>> = Object.freeze({
first_daily_challenge: 'challenge_completion',
daily_task: 'daily_task',
level_up: 'level_up',
theme_node: 'theme_node',
chest: 'chest',
});
const INVENTORY_SOURCE: Readonly<Record<CoinRewardSource, CoinInventorySource>> = Object.freeze({
first_daily_challenge: 'challenge',
daily_task: 'daily_task',
level_up: 'level_up',
theme_node: 'theme_node',
chest: 'chest',
});
export function getCoinRewardAmount(source: CoinRewardSource, amount?: number): number {
switch (source) {
case 'first_daily_challenge':
return COIN_RULES.firstDailyChallenge;
case 'daily_task':
return clampCoins(amount, COIN_RULES.dailyTaskMin, COIN_RULES.dailyTaskMax);
case 'level_up':
return COIN_RULES.levelUp;
case 'theme_node':
return COIN_RULES.themeNode;
case 'chest':
return clampCoins(amount, COIN_RULES.chestMin, COIN_RULES.chestMax);
}
}
export function createCoinReward(source: CoinRewardSource, amount?: number): CoinReward {
const resolvedAmount = getCoinRewardAmount(source, amount);
return {
type: 'coin',
source,
amount: resolvedAmount,
title: `${COIN_REWARD_TITLES[source]} +${resolvedAmount} 金币`,
};
}
export function createFirstDailyChallengeCoinReward(): CoinReward {
return createCoinReward('first_daily_challenge');
}
export async function grantCoins(input: GrantCoinsInput): Promise<GrantCoinsResult> {
const reward = createCoinReward(input.source, input.amount);
const idempotencyKey = input.idempotencyKey ?? `${input.source}:${input.sourceId}`;
const [existing] = await db
.select({ id: inventoryTransactions.id })
.from(inventoryTransactions)
.where(and(
eq(inventoryTransactions.userId, input.userId),
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
))
.limit(1);
const balanceBefore = await getCoinBalance(input.userId);
if (existing) {
return {
reward,
granted: false,
balanceBefore,
balanceAfter: balanceBefore,
};
}
const balanceAfter = balanceBefore + reward.amount;
await upsertWalletForGrant(input.userId, reward.amount, balanceBefore);
const stateBefore = { coinsBalance: balanceBefore };
const stateAfter = { coinsBalance: balanceAfter };
const sourceType = REWARD_LEDGER_SOURCE[input.source];
const inventorySource = INVENTORY_SOURCE[input.source];
const snapshot = {
source: input.source,
sourceId: input.sourceId,
...(input.snapshot ?? {}),
};
// 钱包流水和奖励流水共用同一个幂等 key便于审计时从业务来源追到余额变化。
await db.insert(inventoryTransactions).values({
id: uuid(),
userId: input.userId,
itemId: 'coins',
direction: 'grant',
quantityDelta: reward.amount,
balanceAfter,
sourceType: inventorySource,
sourceId: input.sourceId,
idempotencyKey,
snapshot,
});
await db.insert(rewardLedger).values({
id: uuid(),
userId: input.userId,
sourceType,
sourceId: input.sourceId,
idempotencyKey,
status: 'completed',
rewardSnapshot: {
rewards: [reward],
...snapshot,
},
resourceDeltas: {
coins: reward.amount,
},
stateBefore,
stateAfter,
settledAt: sql`NOW()`,
});
await incrementDailyCoins(input.userId, reward.amount);
return { reward, granted: true, balanceBefore, balanceAfter };
}
export async function grantFirstDailyChallengeCoins(
userId: string,
sessionId: string,
snapshot: Record<string, unknown> = {},
): Promise<CoinReward | null> {
const result = await grantCoins({
userId,
source: 'first_daily_challenge',
sourceId: sessionId,
idempotencyKey: `first_daily_challenge:${sessionId}`,
snapshot,
});
return result.granted ? result.reward : null;
}
async function getCoinBalance(userId: string): Promise<number> {
const [wallet] = await db
.select({ coinsBalance: userWallets.coinsBalance })
.from(userWallets)
.where(eq(userWallets.userId, userId))
.limit(1);
return wallet?.coinsBalance ?? 0;
}
async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise<void> {
if (balanceBefore === 0) {
const [wallet] = await db
.select({ userId: userWallets.userId })
.from(userWallets)
.where(eq(userWallets.userId, userId))
.limit(1);
if (!wallet) {
await db.insert(userWallets).values({
userId,
coinsBalance: amount,
lifetimeCoinsEarned: amount,
});
return;
}
}
await db
.update(userWallets)
.set({
coinsBalance: sql`COALESCE(coins_balance, 0) + ${amount}`,
lifetimeCoinsEarned: sql`COALESCE(lifetime_coins_earned, 0) + ${amount}`,
})
.where(eq(userWallets.userId, userId));
}
async function incrementDailyCoins(userId: string, amount: number): Promise<void> {
const today = new Date().toISOString().slice(0, 10);
const [daily] = await db
.select({ id: userDailyProgress.id })
.from(userDailyProgress)
.where(and(
eq(userDailyProgress.userId, userId),
eq(userDailyProgress.progressDate, sql`CAST(${today} AS DATE)`),
))
.limit(1);
if (!daily) {
await db.insert(userDailyProgress).values({
id: uuid(),
userId,
progressDate: sql`CAST(${today} AS DATE)`,
coinsEarned: amount,
});
return;
}
await db
.update(userDailyProgress)
.set({
coinsEarned: sql`COALESCE(coins_earned, 0) + ${amount}`,
})
.where(eq(userDailyProgress.id, daily.id));
}
function clampCoins(value: number | undefined, min: number, max: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, Math.floor(value)));
}

View File

@ -9,6 +9,7 @@ import { updateStreakForCompletedChallenge } from '../progress/streak-service.js
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
import { getTrackCategory } from './tracks-service.js';
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
import { grantFirstDailyChallengeCoins } from '../gamification/coin-service.js';
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js';
type QuestionRow = typeof questions.$inferSelect;
@ -218,7 +219,7 @@ async function updateChapterProgress(userId: string, session: ChallengeSessionRo
.where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId)));
}
async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise<void> {
async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise<boolean> {
const progressDate = todayUtc();
const [daily] = await db
.select()
@ -238,9 +239,11 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
xpEarned: xpDelta,
streakCounted: 1,
});
return;
return true;
}
const isFirstChallengeToday = !daily.firstChallengeSessionId;
await db
.update(userDailyProgress)
.set({
@ -252,6 +255,8 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
streakCounted: 1,
})
.where(eq(userDailyProgress.id, daily.id));
return isFirstChallengeToday;
}
export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] {
@ -279,14 +284,23 @@ async function settleCompletedChallenge(
await addXp(userId, xpDelta);
}
const [, , streak] = await Promise.all([
updateChapterProgress(userId, session, correctCount, totalQuestions),
const [dailyFirstChallenge, , streak] = await Promise.all([
updateDailyProgress(userId, session, xpDelta),
updateChapterProgress(userId, session, correctCount, totalQuestions),
updateStreakForCompletedChallenge(userId),
]);
const coinReward = dailyFirstChallenge
? await grantFirstDailyChallengeCoins(userId, session.id, {
correctCount,
totalQuestions,
highRewardEligible: session.highRewardEligible === 1,
})
: null;
const rewards = [
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
...(coinReward ? [coinReward] : []),
...(streak.rewards ?? []),
];