实现游戏化商店购买接口
This commit is contained in:
parent
5a29c59cf0
commit
ff75c34873
@ -457,7 +457,8 @@
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
"data": {
|
||||
"benefits": [
|
||||
{
|
||||
"id": "restore-hearts",
|
||||
"type": "hearts",
|
||||
@ -467,12 +468,82 @@
|
||||
"requiresAd": true
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"id": "hint-feather",
|
||||
"type": "item",
|
||||
"itemId": "hint_feather",
|
||||
"title": "提示羽毛",
|
||||
"description": "答题时排除 1 个错误选项",
|
||||
"priceCoins": 80,
|
||||
"quantity": 1,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
说明:`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
|
||||
|
||||
认证:JWT
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
| G3-1 | 实现金币发放服务 | [x] | 支持每日首组挑战 20、每日任务 30-80、升级 100、主题节点 50、宝箱 20-200 |
|
||||
| G3-2 | 实现宝箱奖励服务 | [x] | 支持基础概率、10 连对概率提升、高奖励次数耗尽后的概率降级 |
|
||||
| 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-6 | 更新 bootstrap/shop DTO | [ ] | 客户端能拿到金币、库存、可购买商品、广告商品、订阅权益 |
|
||||
| G3-7 | 添加金币/商店测试 | [ ] | 覆盖余额不足、重复购买、使用道具、药水时效、库存扣减和流水记录 |
|
||||
@ -83,6 +83,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 未签名问题阻塞。
|
||||
验证记录(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 未签名问题阻塞。
|
||||
|
||||
## Phase G4:广告恢复与订阅权益对齐
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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[]) {
|
||||
return {
|
||||
@ -130,4 +130,82 @@ describe('coin-service', () => {
|
||||
expect(db.insert).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('金币余额不足');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getShopBenefits } from '../../../services/shop/shop-service.js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the client shop benefit catalog', async () => {
|
||||
const benefits = await getShopBenefits();
|
||||
|
||||
@ -13,4 +45,75 @@ describe('shop-service', () => {
|
||||
]);
|
||||
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('金币余额不足');
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from '../services/learning/progress-summary-service.js';
|
||||
import { restoreHearts } from '../services/progress/progress-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';
|
||||
|
||||
const rewardSourceSchema = z.object({
|
||||
@ -46,6 +46,11 @@ const subscriptionVerifySchema = z.object({
|
||||
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 {
|
||||
return (request.user as { userId: string }).userId;
|
||||
}
|
||||
@ -152,7 +157,14 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { db } from '../../db/client.js';
|
||||
import { inventoryTransactions, rewardLedger, userDailyProgress, userWallets } from '../../db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ValidationError } from '../../utils/errors.js';
|
||||
import { COIN_RULES } from './rules.js';
|
||||
|
||||
export type CoinRewardSource =
|
||||
@ -37,6 +38,21 @@ export interface GrantCoinsResult {
|
||||
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({
|
||||
first_daily_challenge: '每日首组挑战',
|
||||
daily_task: '每日任务',
|
||||
@ -189,6 +205,64 @@ async function getCoinBalance(userId: string): Promise<number> {
|
||||
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> {
|
||||
if (balanceBefore === 0) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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([
|
||||
{
|
||||
@ -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[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -64,6 +64,50 @@ export interface ShopBenefitDto {
|
||||
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 {
|
||||
user: UserBriefDto;
|
||||
progress: ProgressSummaryDto;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user