From 6bf9db9820921d28a7ebd275f4066e529927b839 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 17:38:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=A9=E5=B1=95=E6=B8=B8=E6=88=8F=E5=8C=96?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=95=86=E5=BA=97=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference.md | 30 +++++++ docs/gamification-server-plan.md | 3 +- .../services/app/bootstrap-service.test.ts | 79 +++++++++++++++++++ .../gamification/inventory-service.test.ts | 18 +++++ src/services/app/bootstrap-service.ts | 17 +++- src/services/gamification/coin-service.ts | 2 +- .../gamification/inventory-service.ts | 36 +++++++++ src/types/app-api.ts | 18 +++++ 8 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/services/app/bootstrap-service.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index b03ed0f..a6ced45 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -215,6 +215,34 @@ }, "tracks": [], "shopBenefits": [], + "shop": { + "benefits": [], + "products": [ + { + "id": "hint-feather", + "type": "item", + "itemId": "hint_feather", + "title": "提示羽毛", + "description": "答题时排除 1 个错误选项", + "priceCoins": 80, + "quantity": 1, + "enabled": true + } + ] + }, + "wallet": { + "coinsBalance": 260 + }, + "inventory": { + "items": [ + { + "itemId": "hint_feather", + "quantity": 2, + "activeUntil": null, + "metadata": null + } + ] + }, "subscription": { "status": "none", "tier": "free", @@ -226,6 +254,8 @@ } ``` +说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。 + #### GET /tracks 认证:JWT diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 9698361..8341c2c 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -77,7 +77,7 @@ | G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | -| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | +| G3-6 | 更新 bootstrap/shop DTO | [x] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | | 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 未签名问题阻塞。 @@ -85,6 +85,7 @@ 验证记录(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 未签名问题阻塞。 +验证记录(2026-05-13):G3-6 已通过 `./node_modules/.bin/tsc --noEmit`、`./node_modules/.bin/eslint .` 和 `git diff --check`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/app/bootstrap-service.test.ts src/__tests__/services/gamification/inventory-service.test.ts src/__tests__/services/shop/shop-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 未签名问题阻塞。 ## Phase G4:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/app/bootstrap-service.test.ts b/src/__tests__/services/app/bootstrap-service.test.ts new file mode 100644 index 0000000..28fcb04 --- /dev/null +++ b/src/__tests__/services/app/bootstrap-service.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { getBootstrap } from '../../../services/app/bootstrap-service.js'; + +function selectRows(rows: unknown[]) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(rows), + limit: vi.fn().mockResolvedValue(rows), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve), + }), + orderBy: 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 mockUpdate() { + return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never; +} + +describe('bootstrap-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('includes wallet, inventory, shop catalog, ad benefits, and subscription in bootstrap', async () => { + mockSelectQueue([ + [{ id: 'user-1', nickname: '多奇', avatarUrl: null, tier: 'free', xpTotal: 100 }], + // getProgressSummary + [{ id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null, dailyAttemptsLeft: 5, dailyAttemptsDate: new Date(), checkInDays: 1, lastCheckInDate: new Date(), streakProtectedUntil: null, heartsRemaining: 5 }], + [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], + [{ streakDays: 1, streakLastDate: new Date() }], + [], + [{ id: 'user-1', tier: 'free', xpTotal: 100, activeTrackId: null, dailyAttemptsLeft: 5, dailyAttemptsDate: new Date(), checkInDays: 1, lastCheckInDate: new Date(), streakProtectedUntil: null, heartsRemaining: 5 }], + [{ used: 1, restored: 0 }], + // getThemeTracks + [], + // getClientSubscription + [], + // getCoinBalance + [{ coinsBalance: 260 }], + // getClientInventory + [{ itemId: 'hint_feather', quantity: 2, activeUntil: null, metadata: null }], + ]); + vi.mocked(db.update).mockReturnValue(mockUpdate()); + + const result = await getBootstrap('user-1'); + + expect(result.user).toEqual({ + id: 'user-1', + nickname: '多奇', + avatarUrl: null, + tier: 'free', + level: 2, + }); + expect(result.wallet).toEqual({ coinsBalance: 260 }); + expect(result.inventory.items).toEqual([ + { itemId: 'hint_feather', quantity: 2, activeUntil: null, metadata: null }, + ]); + expect(result.shop.products.map((product) => product.id)).toContain('hint-feather'); + expect(result.shopBenefits).toBe(result.shop.benefits); + expect(result.subscription).toEqual({ + status: 'none', + tier: 'free', + expiresAt: null, + autoRenew: false, + }); + }); +}); diff --git a/src/__tests__/services/gamification/inventory-service.test.ts b/src/__tests__/services/gamification/inventory-service.test.ts index 6b87851..48cb025 100644 --- a/src/__tests__/services/gamification/inventory-service.test.ts +++ b/src/__tests__/services/gamification/inventory-service.test.ts @@ -3,6 +3,7 @@ import { db } from '../../../db/client.js'; import { consumeInventoryItem, createInventoryReward, + getClientInventory, getInventoryItem, grantInventoryItem, } from '../../../services/gamification/inventory-service.js'; @@ -65,6 +66,23 @@ describe('inventory-service', () => { }); }); + it('returns client inventory with ISO active time', async () => { + mockSelectQueue([ + [{ itemId: 'double_xp_potion', quantity: 1, activeUntil: new Date('2026-05-13T12:00:00.000Z'), metadata: { activeEffect: 'double_xp' } }], + ]); + + const result = await getClientInventory('user-1'); + + expect(result).toEqual({ + items: [{ + itemId: 'double_xp_potion', + quantity: 1, + activeUntil: '2026-05-13T12:00:00.000Z', + metadata: { activeEffect: 'double_xp' }, + }], + }); + }); + it('grants a new item with inventory transaction and reward ledger records', async () => { const insertValues = vi.fn(); mockSelectQueue([ diff --git a/src/services/app/bootstrap-service.ts b/src/services/app/bootstrap-service.ts index e7e8d39..35bb574 100644 --- a/src/services/app/bootstrap-service.ts +++ b/src/services/app/bootstrap-service.ts @@ -3,8 +3,10 @@ import { users } from '../../db/schema.js'; import { eq } from 'drizzle-orm'; import { getProgressSummary, getLevelInfo } from '../learning/progress-summary-service.js'; import { getThemeTracks } from '../learning/tracks-service.js'; -import { getShopBenefits } from '../shop/shop-service.js'; +import { getShopCatalog } from '../shop/shop-service.js'; import { getClientSubscription } from '../subscription/subscription-api-service.js'; +import { getCoinBalance } from '../gamification/coin-service.js'; +import { getClientInventory } from '../gamification/inventory-service.js'; import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js'; export async function getBootstrap(userId: string): Promise { @@ -20,11 +22,13 @@ export async function getBootstrap(userId: string): Promise { .where(eq(users.id, userId)) .limit(1); - const [progress, tracks, shopBenefits, subscription] = await Promise.all([ + const [progress, tracks, shop, subscription, coinsBalance, inventory] = await Promise.all([ getProgressSummary(userId), getThemeTracks(userId), - getShopBenefits(), + getShopCatalog(), getClientSubscription(userId), + getCoinBalance(userId), + getClientInventory(userId), ]); const xp = user?.xpTotal ?? progress.xp; @@ -40,7 +44,12 @@ export async function getBootstrap(userId: string): Promise { }, progress, tracks, - shopBenefits, + shopBenefits: shop.benefits, + shop, + wallet: { + coinsBalance, + }, + inventory, subscription, }; } diff --git a/src/services/gamification/coin-service.ts b/src/services/gamification/coin-service.ts index 33c43bb..6bef24e 100644 --- a/src/services/gamification/coin-service.ts +++ b/src/services/gamification/coin-service.ts @@ -195,7 +195,7 @@ export async function grantFirstDailyChallengeCoins( return result.granted ? result.reward : null; } -async function getCoinBalance(userId: string): Promise { +export async function getCoinBalance(userId: string): Promise { const [wallet] = await db .select({ coinsBalance: userWallets.coinsBalance }) .from(userWallets) diff --git a/src/services/gamification/inventory-service.ts b/src/services/gamification/inventory-service.ts index c1e77e6..7d0acc1 100644 --- a/src/services/gamification/inventory-service.ts +++ b/src/services/gamification/inventory-service.ts @@ -4,6 +4,7 @@ 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'; +import type { InventoryDto, InventoryItemDto } from '../../types/app-api.js'; export type InventorySourceType = | 'challenge' @@ -119,6 +120,22 @@ export async function getInventoryItem(userId: string, itemId: InventoryItemId): }; } +export async function getClientInventory(userId: string): Promise { + const rows = await db + .select({ + itemId: userInventoryItems.itemId, + quantity: userInventoryItems.quantity, + activeUntil: userInventoryItems.activeUntil, + metadata: userInventoryItems.metadata, + }) + .from(userInventoryItems) + .where(eq(userInventoryItems.userId, userId)); + + return { + items: rows.map(toInventoryItemDto), + }; +} + export async function grantInventoryItem(input: GrantInventoryItemInput): Promise { const quantity = normalizeQuantity(input.quantity); const idempotencyKey = input.idempotencyKey ?? `grant_item:${input.itemId}:${input.sourceType}:${input.sourceId}`; @@ -318,3 +335,22 @@ function resolveActiveUntil(input: GrantInventoryItemInput): Date | null { } return null; } + +function toInventoryItemDto(item: { + itemId: InventoryItemId; + quantity: number | null; + activeUntil: Date | string | null; + metadata: Record | null; +}): InventoryItemDto { + return { + itemId: item.itemId, + quantity: item.quantity ?? 0, + activeUntil: toIso(item.activeUntil), + metadata: item.metadata ?? null, + }; +} + +function toIso(value: Date | string | null): string | null { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 93e9746..ae59aa1 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -108,6 +108,21 @@ export interface ShopPurchaseResultDto { }>; } +export interface WalletDto { + coinsBalance: number; +} + +export interface InventoryItemDto { + itemId: string; + quantity: number; + activeUntil: string | null; + metadata: Record | null; +} + +export interface InventoryDto { + items: readonly InventoryItemDto[]; +} + export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather'; export interface UseInventoryItemResultDto { @@ -127,6 +142,9 @@ export interface BootstrapDto { progress: ProgressSummaryDto; tracks: ThemeTrackDto[]; shopBenefits: readonly ShopBenefitDto[]; + shop: ShopCatalogDto; + wallet: WalletDto; + inventory: InventoryDto; subscription: SubscriptionDto; }