实现游戏化道具库存服务

This commit is contained in:
Wang Zhuoxuan 2026-05-13 16:57:34 +08:00
parent 3bcaf0fbf3
commit 5a29c59cf0
3 changed files with 576 additions and 1 deletions

View File

@ -74,7 +74,7 @@
|---|------|------|----------| |---|------|------|----------|
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 | | G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 | | G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 | | G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 | | G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
@ -82,6 +82,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 未签名问题阻塞。 验证记录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 未签名问题阻塞。
验证记录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 未签名问题阻塞。
## Phase G4广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐

View File

@ -0,0 +1,254 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import {
consumeInventoryItem,
createInventoryReward,
getInventoryItem,
grantInventoryItem,
} from '../../../services/gamification/inventory-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('inventory-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates display rewards for the first-version items', () => {
expect(createInventoryReward('streak_shield')).toEqual({
type: 'item',
source: 'inventory',
itemId: 'streak_shield',
quantity: 1,
title: '连胜护盾 x1',
});
expect(createInventoryReward('double_xp_potion', 2).title).toBe('双倍 XP 药水 x2');
expect(createInventoryReward('heart_supply').title).toBe('爱心补给 x1');
expect(createInventoryReward('hint_feather', 3).title).toBe('提示羽毛 x3');
});
it('returns an empty inventory item when no row exists', async () => {
mockSelectQueue([[]]);
const item = await getInventoryItem('user-1', 'hint_feather');
expect(item).toEqual({
itemId: 'hint_feather',
quantity: 0,
activeUntil: null,
metadata: null,
});
});
it('grants a new item with inventory transaction and reward ledger records', async () => {
const insertValues = vi.fn();
mockSelectQueue([
[], // no existing transaction
[], // no current item
[], // no inventory row before insert
[{ id: 'inventory-1' }], // inventory row id for transaction
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
const result = await grantInventoryItem({
userId: 'user-1',
itemId: 'hint_feather',
quantity: 2,
sourceType: 'daily_task',
sourceId: 'task-1',
idempotencyKey: 'daily_task:task-1:hint',
});
expect(result).toEqual({
item: {
itemId: 'hint_feather',
quantity: 2,
activeUntil: null,
metadata: null,
},
quantityDelta: 2,
applied: true,
});
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
itemId: 'hint_feather',
quantity: 2,
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
inventoryItemId: 'inventory-1',
itemId: 'hint_feather',
direction: 'grant',
quantityDelta: 2,
balanceAfter: 2,
sourceType: 'daily_task',
sourceId: 'task-1',
idempotencyKey: 'daily_task:task-1:hint',
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
sourceType: 'daily_task',
sourceId: 'task-1',
idempotencyKey: 'daily_task:task-1:hint',
status: 'completed',
resourceDeltas: {
items: [{ itemId: 'hint_feather', quantity: 2 }],
},
}));
});
it('increments an existing item and preserves active metadata', async () => {
const updateSet = vi.fn();
const insertValues = vi.fn();
const activeUntil = new Date('2026-05-13T12:00:00.000Z');
mockSelectQueue([
[], // no existing transaction
[{ itemId: 'double_xp_potion', quantity: 1, activeUntil: null, metadata: { source: 'old' } }],
[{ id: 'inventory-2' }],
]);
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
const result = await grantInventoryItem({
userId: 'user-1',
itemId: 'double_xp_potion',
quantity: 1,
sourceType: 'chest',
sourceId: 'chest-1',
activeUntil,
metadata: { source: 'chest' },
});
expect(result.item).toEqual({
itemId: 'double_xp_potion',
quantity: 2,
activeUntil,
metadata: { source: 'chest' },
});
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
quantity: expect.any(Object),
activeUntil,
metadata: { source: 'chest' },
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'double_xp_potion',
direction: 'grant',
quantityDelta: 1,
balanceAfter: 2,
sourceType: 'chest',
}));
});
it('does not apply duplicate grants with the same idempotency key', async () => {
mockSelectQueue([
[{ id: 'tx-1' }],
[{ itemId: 'heart_supply', quantity: 1, activeUntil: null, metadata: null }],
]);
const result = await grantInventoryItem({
userId: 'user-1',
itemId: 'heart_supply',
sourceType: 'ad_recovery',
sourceId: 'ad-1',
idempotencyKey: 'ad-1:heart_supply',
});
expect(result).toEqual({
item: {
itemId: 'heart_supply',
quantity: 1,
activeUntil: null,
metadata: null,
},
quantityDelta: 0,
applied: false,
});
expect(db.insert).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
});
it('consumes existing inventory and records a negative transaction', async () => {
const updateSet = vi.fn();
const insertValues = vi.fn();
mockSelectQueue([
[], // no existing transaction
[{ itemId: 'streak_shield', quantity: 2, activeUntil: null, metadata: null }],
[{ id: 'inventory-3' }],
]);
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
const result = await consumeInventoryItem({
userId: 'user-1',
itemId: 'streak_shield',
quantity: 1,
sourceType: 'system_adjust',
sourceId: 'protect-1',
idempotencyKey: 'protect-1:streak_shield',
});
expect(result).toEqual({
item: {
itemId: 'streak_shield',
quantity: 1,
activeUntil: null,
metadata: null,
},
quantityDelta: -1,
applied: true,
});
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
quantity: expect.any(Object),
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'streak_shield',
direction: 'consume',
quantityDelta: -1,
balanceAfter: 1,
sourceType: 'system_adjust',
idempotencyKey: 'protect-1:streak_shield',
}));
});
it('throws when consuming more items than available', async () => {
mockSelectQueue([
[], // no existing transaction
[{ itemId: 'hint_feather', quantity: 0, activeUntil: null, metadata: null }],
]);
await expect(
consumeInventoryItem({
userId: 'user-1',
itemId: 'hint_feather',
quantity: 1,
sourceType: 'challenge',
sourceId: 'challenge-1',
}),
).rejects.toThrow('提示羽毛库存不足');
});
});

