Initial WhaleTown V2 backend
This commit is contained in:
8
src/business/mall/dto/purchase_mall_item.dto.ts
Normal file
8
src/business/mall/dto/purchase_mall_item.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class PurchaseMallItemDto {
|
||||
@IsString({ message: '商品ID必须是字符串' })
|
||||
@Length(1, 100, { message: '商品ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '商品ID格式不正确' })
|
||||
item_id!: string;
|
||||
}
|
||||
59
src/business/mall/mall.controller.ts
Normal file
59
src/business/mall/mall.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Get, HttpStatus, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { MallService } from './mall.service';
|
||||
import { PurchaseMallItemDto } from './dto/purchase_mall_item.dto';
|
||||
|
||||
@ApiTags('shop')
|
||||
@ApiBearerAuth()
|
||||
@Controller('shop')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MallController {
|
||||
constructor(private readonly mallService: MallService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取当前账号商城数据',
|
||||
description: '返回当前账号钱包余额、商城分类和每个商品的用户维度状态。',
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '商城数据获取成功',
|
||||
})
|
||||
@Get('catalog')
|
||||
async getCatalog(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.mallService.getCatalog(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '商城数据获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '购买商城商品',
|
||||
description: '当前阶段支持购买角色皮肤,并返回账号已拥有皮肤列表。',
|
||||
})
|
||||
@ApiBody({ type: PurchaseMallItemDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '购买成功',
|
||||
})
|
||||
@Post('purchases')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async purchase(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() purchaseDto: PurchaseMallItemDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.mallService.purchaseItem(BigInt(user.sub), purchaseDto.item_id);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '购买成功',
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
16
src/business/mall/mall.module.ts
Normal file
16
src/business/mall/mall.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { PlayerModule } from '../player/player.module';
|
||||
import { MallController } from './mall.controller';
|
||||
import { MallService } from './mall.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoginCoreModule,
|
||||
PlayerModule,
|
||||
],
|
||||
controllers: [MallController],
|
||||
providers: [MallService],
|
||||
exports: [MallService],
|
||||
})
|
||||
export class MallModule {}
|
||||
176
src/business/mall/mall.service.ts
Normal file
176
src/business/mall/mall.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
|
||||
import { InventoryService } from '../player/inventory.service';
|
||||
import { EconomyService } from '../player/economy.service';
|
||||
import { PlayerStateService } from '../player/player_state.service';
|
||||
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
||||
|
||||
interface IUserWalletsService {
|
||||
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
||||
}
|
||||
|
||||
export interface PurchaseMallItemResult {
|
||||
item_id: string;
|
||||
item_type: string;
|
||||
skin_id?: string;
|
||||
decor_id?: string;
|
||||
price: number;
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
owned_skin_ids: string[];
|
||||
owned_decor_ids: string[];
|
||||
already_owned: boolean;
|
||||
wallet: PlayerWalletPayload;
|
||||
inventory: PlayerInventoryPayload;
|
||||
snapshot: PlayerSnapshotPayload;
|
||||
}
|
||||
|
||||
export interface MallCatalogItemPayload {
|
||||
id: string;
|
||||
itemType: string;
|
||||
skinId?: string;
|
||||
decorId?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
price: number;
|
||||
status: 'owned' | 'available';
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface MallCatalogPayload {
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
categories: Array<{ id: string; label: string; icon: string }>;
|
||||
items: MallCatalogItemPayload[];
|
||||
owned_skin_ids: string[];
|
||||
owned_decor_ids: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MallService {
|
||||
constructor(
|
||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly economyService: EconomyService,
|
||||
private readonly playerStateService: PlayerStateService,
|
||||
) {}
|
||||
|
||||
async getWallet(userId: bigint) {
|
||||
return await this.userWalletsService.getBalance(userId);
|
||||
}
|
||||
|
||||
async getCatalog(userId: bigint): Promise<MallCatalogPayload> {
|
||||
const [wallet, inventory] = await Promise.all([
|
||||
this.userWalletsService.getBalance(userId),
|
||||
this.inventoryService.listInventory(userId),
|
||||
]);
|
||||
const ownedSkinIds = inventory.skin_ids;
|
||||
const ownedDecorIds = inventory.room_decor_ids;
|
||||
const ownedSet = new Set(ownedSkinIds);
|
||||
const ownedDecorSet = new Set(ownedDecorIds);
|
||||
const items = MALL_ITEMS
|
||||
.map((item) => ({
|
||||
id: item.itemId,
|
||||
itemType: item.itemType,
|
||||
skinId: item.skinId,
|
||||
decorId: item.decorId,
|
||||
icon: item.icon,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
status: (
|
||||
(item.skinId && ownedSet.has(item.skinId)) ||
|
||||
(item.decorId && ownedDecorSet.has(item.decorId))
|
||||
) ? 'owned' as const : 'available' as const,
|
||||
tags: item.tags,
|
||||
sortOrder: item.sortOrder,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return {
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
categories: MALL_CATEGORIES,
|
||||
items,
|
||||
owned_skin_ids: ownedSkinIds,
|
||||
owned_decor_ids: ownedDecorIds,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseItem(userId: bigint, itemId: string): Promise<PurchaseMallItemResult> {
|
||||
const item = findMallItem(itemId);
|
||||
if (!item) {
|
||||
throw new BadRequestException('商品不存在或暂未开放');
|
||||
}
|
||||
if (item.itemType === 'skin' && item.skinId) {
|
||||
return await this.purchaseSkinItem(userId, item);
|
||||
}
|
||||
if (item.itemType === 'room_decor' && item.decorId) {
|
||||
return await this.purchaseRoomDecorItem(userId, item);
|
||||
}
|
||||
throw new BadRequestException('商品类型暂未开放');
|
||||
}
|
||||
|
||||
private async purchaseSkinItem(userId: bigint, item: NonNullable<ReturnType<typeof findMallItem>>): Promise<PurchaseMallItemResult> {
|
||||
const alreadyOwned = await this.inventoryService.hasAsset(userId, 'skin', item.skinId as string);
|
||||
let wallet = await this.economyService.getWallet(userId);
|
||||
if (!alreadyOwned && item.price > 0) {
|
||||
wallet = await this.economyService.spend(userId, item.price, 'shop_purchase', item.itemId, `购买皮肤:${item.name}`);
|
||||
}
|
||||
|
||||
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
|
||||
const [inventory, snapshot] = await Promise.all([
|
||||
this.inventoryService.listInventory(userId),
|
||||
this.playerStateService.getSnapshot(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
item_id: item.itemId,
|
||||
item_type: item.itemType,
|
||||
skin_id: item.skinId,
|
||||
price: item.price,
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
owned_skin_ids: inventory.skin_ids,
|
||||
owned_decor_ids: inventory.room_decor_ids,
|
||||
already_owned: alreadyOwned,
|
||||
wallet,
|
||||
inventory,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
private async purchaseRoomDecorItem(userId: bigint, item: NonNullable<ReturnType<typeof findMallItem>>): Promise<PurchaseMallItemResult> {
|
||||
const decorId = item.decorId as string;
|
||||
const alreadyOwned = await this.inventoryService.hasAsset(userId, 'room_decor', decorId);
|
||||
let wallet = await this.economyService.getWallet(userId);
|
||||
if (!alreadyOwned && item.price > 0) {
|
||||
wallet = await this.economyService.spend(userId, item.price, 'shop_purchase', item.itemId, `购买房间摆件:${item.name}`);
|
||||
}
|
||||
|
||||
await this.inventoryService.grantAsset(userId, 'room_decor', decorId, 'purchase');
|
||||
const [inventory, snapshot] = await Promise.all([
|
||||
this.inventoryService.listInventory(userId),
|
||||
this.playerStateService.getSnapshot(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
item_id: item.itemId,
|
||||
item_type: item.itemType,
|
||||
decor_id: decorId,
|
||||
price: item.price,
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
owned_skin_ids: inventory.skin_ids,
|
||||
owned_decor_ids: inventory.room_decor_ids,
|
||||
already_owned: alreadyOwned,
|
||||
wallet,
|
||||
inventory,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
216
src/business/mall/mall_catalog.ts
Normal file
216
src/business/mall/mall_catalog.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type MallItemType = 'skin' | 'room_decor';
|
||||
|
||||
export interface MallCatalogItem {
|
||||
itemId: string;
|
||||
itemType: MallItemType;
|
||||
skinId?: string;
|
||||
decorId?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
price: number;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const MALL_CATEGORIES = [
|
||||
{ id: 'recommended', label: '推荐', icon: 'recommended' },
|
||||
{ id: 'outfit', label: '装扮', icon: 'outfit' },
|
||||
{ id: 'items', label: '道具', icon: 'items' },
|
||||
{ id: 'companion', label: '伙伴', icon: 'companion' },
|
||||
{ id: 'space', label: '空间', icon: 'space' },
|
||||
{ id: 'limited', label: '限时', icon: 'limited' },
|
||||
];
|
||||
|
||||
export const MALL_ITEMS: MallCatalogItem[] = [
|
||||
{
|
||||
itemId: 'skin_classic_whale',
|
||||
itemType: 'skin',
|
||||
skinId: 'classic_whale',
|
||||
name: '经典鲸鱼',
|
||||
category: 'outfit',
|
||||
description: '圆润、轻快的鲸鱼居民皮肤,适合喜欢海洋感角色的玩家。',
|
||||
price: 680,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_human_whale_directional_v2_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'human_whale_directional_v2_8x4',
|
||||
name: '海风行者',
|
||||
category: 'outfit',
|
||||
description: '蓝白海风主题的人类角色皮肤,带有鲸鱼小镇风格的服装细节。',
|
||||
price: 680,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_girl_sailor_turnaround_v2_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'girl_sailor_turnaround_v2_8x4',
|
||||
name: '海风少女',
|
||||
category: 'outfit',
|
||||
description: '水手风格的人类角色皮肤,适合轻松、清爽的 WhaleTown 日常。',
|
||||
price: 880,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_panda_hero_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'panda_hero_8x4',
|
||||
icon: 'res://assets/ui/mall/skins/panda_hero_8x4_product.png',
|
||||
name: '熊猫侠',
|
||||
category: 'outfit',
|
||||
description: '黑白连帽外观的人类角色皮肤,四方向8帧动作,适合想要更鲜明角色辨识度的玩家。',
|
||||
price: 980,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_ordinary_man_male_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'ordinary_man_male_8x4',
|
||||
icon: 'res://assets/ui/mall/skins/ordinary_man_male_8x4_product.png',
|
||||
name: '普通人(男)',
|
||||
category: 'outfit',
|
||||
description: '男性日常角色皮肤,四方向8帧动作,适合普通玩家形象。',
|
||||
price: 980,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_floor_rug',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_floor_rug',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||
name: '鲸浪地毯',
|
||||
category: 'space',
|
||||
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
||||
price: 260,
|
||||
tags: ['房间家具', '可拖拽', '地面'],
|
||||
sortOrder: 110,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_memory_board',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_memory_board',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||
name: '鲸语记忆板',
|
||||
category: 'space',
|
||||
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
||||
price: 220,
|
||||
tags: ['房间家具', '可拖拽', '挂件'],
|
||||
sortOrder: 120,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_lamp',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_lamp',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||
name: '鲸尾暖灯',
|
||||
category: 'space',
|
||||
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
||||
price: 360,
|
||||
tags: ['房间家具', '可拖拽', '灯具'],
|
||||
sortOrder: 130,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_boat_cabin_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'boat_cabin_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||
name: '船舱小床',
|
||||
category: 'space',
|
||||
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
||||
price: 520,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 140,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_low_wave_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'low_wave_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||
name: '海浪低床',
|
||||
category: 'space',
|
||||
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
||||
price: 500,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 150,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_headboard_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_headboard_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||
name: '鲸尾床头床',
|
||||
category: 'space',
|
||||
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
||||
price: 580,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 180,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_dev_whale_bookshelf',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'dev_whale_bookshelf',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
name: '程序员鲸书架',
|
||||
category: 'space',
|
||||
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
||||
price: 620,
|
||||
tags: ['房间家具', '可拖拽', '书架'],
|
||||
sortOrder: 190,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_bug_feature_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_bug_feature_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||
name: 'BUG特性徽章',
|
||||
category: 'space',
|
||||
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 200,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_buddhist_learning_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_buddhist_learning_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||
name: '佛系学习徽章',
|
||||
category: 'space',
|
||||
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
||||
price: 140,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 210,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_ok_working_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_ok_working_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||
name: '已经在做徽章',
|
||||
category: 'space',
|
||||
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 220,
|
||||
},
|
||||
];
|
||||
|
||||
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);
|
||||
|
||||
export function findMallItem(itemId?: string): MallCatalogItem | undefined {
|
||||
const normalizedItemId = (itemId || '').trim();
|
||||
return MALL_ITEMS.find((item) => normalizedItemId && item.itemId === normalizedItemId);
|
||||
}
|
||||
|
||||
export function findMallSkinItem(itemId?: string): MallCatalogItem | undefined {
|
||||
const item = findMallItem(itemId);
|
||||
return item?.itemType === 'skin' && item.skinId ? item : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user