实现游戏化道具库存服务
This commit is contained in:
parent
3bcaf0fbf3
commit
5a29c59cf0
@ -74,7 +74,7 @@
|
||||
|---|------|------|----------|
|
||||
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| G3-3 | 实现道具库存服务 | [ ] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
|
||||
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 |
|
||||
| G3-5 | 实现道具使用接口 | [ ] | 爱心补给恢复满心,双倍 XP 药水 15 分钟生效,提示羽毛返回可排除选项,连胜护盾可保护断签 |
|
||||
| G3-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||
@ -82,6 +82,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-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 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
|
||||
254
src/__tests__/services/gamification/inventory-service.test.ts
Normal file
254
src/__tests__/services/gamification/inventory-service.test.ts
Normal 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('提示羽毛库存不足');
|
||||
});
|
||||
});
|
||||
320
src/services/gamification/inventory-service.ts
Normal file
320
src/services/gamification/inventory-service.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user