实现游戏化商店购买接口

This commit is contained in:
Wang Zhuoxuan 2026-05-13 17:16:30 +08:00
parent 5a29c59cf0
commit ff75c34873
8 changed files with 526 additions and 17 deletions

View File

@ -457,7 +457,8 @@
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": {
"benefits": [
{ {
"id": "restore-hearts", "id": "restore-hearts",
"type": "hearts", "type": "hearts",
@ -467,12 +468,82 @@
"requiresAd": true "requiresAd": true
} }
], ],
"products": [
{
"id": "hint-feather",
"type": "item",
"itemId": "hint_feather",
"title": "提示羽毛",
"description": "答题时排除 1 个错误选项",
"priceCoins": 80,
"quantity": 1,
"enabled": true
}
]
},
"error": null "error": null
} }
``` ```
说明:`requiresAd=true` 的权益应通过 `/rewards/ad-recovery/session``/rewards/ad-recovery/complete` 完成资格检查和结算。 说明:`requiresAd=true` 的权益应通过 `/rewards/ad-recovery/session``/rewards/ad-recovery/complete` 完成资格检查和结算。
可购买商品价格:提示羽毛 80 金币,爱心补给 150 金币,双倍 XP 药水 250 金币,连胜护盾 400 金币,第一版吉祥物装扮 800 金币。
#### POST /shop/purchase
认证JWT
请求:
```json
{
"productId": "hint-feather",
"clientRequestId": "request-uuid"
}
```
`productId` 取值:`hint-feather`, `heart-supply`, `double-xp-potion`, `streak-shield`, `mascot-outfit-starter`
响应:
```json
{
"success": true,
"data": {
"product": {
"id": "hint-feather",
"type": "item",
"itemId": "hint_feather",
"title": "提示羽毛",
"description": "答题时排除 1 个错误选项",
"priceCoins": 80,
"quantity": 1,
"enabled": true
},
"coinsSpent": 80,
"coinsBalance": 220,
"item": {
"itemId": "hint_feather",
"quantity": 3,
"activeUntil": null,
"metadata": null
},
"rewards": [
{
"type": "item",
"source": "inventory",
"itemId": "hint_feather",
"quantity": 1,
"title": "提示羽毛 x1"
}
]
},
"error": null
}
```
购买使用 `clientRequestId` 作为幂等边界;金币不足时返回统一错误格式,`error.code` 为 `VALIDATION_ERROR`
#### GET /subscription #### GET /subscription
认证JWT 认证JWT

View File

@ -75,7 +75,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 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 | | G3-3 | 实现道具库存服务 | [x] | 支持连胜护盾、双倍 XP 药水、爱心补给、提示羽毛的获得和消耗 |
| G3-4 | 实现商店商品和购买接口 | [ ] | 商品价格符合设计:提示羽毛 80、爱心补给 150、双倍 XP 250、连胜护盾 400、装扮 800-3000 | | G3-4 | 实现商店商品和购买接口 | [x] | 商品价格符合设计:提示羽毛 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 | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 | | G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
@ -83,6 +83,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 未签名问题阻塞。 验证记录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 未签名问题阻塞。
## Phase G4广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js'; import { db } from '../../../db/client.js';
import { createCoinReward, getCoinRewardAmount, grantCoins } from '../../../services/gamification/coin-service.js'; import { createCoinReward, getCoinRewardAmount, grantCoins, spendCoins } from '../../../services/gamification/coin-service.js';
function selectRows(rows: unknown[]) { function selectRows(rows: unknown[]) {
return { return {
@ -130,4 +130,82 @@ describe('coin-service', () => {
expect(db.insert).not.toHaveBeenCalled(); expect(db.insert).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled(); expect(db.update).not.toHaveBeenCalled();
}); });
it('spends coins with a consume transaction for shop purchases', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[], // no existing spend transaction
[{ coinsBalance: 300 }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await spendCoins({
userId: 'user-1',
amount: 80,
sourceId: 'shop:request-1',
idempotencyKey: 'shop:request-1:coins',
snapshot: { productId: 'hint-feather' },
});
expect(result).toEqual({
spent: 80,
applied: true,
balanceBefore: 300,
balanceAfter: 220,
});
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
coinsBalance: expect.any(Object),
lifetimeCoinsSpent: expect.any(Object),
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
itemId: 'coins',
direction: 'consume',
quantityDelta: -80,
balanceAfter: 220,
sourceType: 'shop_purchase',
sourceId: 'shop:request-1',
idempotencyKey: 'shop:request-1:coins',
}));
});
it('does not spend coins twice for a duplicate idempotency key', async () => {
mockSelectQueue([
[{ id: 'tx-1' }],
[{ coinsBalance: 220 }],
]);
const result = await spendCoins({
userId: 'user-1',
amount: 80,
sourceId: 'shop:request-1',
idempotencyKey: 'shop:request-1:coins',
});
expect(result).toEqual({
spent: 80,
applied: false,
balanceBefore: 220,
balanceAfter: 220,
});
expect(db.insert).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
});
it('throws when spending more coins than the current balance', async () => {
mockSelectQueue([
[],
[{ coinsBalance: 20 }],
]);
await expect(
spendCoins({
userId: 'user-1',
amount: 80,
sourceId: 'shop:request-1',
}),
).rejects.toThrow('金币余额不足');
});
}); });

