扩展游戏化启动与商店 DTO
This commit is contained in:
parent
b74201d6e0
commit
6bf9db9820
@ -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
|
||||
|
||||
@ -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:广告恢复与订阅权益对齐
|
||||
|
||||
|
||||
79
src/__tests__/services/app/bootstrap-service.test.ts
Normal file
79
src/__tests__/services/app/bootstrap-service.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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([
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user