1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-07 13:03:09 +00:00
ClassworksKV/SOCKET_API.md
SunWuyuan 02c0da037f
feat: integrate Socket.IO for real-time updates and online device management
- Added Socket.IO dependency to enable real-time communication.
- Initialized Socket.IO in the server and bound it to the HTTP server.
- Implemented functionality to allow clients to join device channels using KV tokens.
- Added endpoints to retrieve online devices and broadcast key changes.
- Enhanced existing routes to include device names in responses.
- Implemented broadcasting of key changes for KV operations.
- Updated documentation to reflect the new Socket.IO integration and usage.
2025-10-25 17:10:22 +08:00

12 KiB
Raw Blame History

Socket.IO 实时频道接口文档(前端)

概述

ClassworksKV 提供基于 Socket.IO 的实时键值变更通知服务。前端使用 KV token(应用安装 token加入频道服务端会自动将 token 映射到对应设备的 uuid 房间。同一设备的不同 token 会被归入同一频道,因此多个客户端/应用可以共享实时更新。

重要变更:不再支持直接使用 uuid 加入频道,所有连接必须使用有效的 KV token。

安装依赖

前端项目安装 Socket.IO 客户端:

# npm
npm install socket.io-client

# pnpm
pnpm add socket.io-client

# yarn
yarn add socket.io-client

连接服务器

基础连接

import { io, Socket } from 'socket.io-client';

const SERVER_URL = 'http://localhost:3000'; // 替换为实际服务器地址

const socket: Socket = io(SERVER_URL, {
  transports: ['websocket'],
});

连接时自动加入频道(推荐)

在连接握手时通过 query 参数传入 token自动加入对应设备频道

const socket = io(SERVER_URL, {
  transports: ['websocket'],
  query: {
    token: '<your-kv-app-token>', // 或使用 apptoken 参数
  },
});

// 监听加入成功
socket.on('joined', (info) => {
  console.log('已加入频道:', info);
  // { by: 'token', uuid: 'device-uuid-xxx' }
});

// 监听加入失败
socket.on('join-error', (error) => {
  console.error('加入频道失败:', error);
  // { by: 'token', reason: 'invalid_token' }
});

事件接口

1. 客户端发送的事件

join-token - 使用 token 加入频道

连接后按需加入频道。

载荷格式:

{
  token?: string;   // KV token二选一
  apptoken?: string; // 或使用 apptoken 字段
}

示例:

socket.emit('join-token', { token: '<your-kv-app-token>' });

leave-token - 使用 token 离开频道

离开指定 token 对应的设备频道。

载荷格式:

{
  token?: string;
  apptoken?: string;
}

示例:

socket.emit('leave-token', { token: '<your-kv-app-token>' });

leave-all - 离开所有频道

断开前清理,离开该连接加入的所有设备频道。

载荷:

示例:

socket.emit('leave-all');

2. 服务端发送的事件

joined - 加入成功通知

当成功加入频道后,服务端会发送此事件。

载荷格式:

{
  by: 'token';
  uuid: string; // 设备 uuid用于调试/日志)
}

示例:

socket.on('joined', (info) => {
  console.log(`成功加入设备 ${info.uuid} 的频道`);
});

join-error - 加入失败通知

token 无效或查询失败时触发。

载荷格式:

{
  by: 'token';
  reason: 'invalid_token'; // 失败原因
}

示例:

socket.on('join-error', (error) => {
  console.error('Token 无效,无法加入频道');
});

kv-key-changed - 键值变更广播

当设备下的 KV 键被创建/更新/删除时,向该设备频道内所有连接广播此事件。

载荷格式:

{
  uuid: string;           // 设备 uuid
  key: string;            // 变更的键名
  action: 'upsert' | 'delete'; // 操作类型

  // 仅 action='upsert' 时存在:
  created?: boolean;      // 是否首次创建
  updatedAt?: string;     // 更新时间ISO 8601
  batch?: boolean;        // 是否为批量导入中的单条

  // 仅 action='delete' 时存在:
  deletedAt?: string;     // 删除时间ISO 8601
}

示例:

socket.on('kv-key-changed', (msg) => {
  if (msg.action === 'upsert') {
    console.log(`键 ${msg.key}${msg.created ? '创建' : '更新'}`);
    // 刷新本地缓存或重新获取数据
  } else if (msg.action === 'delete') {
    console.log(`键 ${msg.key} 已删除`);
    // 从本地缓存移除
  }
});

载荷示例:

  • 新建/更新键:

    {
      "uuid": "device-001",
      "key": "settings/theme",
      "action": "upsert",
      "created": false,
      "updatedAt": "2025-10-25T08:30:00.000Z"
    }
    
  • 删除键:

    {
      "uuid": "device-001",
      "key": "settings/theme",
      "action": "delete",
      "deletedAt": "2025-10-25T08:35:00.000Z"
    }
    
  • 批量导入中的单条:

    {
      "uuid": "device-001",
      "key": "config/version",
      "action": "upsert",
      "created": true,
      "updatedAt": "2025-10-25T08:40:00.000Z",
      "batch": true
    }
    

device-joined - 设备频道连接数变化(可选)

当有新连接加入某设备频道时广播,用于显示在线人数。

载荷格式:

{
  uuid: string;       // 设备 uuid
  connections: number; // 当前连接数
}

示例:

socket.on('device-joined', (info) => {
  console.log(`设备 ${info.uuid} 当前有 ${info.connections} 个连接`);
});

完整使用示例

React Hook 封装

import { useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';

const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000';

interface KvKeyChange {
  uuid: string;
  key: string;
  action: 'upsert' | 'delete';
  created?: boolean;
  updatedAt?: string;
  deletedAt?: string;
  batch?: boolean;
}

export function useKvChannel(
  token: string | null,
  onKeyChanged?: (event: KvKeyChange) => void
) {
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    if (!token) return;

    // 创建连接并加入频道
    const socket = io(SERVER_URL, {
      transports: ['websocket'],
      query: { token },
    });

    socket.on('joined', (info) => {
      console.log('已加入设备频道:', info.uuid);
    });

    socket.on('join-error', (err) => {
      console.error('加入频道失败:', err.reason);
    });

    socket.on('kv-key-changed', (msg: KvKeyChange) => {
      onKeyChanged?.(msg);
    });

    socketRef.current = socket;

    return () => {
      socket.emit('leave-all');
      socket.close();
    };
  }, [token]);

  return socketRef.current;
}

