3 Commits

Author SHA1 Message Date
ANG-Server
968a672fbd refactor: harden task authority and room mutations 2026-07-23 00:59:00 +08:00
ANG-Server
3f14230e15 fix: enforce decor placement surfaces 2026-07-22 14:20:18 +08:00
ANG-Server
b6b32f6676 feat: add personal space visits and editor 2026-07-22 13:41:28 +08:00
31 changed files with 1060 additions and 181 deletions

View File

@@ -56,7 +56,8 @@ export interface AccountProfilePayload {
}; };
} }
export type AccountSettings = Record<string, boolean | number>; export type RoomVisitPolicy = 'friends' | 'public' | 'closed';
export type AccountSettings = Record<string, boolean | number | RoomVisitPolicy>;
export interface UpdateAccountProfileRequest { export interface UpdateAccountProfileRequest {
skin_id?: string; skin_id?: string;
@@ -101,6 +102,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
allow_nearby_private: true, allow_nearby_private: true,
allow_nearby_friend_requests: true, allow_nearby_friend_requests: true,
allow_nearby_profile: true, allow_nearby_profile: true,
room_visit_policy: 'friends',
mute_ui_sfx: false, mute_ui_sfx: false,
}; };
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']); const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']);
@@ -118,6 +120,7 @@ const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
'allow_nearby_profile', 'allow_nearby_profile',
'mute_ui_sfx', 'mute_ui_sfx',
]); ]);
const ROOM_VISIT_POLICIES = new Set<RoomVisitPolicy>(['friends', 'public', 'closed']);
export interface AccountSkinAsset { export interface AccountSkinAsset {
id: string; id: string;
@@ -669,6 +672,8 @@ export class AccountProfileService {
} }
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) { } else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1'; sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
} else if (key === 'room_visit_policy' && typeof value === 'string' && ROOM_VISIT_POLICIES.has(value as RoomVisitPolicy)) {
sanitized[key] = value as RoomVisitPolicy;
} }
} }
return sanitized; return sanitized;

View File

@@ -1,3 +1,5 @@
import { ROOM_DECOR_DEFINITIONS } from '../room_decor/room_decor_catalog';
export type MallItemType = 'skin' | 'room_decor'; export type MallItemType = 'skin' | 'room_decor';
export interface MallCatalogItem { export interface MallCatalogItem {
@@ -23,6 +25,19 @@ export const MALL_CATEGORIES = [
{ id: 'limited', label: '限时', icon: 'limited' }, { id: 'limited', label: '限时', icon: 'limited' },
]; ];
const ROOM_DECOR_MALL_ITEMS: MallCatalogItem[] = ROOM_DECOR_DEFINITIONS.map((definition) => ({
itemId: definition.item_id,
itemType: 'room_decor',
decorId: definition.decor_id,
icon: definition.icon,
name: definition.name,
category: definition.shop.category,
description: definition.shop.description,
price: definition.shop.price,
tags: definition.shop.tags,
sortOrder: definition.shop.sort_order,
}));
export const MALL_ITEMS: MallCatalogItem[] = [ export const MALL_ITEMS: MallCatalogItem[] = [
{ {
itemId: 'skin_classic_whale', itemId: 'skin_classic_whale',
@@ -81,126 +96,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
tags: ['可预览', '永久', '皮肤'], tags: ['可预览', '永久', '皮肤'],
sortOrder: 50, sortOrder: 50,
}, },
{ ...ROOM_DECOR_MALL_ITEMS,
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 const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);

View File

@@ -48,5 +48,5 @@ export interface PlayerSnapshotPayload {
owned_skin_ids: string[]; owned_skin_ids: string[];
owned_skins: unknown[]; owned_skins: unknown[];
}; };
settings: Record<string, boolean | number>; settings: Record<string, boolean | number | string>;
} }

View File

@@ -0,0 +1,16 @@
import { IsInt, IsString, Length, Matches, Min } from 'class-validator';
export class ResetRoomDecorPlacementsDto {
@IsInt({ message: '布局版本必须是整数' })
@Min(0, { message: '布局版本不能小于0' })
layout_revision!: number;
@IsInt({ message: 'mutation版本必须是整数' })
@Min(1, { message: 'mutation版本必须大于0' })
mutation_revision!: number;
@IsString({ message: 'mutation ID必须是字符串' })
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
mutation_id!: string;
}

View File

@@ -1,10 +1,11 @@
import { IsBoolean, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator'; import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
export class SaveRoomDecorPlacementDto { export class SaveRoomDecorPlacementDto {
@IsOptional()
@IsString({ message: '摆件ID必须是字符串' }) @IsString({ message: '摆件ID必须是字符串' })
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' }) @Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' }) @Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
decor_id!: string; decor_id?: string;
@IsBoolean({ message: '摆放状态必须是布尔值' }) @IsBoolean({ message: '摆放状态必须是布尔值' })
placed!: boolean; placed!: boolean;
@@ -27,9 +28,29 @@ export class SaveRoomDecorPlacementDto {
@Max(4, { message: '缩放不能太大' }) @Max(4, { message: '缩放不能太大' })
scale?: number; scale?: number;
@IsOptional()
@IsNumber({}, { message: '旋转角度必须是数字' })
@IsInt({ message: '旋转角度必须是整数' })
@Min(0, { message: '旋转角度超出范围' })
@Max(270, { message: '旋转角度超出范围' })
rotation_degrees?: number;
@IsOptional() @IsOptional()
@IsNumber({}, { message: '层级必须是数字' }) @IsNumber({}, { message: '层级必须是数字' })
@Min(-1000, { message: '层级超出范围' }) @Min(-1000, { message: '层级超出范围' })
@Max(1000, { message: '层级超出范围' }) @Max(1000, { message: '层级超出范围' })
z_index?: number; z_index?: number;
@IsInt({ message: '布局版本必须是整数' })
@Min(0, { message: '布局版本不能小于0' })
layout_revision!: number;
@IsInt({ message: 'mutation版本必须是整数' })
@Min(1, { message: 'mutation版本必须大于0' })
mutation_revision!: number;
@IsString({ message: 'mutation ID必须是字符串' })
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
mutation_id!: string;
} }

View File

@@ -1,15 +1,16 @@
import { Body, Controller, Get, HttpStatus, Param, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Post, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { JwtPayload } from '../../core/login_core/login_core.service'; import { JwtPayload } from '../../core/login_core/login_core.service';
import { CurrentUser } from '../../gateway/auth/current_user.decorator'; import { CurrentUser } from '../../gateway/auth/current_user.decorator';
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
import { RoomDecorService } from './room_decor.service'; import { RoomDecorService } from './room_decor.service';
@ApiTags('room-decor') @ApiTags('room-decor')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('rooms/me/decor-placements') @Controller('rooms')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class RoomDecorController { export class RoomDecorController {
constructor(private readonly roomDecorService: RoomDecorService) {} constructor(private readonly roomDecorService: RoomDecorService) {}
@@ -22,7 +23,7 @@ export class RoomDecorController {
status: 200, status: 200,
description: '房间家具背包获取成功', description: '房间家具背包获取成功',
}) })
@Get() @Get('me/decor-placements')
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> { async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
const data = await this.roomDecorService.getInventory(BigInt(user.sub)); const data = await this.roomDecorService.getInventory(BigInt(user.sub));
res.status(HttpStatus.OK).json({ res.status(HttpStatus.OK).json({
@@ -32,6 +33,45 @@ export class RoomDecorController {
}); });
} }
@ApiOperation({
summary: '参观其他玩家的房间布局',
description: '按房主访问策略返回只读布局;拉黑关系始终拒绝访问。',
})
@SwaggerApiResponse({ status: 200, description: '只读房间布局获取成功' })
@Get(':ownerUserId/decor-placements')
async getRoomView(
@CurrentUser() user: JwtPayload,
@Param('ownerUserId') ownerUserId: string,
@Res() res: Response,
): Promise<void> {
const data = await this.roomDecorService.getRoomView(BigInt(user.sub), this.parseUserId(ownerUserId));
res.status(HttpStatus.OK).json({
success: true,
data,
message: '房间布局获取成功',
});
}
@ApiOperation({
summary: '收回当前账号所有已摆放家具',
description: '仅清空布局,不删除已拥有家具资产。',
})
@SwaggerApiResponse({ status: 200, description: '房间布局已重置' })
@Post('me/decor-placements/reset')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async resetPlacements(
@CurrentUser() user: JwtPayload,
@Body() dto: ResetRoomDecorPlacementsDto,
@Res() res: Response,
): Promise<void> {
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub), dto);
res.status(HttpStatus.OK).json({
success: true,
data,
message: '房间布局已重置,家具已收回背包',
});
}
@ApiOperation({ @ApiOperation({
summary: '保存房间家具摆放', summary: '保存房间家具摆放',
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。', description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
@@ -40,7 +80,7 @@ export class RoomDecorController {
status: 200, status: 200,
description: '家具摆放保存成功', description: '家具摆放保存成功',
}) })
@Put(':decorId') @Put('me/decor-placements/:decorId')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async savePlacement( async savePlacement(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@@ -58,4 +98,11 @@ export class RoomDecorController {
message: '家具摆放保存成功', message: '家具摆放保存成功',
}); });
} }
private parseUserId(value: string): bigint {
if (!/^\d+$/.test(value)) {
throw new BadRequestException('房主用户ID格式不正确');
}
return BigInt(value);
}
} }

