实现游戏化道具库存服务
This commit is contained in:
parent
3bcaf0fbf3
commit
5a29c59cf0
@ -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-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 未签名问题阻塞。
|
||||||
验证记录(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-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:广告恢复与订阅权益对齐
|
## 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