Initial WhaleTown V2 backend

This commit is contained in:
2026-07-20 02:00:52 +08:00
commit c995891c1f
265 changed files with 75689 additions and 0 deletions

View 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;
}

View 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;
}

View 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>;
}

View 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',
};
}
}

View 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: '背包资产获取成功' });
}
}

View 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,
};
}
}

View 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: '玩家资源更新成功' });
}
}

View 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 {}

View 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>;
}

View 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);
}
}