diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 049e96c..426bcd2 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -74,7 +74,7 @@ |---|------|------|----------| | G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 | | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | -| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | +| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | @@ -82,6 +82,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 未签名问题阻塞。 验证记录(2026-05-13):G3-2 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/chest-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification-rules.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 +验证记录(2026-05-13):G3-3 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 ## Phase G4:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/gamification/inventory-service.test.ts b/src/__tests__/services/gamification/inventory-service.test.ts new file mode 100644 index 0000000..6b87851 --- /dev/null +++ b/src/__tests__/services/gamification/inventory-service.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { + consumeInventoryItem, + createInventoryReward, + getInventoryItem, + grantInventoryItem, +} from '../../../services/gamification/inventory-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('inventory-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates display rewards for the first-version items', () => { + expect(createInventoryReward('streak_shield')).toEqual({ + type: 'item', + source: 'inventory', + itemId: 'streak_shield', + quantity: 1, + title: '连胜护盾 x1', + }); + expect(createInventoryReward('double_xp_potion', 2).title).toBe('双倍 XP 药水 x2'); + expect(createInventoryReward('heart_supply').title).toBe('爱心补给 x1'); + expect(createInventoryReward('hint_feather', 3).title).toBe('提示羽毛 x3'); + }); + + it('returns an empty inventory item when no row exists', async () => { + mockSelectQueue([[]]); + + const item = await getInventoryItem('user-1', 'hint_feather'); + + expect(item).toEqual({ + itemId: 'hint_feather', + quantity: 0, + activeUntil: null, + metadata: null, + }); + }); + + it('grants a new item with inventory transaction and reward ledger records', async () => { + const insertValues = vi.fn(); + mockSelectQueue([ + [], // no existing transaction + [], // no current item + [], // no inventory row before insert + [{ id: 'inventory-1' }], // inventory row id for transaction + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + + const result = await grantInventoryItem({ + userId: 'user-1', + itemId: 'hint_feather', + quantity: 2, + sourceType: 'daily_task', + sourceId: 'task-1', + idempotencyKey: 'daily_task:task-1:hint', + }); + + expect(result).toEqual({ + item: { + itemId: 'hint_feather', + quantity: 2, + activeUntil: null, + metadata: null, + }, + quantityDelta: 2, + applied: true, + }); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + itemId: 'hint_feather', + quantity: 2, + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + inventoryItemId: 'inventory-1', + itemId: 'hint_feather', + direction: 'grant', + quantityDelta: 2, + balanceAfter: 2, + sourceType: 'daily_task', + sourceId: 'task-1', + idempotencyKey: 'daily_task:task-1:hint', + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + sourceType: 'daily_task', + sourceId: 'task-1', + idempotencyKey: 'daily_task:task-1:hint', + status: 'completed', + resourceDeltas: { + items: [{ itemId: 'hint_feather', quantity: 2 }], + }, + })); + }); + + it('increments an existing item and preserves active metadata', async () => { + const updateSet = vi.fn(); + const insertValues = vi.fn(); + const activeUntil = new Date('2026-05-13T12:00:00.000Z'); + mockSelectQueue([ + [], // no existing transaction + [{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: { source: 'old' } }], + [{ id: 'inventory-2' }], + ]); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + + const result = await grantInventoryItem({ + userId: 'user-1', + itemId: 'double_xp_potion', + quantity: 1, + sourceType: 'chest', + sourceId: 'chest-1', + activeUntil, + metadata: { source: 'chest' }, + }); + + expect(result.item).toEqual({ + itemId: 'double_xp_potion', + quantity: 2, + activeUntil, + metadata: { source: 'chest' }, + }); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + quantity: expect.any(Object), + activeUntil, + metadata: { source: 'chest' }, + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'double_xp_potion', + direction: 'grant', + quantityDelta: 1, + balanceAfter: 2, + sourceType: 'chest', + })); + }); + + it('does not apply duplicate grants with the same idempotency key', async () => { + mockSelectQueue([ + [{ id: 'tx-1' }], + [{ itemId: 'heart_supply', quantity: 1, activeUntil: null, metadata: null }], + ]); + + const result = await grantInventoryItem({ + userId: 'user-1', + itemId: 'heart_supply', + sourceType: 'ad_recovery', + sourceId: 'ad-1', + idempotencyKey: 'ad-1:heart_supply', + }); + + expect(result).toEqual({ + item: { + itemId: 'heart_supply', + quantity: 1, + activeUntil: null, + metadata: null, + }, + quantityDelta: 0, + applied: false, + }); + expect(db.insert).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('consumes existing inventory and records a negative transaction', async () => { + const updateSet = vi.fn(); + const insertValues = vi.fn(); + mockSelectQueue([ + [], // no existing transaction + [{ itemId: 'streak_shield', quantity: 2, activeUntil: null, metadata: null }], + [{ id: 'inventory-3' }], + ]); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + + const result = await consumeInventoryItem({ + userId: 'user-1', + itemId: 'streak_shield', + quantity: 1, + sourceType: 'system_adjust', + sourceId: 'protect-1', + idempotencyKey: 'protect-1:streak_shield', + }); + + expect(result).toEqual({ + item: { + itemId: 'streak_shield', + quantity: 1, + activeUntil: null, + metadata: null, + }, + quantityDelta: -1, + applied: true, + }); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + quantity: expect.any(Object), + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'streak_shield', + direction: 'consume', + quantityDelta: -1, + balanceAfter: 1, + sourceType: 'system_adjust', + idempotencyKey: 'protect-1:streak_shield', + })); + }); + + it('throws when consuming more items than available', async () => { + mockSelectQueue([ + [], // no existing transaction + [{ itemId: 'hint_feather', quantity: 0, activeUntil: null, metadata: null }], + ]); + + await expect( + consumeInventoryItem({ + userId: 'user-1', + itemId: 'hint_feather', + quantity: 1, + sourceType: 'challenge', + sourceId: 'challenge-1', + }), + ).rejects.toThrow('提示羽毛库存不足'); + }); +}); diff --git a/src/services/gamification/inventory-service.ts b/src/services/gamification/inventory-service.ts new file mode 100644 index 0000000..c1e77e6 --- /dev/null +++ b/src/services/gamification/inventory-service.ts @@ -0,0 +1,320 @@ +import { db } from '../../db/client.js'; +import { inventoryTransactions, rewardLedger, userInventoryItems } from '../../db/schema.js'; +import { and, eq, sql } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import { ValidationError } from '../../utils/errors.js'; +import { ITEM_RULES, type InventoryItemId } from './rules.js'; + +export type InventorySourceType = + | 'challenge' + | 'daily_task' + | 'level_up' + | 'theme_node' + | 'chest' + | 'shop_purchase' + | 'ad_recovery' + | 'subscription' + | 'admin_grant' + | 'system_adjust'; + +type RewardLedgerSourceType = + | 'challenge_completion' + | 'daily_task' + | 'level_up' + | 'theme_node' + | 'chest' + | 'shop_purchase' + | 'ad_recovery' + | 'subscription' + | 'admin_grant' + | 'system_adjust'; + +export interface InventoryItemSnapshot { + itemId: InventoryItemId; + quantity: number; + activeUntil: Date | null; + metadata: Record | null; +} + +export interface GrantInventoryItemInput { + userId: string; + itemId: InventoryItemId; + quantity?: number; + sourceType: InventorySourceType; + sourceId: string; + idempotencyKey?: string; + activeUntil?: Date | null; + metadata?: Record; + snapshot?: Record; +} + +export interface ConsumeInventoryItemInput { + userId: string; + itemId: InventoryItemId; + quantity?: number; + sourceType: InventorySourceType; + sourceId: string; + idempotencyKey?: string; + snapshot?: Record; +} + +export interface InventoryMutationResult { + item: InventoryItemSnapshot; + quantityDelta: number; + applied: boolean; +} + +const ITEM_TITLES: Readonly> = Object.freeze({ + streak_shield: '连胜护盾', + double_xp_potion: '双倍 XP 药水', + heart_supply: '爱心补给', + hint_feather: '提示羽毛', + mascot_outfit: '吉祥物装扮', +}); + +const REWARD_LEDGER_SOURCE: Readonly> = Object.freeze({ + challenge: 'challenge_completion', + daily_task: 'daily_task', + level_up: 'level_up', + theme_node: 'theme_node', + chest: 'chest', + shop_purchase: 'shop_purchase', + ad_recovery: 'ad_recovery', + subscription: 'subscription', + admin_grant: 'admin_grant', + system_adjust: 'system_adjust', +}); + +export function createInventoryReward(itemId: InventoryItemId, quantity = 1) { + const safeQuantity = normalizeQuantity(quantity); + return { + type: 'item' as const, + source: 'inventory' as const, + itemId, + quantity: safeQuantity, + title: `${ITEM_TITLES[itemId]} x${safeQuantity}`, + }; +} + +export async function getInventoryItem(userId: string, itemId: InventoryItemId): Promise { + const [item] = await db + .select({ + itemId: userInventoryItems.itemId, + quantity: userInventoryItems.quantity, + activeUntil: userInventoryItems.activeUntil, + metadata: userInventoryItems.metadata, + }) + .from(userInventoryItems) + .where(and( + eq(userInventoryItems.userId, userId), + eq(userInventoryItems.itemId, itemId), + )) + .limit(1); + + return { + itemId, + quantity: item?.quantity ?? 0, + activeUntil: item?.activeUntil ?? null, + metadata: item?.metadata ?? null, + }; +} + +export async function grantInventoryItem(input: GrantInventoryItemInput): Promise { + const quantity = normalizeQuantity(input.quantity); + const idempotencyKey = input.idempotencyKey ?? `grant_item:${input.itemId}:${input.sourceType}:${input.sourceId}`; + const existing = await getExistingTransaction(input.userId, idempotencyKey); + const itemBefore = await getInventoryItem(input.userId, input.itemId); + + if (existing) { + return { item: itemBefore, quantityDelta: 0, applied: false }; + } + + const itemAfter = await upsertInventoryForGrant(input, quantity, itemBefore); + await db.insert(inventoryTransactions).values({ + id: uuid(), + userId: input.userId, + inventoryItemId: await getInventoryRowId(input.userId, input.itemId), + itemId: input.itemId, + direction: 'grant', + quantityDelta: quantity, + balanceAfter: itemAfter.quantity, + sourceType: input.sourceType, + sourceId: input.sourceId, + idempotencyKey, + snapshot: buildSnapshot(input, itemBefore, itemAfter), + }); + + const reward = createInventoryReward(input.itemId, quantity); + await db.insert(rewardLedger).values({ + id: uuid(), + userId: input.userId, + sourceType: REWARD_LEDGER_SOURCE[input.sourceType], + sourceId: input.sourceId, + idempotencyKey, + status: 'completed', + rewardSnapshot: { + rewards: [reward], + ...buildSnapshot(input, itemBefore, itemAfter), + }, + resourceDeltas: { + items: [{ itemId: input.itemId, quantity }], + }, + stateBefore: { item: itemBefore }, + stateAfter: { item: itemAfter }, + settledAt: sql`NOW()`, + }); + + return { item: itemAfter, quantityDelta: quantity, applied: true }; +} + +export async function consumeInventoryItem(input: ConsumeInventoryItemInput): Promise { + const quantity = normalizeQuantity(input.quantity); + const idempotencyKey = input.idempotencyKey ?? `consume_item:${input.itemId}:${input.sourceType}:${input.sourceId}`; + const existing = await getExistingTransaction(input.userId, idempotencyKey); + const itemBefore = await getInventoryItem(input.userId, input.itemId); + + if (existing) { + return { item: itemBefore, quantityDelta: 0, applied: false }; + } + + if (itemBefore.quantity < quantity) { + throw new ValidationError(`${ITEM_TITLES[input.itemId]}库存不足`); + } + + const itemAfter: InventoryItemSnapshot = { + ...itemBefore, + quantity: itemBefore.quantity - quantity, + }; + + await db + .update(userInventoryItems) + .set({ + quantity: sql`GREATEST(COALESCE(quantity, 0) - ${quantity}, 0)`, + }) + .where(and( + eq(userInventoryItems.userId, input.userId), + eq(userInventoryItems.itemId, input.itemId), + )); + + await db.insert(inventoryTransactions).values({ + id: uuid(), + userId: input.userId, + inventoryItemId: await getInventoryRowId(input.userId, input.itemId), + itemId: input.itemId, + direction: 'consume', + quantityDelta: -quantity, + balanceAfter: itemAfter.quantity, + sourceType: input.sourceType, + sourceId: input.sourceId, + idempotencyKey, + snapshot: buildSnapshot(input, itemBefore, itemAfter), + }); + + return { item: itemAfter, quantityDelta: -quantity, applied: true }; +} + +async function upsertInventoryForGrant( + input: GrantInventoryItemInput, + quantity: number, + itemBefore: InventoryItemSnapshot, +): Promise { + const activeUntil = resolveActiveUntil(input); + const metadata = input.metadata ?? itemBefore.metadata; + + if (itemBefore.quantity === 0) { + const [row] = await db + .select({ id: userInventoryItems.id }) + .from(userInventoryItems) + .where(and( + eq(userInventoryItems.userId, input.userId), + eq(userInventoryItems.itemId, input.itemId), + )) + .limit(1); + + if (!row) { + await db.insert(userInventoryItems).values({ + id: uuid(), + userId: input.userId, + itemId: input.itemId, + quantity, + activeUntil: activeUntil ?? undefined, + metadata: metadata ?? undefined, + }); + return { itemId: input.itemId, quantity, activeUntil, metadata }; + } + } + + await db + .update(userInventoryItems) + .set({ + quantity: sql`COALESCE(quantity, 0) + ${quantity}`, + activeUntil: activeUntil ?? undefined, + metadata: metadata ?? undefined, + }) + .where(and( + eq(userInventoryItems.userId, input.userId), + eq(userInventoryItems.itemId, input.itemId), + )); + + return { + itemId: input.itemId, + quantity: itemBefore.quantity + quantity, + activeUntil, + metadata, + }; +} + +async function getExistingTransaction(userId: string, idempotencyKey: string) { + const [existing] = await db + .select({ id: inventoryTransactions.id }) + .from(inventoryTransactions) + .where(and( + eq(inventoryTransactions.userId, userId), + eq(inventoryTransactions.idempotencyKey, idempotencyKey), + )) + .limit(1); + + return existing ?? null; +} + +async function getInventoryRowId(userId: string, itemId: InventoryItemId): Promise { + const [row] = await db + .select({ id: userInventoryItems.id }) + .from(userInventoryItems) + .where(and( + eq(userInventoryItems.userId, userId), + eq(userInventoryItems.itemId, itemId), + )) + .limit(1); + + return row?.id ?? null; +} + +function buildSnapshot( + input: GrantInventoryItemInput | ConsumeInventoryItemInput, + before: InventoryItemSnapshot, + after: InventoryItemSnapshot, +): Record { + return { + sourceType: input.sourceType, + sourceId: input.sourceId, + itemBefore: before, + itemAfter: after, + ...(input.snapshot ?? {}), + }; +} + +function normalizeQuantity(value = 1): number { + if (!Number.isFinite(value) || value <= 0) { + throw new ValidationError('道具数量必须大于 0'); + } + return Math.floor(value); +} + +function resolveActiveUntil(input: GrantInventoryItemInput): Date | null { + if (input.activeUntil !== undefined) return input.activeUntil; + if (input.itemId === ITEM_RULES.doubleXpPotion.id && input.snapshot?.activatedAt === true) { + return new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs); + } + return null; +}