interaction system

This commit is contained in:
2026-01-13 12:55:17 +08:00
parent 7e7e21c0e6
commit bd7fce4d98
5 changed files with 207 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export class InteractionPayloadDto {
senderUserId: string;
senderName: string;
content: string;
type: string;
timestamp: string;
}

View File

@@ -0,0 +1,16 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class SendInteractionDto {
@IsUUID()
@IsNotEmpty()
recipientUserId: string;
@IsString()
@IsNotEmpty()
@MaxLength(500)
content: string;
@IsString()
@IsNotEmpty()
type: string;
}

View File

@@ -7,6 +7,8 @@ import { JwtVerificationService } from '../../auth/services/jwt-verification.ser
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from '../../database/prisma.service';
import { UserSocketService } from './user-socket.service'; import { UserSocketService } from './user-socket.service';
import { WsNotificationService } from './ws-notification.service'; import { WsNotificationService } from './ws-notification.service';
import { SendInteractionDto } from '../dto/send-interaction.dto';
import { WsException } from '@nestjs/websockets';
interface MockSocket extends Partial<AuthenticatedSocket> { interface MockSocket extends Partial<AuthenticatedSocket> {
id: string; id: string;
@@ -472,4 +474,130 @@ describe('StateGateway', () => {
).rejects.toThrow('Unauthorized'); ).rejects.toThrow('Unauthorized');
}); });
}); });
describe('handleSendInteraction', () => {
it('should send interaction to friend if online', async () => {
const mockClient: MockSocket = {
id: 'client1',
data: {
user: { keycloakSub: 'test-sub', name: 'TestUser' },
userId: 'user-1',
friends: new Set(['friend-1']),
},
emit: jest.fn(),
};
const data: SendInteractionDto = {
recipientUserId: 'friend-1',
content: 'hello',
type: 'text',
};
(mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue(true);
await gateway.handleSendInteraction(
mockClient as unknown as AuthenticatedSocket,
data,
);
expect(mockWsNotificationService.emitToUser).toHaveBeenCalledWith(
'friend-1',
'interaction-received',
expect.objectContaining({
senderUserId: 'user-1',
senderName: 'TestUser',
content: 'hello',
type: 'text',
}),
);
});
it('should fail if recipient is not a friend', async () => {
const mockClient: MockSocket = {
id: 'client1',
data: {
user: { keycloakSub: 'test-sub' },
userId: 'user-1',
friends: new Set(['friend-1']),
},
emit: jest.fn(),
};
const data: SendInteractionDto = {
recipientUserId: 'stranger-1',
content: 'hello',
type: 'text',
};
await gateway.handleSendInteraction(
mockClient as unknown as AuthenticatedSocket,
data,
);
expect(mockClient.emit).toHaveBeenCalledWith(
'interaction-delivery-failed',
expect.objectContaining({
reason: 'Recipient is not a friend',
}),
);
expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled();
});
it('should fail if recipient is offline', async () => {
const mockClient: MockSocket = {
id: 'client1',
data: {
user: { keycloakSub: 'test-sub' },
userId: 'user-1',
friends: new Set(['friend-1']),
},
emit: jest.fn(),
};
const data: SendInteractionDto = {
recipientUserId: 'friend-1',
content: 'hello',
type: 'text',
};
(mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue(
false,
);
await gateway.handleSendInteraction(
mockClient as unknown as AuthenticatedSocket,
data,
);
expect(mockClient.emit).toHaveBeenCalledWith(
'interaction-delivery-failed',
expect.objectContaining({
reason: 'Recipient is offline',
}),
);
expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled();
});
it('should throw Unauthorized if user not initialized', async () => {
const mockClient: MockSocket = {
id: 'client1',
data: {
// Missing user/userId
},
};
const data: SendInteractionDto = {
recipientUserId: 'friend-1',
content: 'hello',
type: 'text',
};
await expect(
gateway.handleSendInteraction(
mockClient as unknown as AuthenticatedSocket,
data,
),
).rejects.toThrow(WsException);
});
});
}); });

View File

@@ -18,6 +18,8 @@ import type { AuthenticatedSocket } from '../../types/socket';
import { AuthService } from '../../auth/auth.service'; import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
import { CursorPositionDto } from '../dto/cursor-position.dto'; import { CursorPositionDto } from '../dto/cursor-position.dto';
import { SendInteractionDto } from '../dto/send-interaction.dto';
import { InteractionPayloadDto } from '../dto/interaction-payload.dto';
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from '../../database/prisma.service';
import { UserSocketService } from './user-socket.service'; import { UserSocketService } from './user-socket.service';
import { WsNotificationService } from './ws-notification.service'; import { WsNotificationService } from './ws-notification.service';
@@ -336,4 +338,55 @@ export class StateGateway
} }
} }
} }
@SubscribeMessage(WS_EVENT.CLIENT_SEND_INTERACTION)
async handleSendInteraction(
client: AuthenticatedSocket,
data: SendInteractionDto,
) {
const user = client.data.user;
const currentUserId = client.data.userId;
if (!user || !currentUserId) {
throw new WsException('Unauthorized: User not initialized');
}
// 1. Verify recipient is a friend
const friends = client.data.friends;
if (!friends || !friends.has(data.recipientUserId)) {
client.emit(WS_EVENT.INTERACTION_DELIVERY_FAILED, {
recipientUserId: data.recipientUserId,
reason: 'Recipient is not a friend',
});
return;
}
// 2. Check if recipient is online
const isOnline = await this.userSocketService.isUserOnline(
data.recipientUserId,
);
if (!isOnline) {
client.emit(WS_EVENT.INTERACTION_DELIVERY_FAILED, {
recipientUserId: data.recipientUserId,
reason: 'Recipient is offline',
});
return;
}
// 3. Construct payload
const payload: InteractionPayloadDto = {
senderUserId: currentUserId,
senderName: user.name || user.username || 'Unknown',
content: data.content,
type: data.type,
timestamp: new Date().toISOString(),
};
// 4. Send to recipient
await this.wsNotificationService.emitToUser(
data.recipientUserId,
WS_EVENT.INTERACTION_RECEIVED,
payload,
);
}
} }

View File

@@ -12,6 +12,9 @@ export const WS_EVENT = {
FRIEND_DOLL_UPDATED: 'friend-doll-updated', FRIEND_DOLL_UPDATED: 'friend-doll-updated',
FRIEND_DOLL_DELETED: 'friend-doll-deleted', FRIEND_DOLL_DELETED: 'friend-doll-deleted',
FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed', FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed',
CLIENT_SEND_INTERACTION: 'client-send-interaction',
INTERACTION_RECEIVED: 'interaction-received',
INTERACTION_DELIVERY_FAILED: 'interaction-delivery-failed',
} as const; } as const;
export const REDIS_CHANNEL = { export const REDIS_CHANNEL = {