View File

@ -1,7 +1,39 @@
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getShopBenefits } from '../../../services/shop/shop-service.js'; import { db } from '../../../db/client.js';
import { getShopBenefits, getShopCatalog, purchaseShopProduct } from '../../../services/shop/shop-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('shop-service', () => { describe('shop-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns the client shop benefit catalog', async () => { it('returns the client shop benefit catalog', async () => {
const benefits = await getShopBenefits(); const benefits = await getShopBenefits();
@ -13,4 +45,75 @@ describe('shop-service', () => {
]); ]);
expect(benefits.every((item) => item.enabled)).toBe(true); expect(benefits.every((item) => item.enabled)).toBe(true);
}); });
it('returns shop products with configured prices', async () => {
const catalog = await getShopCatalog();
expect(catalog.benefits).toHaveLength(4);
expect(catalog.products.map((item) => [item.id, item.priceCoins])).toEqual([
['hint-feather', 80],
['heart-supply', 150],
['double-xp-potion', 250],
['streak-shield', 400],
['mascot-outfit-starter', 800],
]);
});
it('purchases a product by spending coins and granting inventory', async () => {
const insertValues = vi.fn();
const updateSet = vi.fn();
mockSelectQueue([
[], // spendCoins: no existing spend
[{ coinsBalance: 300 }],
[], // grantInventoryItem: no existing item grant
[], // current item
[], // no existing inventory row
[{ id: 'inventory-1' }], // inventory row id for transaction
]);
vi.mocked(db.insert).mockReturnValue(mockInsert(insertValues));
vi.mocked(db.update).mockReturnValue(mockUpdate(updateSet));
const result = await purchaseShopProduct('user-1', 'hint-feather', 'request-1');
expect(result.product.id).toBe('hint-feather');
expect(result.coinsSpent).toBe(80);
expect(result.coinsBalance).toBe(220);
expect(result.item).toEqual({
itemId: 'hint_feather',
quantity: 1,
activeUntil: null,
metadata: null,
});
expect(result.rewards).toEqual([
expect.objectContaining({ type: 'item', itemId: 'hint_feather', quantity: 1 }),
]);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({
coinsBalance: expect.any(Object),
lifetimeCoinsSpent: expect.any(Object),
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'coins',
direction: 'consume',
quantityDelta: -80,
sourceType: 'shop_purchase',
idempotencyKey: 'shop:request-1:coins',
}));
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
itemId: 'hint_feather',
direction: 'grant',
sourceType: 'shop_purchase',
idempotencyKey: 'shop:request-1:item',
}));
});
it('throws when the user does not have enough coins', async () => {
mockSelectQueue([
[], // no existing spend
[{ coinsBalance: 20 }],
]);
await expect(
purchaseShopProduct('user-1', 'hint-feather', 'request-1'),
).rejects.toThrow('金币余额不足');
});
}); });

View File

@ -12,7 +12,7 @@ import {
} from '../services/learning/progress-summary-service.js'; } from '../services/learning/progress-summary-service.js';
import { restoreHearts } from '../services/progress/progress-service.js'; 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 { getShopBenefits } 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';
const rewardSourceSchema = z.object({ const rewardSourceSchema = z.object({
@ -46,6 +46,11 @@ const subscriptionVerifySchema = z.object({
tier: z.enum(['pro', 'proplus']), tier: z.enum(['pro', 'proplus']),
}); });
const shopPurchaseSchema = z.object({
productId: z.enum(['hint-feather', 'heart-supply', 'double-xp-potion', 'streak-shield', 'mascot-outfit-starter']),
clientRequestId: z.string().min(1).max(80),
});
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;
} }
@ -152,7 +157,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
}); });
app.get('/shop', async () => { app.get('/shop', async () => {
const data = await getShopBenefits(); const data = await getShopCatalog();
return { success: true, data, error: null };
});
app.post('/shop/purchase', async (request) => {
const parsed = shopPurchaseSchema.safeParse(request.body);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await purchaseShopProduct(getUserId(request), parsed.data.productId, parsed.data.clientRequestId);
return { success: true, data, error: null }; return { success: true, data, error: null };
}); });