Vue Composable 封装

import { ref, watch, onUnmounted } from 'vue';
import { io, Socket } from 'socket.io-client';

const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000';

export function useKvChannel(token: Ref<string | null>) {
  const socket = ref<Socket | null>(null);
  const isConnected = ref(false);
  const deviceUuid = ref<string | null>(null);

  watch(token, (newToken) => {
    // 清理旧连接
    if (socket.value) {
      socket.value.emit('leave-all');
      socket.value.close();
      socket.value = null;
    }

    if (!newToken) return;

    // 创建新连接
    const s = io(SERVER_URL, {
      transports: ['websocket'],
      query: { token: newToken },
    });

    s.on('connect', () => {
      isConnected.value = true;
    });

    s.on('disconnect', () => {
      isConnected.value = false;
    });

    s.on('joined', (info) => {
      deviceUuid.value = info.uuid;
      console.log('已加入设备频道:', info.uuid);
    });

    s.on('join-error', (err) => {
      console.error('加入失败:', err.reason);
    });

    socket.value = s;
  }, { immediate: true });

  onUnmounted(() => {
    if (socket.value) {
      socket.value.emit('leave-all');
      socket.value.close();
    }
  });

  return { socket, isConnected, deviceUuid };
}

使用示例React

import { useKvChannel } from './hooks/useKvChannel';

function MyComponent() {
  const token = localStorage.getItem('kv-token');

  useKvChannel(token, (event) => {
    console.log('KV 变更:', event);

    if (event.action === 'upsert') {
      // 更新本地状态或重新获取数据
      fetchKeyValue(event.key);
    } else if (event.action === 'delete') {
      // 从本地移除
      removeFromCache(event.key);
    }
  });

  return <div>实时监听中...</div>;
}

