第2章:システムアーキテクチャ

はじめに

ft_transcendenceは複数のサービスが連携する複合システムです。本章では、アーキテクチャ設計、Docker構成、データベース設計、API設計を学びます。

---

1. 全体アーキテクチャ

1.1 システム構成図

                              +--------+
                              | Client |
                              | (SPA)  |
                              +--------+
                                   |
                                 HTTPS
                                   ↓
+------------------------------------------------------------+
|                           Nginx                             |
|                    (Reverse Proxy + TLS)                    |
+------------------------------------------------------------+
        ↓                    ↓                    ↓
+---------------+    +---------------+    +---------------+
|   Frontend    |    |   Backend     |    |   WebSocket   |
|   (Static)    |    |   (REST API)  |    |   (Socket.io) |
|   Port: 80    |    |   Port: 3000  |    |   Port: 3001  |
+---------------+    +---------------+    +---------------+
                            ↓
                    +---------------+
                    |  PostgreSQL   |
                    |   Port: 5432  |
                    +---------------+

通信フロー:
1. クライアントはNginxにHTTPSで接続
2. Nginxが適切なサービスにプロキシ
3. /api/* → Backend
4. /socket.io/* → WebSocket
5. その他 → Frontend (静的ファイル)

1.2 マイクロサービス vs モノリス

ft_transcendence での選択:
→ モジュラーモノリス

理由:
1. 複雑性の管理
   - マイクロサービスは運用が複雑
   - 学習プロジェクトには過剰

2. 開発効率
   - 単一リポジトリ
   - 共有型システム

3. デプロイの簡素化
   - Docker Compose で完結
   - サービス間通信はローカル

モジュラー構成:
backend/
├── src/
│   ├── auth/       # 認証モジュール
│   ├── users/      # ユーザーモジュール
│   ├── chat/       # チャットモジュール
│   ├── game/       # ゲームモジュール
│   └── common/     # 共通ユーティリティ

1.3 レイヤードアーキテクチャ

+--------------------------------------------------+
|                 Presentation Layer                |
|    (Controllers, WebSocket Gateways, DTOs)       |
+--------------------------------------------------+
                          ↓
+--------------------------------------------------+
|                  Application Layer                |
|            (Services, Use Cases)                  |
+--------------------------------------------------+
                          ↓
+--------------------------------------------------+
|                    Domain Layer                   |
|          (Entities, Domain Logic)                 |
+--------------------------------------------------+
                          ↓
+--------------------------------------------------+
|                Infrastructure Layer               |
|    (Repositories, External Services, ORM)         |
+--------------------------------------------------+

各レイヤーの責務:
Presentation: HTTP/WebSocketリクエストの処理
Application: ビジネスロジックの調整
Domain: コアビジネスルール
Infrastructure: 永続化、外部API

---

2. Docker構成

2.1 docker-compose.yml 詳細

version: "3.8"

services:
  # リバースプロキシ
  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    container_name: nginx
    ports:
      - "443:443"
    depends_on:
      - backend
      - frontend
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    networks:
      - transcendence
    restart: always

  # フロントエンド (静的ファイル)
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: frontend
    expose:
      - "80"
    networks:
      - transcendence
    restart: always

  # バックエンド API
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: backend
    expose:
      - "3000"
      - "3001"  # WebSocket
    depends_on:
      db:
        condition: service_healthy
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
      - INTRA_CLIENT_ID=${INTRA_CLIENT_ID}
      - INTRA_CLIENT_SECRET=${INTRA_CLIENT_SECRET}
      - INTRA_CALLBACK_URL=${INTRA_CALLBACK_URL}
      - JWT_SECRET=${JWT_SECRET}
      - JWT_EXPIRATION=1d
      - FRONTEND_URL=https://${DOMAIN_NAME}
    networks:
      - transcendence
    restart: always

  # データベース
  db:
    image: postgres:15-alpine
    container_name: db
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASS}
      - POSTGRES_DB=${DB_NAME}
    networks:
      - transcendence
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: always

volumes:
  db_data:
    driver: local

networks:
  transcendence:
    driver: bridge
    name: transcendence

2.2 Nginx設定

# nginx/nginx.conf

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # ログ設定
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log warn;

    # アップストリーム定義
    upstream backend {
        server backend:3000;
    }

    upstream websocket {
        server backend:3001;
    }

    upstream frontend {
        server frontend:80;
    }

    # HTTPSサーバー
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name ${DOMAIN_NAME};

        # SSL設定
        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;

        # セキュリティヘッダー
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;

        # API エンドポイント
        location /api/ {
            proxy_pass http://backend/;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # WebSocket
        location /socket.io/ {
            proxy_pass http://websocket/socket.io/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # 静的ファイル (SPA)
        location / {
            proxy_pass http://frontend/;
            proxy_http_version 1.1;
            proxy_set_header Host $host;

            # SPA用のフォールバック
            try_files $uri $uri/ /index.html;
        }
    }
}

2.3 バックエンドDockerfile

# backend/Dockerfile

# ビルドステージ
FROM node:18-alpine AS builder

WORKDIR /app

# 依存関係インストール
COPY package*.json ./
RUN npm ci

# ソースコードコピーとビルド
COPY . .
RUN npm run build

# 本番ステージ
FROM node:18-alpine AS production

WORKDIR /app

# 本番依存関係のみ
COPY package*.json ./
RUN npm ci --only=production

# ビルド成果物をコピー
COPY --from=builder /app/dist ./dist

# 非rootユーザー
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nestjs -u 1001

USER nestjs

EXPOSE 3000 3001

CMD ["node", "dist/main.js"]

2.4 フロントエンドDockerfile

# frontend/Dockerfile

# ビルドステージ
FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# 本番ステージ
FROM nginx:alpine

# ビルド成果物をコピー
COPY --from=builder /app/dist /usr/share/nginx/html

# Nginx設定
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

---

3. データベース設計

3.1 ER図

+---------------+       +------------------+       +---------------+
|    users      |       |   game_history   |       |   channels    |
+---------------+       +------------------+       +---------------+
| id (PK)       |←──┐   | id (PK)          |   ┌──→| id (PK)       |
| intra_id      |   │   | player1_id (FK)  |───┘   | name          |
| username      |   ├───| player2_id (FK)  |       | type          |
| email         |   │   | winner_id (FK)   |       | password      |
| avatar_url    |   │   | player1_score    |       | owner_id (FK) |
| status        |   │   | player2_score    |       | created_at    |
| created_at    |   │   | played_at        |       +---------------+
+---------------+   │   +------------------+              ↑
       ↑            │                                     │
       │            │   +------------------+              │
       │            │   |   friendships    |              │
       │            │   +------------------+              │
       │            ├───| user_id (FK)     |              │
       │            └───| friend_id (FK)   |              │
       │                | status           |              │
       │                | created_at       |              │
       │                +------------------+              │
       │                                                  │
       │            +------------------+                  │
       │            |     messages     |                  │
       │            +------------------+                  │
       └────────────| sender_id (FK)   |                  │
                    | channel_id (FK)  |──────────────────┘
                    | content          |
                    | created_at       |
                    +------------------+

+-------------------+       +-------------------+
| channel_members   |       |    blocked_users  |
+-------------------+       +-------------------+
| channel_id (FK)   |       | blocker_id (FK)   |
| user_id (FK)      |       | blocked_id (FK)   |
| role              |       | created_at        |
| joined_at         |       +-------------------+
+-------------------+

3.2 テーブル定義

-- database/init.sql

-- ユーザーテーブル
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    intra_id INTEGER UNIQUE,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    avatar_url VARCHAR(255),
    two_factor_enabled BOOLEAN DEFAULT FALSE,
    two_factor_secret VARCHAR(255),
    status VARCHAR(20) DEFAULT 'offline' CHECK (status IN ('online', 'offline', 'in_game')),
    wins INTEGER DEFAULT 0,
    losses INTEGER DEFAULT 0,
    ladder_level INTEGER DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- ゲーム履歴
CREATE TABLE game_history (
    id SERIAL PRIMARY KEY,
    player1_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    player2_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    player1_score INTEGER NOT NULL CHECK (player1_score >= 0),
    player2_score INTEGER NOT NULL CHECK (player2_score >= 0),
    winner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    game_type VARCHAR(20) DEFAULT 'ranked' CHECK (game_type IN ('ranked', 'casual', 'private')),
    played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- フレンド関係
CREATE TABLE friendships (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(user_id, friend_id),
    CHECK (user_id != friend_id)
);

-- ブロックリスト
CREATE TABLE blocked_users (
    id SERIAL PRIMARY KEY,
    blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(blocker_id, blocked_id),
    CHECK (blocker_id != blocked_id)
);

-- チャンネル
CREATE TABLE channels (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    type VARCHAR(20) DEFAULT 'public' CHECK (type IN ('public', 'private', 'protected', 'direct')),
    password VARCHAR(255),  -- bcrypt hash for protected channels
    owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- チャンネルメンバー
CREATE TABLE channel_members (
    id SERIAL PRIMARY KEY,
    channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
    muted_until TIMESTAMP,
    banned BOOLEAN DEFAULT FALSE,
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(channel_id, user_id)
);

-- メッセージ
CREATE TABLE messages (
    id SERIAL PRIMARY KEY,
    channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
    sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- インデックス
CREATE INDEX idx_game_history_player1 ON game_history(player1_id);
CREATE INDEX idx_game_history_player2 ON game_history(player2_id);
CREATE INDEX idx_game_history_played_at ON game_history(played_at DESC);
CREATE INDEX idx_messages_channel ON messages(channel_id);
CREATE INDEX idx_messages_created ON messages(created_at DESC);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);

3.3 TypeORM エンティティ

// backend/src/users/entities/user.entity.ts

import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    CreateDateColumn,
    UpdateDateColumn,
    OneToMany,
} from 'typeorm';
import { GameHistory } from '../../game/entities/game-history.entity';
import { Message } from '../../chat/entities/message.entity';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true, nullable: true })
    intraId: number;

    @Column({ unique: true, length: 50 })
    username: string;

    @Column({ unique: true })
    email: string;

    @Column({ nullable: true })
    avatarUrl: string;

    @Column({ default: false })
    twoFactorEnabled: boolean;

    @Column({ nullable: true })
    twoFactorSecret: string;

    @Column({ default: 'offline' })
    status: 'online' | 'offline' | 'in_game';

    @Column({ default: 0 })
    wins: number;

    @Column({ default: 0 })
    losses: number;

    @Column({ default: 1 })
    ladderLevel: number;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    @OneToMany(() => GameHistory, (game) => game.player1)
    gamesAsPlayer1: GameHistory[];

    @OneToMany(() => GameHistory, (game) => game.player2)
    gamesAsPlayer2: GameHistory[];

    @OneToMany(() => Message, (message) => message.sender)
    messages: Message[];
}

---

4. API設計

4.1 RESTful エンドポイント

認証:
POST   /api/auth/login/42          # 42 OAuth開始
GET    /api/auth/callback          # OAuthコールバック
POST   /api/auth/2fa/enable        # 2FA有効化
POST   /api/auth/2fa/verify        # 2FA検証
POST   /api/auth/logout            # ログアウト

ユーザー:
GET    /api/users                  # ユーザー一覧
GET    /api/users/me               # 現在のユーザー
GET    /api/users/:id              # ユーザー詳細
PATCH  /api/users/me               # プロフィール更新
POST   /api/users/me/avatar        # アバターアップロード
GET    /api/users/:id/match-history # 対戦履歴

フレンド:
GET    /api/friends                # フレンド一覧
POST   /api/friends/:id            # フレンドリクエスト送信
PATCH  /api/friends/:id            # リクエスト承認/拒否
DELETE /api/friends/:id            # フレンド削除

ブロック:
GET    /api/blocks                 # ブロックリスト
POST   /api/blocks/:id             # ブロック
DELETE /api/blocks/:id             # ブロック解除

チャンネル:
GET    /api/channels               # チャンネル一覧
POST   /api/channels               # チャンネル作成
GET    /api/channels/:id           # チャンネル詳細
PATCH  /api/channels/:id           # チャンネル更新
DELETE /api/channels/:id           # チャンネル削除
POST   /api/channels/:id/join      # チャンネル参加
POST   /api/channels/:id/leave     # チャンネル退出
GET    /api/channels/:id/messages  # メッセージ取得

ゲーム:
GET    /api/games/queue            # マッチメイキングキュー状態
POST   /api/games/queue            # キュー参加
DELETE /api/games/queue            # キュー離脱
GET    /api/games/:id              # ゲーム状態
GET    /api/games/leaderboard      # ランキング

4.2 WebSocket イベント

接続:
connect                 # 接続確立
disconnect              # 切断

認証:
authenticate            # JWT認証

ステータス:
user:status             # ステータス更新 (online/offline/in_game)
friend:status           # フレンドのステータス変更通知

チャット:
chat:join               # チャンネル参加
chat:leave              # チャンネル退出
chat:message            # メッセージ送信
chat:message:new        # 新規メッセージ通知
chat:typing             # タイピング中
chat:user:kick          # ユーザーキック
chat:user:mute          # ユーザーミュート
chat:user:ban           # ユーザーBAN

ゲーム:
game:create             # ゲーム作成
game:join               # ゲーム参加
game:ready              # 準備完了
game:start              # ゲーム開始
game:paddle:move        # パドル移動
game:ball:update        # ボール位置更新 (サーバーから)
game:score              # スコア更新
game:end                # ゲーム終了

招待:
invite:game             # ゲーム招待
invite:game:accept      # 招待承認
invite:game:reject      # 招待拒否

4.3 DTO定義

// backend/src/users/dto/create-user.dto.ts
import { IsString, IsEmail, Length, IsOptional } from 'class-validator';

export class CreateUserDto {
    @IsString()
    @Length(3, 50)
    username: string;

    @IsEmail()
    email: string;

    @IsOptional()
    @IsString()
    avatarUrl?: string;
}

// backend/src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

// backend/src/chat/dto/create-message.dto.ts
export class CreateMessageDto {
    @IsNumber()
    channelId: number;

    @IsString()
    @Length(1, 1000)
    content: string;
}

// backend/src/game/dto/game-state.dto.ts
export class GameStateDto {
    gameId: string;
    player1: {
        id: number;
        username: string;
        y: number;
        score: number;
    };
    player2: {
        id: number;
        username: string;
        y: number;
        score: number;
    };
    ball: {
        x: number;
        y: number;
        vx: number;
        vy: number;
    };
    status: 'waiting' | 'playing' | 'finished';
}

---

5. セキュリティ設計

5.1 認証フロー

42 OAuth 2.0 + JWT:

1. ログインリクエスト
   Client → GET /api/auth/login/42

2. 42認可ページにリダイレクト
   Server → Redirect https://api.intra.42.fr/oauth/authorize

3. ユーザーが承認
   42 → Redirect /api/auth/callback?code=xxx

4. 認可コードをアクセストークンに交換
   Server → POST https://api.intra.42.fr/oauth/token

5. ユーザー情報取得
   Server → GET https://api.intra.42.fr/v2/me

6. JWTトークン発行
   Server → Client (JWT in HTTP-only cookie)

7. 保護されたリソースへのアクセス
   Client → GET /api/protected (with JWT cookie)

5.2 JWT実装

// backend/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
    constructor(
        private jwtService: JwtService,
        private usersService: UsersService,
    ) {}

    async validateOAuthLogin(intraUser: any): Promise<string> {
        // ユーザーを検索または作成
        let user = await this.usersService.findByIntraId(intraUser.id);

        if (!user) {
            user = await this.usersService.create({
                intraId: intraUser.id,
                username: intraUser.login,
                email: intraUser.email,
                avatarUrl: intraUser.image.link,
            });
        }

        // JWTペイロード
        const payload = {
            sub: user.id,
            username: user.username,
        };

        return this.jwtService.sign(payload);
    }

    async validateUser(payload: any): Promise<User> {
        return this.usersService.findById(payload.sub);
    }
}

// backend/src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(private authService: AuthService) {
        super({
            jwtFromRequest: ExtractJwt.fromExtractors([
                (request) => request?.cookies?.jwt,
            ]),
            secretOrKey: process.env.JWT_SECRET,
        });
    }

    async validate(payload: any) {
        return this.authService.validateUser(payload);
    }
}

5.3 二要素認証

// backend/src/auth/2fa/2fa.service.ts
import { Injectable } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';

@Injectable()
export class TwoFactorAuthService {
    async generateSecret(user: User): Promise<{ secret: string; qrCode: string }> {
        const secret = authenticator.generateSecret();

        const otpAuthUrl = authenticator.keyuri(
            user.email,
            'ft_transcendence',
            secret,
        );

        const qrCode = await QRCode.toDataURL(otpAuthUrl);

        return { secret, qrCode };
    }

    verifyCode(secret: string, code: string): boolean {
        return authenticator.verify({ token: code, secret });
    }

    async enable2FA(userId: number, secret: string): Promise<void> {
        await this.usersService.update(userId, {
            twoFactorEnabled: true,
            twoFactorSecret: secret,
        });
    }
}

---

6. リアルタイム通信設計

6.1 WebSocket Gateway

// backend/src/common/gateways/app.gateway.ts
import {
    WebSocketGateway,
    WebSocketServer,
    OnGatewayConnection,
    OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
    cors: {
        origin: process.env.FRONTEND_URL,
        credentials: true,
    },
})
export class AppGateway implements OnGatewayConnection, OnGatewayDisconnect {
    @WebSocketServer()
    server: Server;

    private connectedUsers: Map<number, Socket> = new Map();

    async handleConnection(client: Socket) {
        try {
            // JWTからユーザーを取得
            const user = await this.authService.validateToken(
                client.handshake.auth.token,
            );

            if (!user) {
                client.disconnect();
                return;
            }

            // ユーザーをマップに追加
            this.connectedUsers.set(user.id, client);
            client.data.user = user;

            // ステータス更新
            await this.usersService.updateStatus(user.id, 'online');

            // フレンドに通知
            this.notifyFriends(user.id, 'online');

        } catch (error) {
            client.disconnect();
        }
    }

    async handleDisconnect(client: Socket) {
        const user = client.data.user;
        if (user) {
            this.connectedUsers.delete(user.id);
            await this.usersService.updateStatus(user.id, 'offline');
            this.notifyFriends(user.id, 'offline');
        }
    }

    private async notifyFriends(userId: number, status: string) {
        const friends = await this.friendsService.getFriends(userId);

        for (const friend of friends) {
            const socket = this.connectedUsers.get(friend.id);
            if (socket) {
                socket.emit('friend:status', { userId, status });
            }
        }
    }

    // ユーザーにメッセージを送信
    sendToUser(userId: number, event: string, data: any) {
        const socket = this.connectedUsers.get(userId);
        if (socket) {
            socket.emit(event, data);
        }
    }
}

6.2 ルームベースの通信

// ゲームルーム
@SubscribeMessage('game:join')
async handleGameJoin(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { gameId: string },
) {
    const user = client.data.user;

    // ルームに参加
    client.join(`game:${data.gameId}`);

    // ルーム内の他のプレイヤーに通知
    client.to(`game:${data.gameId}`).emit('game:player:joined', {
        userId: user.id,
        username: user.username,
    });
}

// チャットルーム
@SubscribeMessage('chat:join')
async handleChatJoin(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: { channelId: number },
) {
    const user = client.data.user;

    // 権限チェック
    const canJoin = await this.chatService.canJoin(data.channelId, user.id);
    if (!canJoin) {
        return { success: false, error: 'Cannot join channel' };
    }

    client.join(`chat:${data.channelId}`);
    return { success: true };
}

// メッセージ送信
@SubscribeMessage('chat:message')
async handleChatMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: CreateMessageDto,
) {
    const user = client.data.user;

    // ブロック・ミュートチェック
    if (await this.chatService.isMuted(data.channelId, user.id)) {
        return { success: false, error: 'You are muted' };
    }

    // メッセージ保存
    const message = await this.chatService.createMessage(
        data.channelId,
        user.id,
        data.content,
    );

    // ルーム全体に配信
    this.server.to(`chat:${data.channelId}`).emit('chat:message:new', message);

    return { success: true };
}

---

まとめ

本章で学んだこと:

  • 全体アーキテクチャ: レイヤード構成
  • Docker構成: マルチサービス連携
  • データベース設計: ER図、テーブル定義
  • API設計: RESTful + WebSocket
  • セキュリティ: OAuth、JWT、2FA
  • リアルタイム通信: ルームベース設計

次章では、バックエンドの実装詳細を学びます。