实现游戏化道具使用接口

This commit is contained in:
Wang Zhuoxuan 2026-05-13 17:31:54 +08:00
parent ff75c34873
commit b74201d6e0
6 changed files with 439 additions and 1 deletions

View File

@ -544,6 +544,41 @@
购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR` 购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`
#### POST /inventory/items/use
认证JWT
请求:
```json
{
"itemId": "hint_feather",
"clientRequestId": "request-uuid",
"questionId": "question-uuid"
}
```
`itemId` 取值:`heart_supply`, `double_xp_potion`, `hint_feather`, `streak_shield`。使用 `hint_feather` 时必须传 `questionId`
响应:
```json
{
"success": true,
"data": {
"itemId": "hint_feather",
"quantityRemaining": 2,
"effect": {
"type": "hint",
"excludedOptions": ["错误选项 A"]
}
},
"error": null
}
```
效果说明:`heart_supply` 恢复当前用户爱心到上限;`double_xp_potion` 返回 15 分钟有效期 `activeUntil``hint_feather` 返回可排除选项;`streak_shield` 返回 `streakProtectedUntil`。`clientRequestId` 用于道具消耗幂等。
#### GET /subscription #### GET /subscription
认证JWT 认证JWT

View File

@ -76,7 +76,7 @@
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
| 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 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-5 | 实现道具使用接口 | [x] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | | G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | | G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
@ -84,6 +84,7 @@
验证记录2026-05-13G3-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-13G3-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-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-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-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 未签名问题阻塞。
## Phase G4广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐

View File

@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { useInventoryItem } from '../../../services/gamification/item-use-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<typeof vi.fn>) {
return { values: valuesSpy.mockResolvedValue(undefined) } as never;
}
function mockUpdate(setSpy: ReturnType<typeof vi.fn>) {
return { set: setSpy.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
}
describe('item-use-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses heart supply to restore hearts to the user max', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[], // consume: no existing use transaction
[{ itemId: 'heart_supply', quantity: 2, activeUntil: null, metadata: null }],
[{ id: 'inventory-heart' }],
[{ tier: 'free' }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await useInventoryItem({
userId: 'user-1',
itemId: 'heart_supply',
clientRequestId: 'use-heart-1',
});
expect(result).toEqual({
itemId: 'heart_supply',
quantityRemaining: 1,
effect: {
type: 'restore_hearts',
hearts: 5,
},
});
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
heartsRemaining: 5,
heartsLastRestore: expect.any(Object),
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'heart_supply',
direction: 'consume',
quantityDelta: -1,
idempotencyKey: 'use-item:use-heart-1:heart_supply',
}));
});
it('uses double XP potion and marks the effect active for 15 minutes', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[],
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: null }],
[{ id: 'inventory-xp' }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await useInventoryItem({
userId: 'user-1',
itemId: 'double_xp_potion',
clientRequestId: 'use-xp-1',
});
expect(result.itemId).toBe('double_xp_potion');
expect(result.quantityRemaining).toBe(0);
expect(result.effect.type).toBe('double_xp');
expect(result.effect.activeUntil).toEqual(expect.any(String));
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
activeUntil: expect.any(Date),
metadata: {
activeEffect: 'double_xp',
multiplier: 2,
},
}));
});
it('uses hint feather and returns one distractor to exclude', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[{ distractors: ['错误项 A', '错误项 B'] }],
[],
[{ itemId: 'hint_feather', quantity: 3, activeUntil: null, metadata: null }],
[{ id: 'inventory-hint' }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await useInventoryItem({
userId: 'user-1',
itemId: 'hint_feather',
clientRequestId: 'use-hint-1',
questionId: 'question-1',
});
expect(result).toEqual({
itemId: 'hint_feather',
quantityRemaining: 2,
effect: {
type: 'hint',
excludedOptions: ['错误项 A'],
},
});
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'hint_feather',
direction: 'consume',
snapshot: expect.objectContaining({
questionId: 'question-1',
excludedOptions: ['错误项 A'],
}),
}));
});
it('requires questionId when using hint feather', async () => {
await expect(
useInventoryItem({
userId: 'user-1',
itemId: 'hint_feather',
clientRequestId: 'use-hint-1',
}),
).rejects.toThrow('questionId is required');
});
it('uses streak shield and protects the current streak', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[],
[{ itemId: 'streak_shield', quantity: 1, activeUntil: null, metadata: null }],
[{ id: 'inventory-shield' }],
[{ streakDays: 7 }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await useInventoryItem({
userId: 'user-1',
itemId: 'streak_shield',
clientRequestId: 'use-shield-1',
});
expect(result.itemId).toBe('streak_shield');
expect(result.quantityRemaining).toBe(0);
expect(result.effect.type).toBe('streak_protection');
expect(result.effect.streakProtectedUntil).toEqual(expect.any(String));
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
streakProtectedUntil: expect.any(Date),
}));
});
});