View File

@@ -1,6 +1,8 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { InventoryService } from '../player/inventory.service'; import { InventoryService } from '../player/inventory.service';
import { SocialService } from '../social/social.service';
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
import { import {
ROOM_DECOR_BED_DEFAULT_SCALE, ROOM_DECOR_BED_DEFAULT_SCALE,
ROOM_DECOR_DEFINITIONS, ROOM_DECOR_DEFINITIONS,
@@ -8,6 +10,7 @@ import {
ROOM_DECOR_LEGACY_BED_MAX_SCALE, ROOM_DECOR_LEGACY_BED_MAX_SCALE,
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES, ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
ROOM_DECOR_ROOM_SCALE, ROOM_DECOR_ROOM_SCALE,
RoomDecorDefinition,
findRoomDecorDefinition, findRoomDecorDefinition,
} from './room_decor_catalog'; } from './room_decor_catalog';
@@ -17,18 +20,22 @@ interface UserRoomDecorRow {
position_x: number | null; position_x: number | null;
position_y: number | null; position_y: number | null;
scale: number; scale: number;
rotation_degrees?: number;
z_index: number; z_index: number;
mutation_revision?: number;
} }
interface IRoomDecorPlacementsService { interface IRoomDecorPlacementsService {
listPlacements(userId: bigint): Promise<UserRoomDecorRow[]>; getSnapshot(userId: bigint): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<UserRoomDecorRow>; savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<{ revision: number; placement: UserRoomDecorRow }>;
resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
} }
interface RoomDecorPayloadPlacement { interface RoomDecorPayloadPlacement {
position_x: number | null; position_x: number | null;
position_y: number | null; position_y: number | null;
scale: number; scale: number;
rotation_degrees: number;
} }
@Injectable() @Injectable()
@@ -36,13 +43,15 @@ export class RoomDecorService {
constructor( constructor(
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService, @Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
private readonly inventoryService: InventoryService, private readonly inventoryService: InventoryService,
private readonly socialService: SocialService,
) {} ) {}
async getInventory(userId: bigint) { async getInventory(userId: bigint) {
const [inventory, placements] = await Promise.all([ const [inventory, snapshot] = await Promise.all([
this.inventoryService.listInventory(userId, 'room_decor'), this.inventoryService.listInventory(userId, 'room_decor'),
this.roomDecorPlacementsService.listPlacements(userId), this.roomDecorPlacementsService.getSnapshot(userId),
]); ]);
const placements = snapshot.placements;
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row])); const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
const rows = inventory.room_decor_ids.map((decorId) => { const rows = inventory.room_decor_ids.map((decorId) => {
const definition = findRoomDecorDefinition(decorId); const definition = findRoomDecorDefinition(decorId);
@@ -53,6 +62,7 @@ export class RoomDecorService {
position_x: definition?.default_position.x ?? null, position_x: definition?.default_position.x ?? null,
position_y: definition?.default_position.y ?? null, position_y: definition?.default_position.y ?? null,
scale: definition?.default_scale ?? 1, scale: definition?.default_scale ?? 1,
rotation_degrees: definition?.default_rotation_degrees ?? 0,
z_index: definition?.default_z_index ?? 0, z_index: definition?.default_z_index ?? 0,
}; };
}); });
@@ -61,23 +71,67 @@ export class RoomDecorService {
.filter((row) => findRoomDecorDefinition(row.decor_id)) .filter((row) => findRoomDecorDefinition(row.decor_id))
.map((row) => this.toPayload(row)), .map((row) => this.toPayload(row)),
definitions: ROOM_DECOR_DEFINITIONS, definitions: ROOM_DECOR_DEFINITIONS,
revision: snapshot.revision,
}; };
} }
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) { async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
const definition = findRoomDecorDefinition(placement.decor_id); const decorId = placement.decor_id?.trim() || '';
const definition = findRoomDecorDefinition(decorId);
if (!definition) { if (!definition) {
throw new BadRequestException('摆件不存在或暂未开放'); throw new BadRequestException('摆件不存在或暂未开放');
} }
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', placement.decor_id))) { if (!(await this.inventoryService.hasAsset(userId, 'room_decor', decorId))) {
throw new BadRequestException('尚未拥有该房间摆件'); throw new BadRequestException('尚未拥有该房间摆件');
} }
const row = await this.roomDecorPlacementsService.savePlacement(userId, { const rotationDegrees = placement.rotation_degrees ?? definition.default_rotation_degrees;
if (!definition.rotation_steps.includes(rotationDegrees)) {
throw new BadRequestException('该摆件不支持此旋转角度');
}
const positionX = this.snapToGrid(placement.position_x ?? definition.default_position.x, definition.grid_size);
const positionY = this.snapToGrid(placement.position_y ?? definition.default_position.y, definition.grid_size);
if (placement.placed && !this.isWithinPlacementBounds(positionX, positionY, definition)) {
throw new BadRequestException(definition.placement_surface === 'wall'
? '墙面挂饰只能摆放在墙面网格内'
: '地面家具只能摆放在地面网格内');
}
const result = await this.roomDecorPlacementsService.savePlacement(userId, {
...placement, ...placement,
decor_id: decorId,
position_x: positionX,
position_y: positionY,
scale: placement.scale ?? definition.default_scale, scale: placement.scale ?? definition.default_scale,
rotation_degrees: rotationDegrees,
z_index: placement.z_index ?? definition.default_z_index, z_index: placement.z_index ?? definition.default_z_index,
}); });
return this.toPayload(row); return {
...this.toPayload(result.placement),
layout_revision: result.revision,
};
}
async getRoomView(viewerId: bigint, ownerId: bigint) {
const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId);
const [inventory, snapshot] = await Promise.all([
this.inventoryService.listInventory(ownerId, 'room_decor'),
this.roomDecorPlacementsService.getSnapshot(ownerId),
]);
const placements = snapshot.placements;
const ownedDecorIds = new Set(inventory.room_decor_ids);
return {
owner,
read_only: true,
items: placements
.filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id)))
.map((row) => this.toPayload(row)),
definitions: ROOM_DECOR_DEFINITIONS,
revision: snapshot.revision,
};
}
async resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto) {
await this.roomDecorPlacementsService.resetPlacements(userId, mutation);
return this.getInventory(userId);
} }
private toPayload(row: UserRoomDecorRow) { private toPayload(row: UserRoomDecorRow) {
@@ -93,26 +147,36 @@ export class RoomDecorService {
position_x: placement.position_x, position_x: placement.position_x,
position_y: placement.position_y, position_y: placement.position_y,
scale: placement.scale, scale: placement.scale,
rotation_degrees: placement.rotation_degrees,
z_index: row.z_index ?? definition?.default_z_index ?? 0, z_index: row.z_index ?? definition?.default_z_index ?? 0,
mutation_revision: row.mutation_revision ?? 0,
category: definition?.category ?? 'floor',
placement_surface: definition?.placement_surface ?? 'floor',
placement_bounds: definition?.placement_bounds,
grid_size: definition?.grid_size ?? 16,
rotation_steps: definition?.rotation_steps ?? [0],
stackable: definition?.stackable ?? false,
default_position: definition?.default_position ?? { x: 0, y: 0 }, default_position: definition?.default_position ?? { x: 0, y: 0 },
default_scale: definition?.default_scale ?? 1, default_scale: definition?.default_scale ?? 1,
default_rotation_degrees: definition?.default_rotation_degrees ?? 0,
default_z_index: definition?.default_z_index ?? 0, default_z_index: definition?.default_z_index ?? 0,
}; };
} }
private normalizedPlacement( private normalizedPlacement(
row: UserRoomDecorRow, row: UserRoomDecorRow,
definition?: { default_scale: number; default_position: { x: number; y: number } }, definition?: RoomDecorDefinition,
): RoomDecorPayloadPlacement { ): RoomDecorPayloadPlacement {
const usesLegacyPlacement = this.usesLegacyPlacement(row); const usesLegacyPlacement = this.usesLegacyPlacement(row);
return { return {
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement), position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement), position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
scale: this.normalizedScale(row, definition), scale: this.normalizedScale(row, definition),
rotation_degrees: this.normalizedRotation(row, definition),
}; };
} }
private normalizedScale(row: UserRoomDecorRow, definition?: { default_scale: number }) { private normalizedScale(row: UserRoomDecorRow, definition?: Pick<RoomDecorDefinition, 'default_scale'>) {
const scale = row.scale ?? definition?.default_scale ?? 1; const scale = row.scale ?? definition?.default_scale ?? 1;
if (!row.placed && definition) { if (!row.placed && definition) {
return definition.default_scale; return definition.default_scale;
@@ -146,6 +210,20 @@ export class RoomDecorService {
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value; return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
} }
private normalizedRotation(row: UserRoomDecorRow, definition?: RoomDecorDefinition): number {
const rotation = row.rotation_degrees ?? definition?.default_rotation_degrees ?? 0;
return definition?.rotation_steps.includes(rotation) ? rotation : definition?.default_rotation_degrees ?? 0;
}
private snapToGrid(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize;
}
private isWithinPlacementBounds(x: number, y: number, definition: RoomDecorDefinition): boolean {
const bounds = definition.placement_bounds;
return x >= bounds.min_x && x <= bounds.max_x && y >= bounds.min_y && y <= bounds.max_y;
}
private usesLegacyPlacement(row: UserRoomDecorRow) { private usesLegacyPlacement(row: UserRoomDecorRow) {
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id]; const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
if (!legacy) { if (!legacy) {

View File

@@ -2,14 +2,28 @@ export interface RoomDecorDefinition {
decor_id: string; decor_id: string;
name: string; name: string;
item_id: string; item_id: string;
category: 'floor' | 'rug' | 'wall';
placement_surface: 'floor' | 'wall';
placement_bounds: RoomDecorPlacementBounds;
grid_size: number;
rotation_steps: number[];
stackable: boolean;
icon: string; icon: string;
texture?: string; texture?: string;
default_scale: number; default_scale: number;
default_rotation_degrees: number;
default_position: { default_position: {
x: number; x: number;
y: number; y: number;
}; };
default_z_index: number; default_z_index: number;
shop: {
category: 'space';
description: string;
price: number;
tags: string[];
sort_order: number;
};
collision_size?: { collision_size?: {
x: number; x: number;
y: number; y: number;
@@ -20,6 +34,13 @@ export interface RoomDecorDefinition {
}; };
} }
export interface RoomDecorPlacementBounds {
min_x: number;
max_x: number;
min_y: number;
max_y: number;
}
export interface RoomDecorLegacyDefault { export interface RoomDecorLegacyDefault {
scale: number; scale: number;
default_position: { default_position: {
@@ -35,6 +56,18 @@ export const ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE = 1.0;
export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04; export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04;
export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35; export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35;
export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18]; export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18];
export const ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS: RoomDecorPlacementBounds = {
min_x: -426,
max_x: 426,
min_y: -176,
max_y: 212,
};
export const ROOM_DECOR_WALL_PLACEMENT_BOUNDS: RoomDecorPlacementBounds = {
min_x: -378,
max_x: 378,
min_y: -258,
max_y: -192,
};
export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = { export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = {
whale_floor_rug: { whale_floor_rug: {
@@ -84,30 +117,54 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'whale_floor_rug', decor_id: 'whale_floor_rug',
item_id: 'decor_whale_floor_rug', item_id: 'decor_whale_floor_rug',
name: '鲸浪地毯', name: '鲸浪地毯',
category: 'rug',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png', icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE, default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: 161 }, default_position: { x: 0, y: 161 },
default_z_index: -8, default_z_index: -8,
shop: { category: 'space', description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', price: 260, tags: ['房间家具', '可拖拽', '地面'], sort_order: 110 },
}, },
{ {
decor_id: 'whale_memory_board', decor_id: 'whale_memory_board',
item_id: 'decor_whale_memory_board', item_id: 'decor_whale_memory_board',
name: '鲸语记忆板', name: '鲸语记忆板',
category: 'wall',
placement_surface: 'wall',
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png', icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
default_scale: 0.11, default_scale: 0.11,
default_rotation_degrees: 0,
default_position: { x: 182, y: -207 }, default_position: { x: 182, y: -207 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', price: 220, tags: ['房间家具', '可拖拽', '挂件'], sort_order: 120 },
}, },
{ {
decor_id: 'whale_tail_lamp', decor_id: 'whale_tail_lamp',
item_id: 'decor_whale_tail_lamp', item_id: 'decor_whale_tail_lamp',
name: '鲸尾暖灯', name: '鲸尾暖灯',
category: 'floor',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0, 90, 180, 270],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png', icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
default_scale: 1, default_scale: 1,
default_rotation_degrees: 0,
default_position: { x: 231, y: -175 }, default_position: { x: 231, y: -175 },
default_z_index: -10, default_z_index: -10,
shop: { category: 'space', description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', price: 360, tags: ['房间家具', '可拖拽', '灯具'], sort_order: 130 },
collision_size: { x: 50, y: 32 }, collision_size: { x: 50, y: 32 },
collision_offset: { x: 0, y: 56 }, collision_offset: { x: 0, y: 56 },
}, },
@@ -115,11 +172,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'boat_cabin_bed', decor_id: 'boat_cabin_bed',
item_id: 'decor_boat_cabin_bed', item_id: 'decor_boat_cabin_bed',
name: '船舱小床', name: '船舱小床',
category: 'floor',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0, 90, 180, 270],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png', icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -161, y: 25 }, default_position: { x: -161, y: 25 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '白木船舱造型的小床,适合放在个人房间地面区域。', price: 520, tags: ['房间家具', '可拖拽', '床'], sort_order: 140 },
collision_size: { x: 220, y: 112 }, collision_size: { x: 220, y: 112 },
collision_offset: { x: 0, y: 52 }, collision_offset: { x: 0, y: 52 },
}, },
@@ -127,11 +192,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'low_wave_bed', decor_id: 'low_wave_bed',
item_id: 'decor_low_wave_bed', item_id: 'decor_low_wave_bed',
name: '海浪低床', name: '海浪低床',
category: 'floor',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0, 90, 180, 270],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png', icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -98, y: 39 }, default_position: { x: -98, y: 39 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', price: 500, tags: ['房间家具', '可拖拽', '床'], sort_order: 150 },
collision_size: { x: 220, y: 112 }, collision_size: { x: 220, y: 112 },
collision_offset: { x: 0, y: 56 }, collision_offset: { x: 0, y: 56 },
}, },
@@ -139,11 +212,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'whale_tail_headboard_bed', decor_id: 'whale_tail_headboard_bed',
item_id: 'decor_whale_tail_headboard_bed', item_id: 'decor_whale_tail_headboard_bed',
name: '鲸尾床头床', name: '鲸尾床头床',
category: 'floor',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0, 90, 180, 270],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png', icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: 32 }, default_position: { x: 0, y: 32 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', price: 580, tags: ['房间家具', '可拖拽', '床'], sort_order: 180 },
collision_size: { x: 214, y: 112 }, collision_size: { x: 214, y: 112 },
collision_offset: { x: 0, y: 62 }, collision_offset: { x: 0, y: 62 },
}, },
@@ -151,11 +232,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'dev_whale_bookshelf', decor_id: 'dev_whale_bookshelf',
item_id: 'decor_dev_whale_bookshelf', item_id: 'decor_dev_whale_bookshelf',
name: '程序员鲸书架', name: '程序员鲸书架',
category: 'floor',
placement_surface: 'floor',
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0, 90, 180, 270],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png', icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png', texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE, default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -210, y: -39 }, default_position: { x: -210, y: -39 },
default_z_index: -10, default_z_index: -10,
shop: { category: 'space', description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。', price: 620, tags: ['房间家具', '可拖拽', '书架'], sort_order: 190 },
collision_size: { x: 626.667, y: 226.667 }, collision_size: { x: 626.667, y: 226.667 },
collision_offset: { x: 0, y: 580 }, collision_offset: { x: 0, y: 580 },
}, },
@@ -163,31 +252,55 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'datawhale_bug_feature_badge', decor_id: 'datawhale_bug_feature_badge',
item_id: 'decor_datawhale_bug_feature_badge', item_id: 'decor_datawhale_bug_feature_badge',
name: 'BUG特性徽章', name: 'BUG特性徽章',
category: 'wall',
placement_surface: 'wall',
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png', icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png',
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -210, y: -203 }, default_position: { x: -210, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '写着“这不是BUG 这是feature”的佛系学习小徽章适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 200 },
}, },
{ {
decor_id: 'datawhale_buddhist_learning_badge', decor_id: 'datawhale_buddhist_learning_badge',
item_id: 'decor_datawhale_buddhist_learning_badge', item_id: 'decor_datawhale_buddhist_learning_badge',
name: '佛系学习徽章', name: '佛系学习徽章',
category: 'wall',
placement_surface: 'wall',
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png', icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png',
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: -203 }, default_position: { x: 0, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', price: 140, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 210 },
}, },
{ {
decor_id: 'datawhale_ok_working_badge', decor_id: 'datawhale_ok_working_badge',
item_id: 'decor_datawhale_ok_working_badge', item_id: 'decor_datawhale_ok_working_badge',
name: '已经在做徽章', name: '已经在做徽章',
category: 'wall',
placement_surface: 'wall',
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
grid_size: 16,
rotation_steps: [0],
stackable: false,
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png', icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png',
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 210, y: -203 }, default_position: { x: 210, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 220 },
}, },
]; ];