View File

@ -2,6 +2,7 @@ import { db } from '../../db/client.js';
import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js'; import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ValidationError } from '../../utils/errors.js';
import { COIN_RULES } from './rules.js'; import { COIN_RULES } from './rules.js';
export type CoinRewardSource = export type CoinRewardSource =
@ -37,6 +38,21 @@ export interface GrantCoinsResult {
balanceAfter: number; balanceAfter: number;
} }
export interface SpendCoinsInput {
userId: string;
amount: number;
sourceId: string;
idempotencyKey?: string;
snapshot?: Record<string, unknown>;
}
export interface SpendCoinsResult {
spent: number;
applied: boolean;
balanceBefore: number;
balanceAfter: number;
}
const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.freeze({ const COIN_REWARD_TITLES: Readonly<Record<CoinRewardSource, string>> = Object.freeze({
first_daily_challenge: '每日首组挑战', first_daily_challenge: '每日首组挑战',
daily_task: '每日任务', daily_task: '每日任务',
@ -189,6 +205,64 @@ async function getCoinBalance(userId: string): Promise<number> {
return wallet?.coinsBalance ?? 0; return wallet?.coinsBalance ?? 0;
} }
export async function spendCoins(input: SpendCoinsInput): Promise<SpendCoinsResult> {
const amount = normalizeSpendAmount(input.amount);
const idempotencyKey = input.idempotencyKey ?? `shop_purchase:${input.sourceId}:coins`;
const [existing] = await db
.select({ id: inventoryTransactions.id })
.from(inventoryTransactions)
.where(and(
eq(inventoryTransactions.userId, input.userId),
eq(inventoryTransactions.idempotencyKey, idempotencyKey),
))
.limit(1);
const balanceBefore = await getCoinBalance(input.userId);
if (existing) {
return {
spent: amount,
applied: false,
balanceBefore,
balanceAfter: balanceBefore,
};
}
if (balanceBefore < amount) {
throw new ValidationError('金币余额不足');
}
const balanceAfter = balanceBefore - amount;
await db
.update(userWallets)
.set({
coinsBalance: sql`GREATEST(COALESCE(coins_balance, 0) - ${amount}, 0)`,
lifetimeCoinsSpent: sql`COALESCE(lifetime_coins_spent, 0) + ${amount}`,
})
.where(eq(userWallets.userId, input.userId));
await db.insert(inventoryTransactions).values({
id: uuid(),
userId: input.userId,
itemId: 'coins',
direction: 'consume',
quantityDelta: -amount,
balanceAfter,
sourceType: 'shop_purchase',
sourceId: input.sourceId,
idempotencyKey,
snapshot: {
source: 'shop_purchase',
sourceId: input.sourceId,
coinsBalanceBefore: balanceBefore,
coinsBalanceAfter: balanceAfter,
...(input.snapshot ?? {}),
},
});
return { spent: amount, applied: true, balanceBefore, balanceAfter };
}
async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise<void> { async function upsertWalletForGrant(userId: string, amount: number, balanceBefore: number): Promise<void> {
if (balanceBefore === 0) { if (balanceBefore === 0) {
const [wallet] = await db const [wallet] = await db
@ -249,3 +323,10 @@ function clampCoins(value: number | undefined, min: number, max: number): number
if (typeof value !== 'number' || Number.isNaN(value)) return min; if (typeof value !== 'number' || Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, Math.floor(value))); return Math.max(min, Math.min(max, Math.floor(value)));
} }
function normalizeSpendAmount(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
throw new ValidationError('金币消耗数量必须大于 0');
}
return Math.floor(value);
}

View File

@ -1,4 +1,8 @@
import type { ShopBenefitDto } from '../../types/app-api.js'; import { ValidationError } from '../../utils/errors.js';
import { spendCoins } from '../gamification/coin-service.js';
import { createInventoryReward, grantInventoryItem } from '../gamification/inventory-service.js';
import { ITEM_RULES, type InventoryItemId } from '../gamification/rules.js';
import type { ShopBenefitDto, ShopCatalogDto, ShopProductDto, ShopProductId, ShopPurchaseResultDto } from '../../types/app-api.js';
const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([ const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
{ {
@ -35,6 +39,121 @@ const SHOP_BENEFITS: readonly ShopBenefitDto[] = Object.freeze([
}, },
]); ]);
const SHOP_PRODUCTS: readonly ShopProductDto[] = Object.freeze([
{
id: 'hint-feather',
type: 'item',
itemId: ITEM_RULES.hintFeather.id,
title: '提示羽毛',
description: '答题时排除 1 个错误选项',
priceCoins: ITEM_RULES.hintFeather.shopPriceCoins,
quantity: 1,
enabled: true,
},
{
id: 'heart-supply',
type: 'item',
itemId: ITEM_RULES.heartSupply.id,
title: '爱心补给',
description: '使用后恢复满爱心',
priceCoins: ITEM_RULES.heartSupply.shopPriceCoins,
quantity: 1,
enabled: true,
},
{
id: 'double-xp-potion',
type: 'item',
itemId: ITEM_RULES.doubleXpPotion.id,
title: '双倍 XP 药水',
description: '使用后 15 分钟内 XP 翻倍',
priceCoins: ITEM_RULES.doubleXpPotion.shopPriceCoins,
quantity: 1,
enabled: true,
},
{
id: 'streak-shield',
type: 'item',
itemId: ITEM_RULES.streakShield.id,
title: '连胜护盾',
description: '忙碌时保护 1 天连续学习',
priceCoins: ITEM_RULES.streakShield.shopPriceCoins,
quantity: 1,
enabled: true,
},
{
id: 'mascot-outfit-starter',
type: 'cosmetic',
itemId: ITEM_RULES.mascotOutfit.id,
title: '多奇出行装',
description: '第一版吉祥物装扮',
priceCoins: ITEM_RULES.mascotOutfit.shopPriceCoinsMin,
quantity: 1,
enabled: true,
},
]);
export async function getShopBenefits(): Promise<readonly ShopBenefitDto[]> { export async function getShopBenefits(): Promise<readonly ShopBenefitDto[]> {
return SHOP_BENEFITS; return SHOP_BENEFITS;
} }
export async function getShopCatalog(): Promise<ShopCatalogDto> {
return {
benefits: SHOP_BENEFITS,
products: SHOP_PRODUCTS,
};
}
export async function purchaseShopProduct(
userId: string,
productId: ShopProductId,
clientRequestId: string,
): Promise<ShopPurchaseResultDto> {
const product = getShopProduct(productId);
if (!product.enabled) {
throw new ValidationError('商品暂不可购买');
}
const sourceId = `shop:${clientRequestId}`;
const spend = await spendCoins({
userId,
amount: product.priceCoins,
sourceId,
idempotencyKey: `${sourceId}:coins`,
snapshot: { productId: product.id },
});
const inventory = await grantInventoryItem({
userId,
itemId: product.itemId as InventoryItemId,
quantity: product.quantity,
sourceType: 'shop_purchase',
sourceId,
idempotencyKey: `${sourceId}:item`,
metadata: product.type === 'cosmetic' ? { productId: product.id, title: product.title } : undefined,
snapshot: {
productId: product.id,
priceCoins: product.priceCoins,
coinsSpentApplied: spend.applied,
},
});
return {
product,
coinsSpent: spend.applied ? product.priceCoins : 0,
coinsBalance: spend.balanceAfter,
item: {
itemId: inventory.item.itemId,
quantity: inventory.item.quantity,
activeUntil: inventory.item.activeUntil?.toISOString() ?? null,
metadata: inventory.item.metadata,
},
rewards: [createInventoryReward(product.itemId as InventoryItemId, product.quantity)],
};
}
function getShopProduct(productId: ShopProductId): ShopProductDto {
const product = SHOP_PRODUCTS.find((item) => item.id === productId);
if (!product) {
throw new ValidationError('商品不存在');
}
return product;
}

View File

@ -64,6 +64,50 @@ export interface ShopBenefitDto {
requiresAd: boolean; requiresAd: boolean;
} }
export type ShopProductType = 'item' | 'cosmetic';
export type ShopProductId =
| 'hint-feather'
| 'heart-supply'
| 'double-xp-potion'
| 'streak-shield'
| 'mascot-outfit-starter';
export interface ShopProductDto {
id: ShopProductId;
type: ShopProductType;
itemId: string;
title: string;
description: string;
priceCoins: number;
quantity: number;
enabled: boolean;
}
export interface ShopCatalogDto {
benefits: readonly ShopBenefitDto[];
products: readonly ShopProductDto[];
}
export interface ShopPurchaseResultDto {
product: ShopProductDto;
coinsSpent: number;
coinsBalance: number;
item: {
itemId: string;
quantity: number;
activeUntil: string | null;
metadata: Record<string, unknown> | null;
};
rewards: ReadonlyArray<{
type: string;
source?: string;
amount?: number;
itemId?: string;
quantity?: number;
title?: string;
}>;
}
export interface BootstrapDto { export interface BootstrapDto {
user: UserBriefDto; user: UserBriefDto;
progress: ProgressSummaryDto; progress: ProgressSummaryDto;