import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { AccountProfileService } from '../auth/account_profile.service'; import { ChatSessionService } from '../chat/services/chat_session.service'; import { FriendRequestStatus } from './social.entities'; import { SOCIAL_STORE, SocialStore } from './social.store'; const NEARBY_DISTANCE = 160; const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const FRIEND_REQUEST_MS = 30 * 24 * 60 * 60 * 1000; const NICKNAME_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; export const INTEREST_TAGS = [ { id: 'ai', label: 'AI/大模型' }, { id: 'programming', label: '编程开发' }, { id: 'data_science', label: '数据科学' }, { id: 'open_source', label: '开源协作' }, { id: 'product', label: '产品' }, { id: 'design', label: '设计' }, { id: 'game_dev', label: '游戏开发' }, { id: 'content_creation', label: '内容创作' }, { id: 'community', label: '社区活动' }, { id: 'learning_partner', label: '学习搭子' }, { id: 'career', label: '职业成长' }, { id: 'casual_chat', label: '轻松闲聊' }, ] as const; const INTEREST_IDS = new Set(INTEREST_TAGS.map((tag) => tag.id)); const SOCIAL_SETTINGS_KEY = 'whaletown_settings'; const DEFAULT_PRIVACY = { allow_nearby_profile: true, allow_nearby_private: true, allow_nearby_friend_requests: true, }; const TRAVEL_MAP_ORIGINS: Record = { whale_port: { x: 1280, y: 960 }, work_zone: { x: 1280, y: 960 }, whale_cafe: { x: 768, y: 512 }, personal_space: { x: 768, y: 512 }, }; const DISCOVERY_DISTANCE = 180; export const TRAVEL_DESTINATIONS = [ { id: 'square_center', mapId: 'whale_port', label: '广场中心', x: 1280, y: 990, initial: true }, { id: 'square_dock', mapId: 'whale_port', label: '码头', x: 410, y: 758 }, { id: 'square_headquarters', mapId: 'whale_port', label: '总部', x: 1293, y: 360 }, { id: 'square_cottage', mapId: 'whale_port', label: '小屋', x: 2125, y: 768 }, { id: 'square_workshop', mapId: 'whale_port', label: '工坊', x: 1925, y: 1460 }, { id: 'square_notice', mapId: 'whale_port', label: '公告栏', x: 738, y: 1608 }, { id: 'square_work_zone_gate', mapId: 'whale_port', label: '打工区入口', x: 1280, y: 1735 }, { id: 'work_entrance', mapId: 'work_zone', label: '打工区入口', x: 1280, y: 1715 }, { id: 'work_mall', mapId: 'work_zone', label: '商城', x: 1280, y: 346 }, { id: 'work_cafe_gate', mapId: 'work_zone', label: '咖啡馆入口', x: 236, y: 1182 }, { id: 'work_jobs', mapId: 'work_zone', label: '任务中心', x: 778, y: 1152 }, { id: 'work_courses', mapId: 'work_zone', label: '课程看板', x: 1776, y: 960 }, { id: 'work_ai', mapId: 'work_zone', label: 'AI 站', x: 1732, y: 1508 }, { id: 'work_exchange', mapId: 'work_zone', label: '鲸币兑换处', x: 2355, y: 1508 }, { id: 'cafe_entrance', mapId: 'whale_cafe', label: '咖啡馆入口', x: 768, y: 875 }, { id: 'cafe_counter', mapId: 'whale_cafe', label: '服务台', x: 768, y: 475 }, { id: 'cafe_companion', mapId: 'whale_cafe', label: '陪伴区', x: 370, y: 286 }, { id: 'personal_room', mapId: 'personal_space', label: '我的房间', x: 768, y: 512, initial: true }, ] as const; type RealtimeGateway = { sendToPlayer(socketId: string, payload: Record): void; }; interface UserRecord { id: bigint; username: string; nickname: string; avatar_url?: string | null; nickname_updated_at?: Date | null; } interface UserService { findOne(id: bigint): Promise; update(id: bigint, payload: Record): Promise; } interface ProfileRecord { id: bigint; user_id: bigint; bio?: string | null; tags?: Record | null; skin_id?: string | null; current_map: string; pos_x: number; pos_y: number; } interface ProfileService { findByUserId(userId: bigint): Promise; update(id: bigint, payload: Record): Promise; } @Injectable() export class SocialService { private readonly logger = new Logger(SocialService.name); private realtimeGateway?: RealtimeGateway; constructor( @Inject(SOCIAL_STORE) private readonly store: SocialStore, @Inject('UsersService') private readonly usersService: UserService, @Inject('IUserProfilesService') private readonly profiles: ProfileService, private readonly accountProfileService: AccountProfileService, private readonly sessions: ChatSessionService, ) {} setRealtimeGateway(gateway: RealtimeGateway): void { this.realtimeGateway = gateway; } getInterestTags() { return INTEREST_TAGS; } async getOwnSocialProfile(userId: bigint) { await this.ensureProfile(userId); return this.buildProfile(userId, userId, true); } async updateSocialProfile(userId: bigint, update: { nickname?: string; bio?: string; interests?: string[] }) { const user = await this.usersService.findOne(userId); const profile = await this.ensureProfile(userId); if (update.nickname !== undefined) { const nickname = update.nickname.trim(); if (!nickname) throw new BadRequestException('昵称不能为空'); if (nickname !== user.nickname) { const lastUpdatedAt = user.nickname_updated_at ? new Date(user.nickname_updated_at).getTime() : 0; const remaining = NICKNAME_COOLDOWN_MS - (Date.now() - lastUpdatedAt); if (lastUpdatedAt && remaining > 0) { throw new ForbiddenException(`昵称每 7 天只能修改一次,还需等待 ${Math.ceil(remaining / 86400000)} 天`); } await this.usersService.update(userId, { nickname, nickname_updated_at: new Date() }); } } const tags = this.profileTags(profile); if (update.interests !== undefined) { const interests = [...new Set(update.interests.map((value) => value.trim()))]; if (interests.length > 3 || interests.some((value) => !INTEREST_IDS.has(value))) { throw new BadRequestException('兴趣标签不在允许的目录内'); } tags.interests = interests; } await this.profiles.update(profile.id, { bio: update.bio !== undefined ? update.bio.trim() : profile.bio || '', tags }); return this.getOwnSocialProfile(userId); } async getPublicProfile(viewerId: bigint, targetId: bigint) { const self = viewerId === targetId; if (!self && !(await this.areFriends(viewerId, targetId))) { await this.assertNearbyAllowed(viewerId, targetId, 'profile'); } return this.buildProfile(viewerId, targetId, self); } async getFriends(userId: bigint) { const friendships = await this.store.listFriendships(userId); const result = []; for (const friendship of friendships) { const friendId = friendship.user_low_id === userId ? friendship.user_high_id : friendship.user_low_id; result.push(await this.buildProfile(userId, friendId, false)); } return result.sort((a, b) => Number(b.online) - Number(a.online) || a.nickname.localeCompare(b.nickname)); } async getFriendRequests(userId: bigint) { const requests = await this.store.listFriendRequests(userId); return Promise.all(requests.map(async (request) => ({ id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at, requester: await this.buildProfile(userId, request.requester_id, false), }))); } async createFriendRequest(requesterId: bigint, targetId: bigint) { this.assertDistinct(requesterId, targetId); await this.assertNotBlockedEitherWay(requesterId, targetId); if (await this.areFriends(requesterId, targetId)) throw new BadRequestException('已经是好友'); await this.assertNearbyAllowed(requesterId, targetId, 'friend'); if (await this.store.findPendingFriendRequest(requesterId, targetId)) throw new BadRequestException('好友请求已发送'); await this.usersService.findOne(targetId); const request = await this.store.createFriendRequest(requesterId, targetId, new Date(Date.now() + FRIEND_REQUEST_MS)); const requester = await this.buildProfile(targetId, requesterId, false); await this.createNotification(targetId, 'friend_request', '新的好友申请', `${requester.nickname} 想与你成为好友`, { requestId: request.id.toString(), userId: requesterId.toString() }); await this.sendToUser(targetId, { t: 'friend_request_received', request: { id: request.id.toString(), requester } }); return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at }; } async acceptFriendRequest(userId: bigint, requestId: bigint) { const request = await this.store.findFriendRequest(requestId); if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING || request.expires_at <= new Date()) throw new NotFoundException('好友请求不存在或已过期'); await this.assertNotBlockedEitherWay(userId, request.requester_id); const [low, high] = this.sortIds(userId, request.requester_id); await this.store.createFriendship(low, high); request.status = FriendRequestStatus.ACCEPTED; request.responded_at = new Date(); await this.store.saveFriendRequest(request); await this.store.cancelPendingRequestsBetween(userId, request.requester_id); const accepter = await this.buildProfile(request.requester_id, userId, false); await this.createNotification(request.requester_id, 'friend_accepted', '好友申请已接受', `${accepter.nickname} 已成为你的好友`, { userId: userId.toString() }); await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'accepted', friend: accepter }); await this.sendToUser(userId, { t: 'friendship_changed', action: 'accepted', friend: await this.buildProfile(userId, request.requester_id, false) }); return { friend: await this.buildProfile(userId, request.requester_id, false) }; } async rejectFriendRequest(userId: bigint, requestId: bigint) { const request = await this.store.findFriendRequest(requestId); if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在'); request.status = FriendRequestStatus.REJECTED; request.responded_at = new Date(); await this.store.saveFriendRequest(request); const rejecter = await this.buildProfile(request.requester_id, userId, false); await this.createNotification(request.requester_id, 'friend_rejected', '好友申请未通过', `${rejecter.nickname} 暂未接受你的好友申请`, { userId: userId.toString() }); await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'rejected', userId: userId.toString() }); } async cancelFriendRequest(userId: bigint, requestId: bigint) { const request = await this.store.findFriendRequest(requestId); if (!request || request.requester_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在'); request.status = FriendRequestStatus.CANCELLED; request.responded_at = new Date(); await this.store.saveFriendRequest(request); } async removeFriend(userId: bigint, friendId: bigint) { const [low, high] = this.sortIds(userId, friendId); await this.store.deleteFriendship(low, high); await this.sendToUser(friendId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() }); } async listBlocks(userId: bigint) { const blocks = await this.store.listBlocks(userId); return Promise.all(blocks.map(async (block) => ({ createdAt: block.created_at, profile: await this.buildProfile(userId, block.blocked_user_id, false) }))); } async blockUser(userId: bigint, targetId: bigint) { this.assertDistinct(userId, targetId); await this.usersService.findOne(targetId); await this.store.createBlock(userId, targetId); const [low, high] = this.sortIds(userId, targetId); await this.store.deleteFriendship(low, high); await this.store.cancelPendingRequestsBetween(userId, targetId); await this.sendToUser(targetId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() }); return { success: true }; } async unblockUser(userId: bigint, targetId: bigint) { await this.store.deleteBlock(userId, targetId); return { success: true }; } async createReport(reporterId: bigint, input: { userId: bigint; reason: string; note?: string; messageId?: bigint; blockAlso?: boolean }) { this.assertDistinct(reporterId, input.userId); const report = await this.store.createReport({ reporter_id: reporterId, reported_user_id: input.userId, reason: input.reason, note: input.note?.trim() || null, message_id: input.messageId || null }); if (input.blockAlso) await this.blockUser(reporterId, input.userId); await this.createNotification(reporterId, 'report_receipt', '举报已提交', '我们已收到你的举报,会尽快处理。', { reportId: report.id.toString() }); return { id: report.id.toString(), status: report.status }; } async sendDirectMessage(senderId: bigint, targetId: bigint, content: string) { this.assertDistinct(senderId, targetId); const normalizedContent = content.trim(); if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符'); await this.assertNotBlockedEitherWay(senderId, targetId); const targetSocket = await this.sessions.getSocketIdByUserId(targetId.toString()); if (!targetSocket) throw new BadRequestException('对方当前不在线'); if (!(await this.areFriends(senderId, targetId))) await this.assertNearbyAllowed(senderId, targetId, 'private'); const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) }); const sender = await this.buildProfile(targetId, senderId, false); const payload = { t: 'dm_message', message: { id: message.id.toString(), senderId: senderId.toString(), recipientId: targetId.toString(), content: message.content, createdAt: message.created_at, sender } }; await this.sendToUser(senderId, payload); await this.sendToUser(targetId, payload); return payload.message; } async listConversation(userId: bigint, otherUserId: bigint, limit: number, before?: Date) { await this.assertNotBlockedEitherWay(userId, otherUserId); const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before); const other = await this.buildProfile(userId, otherUserId, false); return { other, messages: messages.reverse().map((message) => this.serializeDirectMessage(message)), unreadCount: await this.store.countUnreadDirectMessages(userId) }; } async listConversations(userId: bigint) { const latest = await this.store.listConversations(userId); return Promise.all(latest.map(async (message) => { const otherId = message.sender_id === userId ? message.recipient_id : message.sender_id; return { other: await this.buildProfile(userId, otherId, false), latestMessage: this.serializeDirectMessage(message) }; })); } async markConversationRead(userId: bigint, otherUserId: bigint) { const affected = await this.store.markDirectMessagesRead(userId, otherUserId); await this.sendToUser(otherUserId, { t: 'dm_read', readerId: userId.toString() }); return { affected }; } async getNotificationSummary(userId: bigint, limit: number, before?: Date) { const [notifications, unreadCount, unreadMessages] = await Promise.all([ this.store.listNotifications(userId, limit, before), this.store.countUnreadNotifications(userId), this.store.countUnreadDirectMessages(userId), ]); return { notifications: notifications.map((notification) => this.serializeNotification(notification)), unreadCount, unreadMessages }; } async markNotificationRead(userId: bigint, notificationId: bigint) { const notification = await this.store.markNotificationRead(userId, notificationId); if (!notification) throw new NotFoundException('通知不存在'); return this.serializeNotification(notification); } async markAllNotificationsRead(userId: bigint) { return { affected: await this.store.markAllNotificationsRead(userId) }; } async getTravelDestinations(userId: bigint) { const unlocks = await this.store.listUnlocks(userId); const unlocked = new Set(unlocks.map((unlock) => unlock.destination_id)); return TRAVEL_DESTINATIONS.map((destination) => ({ ...destination, unlocked: Boolean(('initial' in destination && destination.initial) || unlocked.has(destination.id)) })); } async discoverDestination(userId: bigint, destinationId: string) { const destination = this.destination(destinationId); if (!('initial' in destination && destination.initial)) await this.assertAtTravelDestination(userId, destination); await this.store.createUnlock(userId, destination.id); return { ...destination, unlocked: true }; } async travelTo(userId: bigint, destinationId: string) { const destination = this.destination(destinationId); const unlocked = ('initial' in destination && destination.initial) || (await this.store.listUnlocks(userId)).some((unlock) => unlock.destination_id === destinationId); if (!unlocked) throw new ForbiddenException('该地点尚未解锁'); return { ...destination, unlocked: true }; } async canSeeChat(senderId: string, recipientId: string): Promise { if (!/^\d+$/.test(senderId) || !/^\d+$/.test(recipientId)) return false; return !(await this.isBlockedEitherWay(BigInt(senderId), BigInt(recipientId))); } async notifyPresenceChanged(userId: string, online: boolean): Promise { if (!/^\d+$/.test(userId)) return; const friends = await this.store.listFriendships(BigInt(userId)); for (const friendship of friends) { const otherId = friendship.user_low_id === BigInt(userId) ? friendship.user_high_id : friendship.user_low_id; await this.sendToUser(otherId, { t: 'friend_presence_changed', userId, online }); } } @Cron(CronExpression.EVERY_HOUR) async cleanupExpiredData(): Promise { await this.store.cleanupExpired(new Date()); } private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) { const [user, profile, socketId] = await Promise.all([ this.usersService.findOne(targetId), this.ensureProfile(targetId), this.sessions.getSocketIdByUserId(targetId.toString()), ]); const tags = this.profileTags(profile); const session = socketId ? await this.sessions.getSession(socketId) : null; return { id: targetId.toString(), username: user.username, nickname: user.nickname, avatarUrl: user.avatar_url || '', skinId: profile.skin_id || '', online: Boolean(socketId), currentArea: session?.currentMap || profile.current_map, bio: String(profile.bio || '').slice(0, 160), interests: this.validInterests(tags.interests), privacy: includePrivate ? this.privacy(profile) : undefined, isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId), blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId), }; } private async ensureProfile(userId: bigint): Promise { const existing = await this.profiles.findByUserId(userId); if (existing) return existing; await this.accountProfileService.ensureProfile(userId); const profile = await this.profiles.findByUserId(userId); if (!profile) throw new NotFoundException('用户档案不存在'); return profile; } private profileTags(profile: ProfileRecord): Record { return profile.tags && typeof profile.tags === 'object' ? { ...profile.tags } : {}; } private privacy(profile: ProfileRecord): Record { const tags = this.profileTags(profile); const values = tags[SOCIAL_SETTINGS_KEY]; return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) }; } private validInterests(value: unknown): string[] { return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : []; } private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') { const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString()); const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString()); if (!targetSocketId || !sourceSocketId) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动'); const [target, source, profile] = await Promise.all([this.sessions.getSession(targetSocketId), this.sessions.getSession(sourceSocketId), this.ensureProfile(targetId)]); if (!target || !source || target.currentMap !== source.currentMap) throw new ForbiddenException('陌生玩家仅可在同一地图互动'); const distance = Math.hypot(Number(target.position?.x || 0) - Number(source.position?.x || 0), Number(target.position?.y || 0) - Number(source.position?.y || 0)); if (distance > NEARBY_DISTANCE) throw new ForbiddenException('请靠近该玩家后再互动'); const privacy = this.privacy(profile); const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests'; if (!privacy[key]) throw new ForbiddenException('对方已关闭此类附近互动'); } private async areFriends(userA: bigint, userB: bigint): Promise { const [low, high] = this.sortIds(userA, userB); return Boolean(await this.store.findFriendship(low, high)); } private async isBlockedEitherWay(userA: bigint, userB: bigint): Promise { const [aBlocksB, bBlocksA] = await Promise.all([this.store.isBlocked(userA, userB), this.store.isBlocked(userB, userA)]); return aBlocksB || bBlocksA; } private async assertNotBlockedEitherWay(userA: bigint, userB: bigint) { if (await this.isBlockedEitherWay(userA, userB)) throw new ForbiddenException('该互动当前不可用'); } private async createNotification(userId: bigint, category: string, title: string, content: string, actionMetadata: Record) { const notification = await this.store.createNotification({ user_id: userId, category, title, content, action_metadata: actionMetadata, expires_at: new Date(Date.now() + RETENTION_MS) }); await this.sendToUser(userId, { t: 'notification_created', notification: this.serializeNotification(notification) }); return notification; } private async sendToUser(userId: bigint, payload: Record) { const socketId = await this.sessions.getSocketIdByUserId(userId.toString()); if (socketId && this.realtimeGateway) this.realtimeGateway.sendToPlayer(socketId, payload); } private serializeDirectMessage(message: any) { return { id: message.id.toString(), senderId: message.sender_id.toString(), recipientId: message.recipient_id.toString(), content: message.content, createdAt: message.created_at, readAt: message.read_at || null }; } private serializeNotification(notification: any) { return { id: notification.id.toString(), category: notification.category, title: notification.title, content: notification.content, actionMetadata: notification.action_metadata || {}, createdAt: notification.created_at, readAt: notification.read_at || null, expiresAt: notification.expires_at }; } private destination(destinationId: string) { const destination = TRAVEL_DESTINATIONS.find((item) => item.id === destinationId); if (!destination) throw new NotFoundException('未知地点'); return destination; } private async assertAtTravelDestination(userId: bigint, destination: (typeof TRAVEL_DESTINATIONS)[number]) { const socketId = await this.sessions.getSocketIdByUserId(userId.toString()); const session = socketId ? await this.sessions.getSession(socketId) : null; if (!session || session.currentMap !== destination.mapId) throw new ForbiddenException('需要先在该地点附近探索'); const origin = TRAVEL_MAP_ORIGINS[destination.mapId] || { x: 0, y: 0 }; const targetX = destination.x - origin.x; const targetY = destination.y - origin.y; const distance = Math.hypot(Number(session.position?.x || 0) - targetX, Number(session.position?.y || 0) - targetY); if (distance > DISCOVERY_DISTANCE) throw new ForbiddenException('需要靠近地点后才能解锁'); } private assertDistinct(userA: bigint, userB: bigint) { if (userA === userB) throw new BadRequestException('不能对自己执行此操作'); } private sortIds(userA: bigint, userB: bigint): [bigint, bigint] { return userA < userB ? [userA, userB] : [userB, userA]; } }