实现游戏化道具使用接口
This commit is contained in:
parent
ff75c34873
commit
b74201d6e0
@ -544,6 +544,41 @@
|
||||
|
||||
购买使用 `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
|
||||
|
||||
认证:JWT
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| 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-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
验证记录(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 未签名问题阻塞。
|
||||
验证记录(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 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
|
||||
178
src/__tests__/services/gamification/item-use-service.test.ts
Normal file
178
src/__tests__/services/gamification/item-use-service.test.ts
Normal 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),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -14,6 +14,7 @@ import { restoreHearts } from '../services/progress/progress-service.js';
|
||||
import { getClientLeaderboard, getClientLeaderboardMe } from '../services/learning/leaderboard-api-service.js';
|
||||
import { getShopCatalog, purchaseShopProduct } from '../services/shop/shop-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({
|
||||
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),
|
||||
});
|
||||
|
||||
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 {
|
||||
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 };
|
||||
});
|
||||
|
||||
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) => {
|
||||
const data = await getClientSubscription(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
|
||||
191
src/services/gamification/item-use-service.ts
Normal file
191
src/services/gamification/item-use-service.ts
Normal 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);
|
||||
}
|
||||
@ -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 {
|
||||
user: UserBriefDto;
|
||||
progress: ProgressSummaryDto;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user