diff --git a/docs/api-reference.md b/docs/api-reference.md index ff55c46..b03ed0f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -544,6 +544,41 @@ 购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`。 +#### POST /inventory/items/use + +认证:JWT + +请求: + +```json +{ + "itemId": "hint_feather", + "clientRequestId": "request-uuid", + "questionId": "question-uuid" +} +``` + +`itemId` 取值:`heart_supply`, `double_xp_potion`, `hint_feather`, `streak_shield`。使用 `hint_feather` 时必须传 `questionId`。 + +响应: + +```json +{ + "success": true, + "data": { + "itemId": "hint_feather", + "quantityRemaining": 2, + "effect": { + "type": "hint", + "excludedOptions": ["错误选项 A"] + } + }, + "error": null +} +``` + +效果说明:`heart_supply` 恢复当前用户爱心到上限;`double_xp_potion` 返回 15 分钟有效期 `activeUntil`;`hint_feather` 返回可排除选项;`streak_shield` 返回 `streakProtectedUntil`。`clientRequestId` 用于道具消耗幂等。 + #### GET /subscription 认证:JWT diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 89aa186..9698361 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -76,7 +76,7 @@ | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | | G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | -| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | +| G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | | G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | @@ -84,6 +84,7 @@ 验证记录(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 未签名问题阻塞。 验证记录(2026-05-13):G3-4 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/shop/shop-service.test.ts src/__tests__/services/gamification/coin-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 +验证记录(2026-05-13):G3-5 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/gamification/item-use-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 ## Phase G4:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/gamification/item-use-service.test.ts b/src/__tests__/services/gamification/item-use-service.test.ts new file mode 100644 index 0000000..f4946a5 --- /dev/null +++ b/src/__tests__/services/gamification/item-use-service.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { useInventoryItem } from '../../../services/gamification/item-use-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('item-use-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses heart supply to restore hearts to the user max', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [], // consume: no existing use transaction + [{ itemId: 'heart_supply', quantity: 2, activeUntil: null, metadata: null }], + [{ id: 'inventory-heart' }], + [{ tier: 'free' }], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await useInventoryItem({ + userId: 'user-1', + itemId: 'heart_supply', + clientRequestId: 'use-heart-1', + }); + + expect(result).toEqual({ + itemId: 'heart_supply', + quantityRemaining: 1, + effect: { + type: 'restore_hearts', + hearts: 5, + }, + }); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + heartsRemaining: 5, + heartsLastRestore: expect.any(Object), + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'heart_supply', + direction: 'consume', + quantityDelta: -1, + idempotencyKey: 'use-item:use-heart-1:heart_supply', + })); + }); + + it('uses double XP potion and marks the effect active for 15 minutes', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [], + [{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: null }], + [{ id: 'inventory-xp' }], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await useInventoryItem({ + userId: 'user-1', + itemId: 'double_xp_potion', + clientRequestId: 'use-xp-1', + }); + + expect(result.itemId).toBe('double_xp_potion'); + expect(result.quantityRemaining).toBe(0); + expect(result.effect.type).toBe('double_xp'); + expect(result.effect.activeUntil).toEqual(expect.any(String)); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + activeUntil: expect.any(Date), + metadata: { + activeEffect: 'double_xp', + multiplier: 2, + }, + })); + }); + + it('uses hint feather and returns one distractor to exclude', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [{ distractors: ['错误项 A', '错误项 B'] }], + [], + [{ itemId: 'hint_feather', quantity: 3, activeUntil: null, metadata: null }], + [{ id: 'inventory-hint' }], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await useInventoryItem({ + userId: 'user-1', + itemId: 'hint_feather', + clientRequestId: 'use-hint-1', + questionId: 'question-1', + }); + + expect(result).toEqual({ + itemId: 'hint_feather', + quantityRemaining: 2, + effect: { + type: 'hint', + excludedOptions: ['错误项 A'], + }, + }); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'hint_feather', + direction: 'consume', + snapshot: expect.objectContaining({ + questionId: 'question-1', + excludedOptions: ['错误项 A'], + }), + })); + }); + + it('requires questionId when using hint feather', async () => { + await expect( + useInventoryItem({ + userId: 'user-1', + itemId: 'hint_feather', + clientRequestId: 'use-hint-1', + }), + ).rejects.toThrow('questionId is required'); + }); + + it('uses streak shield and protects the current streak', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [], + [{ itemId: 'streak_shield', quantity: 1, activeUntil: null, metadata: null }], + [{ id: 'inventory-shield' }], + [{ streakDays: 7 }], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await useInventoryItem({ + userId: 'user-1', + itemId: 'streak_shield', + clientRequestId: 'use-shield-1', + }); + + expect(result.itemId).toBe('streak_shield'); + expect(result.quantityRemaining).toBe(0); + expect(result.effect.type).toBe('streak_protection'); + expect(result.effect.streakProtectedUntil).toEqual(expect.any(String)); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + streakProtectedUntil: expect.any(Date), + })); + }); +}); diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index 8476208..5840ad8 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -14,6 +14,7 @@ import { restoreHearts } from '../services/progress/progress-service.js'; import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js'; import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js'; import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js'; +import { useInventoryItem } from '../services/gamification/item-use-service.js'; const rewardSourceSchema = z.object({ source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']), @@ -51,6 +52,12 @@ const shopPurchaseSchema = z.object({ clientRequestId: z.string().min(1).max(80), }); +const useItemSchema = z.object({ + itemId: z.enum(['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather']), + clientRequestId: z.string().min(1).max(80), + questionId: z.string().min(1).optional(), +}); + function getUserId(request: { user: unknown }): string { return (request.user as { userId: string }).userId; } @@ -168,6 +175,18 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + app.post('/inventory/items/use', async (request) => { + const parsed = useItemSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await useInventoryItem({ + userId: getUserId(request), + itemId: parsed.data.itemId, + clientRequestId: parsed.data.clientRequestId, + questionId: parsed.data.questionId, + }); + return { success: true, data, error: null }; + }); + app.get('/subscription', async (request) => { const data = await getClientSubscription(getUserId(request)); return { success: true, data, error: null }; diff --git a/src/services/gamification/item-use-service.ts b/src/services/gamification/item-use-service.ts new file mode 100644 index 0000000..93b7bec --- /dev/null +++ b/src/services/gamification/item-use-service.ts @@ -0,0 +1,191 @@ +import { db } from '../../db/client.js'; +import { questions, userInventoryItems, users } from '../../db/schema.js'; +import { and, eq, sql } from 'drizzle-orm'; +import { NotFoundError, ValidationError } from '../../utils/errors.js'; +import { freezeStreak } from '../progress/streak-service.js'; +import { HEART_RULES, ITEM_RULES, type InventoryItemId } from './rules.js'; +import { consumeInventoryItem } from './inventory-service.js'; +import type { UseInventoryItemResultDto, UsableInventoryItemId } from '../../types/app-api.js'; + +export interface UseInventoryItemInput { + userId: string; + itemId: UsableInventoryItemId; + clientRequestId: string; + questionId?: string; +} + +export async function useInventoryItem(input: UseInventoryItemInput): Promise { + switch (input.itemId) { + case ITEM_RULES.heartSupply.id: + return useHeartSupply(input); + case ITEM_RULES.doubleXpPotion.id: + return useDoubleXpPotion(input); + case ITEM_RULES.hintFeather.id: + return useHintFeather(input); + case ITEM_RULES.streakShield.id: + return useStreakShield(input); + } +} + +async function useHeartSupply(input: UseInventoryItemInput): Promise { + const consumption = await consumeUseItem(input); + const maxHearts = await getMaxHearts(input.userId); + await db + .update(users) + .set({ + heartsRemaining: maxHearts, + heartsLastRestore: sql`NOW()`, + }) + .where(eq(users.id, input.userId)); + + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'restore_hearts', + hearts: maxHearts, + }, + }; +} + +async function useDoubleXpPotion(input: UseInventoryItemInput): Promise { + const consumption = await consumeUseItem(input); + if (!consumption.applied) { + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'double_xp', + activeUntil: consumption.item.activeUntil?.toISOString() ?? null, + }, + }; + } + + const activeUntil = new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs); + await db + .update(userInventoryItems) + .set({ + activeUntil, + metadata: { + activeEffect: 'double_xp', + multiplier: ITEM_RULES.doubleXpPotion.multiplier, + }, + }) + .where(and( + eq(userInventoryItems.userId, input.userId), + eq(userInventoryItems.itemId, input.itemId), + )); + + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'double_xp', + activeUntil: activeUntil.toISOString(), + }, + }; +} + +async function useHintFeather(input: UseInventoryItemInput): Promise { + if (!input.questionId) { + throw new ValidationError('questionId is required for hint feather'); + } + + const excludedOptions = await getHintExcludedOptions(input.questionId); + const consumption = await consumeUseItem(input, { + questionId: input.questionId, + excludedOptions, + }); + + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'hint', + excludedOptions, + }, + }; +} + +async function useStreakShield(input: UseInventoryItemInput): Promise { + const consumption = await consumeUseItem(input); + if (!consumption.applied) { + const protectedUntil = await getStreakProtectedUntil(input.userId); + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'streak_protection', + streakProtectedUntil: protectedUntil, + }, + }; + } + + const protectedUntil = new Date(); + protectedUntil.setUTCDate(protectedUntil.getUTCDate() + ITEM_RULES.streakShield.protectDays); + await db + .update(users) + .set({ streakProtectedUntil: protectedUntil }) + .where(eq(users.id, input.userId)); + await freezeStreak(input.userId); + + return { + itemId: input.itemId, + quantityRemaining: consumption.item.quantity, + effect: { + type: 'streak_protection', + streakProtectedUntil: protectedUntil.toISOString(), + }, + }; +} + +async function getStreakProtectedUntil(userId: string): Promise { + const [user] = await db + .select({ streakProtectedUntil: users.streakProtectedUntil }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const value = user?.streakProtectedUntil ?? null; + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +async function consumeUseItem(input: UseInventoryItemInput, snapshot: Record = {}) { + return consumeInventoryItem({ + userId: input.userId, + itemId: input.itemId as InventoryItemId, + quantity: 1, + sourceType: 'system_adjust', + sourceId: `use-item:${input.clientRequestId}`, + idempotencyKey: `use-item:${input.clientRequestId}:${input.itemId}`, + snapshot, + }); +} + +async function getMaxHearts(userId: string): Promise { + const [user] = await db + .select({ tier: users.tier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.tier === 'pro' || user?.tier === 'proplus' + ? HEART_RULES.subscribedMax + : HEART_RULES.freeMax; +} + +async function getHintExcludedOptions(questionId: string): Promise { + const [question] = await db + .select({ distractors: questions.distractors }) + .from(questions) + .where(eq(questions.id, questionId)) + .limit(1); + + if (!question) throw new NotFoundError('Question'); + const distractors = Array.isArray(question.distractors) + ? question.distractors.filter((item): item is string => typeof item === 'string') + : []; + + return distractors.slice(0, ITEM_RULES.hintFeather.excludedDistractors); +} diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 781f0f0..93e9746 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -108,6 +108,20 @@ export interface ShopPurchaseResultDto { }>; } +export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather'; + +export interface UseInventoryItemResultDto { + itemId: UsableInventoryItemId; + quantityRemaining: number; + effect: { + type: 'restore_hearts' | 'double_xp' | 'hint' | 'streak_protection'; + activeUntil?: string | null; + hearts?: number; + excludedOptions?: readonly string[]; + streakProtectedUntil?: string | null; + }; +} + export interface BootstrapDto { user: UserBriefDto; progress: ProgressSummaryDto;