View File

@ -14,6 +14,7 @@ import { restoreHearts } from '../services/progress/progress-service.js';
import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js'; import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js';
import { getShopCatalog, purchaseShopProduct } 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'; import { getClientSubscription, verifyClientSubscription } from '../services/subscription/subscription-api-service.js';
import { useInventoryItem } from '../services/gamification/item-use-service.js';
const rewardSourceSchema = z.object({ const rewardSourceSchema = z.object({
source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']), source: z.enum(['ad', 'check_in', 'subscription', 'admin_grant', 'debug']),
@ -51,6 +52,12 @@ const shopPurchaseSchema = z.object({
clientRequestId: z.string().min(1).max(80), clientRequestId: z.string().min(1).max(80),
}); });
const useItemSchema = z.object({
itemId: z.enum(['streak_shield', 'double_xp_potion', 'heart_supply', 'hint_feather']),
clientRequestId: z.string().min(1).max(80),
questionId: z.string().min(1).optional(),
});
function getUserId(request: { user: unknown }): string { function getUserId(request: { user: unknown }): string {
return (request.user as { userId: string }).userId; return (request.user as { userId: string }).userId;
} }
@ -168,6 +175,18 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
return { success: true, data, error: null }; return { success: true, data, error: null };
}); });
app.post('/inventory/items/use', async (request) => {
const parsed = useItemSchema.safeParse(request.body);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await useInventoryItem({
userId: getUserId(request),
itemId: parsed.data.itemId,
clientRequestId: parsed.data.clientRequestId,
questionId: parsed.data.questionId,
});
return { success: true, data, error: null };
});
app.get('/subscription', async (request) => { app.get('/subscription', async (request) => {
const data = await getClientSubscription(getUserId(request)); const data = await getClientSubscription(getUserId(request));
return { success: true, data, error: null }; return { success: true, data, error: null };

View File

@ -0,0 +1,191 @@
import { db } from '../../db/client.js';
import { questions, userInventoryItems, users } from '../../db/schema.js';
import { and, eq, sql } from 'drizzle-orm';
import { NotFoundError, ValidationError } from '../../utils/errors.js';
import { freezeStreak } from '../progress/streak-service.js';
import { HEART_RULES, ITEM_RULES, type InventoryItemId } from './rules.js';
import { consumeInventoryItem } from './inventory-service.js';
import type { UseInventoryItemResultDto, UsableInventoryItemId } from '../../types/app-api.js';
export interface UseInventoryItemInput {
userId: string;
itemId: UsableInventoryItemId;
clientRequestId: string;
questionId?: string;
}
export async function useInventoryItem(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
switch (input.itemId) {
case ITEM_RULES.heartSupply.id:
return useHeartSupply(input);
case ITEM_RULES.doubleXpPotion.id:
return useDoubleXpPotion(input);
case ITEM_RULES.hintFeather.id:
return useHintFeather(input);
case ITEM_RULES.streakShield.id:
return useStreakShield(input);
}
}
async function useHeartSupply(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
const consumption = await consumeUseItem(input);
const maxHearts = await getMaxHearts(input.userId);
await db
.update(users)
.set({
heartsRemaining: maxHearts,
heartsLastRestore: sql`NOW()`,
})
.where(eq(users.id, input.userId));
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'restore_hearts',
hearts: maxHearts,
},
};
}
async function useDoubleXpPotion(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
const consumption = await consumeUseItem(input);
if (!consumption.applied) {
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'double_xp',
activeUntil: consumption.item.activeUntil?.toISOString() ?? null,
},
};
}
const activeUntil = new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs);
await db
.update(userInventoryItems)
.set({
activeUntil,
metadata: {
activeEffect: 'double_xp',
multiplier: ITEM_RULES.doubleXpPotion.multiplier,
},
})
.where(and(
eq(userInventoryItems.userId, input.userId),
eq(userInventoryItems.itemId, input.itemId),
));
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'double_xp',
activeUntil: activeUntil.toISOString(),
},
};
}
async function useHintFeather(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
if (!input.questionId) {
throw new ValidationError('questionId is required for hint feather');
}
const excludedOptions = await getHintExcludedOptions(input.questionId);
const consumption = await consumeUseItem(input, {
questionId: input.questionId,
excludedOptions,
});
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'hint',
excludedOptions,
},
};
}
async function useStreakShield(input: UseInventoryItemInput): Promise<UseInventoryItemResultDto> {
const consumption = await consumeUseItem(input);
if (!consumption.applied) {
const protectedUntil = await getStreakProtectedUntil(input.userId);
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'streak_protection',
streakProtectedUntil: protectedUntil,
},
};
}
const protectedUntil = new Date();
protectedUntil.setUTCDate(protectedUntil.getUTCDate() + ITEM_RULES.streakShield.protectDays);
await db
.update(users)
.set({ streakProtectedUntil: protectedUntil })
.where(eq(users.id, input.userId));
await freezeStreak(input.userId);
return {
itemId: input.itemId,
quantityRemaining: consumption.item.quantity,
effect: {
type: 'streak_protection',
streakProtectedUntil: protectedUntil.toISOString(),
},
};
}
async function getStreakProtectedUntil(userId: string): Promise<string | null> {
const [user] = await db
.select({ streakProtectedUntil: users.streakProtectedUntil })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const value = user?.streakProtectedUntil ?? null;
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
async function consumeUseItem(input: UseInventoryItemInput, snapshot: Record<string, unknown> = {}) {
return consumeInventoryItem({
userId: input.userId,
itemId: input.itemId as InventoryItemId,
quantity: 1,
sourceType: 'system_adjust',
sourceId: `use-item:${input.clientRequestId}`,
idempotencyKey: `use-item:${input.clientRequestId}:${input.itemId}`,
snapshot,
});
}
async function getMaxHearts(userId: string): Promise<number> {
const [user] = await db
.select({ tier: users.tier })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.tier === 'pro' || user?.tier === 'proplus'
? HEART_RULES.subscribedMax
: HEART_RULES.freeMax;
}
async function getHintExcludedOptions(questionId: string): Promise<readonly string[]> {
const [question] = await db
.select({ distractors: questions.distractors })
.from(questions)
.where(eq(questions.id, questionId))
.limit(1);
if (!question) throw new NotFoundError('Question');
const distractors = Array.isArray(question.distractors)
? question.distractors.filter((item): item is string => typeof item === 'string')
: [];
return distractors.slice(0, ITEM_RULES.hintFeather.excludedDistractors);
}

View File

@ -108,6 +108,20 @@ export interface ShopPurchaseResultDto {
}>; }>;
} }
export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather';
export interface UseInventoryItemResultDto {
itemId: UsableInventoryItemId;
quantityRemaining: number;
effect: {
type: 'restore_hearts' | 'double_xp' | 'hint' | 'streak_protection';
activeUntil?: string | null;
hearts?: number;
excludedOptions?: readonly string[];
streakProtectedUntil?: string | null;
};
}
export interface BootstrapDto { export interface BootstrapDto {
user: UserBriefDto; user: UserBriefDto;
progress: ProgressSummaryDto; progress: ProgressSummaryDto;