diff --git a/docs/api-reference.md b/docs/api-reference.md index 142ff18..ff55c46 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -457,22 +457,93 @@ ```json { "success": true, - "data": [ - { - "id": "restore-hearts", - "type": "hearts", - "title": "恢复满血", - "description": "血量不足时继续挑战", - "enabled": true, - "requiresAd": true - } - ], + "data": { + "benefits": [ + { + "id": "restore-hearts", + "type": "hearts", + "title": "恢复满血", + "description": "血量不足时继续挑战", + "enabled": true, + "requiresAd": true + } + ], + "products": [ + { + "id": "hint-feather", + "type": "item", + "itemId": "hint_feather", + "title": "提示羽毛", + "description": "答题时排除 1 个错误选项", + "priceCoins": 80, + "quantity": 1, + "enabled": true + } + ] + }, "error": null } ``` 说明:`requiresAd=true` 的权益应通过 `/rewards/ad-recovery/session` 和 `/rewards/ad-recovery/complete` 完成资格检查和结算。 +可购买商品价格:提示羽毛 80 金币,爱心补给 150 金币,双倍 XP 药水 250 金币,连胜护盾 400 金币,第一版吉祥物装扮 800 金币。 + +#### POST /shop/purchase + +认证:JWT + +请求: + +```json +{ + "productId": "hint-feather", + "clientRequestId": "request-uuid" +} +``` + +`productId` 取值:`hint-feather`, `heart-supply`, `double-xp-potion`, `streak-shield`, `mascot-outfit-starter`。 + +响应: + +```json +{ + "success": true, + "data": { + "product": { + "id": "hint-feather", + "type": "item", + "itemId": "hint_feather", + "title": "提示羽毛", + "description": "答题时排除 1 个错误选项", + "priceCoins": 80, + "quantity": 1, + "enabled": true + }, + "coinsSpent": 80, + "coinsBalance": 220, + "item": { + "itemId": "hint_feather", + "quantity": 3, + "activeUntil": null, + "metadata": null + }, + "rewards": [ + { + "type": "item", + "source": "inventory", + "itemId": "hint_feather", + "quantity": 1, + "title": "提示羽毛 x1" + } + ] + }, + "error": null +} +``` + +购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`。 + #### GET /subscription 认证:JWT diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 426bcd2..89aa186 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -75,7 +75,7 @@ | G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 | | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | | G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | -| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | +| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | | G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | @@ -83,6 +83,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 未签名问题阻塞。 +验证记录(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 未签名问题阻塞。 ## Phase G4:广告恢复与订阅权益对齐 diff --git a/src/__tests__/services/gamification/coin-service.test.ts b/src/__tests__/services/gamification/coin-service.test.ts index b72a8b1..d440bbd 100644 --- a/src/__tests__/services/gamification/coin-service.test.ts +++ b/src/__tests__/services/gamification/coin-service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '../../../db/client.js'; -import { createCoinReward, getCoinRewardAmount, grantCoins } from '../../../services/gamification/coin-service.js'; +import { createCoinReward, getCoinRewardAmount, grantCoins, spendCoins } from '../../../services/gamification/coin-service.js'; function selectRows(rows: unknown[]) { return { @@ -130,4 +130,82 @@ describe('coin-service', () => { expect(db.insert).not.toHaveBeenCalled(); expect(db.update).not.toHaveBeenCalled(); }); + + it('spends coins with a consume transaction for shop purchases', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [], // no existing spend transaction + [{ coinsBalance: 300 }], + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await spendCoins({ + userId: 'user-1', + amount: 80, + sourceId: 'shop:request-1', + idempotencyKey: 'shop:request-1:coins', + snapshot: { productId: 'hint-feather' }, + }); + + expect(result).toEqual({ + spent: 80, + applied: true, + balanceBefore: 300, + balanceAfter: 220, + }); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + coinsBalance: expect.any(Object), + lifetimeCoinsSpent: expect.any(Object), + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + itemId: 'coins', + direction: 'consume', + quantityDelta: -80, + balanceAfter: 220, + sourceType: 'shop_purchase', + sourceId: 'shop:request-1', + idempotencyKey: 'shop:request-1:coins', + })); + }); + + it('does not spend coins twice for a duplicate idempotency key', async () => { + mockSelectQueue([ + [{ id: 'tx-1' }], + [{ coinsBalance: 220 }], + ]); + + const result = await spendCoins({ + userId: 'user-1', + amount: 80, + sourceId: 'shop:request-1', + idempotencyKey: 'shop:request-1:coins', + }); + + expect(result).toEqual({ + spent: 80, + applied: false, + balanceBefore: 220, + balanceAfter: 220, + }); + expect(db.insert).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('throws when spending more coins than the current balance', async () => { + mockSelectQueue([ + [], + [{ coinsBalance: 20 }], + ]); + + await expect( + spendCoins({ + userId: 'user-1', + amount: 80, + sourceId: 'shop:request-1', + }), + ).rejects.toThrow('金币余额不足'); + }); }); diff --git a/src/__tests__/services/shop/shop-service.test.ts b/src/__tests__/services/shop/shop-service.test.ts index b90b558..5ea0d71 100644 --- a/src/__tests__/services/shop/shop-service.test.ts +++ b/src/__tests__/services/shop/shop-service.test.ts @@ -1,7 +1,39 @@ -import { describe, expect, it } from 'vitest'; -import { getShopBenefits } from '../../../services/shop/shop-service.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { getShopBenefits, getShopCatalog, purchaseShopProduct } from '../../../services/shop/shop-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('shop-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('returns the client shop benefit catalog', async () => { const benefits = await getShopBenefits(); @@ -13,4 +45,75 @@ describe('shop-service', () => { ]); expect(benefits.every((item) => item.enabled)).toBe(true); }); + + it('returns shop products with configured prices', async () => { + const catalog = await getShopCatalog(); + + expect(catalog.benefits).toHaveLength(4); + expect(catalog.products.map((item) => [item.id, item.priceCoins])).toEqual([ + ['hint-feather', 80], + ['heart-supply', 150], + ['double-xp-potion', 250], + ['streak-shield', 400], + ['mascot-outfit-starter', 800], + ]); + }); + + it('purchases a product by spending coins and granting inventory', async () => { + const insertValues = vi.fn(); + const updateSet = vi.fn(); + mockSelectQueue([ + [], // spendCoins: no existing spend + [{ coinsBalance: 300 }], + [], // grantInventoryItem: no existing item grant + [], // current item + [], // no existing inventory row + [{ id: 'inventory-1' }], // inventory row id for transaction + ]); + vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues)); + vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet)); + + const result = await purchaseShopProduct('user-1', 'hint-feather', 'request-1'); + + expect(result.product.id).toBe('hint-feather'); + expect(result.coinsSpent).toBe(80); + expect(result.coinsBalance).toBe(220); + expect(result.item).toEqual({ + itemId: 'hint_feather', + quantity: 1, + activeUntil: null, + metadata: null, + }); + expect(result.rewards).toEqual([ + expect.objectContaining({ type: 'item', itemId: 'hint_feather', quantity: 1 }), + ]); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ + coinsBalance: expect.any(Object), + lifetimeCoinsSpent: expect.any(Object), + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'coins', + direction: 'consume', + quantityDelta: -80, + sourceType: 'shop_purchase', + idempotencyKey: 'shop:request-1:coins', + })); + expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ + itemId: 'hint_feather', + direction: 'grant', + sourceType: 'shop_purchase', + idempotencyKey: 'shop:request-1:item', + })); + }); + + it('throws when the user does not have enough coins', async () => { + mockSelectQueue([ + [], // no existing spend + [{ coinsBalance: 20 }], + ]); + + await expect( + purchaseShopProduct('user-1', 'hint-feather', 'request-1'), + ).rejects.toThrow('金币余额不足'); + }); }); diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index 8ad3266..8476208 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -12,7 +12,7 @@ import { } from '../services/learning/progress-summary-service.js'; import { restoreHearts } from '../services/progress/progress-service.js'; import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js'; -import { getShopBenefits } from '../services/shop/shop-service.js'; +import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-service.js'; import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js'; const rewardSourceSchema = z.object({ @@ -46,6 +46,11 @@ const subscriptionVerifySchema = z.object({ tier: z.enum(['pro', 'proplus']), }); +const shopPurchaseSchema = z.object({ + productId: z.enum(['hint-feather', 'heart-supply', 'double-xp-potion', 'streak-shield', 'mascot-outfit-starter']), + clientRequestId: z.string().min(1).max(80), +}); + function getUserId(request: { user: unknown }): string { return (request.user as { userId: string }).userId; } @@ -152,7 +157,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise { }); app.get('/shop', async () => { - const data = await getShopBenefits(); + const data = await getShopCatalog(); + return { success: true, data, error: null }; + }); + + app.post('/shop/purchase', async (request) => { + const parsed = shopPurchaseSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await purchaseShopProduct(getUserId(request), parsed.data.productId, parsed.data.clientRequestId); return { success: true, data, error: null }; }); diff --git a/src/services/gamification/coin-service.ts b/src/services/gamification/coin-service.ts index cd96cfb..33c43bb 100644 --- a/src/services/gamification/coin-service.ts +++ b/src/services/gamification/coin-service.ts @@ -2,6 +2,7 @@ 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 { ValidationError } from '../../utils/errors.js'; import { COIN_RULES } from './rules.js'; export type CoinRewardSource = @@ -37,6 +38,21 @@ export interface GrantCoinsResult { balanceAfter: number; } +export interface SpendCoinsInput { + userId: string; + amount: number; + sourceId: string; + idempotencyKey?: string; + snapshot?: Record; +} + +export interface SpendCoinsResult { + spent: number; + applied: boolean; + balanceBefore: number; + balanceAfter: number; +} + const COIN_REWARD_TITLES: Readonly> = Object.freeze({ first_daily_challenge: '每日首组挑战', daily_task: '每日任务', @@ -189,6 +205,64 @@ async function getCoinBalance(userId: string): Promise { return wallet?.coinsBalance ?? 0; } +export async function spendCoins(input: SpendCoinsInput): Promise { + const amount = normalizeSpendAmount(input.amount); + const idempotencyKey = input.idempotencyKey ?? `shop_purchase:${input.sourceId}:coins`; + + 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 { + spent: amount, + applied: false, + balanceBefore, + balanceAfter: balanceBefore, + }; + } + + if (balanceBefore < amount) { + throw new ValidationError('金币余额不足'); + } + + const balanceAfter = balanceBefore - amount; + await db + .update(userWallets) + .set({ + coinsBalance: sql`GREATEST(COALESCE(coins_balance, 0) - ${amount}, 0)`, + lifetimeCoinsSpent: sql`COALESCE(lifetime_coins_spent, 0) + ${amount}`, + }) + .where(eq(userWallets.userId, input.userId)); + + await db.insert(inventoryTransactions).values({ + id: uuid(), + userId: input.userId, + itemId: 'coins', + direction: 'consume', + quantityDelta: -amount, + balanceAfter, + sourceType: 'shop_purchase', + sourceId: input.sourceId, + idempotencyKey, + snapshot: { + source: 'shop_purchase', + sourceId: input.sourceId, + coinsBalanceBefore: balanceBefore, + coinsBalanceAfter: balanceAfter, + ...(input.snapshot ?? {}), + }, + }); + + return { spent: amount, applied: true, balanceBefore, balanceAfter }; +} + async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise { if (balanceBefore === 0) { const [wallet] = await db @@ -249,3 +323,10 @@ 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))); } + +function normalizeSpendAmount(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + throw new ValidationError('金币消耗数量必须大于 0'); + } + return Math.floor(value); +} diff --git a/src/services/shop/shop-service.ts b/src/services/shop/shop-service.ts index 5670b7f..0a4d3b7 100644 --- a/src/services/shop/shop-service.ts +++ b/src/services/shop/shop-service.ts @@ -1,4 +1,8 @@ -import type { ShopBenefitDto } from '../../types/app-api.js'; +import { ValidationError } from '../../utils/errors.js'; +import { spendCoins } from '../gamification/coin-service.js'; +import { createInventoryReward, grantInventoryItem } from '../gamification/inventory-service.js'; +import { ITEM_RULES, type InventoryItemId } from '../gamification/rules.js'; +import type { ShopBenefitDto, ShopCatalogDto, ShopProductDto, ShopProductId, ShopPurchaseResultDto } from '../../types/app-api.js'; const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([ { @@ -35,6 +39,121 @@ const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([ }, ]); +const SHOP_PRODUCTS: readonly ShopProductDto[] = Object.freeze([ + { + id: 'hint-feather', + type: 'item', + itemId: ITEM_RULES.hintFeather.id, + title: '提示羽毛', + description: '答题时排除 1 个错误选项', + priceCoins: ITEM_RULES.hintFeather.shopPriceCoins, + quantity: 1, + enabled: true, + }, + { + id: 'heart-supply', + type: 'item', + itemId: ITEM_RULES.heartSupply.id, + title: '爱心补给', + description: '使用后恢复满爱心', + priceCoins: ITEM_RULES.heartSupply.shopPriceCoins, + quantity: 1, + enabled: true, + }, + { + id: 'double-xp-potion', + type: 'item', + itemId: ITEM_RULES.doubleXpPotion.id, + title: '双倍 XP 药水', + description: '使用后 15 分钟内 XP 翻倍', + priceCoins: ITEM_RULES.doubleXpPotion.shopPriceCoins, + quantity: 1, + enabled: true, + }, + { + id: 'streak-shield', + type: 'item', + itemId: ITEM_RULES.streakShield.id, + title: '连胜护盾', + description: '忙碌时保护 1 天连续学习', + priceCoins: ITEM_RULES.streakShield.shopPriceCoins, + quantity: 1, + enabled: true, + }, + { + id: 'mascot-outfit-starter', + type: 'cosmetic', + itemId: ITEM_RULES.mascotOutfit.id, + title: '多奇出行装', + description: '第一版吉祥物装扮', + priceCoins: ITEM_RULES.mascotOutfit.shopPriceCoinsMin, + quantity: 1, + enabled: true, + }, +]); + export async function getShopBenefits(): Promise { return SHOP_BENEFITS; } + +export async function getShopCatalog(): Promise { + return { + benefits: SHOP_BENEFITS, + products: SHOP_PRODUCTS, + }; +} + +export async function purchaseShopProduct( + userId: string, + productId: ShopProductId, + clientRequestId: string, +): Promise { + const product = getShopProduct(productId); + if (!product.enabled) { + throw new ValidationError('商品暂不可购买'); + } + + const sourceId = `shop:${clientRequestId}`; + const spend = await spendCoins({ + userId, + amount: product.priceCoins, + sourceId, + idempotencyKey: `${sourceId}:coins`, + snapshot: { productId: product.id }, + }); + const inventory = await grantInventoryItem({ + userId, + itemId: product.itemId as InventoryItemId, + quantity: product.quantity, + sourceType: 'shop_purchase', + sourceId, + idempotencyKey: `${sourceId}:item`, + metadata: product.type === 'cosmetic' ? { productId: product.id, title: product.title } : undefined, + snapshot: { + productId: product.id, + priceCoins: product.priceCoins, + coinsSpentApplied: spend.applied, + }, + }); + + return { + product, + coinsSpent: spend.applied ? product.priceCoins : 0, + coinsBalance: spend.balanceAfter, + item: { + itemId: inventory.item.itemId, + quantity: inventory.item.quantity, + activeUntil: inventory.item.activeUntil?.toISOString() ?? null, + metadata: inventory.item.metadata, + }, + rewards: [createInventoryReward(product.itemId as InventoryItemId, product.quantity)], + }; +} + +function getShopProduct(productId: ShopProductId): ShopProductDto { + const product = SHOP_PRODUCTS.find((item) => item.id === productId); + if (!product) { + throw new ValidationError('商品不存在'); + } + return product; +} diff --git a/src/types/app-api.ts b/src/types/app-api.ts index b6ea872..781f0f0 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -64,6 +64,50 @@ export interface ShopBenefitDto { requiresAd: boolean; } +export type ShopProductType = 'item' | 'cosmetic'; +export type ShopProductId = + | 'hint-feather' + | 'heart-supply' + | 'double-xp-potion' + | 'streak-shield' + | 'mascot-outfit-starter'; + +export interface ShopProductDto { + id: ShopProductId; + type: ShopProductType; + itemId: string; + title: string; + description: string; + priceCoins: number; + quantity: number; + enabled: boolean; +} + +export interface ShopCatalogDto { + benefits: readonly ShopBenefitDto[]; + products: readonly ShopProductDto[]; +} + +export interface ShopPurchaseResultDto { + product: ShopProductDto; + coinsSpent: number; + coinsBalance: number; + item: { + itemId: string; + quantity: number; + activeUntil: string | null; + metadata: Record | null; + }; + rewards: ReadonlyArray<{ + type: string; + source?: string; + amount?: number; + itemId?: string; + quantity?: number; + title?: string; + }>; +} + export interface BootstrapDto { user: UserBriefDto; progress: ProgressSummaryDto;