View File

@@ -40,6 +40,7 @@ const DEFAULT_PRIVACY = {
allow_nearby_private: true, allow_nearby_private: true,
allow_nearby_friend_requests: true, allow_nearby_friend_requests: true,
}; };
const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']);
const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = { const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = {
whale_port: { x: 1280, y: 960 }, whale_port: { x: 1280, y: 960 },
work_zone: { x: 1280, y: 960 }, work_zone: { x: 1280, y: 960 },
@@ -163,6 +164,26 @@ export class SocialService {
return this.buildProfile(viewerId, targetId, self); return this.buildProfile(viewerId, targetId, self);
} }
async canVisitRoom(viewerId: bigint, ownerId: bigint): Promise<boolean> {
if (viewerId === ownerId) return true;
const profile = await this.ensureProfile(ownerId);
return await this.canVisitRoomWithProfile(viewerId, ownerId, profile);
}
async getRoomOwnerProfile(viewerId: bigint, ownerId: bigint) {
if (!(await this.canVisitRoom(viewerId, ownerId))) {
throw new ForbiddenException('该玩家的个人空间暂不可访问');
}
const profile = await this.buildProfile(viewerId, ownerId, viewerId === ownerId);
return {
id: profile.id,
username: profile.username,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
skinId: profile.skinId,
};
}
async getFriends(userId: bigint) { async getFriends(userId: bigint) {
const friendships = await this.store.listFriendships(userId); const friendships = await this.store.listFriendships(userId);
const result = []; const result = [];
@@ -416,6 +437,8 @@ export class SocialService {
const tags = this.profileTags(profile); const tags = this.profileTags(profile);
const session = socketId ? await this.sessions.getSession(socketId) : null; const session = socketId ? await this.sessions.getSession(socketId) : null;
const testPresence = getTestLabPresence(targetId); const testPresence = getTestLabPresence(targetId);
const isFriend = viewerId === targetId ? false : await this.areFriends(viewerId, targetId);
const blockedEitherWay = viewerId === targetId ? false : await this.isBlockedEitherWay(viewerId, targetId);
return { return {
id: targetId.toString(), id: targetId.toString(),
username: user.username, username: user.username,
@@ -427,8 +450,9 @@ export class SocialService {
bio: String(profile.bio || '').slice(0, 160), bio: String(profile.bio || '').slice(0, 160),
interests: this.validInterests(tags.interests), interests: this.validInterests(tags.interests),
privacy: includePrivate ? this.privacy(profile) : undefined, privacy: includePrivate ? this.privacy(profile) : undefined,
isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId), isFriend: includePrivate || viewerId === targetId ? false : isFriend,
blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId), blocked: includePrivate ? false : blockedEitherWay,
room_visitable: viewerId === targetId || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'public') || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'friends' && isFriend),
}; };
} }
@@ -451,6 +475,23 @@ export class SocialService {
return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) }; return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) };
} }
private roomVisitPolicy(profile: ProfileRecord): 'friends' | 'public' | 'closed' {
const value = this.profileTags(profile)[SOCIAL_SETTINGS_KEY];
if (value && typeof value === 'object' && !Array.isArray(value)) {
const policy = String((value as Record<string, unknown>).room_visit_policy || 'friends');
if (ROOM_VISIT_POLICIES.has(policy)) return policy as 'friends' | 'public' | 'closed';
}
return 'friends';
}
private async canVisitRoomWithProfile(viewerId: bigint, ownerId: bigint, profile: ProfileRecord): Promise<boolean> {
if (viewerId === ownerId) return true;
if (await this.isBlockedEitherWay(viewerId, ownerId)) return false;
const policy = this.roomVisitPolicy(profile);
if (policy === 'public') return true;
return policy === 'friends' && await this.areFriends(viewerId, ownerId);
}
private validInterests(value: unknown): string[] { private validInterests(value: unknown): string[] {
return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : []; return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : [];
} }

