扩展游戏化启动与商店 DTO

This commit is contained in:
Wang Zhuoxuan 2026-05-13 17:38:54 +08:00
parent b74201d6e0
commit 6bf9db9820
8 changed files with 197 additions and 6 deletions

View File

@ -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

View File

@ -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-13G3-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-13G3-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-13G3-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-13G3-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-13G3-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广告恢复与订阅权益对齐

View File

@ -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,
});
});
});

View File

@ -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([

View File

@ -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<BootstrapDto> {
@ -20,11 +22,13 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
.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<BootstrapDto> {
},
progress,
tracks,
shopBenefits,
shopBenefits: shop.benefits,
shop,
wallet: {
coinsBalance,
},
inventory,
subscription,
};
}

View File

@ -195,7 +195,7 @@ export async function grantFirstDailyChallengeCoins(
return result.granted ? result.reward : null;
}
async function getCoinBalance(userId: string): Promise<number> {
export async function getCoinBalance(userId: string): Promise<number> {
const [wallet] = await db
.select({ coinsBalance: userWallets.coinsBalance })
.from(userWallets)

View File

@ -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<InventoryDto> {
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<InventoryMutationResult> {
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<string, unknown> | 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();
}

View File

@ -108,6 +108,21 @@ export interface ShopPurchaseResultDto {
}>;
}
export interface WalletDto {
coinsBalance: number;
}
export interface InventoryItemDto {
itemId: string;
quantity: number;
activeUntil: string | null;
metadata: Record<string, unknown> | 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;
}