forked from xiangwang25/whale-town-end-v2
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9119737f11 | ||
|
|
b5365751bb |
47
DEPLOYMENT.md
Normal file
47
DEPLOYMENT.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# WhaleTown V2 后端部署
|
||||
|
||||
## 1. 生产配置
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
openssl rand -hex 32 # 分别用于 JWT_SECRET、ADMIN_TOKEN_SECRET 和 ZULIP_API_KEY_ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
必须填写 MySQL、Redis 和三个随机密钥。REST API 使用 `3000` 端口,原生 WebSocket 使用 `3001` 端口。不要把两个端口配成相同值。
|
||||
|
||||
## 2. 数据库
|
||||
|
||||
部署前先备份现有数据库。v2 不会自动修改表结构(`synchronize: false`)。根据目标库的现有结构审核并执行仓库内的增量 SQL:
|
||||
|
||||
```bash
|
||||
mysql -u <user> -p <database> < src/core/db/player_assets/create-player-assets-tables.sql
|
||||
mysql -u <user> -p <database> < src/core/db/user_wallets/create-user-wallets-tables.sql
|
||||
mysql -u <user> -p <database> < src/business/notice/migrations/create-notices-table.sql
|
||||
```
|
||||
|
||||
## 3. 构建与启动
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
VITE_API_BASE_URL=https://whaletownend.xinghangee.icu pnpm --filter whale-town-admin run build
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 4. 反向代理
|
||||
|
||||
`deploy/nginx/whaletownend-v2.conf.example` 将 REST 转发到 `3000`,将 `/game` WebSocket 转发到 `3001`。管理端可使用 `deploy/nginx/whaletown-admin-v2.conf.example` 作为独立静态站点。
|
||||
|
||||
启用 HTTPS 后验证:
|
||||
|
||||
```bash
|
||||
curl https://whaletownend.xinghangee.icu/
|
||||
curl https://whaletownend.xinghangee.icu/api-docs
|
||||
curl --http1.1 -i \
|
||||
-H 'Connection: Upgrade' \
|
||||
-H 'Upgrade: websocket' \
|
||||
-H 'Sec-WebSocket-Version: 13' \
|
||||
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||
https://whaletownend.xinghangee.icu/game
|
||||
```
|
||||
@@ -29,7 +29,7 @@ pnpm run start:prod
|
||||
|
||||
启动前至少需要在 `.env` 中设置随机的 `JWT_SECRET`、`ADMIN_TOKEN_SECRET` 和 `ZULIP_API_KEY_ENCRYPTION_KEY`。生产环境请从 `.env.production.example` 开始配置,不要直接使用示例值。
|
||||
|
||||
API 默认监听 `3000` 端口,Swagger 地址为 `/api-docs`。
|
||||
REST API 默认监听 `3000` 端口,原生 WebSocket 默认监听 `3001` 端口并使用 `/game` 路径,Swagger 地址为 `/api-docs`。生产环境的反向代理需要分别转发这两个端口。
|
||||
|
||||
## 管理端
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
const data = await this.socialService.updateSocialProfile(BigInt(user.sub), dto);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '社区名片已更新' });
|
||||
}
|
||||
}
|
||||
|
||||
65
src/business/social/dto/social.dto.ts
Normal file
65
src/business/social/dto/social.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
92
src/business/social/migrations/create-social-tables.sql
Normal file
92
src/business/social/migrations/create-social-tables.sql
Normal file
@@ -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;
|
||||
100
src/business/social/social.controller.ts
Normal file
100
src/business/social/social.controller.ts
Normal file
@@ -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) }; }
|
||||
}
|
||||
62
src/business/social/social.database-store.ts
Normal file
62
src/business/social/social.database-store.ts
Normal file
@@ -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<Friendship>,
|
||||
@InjectRepository(FriendRequest) private readonly requests: Repository<FriendRequest>,
|
||||
@InjectRepository(UserBlock) private readonly blocks: Repository<UserBlock>,
|
||||
@InjectRepository(DirectMessage) private readonly messages: Repository<DirectMessage>,
|
||||
@InjectRepository(UserReport) private readonly reports: Repository<UserReport>,
|
||||
@InjectRepository(PlayerTravelUnlock) private readonly unlocks: Repository<PlayerTravelUnlock>,
|
||||
@InjectRepository(SocialNotification) private readonly notifications: Repository<SocialNotification>,
|
||||
) {}
|
||||
|
||||
async listFriendships(userId: bigint): Promise<Friendship[]> {
|
||||
return this.friendships.find({ where: [{ user_low_id: userId }, { user_high_id: userId }], order: { created_at: 'DESC' } });
|
||||
}
|
||||
async findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null> { return this.friendships.findOne({ where: { user_low_id: userLowId, user_high_id: userHighId } }); }
|
||||
async createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship> {
|
||||
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<void> { await this.friendships.delete({ user_low_id: userLowId, user_high_id: userHighId }); }
|
||||
async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null> { 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<FriendRequest> { return this.requests.save(this.requests.create({ requester_id: requesterId, recipient_id: recipientId, expires_at: expiresAt, status: FriendRequestStatus.PENDING })); }
|
||||
async findFriendRequest(id: bigint): Promise<FriendRequest | null> { return this.requests.findOne({ where: { id } }); }
|
||||
async saveFriendRequest(request: FriendRequest): Promise<FriendRequest> { return this.requests.save(request); }
|
||||
async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void> {
|
||||
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<FriendRequest[]> { 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<UserBlock> { 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<void> { await this.blocks.delete({ user_id: userId, blocked_user_id: blockedUserId }); }
|
||||
async isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean> { return (await this.blocks.count({ where: { user_id: userId, blocked_user_id: blockedUserId } })) > 0; }
|
||||
async listBlocks(userId: bigint): Promise<UserBlock[]> { return this.blocks.find({ where: { user_id: userId }, order: { created_at: 'DESC' } }); }
|
||||
async createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage> { return this.messages.save(this.messages.create(input)); }
|
||||
async listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise<DirectMessage[]> { 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<number> { 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<DirectMessage[]> { 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<string>(); 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<number> { return this.messages.count({ where: { recipient_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
||||
async createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport> { return this.reports.save(this.reports.create(input)); }
|
||||
async createUnlock(userId: bigint, destinationId: string): Promise<PlayerTravelUnlock> { 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<PlayerTravelUnlock[]> { return this.unlocks.find({ where: { user_id: userId }, order: { discovered_at: 'ASC' } }); }
|
||||
async createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification> { return this.notifications.save(this.notifications.create(input)); }
|
||||
async listNotifications(userId: bigint, limit: number, before?: Date): Promise<SocialNotification[]> { 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<SocialNotification | null> { 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<number> { 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<number> { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
||||
async cleanupExpired(now: Date): Promise<void> { 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) })]); }
|
||||
}
|
||||
176
src/business/social/social.entities.ts
Normal file
176
src/business/social/social.entities.ts
Normal file
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
78
src/business/social/social.memory-store.ts
Normal file
78
src/business/social/social.memory-store.ts
Normal file
@@ -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<Friendship[]> {
|
||||
return this.friendships.filter((item) => item.user_low_id === userId || item.user_high_id === userId);
|
||||
}
|
||||
async findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null> {
|
||||
return this.friendships.find((item) => item.user_low_id === userLowId && item.user_high_id === userHighId) || null;
|
||||
}
|
||||
async createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship> {
|
||||
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<void> {
|
||||
this.friendships = this.friendships.filter((item) => item.user_low_id !== userLowId || item.user_high_id !== userHighId);
|
||||
}
|
||||
async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null> {
|
||||
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<FriendRequest> {
|
||||
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<FriendRequest | null> { return this.requests.find((item) => item.id === id) || null; }
|
||||
async saveFriendRequest(request: FriendRequest): Promise<FriendRequest> { return request; }
|
||||
async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void> {
|
||||
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<FriendRequest[]> { 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<UserBlock> {
|
||||
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<void> { this.blocks = this.blocks.filter((item) => item.user_id !== userId || item.blocked_user_id !== blockedUserId); }
|
||||
async isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean> { return this.blocks.some((item) => item.user_id === userId && item.blocked_user_id === blockedUserId); }
|
||||
async listBlocks(userId: bigint): Promise<UserBlock[]> { return this.blocks.filter((item) => item.user_id === userId); }
|
||||
async createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage> {
|
||||
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<DirectMessage[]> { 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<number> { 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<DirectMessage[]> { const latest = new Map<string, DirectMessage>(); 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<number> { return this.messages.filter((item) => item.recipient_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
||||
async createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport> { 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<PlayerTravelUnlock> { 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<PlayerTravelUnlock[]> { return this.unlocks.filter((item) => item.user_id === userId); }
|
||||
async createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification> { 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<SocialNotification[]> { 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<SocialNotification | null> { 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<number> { 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<number> { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
||||
async cleanupExpired(now: Date): Promise<void> { 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); }
|
||||
}
|
||||
34
src/business/social/social.module.ts
Normal file
34
src/business/social/social.module.ts
Normal file
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
473
src/business/social/social.service.ts
Normal file
473
src/business/social/social.service.ts
Normal file
@@ -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<string>(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<string, { x: number; y: number }> = {
|
||||
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<string, unknown>): void;
|
||||
};
|
||||
|
||||
interface UserRecord {
|
||||
id: bigint;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar_url?: string | null;
|
||||
nickname_updated_at?: Date | null;
|
||||
}
|
||||
|
||||
interface UserService {
|
||||
findOne(id: bigint): Promise<UserRecord>;
|
||||
update(id: bigint, payload: Record<string, unknown>): Promise<UserRecord>;
|
||||
}
|
||||
|
||||
interface ProfileRecord {
|
||||
id: bigint;
|
||||
user_id: bigint;
|
||||
bio?: string | null;
|
||||
tags?: Record<string, unknown> | null;
|
||||
skin_id?: string | null;
|
||||
current_map: string;
|
||||
pos_x: number;
|
||||
pos_y: number;
|
||||
}
|
||||
|
||||
interface ProfileService {
|
||||
findByUserId(userId: bigint): Promise<ProfileRecord | null>;
|
||||
update(id: bigint, payload: Record<string, unknown>): Promise<ProfileRecord>;
|
||||
}
|
||||
|
||||
@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<boolean> {
|
||||
if (!/^\d+$/.test(senderId) || !/^\d+$/.test(recipientId)) return false;
|
||||
return !(await this.isBlockedEitherWay(BigInt(senderId), BigInt(recipientId)));
|
||||
}
|
||||
|
||||
async notifyPresenceChanged(userId: string, online: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<ProfileRecord> {
|
||||
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<string, any> {
|
||||
return profile.tags && typeof profile.tags === 'object' ? { ...profile.tags } : {};
|
||||
}
|
||||
|
||||
private privacy(profile: ProfileRecord): Record<string, boolean> {
|
||||
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<boolean> {
|
||||
const [low, high] = this.sortIds(userA, userB);
|
||||
return Boolean(await this.store.findFriendship(low, high));
|
||||
}
|
||||
|
||||
private async isBlockedEitherWay(userA: bigint, userB: bigint): Promise<boolean> {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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]; }
|
||||
}
|
||||
42
src/business/social/social.store.ts
Normal file
42
src/business/social/social.store.ts
Normal file
@@ -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<Friendship[]>;
|
||||
findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null>;
|
||||
createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship>;
|
||||
deleteFriendship(userLowId: bigint, userHighId: bigint): Promise<void>;
|
||||
findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null>;
|
||||
createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise<FriendRequest>;
|
||||
findFriendRequest(id: bigint): Promise<FriendRequest | null>;
|
||||
saveFriendRequest(request: FriendRequest): Promise<FriendRequest>;
|
||||
cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void>;
|
||||
listFriendRequests(userId: bigint): Promise<FriendRequest[]>;
|
||||
createBlock(userId: bigint, blockedUserId: bigint): Promise<UserBlock>;
|
||||
deleteBlock(userId: bigint, blockedUserId: bigint): Promise<void>;
|
||||
isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean>;
|
||||
listBlocks(userId: bigint): Promise<UserBlock[]>;
|
||||
createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage>;
|
||||
listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise<DirectMessage[]>;
|
||||
markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise<number>;
|
||||
listConversations(userId: bigint): Promise<DirectMessage[]>;
|
||||
countUnreadDirectMessages(userId: bigint): Promise<number>;
|
||||
createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport>;
|
||||
createUnlock(userId: bigint, destinationId: string): Promise<PlayerTravelUnlock>;
|
||||
listUnlocks(userId: bigint): Promise<PlayerTravelUnlock[]>;
|
||||
createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification>;
|
||||
listNotifications(userId: bigint, limit: number, before?: Date): Promise<SocialNotification[]>;
|
||||
markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null>;
|
||||
markAllNotificationsRead(userId: bigint): Promise<number>;
|
||||
countUnreadNotifications(userId: bigint): Promise<number>;
|
||||
cleanupExpired(now: Date): Promise<void>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Set<string>>();
|
||||
private lastWelcomeAtByUserId = new Map<string, number>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user