Files
docs.a2v.space/public/guides/leadera-websocket-gantt/index.html
T
2026-04-26 17:09:31 +03:00

1101 lines
110 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru-ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leadera — WebSocket для диаграмм Ганта — docs.a2v.space</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="../../css/custom.css">
</head>
<body>
<header class="site-header">
<nav class="navbar navbar-expand-lg">
<div class="container-fluid">
<a class="navbar-brand" href="../../">docs.a2v.space</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain" aria-controls="navbarMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarMain">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="../../diary/">Дневник</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../kb/">База знаний</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../plans/">Планы</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../guides/">Инструкции</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../creds/">Креды</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../../personal-site/">Личный сайт</a>
</li>
</ul>
<form class="d-flex search-form" role="search">
<input class="form-control me-2" type="search" placeholder="Поиск..." aria-label="Search" id="search-input">
<button class="btn btn-outline-light" type="submit">Найти</button>
</form>
</div>
<button class="btn btn-outline-light d-md-none ms-2" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar" aria-expanded="false" aria-controls="sidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
</svg>
</button>
</div>
</nav>
<div id="search-results" class="search-results" style="display: none;">
<div class="search-results-content">
<button type="button" class="btn-close float-end" id="close-search"></button>
<h5>Результаты поиска</h5>
<ul id="results-list"></ul>
</div>
</div>
</header>
<div class="container-fluid">
<div class="row">
<aside class="col-md-3 col-lg-2 d-md-block sidebar collapse" id="sidebar">
<div class="sidebar-content">
<h6>Фильтры</h6>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="../../guides/">Все инструкции</a></li>
</ul>
<h6 class="mt-3">Теги</h6>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="../../tags/audit/">audit</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/leadera/">leadera</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/security/">security</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/architecture/">architecture</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/websocket/">websocket</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/realtime/">realtime</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/gantt/">gantt</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/api/">api</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/frontend/">frontend</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/requirements/">requirements</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/angular/">angular</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/go/">go</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/ux/">ux</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/ssh/">ssh</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/tunnel/">tunnel</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/vps/">vps</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/reverse-proxy/">reverse-proxy</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/nginx/">nginx</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/devops/">devops</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/openclaw/">openclaw</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/opencode/">opencode</a></li>
<li class="nav-item"><a class="nav-link" href="../../tags/docker/">docker</a></li>
</ul>
</div>
</aside>
<main class="col-md-9 col-lg-10 main-content">
<div class="article-content">
<h1>Leadera — WebSocket для диаграмм Ганта</h1>
<div class="source-path mb-3">
<small class="text-muted">
📁 <code id="source-path" class="user-select-all">~/Projects/docs-a2v/content/guides/leadera-websocket-gantt.md</code>
<button class="btn btn-sm btn-outline-secondary ms-1 py-0 px-1" onclick="navigator.clipboard.writeText('~/Projects/docs-a2v/content/guides\/leadera-websocket-gantt.md')" title="Копировать путь">
📋
</button>
</small>
</div>
<div class="tags mb-3">
<a href="../../tags/leadera/" class="tag">leadera</a>
<a href="../../tags/websocket/" class="tag">websocket</a>
<a href="../../tags/realtime/" class="tag">realtime</a>
<a href="../../tags/gantt/" class="tag">gantt</a>
<a href="../../tags/architecture/" class="tag">architecture</a>
</div>
<h1 id="websocket-для-диаграмм-ганта--realtime-совместное-редактирование">WebSocket для диаграмм Ганта — realtime-совместное редактирование</h1>
<p><strong>Дата:</strong> 07.04.2026
<strong>Статус:</strong> Планирование
<strong>Проект:</strong> Leadera (dev — sand.a2v.space)</p>
<hr>
<h2 id="1-постановка-задачи">1. Постановка задачи</h2>
<p>Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.</p>
<hr>
<h2 id="2-архитектура">2. Архитектура</h2>
<pre tabindex="0"><code>Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL
│ │
│ HTTP (мутации) │ Broadcast
└──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты
</code></pre><p><strong>Ключевой принцип:</strong> мутации данных идут через HTTP (REST), WebSocket используется <strong>только для доставки уведомлений</strong>. Сервер — источник истины, клиент не может обойти проверки через WS.</p>
<h3 id="комнаты">Комнаты</h3>
<p>Каждый открытый Гант — это «комната» с идентификатором <code>chartId</code>. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты.</p>
<hr>
<h2 id="3-backend-go">3. Backend (Go)</h2>
<h3 id="31-зависимости">3.1. Зависимости</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>go get github.com/gorilla/websocket
</span></span></code></pre></div><h3 id="32-новые-файлы--internalws">3.2. Новые файлы — <code>internal/ws/</code></h3>
<h4 id="messagego--типы-сообщений"><code>message.go</code> — типы сообщений</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">ws</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;time&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// EventType — тип события</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">EventType</span> <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> (
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Данные</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventSectionCreated</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;section_created&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventSectionUpdated</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;section_updated&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventSectionDeleted</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;section_deleted&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventSectionsReordered</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;sections_reordered&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventTaskCreated</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;task_created&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventTaskUpdated</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;task_updated&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventTaskDeleted</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;task_deleted&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Присутствие</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventUserJoined</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;user_joined&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventUserLeft</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;user_left&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Безопасность / доступ</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventAccessRevoked</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;access_revoked&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventChartArchived</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;chart_archived&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventRoleChanged</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;role_changed&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">EventTokenExpired</span> <span style="color:#a6e22e">EventType</span> = <span style="color:#e6db74">&#34;token_expired&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Event — WS-событие, отправляемое клиентам</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">Event</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span> <span style="color:#a6e22e">EventType</span> <span style="color:#e6db74">`json:&#34;type&#34;`</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span> <span style="color:#66d9ef">string</span> <span style="color:#e6db74">`json:&#34;chartId&#34;`</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span> <span style="color:#66d9ef">string</span> <span style="color:#e6db74">`json:&#34;userId,omitempty&#34;`</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Payload</span> <span style="color:#66d9ef">interface</span>{} <span style="color:#e6db74">`json:&#34;payload,omitempty&#34;`</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Timestamp</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Time</span> <span style="color:#e6db74">`json:&#34;timestamp&#34;`</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// IncomingMessage — сообщение от клиента (для ping/pong)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">IncomingMessage</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span> <span style="color:#66d9ef">string</span> <span style="color:#e6db74">`json:&#34;type&#34;`</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h4 id="hubgo--центральный-брокер"><code>hub.go</code> — центральный брокер</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">ws</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;sync&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Hub управляет комнатами и рассылкой событий</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">Hub</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">mu</span> <span style="color:#a6e22e">sync</span>.<span style="color:#a6e22e">RWMutex</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rooms</span> <span style="color:#66d9ef">map</span>[<span style="color:#66d9ef">string</span>]<span style="color:#66d9ef">map</span>[<span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>]<span style="color:#66d9ef">bool</span> <span style="color:#75715e">// chartId → клиенты</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">NewHub</span>() <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">Hub</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rooms</span>: make(<span style="color:#66d9ef">map</span>[<span style="color:#66d9ef">string</span>]<span style="color:#66d9ef">map</span>[<span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>]<span style="color:#66d9ef">bool</span>),
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Subscribe добавляет клиента в комнату</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>) <span style="color:#a6e22e">Subscribe</span>(<span style="color:#a6e22e">chartID</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">client</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Lock</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>] <span style="color:#f92672">==</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>] = make(<span style="color:#66d9ef">map</span>[<span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>]<span style="color:#66d9ef">bool</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>][<span style="color:#a6e22e">client</span>] = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Уведомить остальных: user_joined</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">broadcastExcept</span>(<span style="color:#a6e22e">chartID</span>, <span style="color:#a6e22e">client</span>, <span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">EventUserJoined</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span>: <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span>: <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">UserID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Payload</span>: <span style="color:#66d9ef">map</span>[<span style="color:#66d9ef">string</span>]<span style="color:#66d9ef">string</span>{<span style="color:#e6db74">&#34;username&#34;</span>: <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">Username</span>},
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Timestamp</span>: <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>(),
</span></span><span style="display:flex;"><span> })
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Unsubscribe удаляет клиента из комнаты</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>) <span style="color:#a6e22e">Unsubscribe</span>(<span style="color:#a6e22e">chartID</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">client</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Lock</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">clients</span>, <span style="color:#a6e22e">ok</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>]; <span style="color:#a6e22e">ok</span> {
</span></span><span style="display:flex;"><span> delete(<span style="color:#a6e22e">clients</span>, <span style="color:#a6e22e">client</span>)
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> len(<span style="color:#a6e22e">clients</span>) <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span> {
</span></span><span style="display:flex;"><span> delete(<span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>, <span style="color:#a6e22e">chartID</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Уведомить остальных: user_left</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">broadcastExcept</span>(<span style="color:#a6e22e">chartID</span>, <span style="color:#a6e22e">client</span>, <span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">EventUserLeft</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span>: <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span>: <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">UserID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Timestamp</span>: <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>(),
</span></span><span style="display:flex;"><span> })
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Broadcast рассылает событие всем клиентам в комнате</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>) <span style="color:#a6e22e">Broadcast</span>(<span style="color:#a6e22e">chartID</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">event</span> <span style="color:#a6e22e">Event</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">RLock</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">RUnlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">clients</span>, <span style="color:#a6e22e">ok</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>]; <span style="color:#a6e22e">ok</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">clients</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">Send</span>(<span style="color:#a6e22e">event</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// SendToUser отправляет событие конкретному пользователю во всех его комнатах</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>) <span style="color:#a6e22e">SendToUser</span>(<span style="color:#a6e22e">userID</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">event</span> <span style="color:#a6e22e">Event</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">RLock</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">RUnlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">clients</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">clients</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">UserID</span> <span style="color:#f92672">==</span> <span style="color:#a6e22e">userID</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">Send</span>(<span style="color:#a6e22e">event</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// broadcastExcept рассылает всем, кроме отправителя</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>) <span style="color:#a6e22e">broadcastExcept</span>(<span style="color:#a6e22e">chartID</span> <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">sender</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>, <span style="color:#a6e22e">event</span> <span style="color:#a6e22e">Event</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">clients</span>, <span style="color:#a6e22e">ok</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">rooms</span>[<span style="color:#a6e22e">chartID</span>]; <span style="color:#a6e22e">ok</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">clients</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">!=</span> <span style="color:#a6e22e">sender</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">Send</span>(<span style="color:#a6e22e">event</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h4 id="clientgo--обёртка-ws-соединения"><code>client.go</code> — обёртка WS-соединения</h4>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">ws</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;encoding/json&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;sync&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;time&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;github.com/gorilla/websocket&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> (
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">writeWait</span> = <span style="color:#ae81ff">10</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Second</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">pongWait</span> = <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Second</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">pingPeriod</span> = (<span style="color:#a6e22e">pongWait</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">9</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">maxMessageSize</span> = <span style="color:#ae81ff">1024</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">Client</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Hub</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Conn</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">Conn</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span> <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Username</span> <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span> <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">SendCh</span> <span style="color:#66d9ef">chan</span> <span style="color:#a6e22e">Event</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">mu</span> <span style="color:#a6e22e">sync</span>.<span style="color:#a6e22e">Mutex</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">NewClient</span>(<span style="color:#a6e22e">hub</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Hub</span>, <span style="color:#a6e22e">conn</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">Conn</span>, <span style="color:#a6e22e">userID</span>, <span style="color:#a6e22e">username</span>, <span style="color:#a6e22e">chartID</span> <span style="color:#66d9ef">string</span>) <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">Client</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Hub</span>: <span style="color:#a6e22e">hub</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Conn</span>: <span style="color:#a6e22e">conn</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span>: <span style="color:#a6e22e">userID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Username</span>: <span style="color:#a6e22e">username</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span>: <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">SendCh</span>: make(<span style="color:#66d9ef">chan</span> <span style="color:#a6e22e">Event</span>, <span style="color:#ae81ff">64</span>),
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ReadPump читает входящие сообщения (ping/pong)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>) <span style="color:#a6e22e">ReadPump</span>() {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#66d9ef">func</span>() {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Hub</span>.<span style="color:#a6e22e">Unsubscribe</span>(<span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">ChartID</span>, <span style="color:#a6e22e">c</span>)
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">Close</span>()
</span></span><span style="display:flex;"><span> }()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetReadLimit</span>(<span style="color:#a6e22e">maxMessageSize</span>)
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetReadDeadline</span>(<span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>().<span style="color:#a6e22e">Add</span>(<span style="color:#a6e22e">pongWait</span>))
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetPongHandler</span>(<span style="color:#66d9ef">func</span>(<span style="color:#66d9ef">string</span>) <span style="color:#66d9ef">error</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetReadDeadline</span>(<span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>().<span style="color:#a6e22e">Add</span>(<span style="color:#a6e22e">pongWait</span>))
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span> })
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">message</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">ReadMessage</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Клиент может слать только ping, данные игнорируем</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">_</span> = <span style="color:#a6e22e">message</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// WritePump отправляет события клиенту</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>) <span style="color:#a6e22e">WritePump</span>() {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ticker</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">NewTicker</span>(<span style="color:#a6e22e">pingPeriod</span>)
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">defer</span> <span style="color:#66d9ef">func</span>() {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ticker</span>.<span style="color:#a6e22e">Stop</span>()
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">Close</span>()
</span></span><span style="display:flex;"><span> }()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">select</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">event</span>, <span style="color:#a6e22e">ok</span> <span style="color:#f92672">:=</span> <span style="color:#f92672">&lt;-</span><span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">SendCh</span>:
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetWriteDeadline</span>(<span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>().<span style="color:#a6e22e">Add</span>(<span style="color:#a6e22e">writeWait</span>))
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> !<span style="color:#a6e22e">ok</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">WriteMessage</span>(<span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">CloseMessage</span>, []<span style="color:#66d9ef">byte</span>{})
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">_</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">json</span>.<span style="color:#a6e22e">Marshal</span>(<span style="color:#a6e22e">event</span>)
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Lock</span>()
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">WriteMessage</span>(<span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">TextMessage</span>, <span style="color:#a6e22e">data</span>)
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">mu</span>.<span style="color:#a6e22e">Unlock</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#f92672">&lt;-</span><span style="color:#a6e22e">ticker</span>.<span style="color:#a6e22e">C</span>:
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">SetWriteDeadline</span>(<span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>().<span style="color:#a6e22e">Add</span>(<span style="color:#a6e22e">writeWait</span>))
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Conn</span>.<span style="color:#a6e22e">WriteMessage</span>(<span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">PingMessage</span>, <span style="color:#66d9ef">nil</span>); <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Send ставит событие в очередь отправки</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">Client</span>) <span style="color:#a6e22e">Send</span>(<span style="color:#a6e22e">event</span> <span style="color:#a6e22e">Event</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">select</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">SendCh</span> <span style="color:#f92672">&lt;-</span> <span style="color:#a6e22e">event</span>:
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Очередь полна — отключаем медленного клиента</span>
</span></span><span style="display:flex;"><span> close(<span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">SendCh</span>)
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="33-ws-endpoint--internalhandlersws_handlergo">3.3. WS endpoint — <code>internal/handlers/ws_handler.go</code></h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">package</span> <span style="color:#a6e22e">handlers</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> (
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;net/http&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;time&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;app-leadera-api/internal/middleware&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;app-leadera-api/internal/ws&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;github.com/gin-gonic/gin&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;github.com/google/uuid&#34;</span>
</span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;github.com/gorilla/websocket&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">upgrader</span> = <span style="color:#a6e22e">websocket</span>.<span style="color:#a6e22e">Upgrader</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ReadBufferSize</span>: <span style="color:#ae81ff">1024</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">WriteBufferSize</span>: <span style="color:#ae81ff">1024</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">CheckOrigin</span>: <span style="color:#66d9ef">func</span>(<span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) <span style="color:#66d9ef">bool</span> { <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span> }, <span style="color:#75715e">// nginx проксирует</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">WSHandler</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hub</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Hub</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ganttMW</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">middleware</span>.<span style="color:#a6e22e">GanttMiddleware</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">NewWSHandler</span>(<span style="color:#a6e22e">hub</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Hub</span>, <span style="color:#a6e22e">ganttMW</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">middleware</span>.<span style="color:#a6e22e">GanttMiddleware</span>) <span style="color:#f92672">*</span><span style="color:#a6e22e">WSHandler</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">WSHandler</span>{<span style="color:#a6e22e">hub</span>: <span style="color:#a6e22e">hub</span>, <span style="color:#a6e22e">ganttMW</span>: <span style="color:#a6e22e">ganttMW</span>}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">h</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">WSHandler</span>) <span style="color:#a6e22e">HandleGanttWS</span>(<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">gin</span>.<span style="color:#a6e22e">Context</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// 1. Получить пользователя из контекста (auth middleware уже отработал)</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">userID</span>, <span style="color:#a6e22e">exists</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Get</span>(<span style="color:#e6db74">&#34;userID&#34;</span>)
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> !<span style="color:#a6e22e">exists</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">AbortWithStatusJSON</span>(<span style="color:#ae81ff">401</span>, <span style="color:#a6e22e">gin</span>.<span style="color:#a6e22e">H</span>{<span style="color:#e6db74">&#34;error&#34;</span>: <span style="color:#e6db74">&#34;unauthorized&#34;</span>})
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// 2. Проверить доступ к chartId (gantt middleware)</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">chartID</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Param</span>(<span style="color:#e6db74">&#34;chartId&#34;</span>)
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">uuid</span>.<span style="color:#a6e22e">Parse</span>(<span style="color:#a6e22e">chartID</span>); <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">AbortWithStatusJSON</span>(<span style="color:#ae81ff">400</span>, <span style="color:#a6e22e">gin</span>.<span style="color:#a6e22e">H</span>{<span style="color:#e6db74">&#34;error&#34;</span>: <span style="color:#e6db74">&#34;invalid chart id&#34;</span>})
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// TODO: проверить через ganttMW что userID имеет доступ к этому chart</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// 3. Upgrade до WebSocket</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">conn</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">upgrader</span>.<span style="color:#a6e22e">Upgrade</span>(<span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Writer</span>, <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">Request</span>, <span style="color:#66d9ef">nil</span>)
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// 4. Создать клиент и подписать на комнату</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">NewClient</span>(
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">hub</span>, <span style="color:#a6e22e">conn</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">userID</span>.(<span style="color:#66d9ef">string</span>),
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">GetString</span>(<span style="color:#e6db74">&#34;username&#34;</span>),
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span> )
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">hub</span>.<span style="color:#a6e22e">Subscribe</span>(<span style="color:#a6e22e">chartID</span>, <span style="color:#a6e22e">client</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// 5. Запустить pump&#39;ы</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">go</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">WritePump</span>()
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">go</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">ReadPump</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="34-регистрация-маршрута">3.4. Регистрация маршрута</h3>
<p>В <code>cmd/server/main.go</code> или где регистрируются routes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// После создания hub</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wsHub</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">NewHub</span>()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// WS endpoint</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wsHandler</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">handlers</span>.<span style="color:#a6e22e">NewWSHandler</span>(<span style="color:#a6e22e">wsHub</span>, <span style="color:#a6e22e">ganttMW</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">api</span>.<span style="color:#a6e22e">GET</span>(<span style="color:#e6db74">&#34;/ws/gantt/:chartId&#34;</span>, <span style="color:#a6e22e">authMW</span>.<span style="color:#a6e22e">RequireAuth</span>(), <span style="color:#a6e22e">wsHandler</span>.<span style="color:#a6e22e">HandleGanttWS</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Передать hub в ganttHandler для broadcast&#39;ов</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ganttHandler</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">handlers</span>.<span style="color:#a6e22e">NewGanttHandler</span>(<span style="color:#a6e22e">ganttService</span>, <span style="color:#a6e22e">sectionService</span>, <span style="color:#a6e22e">taskService</span>, <span style="color:#a6e22e">blockService</span>, <span style="color:#a6e22e">wsHub</span>)
</span></span></code></pre></div><h3 id="35-интеграция-broadcast-в-handlers">3.5. Интеграция broadcast в handlers</h3>
<p>В каждый write-handler — одна строка после успешной операции:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Пример: после успешного UpdateTask</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">hub</span>.<span style="color:#a6e22e">Broadcast</span>(<span style="color:#a6e22e">chartID</span>, <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">EventTaskUpdated</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span>: <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">UserID</span>: <span style="color:#a6e22e">userID</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Payload</span>: <span style="color:#a6e22e">updatedTask</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Timestamp</span>: <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Now</span>(),
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><p>Точки интеграции в <code>gantt_handler.go</code>:</p>
<ul>
<li><code>CreateGanttChart</code> → не нужен (пользователь только что создал, он один)</li>
<li><code>UpdateGanttChart</code> / <code>ArchiveGanttChart</code><code>chart_archived</code></li>
<li><code>CreateSection</code><code>section_created</code></li>
<li><code>UpdateSection</code><code>section_updated</code></li>
<li><code>DeleteSection</code><code>section_deleted</code></li>
<li><code>ReorderSections</code><code>sections_reordered</code></li>
<li><code>CreateTask</code><code>task_created</code></li>
<li><code>UpdateTask</code><code>task_updated</code></li>
<li><code>DeleteTask</code><code>task_deleted</code></li>
</ul>
<h3 id="36-интеграция-событий-безопасности">3.6. Интеграция событий безопасности</h3>
<p>В соответствующих service-методах:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// При исключении из space (SpaceService.RemoveMember)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wsHub</span>.<span style="color:#a6e22e">SendToUser</span>(<span style="color:#a6e22e">removedUserID</span>, <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">EventAccessRevoked</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Payload</span>: <span style="color:#66d9ef">map</span>[<span style="color:#66d9ef">string</span>]<span style="color:#66d9ef">string</span>{<span style="color:#e6db74">&#34;spaceId&#34;</span>: <span style="color:#a6e22e">spaceID</span>},
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// При архивации ганта</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wsHub</span>.<span style="color:#a6e22e">Broadcast</span>(<span style="color:#a6e22e">chartID</span>, <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">EventChartArchived</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ChartID</span>: <span style="color:#a6e22e">chartID</span>,
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// При изменении роли</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wsHub</span>.<span style="color:#a6e22e">SendToUser</span>(<span style="color:#a6e22e">userID</span>, <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">Event</span>{
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Type</span>: <span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">EventRoleChanged</span>,
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">Payload</span>: <span style="color:#66d9ef">map</span>[<span style="color:#66d9ef">string</span>]<span style="color:#66d9ef">string</span>{<span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#a6e22e">newRole</span>},
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><hr>
<h2 id="4-frontend-angular">4. Frontend (Angular)</h2>
<h3 id="41-websocket-сервис--gantt-wsservicets">4.1. WebSocket сервис — <code>gantt-ws.service.ts</code></h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#75715e">// src/app/pages/gantt/services/gantt-ws.service.ts
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">Injectable</span>, <span style="color:#a6e22e">inject</span>, <span style="color:#a6e22e">OnDestroy</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;@angular/core&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">Subject</span>, <span style="color:#a6e22e">Observable</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;rxjs&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">environment</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;../../../environments/environment&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">SpaceStorageService</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;../../../core/services/space-storage.service&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">AuthService</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;../../../core/auth/services/auth.service&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">interface</span> <span style="color:#a6e22e">WSEvent</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">chartId?</span>: <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">userId?</span>: <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">payload?</span>: <span style="color:#66d9ef">any</span>;
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">timestamp?</span>: <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">@Injectable</span>({ <span style="color:#a6e22e">providedIn</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;root&#39;</span> })
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GanttWSService</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">OnDestroy</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">spaceStorage</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">inject</span>(<span style="color:#a6e22e">SpaceStorageService</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">auth</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">inject</span>(<span style="color:#a6e22e">AuthService</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">socket</span>: <span style="color:#66d9ef">WebSocket</span> <span style="color:#f92672">|</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">eventSubject</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Subject</span>&lt;<span style="color:#f92672">WSEvent</span>&gt;();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">reconnectTimer</span>: <span style="color:#66d9ef">any</span>;
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">chartId</span>: <span style="color:#66d9ef">string</span> <span style="color:#f92672">|</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">/** Observable для подписки на события */</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">get</span> <span style="color:#a6e22e">events$</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Observable</span>&lt;<span style="color:#f92672">WSEvent</span>&gt; {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">eventSubject</span>.<span style="color:#a6e22e">asObservable</span>();
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">/** Подключиться к комнате ганта */</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">connect</span>(<span style="color:#a6e22e">chartId</span>: <span style="color:#66d9ef">string</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">disconnect</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">chartId</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">auth</span>.<span style="color:#a6e22e">getAccessToken</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wsUrl</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">buildWSUrl</span>(<span style="color:#a6e22e">chartId</span>, <span style="color:#a6e22e">token</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">WebSocket</span>(<span style="color:#a6e22e">wsUrl</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>.<span style="color:#a6e22e">onopen</span> <span style="color:#f92672">=</span> () <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">`[WS] Connected to gantt </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">chartId</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>.<span style="color:#a6e22e">onmessage</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">msg</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">event</span>: <span style="color:#66d9ef">WSEvent</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#a6e22e">msg</span>.<span style="color:#a6e22e">data</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">eventSubject</span>.<span style="color:#a6e22e">next</span>(<span style="color:#a6e22e">event</span>);
</span></span><span style="display:flex;"><span> } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">e</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#39;[WS] Parse error&#39;</span>, <span style="color:#a6e22e">e</span>);
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>.<span style="color:#a6e22e">onclose</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">event</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">`[WS] Closed: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">code</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">!==</span> <span style="color:#ae81ff">1000</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">scheduleReconnect</span>();
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>.<span style="color:#a6e22e">onerror</span> <span style="color:#f92672">=</span> () <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">close</span>();
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">/** Отключиться */</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">disconnect</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">clearTimeout</span>(<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">reconnectTimer</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span>.<span style="color:#a6e22e">close</span>(<span style="color:#ae81ff">1000</span>, <span style="color:#e6db74">&#39;manual&#39;</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">socket</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ngOnDestroy</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">disconnect</span>();
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">buildWSUrl</span>(<span style="color:#a6e22e">chartId</span>: <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">token</span>: <span style="color:#66d9ef">string</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">string</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">base</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">environment</span>.<span style="color:#a6e22e">wsUrl</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">location</span>.<span style="color:#a6e22e">protocol</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#39;http&#39;</span>, <span style="color:#e6db74">&#39;ws&#39;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#39;//&#39;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">location</span>.<span style="color:#a6e22e">host</span>;
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">base</span><span style="color:#e6db74">}</span><span style="color:#e6db74">/api/v1/ws/gantt/</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">chartId</span><span style="color:#e6db74">}</span><span style="color:#e6db74">?token=</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>;
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">scheduleReconnect</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">reconnectTimer</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">setTimeout</span>(() <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;[WS] Reconnecting...&#39;</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span>) <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">connect</span>(<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span>);
</span></span><span style="display:flex;"><span> }, <span style="color:#ae81ff">3000</span>);
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="42-интеграция-в-gantt-chart-canvas-компонент">4.2. Интеграция в gantt-chart-canvas компонент</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#75715e">// В gantt-chart-canvas.component.ts
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GanttChartCanvasComponent</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">OnInit</span>, <span style="color:#a6e22e">OnDestroy</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">ws</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">inject</span>(<span style="color:#a6e22e">GanttWSService</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">onlineUsers</span>: <span style="color:#66d9ef">Set</span>&lt;<span style="color:#f92672">string</span>&gt; <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Set</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">wsSubscription</span>: <span style="color:#66d9ef">Subscription</span> <span style="color:#f92672">|</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ngOnInit</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Подключиться к WS при загрузке ганта
</span></span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">connect</span>(<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">chartId</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">wsSubscription</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">events$</span>.<span style="color:#a6e22e">subscribe</span>(<span style="color:#a6e22e">event</span> <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">handleWSEvent</span>(<span style="color:#a6e22e">event</span>);
</span></span><span style="display:flex;"><span> });
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">ngOnDestroy</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">wsSubscription</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">unsubscribe</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">ws</span>.<span style="color:#a6e22e">disconnect</span>();
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">handleWSEvent</span>(<span style="color:#a6e22e">event</span>: <span style="color:#66d9ef">WSEvent</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Если это событие от текущего пользователя — пропускаем
</span></span></span><span style="display:flex;"><span> <span style="color:#75715e">// (мы уже обновили UI оптимистично или после HTTP-ответа)
</span></span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">userId</span> <span style="color:#f92672">===</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">currentUserId</span>) <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">switch</span> (<span style="color:#a6e22e">event</span>.<span style="color:#66d9ef">type</span>) {
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;section_created&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">addSectionLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;section_updated&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">updateSectionLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;section_deleted&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">removeSectionLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">id</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;sections_reordered&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">reorderSectionsLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">ordered_ids</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;task_created&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">addTaskLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;task_updated&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">updateTaskLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;task_deleted&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">removeTaskLocal</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">id</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">redrawCanvas</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;user_joined&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">onlineUsers</span>.<span style="color:#a6e22e">add</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">userId</span><span style="color:#f92672">!</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">showOnlineIndicator</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;user_left&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">onlineUsers</span>.<span style="color:#66d9ef">delete</span>(<span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">userId</span><span style="color:#f92672">!</span>);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">showOnlineIndicator</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// === Безопасность ===
</span></span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;access_revoked&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">router</span>.<span style="color:#a6e22e">navigate</span>([<span style="color:#e6db74">&#39;/gantt&#39;</span>], {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">state</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">alert</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Доступ к пространству был отозван&#39;</span> }
</span></span><span style="display:flex;"><span> });
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;chart_archived&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">router</span>.<span style="color:#a6e22e">navigate</span>([<span style="color:#e6db74">&#39;/gantt&#39;</span>], {
</span></span><span style="display:flex;"><span> <span style="color:#a6e22e">state</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">alert</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Диаграмма была заархивирована&#39;</span> }
</span></span><span style="display:flex;"><span> });
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;role_changed&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">currentRole</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">payload</span>.<span style="color:#a6e22e">role</span>;
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">updateEditableState</span>();
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;token_expired&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">router</span>.<span style="color:#a6e22e">navigate</span>([<span style="color:#e6db74">&#39;/auth/login&#39;</span>]);
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">redrawCanvas</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">void</span> {
</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Перерисовать Konva canvas с обновлёнными данными
</span></span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="43-индикатор-онлайн-пользователей">4.3. Индикатор онлайн-пользователей</h3>
<p>Добавить в шаблон canvas-компонента:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><span style="color:#75715e">&lt;!-- Онлайн-индикатор --&gt;</span>
</span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">div</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;online-indicator&#34;</span> <span style="color:#960050;background-color:#1e0010">*</span><span style="color:#a6e22e">ngIf</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;onlineUsers.size &gt; 0&#34;</span>&gt;
</span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;pulse-dot&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;
</span></span><span style="display:flex;"><span> {{ onlineUsers.size }} онлайн
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">div</span>&gt;
</span></span></code></pre></div><hr>
<h2 id="5-безопасность">5. Безопасность</h2>
<h3 id="51-авторизация-при-подключении">5.1. Авторизация при подключении</h3>
<table>
<thead>
<tr>
<th>Этап</th>
<th>Проверка</th>
</tr>
</thead>
<tbody>
<tr>
<td>WS Handshake</td>
<td>JWT в query-параметре <code>?token=...</code></td>
</tr>
<tr>
<td>Подписка на комнату</td>
<td>Проверка membership в space</td>
</tr>
<tr>
<td>Каждое broadcast-событие</td>
<td>Hub знает userId каждого клиента</td>
</tr>
</tbody>
</table>
<h3 id="52-сценарии-отзыва-доступа">5.2. Сценарии отзыва доступа</h3>
<table>
<thead>
<tr>
<th>Событие</th>
<th>Триггер</th>
<th>Действие на клиенте</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>access_revoked</code></td>
<td>Исключение из space</td>
<td>Редирект на список гантов</td>
</tr>
<tr>
<td><code>chart_archived</code></td>
<td>Архивация ганта</td>
<td>Редирект на список гантов</td>
</tr>
<tr>
<td><code>role_changed</code></td>
<td>Смена роли (editor→viewer)</td>
<td>Отключить редактирование</td>
</tr>
<tr>
<td><code>token_expired</code></td>
<td>Токен протух</td>
<td>Редирект на логин</td>
</tr>
</tbody>
</table>
<h3 id="53-защита-от-злоупотреблений">5.3. Защита от злоупотреблений</h3>
<table>
<thead>
<tr>
<th>Угроза</th>
<th>Мера</th>
</tr>
</thead>
<tbody>
<tr>
<td>Спам WS-сообщений</td>
<td>Rate limit: 30 msg/min на клиента, <code>ReadLimit</code> в gorilla/websocket</td>
</tr>
<tr>
<td>Подключение без прав</td>
<td>Проверка space membership при handshake</td>
</tr>
<tr>
<td>Истёкший токен</td>
<td>Ping/pong + периодическая проверка JWT (раз в 5 мин)</td>
</tr>
<tr>
<td>XSS через WS</td>
<td>Payload — JSON, sanitize имён на frontend</td>
</tr>
<tr>
<td>Мутации через WS</td>
<td>WS <strong>только для чтения</strong> (уведомления). Мутации — через HTTP API с полной валидацией</td>
</tr>
</tbody>
</table>
<h3 id="54-nginx-конфигурация">5.4. Nginx конфигурация</h3>
<p>В конфиг <code>sand.a2v.space</code> добавить:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#75715e"># WebSocket проксирование
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/api/v1/ws/</span> {
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://127.0.0.1:8080</span>;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_http_version</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">.1</span>;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Upgrade</span> $http_upgrade;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Connection</span> <span style="color:#e6db74">&#34;upgrade&#34;</span>;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Host</span> $host;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Real-IP</span> $remote_addr;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-For</span> $proxy_add_x_forwarded_for;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-Proto</span> $scheme;
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_read_timeout</span> <span style="color:#e6db74">86400s</span>; <span style="color:#75715e"># 24h для long-lived WS
</span></span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_send_timeout</span> <span style="color:#e6db74">86400s</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="6-оценка-трудозатрат">6. Оценка трудозатрат</h2>
<table>
<thead>
<tr>
<th>Компонент</th>
<th>Строки кода</th>
<th>Время</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>internal/ws/</code> (hub, client, message)</td>
<td>~200</td>
<td>2-3 часа</td>
</tr>
<tr>
<td><code>ws_handler.go</code> (endpoint)</td>
<td>~60</td>
<td>30 мин</td>
</tr>
<tr>
<td>Интеграция broadcast в handlers</td>
<td>~20 (по строке на endpoint)</td>
<td>30 мин</td>
</tr>
<tr>
<td>События безопасности в services</td>
<td>~30</td>
<td>30 мин</td>
</tr>
<tr>
<td><code>gantt-ws.service.ts</code> (Angular)</td>
<td>~80</td>
<td>1 час</td>
</tr>
<tr>
<td>Интеграция в canvas-компонент</td>
<td>~80</td>
<td>1-2 часа</td>
</tr>
<tr>
<td>Индикатор онлайн</td>
<td>~30</td>
<td>30 мин</td>
</tr>
<tr>
<td>Nginx конфигурация</td>
<td>~10</td>
<td>10 мин</td>
</tr>
<tr>
<td>Тестирование</td>
<td></td>
<td>2-3 часа</td>
</tr>
<tr>
<td><strong>Итого</strong></td>
<td><strong>~510 строк</strong></td>
<td><strong>~1-2 дня</strong></td>
</tr>
</tbody>
</table>
<hr>
<h2 id="7-порядок-реализации">7. Порядок реализации</h2>
<ol>
<li><strong>Backend: <code>internal/ws/</code></strong> — hub, client, message types</li>
<li><strong>Backend: WS endpoint</strong> — handler + route</li>
<li><strong>Backend: Broadcast в handlers</strong> — интеграция в существующие CRUD-эндпоинты</li>
<li><strong>Backend: События безопасности</strong> — access_revoked, chart_archived, role_changed</li>
<li><strong>Nginx: WS location</strong> — проксирование WebSocket</li>
<li><strong>Frontend: WS service</strong> — подключение, реконнект, парсинг событий</li>
<li><strong>Frontend: Интеграция в canvas</strong> — обработка событий, обновление Konva</li>
<li><strong>Frontend: Индикатор онлайн</strong> — визуальная обратная связь</li>
<li><strong>Тестирование</strong> — многопользовательский сценарий, отзыв прав, обрывы связи</li>
</ol>
<hr>
<h2 id="8-дальнейшие-улучшения-после-mvp">8. Дальнейшие улучшения (после MVP)</h2>
<ul>
<li><strong>Cursor других пользователей</strong> — показывать позицию курсора на таймлайне</li>
<li><strong>Блокировка элемента</strong> — «кто-то редактирует эту задачу» (optimistic locking через WS)</li>
<li><strong>Чат в ганте</strong> — комментарии к задачам прямо на канвасе</li>
<li><strong>History/undo</strong> — откат изменений с визуальной историей</li>
<li><strong>Уведомления</strong> — push-уведомления при @mention в комментариях к задачам</li>
</ul>
</div>
</main>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p class="text-center mb-0">&copy; 2026 docs.a2v.space</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js"></script>
<script src="../../js/search.js"></script>
<script src="../../js/creds.js"></script>
</body>
</html>