REST API查询在线设备

除了 Socket.IO 实时事件,还提供 HTTP 接口查询当前在线设备列表。

GET /devices/online

响应格式:

{
  success: true;
  devices: Array<{
    uuid: string;        // 设备 uuid
    connections: number; // 当前连接数
    name: string | null; // 设备名称(若已设置)
  }>;
}

示例:

const response = await fetch(`${SERVER_URL}/devices/online`);
const data = await response.json();

console.log('在线设备:', data.devices);
// [{ uuid: 'device-001', connections: 3, name: 'My Device' }, ...]

获取 KV Token

前端需要先获取有效的 KV token 才能加入频道。Token 通过以下接口获取:

安装应用获取 token

接口: POST /apps/devices/:uuid/install/:appId

认证: 需要设备 UUID 认证(密码或账户 JWT

响应包含:

{
  id: string;
  appId: string;
  token: string;      // 用于 KV 操作和加入频道
  note: string | null;
  name: string | null; // 等同于 note便于展示
  installedAt: string;
}

列出设备已有的 token

接口: GET /apps/tokens?uuid=<device-uuid>

响应:

{
  success: true;
  tokens: Array<{
    id: string;
    token: string;
    appId: string;
    installedAt: string;
    note: string | null;
    name: string | null; // 等同于 note
  }>;
  deviceUuid: string;
}

注意事项与最佳实践

  1. Token 必需:所有连接必须提供有效的 KV token不再支持直接使用 uuid。

  2. 频道归并:同一设备的不同 token 会自动归入同一房间(以设备 uuid 为房间名),因此多个应用/客户端可以共享实时更新。

  3. 连接管理

    • 组件卸载时调用 leave-allleave-token 清理连接
    • 避免频繁创建/销毁连接,建议在应用全局维护单个 socket 实例
  4. 重连处理

    • Socket.IO 客户端内置自动重连
    • connect 事件后重新 emit join-token 确保重连后仍在频道内(或在握手时传 token 自动加入)
  5. CORS 配置

    • 服务端通过环境变量 FRONTEND_URL 控制允许的来源
    • 未设置时默认为 *(允许所有来源)
    • 生产环境建议设置为前端实际域名
  6. 错误处理

    • 监听 join-error 事件处理 token 无效情况
    • 监听 connect_error 处理网络连接失败
  7. 性能优化

    • 批量导入时会逐条广播,前端可根据 batch: true 标记做去抖处理
    • 建议在本地维护 KV 缓存,收到变更通知时增量更新而非全量刷新

环境变量配置

服务端需要配置以下环境变量:

# Socket.IO CORS 允许的来源
FRONTEND_URL=http://localhost:5173

# 服务器端口(可选,默认 3000
PORT=3000

常见问题

Q: 如何支持多个设备?

A: 对每个设备的 token 分别调用 join-token,或在连接时传入一个 token后续通过事件加入其他设备。

socket.emit('join-token', { token: token1 });
socket.emit('join-token', { token: token2 });

Q: 广播延迟有多大?

A: 通常在毫秒级取决于网络状况。WebSocket 连接建立后,广播几乎实时。

Q: Token 过期怎么办?

A: Token 本身不会过期,除非手动删除应用安装记录。如收到 join-error,检查 token 是否已被卸载。

Q: 可以在 Node.js 后端使用吗?

A: 可以,使用相同的 socket.io-client 包,接口完全一致。


更新日志

v1.1.0 (2025-10-25)

破坏性变更:

  • 移除直接使用 uuid 加入频道的接口(join-device / leave-device
  • 现在必须使用 KV token 通过 join-token 或握手 query 加入

新增:

  • leave-all 事件:离开所有已加入的频道
  • 握手时支持 tokenapptoken 两种参数名

改进:

  • 同一设备的不同 token 自动归入同一房间
  • 优化在线设备计数准确性

技术支持

如有问题,请查阅:

  • 服务端源码:utils/socket.js
  • KV 路由:routes/kv-token.js
  • 设备管理:routes/device.js

或提交 Issue 到项目仓库。