View File

@ -0,0 +1,320 @@
import { db } from '../../db/client.js';
import { inventoryTransactions, rewardLedger, userInventoryItems } from '../../db/schema.js';
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';
export type InventorySourceType =
| 'challenge'
| 'daily_task'
| 'level_up'
| 'theme_node'
| 'chest'
| 'shop_purchase'
| 'ad_recovery'
| 'subscription'
| 'admin_grant'
| 'system_adjust';
type RewardLedgerSourceType =
| 'challenge_completion'
| 'daily_task'
| 'level_up'
| 'theme_node'
| 'chest'
| 'shop_purchase'
| 'ad_recovery'
| 'subscription'
| 'admin_grant'
| 'system_adjust';
export interface InventoryItemSnapshot {
itemId: InventoryItemId;
quantity: number;
activeUntil: Date | null;
metadata: Record<string, unknown> | null;
}
export interface GrantInventoryItemInput {
userId: string;
itemId: InventoryItemId;
quantity?: number;
sourceType: InventorySourceType;
sourceId: string;
idempotencyKey?: string;
activeUntil?: Date | null;
metadata?: Record<string, unknown>;
snapshot?: Record<string, unknown>;
}
export interface ConsumeInventoryItemInput {
userId: string;
itemId: InventoryItemId;
quantity?: number;
sourceType: InventorySourceType;
sourceId: string;
idempotencyKey?: string;
snapshot?: Record<string, unknown>;
}
export interface InventoryMutationResult {
item: InventoryItemSnapshot;
quantityDelta: number;
applied: boolean;
}
const ITEM_TITLES: Readonly<Record<InventoryItemId, string>> = Object.freeze({
streak_shield: '连胜护盾',
double_xp_potion: '双倍 XP 药水',
heart_supply: '爱心补给',
hint_feather: '提示羽毛',
mascot_outfit: '吉祥物装扮',
});
const REWARD_LEDGER_SOURCE: Readonly<Record<InventorySourceType, RewardLedgerSourceType>> = Object.freeze({
challenge: 'challenge_completion',
daily_task: 'daily_task',
level_up: 'level_up',
theme_node: 'theme_node',
chest: 'chest',
shop_purchase: 'shop_purchase',
ad_recovery: 'ad_recovery',
subscription: 'subscription',
admin_grant: 'admin_grant',
system_adjust: 'system_adjust',
});
export function createInventoryReward(itemId: InventoryItemId, quantity = 1) {
const safeQuantity = normalizeQuantity(quantity);
return {
type: 'item' as const,
source: 'inventory' as const,
itemId,
quantity: safeQuantity,
title: `${ITEM_TITLES[itemId]} x${safeQuantity}`,
};
}
export async function getInventoryItem(userId: string, itemId: InventoryItemId): Promise<InventoryItemSnapshot> {
const [item] = await db
.select({
itemId: userInventoryItems.itemId,
quantity: userInventoryItems.quantity,
activeUntil: userInventoryItems.activeUntil,
metadata: userInventoryItems.metadata,
})
.from(userInventoryItems)
.where(and(
eq(userInventoryItems.userId, userId),
eq(userInventoryItems.itemId, itemId),
))
.limit(1);
return {
itemId,
quantity: item?.quantity ?? 0,
activeUntil: item?.activeUntil ?? null,
metadata: item?.metadata ?? null,
};
}
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}`;
const existing = await getExistingTransaction(input.userId, idempotencyKey);
const itemBefore = await getInventoryItem(input.userId, input.itemId);
if (existing) {
return { item: itemBefore, quantityDelta: 0, applied: false };
}
const itemAfter = await upsertInventoryForGrant(input, quantity, itemBefore);
await db.insert(inventoryTransactions).values({
id: uuid(),
userId: input.userId,
inventoryItemId: await getInventoryRowId(input.userId, input.itemId),
itemId: input.itemId,
direction: 'grant',
quantityDelta: quantity,
balanceAfter: itemAfter.quantity,
sourceType: input.sourceType,
sourceId: input.sourceId,
idempotencyKey,
snapshot: buildSnapshot(input, itemBefore, itemAfter),
});
const reward = createInventoryReward(input.itemId, quantity);
await db.insert(rewardLedger).values({
id: uuid(),
userId: input.userId,
sourceType: REWARD_LEDGER_SOURCE[input.sourceType],
sourceId: input.sourceId,
idempotencyKey,
status: 'completed',
rewardSnapshot: {
rewards: [reward],
...buildSnapshot(input, itemBefore, itemAfter),
},
resourceDeltas: {
items: [{ itemId: input.itemId, quantity }],
},
stateBefore: { item: itemBefore },
stateAfter: { item: itemAfter },
settledAt: sql`NOW()`,
});
return { item: itemAfter, quantityDelta: quantity, applied: true };
}
export async function consumeInventoryItem(input: ConsumeInventoryItemInput): Promise<InventoryMutationResult> {
const quantity = normalizeQuantity(input.quantity);
const idempotencyKey = input.idempotencyKey ?? `consume_item:${input.itemId}:${input.sourceType}:${input.sourceId}`;
const existing = await getExistingTransaction(input.userId, idempotencyKey);
const itemBefore = await getInventoryItem(input.userId, input.itemId);
if (existing) {
return { item: itemBefore, quantityDelta: 0, applied: false };
}
if (itemBefore.quantity < quantity) {
throw new ValidationError(`${ITEM_TITLES[input.itemId]}库存不足`);
}
const itemAfter: InventoryItemSnapshot = {
...itemBefore,
quantity: itemBefore.quantity - quantity,
};
await db
.update(userInventoryItems)
.set({
quantity: sql`GREATEST(COALESCE(quantity, 0) - ${quantity}, 0)`,
})
.where(and(
eq(userInventoryItems.userId, input.userId),
eq(userInventoryItems.itemId, input.itemId),
));
await db.insert(inventoryTransactions).values({
id: uuid(),
userId: input.userId,
inventoryItemId: await getInventoryRowId(input.userId, input.itemId),
itemId: input.itemId,
direction: 'consume',
quantityDelta: -quantity,
balanceAfter: itemAfter.quantity,
sourceType: input.sourceType,
sourceId: input.sourceId,
idempotencyKey,
snapshot: buildSnapshot(input, itemBefore, itemAfter),
});
return { item: itemAfter, quantityDelta: -quantity, applied: true };
}
async function upsertInventoryForGrant(
input: GrantInventoryItemInput,
quantity: number,
itemBefore: InventoryItemSnapshot,
): Promise<InventoryItemSnapshot> {
const activeUntil = resolveActiveUntil(input);
const metadata = input.metadata ?? itemBefore.metadata;
if (itemBefore.quantity === 0) {
const [row] = await db
.select({ id: userInventoryItems.id })
.from(userInventoryItems)
.where(and(
eq(userInventoryItems.userId, input.userId),
eq(userInventoryItems.itemId, input.itemId),
))
.limit(1);
if (!row) {
await db.insert(userInventoryItems).values({
id: uuid(),
userId: input.userId,
itemId: input.itemId,
quantity,
activeUntil: activeUntil ?? undefined,
metadata: metadata ?? undefined,
});
return { itemId: input.itemId, quantity, activeUntil, metadata };
}
}
await db
.update(userInventoryItems)
.set({
quantity: sql`COALESCE(quantity, 0) + ${quantity}`,
activeUntil: activeUntil ?? undefined,
metadata: metadata ?? undefined,
})
.where(and(
eq(userInventoryItems.userId, input.userId),
eq(userInventoryItems.itemId, input.itemId),
));
return {
itemId: input.itemId,
quantity: itemBefore.quantity + quantity,
activeUntil,
metadata,
};
}
async function getExistingTransaction(userId: string, idempotencyKey: string) {
const [existing] = await db
.select({ id: inventoryTransactions.id })
.from(inventoryTransactions)
.where(and(
eq(inventoryTransactions.userId, userId),
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
))
.limit(1);
return existing ?? null;
}
async function getInventoryRowId(userId: string, itemId: InventoryItemId): Promise<string | null> {
const [row] = await db
.select({ id: userInventoryItems.id })
.from(userInventoryItems)
.where(and(
eq(userInventoryItems.userId, userId),
eq(userInventoryItems.itemId, itemId),
))
.limit(1);
return row?.id ?? null;
}
function buildSnapshot(
input: GrantInventoryItemInput | ConsumeInventoryItemInput,
before: InventoryItemSnapshot,
after: InventoryItemSnapshot,
): Record<string, unknown> {
return {
sourceType: input.sourceType,
sourceId: input.sourceId,
itemBefore: before,
itemAfter: after,
...(input.snapshot ?? {}),
};
}
function normalizeQuantity(value = 1): number {
if (!Number.isFinite(value) || value <= 0) {
throw new ValidationError('道具数量必须大于 0');
}
return Math.floor(value);
}
function resolveActiveUntil(input: GrantInventoryItemInput): Date | null {
if (input.activeUntil !== undefined) return input.activeUntil;
if (input.itemId === ITEM_RULES.doubleXpPotion.id && input.snapshot?.activatedAt === true) {
return new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs);
}
return null;
}