View File

@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; import { IsIn, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { TASK_ACTIVITY_TYPES } from '../task_catalog'; import { TASK_ACTIVITY_TYPES } from '../task_catalog';
export class ReportTaskActivityDto { export class ReportTaskActivityDto {
@@ -9,4 +9,13 @@ export class ReportTaskActivityDto {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
target_id?: string; target_id?: string;
@IsString()
@MaxLength(64)
@Matches(/^[A-Za-z0-9_-]{16,64}$/)
nonce: string;
@IsOptional()
@IsISO8601()
occurred_at?: string;
} }

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS `task_activity_audit` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`nonce` varchar(64) NOT NULL,
`activity` varchar(40) NOT NULL,
`requested_target_id` varchar(80) NULL,
`resolved_target_id` varchar(80) NULL,
`accepted` boolean NOT NULL DEFAULT FALSE,
`reason` varchar(120) NOT NULL,
`session_id` varchar(80) NULL,
`map_id` varchar(40) NULL,
`position_x` float NULL,
`position_y` float NULL,
`client_ip` varchar(64) NULL,
`user_agent` varchar(255) NULL,
`client_occurred_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_task_activity_audit_user_created` (`user_id`, `created_at`),
KEY `idx_task_activity_audit_nonce` (`user_id`, `nonce`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务活动校验审计';

View File

@@ -1,4 +1,6 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
import { TaskActivityAuthorityService } from './task_activity_authority.service';
import { TaskActivityType } from './task_catalog'; import { TaskActivityType } from './task_catalog';
import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types'; import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types';
@@ -12,17 +14,25 @@ const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [
@Injectable() @Injectable()
export class TaskService { export class TaskService {
constructor(@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore) {} constructor(
@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore,
private readonly taskActivityAuthority: TaskActivityAuthorityService,
) {}
async getBoard(userId: bigint): Promise<TaskBoardPayload> { async getBoard(userId: bigint): Promise<TaskBoardPayload> {
return await this.taskProgressStore.getBoard(userId); return await this.taskProgressStore.getBoard(userId);
} }
async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> { async recordClientActivity(
if (!CLIENT_ACTIVITY_TYPES.includes(activity)) { userId: bigint,
dto: ReportTaskActivityDto,
context: { clientIp?: string; userAgent?: string } = {},
): Promise<TaskBoardPayload> {
if (!CLIENT_ACTIVITY_TYPES.includes(dto.activity)) {
throw new BadRequestException('该任务活动只能由服务器业务记录'); throw new BadRequestException('该任务活动只能由服务器业务记录');
} }
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim()); const resolved = await this.taskActivityAuthority.resolve(userId, dto, context);
return await this.taskProgressStore.recordActivity(userId, resolved.activity, resolved.targetId);
} }
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> { async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {

View File

@@ -0,0 +1,54 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('task_activity_audit')
@Index('idx_task_activity_audit_user_created', ['user_id', 'created_at'])
@Index('idx_task_activity_audit_nonce', ['user_id', 'nonce'])
export class TaskActivityAudit {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: bigint;
@Column({ type: 'bigint', nullable: false })
user_id: bigint;
@Column({ type: 'varchar', length: 64, nullable: false })
nonce: string;
@Column({ type: 'varchar', length: 40, nullable: false })
activity: string;
@Column({ type: 'varchar', length: 80, nullable: true })
requested_target_id: string | null;
@Column({ type: 'varchar', length: 80, nullable: true })
resolved_target_id: string | null;
@Column({ type: 'boolean', nullable: false, default: false })
accepted: boolean;
@Column({ type: 'varchar', length: 120, nullable: false })
reason: string;
@Column({ type: 'varchar', length: 80, nullable: true })
session_id: string | null;
@Column({ type: 'varchar', length: 40, nullable: true })
map_id: string | null;
@Column({ type: 'float', nullable: true })
position_x: number | null;
@Column({ type: 'float', nullable: true })
position_y: number | null;
@Column({ type: 'varchar', length: 64, nullable: true })
client_ip: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
user_agent: string | null;
@Column({ type: 'timestamp', nullable: true })
client_occurred_at: Date | null;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
created_at: Date;
}

View File

@@ -0,0 +1,22 @@
export interface TaskActivityAuditRecord {
user_id: bigint;
nonce: string;
activity: string;
requested_target_id: string | null;
resolved_target_id: string | null;
accepted: boolean;
reason: string;
session_id: string | null;
map_id: string | null;
position_x: number | null;
position_y: number | null;
client_ip: string | null;
user_agent: string | null;
client_occurred_at: Date | null;
}
export interface TaskActivityAuditStore {
append(record: TaskActivityAuditRecord): Promise<void>;
}
export const TASK_ACTIVITY_AUDIT_STORE = 'TASK_ACTIVITY_AUDIT_STORE';

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TaskActivityAudit } from './task_activity_audit.entity';
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
@Injectable()
export class TaskActivityAuditDatabaseService implements TaskActivityAuditStore {
constructor(
@InjectRepository(TaskActivityAudit)
private readonly repository: Repository<TaskActivityAudit>,
) {}
async append(record: TaskActivityAuditRecord): Promise<void> {
await this.repository.insert(record);
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
@Injectable()
export class TaskActivityAuditMemoryService implements TaskActivityAuditStore {
private readonly records: Array<TaskActivityAuditRecord & { created_at: Date }> = [];
private static readonly MAX_RECORDS = 5000;
async append(record: TaskActivityAuditRecord): Promise<void> {
this.records.push({ ...record, created_at: new Date() });
if (this.records.length > TaskActivityAuditMemoryService.MAX_RECORDS) {
this.records.splice(0, this.records.length - TaskActivityAuditMemoryService.MAX_RECORDS);
}
}
}

View File

@@ -0,0 +1,218 @@
import { ConflictException, HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common';
import { IRedisService } from '../../core/redis/redis.interface';
import { Position } from '../../core/location_broadcast_core/position.interface';
import { LocationBroadcastCore } from '../../core/location_broadcast_core/location_broadcast_core.service';
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
import { TaskActivityType } from './task_catalog';
import {
TASK_ACTIVITY_AUDIT_STORE,
TaskActivityAuditRecord,
TaskActivityAuditStore,
} from './task_activity_audit.store';
interface TaskActivityRequestContext {
clientIp?: string;
userAgent?: string;
}
interface ResolvedActivity {
activity: TaskActivityType;
targetId?: string;
}
interface FacilityPoint {
mapId: string;
x: number;
y: number;
radius: number;
}
const SESSION_REQUIRED_ACTIVITIES = new Set<TaskActivityType>([
'map_visited',
'notice_viewed',
'course_board_opened',
'facility_interacted',
]);
const MAP_TASK_TARGETS: Record<string, string> = {
whale_port: 'square',
work_zone: 'work_zone',
whale_cafe: 'whale_cafe',
};
const FACILITY_POINTS: Record<string, FacilityPoint[]> = {
welcome_board: [{ mapId: 'whale_port', x: 1470, y: 408, radius: 220 }],
notice_board: [{ mapId: 'whale_port', x: 701, y: 1529, radius: 240 }],
course_board: [{ mapId: 'work_zone', x: 1778, y: 1366, radius: 240 }],
npc: [
{ mapId: 'whale_port', x: 1081, y: 445, radius: 220 },
{ mapId: 'whale_port', x: 455, y: 1397, radius: 220 },
{ mapId: 'whale_cafe', x: 296, y: 221, radius: 240 },
],
};
const NONCE_TTL_SECONDS = 10 * 60;
const SESSION_FRESHNESS_MS = 30 * 1000;
const RATE_LIMIT_PER_MINUTE = 30;
@Injectable()
export class TaskActivityAuthorityService {
private readonly logger = new Logger(TaskActivityAuthorityService.name);
constructor(
@Inject('REDIS_SERVICE') private readonly redisService: IRedisService,
private readonly locationBroadcastCore: LocationBroadcastCore,
@Inject(TASK_ACTIVITY_AUDIT_STORE) private readonly auditStore: TaskActivityAuditStore,
) {}
async resolve(
userId: bigint,
dto: ReportTaskActivityDto,
context: TaskActivityRequestContext = {},
): Promise<ResolvedActivity> {
const requestedTargetId = dto.target_id?.trim() || undefined;
const auditBase: TaskActivityAuditRecord = {
user_id: userId,
nonce: dto.nonce,
activity: dto.activity,
requested_target_id: requestedTargetId ?? null,
resolved_target_id: null,
accepted: false,
reason: 'pending',
session_id: null,
map_id: null,
position_x: null,
position_y: null,
client_ip: context.clientIp?.slice(0, 64) || null,
user_agent: context.userAgent?.slice(0, 255) || null,
client_occurred_at: dto.occurred_at ? new Date(dto.occurred_at) : null,
};
try {
await this.enforceRateLimit(userId);
const nonceAccepted = await this.redisService.setIfAbsent(
`tasks:activity:nonce:${userId.toString()}:${dto.nonce}`,
'1',
NONCE_TTL_SECONDS,
);
if (!nonceAccepted) {
throw new ConflictException('任务活动 nonce 已使用');
}
let position: Position | null = null;
let sessionId: string | null = null;
if (SESSION_REQUIRED_ACTIVITIES.has(dto.activity)) {
[sessionId, position] = await Promise.all([
this.redisService.get(`user:${userId.toString()}:session`),
this.locationBroadcastCore.getUserPosition(userId.toString()),
]);
auditBase.session_id = sessionId;
auditBase.map_id = position?.mapId ?? null;
auditBase.position_x = position?.x ?? null;
auditBase.position_y = position?.y ?? null;
this.assertActiveSession(sessionId, position);
}
const resolved = this.resolveActivity(dto.activity, requestedTargetId, position);
await this.appendAudit({
...auditBase,
resolved_target_id: resolved.targetId ?? null,
accepted: true,
reason: 'accepted',
});
return resolved;
} catch (error) {
await this.appendAudit({
...auditBase,
reason: error instanceof Error ? error.message.slice(0, 120) : 'unknown_error',
});
throw error;
}
}
private async enforceRateLimit(userId: bigint): Promise<void> {
const minuteBucket = Math.floor(Date.now() / 60000);
const key = `tasks:activity:rate:${userId.toString()}:${minuteBucket}`;
const count = await this.redisService.incr(key);
if (count === 1) {
await this.redisService.expire(key, 120);
}
if (count > RATE_LIMIT_PER_MINUTE) {
throw new HttpException('任务活动上报过于频繁', HttpStatus.TOO_MANY_REQUESTS);
}
}
private assertActiveSession(sessionId: string | null, position: Position | null): asserts position is Position {
if (!sessionId || !position) {
throw new ConflictException('游戏会话尚未就绪');
}
if (!Number.isFinite(position.timestamp) || Date.now() - position.timestamp > SESSION_FRESHNESS_MS) {
throw new ConflictException('游戏会话位置已过期');
}
}
private resolveActivity(
activity: TaskActivityType,
requestedTargetId: string | undefined,
position: Position | null,
): ResolvedActivity {
if (activity === 'map_visited') {
const targetId = MAP_TASK_TARGETS[position?.mapId ?? ''];
if (!targetId) {
throw new ConflictException('当前地图不计入任务进度');
}
if (requestedTargetId && requestedTargetId !== targetId) {
throw new ConflictException('地图活动与当前会话不一致');
}
return { activity, targetId };
}
if (activity === 'notice_viewed') {
this.assertNearFacility(position, 'notice_board');
return { activity, targetId: 'notice_board' };
}
if (activity === 'course_board_opened') {
this.assertNearFacility(position, 'course_board');
return { activity, targetId: 'course_board' };
}
if (activity === 'facility_interacted') {
if (!requestedTargetId || !FACILITY_POINTS[requestedTargetId]) {
throw new ConflictException('设施活动目标无效');
}
this.assertNearFacility(position, requestedTargetId);
return { activity, targetId: requestedTargetId };
}
return { activity, targetId: requestedTargetId };
}
private assertNearFacility(position: Position | null, facilityId: string): void {
if (!position) {
throw new ConflictException('游戏会话尚未就绪');
}
const matched = FACILITY_POINTS[facilityId].some((point) => {
if (point.mapId !== position.mapId) return false;
const deltaX = position.x - point.x;
const deltaY = position.y - point.y;
return deltaX * deltaX + deltaY * deltaY <= point.radius * point.radius;
});
if (!matched) {
throw new ConflictException('玩家不在目标设施交互范围内');
}
}
private async appendAudit(record: TaskActivityAuditRecord): Promise<void> {
try {
await this.auditStore.append(record);
this.logger.log({
operation: 'task_activity',
userId: record.user_id.toString(),
activity: record.activity,
accepted: record.accepted,
reason: record.reason,
mapId: record.map_id,
});
} catch (error) {
this.logger.error('任务活动审计写入失败', error instanceof Error ? error.stack : String(error));
}
}
}

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, HttpStatus, Param, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Param, Post, Req, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Request, Response } from 'express';
import { CurrentUser } from '../../gateway/auth/current_user.decorator'; import { CurrentUser } from '../../gateway/auth/current_user.decorator';
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
import { JwtPayload } from '../../core/login_core/login_core.service'; import { JwtPayload } from '../../core/login_core/login_core.service';
@@ -29,9 +29,13 @@ export class TasksController {
async reportActivity( async reportActivity(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Body() dto: ReportTaskActivityDto, @Body() dto: ReportTaskActivityDto,
@Req() req: Request,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto.activity, dto.target_id); const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto, {
clientIp: req.ip,
userAgent: req.get('user-agent'),
});
res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' }); res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' });
} }

View File

@@ -1,7 +1,14 @@
import { DynamicModule, Global, Module } from '@nestjs/common'; import { DynamicModule, Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { LocationBroadcastCoreModule } from '../../core/location_broadcast_core/location_broadcast_core.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { RedisModule } from '../../core/redis/redis.module';
import { PlayerTaskProgress } from './player_task_progress.entity'; import { PlayerTaskProgress } from './player_task_progress.entity';
import { TaskActivityAudit } from './task_activity_audit.entity';
import { TASK_ACTIVITY_AUDIT_STORE } from './task_activity_audit.store';
import { TaskActivityAuditDatabaseService } from './task_activity_audit_database.service';
import { TaskActivityAuditMemoryService } from './task_activity_audit_memory.service';
import { TaskActivityAuthorityService } from './task_activity_authority.service';
import { TaskProgressDatabaseService } from './task_progress_database.service'; import { TaskProgressDatabaseService } from './task_progress_database.service';
import { TaskProgressMemoryService } from './task_progress_memory.service'; import { TaskProgressMemoryService } from './task_progress_memory.service';
import { TaskService } from './task.service'; import { TaskService } from './task.service';
@@ -14,11 +21,14 @@ export class TasksModule {
return { return {
module: TasksModule, module: TasksModule,
global: true, global: true,
imports: [LoginCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress])], imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress, TaskActivityAudit])],
controllers: [TasksController], controllers: [TasksController],
providers: [ providers: [
TaskProgressDatabaseService, TaskProgressDatabaseService,
{ provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService }, { provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService },
TaskActivityAuditDatabaseService,
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditDatabaseService },
TaskActivityAuthorityService,
TaskService, TaskService,
], ],
exports: [TaskService, 'ITaskProgressStore'], exports: [TaskService, 'ITaskProgressStore'],
@@ -29,11 +39,14 @@ export class TasksModule {
return { return {
module: TasksModule, module: TasksModule,
global: true, global: true,
imports: [LoginCoreModule], imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule],
controllers: [TasksController], controllers: [TasksController],
providers: [ providers: [
TaskProgressMemoryService, TaskProgressMemoryService,
{ provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService }, { provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService },
TaskActivityAuditMemoryService,
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditMemoryService },
TaskActivityAuthorityService,
TaskService, TaskService,
], ],
exports: [TaskService, 'ITaskProgressStore'], exports: [TaskService, 'ITaskProgressStore'],

View File

@@ -19,10 +19,21 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` (
`position_x` FLOAT NULL COMMENT '房间内X坐标', `position_x` FLOAT NULL COMMENT '房间内X坐标',
`position_y` FLOAT NULL COMMENT '房间内Y坐标', `position_y` FLOAT NULL COMMENT '房间内Y坐标',
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放', `scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级', `z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
`mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`), UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`),
KEY `idx_room_decor_placements_user_id` (`user_id`) KEY `idx_room_decor_placements_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,14 @@
ALTER TABLE `room_decor_placements`
ADD COLUMN IF NOT EXISTS `mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本' AFTER `z_index`;
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `room_decor_layout_state`
ADD COLUMN IF NOT EXISTS `last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域' AFTER `last_mutation_id`;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `room_decor_placements`
ADD COLUMN IF NOT EXISTS `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度' AFTER `scale`;

View File

@@ -4,6 +4,7 @@ import { PlayerAssets } from './player_assets.entity';
import { PlayerAssetsMemoryService } from './player_assets_memory.service'; import { PlayerAssetsMemoryService } from './player_assets_memory.service';
import { PlayerAssetsService } from './player_assets.service'; import { PlayerAssetsService } from './player_assets.service';
import { RoomDecorPlacements } from './room_decor_placements.entity'; import { RoomDecorPlacements } from './room_decor_placements.entity';
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service'; import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service';
import { RoomDecorPlacementsService } from './room_decor_placements.service'; import { RoomDecorPlacementsService } from './room_decor_placements.service';
@@ -13,12 +14,12 @@ export class PlayerAssetsModule {
static forDatabase(): DynamicModule { static forDatabase(): DynamicModule {
return { return {
module: PlayerAssetsModule, module: PlayerAssetsModule,
imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements])], imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements, RoomDecorLayoutState])],
providers: [ providers: [
PlayerAssetsService, PlayerAssetsService,
RoomDecorPlacementsService, RoomDecorPlacementsService,
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsService }, { provide: 'IPlayerAssetsService', useClass: PlayerAssetsService },
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsService }, { provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsService },
], ],
exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'], exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
}; };
@@ -31,7 +32,7 @@ export class PlayerAssetsModule {
PlayerAssetsMemoryService, PlayerAssetsMemoryService,
RoomDecorPlacementsMemoryService, RoomDecorPlacementsMemoryService,
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService }, { provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService },
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsMemoryService }, { provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsMemoryService },
], ],
exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'], exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
}; };

View File

@@ -0,0 +1,19 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('room_decor_layout_state')
export class RoomDecorLayoutState {
@PrimaryColumn({ type: 'bigint', comment: '关联users.id' })
user_id: bigint;
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '当前布局版本' })
revision: number;
@Column({ type: 'varchar', length: 64, nullable: true, comment: '最后一次幂等mutation ID' })
last_mutation_id: string | null;
@Column({ type: 'varchar', length: 120, nullable: true, comment: '最后一次mutation作用域' })
last_mutation_scope: string | null;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
updated_at: Date;
}

View File

@@ -25,9 +25,15 @@ export class RoomDecorPlacements {
@Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' }) @Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' })
scale: number; scale: number;
@Column({ type: 'float', nullable: false, default: 0, comment: '摆件旋转角度' })
rotation_degrees: number;
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' }) @Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
z_index: number; z_index: number;
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '最后修改该摆件的布局版本' })
mutation_revision: number;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' }) @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
created_at: Date; created_at: Date;

View File

@@ -1,28 +1,51 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { Repository } from 'typeorm';
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto'; import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
import { RoomDecorPlacements } from './room_decor_placements.entity'; import { RoomDecorPlacements } from './room_decor_placements.entity';
@Injectable() @Injectable()
export class RoomDecorPlacementsService { export class RoomDecorPlacementsService {
constructor( constructor(private readonly dataSource: DataSource) {}
@InjectRepository(RoomDecorPlacements)
private readonly placementsRepository: Repository<RoomDecorPlacements>,
) {}
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> { async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
return await this.placementsRepository.find({ return await this.dataSource.getRepository(RoomDecorPlacements).find({
where: { user_id: userId }, where: { user_id: userId },
order: { created_at: 'ASC', id: 'ASC' }, order: { created_at: 'ASC', id: 'ASC' },
}); });
} }
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> { async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
const decorId = this.normalizeDecorId(placement.decor_id); return await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
let row = await this.placementsRepository.findOne({ await this.ensureLayoutState(manager, userId);
where: { user_id: userId, decor_id: decorId }, const state = await manager.getRepository(RoomDecorLayoutState).findOneByOrFail({ user_id: userId });
const placements = await manager.getRepository(RoomDecorPlacements).find({
where: { user_id: userId },
order: { created_at: 'ASC', id: 'ASC' },
}); });
return { revision: state.revision, placements };
});
}
async savePlacement(
userId: bigint,
placement: SaveRoomDecorPlacementDto,
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
return await this.dataSource.transaction(async (manager) => {
const state = await this.lockLayoutState(manager, userId);
const placementsRepository = manager.getRepository(RoomDecorPlacements);
const decorId = this.normalizeDecorId(placement.decor_id);
const mutationScope = `decor:${decorId}`;
let row = await placementsRepository.findOne({
where: { user_id: userId, decor_id: decorId },
lock: { mode: 'pessimistic_write' },
});
if (this.isIdempotentRetry(state, placement, mutationScope)) {
if (!row) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
return { revision: state.revision, placement: row };
}
this.assertNextRevision(state, placement);
if (!row) { if (!row) {
row = new RoomDecorPlacements(); row = new RoomDecorPlacements();
row.user_id = userId; row.user_id = userId;
@@ -33,9 +56,94 @@ export class RoomDecorPlacementsService {
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
row.scale = Number(placement.scale ?? row.scale ?? 1); row.scale = Number(placement.scale ?? row.scale ?? 1);
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
row.z_index = Number(placement.z_index ?? row.z_index ?? 0); row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
row.mutation_revision = placement.mutation_revision;
row.updated_at = new Date(); row.updated_at = new Date();
return await this.placementsRepository.save(row); const saved = await placementsRepository.save(row);
await this.advanceLayoutState(manager, state, placement.mutation_revision, placement.mutation_id, mutationScope);
return { revision: placement.mutation_revision, placement: saved };
});
}
async resetPlacements(
userId: bigint,
mutation: ResetRoomDecorPlacementsDto,
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
return await this.dataSource.transaction(async (manager) => {
const state = await this.lockLayoutState(manager, userId);
if (!this.isIdempotentRetry(state, mutation, 'reset')) {
this.assertNextRevision(state, mutation);
await manager.getRepository(RoomDecorPlacements).createQueryBuilder()
.update(RoomDecorPlacements)
.set({
placed: false,
position_x: null,
position_y: null,
mutation_revision: mutation.mutation_revision,
})
.where('user_id = :userId', { userId: userId.toString() })
.execute();
await this.advanceLayoutState(manager, state, mutation.mutation_revision, mutation.mutation_id, 'reset');
}
const placements = await manager.getRepository(RoomDecorPlacements).find({
where: { user_id: userId },
order: { created_at: 'ASC', id: 'ASC' },
});
return { revision: state.revision, placements };
});
}
private async ensureLayoutState(manager: EntityManager, userId: bigint): Promise<void> {
await manager.getRepository(RoomDecorLayoutState).createQueryBuilder()
.insert()
.values({ user_id: userId, revision: 0, last_mutation_id: null, last_mutation_scope: null })
.orIgnore()
.execute();
}
private async lockLayoutState(manager: EntityManager, userId: bigint): Promise<RoomDecorLayoutState> {
await this.ensureLayoutState(manager, userId);
return await manager.getRepository(RoomDecorLayoutState).findOneOrFail({
where: { user_id: userId },
lock: { mode: 'pessimistic_write' },
});
}
private isIdempotentRetry(
state: RoomDecorLayoutState,
mutation: Pick<SaveRoomDecorPlacementDto, 'mutation_revision' | 'mutation_id'>,
mutationScope: string,
): boolean {
return mutation.mutation_revision === state.revision
&& mutation.mutation_id === state.last_mutation_id
&& mutationScope === state.last_mutation_scope;
}
private assertNextRevision(
state: RoomDecorLayoutState,
mutation: Pick<SaveRoomDecorPlacementDto, 'layout_revision' | 'mutation_revision'>,
): void {
if (mutation.layout_revision !== state.revision || mutation.mutation_revision !== state.revision + 1) {
throw new ConflictException({
message: '装修布局版本已过期,请刷新后重试',
current_revision: state.revision,
});
}
}
private async advanceLayoutState(
manager: EntityManager,
state: RoomDecorLayoutState,
revision: number,
mutationId: string,
mutationScope: string,
): Promise<void> {
state.revision = revision;
state.last_mutation_id = mutationId;
state.last_mutation_scope = mutationScope;
state.updated_at = new Date();
await manager.getRepository(RoomDecorLayoutState).save(state);
} }
private normalizeDecorId(decorId: string): string { private normalizeDecorId(decorId: string): string {

View File

@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto'; import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
import { RoomDecorPlacements } from './room_decor_placements.entity'; import { RoomDecorPlacements } from './room_decor_placements.entity';
@Injectable() @Injectable()
@@ -7,6 +8,11 @@ export class RoomDecorPlacementsMemoryService {
private placements: Map<bigint, RoomDecorPlacements> = new Map(); private placements: Map<bigint, RoomDecorPlacements> = new Map();
private userDecorIndex: Map<string, bigint> = new Map(); private userDecorIndex: Map<string, bigint> = new Map();
private currentId: bigint = BigInt(1); private currentId: bigint = BigInt(1);
private layoutStates: Map<bigint, {
revision: number;
last_mutation_id: string | null;
last_mutation_scope: string | null;
}> = new Map();
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> { async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
return Array.from(this.placements.values()) return Array.from(this.placements.values())
@@ -17,11 +23,28 @@ export class RoomDecorPlacementsMemoryService {
}); });
} }
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> { async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
const state = this.getLayoutState(userId);
return { revision: state.revision, placements: await this.listPlacements(userId) };
}
async savePlacement(
userId: bigint,
placement: SaveRoomDecorPlacementDto,
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
const state = this.getLayoutState(userId);
const decorId = this.normalizeDecorId(placement.decor_id); const decorId = this.normalizeDecorId(placement.decor_id);
const mutationScope = `decor:${decorId}`;
const key = this.indexKey(userId, decorId); const key = this.indexKey(userId, decorId);
const existingId = this.userDecorIndex.get(key); const existingId = this.userDecorIndex.get(key);
const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements(); const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements();
if (placement.mutation_revision === state.revision
&& placement.mutation_id === state.last_mutation_id
&& mutationScope === state.last_mutation_scope) {
if (!existingId) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
return { revision: state.revision, placement: row };
}
this.assertNextRevision(state, placement.layout_revision, placement.mutation_revision);
if (!existingId) { if (!existingId) {
row.id = this.currentId++; row.id = this.currentId++;
row.user_id = userId; row.user_id = userId;
@@ -34,9 +57,64 @@ export class RoomDecorPlacementsMemoryService {
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
row.scale = Number(placement.scale ?? row.scale ?? 1); row.scale = Number(placement.scale ?? row.scale ?? 1);
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
row.z_index = Number(placement.z_index ?? row.z_index ?? 0); row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
row.mutation_revision = placement.mutation_revision;
row.updated_at = new Date(); row.updated_at = new Date();
return row; state.revision = placement.mutation_revision;
state.last_mutation_id = placement.mutation_id;
state.last_mutation_scope = mutationScope;
return { revision: state.revision, placement: row };
}
async resetPlacements(
userId: bigint,
mutation: ResetRoomDecorPlacementsDto,
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
const state = this.getLayoutState(userId);
if (!(mutation.mutation_revision === state.revision
&& mutation.mutation_id === state.last_mutation_id
&& state.last_mutation_scope === 'reset')) {
this.assertNextRevision(state, mutation.layout_revision, mutation.mutation_revision);
for (const row of this.placements.values()) {
if (row.user_id !== userId) continue;
row.placed = false;
row.position_x = null;
row.position_y = null;
row.mutation_revision = mutation.mutation_revision;
row.updated_at = new Date();
}
state.revision = mutation.mutation_revision;
state.last_mutation_id = mutation.mutation_id;
state.last_mutation_scope = 'reset';
}
return { revision: state.revision, placements: await this.listPlacements(userId) };
}
private getLayoutState(userId: bigint): {
revision: number;
last_mutation_id: string | null;
last_mutation_scope: string | null;
} {
let state = this.layoutStates.get(userId);
if (!state) {
state = { revision: 0, last_mutation_id: null, last_mutation_scope: null };
this.layoutStates.set(userId, state);
}
return state;
}
private assertNextRevision(
state: { revision: number },
layoutRevision: number,
mutationRevision: number,
): void {
if (layoutRevision !== state.revision || mutationRevision !== state.revision + 1) {
throw new ConflictException({
message: '装修布局版本已过期,请刷新后重试',
current_revision: state.revision,
});
}
} }
private indexKey(userId: bigint, decorId: string): string { private indexKey(userId: bigint, decorId: string): string {

View File

@@ -265,6 +265,22 @@ export class FileRedisService implements IRedisService, OnModuleDestroy {
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`); this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
} }
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
const existing = this.data.get(key);
if (existing && (!existing.expireAt || existing.expireAt > Date.now())) {
return false;
}
if (existing) {
this.data.delete(key);
}
this.data.set(key, {
value,
expireAt: ttl && ttl > 0 ? Date.now() + ttl * 1000 : undefined,
});
await this.saveData();
return true;
}
/** /**
* 获取键对应的值 * 获取键对应的值
* *

View File

@@ -134,6 +134,18 @@ export class RealRedisService implements IRedisService, OnModuleDestroy {
} }
} }
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
try {
const result = ttl && ttl > 0
? await this.redis.set(key, value, 'EX', ttl, 'NX')
: await this.redis.set(key, value, 'NX');
return result === 'OK';
} catch (error) {
this.logger.error(`原子设置Redis键失败: ${key}`, error);
throw error;
}
}
/** /**
* 获取键对应的值 * 获取键对应的值
* *

View File

@@ -45,6 +45,13 @@ export interface IRedisService {
*/ */
set(key: string, value: string, ttl?: number): Promise<void>; set(key: string, value: string, ttl?: number): Promise<void>;
/**
* 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。
*
* @returns 成功占位返回 true键已存在返回 false
*/
setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean>;
/** /**
* 设置键值对并指定过期时间 * 设置键值对并指定过期时间
* *

View File

@@ -1108,6 +1108,11 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
} }
private legacyFriend(profile: any) { private legacyFriend(profile: any) {
return { userId: String(profile.id), username: String(profile.nickname || profile.username || '玩家'), online: Boolean(profile.online) }; return {
userId: String(profile.id),
username: String(profile.nickname || profile.username || '玩家'),
online: Boolean(profile.online),
room_visitable: Boolean(profile.room_visitable),
};
} }
} }