From b5365751bba6dac422fb2f55de7496a2e1c8ff8e Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:19:58 +0800 Subject: [PATCH] feat: add nearby social services --- src/app.module.ts | 2 + src/business/auth/account_profile.service.ts | 6 +- src/business/player/player.controller.ts | 15 + src/business/social/dto/social.dto.ts | 65 +++ .../migrations/create-social-tables.sql | 92 ++++ src/business/social/social.controller.ts | 100 ++++ src/business/social/social.database-store.ts | 62 +++ src/business/social/social.entities.ts | 176 +++++++ src/business/social/social.memory-store.ts | 78 +++ src/business/social/social.module.ts | 34 ++ src/business/social/social.service.ts | 473 ++++++++++++++++++ src/business/social/social.store.ts | 42 ++ .../db/user_profiles/user_profiles.entity.ts | 6 +- .../db/user_profiles/user_profiles.service.ts | 4 +- .../user_profiles_memory.service.ts | 4 +- src/core/db/users/users.entity.ts | 9 +- src/gateway/chat/chat.gateway.ts | 219 ++++---- 17 files changed, 1266 insertions(+), 121 deletions(-) create mode 100644 src/business/social/dto/social.dto.ts create mode 100644 src/business/social/migrations/create-social-tables.sql create mode 100644 src/business/social/social.controller.ts create mode 100644 src/business/social/social.database-store.ts create mode 100644 src/business/social/social.entities.ts create mode 100644 src/business/social/social.memory-store.ts create mode 100644 src/business/social/social.module.ts create mode 100644 src/business/social/social.service.ts create mode 100644 src/business/social/social.store.ts diff --git a/src/app.module.ts b/src/app.module.ts index 67009aa..4b6a315 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,7 @@ import { UserWalletsModule } from './core/db/user_wallets/user_wallets.module'; import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module'; import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware'; import { ContentTypeMiddleware } from './core/security_core/content_type.middleware'; +import { SocialModule } from './business/social/social.module'; /** * 检查数据库配置是否完整 by angjustinl 2025-12-17 @@ -106,6 +107,7 @@ function isDatabaseConfigured(): boolean { CafeCompanionModule, CourseResourcesModule, RankingsModule, + SocialModule.forRoot(), ], controllers: [AppController], providers: [ diff --git a/src/business/auth/account_profile.service.ts b/src/business/auth/account_profile.service.ts index d0ab5ce..12b36c5 100644 --- a/src/business/auth/account_profile.service.ts +++ b/src/business/auth/account_profile.service.ts @@ -92,6 +92,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = { ui_scale: 1.00, fullscreen: false, show_interaction_hints: true, + show_interaction_points: false, show_name_always: false, show_chat_bubbles: true, world_notifications: true, @@ -99,12 +100,14 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = { friend_request_notifications: true, allow_nearby_private: true, allow_nearby_friend_requests: true, + allow_nearby_profile: true, mute_ui_sfx: false, }; const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']); const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([ 'fullscreen', 'show_interaction_hints', + 'show_interaction_points', 'show_name_always', 'show_chat_bubbles', 'world_notifications', @@ -112,6 +115,7 @@ const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([ 'friend_request_notifications', 'allow_nearby_private', 'allow_nearby_friend_requests', + 'allow_nearby_profile', 'mute_ui_sfx', ]); @@ -246,7 +250,7 @@ export class AccountProfileService { tags: { [REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: true, }, - current_map: 'plaza', + current_map: 'whale_port', pos_x: 0, pos_y: 0, status: 0, diff --git a/src/business/player/player.controller.ts b/src/business/player/player.controller.ts index 3ebc67d..6b42860 100644 --- a/src/business/player/player.controller.ts +++ b/src/business/player/player.controller.ts @@ -9,6 +9,8 @@ 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'; +import { SocialService } from '../social/social.service'; +import { UpdateSocialProfileDto } from '../social/dto/social.dto'; @ApiTags('player') @ApiBearerAuth() @@ -18,6 +20,7 @@ export class PlayerController { constructor( private readonly playerStateService: PlayerStateService, private readonly economyService: EconomyService, + private readonly socialService: SocialService, ) {} @ApiOperation({ summary: '获取当前玩家快照' }) @@ -77,4 +80,16 @@ export class PlayerController { const data = await this.playerStateService.updateProfileAssets(BigInt(user.sub), dto); res.status(HttpStatus.OK).json({ success: true, data, message: '玩家资源更新成功' }); } + + @ApiOperation({ summary: '更新当前玩家社区名片' }) + @Patch('social-profile') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async updateSocialProfile( + @CurrentUser() user: JwtPayload, + @Body() dto: UpdateSocialProfileDto, + @Res() res: Response, + ): Promise { + const data = await this.socialService.updateSocialProfile(BigInt(user.sub), dto); + res.status(HttpStatus.OK).json({ success: true, data, message: '社区名片已更新' }); + } } diff --git a/src/business/social/dto/social.dto.ts b/src/business/social/dto/social.dto.ts new file mode 100644 index 0000000..6aa205e --- /dev/null +++ b/src/business/social/dto/social.dto.ts @@ -0,0 +1,65 @@ +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, Length, Max, Min } from 'class-validator'; + +export class UpdateSocialProfileDto { + @IsOptional() + @IsString() + @Length(1, 50) + nickname?: string; + + @IsOptional() + @IsString() + @Length(0, 160) + bio?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(3) + @IsString({ each: true }) + interests?: string[]; +} + +export class SocialUserActionDto { + @IsString() + @IsNotEmpty() + userId: string; +} + +export class CreateBlockDto extends SocialUserActionDto {} + +export class CreateReportDto extends SocialUserActionDto { + @IsString() + @IsIn(['harassment', 'spam', 'inappropriate_content', 'impersonation', 'other']) + reason: string; + + @IsOptional() + @IsString() + @Length(0, 500) + note?: string; + + @IsOptional() + @IsString() + messageId?: string; + + @IsOptional() + @IsBoolean() + blockAlso?: boolean; +} + +export class PaginationDto { + @IsOptional() + @Type(() => Number) + @Min(1) + @Max(100) + limit?: number = 30; + + @IsOptional() + @IsString() + before?: string; +} + +export class TravelDestinationDto { + @IsString() + @IsNotEmpty() + destinationId: string; +} diff --git a/src/business/social/migrations/create-social-tables.sql b/src/business/social/migrations/create-social-tables.sql new file mode 100644 index 0000000..ed086d5 --- /dev/null +++ b/src/business/social/migrations/create-social-tables.sql @@ -0,0 +1,92 @@ +-- WhaleTown V2 core social infrastructure. Run after the existing users/user_profiles tables. +-- Compatible with MySQL 5.7+/MariaDB: add the column only once. +SET @nickname_column_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'nickname_updated_at' +); +SET @nickname_column_sql := IF( + @nickname_column_exists = 0, + 'ALTER TABLE users ADD COLUMN nickname_updated_at DATETIME NULL COMMENT ''社区昵称最近修改时间''', + 'SELECT 1' +); +PREPARE nickname_column_statement FROM @nickname_column_sql; +EXECUTE nickname_column_statement; +DEALLOCATE PREPARE nickname_column_statement; +UPDATE user_profiles SET current_map = 'whale_port' WHERE current_map = 'plaza'; +UPDATE user_profiles SET current_map = 'personal_space' WHERE current_map = 'room'; + +CREATE TABLE IF NOT EXISTS friendships ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_low_id BIGINT NOT NULL, + user_high_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_friendships_pair (user_low_id, user_high_id), + KEY idx_friendships_low (user_low_id), + KEY idx_friendships_high (user_high_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS friend_requests ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + requester_id BIGINT NOT NULL, + recipient_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + responded_at DATETIME NULL, + KEY idx_friend_requests_recipient (recipient_id, status, expires_at), + KEY idx_friend_requests_pair (requester_id, recipient_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_blocks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + blocked_user_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_blocks_pair (user_id, blocked_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS direct_messages ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + sender_id BIGINT NOT NULL, + recipient_id BIGINT NOT NULL, + content VARCHAR(1000) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + read_at DATETIME NULL, + KEY idx_direct_messages_conversation (sender_id, recipient_id, created_at), + KEY idx_direct_messages_unread (recipient_id, read_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_reports ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + reporter_id BIGINT NOT NULL, + reported_user_id BIGINT NOT NULL, + reason VARCHAR(32) NOT NULL, + note VARCHAR(500) NULL, + message_id BIGINT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'received', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_reports_reporter (reporter_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS player_travel_unlocks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + destination_id VARCHAR(80) NOT NULL, + discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_player_travel_unlocks (user_id, destination_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS social_notifications ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + category VARCHAR(48) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(500) NOT NULL, + action_metadata JSON NULL, + read_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_social_notifications_user (user_id, read_at, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/business/social/social.controller.ts b/src/business/social/social.controller.ts new file mode 100644 index 0000000..a3d1ee3 --- /dev/null +++ b/src/business/social/social.controller.ts @@ -0,0 +1,100 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtPayload } from '../../core/login_core/login_core.service'; +import { CurrentUser } from '../../gateway/auth/current_user.decorator'; +import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; +import { CreateBlockDto, CreateReportDto, PaginationDto, SocialUserActionDto, TravelDestinationDto, UpdateSocialProfileDto } from './dto/social.dto'; +import { SocialService } from './social.service'; + +function id(value: string): bigint { + if (!/^\d+$/.test(String(value || ''))) throw new Error('用户标识无效'); + return BigInt(value); +} + +@ApiTags('social') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('social') +export class SocialController { + constructor(private readonly social: SocialService) {} + + @Get('interest-tags') + interestTags() { return { success: true, data: this.social.getInterestTags() }; } + + @Patch('profile') + async updateProfile(@CurrentUser() user: JwtPayload, @Body() body: UpdateSocialProfileDto) { return { success: true, data: await this.social.updateSocialProfile(id(user.sub), body) }; } + + @Get('profile') + async ownProfile(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getOwnSocialProfile(id(user.sub)) }; } + + @Get('profiles/:userId') + async profile(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.getPublicProfile(id(user.sub), id(userId)) }; } + + @Get('friends') + async friends(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getFriends(id(user.sub)) }; } + + @Get('friend-requests') + async friendRequests(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getFriendRequests(id(user.sub)) }; } + + @Post('friend-requests') + async createFriendRequest(@CurrentUser() user: JwtPayload, @Body() body: SocialUserActionDto) { return { success: true, data: await this.social.createFriendRequest(id(user.sub), id(body.userId)) }; } + + @Post('friend-requests/:requestId/accept') + async acceptFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { return { success: true, data: await this.social.acceptFriendRequest(id(user.sub), id(requestId)) }; } + + @Post('friend-requests/:requestId/reject') + async rejectFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { await this.social.rejectFriendRequest(id(user.sub), id(requestId)); return { success: true }; } + + @Delete('friend-requests/:requestId') + async cancelFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { await this.social.cancelFriendRequest(id(user.sub), id(requestId)); return { success: true }; } + + @Delete('friends/:userId') + async removeFriend(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { await this.social.removeFriend(id(user.sub), id(userId)); return { success: true }; } + + @Get('blocks') + async blocks(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.listBlocks(id(user.sub)) }; } + + @Post('blocks') + async block(@CurrentUser() user: JwtPayload, @Body() body: CreateBlockDto) { return { success: true, data: await this.social.blockUser(id(user.sub), id(body.userId)) }; } + + @Delete('blocks/:userId') + async unblock(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.unblockUser(id(user.sub), id(userId)) }; } + + @Post('reports') + async report(@CurrentUser() user: JwtPayload, @Body() body: CreateReportDto) { return { success: true, data: await this.social.createReport(id(user.sub), { userId: id(body.userId), reason: body.reason, note: body.note, messageId: body.messageId ? id(body.messageId) : undefined, blockAlso: body.blockAlso }) }; } + + @Get('conversations') + async conversations(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.listConversations(id(user.sub)) }; } + + @Get('conversations/:userId/messages') + async conversation(@CurrentUser() user: JwtPayload, @Param('userId') userId: string, @Query() query: PaginationDto) { return { success: true, data: await this.social.listConversation(id(user.sub), id(userId), query.limit || 30, query.before ? new Date(query.before) : undefined) }; } + + @Patch('conversations/:userId/read') + async markConversationRead(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.markConversationRead(id(user.sub), id(userId)) }; } + + @Get('notifications') + async notifications(@CurrentUser() user: JwtPayload, @Query() query: PaginationDto) { return { success: true, data: await this.social.getNotificationSummary(id(user.sub), query.limit || 30, query.before ? new Date(query.before) : undefined) }; } + + @Patch('notifications/:notificationId/read') + async markNotificationRead(@CurrentUser() user: JwtPayload, @Param('notificationId') notificationId: string) { return { success: true, data: await this.social.markNotificationRead(id(user.sub), id(notificationId)) }; } + + @Patch('notifications/read-all') + async markAllNotificationsRead(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.markAllNotificationsRead(id(user.sub)) }; } +} + +@ApiTags('world') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('world/travel-destinations') +export class WorldTravelController { + constructor(private readonly social: SocialService) {} + + @Get() + async destinations(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getTravelDestinations(id(user.sub)) }; } + + @Post(':destinationId/discover') + async discover(@CurrentUser() user: JwtPayload, @Param('destinationId') destinationId: string) { return { success: true, data: await this.social.discoverDestination(id(user.sub), destinationId) }; } + + @Post(':destinationId/travel') + async travel(@CurrentUser() user: JwtPayload, @Param('destinationId') destinationId: string) { return { success: true, data: await this.social.travelTo(id(user.sub), destinationId) }; } +} diff --git a/src/business/social/social.database-store.ts b/src/business/social/social.database-store.ts new file mode 100644 index 0000000..65ef8c4 --- /dev/null +++ b/src/business/social/social.database-store.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, LessThan, MoreThan, Repository } from 'typeorm'; +import { + DirectMessage, + FriendRequest, + FriendRequestStatus, + Friendship, + PlayerTravelUnlock, + SocialNotification, + UserBlock, + UserReport, +} from './social.entities'; +import { SocialStore } from './social.store'; + +@Injectable() +export class SocialDatabaseStore implements SocialStore { + constructor( + @InjectRepository(Friendship) private readonly friendships: Repository, + @InjectRepository(FriendRequest) private readonly requests: Repository, + @InjectRepository(UserBlock) private readonly blocks: Repository, + @InjectRepository(DirectMessage) private readonly messages: Repository, + @InjectRepository(UserReport) private readonly reports: Repository, + @InjectRepository(PlayerTravelUnlock) private readonly unlocks: Repository, + @InjectRepository(SocialNotification) private readonly notifications: Repository, + ) {} + + async listFriendships(userId: bigint): Promise { + return this.friendships.find({ where: [{ user_low_id: userId }, { user_high_id: userId }], order: { created_at: 'DESC' } }); + } + async findFriendship(userLowId: bigint, userHighId: bigint): Promise { return this.friendships.findOne({ where: { user_low_id: userLowId, user_high_id: userHighId } }); } + async createFriendship(userLowId: bigint, userHighId: bigint): Promise { + const existing = await this.findFriendship(userLowId, userHighId); return existing || this.friendships.save(this.friendships.create({ user_low_id: userLowId, user_high_id: userHighId })); + } + async deleteFriendship(userLowId: bigint, userHighId: bigint): Promise { await this.friendships.delete({ user_low_id: userLowId, user_high_id: userHighId }); } + async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise { return this.requests.findOne({ where: { requester_id: requesterId, recipient_id: recipientId, status: FriendRequestStatus.PENDING, expires_at: MoreThan(new Date()) } }); } + async createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise { return this.requests.save(this.requests.create({ requester_id: requesterId, recipient_id: recipientId, expires_at: expiresAt, status: FriendRequestStatus.PENDING })); } + async findFriendRequest(id: bigint): Promise { return this.requests.findOne({ where: { id } }); } + async saveFriendRequest(request: FriendRequest): Promise { return this.requests.save(request); } + async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise { + await this.requests.createQueryBuilder().update(FriendRequest).set({ status: FriendRequestStatus.CANCELLED, responded_at: new Date() }).where('status = :status', { status: FriendRequestStatus.PENDING }).andWhere(new Brackets((qb) => qb.where('(requester_id = :a AND recipient_id = :b)', { a: userA, b: userB }).orWhere('(requester_id = :b AND recipient_id = :a)', { a: userA, b: userB }))).execute(); + } + async listFriendRequests(userId: bigint): Promise { return this.requests.find({ where: { recipient_id: userId, status: FriendRequestStatus.PENDING, expires_at: MoreThan(new Date()) }, order: { created_at: 'DESC' } }); } + async createBlock(userId: bigint, blockedUserId: bigint): Promise { const found = await this.blocks.findOne({ where: { user_id: userId, blocked_user_id: blockedUserId } }); return found || this.blocks.save(this.blocks.create({ user_id: userId, blocked_user_id: blockedUserId })); } + async deleteBlock(userId: bigint, blockedUserId: bigint): Promise { await this.blocks.delete({ user_id: userId, blocked_user_id: blockedUserId }); } + async isBlocked(userId: bigint, blockedUserId: bigint): Promise { return (await this.blocks.count({ where: { user_id: userId, blocked_user_id: blockedUserId } })) > 0; } + async listBlocks(userId: bigint): Promise { return this.blocks.find({ where: { user_id: userId }, order: { created_at: 'DESC' } }); } + async createDirectMessage(input: Pick): Promise { return this.messages.save(this.messages.create(input)); } + async listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise { const query = this.messages.createQueryBuilder('message').where('message.expires_at > :now', { now: new Date() }).andWhere(new Brackets((qb) => qb.where('(message.sender_id = :a AND message.recipient_id = :b)', { a: userA, b: userB }).orWhere('(message.sender_id = :b AND message.recipient_id = :a)', { a: userA, b: userB }))); if (before) query.andWhere('message.created_at < :before', { before }); return query.orderBy('message.created_at', 'DESC').take(limit).getMany(); } + async markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise { const result = await this.messages.createQueryBuilder().update(DirectMessage).set({ read_at: new Date() }).where('sender_id = :otherUserId AND recipient_id = :readerId AND read_at IS NULL', { readerId, otherUserId }).execute(); return result.affected || 0; } + async listConversations(userId: bigint): Promise { const rows = await this.messages.createQueryBuilder('message').where('(message.sender_id = :userId OR message.recipient_id = :userId)', { userId }).andWhere('message.expires_at > :now', { now: new Date() }).orderBy('message.created_at', 'DESC').getMany(); const seen = new Set(); return rows.filter((row) => { const other = (row.sender_id === userId ? row.recipient_id : row.sender_id).toString(); if (seen.has(other)) return false; seen.add(other); return true; }); } + async countUnreadDirectMessages(userId: bigint): Promise { return this.messages.count({ where: { recipient_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); } + async createReport(input: Pick): Promise { return this.reports.save(this.reports.create(input)); } + async createUnlock(userId: bigint, destinationId: string): Promise { const found = await this.unlocks.findOne({ where: { user_id: userId, destination_id: destinationId } }); return found || this.unlocks.save(this.unlocks.create({ user_id: userId, destination_id: destinationId })); } + async listUnlocks(userId: bigint): Promise { return this.unlocks.find({ where: { user_id: userId }, order: { discovered_at: 'ASC' } }); } + async createNotification(input: Pick): Promise { return this.notifications.save(this.notifications.create(input)); } + async listNotifications(userId: bigint, limit: number, before?: Date): Promise { const where: any = { user_id: userId, expires_at: MoreThan(new Date()) }; if (before) where.created_at = LessThan(before); return this.notifications.find({ where, order: { created_at: 'DESC' }, take: limit }); } + async markNotificationRead(userId: bigint, id: bigint): Promise { const notification = await this.notifications.findOne({ where: { id, user_id: userId } }); if (!notification) return null; notification.read_at = new Date(); return this.notifications.save(notification); } + async markAllNotificationsRead(userId: bigint): Promise { const result = await this.notifications.createQueryBuilder().update(SocialNotification).set({ read_at: new Date() }).where('user_id = :userId AND read_at IS NULL', { userId }).execute(); return result.affected || 0; } + async countUnreadNotifications(userId: bigint): Promise { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); } + async cleanupExpired(now: Date): Promise { await Promise.all([this.requests.delete({ status: FriendRequestStatus.PENDING, expires_at: LessThan(now) }), this.messages.delete({ expires_at: LessThan(now) }), this.notifications.delete({ expires_at: LessThan(now) })]); } +} diff --git a/src/business/social/social.entities.ts b/src/business/social/social.entities.ts new file mode 100644 index 0000000..6df4ad0 --- /dev/null +++ b/src/business/social/social.entities.ts @@ -0,0 +1,176 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('friendships') +@Index(['user_low_id', 'user_high_id'], { unique: true }) +export class Friendship { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + user_low_id: bigint; + + @Column({ type: 'bigint' }) + user_high_id: bigint; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; +} + +export enum FriendRequestStatus { + PENDING = 'pending', + ACCEPTED = 'accepted', + REJECTED = 'rejected', + CANCELLED = 'cancelled', +} + +@Entity('friend_requests') +@Index(['requester_id', 'recipient_id', 'status']) +export class FriendRequest { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + requester_id: bigint; + + @Column({ type: 'bigint' }) + recipient_id: bigint; + + @Column({ type: 'varchar', length: 16, default: FriendRequestStatus.PENDING }) + status: FriendRequestStatus; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; + + @Column({ type: 'datetime' }) + expires_at: Date; + + @Column({ type: 'datetime', nullable: true }) + responded_at?: Date | null; +} + +@Entity('user_blocks') +@Index(['user_id', 'blocked_user_id'], { unique: true }) +export class UserBlock { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + user_id: bigint; + + @Column({ type: 'bigint' }) + blocked_user_id: bigint; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; +} + +@Entity('direct_messages') +@Index(['sender_id', 'recipient_id', 'created_at']) +@Index(['recipient_id', 'read_at']) +export class DirectMessage { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + sender_id: bigint; + + @Column({ type: 'bigint' }) + recipient_id: bigint; + + @Column({ type: 'varchar', length: 1000 }) + content: string; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; + + @Column({ type: 'datetime' }) + expires_at: Date; + + @Column({ type: 'datetime', nullable: true }) + read_at?: Date | null; +} + +@Entity('user_reports') +@Index(['reporter_id', 'created_at']) +export class UserReport { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + reporter_id: bigint; + + @Column({ type: 'bigint' }) + reported_user_id: bigint; + + @Column({ type: 'varchar', length: 32 }) + reason: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + note?: string | null; + + @Column({ type: 'bigint', nullable: true }) + message_id?: bigint | null; + + @Column({ type: 'varchar', length: 24, default: 'received' }) + status: string; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; +} + +@Entity('player_travel_unlocks') +@Index(['user_id', 'destination_id'], { unique: true }) +export class PlayerTravelUnlock { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + user_id: bigint; + + @Column({ type: 'varchar', length: 80 }) + destination_id: string; + + @CreateDateColumn({ type: 'datetime' }) + discovered_at: Date; +} + +@Entity('social_notifications') +@Index(['user_id', 'read_at', 'created_at']) +export class SocialNotification { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: bigint; + + @Column({ type: 'bigint' }) + user_id: bigint; + + @Column({ type: 'varchar', length: 48 }) + category: string; + + @Column({ type: 'varchar', length: 100 }) + title: string; + + @Column({ type: 'varchar', length: 500 }) + content: string; + + @Column({ type: 'json', nullable: true }) + action_metadata?: Record | null; + + @Column({ type: 'datetime', nullable: true }) + read_at?: Date | null; + + @CreateDateColumn({ type: 'datetime' }) + created_at: Date; + + @Column({ type: 'datetime' }) + expires_at: Date; + + @UpdateDateColumn({ type: 'datetime' }) + updated_at: Date; +} diff --git a/src/business/social/social.memory-store.ts b/src/business/social/social.memory-store.ts new file mode 100644 index 0000000..566e67d --- /dev/null +++ b/src/business/social/social.memory-store.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { + DirectMessage, + FriendRequest, + FriendRequestStatus, + Friendship, + PlayerTravelUnlock, + SocialNotification, + UserBlock, + UserReport, +} from './social.entities'; +import { SocialStore } from './social.store'; + +@Injectable() +export class SocialMemoryStore implements SocialStore { + private nextId = BigInt(1); + private friendships: Friendship[] = []; + private requests: FriendRequest[] = []; + private blocks: UserBlock[] = []; + private messages: DirectMessage[] = []; + private reports: UserReport[] = []; + private unlocks: PlayerTravelUnlock[] = []; + private notifications: SocialNotification[] = []; + + private id(): bigint { return this.nextId++; } + + async listFriendships(userId: bigint): Promise { + return this.friendships.filter((item) => item.user_low_id === userId || item.user_high_id === userId); + } + async findFriendship(userLowId: bigint, userHighId: bigint): Promise { + return this.friendships.find((item) => item.user_low_id === userLowId && item.user_high_id === userHighId) || null; + } + async createFriendship(userLowId: bigint, userHighId: bigint): Promise { + const found = await this.findFriendship(userLowId, userHighId); + if (found) return found; + const record = Object.assign(new Friendship(), { id: this.id(), user_low_id: userLowId, user_high_id: userHighId, created_at: new Date() }); + this.friendships.push(record); return record; + } + async deleteFriendship(userLowId: bigint, userHighId: bigint): Promise { + this.friendships = this.friendships.filter((item) => item.user_low_id !== userLowId || item.user_high_id !== userHighId); + } + async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise { + return this.requests.find((item) => item.requester_id === requesterId && item.recipient_id === recipientId && item.status === FriendRequestStatus.PENDING && item.expires_at > new Date()) || null; + } + async createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise { + const record = Object.assign(new FriendRequest(), { id: this.id(), requester_id: requesterId, recipient_id: recipientId, status: FriendRequestStatus.PENDING, created_at: new Date(), expires_at: expiresAt, responded_at: null }); + this.requests.push(record); return record; + } + async findFriendRequest(id: bigint): Promise { return this.requests.find((item) => item.id === id) || null; } + async saveFriendRequest(request: FriendRequest): Promise { return request; } + async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise { + for (const request of this.requests) if (request.status === FriendRequestStatus.PENDING && ((request.requester_id === userA && request.recipient_id === userB) || (request.requester_id === userB && request.recipient_id === userA))) { request.status = FriendRequestStatus.CANCELLED; request.responded_at = new Date(); } + } + async listFriendRequests(userId: bigint): Promise { return this.requests.filter((item) => item.recipient_id === userId && item.status === FriendRequestStatus.PENDING && item.expires_at > new Date()).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); } + async createBlock(userId: bigint, blockedUserId: bigint): Promise { + const found = this.blocks.find((item) => item.user_id === userId && item.blocked_user_id === blockedUserId); if (found) return found; + const record = Object.assign(new UserBlock(), { id: this.id(), user_id: userId, blocked_user_id: blockedUserId, created_at: new Date() }); this.blocks.push(record); return record; + } + async deleteBlock(userId: bigint, blockedUserId: bigint): Promise { this.blocks = this.blocks.filter((item) => item.user_id !== userId || item.blocked_user_id !== blockedUserId); } + async isBlocked(userId: bigint, blockedUserId: bigint): Promise { return this.blocks.some((item) => item.user_id === userId && item.blocked_user_id === blockedUserId); } + async listBlocks(userId: bigint): Promise { return this.blocks.filter((item) => item.user_id === userId); } + async createDirectMessage(input: Pick): Promise { + const record = Object.assign(new DirectMessage(), { id: this.id(), ...input, created_at: new Date(), read_at: null }); this.messages.push(record); return record; + } + async listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise { return this.messages.filter((item) => ((item.sender_id === userA && item.recipient_id === userB) || (item.sender_id === userB && item.recipient_id === userA)) && item.expires_at > new Date() && (!before || item.created_at < before)).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit); } + async markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise { let affected = 0; for (const item of this.messages) if (item.sender_id === otherUserId && item.recipient_id === readerId && !item.read_at) { item.read_at = new Date(); affected++; } return affected; } + async listConversations(userId: bigint): Promise { const latest = new Map(); for (const item of this.messages) { if (item.expires_at <= new Date() || (item.sender_id !== userId && item.recipient_id !== userId)) continue; const other = item.sender_id === userId ? item.recipient_id : item.sender_id; const old = latest.get(other.toString()); if (!old || old.created_at < item.created_at) latest.set(other.toString(), item); } return [...latest.values()].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); } + async countUnreadDirectMessages(userId: bigint): Promise { return this.messages.filter((item) => item.recipient_id === userId && !item.read_at && item.expires_at > new Date()).length; } + async createReport(input: Pick): Promise { const record = Object.assign(new UserReport(), { id: this.id(), ...input, status: 'received', created_at: new Date() }); this.reports.push(record); return record; } + async createUnlock(userId: bigint, destinationId: string): Promise { const found = this.unlocks.find((item) => item.user_id === userId && item.destination_id === destinationId); if (found) return found; const record = Object.assign(new PlayerTravelUnlock(), { id: this.id(), user_id: userId, destination_id: destinationId, discovered_at: new Date() }); this.unlocks.push(record); return record; } + async listUnlocks(userId: bigint): Promise { return this.unlocks.filter((item) => item.user_id === userId); } + async createNotification(input: Pick): Promise { const now = new Date(); const record = Object.assign(new SocialNotification(), { id: this.id(), ...input, created_at: now, updated_at: now, read_at: null }); this.notifications.push(record); return record; } + async listNotifications(userId: bigint, limit: number, before?: Date): Promise { return this.notifications.filter((item) => item.user_id === userId && item.expires_at > new Date() && (!before || item.created_at < before)).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit); } + async markNotificationRead(userId: bigint, id: bigint): Promise { const item = this.notifications.find((entry) => entry.user_id === userId && entry.id === id); if (item) { item.read_at = new Date(); item.updated_at = new Date(); } return item || null; } + async markAllNotificationsRead(userId: bigint): Promise { let affected = 0; for (const item of this.notifications) if (item.user_id === userId && !item.read_at) { item.read_at = new Date(); item.updated_at = new Date(); affected++; } return affected; } + async countUnreadNotifications(userId: bigint): Promise { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; } + async cleanupExpired(now: Date): Promise { this.requests = this.requests.filter((item) => item.status !== FriendRequestStatus.PENDING || item.expires_at > now); this.messages = this.messages.filter((item) => item.expires_at > now); this.notifications = this.notifications.filter((item) => item.expires_at > now); } +} diff --git a/src/business/social/social.module.ts b/src/business/social/social.module.ts new file mode 100644 index 0000000..96050a1 --- /dev/null +++ b/src/business/social/social.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { ChatModule } from '../chat/chat.module'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { SocialController, WorldTravelController } from './social.controller'; +import { SocialDatabaseStore } from './social.database-store'; +import { SocialMemoryStore } from './social.memory-store'; +import { DirectMessage, FriendRequest, Friendship, PlayerTravelUnlock, SocialNotification, UserBlock, UserReport } from './social.entities'; +import { SocialService } from './social.service'; +import { SOCIAL_STORE } from './social.store'; + +function isDatabaseConfigured(): boolean { + return ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'].every((key) => process.env[key]); +} + +@Global() +@Module({}) +export class SocialModule { + static forRoot(): DynamicModule { + const database = isDatabaseConfigured(); + return { + module: SocialModule, + imports: [AuthModule, ChatModule, LoginCoreModule, ...(database ? [TypeOrmModule.forFeature([Friendship, FriendRequest, UserBlock, DirectMessage, UserReport, PlayerTravelUnlock, SocialNotification])] : [])], + controllers: [SocialController, WorldTravelController], + providers: [ + ...(database ? [SocialDatabaseStore] : [SocialMemoryStore]), + { provide: SOCIAL_STORE, useExisting: database ? SocialDatabaseStore : SocialMemoryStore }, + SocialService, + ], + exports: [SocialService], + }; + } +} diff --git a/src/business/social/social.service.ts b/src/business/social/social.service.ts new file mode 100644 index 0000000..55aac37 --- /dev/null +++ b/src/business/social/social.service.ts @@ -0,0 +1,473 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { AccountProfileService } from '../auth/account_profile.service'; +import { ChatSessionService } from '../chat/services/chat_session.service'; +import { FriendRequestStatus } from './social.entities'; +import { SOCIAL_STORE, SocialStore } from './social.store'; + +const NEARBY_DISTANCE = 160; +const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const FRIEND_REQUEST_MS = 30 * 24 * 60 * 60 * 1000; +const NICKNAME_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; + +export const INTEREST_TAGS = [ + { id: 'ai', label: 'AI/大模型' }, + { id: 'programming', label: '编程开发' }, + { id: 'data_science', label: '数据科学' }, + { id: 'open_source', label: '开源协作' }, + { id: 'product', label: '产品' }, + { id: 'design', label: '设计' }, + { id: 'game_dev', label: '游戏开发' }, + { id: 'content_creation', label: '内容创作' }, + { id: 'community', label: '社区活动' }, + { id: 'learning_partner', label: '学习搭子' }, + { id: 'career', label: '职业成长' }, + { id: 'casual_chat', label: '轻松闲聊' }, +] as const; + +const INTEREST_IDS = new Set(INTEREST_TAGS.map((tag) => tag.id)); +const SOCIAL_SETTINGS_KEY = 'whaletown_settings'; +const DEFAULT_PRIVACY = { + allow_nearby_profile: true, + allow_nearby_private: true, + allow_nearby_friend_requests: true, +}; +const TRAVEL_MAP_ORIGINS: Record = { + whale_port: { x: 1280, y: 960 }, + work_zone: { x: 1280, y: 960 }, + whale_cafe: { x: 768, y: 512 }, + personal_space: { x: 768, y: 512 }, +}; +const DISCOVERY_DISTANCE = 180; + +export const TRAVEL_DESTINATIONS = [ + { id: 'square_center', mapId: 'whale_port', label: '广场中心', x: 1280, y: 990, initial: true }, + { id: 'square_dock', mapId: 'whale_port', label: '码头', x: 410, y: 758 }, + { id: 'square_headquarters', mapId: 'whale_port', label: '总部', x: 1293, y: 360 }, + { id: 'square_cottage', mapId: 'whale_port', label: '小屋', x: 2125, y: 768 }, + { id: 'square_workshop', mapId: 'whale_port', label: '工坊', x: 1925, y: 1460 }, + { id: 'square_notice', mapId: 'whale_port', label: '公告栏', x: 738, y: 1608 }, + { id: 'square_work_zone_gate', mapId: 'whale_port', label: '打工区入口', x: 1280, y: 1735 }, + { id: 'work_entrance', mapId: 'work_zone', label: '打工区入口', x: 1280, y: 1715 }, + { id: 'work_mall', mapId: 'work_zone', label: '商城', x: 1280, y: 346 }, + { id: 'work_cafe_gate', mapId: 'work_zone', label: '咖啡馆入口', x: 236, y: 1182 }, + { id: 'work_jobs', mapId: 'work_zone', label: '任务中心', x: 778, y: 1152 }, + { id: 'work_courses', mapId: 'work_zone', label: '课程看板', x: 1776, y: 960 }, + { id: 'work_ai', mapId: 'work_zone', label: 'AI 站', x: 1732, y: 1508 }, + { id: 'work_exchange', mapId: 'work_zone', label: '鲸币兑换处', x: 2355, y: 1508 }, + { id: 'cafe_entrance', mapId: 'whale_cafe', label: '咖啡馆入口', x: 768, y: 875 }, + { id: 'cafe_counter', mapId: 'whale_cafe', label: '服务台', x: 768, y: 475 }, + { id: 'cafe_companion', mapId: 'whale_cafe', label: '陪伴区', x: 370, y: 286 }, + { id: 'personal_room', mapId: 'personal_space', label: '我的房间', x: 768, y: 512, initial: true }, +] as const; + +type RealtimeGateway = { + sendToPlayer(socketId: string, payload: Record): void; +}; + +interface UserRecord { + id: bigint; + username: string; + nickname: string; + avatar_url?: string | null; + nickname_updated_at?: Date | null; +} + +interface UserService { + findOne(id: bigint): Promise; + update(id: bigint, payload: Record): Promise; +} + +interface ProfileRecord { + id: bigint; + user_id: bigint; + bio?: string | null; + tags?: Record | null; + skin_id?: string | null; + current_map: string; + pos_x: number; + pos_y: number; +} + +interface ProfileService { + findByUserId(userId: bigint): Promise; + update(id: bigint, payload: Record): Promise; +} + +@Injectable() +export class SocialService { + private readonly logger = new Logger(SocialService.name); + private realtimeGateway?: RealtimeGateway; + + constructor( + @Inject(SOCIAL_STORE) private readonly store: SocialStore, + @Inject('UsersService') private readonly usersService: UserService, + @Inject('IUserProfilesService') private readonly profiles: ProfileService, + private readonly accountProfileService: AccountProfileService, + private readonly sessions: ChatSessionService, + ) {} + + setRealtimeGateway(gateway: RealtimeGateway): void { + this.realtimeGateway = gateway; + } + + getInterestTags() { return INTEREST_TAGS; } + + async getOwnSocialProfile(userId: bigint) { + await this.ensureProfile(userId); + return this.buildProfile(userId, userId, true); + } + + async updateSocialProfile(userId: bigint, update: { nickname?: string; bio?: string; interests?: string[] }) { + const user = await this.usersService.findOne(userId); + const profile = await this.ensureProfile(userId); + + if (update.nickname !== undefined) { + const nickname = update.nickname.trim(); + if (!nickname) throw new BadRequestException('昵称不能为空'); + if (nickname !== user.nickname) { + const lastUpdatedAt = user.nickname_updated_at ? new Date(user.nickname_updated_at).getTime() : 0; + const remaining = NICKNAME_COOLDOWN_MS - (Date.now() - lastUpdatedAt); + if (lastUpdatedAt && remaining > 0) { + throw new ForbiddenException(`昵称每 7 天只能修改一次,还需等待 ${Math.ceil(remaining / 86400000)} 天`); + } + await this.usersService.update(userId, { nickname, nickname_updated_at: new Date() }); + } + } + + const tags = this.profileTags(profile); + if (update.interests !== undefined) { + const interests = [...new Set(update.interests.map((value) => value.trim()))]; + if (interests.length > 3 || interests.some((value) => !INTEREST_IDS.has(value))) { + throw new BadRequestException('兴趣标签不在允许的目录内'); + } + tags.interests = interests; + } + await this.profiles.update(profile.id, { bio: update.bio !== undefined ? update.bio.trim() : profile.bio || '', tags }); + return this.getOwnSocialProfile(userId); + } + + async getPublicProfile(viewerId: bigint, targetId: bigint) { + const self = viewerId === targetId; + if (!self && !(await this.areFriends(viewerId, targetId))) { + await this.assertNearbyAllowed(viewerId, targetId, 'profile'); + } + return this.buildProfile(viewerId, targetId, self); + } + + async getFriends(userId: bigint) { + const friendships = await this.store.listFriendships(userId); + const result = []; + for (const friendship of friendships) { + const friendId = friendship.user_low_id === userId ? friendship.user_high_id : friendship.user_low_id; + result.push(await this.buildProfile(userId, friendId, false)); + } + return result.sort((a, b) => Number(b.online) - Number(a.online) || a.nickname.localeCompare(b.nickname)); + } + + async getFriendRequests(userId: bigint) { + const requests = await this.store.listFriendRequests(userId); + return Promise.all(requests.map(async (request) => ({ + id: request.id.toString(), + createdAt: request.created_at, + expiresAt: request.expires_at, + requester: await this.buildProfile(userId, request.requester_id, false), + }))); + } + + async createFriendRequest(requesterId: bigint, targetId: bigint) { + this.assertDistinct(requesterId, targetId); + await this.assertNotBlockedEitherWay(requesterId, targetId); + if (await this.areFriends(requesterId, targetId)) throw new BadRequestException('已经是好友'); + await this.assertNearbyAllowed(requesterId, targetId, 'friend'); + if (await this.store.findPendingFriendRequest(requesterId, targetId)) throw new BadRequestException('好友请求已发送'); + + await this.usersService.findOne(targetId); + const request = await this.store.createFriendRequest(requesterId, targetId, new Date(Date.now() + FRIEND_REQUEST_MS)); + const requester = await this.buildProfile(targetId, requesterId, false); + await this.createNotification(targetId, 'friend_request', '新的好友申请', `${requester.nickname} 想与你成为好友`, { requestId: request.id.toString(), userId: requesterId.toString() }); + await this.sendToUser(targetId, { t: 'friend_request_received', request: { id: request.id.toString(), requester } }); + return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at }; + } + + async acceptFriendRequest(userId: bigint, requestId: bigint) { + const request = await this.store.findFriendRequest(requestId); + if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING || request.expires_at <= new Date()) throw new NotFoundException('好友请求不存在或已过期'); + await this.assertNotBlockedEitherWay(userId, request.requester_id); + const [low, high] = this.sortIds(userId, request.requester_id); + await this.store.createFriendship(low, high); + request.status = FriendRequestStatus.ACCEPTED; + request.responded_at = new Date(); + await this.store.saveFriendRequest(request); + await this.store.cancelPendingRequestsBetween(userId, request.requester_id); + const accepter = await this.buildProfile(request.requester_id, userId, false); + await this.createNotification(request.requester_id, 'friend_accepted', '好友申请已接受', `${accepter.nickname} 已成为你的好友`, { userId: userId.toString() }); + await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'accepted', friend: accepter }); + await this.sendToUser(userId, { t: 'friendship_changed', action: 'accepted', friend: await this.buildProfile(userId, request.requester_id, false) }); + return { friend: await this.buildProfile(userId, request.requester_id, false) }; + } + + async rejectFriendRequest(userId: bigint, requestId: bigint) { + const request = await this.store.findFriendRequest(requestId); + if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在'); + request.status = FriendRequestStatus.REJECTED; + request.responded_at = new Date(); + await this.store.saveFriendRequest(request); + const rejecter = await this.buildProfile(request.requester_id, userId, false); + await this.createNotification(request.requester_id, 'friend_rejected', '好友申请未通过', `${rejecter.nickname} 暂未接受你的好友申请`, { userId: userId.toString() }); + await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'rejected', userId: userId.toString() }); + } + + async cancelFriendRequest(userId: bigint, requestId: bigint) { + const request = await this.store.findFriendRequest(requestId); + if (!request || request.requester_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在'); + request.status = FriendRequestStatus.CANCELLED; + request.responded_at = new Date(); + await this.store.saveFriendRequest(request); + } + + async removeFriend(userId: bigint, friendId: bigint) { + const [low, high] = this.sortIds(userId, friendId); + await this.store.deleteFriendship(low, high); + await this.sendToUser(friendId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() }); + } + + async listBlocks(userId: bigint) { + const blocks = await this.store.listBlocks(userId); + return Promise.all(blocks.map(async (block) => ({ createdAt: block.created_at, profile: await this.buildProfile(userId, block.blocked_user_id, false) }))); + } + + async blockUser(userId: bigint, targetId: bigint) { + this.assertDistinct(userId, targetId); + await this.usersService.findOne(targetId); + await this.store.createBlock(userId, targetId); + const [low, high] = this.sortIds(userId, targetId); + await this.store.deleteFriendship(low, high); + await this.store.cancelPendingRequestsBetween(userId, targetId); + await this.sendToUser(targetId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() }); + return { success: true }; + } + + async unblockUser(userId: bigint, targetId: bigint) { + await this.store.deleteBlock(userId, targetId); + return { success: true }; + } + + async createReport(reporterId: bigint, input: { userId: bigint; reason: string; note?: string; messageId?: bigint; blockAlso?: boolean }) { + this.assertDistinct(reporterId, input.userId); + const report = await this.store.createReport({ reporter_id: reporterId, reported_user_id: input.userId, reason: input.reason, note: input.note?.trim() || null, message_id: input.messageId || null }); + if (input.blockAlso) await this.blockUser(reporterId, input.userId); + await this.createNotification(reporterId, 'report_receipt', '举报已提交', '我们已收到你的举报,会尽快处理。', { reportId: report.id.toString() }); + return { id: report.id.toString(), status: report.status }; + } + + async sendDirectMessage(senderId: bigint, targetId: bigint, content: string) { + this.assertDistinct(senderId, targetId); + const normalizedContent = content.trim(); + if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符'); + await this.assertNotBlockedEitherWay(senderId, targetId); + const targetSocket = await this.sessions.getSocketIdByUserId(targetId.toString()); + if (!targetSocket) throw new BadRequestException('对方当前不在线'); + if (!(await this.areFriends(senderId, targetId))) await this.assertNearbyAllowed(senderId, targetId, 'private'); + + const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) }); + const sender = await this.buildProfile(targetId, senderId, false); + const payload = { t: 'dm_message', message: { id: message.id.toString(), senderId: senderId.toString(), recipientId: targetId.toString(), content: message.content, createdAt: message.created_at, sender } }; + await this.sendToUser(senderId, payload); + await this.sendToUser(targetId, payload); + return payload.message; + } + + async listConversation(userId: bigint, otherUserId: bigint, limit: number, before?: Date) { + await this.assertNotBlockedEitherWay(userId, otherUserId); + const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before); + const other = await this.buildProfile(userId, otherUserId, false); + return { other, messages: messages.reverse().map((message) => this.serializeDirectMessage(message)), unreadCount: await this.store.countUnreadDirectMessages(userId) }; + } + + async listConversations(userId: bigint) { + const latest = await this.store.listConversations(userId); + return Promise.all(latest.map(async (message) => { + const otherId = message.sender_id === userId ? message.recipient_id : message.sender_id; + return { other: await this.buildProfile(userId, otherId, false), latestMessage: this.serializeDirectMessage(message) }; + })); + } + + async markConversationRead(userId: bigint, otherUserId: bigint) { + const affected = await this.store.markDirectMessagesRead(userId, otherUserId); + await this.sendToUser(otherUserId, { t: 'dm_read', readerId: userId.toString() }); + return { affected }; + } + + async getNotificationSummary(userId: bigint, limit: number, before?: Date) { + const [notifications, unreadCount, unreadMessages] = await Promise.all([ + this.store.listNotifications(userId, limit, before), + this.store.countUnreadNotifications(userId), + this.store.countUnreadDirectMessages(userId), + ]); + return { notifications: notifications.map((notification) => this.serializeNotification(notification)), unreadCount, unreadMessages }; + } + + async markNotificationRead(userId: bigint, notificationId: bigint) { + const notification = await this.store.markNotificationRead(userId, notificationId); + if (!notification) throw new NotFoundException('通知不存在'); + return this.serializeNotification(notification); + } + + async markAllNotificationsRead(userId: bigint) { return { affected: await this.store.markAllNotificationsRead(userId) }; } + + async getTravelDestinations(userId: bigint) { + const unlocks = await this.store.listUnlocks(userId); + const unlocked = new Set(unlocks.map((unlock) => unlock.destination_id)); + return TRAVEL_DESTINATIONS.map((destination) => ({ ...destination, unlocked: Boolean(('initial' in destination && destination.initial) || unlocked.has(destination.id)) })); + } + + async discoverDestination(userId: bigint, destinationId: string) { + const destination = this.destination(destinationId); + if (!('initial' in destination && destination.initial)) await this.assertAtTravelDestination(userId, destination); + await this.store.createUnlock(userId, destination.id); + return { ...destination, unlocked: true }; + } + + async travelTo(userId: bigint, destinationId: string) { + const destination = this.destination(destinationId); + const unlocked = ('initial' in destination && destination.initial) || (await this.store.listUnlocks(userId)).some((unlock) => unlock.destination_id === destinationId); + if (!unlocked) throw new ForbiddenException('该地点尚未解锁'); + return { ...destination, unlocked: true }; + } + + async canSeeChat(senderId: string, recipientId: string): Promise { + if (!/^\d+$/.test(senderId) || !/^\d+$/.test(recipientId)) return false; + return !(await this.isBlockedEitherWay(BigInt(senderId), BigInt(recipientId))); + } + + async notifyPresenceChanged(userId: string, online: boolean): Promise { + if (!/^\d+$/.test(userId)) return; + const friends = await this.store.listFriendships(BigInt(userId)); + for (const friendship of friends) { + const otherId = friendship.user_low_id === BigInt(userId) ? friendship.user_high_id : friendship.user_low_id; + await this.sendToUser(otherId, { t: 'friend_presence_changed', userId, online }); + } + } + + @Cron(CronExpression.EVERY_HOUR) + async cleanupExpiredData(): Promise { + await this.store.cleanupExpired(new Date()); + } + + private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) { + const [user, profile, socketId] = await Promise.all([ + this.usersService.findOne(targetId), + this.ensureProfile(targetId), + this.sessions.getSocketIdByUserId(targetId.toString()), + ]); + const tags = this.profileTags(profile); + const session = socketId ? await this.sessions.getSession(socketId) : null; + return { + id: targetId.toString(), + username: user.username, + nickname: user.nickname, + avatarUrl: user.avatar_url || '', + skinId: profile.skin_id || '', + online: Boolean(socketId), + currentArea: session?.currentMap || profile.current_map, + bio: String(profile.bio || '').slice(0, 160), + interests: this.validInterests(tags.interests), + privacy: includePrivate ? this.privacy(profile) : undefined, + isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId), + blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId), + }; + } + + private async ensureProfile(userId: bigint): Promise { + const existing = await this.profiles.findByUserId(userId); + if (existing) return existing; + await this.accountProfileService.ensureProfile(userId); + const profile = await this.profiles.findByUserId(userId); + if (!profile) throw new NotFoundException('用户档案不存在'); + return profile; + } + + private profileTags(profile: ProfileRecord): Record { + return profile.tags && typeof profile.tags === 'object' ? { ...profile.tags } : {}; + } + + private privacy(profile: ProfileRecord): Record { + const tags = this.profileTags(profile); + const values = tags[SOCIAL_SETTINGS_KEY]; + return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) }; + } + + private validInterests(value: unknown): string[] { + return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : []; + } + + private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') { + const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString()); + const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString()); + if (!targetSocketId || !sourceSocketId) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动'); + const [target, source, profile] = await Promise.all([this.sessions.getSession(targetSocketId), this.sessions.getSession(sourceSocketId), this.ensureProfile(targetId)]); + if (!target || !source || target.currentMap !== source.currentMap) throw new ForbiddenException('陌生玩家仅可在同一地图互动'); + const distance = Math.hypot(Number(target.position?.x || 0) - Number(source.position?.x || 0), Number(target.position?.y || 0) - Number(source.position?.y || 0)); + if (distance > NEARBY_DISTANCE) throw new ForbiddenException('请靠近该玩家后再互动'); + const privacy = this.privacy(profile); + const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests'; + if (!privacy[key]) throw new ForbiddenException('对方已关闭此类附近互动'); + } + + private async areFriends(userA: bigint, userB: bigint): Promise { + const [low, high] = this.sortIds(userA, userB); + return Boolean(await this.store.findFriendship(low, high)); + } + + private async isBlockedEitherWay(userA: bigint, userB: bigint): Promise { + const [aBlocksB, bBlocksA] = await Promise.all([this.store.isBlocked(userA, userB), this.store.isBlocked(userB, userA)]); + return aBlocksB || bBlocksA; + } + + private async assertNotBlockedEitherWay(userA: bigint, userB: bigint) { + if (await this.isBlockedEitherWay(userA, userB)) throw new ForbiddenException('该互动当前不可用'); + } + + private async createNotification(userId: bigint, category: string, title: string, content: string, actionMetadata: Record) { + const notification = await this.store.createNotification({ user_id: userId, category, title, content, action_metadata: actionMetadata, expires_at: new Date(Date.now() + RETENTION_MS) }); + await this.sendToUser(userId, { t: 'notification_created', notification: this.serializeNotification(notification) }); + return notification; + } + + private async sendToUser(userId: bigint, payload: Record) { + const socketId = await this.sessions.getSocketIdByUserId(userId.toString()); + if (socketId && this.realtimeGateway) this.realtimeGateway.sendToPlayer(socketId, payload); + } + + private serializeDirectMessage(message: any) { + return { id: message.id.toString(), senderId: message.sender_id.toString(), recipientId: message.recipient_id.toString(), content: message.content, createdAt: message.created_at, readAt: message.read_at || null }; + } + private serializeNotification(notification: any) { + return { id: notification.id.toString(), category: notification.category, title: notification.title, content: notification.content, actionMetadata: notification.action_metadata || {}, createdAt: notification.created_at, readAt: notification.read_at || null, expiresAt: notification.expires_at }; + } + private destination(destinationId: string) { + const destination = TRAVEL_DESTINATIONS.find((item) => item.id === destinationId); + if (!destination) throw new NotFoundException('未知地点'); + return destination; + } + private async assertAtTravelDestination(userId: bigint, destination: (typeof TRAVEL_DESTINATIONS)[number]) { + const socketId = await this.sessions.getSocketIdByUserId(userId.toString()); + const session = socketId ? await this.sessions.getSession(socketId) : null; + if (!session || session.currentMap !== destination.mapId) throw new ForbiddenException('需要先在该地点附近探索'); + const origin = TRAVEL_MAP_ORIGINS[destination.mapId] || { x: 0, y: 0 }; + const targetX = destination.x - origin.x; + const targetY = destination.y - origin.y; + const distance = Math.hypot(Number(session.position?.x || 0) - targetX, Number(session.position?.y || 0) - targetY); + if (distance > DISCOVERY_DISTANCE) throw new ForbiddenException('需要靠近地点后才能解锁'); + } + private assertDistinct(userA: bigint, userB: bigint) { if (userA === userB) throw new BadRequestException('不能对自己执行此操作'); } + private sortIds(userA: bigint, userB: bigint): [bigint, bigint] { return userA < userB ? [userA, userB] : [userB, userA]; } +} diff --git a/src/business/social/social.store.ts b/src/business/social/social.store.ts new file mode 100644 index 0000000..60742bd --- /dev/null +++ b/src/business/social/social.store.ts @@ -0,0 +1,42 @@ +import { + DirectMessage, + FriendRequest, + Friendship, + PlayerTravelUnlock, + SocialNotification, + UserBlock, + UserReport, +} from './social.entities'; + +export const SOCIAL_STORE = 'SOCIAL_STORE'; + +export interface SocialStore { + listFriendships(userId: bigint): Promise; + findFriendship(userLowId: bigint, userHighId: bigint): Promise; + createFriendship(userLowId: bigint, userHighId: bigint): Promise; + deleteFriendship(userLowId: bigint, userHighId: bigint): Promise; + findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise; + createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise; + findFriendRequest(id: bigint): Promise; + saveFriendRequest(request: FriendRequest): Promise; + cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise; + listFriendRequests(userId: bigint): Promise; + createBlock(userId: bigint, blockedUserId: bigint): Promise; + deleteBlock(userId: bigint, blockedUserId: bigint): Promise; + isBlocked(userId: bigint, blockedUserId: bigint): Promise; + listBlocks(userId: bigint): Promise; + createDirectMessage(input: Pick): Promise; + listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise; + markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise; + listConversations(userId: bigint): Promise; + countUnreadDirectMessages(userId: bigint): Promise; + createReport(input: Pick): Promise; + createUnlock(userId: bigint, destinationId: string): Promise; + listUnlocks(userId: bigint): Promise; + createNotification(input: Pick): Promise; + listNotifications(userId: bigint, limit: number, before?: Date): Promise; + markNotificationRead(userId: bigint, id: bigint): Promise; + markAllNotificationsRead(userId: bigint): Promise; + countUnreadNotifications(userId: bigint): Promise; + cleanupExpired(now: Date): Promise; +} diff --git a/src/core/db/user_profiles/user_profiles.entity.ts b/src/core/db/user_profiles/user_profiles.entity.ts index eb8225a..2872cd8 100644 --- a/src/core/db/user_profiles/user_profiles.entity.ts +++ b/src/core/db/user_profiles/user_profiles.entity.ts @@ -241,14 +241,14 @@ export class UserProfiles { * * 数据库设计: * - 类型:VARCHAR(50),支持地图名称 - * - 约束:非空、默认值'plaza' + * - 约束:非空、默认值'whale_port' * - 索引:用于地图用户查询 * * 业务规则: * - 用户当前所在的游戏地图 * - 用于位置广播系统的地图过滤 * - 影响用户可见性和交互范围 - * - 默认为广场(plaza),新用户的起始位置 + * - 默认为广场(whale_port),新用户的起始位置 * * 位置广播系统: * - 核心字段,用于确定用户所在区域 @@ -259,7 +259,7 @@ export class UserProfiles { type: 'varchar', length: 50, nullable: false, - default: 'plaza', + default: 'whale_port', comment: '当前所在地图' }) current_map: string; diff --git a/src/core/db/user_profiles/user_profiles.service.ts b/src/core/db/user_profiles/user_profiles.service.ts index 0fdfa14..9ec33da 100644 --- a/src/core/db/user_profiles/user_profiles.service.ts +++ b/src/core/db/user_profiles/user_profiles.service.ts @@ -128,7 +128,7 @@ export class UserProfilesService extends BaseUserProfilesService { userProfile.tags = createUserProfileDto.tags || null; userProfile.social_links = createUserProfileDto.social_links || null; userProfile.skin_id = createUserProfileDto.skin_id || null; - userProfile.current_map = createUserProfileDto.current_map || 'plaza'; + userProfile.current_map = createUserProfileDto.current_map || 'whale_port'; userProfile.pos_x = createUserProfileDto.pos_x || 0; userProfile.pos_y = createUserProfileDto.pos_y || 0; userProfile.status = createUserProfileDto.status || 0; @@ -618,4 +618,4 @@ export class UserProfilesService extends BaseUserProfilesService { }); return count > 0; } -} \ No newline at end of file +} diff --git a/src/core/db/user_profiles/user_profiles_memory.service.ts b/src/core/db/user_profiles/user_profiles_memory.service.ts index 22b379e..8e898f6 100644 --- a/src/core/db/user_profiles/user_profiles_memory.service.ts +++ b/src/core/db/user_profiles/user_profiles_memory.service.ts @@ -150,7 +150,7 @@ export class UserProfilesMemoryService extends BaseUserProfilesService { userProfile.tags = createUserProfileDto.tags || null; userProfile.social_links = createUserProfileDto.social_links || null; userProfile.skin_id = createUserProfileDto.skin_id || null; - userProfile.current_map = createUserProfileDto.current_map || 'plaza'; + userProfile.current_map = createUserProfileDto.current_map || 'whale_port'; userProfile.pos_x = createUserProfileDto.pos_x || 0; userProfile.pos_y = createUserProfileDto.pos_y || 0; userProfile.status = createUserProfileDto.status || 0; @@ -694,4 +694,4 @@ export class UserProfilesMemoryService extends BaseUserProfilesService { currentId: this.CURRENT_ID.toString() }; } -} \ No newline at end of file +} diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts index 423cf62..9c49d85 100644 --- a/src/core/db/users/users.entity.ts +++ b/src/core/db/users/users.entity.ts @@ -318,6 +318,13 @@ export class Users { }) avatar_url: string; + @Column({ + type: 'datetime', + nullable: true, + comment: '社区昵称最近修改时间' + }) + nickname_updated_at?: Date | null; + /** * 用户角色 * @@ -494,4 +501,4 @@ export class Users { */ @OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser) zulipAccount?: ZulipAccounts; -} \ No newline at end of file +} diff --git a/src/gateway/chat/chat.gateway.ts b/src/gateway/chat/chat.gateway.ts index eb43bcf..8c33273 100644 --- a/src/gateway/chat/chat.gateway.ts +++ b/src/gateway/chat/chat.gateway.ts @@ -33,6 +33,7 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import * as WebSocket from 'ws'; import { ChatService } from '../../business/chat/chat.service'; +import { SocialService } from '../../business/social/social.service'; /** WebSocket 服务器默认端口 */ const DEFAULT_WEBSOCKET_PORT = 3001; @@ -102,7 +103,10 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha private mapRooms = new Map>(); private lastWelcomeAtByUserId = new Map(); - constructor(private readonly chatService: ChatService) {} + constructor( + private readonly chatService: ChatService, + private readonly socialService: SocialService, + ) {} async onModuleInit() { const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT; @@ -140,6 +144,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha // 设置网关引用到业务层 this.chatService.setWebSocketGateway(this); + this.socialService.setRealtimeGateway(this); this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`); } @@ -186,6 +191,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha case 'chat': await this.handleChat(ws, message); break; + case 'dm_send': + await this.handleDirectMessage(ws, message); + break; + case 'dm_read': + await this.handleDirectMessageRead(ws, message); + break; case 'position': await this.handlePosition(ws, message); break; @@ -260,6 +271,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha }); this.logger.log(`用户登录成功: ${result.username} (${ws.id})`); + await this.socialService.notifyPresenceChanged(String(result.userId), true); } else { this.sendMessage(ws, { t: 'login_error', @@ -316,6 +328,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } + const scope = String(message.scope || 'local').trim().toLowerCase(); + if (scope === 'private' || scope === 'whisper' || scope === 'dm') { + await this.handleDirectMessage(ws, message); + return; + } + try { const result = await this.chatService.sendChatMessage({ socketId: ws.id, @@ -347,6 +365,43 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha } } + private async handleDirectMessage(ws: ExtendedWebSocket, message: any) { + if (!ws.authenticated || !ws.userId) { + this.sendError(ws, '请先登录'); + return; + } + const targetUserId = String(message.targetUserId || message.target_user_id || message.userId || message.user_id || '').trim(); + const content = String(message.content || message.txt || '').trim(); + if (!/^\d+$/.test(targetUserId) || !content) { + this.sendMessage(ws, { t: 'chat_error', code: 'CHAT_ERROR', message: '私聊目标或内容无效' }); + return; + } + try { + const result = await this.socialService.sendDirectMessage(BigInt(ws.userId), BigInt(targetUserId), content); + this.sendMessage(ws, { t: 'chat_sent', messageId: result.id, message: '消息发送成功' }); + } catch (error) { + this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode((error as Error).message), message: (error as Error).message || '私聊发送失败' }); + } + } + + private async handleDirectMessageRead(ws: ExtendedWebSocket, message: any) { + if (!ws.authenticated || !ws.userId) { + this.sendError(ws, '请先登录'); + return; + } + const targetUserId = String(message.userId || message.user_id || message.targetUserId || '').trim(); + if (!/^\d+$/.test(targetUserId)) { + this.sendError(ws, '私聊对象无效'); + return; + } + try { + const result = await this.socialService.markConversationRead(BigInt(ws.userId), BigInt(targetUserId)); + this.sendMessage(ws, { t: 'dm_read_success', ...result, userId: targetUserId }); + } catch (error) { + this.sendError(ws, (error as Error).message || '标记私聊已读失败'); + } + } + private async handleFriendAdd(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); @@ -359,26 +414,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } - const result = await this.chatService.addFriend({ - socketId: ws.id, - friendUserId, - friendUsername: message.friendUsername || message.friend_username || message.username, - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'friend_added', - friend: result.friend, - }); - await this.sendFriendList(ws); - return; + try { + const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId))); + this.sendMessage(ws, { t: 'friend_request_sent', request }); + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '添加好友失败', - }); } private async handleFriendRequest(ws: ExtendedWebSocket, message: any) { @@ -393,25 +434,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } - const result = await this.chatService.requestFriend({ - socketId: ws.id, - friendUserId, - friendUsername: message.friendUsername || message.friend_username || message.username, - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'friend_request_sent', - request: result.friendRequest, - }); - return; + try { + const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId))); + this.sendMessage(ws, { t: 'friend_request_sent', request }); + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '好友请求发送失败', - }); } private async handleFriendAccept(ws: ExtendedWebSocket, message: any) { @@ -426,26 +454,16 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } - const result = await this.chatService.acceptFriendRequest({ - socketId: ws.id, - friendUserId, - friendUsername: message.friendUsername || message.friend_username || message.username, - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'friend_added', - friend: result.friend, - }); + try { + const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId))); + const request = requests.find((item) => item.requester.id === String(friendUserId)); + if (!request) throw new Error('好友请求不存在或已过期'); + const result = await this.socialService.acceptFriendRequest(BigInt(String(ws.userId)), BigInt(request.id)); + this.sendMessage(ws, { t: 'friend_added', friend: this.legacyFriend(result.friend) }); await this.sendFriendList(ws); - return; + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '接受好友请求失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '接受好友请求失败', - }); } private async handleFriendReject(ws: ExtendedWebSocket, message: any) { @@ -460,26 +478,16 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } - const result = await this.chatService.rejectFriendRequest({ - socketId: ws.id, - friendUserId, - friendUsername: message.friendUsername || message.friend_username || message.username, - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'friend_request_rejected', - userId: friendUserId, - }); + try { + const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId))); + const request = requests.find((item) => item.requester.id === String(friendUserId)); + if (!request) throw new Error('好友请求不存在或已过期'); + await this.socialService.rejectFriendRequest(BigInt(String(ws.userId)), BigInt(request.id)); + this.sendMessage(ws, { t: 'friend_request_rejected', userId: String(friendUserId) }); await this.sendFriendList(ws); - return; + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '拒绝好友请求失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '拒绝好友请求失败', - }); } private async handleFriendRemove(ws: ExtendedWebSocket, message: any) { @@ -494,25 +502,13 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return; } - const result = await this.chatService.removeFriend({ - socketId: ws.id, - friendUserId, - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'friend_removed', - friendUserId, - }); + try { + await this.socialService.removeFriend(BigInt(String(ws.userId)), BigInt(String(friendUserId))); + this.sendMessage(ws, { t: 'friend_removed', friendUserId: String(friendUserId) }); await this.sendFriendList(ws); - return; + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '移除好友失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '移除好友失败', - }); } private async handleFriendList(ws: ExtendedWebSocket) { @@ -525,21 +521,13 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha } private async sendFriendList(ws: ExtendedWebSocket) { - const result = await this.chatService.getFriends(ws.id); - if (result.success) { - this.sendMessage(ws, { - t: 'friend_list', - friends: result.friends || [], - requests: result.requests || [], - }); - return; + try { + const userId = BigInt(String(ws.userId)); + const [friends, requests] = await Promise.all([this.socialService.getFriends(userId), this.socialService.getFriendRequests(userId)]); + this.sendMessage(ws, { t: 'friend_list', friends: friends.map((friend) => this.legacyFriend(friend)), requests: requests.map((request) => ({ userId: request.requester.id, username: request.requester.nickname, createdAt: request.createdAt, requestId: request.id })) }); + } catch (error) { + this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '获取好友列表失败' }); } - - this.sendMessage(ws, { - t: 'friend_error', - code: this.toClientErrorCode(result.error), - message: result.error || '获取好友列表失败', - }); } /** @@ -868,27 +856,29 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha } } - public broadcastToMap(mapId: string, data: any, excludeId?: string): void { + public async broadcastToMap(mapId: string, data: any, excludeId?: string): Promise { const room = this.mapRooms.get(mapId); if (!room) return; - room.forEach(clientId => { + for (const clientId of room) { if (clientId !== excludeId) { const client = this.clients.get(clientId); if (client && client.authenticated && client.readyState === WebSocket.OPEN) { + if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue; this.sendMessage(client, data); } } - }); + } } - public broadcastToAll(data: any, excludeId?: string): void { - this.clients.forEach((client, clientId) => { - if (clientId === excludeId) return; + public async broadcastToAll(data: any, excludeId?: string): Promise { + for (const [clientId, client] of this.clients) { + if (clientId === excludeId) continue; if (client.authenticated && client.readyState === WebSocket.OPEN) { + if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue; this.sendMessage(client, data); } - }); + } } public getConnectionCount(): number { @@ -1005,6 +995,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha } if (ws.authenticated && ws.id) { await this.chatService.handlePlayerLogout(ws.id, reason); + if (ws.userId) await this.socialService.notifyPresenceChanged(String(ws.userId), false); } if (ws.currentMap) { this.leaveMapRoom(ws.id, ws.currentMap); @@ -1044,4 +1035,8 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha private generateClientId(): string { return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } + + private legacyFriend(profile: any) { + return { userId: String(profile.id), username: String(profile.nickname || profile.username || '玩家'), online: Boolean(profile.online) }; + } }