扩展游戏化启动与商店 DTO
This commit is contained in:
parent
b74201d6e0
commit
6bf9db9820
@ -215,6 +215,34 @@
|
|||||||
},
|
},
|
||||||
"tracks": [],
|
"tracks": [],
|
||||||
"shopBenefits": [],
|
"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": {
|
"subscription": {
|
||||||
"status": "none",
|
"status": "none",
|
||||||
"tier": "free",
|
"tier": "free",
|
||||||
@ -226,6 +254,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。
|
||||||
|
|
||||||
#### GET /tracks
|
#### GET /tracks
|
||||||
|
|
||||||
认证:JWT
|
认证:JWT
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||||
| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
| G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||||
| G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
| G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
||||||
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
| G3-6 | 更新 bootstrap/shop DTO | [x] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||||
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
| 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 未签名问题阻塞。
|
验证记录(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-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-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-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:广告恢复与订阅权益对齐
|
## 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 {
|
import {
|
||||||
consumeInventoryItem,
|
consumeInventoryItem,
|
||||||
createInventoryReward,
|
createInventoryReward,
|
||||||
|
getClientInventory,
|
||||||
getInventoryItem,
|
getInventoryItem,
|
||||||
grantInventoryItem,
|
grantInventoryItem,
|
||||||
} from '../../../services/gamification/inventory-service.js';
|
} 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 () => {
|
it('grants a new item with inventory transaction and reward ledger records', async () => {
|
||||||
const insertValues = vi.fn();
|
const insertValues = vi.fn();
|
||||||
mockSelectQueue([
|
mockSelectQueue([
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import { users } from '../../db/schema.js';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getProgressSummary, getLevelInfo } from '../learning/progress-summary-service.js';
|
import { getProgressSummary, getLevelInfo } from '../learning/progress-summary-service.js';
|
||||||
import { getThemeTracks } from '../learning/tracks-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 { 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';
|
import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
|
||||||
|
|
||||||
export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
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))
|
.where(eq(users.id, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const [progress, tracks, shopBenefits, subscription] = await Promise.all([
|
const [progress, tracks, shop, subscription, coinsBalance, inventory] = await Promise.all([
|
||||||
getProgressSummary(userId),
|
getProgressSummary(userId),
|
||||||
getThemeTracks(userId),
|
getThemeTracks(userId),
|
||||||
getShopBenefits(),
|
getShopCatalog(),
|
||||||
getClientSubscription(userId),
|
getClientSubscription(userId),
|
||||||
|
getCoinBalance(userId),
|
||||||
|
getClientInventory(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const xp = user?.xpTotal ?? progress.xp;
|
const xp = user?.xpTotal ?? progress.xp;
|
||||||
@ -40,7 +44,12 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
|||||||
},
|
},
|
||||||
progress,
|
progress,
|
||||||
tracks,
|
tracks,
|
||||||
shopBenefits,
|
shopBenefits: shop.benefits,
|
||||||
|
shop,
|
||||||
|
wallet: {
|
||||||
|
coinsBalance,
|
||||||
|
},
|
||||||
|
inventory,
|
||||||
subscription,
|
subscription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -195,7 +195,7 @@ export async function grantFirstDailyChallengeCoins(
|
|||||||
return result.granted ? result.reward : null;
|
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
|
const [wallet] = await db
|
||||||
.select({ coinsBalance: userWallets.coinsBalance })
|
.select({ coinsBalance: userWallets.coinsBalance })
|
||||||
.from(userWallets)
|
.from(userWallets)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ValidationError } from '../../utils/errors.js';
|
import { ValidationError } from '../../utils/errors.js';
|
||||||
import { ITEM_RULES, type InventoryItemId } from './rules.js';
|
import { ITEM_RULES, type InventoryItemId } from './rules.js';
|
||||||
|
import type { InventoryDto, InventoryItemDto } from '../../types/app-api.js';
|
||||||
|
|
||||||
export type InventorySourceType =
|
export type InventorySourceType =
|
||||||
| 'challenge'
|
| '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> {
|
export async function grantInventoryItem(input: GrantInventoryItemInput): Promise<InventoryMutationResult> {
|
||||||
const quantity = normalizeQuantity(input.quantity);
|
const quantity = normalizeQuantity(input.quantity);
|
||||||
const idempotencyKey = input.idempotencyKey ?? `grant_item:${input.itemId}:${input.sourceType}:${input.sourceId}`;
|
const idempotencyKey = input.idempotencyKey ?? `grant_item:${input.itemId}:${input.sourceType}:${input.sourceId}`;
|
||||||
@ -318,3 +335,22 @@ function resolveActiveUntil(input: GrantInventoryItemInput): Date | null {
|
|||||||
}
|
}
|
||||||
return 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 type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather';
|
||||||
|
|
||||||
export interface UseInventoryItemResultDto {
|
export interface UseInventoryItemResultDto {
|
||||||
@ -127,6 +142,9 @@ export interface BootstrapDto {
|
|||||||
progress: ProgressSummaryDto;
|
progress: ProgressSummaryDto;
|
||||||
tracks: ThemeTrackDto[];
|
tracks: ThemeTrackDto[];
|
||||||
shopBenefits: readonly ShopBenefitDto[];
|
shopBenefits: readonly ShopBenefitDto[];
|
||||||
|
shop: ShopCatalogDto;
|
||||||
|
wallet: WalletDto;
|
||||||
|
inventory: InventoryDto;
|
||||||
subscription: SubscriptionDto;
|
subscription: SubscriptionDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user