diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index cd0f687..626a700 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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-13):G3-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:广告恢复与订阅权益对齐 | # | 任务 | 状态 | 验收标准 | diff --git a/src/__tests__/services/gamification/coin-service.test.ts b/src/__tests__/services/gamification/coin-service.test.ts new file mode 100644 index 0000000..b72a8b1 --- /dev/null +++ b/src/__tests__/services/gamification/coin-service.test.ts @@ -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) { + return { values: valuesSpy.mockResolvedValue(undefined) } as never; +} + +function mockUpdate(setSpy: ReturnType) { + 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(); + }); +}); diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 0738518..2e95b12 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -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('全对')); }); diff --git a/src/services/gamification/coin-service.ts b/src/services/gamification/coin-service.ts new file mode 100644 index 0000000..cd96cfb --- /dev/null +++ b/src/services/gamification/coin-service.ts @@ -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; +} + +export interface GrantCoinsResult { + reward: CoinReward; + granted: boolean; + balanceBefore: number; + balanceAfter: number; +} + +const COIN_REWARD_TITLES: Readonly> = Object.freeze({ + first_daily_challenge: '每日首组挑战', + daily_task: '每日任务', + level_up: '升级奖励', + theme_node: '主题节点', + chest: '宝箱奖励', +}); + +const REWARD_LEDGER_SOURCE: Readonly> = 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> = 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 { + 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 = {}, +): Promise { + 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 { + 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 { + 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 { + 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))); +} diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 7293629..8602057 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -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 { +async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise { 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 ?? []), ];