forked from xiangwang25/whale-town-end-v2
Initial WhaleTown V2 backend
This commit is contained in:
13
src/business/player/dto/update_player_appearance.dto.ts
Normal file
13
src/business/player/dto/update_player_appearance.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerAppearanceDto {
|
||||
@ApiProperty({
|
||||
description: '要穿戴的角色皮肤ID',
|
||||
example: 'classic_whale',
|
||||
})
|
||||
@IsString({ message: '皮肤ID必须是字符串' })
|
||||
@Length(1, 100, { message: '皮肤ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '皮肤ID格式不正确' })
|
||||
skin_id!: string;
|
||||
}
|
||||
40
src/business/player/dto/update_player_profile_assets.dto.ts
Normal file
40
src/business/player/dto/update_player_profile_assets.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerProfileAssetsDto {
|
||||
@ApiPropertyOptional({ description: '当前账号头像URL', maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像URL必须是字符串' })
|
||||
@Length(0, 255, { message: '头像URL长度不能超过255字符' })
|
||||
avatar_url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像图片Base64,服务端保存后写入账号头像URL' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像图片必须是Base64字符串' })
|
||||
@MaxLength(5_000_000, { message: '头像图片内容过大' })
|
||||
avatar_image_base64?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像图片MIME类型', example: 'image/png' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像MIME类型必须是字符串' })
|
||||
@Length(1, 40, { message: '头像MIME类型长度不正确' })
|
||||
avatar_mime_type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '8x4角色皮肤PNG Base64,服务端保存后授予账号自定义皮肤' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤图片必须是Base64字符串' })
|
||||
@MaxLength(12_000_000, { message: '角色皮肤图片内容过大' })
|
||||
skin_image_base64?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '角色皮肤MIME类型', example: 'image/png' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤MIME类型必须是字符串' })
|
||||
@Length(1, 40, { message: '角色皮肤MIME类型长度不正确' })
|
||||
skin_mime_type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '自定义角色皮肤名称', maxLength: 40 })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤名称必须是字符串' })
|
||||
@Length(1, 40, { message: '角色皮肤名称长度需在1-40字符之间' })
|
||||
skin_name?: string;
|
||||
}
|
||||
11
src/business/player/dto/update_player_settings.dto.ts
Normal file
11
src/business/player/dto/update_player_settings.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsObject } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerSettingsDto {
|
||||
@ApiProperty({
|
||||
description: '账号级游戏设置',
|
||||
example: { master_volume: 0.8, show_chat_bubbles: true },
|
||||
})
|
||||
@IsObject({ message: '账号设置必须是对象格式' })
|
||||
settings!: Record<string, unknown>;
|
||||
}
|
||||
37
src/business/player/economy.service.ts
Normal file
37
src/business/player/economy.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PlayerWalletPayload } from './player.types';
|
||||
|
||||
interface IUserWalletsService {
|
||||
getBalance(userId: bigint): Promise<PlayerWalletPayload>;
|
||||
spend(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
|
||||
earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EconomyService {
|
||||
constructor(
|
||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||
) {}
|
||||
|
||||
async getWallet(userId: bigint): Promise<PlayerWalletPayload> {
|
||||
return await this.userWalletsService.getBalance(userId);
|
||||
}
|
||||
|
||||
async spend(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<PlayerWalletPayload> {
|
||||
const result = await this.userWalletsService.spend(userId, amount, referenceType, referenceId, note);
|
||||
return {
|
||||
user_id: userId.toString(),
|
||||
balance: result.wallet.balance,
|
||||
currency: 'whale_coin',
|
||||
};
|
||||
}
|
||||
|
||||
async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<PlayerWalletPayload> {
|
||||
const result = await this.userWalletsService.earn(userId, amount, referenceType, referenceId, note);
|
||||
return {
|
||||
user_id: userId.toString(),
|
||||
balance: result.wallet.balance,
|
||||
currency: 'whale_coin',
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/business/player/inventory.controller.ts
Normal file
28
src/business/player/inventory.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, HttpStatus, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerAssetType } from './player.types';
|
||||
|
||||
@ApiTags('inventory')
|
||||
@ApiBearerAuth()
|
||||
@Controller('inventory')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家背包资产' })
|
||||
@SwaggerApiResponse({ status: 200, description: '背包资产获取成功' })
|
||||
@Get()
|
||||
async listInventory(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('type') type: PlayerAssetType | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.inventoryService.listInventory(BigInt(user.sub), type);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '背包资产获取成功' });
|
||||
}
|
||||
}
|
||||
50
src/business/player/inventory.service.ts
Normal file
50
src/business/player/inventory.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PlayerAssets, PlayerAssetType as CorePlayerAssetType } from '../../core/db/player_assets/player_assets.entity';
|
||||
import { PlayerAsset, PlayerAssetType, PlayerInventoryPayload } from './player.types';
|
||||
|
||||
interface IPlayerAssetsService {
|
||||
grantAsset(userId: bigint, assetType: CorePlayerAssetType, assetId: string, source?: string, metadata?: Record<string, unknown>): Promise<PlayerAssets>;
|
||||
hasAsset(userId: bigint, assetType: CorePlayerAssetType, assetId: string): Promise<boolean>;
|
||||
listAssets(userId: bigint, assetType?: CorePlayerAssetType): Promise<PlayerAssets[]>;
|
||||
listAssetIds(userId: bigint, assetType: CorePlayerAssetType): Promise<string[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(
|
||||
@Inject('IPlayerAssetsService') private readonly playerAssetsService: IPlayerAssetsService,
|
||||
) {}
|
||||
|
||||
async listInventory(userId: bigint, assetType?: PlayerAssetType): Promise<PlayerInventoryPayload> {
|
||||
const [skinIds, roomDecorIds] = await Promise.all([
|
||||
assetType && assetType !== 'skin' ? Promise.resolve([]) : this.playerAssetsService.listAssetIds(userId, 'skin'),
|
||||
assetType && assetType !== 'room_decor' ? Promise.resolve([]) : this.playerAssetsService.listAssetIds(userId, 'room_decor'),
|
||||
]);
|
||||
|
||||
const rows = await this.playerAssetsService.listAssets(userId, assetType as CorePlayerAssetType | undefined);
|
||||
const assets: PlayerAsset[] = rows.map((row) => ({
|
||||
asset_type: row.asset_type,
|
||||
asset_id: row.asset_id,
|
||||
source: row.source,
|
||||
}));
|
||||
|
||||
return {
|
||||
assets,
|
||||
skin_ids: skinIds,
|
||||
room_decor_ids: roomDecorIds,
|
||||
};
|
||||
}
|
||||
|
||||
async hasAsset(userId: bigint, assetType: PlayerAssetType, assetId: string): Promise<boolean> {
|
||||
return await this.playerAssetsService.hasAsset(userId, assetType, assetId);
|
||||
}
|
||||
|
||||
async grantAsset(userId: bigint, assetType: PlayerAssetType, assetId: string, source = 'system'): Promise<PlayerAsset> {
|
||||
await this.playerAssetsService.grantAsset(userId, assetType, assetId, source);
|
||||
return {
|
||||
asset_type: assetType,
|
||||
asset_id: assetId,
|
||||
source,
|
||||
};
|
||||
}
|
||||
}
|
||||
80
src/business/player/player.controller.ts
Normal file
80
src/business/player/player.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Body, Controller, Get, HttpStatus, Patch, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { PlayerStateService } from './player_state.service';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { UpdatePlayerAppearanceDto } from './dto/update_player_appearance.dto';
|
||||
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
||||
import { UpdatePlayerSettingsDto } from './dto/update_player_settings.dto';
|
||||
|
||||
@ApiTags('player')
|
||||
@ApiBearerAuth()
|
||||
@Controller('player')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlayerController {
|
||||
constructor(
|
||||
private readonly playerStateService: PlayerStateService,
|
||||
private readonly economyService: EconomyService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家快照' })
|
||||
@SwaggerApiResponse({ status: 200, description: '玩家快照获取成功' })
|
||||
@Get('snapshot')
|
||||
async getSnapshot(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.playerStateService.getSnapshot(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家快照获取成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家钱包' })
|
||||
@SwaggerApiResponse({ status: 200, description: '钱包获取成功' })
|
||||
@Get('wallet')
|
||||
async getWallet(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.economyService.getWallet(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '钱包获取成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前穿戴皮肤' })
|
||||
@ApiBody({ type: UpdatePlayerAppearanceDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '外观更新成功' })
|
||||
@Patch('appearance')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateAppearance(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerAppearanceDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateAppearance(BigInt(user.sub), dto.skin_id);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '外观更新成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前玩家设置' })
|
||||
@ApiBody({ type: UpdatePlayerSettingsDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '设置更新成功' })
|
||||
@Patch('settings')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateSettings(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerSettingsDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateSettings(BigInt(user.sub), dto.settings);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '设置更新成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前玩家头像或自定义皮肤资源' })
|
||||
@ApiBody({ type: UpdatePlayerProfileAssetsDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '玩家资源更新成功' })
|
||||
@Patch('profile-assets')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateProfileAssets(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerProfileAssetsDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateProfileAssets(BigInt(user.sub), dto);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家资源更新成功' });
|
||||
}
|
||||
}
|
||||
16
src/business/player/player.module.ts
Normal file
16
src/business/player/player.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { PlayerController } from './player.controller';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerStateService } from './player_state.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, LoginCoreModule],
|
||||
controllers: [PlayerController, InventoryController],
|
||||
providers: [EconomyService, InventoryService, PlayerStateService],
|
||||
exports: [EconomyService, InventoryService, PlayerStateService],
|
||||
})
|
||||
export class PlayerModule {}
|
||||
52
src/business/player/player.types.ts
Normal file
52
src/business/player/player.types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type PlayerAssetType = 'skin' | 'room_decor';
|
||||
|
||||
export interface PlayerAsset {
|
||||
asset_type: PlayerAssetType;
|
||||
asset_id: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface PlayerInventoryPayload {
|
||||
assets: PlayerAsset[];
|
||||
skin_ids: string[];
|
||||
room_decor_ids: string[];
|
||||
}
|
||||
|
||||
export interface PlayerWalletPayload {
|
||||
user_id: string;
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
}
|
||||
|
||||
export interface PlayerSnapshotPayload {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
profile: {
|
||||
user_id: string;
|
||||
selected_skin_id: string;
|
||||
avatar_id: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
current_map: string;
|
||||
pos_x: number;
|
||||
pos_y: number;
|
||||
status: number;
|
||||
};
|
||||
wallet: PlayerWalletPayload;
|
||||
inventory: PlayerInventoryPayload;
|
||||
appearance: {
|
||||
selected_skin_id: string;
|
||||
owned_skin_ids: string[];
|
||||
owned_skins: unknown[];
|
||||
};
|
||||
settings: Record<string, boolean | number>;
|
||||
}
|
||||
62
src/business/player/player_state.service.ts
Normal file
62
src/business/player/player_state.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountProfileService } from '../auth/account_profile.service';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerSnapshotPayload } from './player.types';
|
||||
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PlayerStateService {
|
||||
constructor(
|
||||
private readonly accountProfileService: AccountProfileService,
|
||||
private readonly economyService: EconomyService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
) {}
|
||||
|
||||
async getSnapshot(userId: bigint): Promise<PlayerSnapshotPayload> {
|
||||
const [accountProfile, wallet, inventory] = await Promise.all([
|
||||
this.accountProfileService.getAccountProfile(userId),
|
||||
this.economyService.getWallet(userId),
|
||||
this.inventoryService.listInventory(userId),
|
||||
]);
|
||||
const selectedSkinId = accountProfile.profile.skin_id || '';
|
||||
|
||||
return {
|
||||
user: accountProfile.user,
|
||||
profile: {
|
||||
user_id: accountProfile.profile.user_id,
|
||||
selected_skin_id: selectedSkinId,
|
||||
avatar_id: accountProfile.profile.avatar_id,
|
||||
avatar_url: accountProfile.profile.avatar_url,
|
||||
avatar_base64: accountProfile.profile.avatar_base64,
|
||||
current_map: accountProfile.profile.current_map,
|
||||
pos_x: accountProfile.profile.pos_x,
|
||||
pos_y: accountProfile.profile.pos_y,
|
||||
status: accountProfile.profile.status,
|
||||
},
|
||||
wallet,
|
||||
inventory,
|
||||
appearance: {
|
||||
selected_skin_id: selectedSkinId,
|
||||
owned_skin_ids: accountProfile.profile.owned_skin_ids,
|
||||
owned_skins: accountProfile.profile.owned_skins,
|
||||
},
|
||||
settings: accountProfile.profile.settings,
|
||||
};
|
||||
}
|
||||
|
||||
async updateAppearance(userId: bigint, skinId: string): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, { skin_id: skinId });
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
|
||||
async updateSettings(userId: bigint, settings: Record<string, unknown>): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, { settings });
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
|
||||
async updateProfileAssets(userId: bigint, update: UpdatePlayerProfileAssetsDto): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, update);
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user