forked from xiangwang25/whale-town-end-v2
474 lines
25 KiB
TypeScript
474 lines
25 KiB
TypeScript
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]; }
|
|
}
|