mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-08 22:53:10 +00:00
Compare commits
4 Commits
b20d8dab96
...
9f051885c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f051885c2 | ||
|
|
7d00d83dc9 | ||
|
|
bb61e6e6f5 | ||
|
|
02c0da037f |
276
API_AUTOAUTH.md
Normal file
276
API_AUTOAUTH.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# AutoAuth 和新增 Apps API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述了自动授权 (AutoAuth) 相关的 API 接口以及新增的应用管理接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Apps API 新增接口
|
||||||
|
|
||||||
|
### 1. 通过 namespace 和密码获取 token
|
||||||
|
|
||||||
|
**端点**: `POST /apps/auth/token`
|
||||||
|
|
||||||
|
**描述**: 通过设备的 namespace 和密码进行自动授权,创建新的 AppInstall 并返回 token。
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"namespace": "string (必填)",
|
||||||
|
"password": "string (可选,根据自动授权配置)",
|
||||||
|
"appId": "string (必填)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** (201 Created):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "string",
|
||||||
|
"deviceType": "string | null",
|
||||||
|
"isReadOnly": boolean,
|
||||||
|
"installedAt": "datetime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
- `400 Bad Request`: 缺少必填字段
|
||||||
|
- `404 Not Found`: 设备不存在或 namespace 不正确
|
||||||
|
- `401 Unauthorized`: 密码不正确或需要提供密码
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 该接口会查找匹配的 AutoAuth 配置
|
||||||
|
- 如果提供了密码,会验证密码是否匹配任何自动授权配置
|
||||||
|
- 如果没有提供密码,会查找无密码的自动授权配置
|
||||||
|
- 根据匹配的 AutoAuth 配置设置 `deviceType` 和 `isReadOnly` 属性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 设置学生名称
|
||||||
|
|
||||||
|
**端点**: `POST /apps/tokens/:token/set-student-name`
|
||||||
|
|
||||||
|
**描述**: 为学生类型的 token 设置名称(更新 note 字段)。
|
||||||
|
|
||||||
|
**URL 参数**:
|
||||||
|
- `token`: AppInstall 的 token
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string (必填)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "string",
|
||||||
|
"name": "string",
|
||||||
|
"updatedAt": "datetime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
- `400 Bad Request`: 缺少名称或名称不在学生列表中
|
||||||
|
- `403 Forbidden`: token 类型不是 student
|
||||||
|
- `404 Not Found`: token 不存在或设备未设置学生列表
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 只有 `deviceType` 为 `student` 的 token 才能使用此接口
|
||||||
|
- 会验证提供的名称是否存在于设备的 `classworks-list-main` 键值中
|
||||||
|
- 学生列表格式: `[{"id": 1, "name": "学生1"}, {"id": 2, "name": "学生2"}]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AutoAuth 管理 API
|
||||||
|
|
||||||
|
> 🔐 **所有 AutoAuth 管理接口都需要 JWT Account Token 认证**
|
||||||
|
>
|
||||||
|
> **重要**: 只有已绑定账户的设备才能使用这些接口。未绑定账户的设备无法管理 AutoAuth 配置。
|
||||||
|
>
|
||||||
|
> 通过 HTTP Headers 提供:
|
||||||
|
> - `Authorization`: `Bearer {jwt_token}` - 账户的 JWT Token
|
||||||
|
|
||||||
|
### 1. 获取设备的自动授权配置列表
|
||||||
|
|
||||||
|
**端点**: `GET /auto-auth/devices/:uuid/auth-configs`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||||
|
|
||||||
|
**URL 参数**:
|
||||||
|
- `uuid`: 设备的 UUID
|
||||||
|
|
||||||
|
**成功响应** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"configs": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"hasPassword": boolean,
|
||||||
|
"deviceType": "string | null",
|
||||||
|
"isReadOnly": boolean,
|
||||||
|
"createdAt": "datetime",
|
||||||
|
"updatedAt": "datetime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 返回的配置不包含实际的密码哈希值,只显示是否有密码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 创建自动授权配置
|
||||||
|
|
||||||
|
**端点**: `POST /auto-auth/devices/:uuid/auth-configs`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||||
|
|
||||||
|
**URL 参数**:
|
||||||
|
- `uuid`: 设备的 UUID
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "string (可选)",
|
||||||
|
"deviceType": "string (可选: teacher|student|classroom|parent)",
|
||||||
|
"isReadOnly": boolean (可选,默认 false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** (201 Created):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"config": {
|
||||||
|
"id": "string",
|
||||||
|
"hasPassword": boolean,
|
||||||
|
"deviceType": "string | null",
|
||||||
|
"isReadOnly": boolean,
|
||||||
|
"createdAt": "datetime"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
- `400 Bad Request`: 设备类型无效或密码配置已存在
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 同一设备的密码必须唯一(包括空密码)
|
||||||
|
- `deviceType` 必须是 `teacher`、`student`、`classroom`、`parent` 之一,或为空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 更新自动授权配置
|
||||||
|
|
||||||
|
**端点**: `PUT /auto-auth/devices/:uuid/auth-configs/:configId`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||||
|
|
||||||
|
**URL 参数**:
|
||||||
|
- `uuid`: 设备的 UUID
|
||||||
|
- `configId`: 自动授权配置的 ID
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "string (可选)",
|
||||||
|
"deviceType": "string (可选: teacher|student|classroom|parent)",
|
||||||
|
"isReadOnly": boolean (可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"config": {
|
||||||
|
"id": "string",
|
||||||
|
"hasPassword": boolean,
|
||||||
|
"deviceType": "string | null",
|
||||||
|
"isReadOnly": boolean,
|
||||||
|
"updatedAt": "datetime"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
- `400 Bad Request`: 设备类型无效或新密码与其他配置冲突
|
||||||
|
- `403 Forbidden`: 无权操作此配置
|
||||||
|
- `404 Not Found`: 配置不存在
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 只能更新属于当前设备的配置
|
||||||
|
- 更新密码时会检查是否与该设备的其他配置冲突
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 删除自动授权配置
|
||||||
|
|
||||||
|
**端点**: `DELETE /auto-auth/devices/:uuid/auth-configs/:configId`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||||
|
|
||||||
|
**URL 参数**:
|
||||||
|
- `uuid`: 设备的 UUID
|
||||||
|
- `configId`: 自动授权配置的 ID
|
||||||
|
|
||||||
|
**成功响应** (204 No Content):
|
||||||
|
- 无响应体
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
- `403 Forbidden`: 无权操作此配置
|
||||||
|
- `404 Not Found`: 配置不存在
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 只能删除属于当前设备的配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设备类型 (deviceType)
|
||||||
|
|
||||||
|
可选的设备类型值:
|
||||||
|
- `teacher`: 教师
|
||||||
|
- `student`: 学生
|
||||||
|
- `classroom`: 班级一体机
|
||||||
|
- `parent`: 家长
|
||||||
|
- `null`: 未指定类型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用流程示例
|
||||||
|
|
||||||
|
### 场景 1: 学生使用 namespace 登录
|
||||||
|
|
||||||
|
1. 学生输入班级的 namespace 和密码
|
||||||
|
2. 调用 `POST /apps/auth/token` 获取 token
|
||||||
|
3. 使用返回的 token 访问 KV 存储
|
||||||
|
4. 如果是学生类型,调用 `POST /apps/tokens/:token/set-student-name` 设置自己的名称
|
||||||
|
|
||||||
|
### 场景 2: 管理员配置自动授权
|
||||||
|
|
||||||
|
1. 管理员通过账户登录获取 JWT Token
|
||||||
|
2. 调用 `POST /auto-auth/devices/:uuid/auth-configs` 创建多个授权配置:
|
||||||
|
- 教师密码(deviceType: teacher, isReadOnly: false)
|
||||||
|
- 学生密码(deviceType: student, isReadOnly: false)
|
||||||
|
- 家长密码(deviceType: parent, isReadOnly: true)
|
||||||
|
3. 学生/教师/家长使用对应密码通过 namespace 登录
|
||||||
|
|
||||||
|
**注意**: 设备必须已绑定到管理员的账户才能配置 AutoAuth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **密码安全**: 所有密码都使用 bcrypt 进行哈希存储
|
||||||
|
2. **唯一性约束**:
|
||||||
|
- 同一设备的 namespace 必须唯一
|
||||||
|
- 同一设备的 AutoAuth 密码必须唯一(包括 null)
|
||||||
|
3. **级联删除**: 删除设备会级联删除所有相关的 AutoAuth 配置和 AppInstall 记录
|
||||||
|
4. **只读限制**: isReadOnly 为 true 的 token 在 KV 操作中会受到写入限制
|
||||||
|
5. **账户绑定要求**: 只有已绑定账户的设备才能管理 AutoAuth 配置,未绑定账户的设备无法使用 AutoAuth 管理接口
|
||||||
0
API_QUICK_REFERENCE.md
Normal file
0
API_QUICK_REFERENCE.md
Normal file
0
NEW_APIS_SUMMARY.md
Normal file
0
NEW_APIS_SUMMARY.md
Normal file
565
SOCKET_API.md
Normal file
565
SOCKET_API.md
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
# Socket.IO 实时频道接口文档(前端)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
ClassworksKV 提供基于 Socket.IO 的实时键值变更通知服务。前端使用 **KV token**(应用安装 token)加入频道,服务端会自动将 token 映射到对应设备的 uuid 房间。**同一设备的不同 token 会被归入同一频道**,因此多个客户端/应用可以共享实时更新。
|
||||||
|
|
||||||
|
**重要变更**:不再支持直接使用 uuid 加入频道,所有连接必须使用有效的 KV token。
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
前端项目安装 Socket.IO 客户端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install socket.io-client
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm add socket.io-client
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn add socket.io-client
|
||||||
|
```
|
||||||
|
|
||||||
|
## 连接服务器
|
||||||
|
|
||||||
|
### 基础连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SERVER_URL = 'http://localhost:3000'; // 替换为实际服务器地址
|
||||||
|
|
||||||
|
const socket: Socket = io(SERVER_URL, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 连接时自动加入频道(推荐)
|
||||||
|
|
||||||
|
在连接握手时通过 query 参数传入 token,自动加入对应设备频道:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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 加入频道
|
||||||
|
|
||||||
|
连接后按需加入频道。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
token?: string; // KV token(二选一)
|
||||||
|
apptoken?: string; // 或使用 apptoken 字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.emit('join-token', { token: '<your-kv-app-token>' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `leave-token` - 使用 token 离开频道
|
||||||
|
|
||||||
|
离开指定 token 对应的设备频道。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
token?: string;
|
||||||
|
apptoken?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.emit('leave-token', { token: '<your-kv-app-token>' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `leave-all` - 离开所有频道
|
||||||
|
|
||||||
|
断开前清理,离开该连接加入的所有设备频道。
|
||||||
|
|
||||||
|
**载荷:** 无
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.emit('leave-all');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 服务端发送的事件
|
||||||
|
|
||||||
|
#### `joined` - 加入成功通知
|
||||||
|
|
||||||
|
当成功加入频道后,服务端会发送此事件。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
by: 'token';
|
||||||
|
uuid: string; // 设备 uuid(用于调试/日志)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.on('joined', (info) => {
|
||||||
|
console.log(`成功加入设备 ${info.uuid} 的频道`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `join-error` - 加入失败通知
|
||||||
|
|
||||||
|
token 无效或查询失败时触发。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
by: 'token';
|
||||||
|
reason: 'invalid_token'; // 失败原因
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.on('join-error', (error) => {
|
||||||
|
console.error('Token 无效,无法加入频道');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `kv-key-changed` - 键值变更广播
|
||||||
|
|
||||||
|
当设备下的 KV 键被创建/更新/删除时,向该设备频道内所有连接广播此事件。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
uuid: string; // 设备 uuid
|
||||||
|
key: string; // 变更的键名
|
||||||
|
action: 'upsert' | 'delete'; // 操作类型
|
||||||
|
|
||||||
|
// 仅 action='upsert' 时存在:
|
||||||
|
created?: boolean; // 是否首次创建
|
||||||
|
updatedAt?: string; // 更新时间(ISO 8601)
|
||||||
|
batch?: boolean; // 是否为批量导入中的单条
|
||||||
|
|
||||||
|
// 仅 action='delete' 时存在:
|
||||||
|
deletedAt?: string; // 删除时间(ISO 8601)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
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} 已删除`);
|
||||||
|
// 从本地缓存移除
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**载荷示例:**
|
||||||
|
|
||||||
|
- 新建/更新键:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "device-001",
|
||||||
|
"key": "settings/theme",
|
||||||
|
"action": "upsert",
|
||||||
|
"created": false,
|
||||||
|
"updatedAt": "2025-10-25T08:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 删除键:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "device-001",
|
||||||
|
"key": "settings/theme",
|
||||||
|
"action": "delete",
|
||||||
|
"deletedAt": "2025-10-25T08:35:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 批量导入中的单条:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "device-001",
|
||||||
|
"key": "config/version",
|
||||||
|
"action": "upsert",
|
||||||
|
"created": true,
|
||||||
|
"updatedAt": "2025-10-25T08:40:00.000Z",
|
||||||
|
"batch": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `device-joined` - 设备频道连接数变化(可选)
|
||||||
|
|
||||||
|
当有新连接加入某设备频道时广播,用于显示在线人数。
|
||||||
|
|
||||||
|
**载荷格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
uuid: string; // 设备 uuid
|
||||||
|
connections: number; // 当前连接数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
socket.on('device-joined', (info) => {
|
||||||
|
console.log(`设备 ${info.uuid} 当前有 ${info.connections} 个连接`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整使用示例
|
||||||
|
|
||||||
|
### React Hook 封装
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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 封装
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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`
|
||||||
|
|
||||||
|
**响应格式:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true;
|
||||||
|
devices: Array<{
|
||||||
|
uuid: string; // 设备 uuid
|
||||||
|
connections: number; // 当前连接数
|
||||||
|
name: string | null; // 设备名称(若已设置)
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```typescript
|
||||||
|
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)
|
||||||
|
|
||||||
|
**响应包含:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
token: string; // 用于 KV 操作和加入频道
|
||||||
|
note: string | null;
|
||||||
|
name: string | null; // 等同于 note,便于展示
|
||||||
|
installedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 列出设备已有的 token
|
||||||
|
|
||||||
|
**接口:** `GET /apps/tokens?uuid=<device-uuid>`
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
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-all` 或 `leave-token` 清理连接
|
||||||
|
- 避免频繁创建/销毁连接,建议在应用全局维护单个 socket 实例
|
||||||
|
|
||||||
|
4. **重连处理**:
|
||||||
|
- Socket.IO 客户端内置自动重连
|
||||||
|
- 在 `connect` 事件后重新 emit `join-token` 确保重连后仍在频道内(或在握手时传 token 自动加入)
|
||||||
|
|
||||||
|
5. **CORS 配置**:
|
||||||
|
- 服务端通过环境变量 `FRONTEND_URL` 控制允许的来源
|
||||||
|
- 未设置时默认为 `*`(允许所有来源)
|
||||||
|
- 生产环境建议设置为前端实际域名
|
||||||
|
|
||||||
|
6. **错误处理**:
|
||||||
|
- 监听 `join-error` 事件处理 token 无效情况
|
||||||
|
- 监听 `connect_error` 处理网络连接失败
|
||||||
|
|
||||||
|
7. **性能优化**:
|
||||||
|
- 批量导入时会逐条广播,前端可根据 `batch: true` 标记做去抖处理
|
||||||
|
- 建议在本地维护 KV 缓存,收到变更通知时增量更新而非全量刷新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
服务端需要配置以下环境变量:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Socket.IO CORS 允许的来源
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# 服务器端口(可选,默认 3000)
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何支持多个设备?
|
||||||
|
|
||||||
|
A: 对每个设备的 token 分别调用 `join-token`,或在连接时传入一个 token,后续通过事件加入其他设备。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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` 事件:离开所有已加入的频道
|
||||||
|
- 握手时支持 `token` 和 `apptoken` 两种参数名
|
||||||
|
|
||||||
|
**改进:**
|
||||||
|
- 同一设备的不同 token 自动归入同一房间
|
||||||
|
- 优化在线设备计数准确性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有问题,请查阅:
|
||||||
|
- 服务端源码:`utils/socket.js`
|
||||||
|
- KV 路由:`routes/kv-token.js`
|
||||||
|
- 设备管理:`routes/device.js`
|
||||||
|
|
||||||
|
或提交 Issue 到项目仓库。
|
||||||
4
app.js
4
app.js
@ -20,6 +20,7 @@ import appsRouter from "./routes/apps.js";
|
|||||||
import deviceRouter from "./routes/device.js";
|
import deviceRouter from "./routes/device.js";
|
||||||
import deviceAuthRouter from "./routes/device-auth.js";
|
import deviceAuthRouter from "./routes/device-auth.js";
|
||||||
import accountsRouter from "./routes/accounts.js";
|
import accountsRouter from "./routes/accounts.js";
|
||||||
|
import autoAuthRouter from "./routes/auto-auth.js";
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
@ -87,6 +88,9 @@ app.get("/check", apiLimiter, (req, res) => {
|
|||||||
// Mount the Apps router with API rate limiting
|
// Mount the Apps router with API rate limiting
|
||||||
app.use("/apps", apiLimiter, appsRouter);
|
app.use("/apps", apiLimiter, appsRouter);
|
||||||
|
|
||||||
|
// Mount the Auto Auth router with API rate limiting
|
||||||
|
app.use("/auto-auth", apiLimiter, autoAuthRouter);
|
||||||
|
|
||||||
// Mount the Device router with API rate limiting
|
// Mount the Device router with API rate limiting
|
||||||
app.use("/devices", apiLimiter, deviceRouter);
|
app.use("/devices", apiLimiter, deviceRouter);
|
||||||
|
|
||||||
|
|||||||
4
bin/www
4
bin/www
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import app from '../app.js';
|
import app from '../app.js';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
|
import { initSocket } from '../utils/socket.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get port from environment and store in Express.
|
* Get port from environment and store in Express.
|
||||||
@ -20,6 +21,9 @@ app.set("port", port);
|
|||||||
|
|
||||||
var server = createServer(app);
|
var server = createServer(app);
|
||||||
|
|
||||||
|
// 初始化 Socket.IO 并绑定到 HTTP Server
|
||||||
|
initSocket(server);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen on provided port, on all network interfaces.
|
* Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const prisma = new PrismaClient();
|
|||||||
/**
|
/**
|
||||||
* 设备中间件 - 统一处理设备UUID
|
* 设备中间件 - 统一处理设备UUID
|
||||||
*
|
*
|
||||||
* 从req.params.deviceUuid、req.params.namespace或req.body.deviceUuid获取UUID
|
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
|
||||||
* 如果设备不存在则自动创建
|
* 如果设备不存在则自动创建
|
||||||
* 将设备信息存储到res.locals.device
|
* 将设备信息存储到res.locals.device
|
||||||
*
|
*
|
||||||
@ -25,7 +25,7 @@ const prisma = new PrismaClient();
|
|||||||
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
|
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
|
||||||
*/
|
*/
|
||||||
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceUuid = req.params.deviceUuid || req.params.namespace || req.body.deviceUuid;
|
const deviceUuid = req.params.deviceUuid || req.body.deviceUuid;
|
||||||
|
|
||||||
if (!deviceUuid) {
|
if (!deviceUuid) {
|
||||||
return next(errors.createError(400, "缺少设备UUID"));
|
return next(errors.createError(400, "缺少设备UUID"));
|
||||||
@ -65,7 +65,7 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
|||||||
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
|
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
|
||||||
*/
|
*/
|
||||||
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
|
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceUuid = req.params.deviceUuid || req.params.namespace;
|
const deviceUuid = req.params.deviceUuid ;
|
||||||
|
|
||||||
if (!deviceUuid) {
|
if (!deviceUuid) {
|
||||||
return next(errors.createError(400, "缺少设备UUID"));
|
return next(errors.createError(400, "缺少设备UUID"));
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const kvTokenAuth = async (req, res, next) => {
|
|||||||
res.locals.device = appInstall.device;
|
res.locals.device = appInstall.device;
|
||||||
res.locals.appInstall = appInstall;
|
res.locals.appInstall = appInstall;
|
||||||
res.locals.deviceId = appInstall.device.id;
|
res.locals.deviceId = appInstall.device.id;
|
||||||
|
res.locals.token = token;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@ -54,6 +54,13 @@ export const kvTokenAuth = async (req, res, next) => {
|
|||||||
* 3. Body: token 或 apptoken
|
* 3. Body: token 或 apptoken
|
||||||
*/
|
*/
|
||||||
function extractToken(req) {
|
function extractToken(req) {
|
||||||
|
// 优先从 Authorization header 提取 Bearer token(支持大小写)
|
||||||
|
const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||||
|
if (authHeader) {
|
||||||
|
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
req.headers["x-app-token"] ||
|
req.headers["x-app-token"] ||
|
||||||
req.query.token ||
|
req.query.token ||
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ClassworksKV",
|
"name": "ClassworksKV",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"prisma": "prisma generate",
|
|
||||||
"dev": "NODE_ENV=development nodemon node .bin/www",
|
"dev": "NODE_ENV=development nodemon node .bin/www",
|
||||||
"get-token": "node ./cli/get-token.js"
|
"get-token": "node ./cli/get-token.js"
|
||||||
},
|
},
|
||||||
@ -31,9 +30,10 @@
|
|||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "~1.10.0",
|
"morgan": "~1.10.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "6.16.2"
|
"prisma": "^6.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
pnpm-lock.yaml
generated
222
pnpm-lock.yaml
generated
@ -28,7 +28,7 @@ importers:
|
|||||||
version: 1.34.0
|
version: 1.34.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 6.16.2
|
specifier: 6.16.2
|
||||||
version: 6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)
|
version: 6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3)
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.0(debug@4.4.1)
|
version: 1.9.0(debug@4.4.1)
|
||||||
@ -71,13 +71,16 @@ importers:
|
|||||||
morgan:
|
morgan:
|
||||||
specifier: ~1.10.0
|
specifier: ~1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.1
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
prisma:
|
prisma:
|
||||||
specifier: 6.16.2
|
specifier: ^6.18.0
|
||||||
version: 6.16.2(typescript@5.8.3)
|
version: 6.18.0(typescript@5.8.3)
|
||||||
|
|
||||||
kv-admin:
|
kv-admin:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -899,23 +902,23 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@prisma/config@6.16.2':
|
'@prisma/config@6.18.0':
|
||||||
resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==}
|
resolution: {integrity: sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==}
|
||||||
|
|
||||||
'@prisma/debug@6.16.2':
|
'@prisma/debug@6.18.0':
|
||||||
resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==}
|
resolution: {integrity: sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==}
|
||||||
|
|
||||||
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43':
|
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f':
|
||||||
resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==}
|
resolution: {integrity: sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==}
|
||||||
|
|
||||||
'@prisma/engines@6.16.2':
|
'@prisma/engines@6.18.0':
|
||||||
resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==}
|
resolution: {integrity: sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==}
|
||||||
|
|
||||||
'@prisma/fetch-engine@6.16.2':
|
'@prisma/fetch-engine@6.18.0':
|
||||||
resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==}
|
resolution: {integrity: sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==}
|
||||||
|
|
||||||
'@prisma/get-platform@6.16.2':
|
'@prisma/get-platform@6.18.0':
|
||||||
resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==}
|
resolution: {integrity: sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==}
|
||||||
|
|
||||||
'@protobufjs/aspromise@1.1.2':
|
'@protobufjs/aspromise@1.1.2':
|
||||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||||
@ -1078,6 +1081,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2':
|
||||||
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0':
|
'@standard-schema/spec@1.0.0':
|
||||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
@ -1201,6 +1207,9 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@ -1347,6 +1356,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -1405,6 +1418,10 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64id@2.0.0:
|
||||||
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
||||||
basic-auth@2.0.1:
|
basic-auth@2.0.1:
|
||||||
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
|
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1547,6 +1564,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.1:
|
debug@4.4.1:
|
||||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@ -1596,8 +1622,8 @@ packages:
|
|||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
effect@3.16.12:
|
effect@3.18.4:
|
||||||
resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==}
|
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
|
||||||
|
|
||||||
ejs@3.1.10:
|
ejs@3.1.10:
|
||||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||||
@ -1615,6 +1641,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3:
|
||||||
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
engine.io@6.6.4:
|
||||||
|
resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@ -1833,10 +1867,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jiti@2.4.2:
|
|
||||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -2049,6 +2079,10 @@ packages:
|
|||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
negotiator@0.6.3:
|
||||||
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
negotiator@1.0.0:
|
negotiator@1.0.0:
|
||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -2168,8 +2202,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prisma@6.16.2:
|
prisma@6.18.0:
|
||||||
resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==}
|
resolution: {integrity: sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2300,6 +2334,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.5:
|
||||||
|
resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
|
||||||
|
|
||||||
|
socket.io-parser@4.2.4:
|
||||||
|
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io@4.8.1:
|
||||||
|
resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2523,6 +2568,18 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
ws@8.17.1:
|
||||||
|
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@ -3456,40 +3513,40 @@ snapshots:
|
|||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
|
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
|
||||||
|
|
||||||
'@prisma/client@6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)':
|
'@prisma/client@6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.16.2(typescript@5.8.3)
|
prisma: 6.18.0(typescript@5.8.3)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
'@prisma/config@6.16.2':
|
'@prisma/config@6.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 3.1.0
|
c12: 3.1.0
|
||||||
deepmerge-ts: 7.1.5
|
deepmerge-ts: 7.1.5
|
||||||
effect: 3.16.12
|
effect: 3.18.4
|
||||||
empathic: 2.0.0
|
empathic: 2.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
'@prisma/debug@6.16.2': {}
|
'@prisma/debug@6.18.0': {}
|
||||||
|
|
||||||
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': {}
|
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f': {}
|
||||||
|
|
||||||
'@prisma/engines@6.16.2':
|
'@prisma/engines@6.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 6.16.2
|
'@prisma/debug': 6.18.0
|
||||||
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
|
||||||
'@prisma/fetch-engine': 6.16.2
|
'@prisma/fetch-engine': 6.18.0
|
||||||
'@prisma/get-platform': 6.16.2
|
'@prisma/get-platform': 6.18.0
|
||||||
|
|
||||||
'@prisma/fetch-engine@6.16.2':
|
'@prisma/fetch-engine@6.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 6.16.2
|
'@prisma/debug': 6.18.0
|
||||||
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
|
||||||
'@prisma/get-platform': 6.16.2
|
'@prisma/get-platform': 6.18.0
|
||||||
|
|
||||||
'@prisma/get-platform@6.16.2':
|
'@prisma/get-platform@6.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 6.16.2
|
'@prisma/debug': 6.18.0
|
||||||
|
|
||||||
'@protobufjs/aspromise@1.1.2': {}
|
'@protobufjs/aspromise@1.1.2': {}
|
||||||
|
|
||||||
@ -3591,6 +3648,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.52.3':
|
'@rollup/rollup-win32-x64-msvc@4.52.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0': {}
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.17':
|
'@swc/helpers@0.5.17':
|
||||||
@ -3692,6 +3751,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.15.17
|
'@types/node': 22.15.17
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.6.1
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/memcached@2.2.10':
|
'@types/memcached@2.2.10':
|
||||||
@ -3713,7 +3776,6 @@ snapshots:
|
|||||||
'@types/node@24.6.1':
|
'@types/node@24.6.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.13.0
|
undici-types: 7.13.0
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@types/oracledb@6.5.2':
|
'@types/oracledb@6.5.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3904,6 +3966,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.22(typescript@5.8.3)
|
vue: 3.5.22(typescript@5.8.3)
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
dependencies:
|
||||||
|
mime-types: 2.1.35
|
||||||
|
negotiator: 0.6.3
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.1
|
mime-types: 3.0.1
|
||||||
@ -3955,6 +4022,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
basic-auth@2.0.1:
|
basic-auth@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.1.2
|
safe-buffer: 5.1.2
|
||||||
@ -4003,7 +4072,7 @@ snapshots:
|
|||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
exsolve: 1.0.7
|
exsolve: 1.0.7
|
||||||
giget: 2.0.0
|
giget: 2.0.0
|
||||||
jiti: 2.4.2
|
jiti: 2.6.1
|
||||||
ohash: 2.0.11
|
ohash: 2.0.11
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
perfect-debounce: 1.0.0
|
perfect-debounce: 1.0.0
|
||||||
@ -4099,6 +4168,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
debug@4.4.1:
|
debug@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@ -4131,7 +4204,7 @@ snapshots:
|
|||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
effect@3.16.12:
|
effect@3.18.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.0.0
|
'@standard-schema/spec': 1.0.0
|
||||||
fast-check: 3.23.2
|
fast-check: 3.23.2
|
||||||
@ -4146,6 +4219,24 @@ snapshots:
|
|||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3: {}
|
||||||
|
|
||||||
|
engine.io@6.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/cors': 2.8.19
|
||||||
|
'@types/node': 24.6.1
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cookie: 0.7.2
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.17.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@ -4412,8 +4503,6 @@ snapshots:
|
|||||||
filelist: 1.0.4
|
filelist: 1.0.4
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
|
|
||||||
jiti@2.4.2: {}
|
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
js-base64@3.7.7: {}
|
js-base64@3.7.7: {}
|
||||||
@ -4596,6 +4685,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@5.1.6: {}
|
nanoid@5.1.6: {}
|
||||||
|
|
||||||
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
node-addon-api@8.3.1: {}
|
node-addon-api@8.3.1: {}
|
||||||
@ -4692,10 +4783,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
|
|
||||||
prisma@6.16.2(typescript@5.8.3):
|
prisma@6.18.0(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/config': 6.16.2
|
'@prisma/config': 6.18.0
|
||||||
'@prisma/engines': 6.16.2
|
'@prisma/engines': 6.18.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -4906,6 +4997,36 @@ snapshots:
|
|||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.5:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.3.7
|
||||||
|
ws: 8.17.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-parser@4.2.4:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.3.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
socket.io@4.8.1:
|
||||||
|
dependencies:
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io: 6.6.4
|
||||||
|
socket.io-adapter: 2.5.5
|
||||||
|
socket.io-parser: 4.2.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
speakingurl@14.0.1: {}
|
speakingurl@14.0.1: {}
|
||||||
@ -4978,8 +5099,7 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.11.0: {}
|
undici-types@7.11.0: {}
|
||||||
|
|
||||||
undici-types@7.13.0:
|
undici-types@7.13.0: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
@ -5085,6 +5205,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.17.1: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[namespace]` on the table `Device` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `AppInstall` ADD COLUMN `deviceType` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `isReadOnly` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Device` ADD COLUMN `namespace` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- 将所有设备的 namespace 设置为对应的 uuid 值,避免唯一键冲突
|
||||||
|
UPDATE `Device` SET `namespace` = `uuid` WHERE `namespace` IS NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AutoAuth` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`deviceId` INTEGER NOT NULL,
|
||||||
|
`password` VARCHAR(191) NULL,
|
||||||
|
`deviceType` VARCHAR(191) NULL,
|
||||||
|
`isReadOnly` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `AutoAuth_deviceId_password_key`(`deviceId`, `password`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 为每个设备创建默认的 AutoAuth 记录,将 Device.password 复制为 AutoAuth.password
|
||||||
|
INSERT INTO `AutoAuth` (`id`, `deviceId`, `password`, `deviceType`, `isReadOnly`, `createdAt`, `updatedAt`)
|
||||||
|
SELECT
|
||||||
|
CONCAT('autoauth_', UUID()),
|
||||||
|
`id`,
|
||||||
|
`password`,
|
||||||
|
NULL,
|
||||||
|
false,
|
||||||
|
CURRENT_TIMESTAMP(3),
|
||||||
|
CURRENT_TIMESTAMP(3)
|
||||||
|
FROM `Device`;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `Device_namespace_key` ON `Device`(`namespace`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AutoAuth` ADD CONSTRAINT `AutoAuth_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -8,7 +8,7 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model KVStore {
|
model KVStore {
|
||||||
deviceId Int // 设备ID,作为namespace的一部分
|
deviceId Int
|
||||||
key String
|
key String
|
||||||
value Json
|
value Json
|
||||||
creatorIp String? @default("")
|
creatorIp String? @default("")
|
||||||
@ -48,22 +48,41 @@ model Device {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
password String?
|
password String?
|
||||||
passwordHint String?
|
passwordHint String?
|
||||||
|
namespace String? @unique // 用户自定义的唯一命名空间
|
||||||
|
|
||||||
// 关联关系
|
// 关联关系
|
||||||
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
||||||
appInstalls AppInstall[]
|
appInstalls AppInstall[]
|
||||||
kvStore KVStore[] // 设备相关的KV存储
|
kvStore KVStore[] // 设备相关的KV存储
|
||||||
|
autoAuths AutoAuth[] // 自动授权配置
|
||||||
}
|
}
|
||||||
|
|
||||||
model AppInstall {
|
model AppInstall {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
deviceId Int // 关联的设备ID
|
deviceId Int // 关联的设备ID
|
||||||
appId String // 应用ID (SHA256 hash)
|
appId String // 应用ID (SHA256 hash)
|
||||||
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限
|
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限
|
||||||
note String? // 安装备注
|
note String? // 安装备注
|
||||||
|
isReadOnly Boolean @default(false) // 是否只读
|
||||||
|
deviceType String? // 设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长)
|
||||||
installedAt DateTime @default(now())
|
installedAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// 关联关系
|
// 关联关系
|
||||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AutoAuth {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
deviceId Int // 关联的设备ID
|
||||||
|
password String? // 配置密码,可以为空
|
||||||
|
deviceType String? // 自动设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长)
|
||||||
|
isReadOnly Boolean @default(false) // 是否只读
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([deviceId, password]) // 同一设备的密码必须唯一
|
||||||
|
}
|
||||||
|
|||||||
218
routes/apps.js
218
routes/apps.js
@ -2,9 +2,11 @@ import { Router } from "express";
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||||
|
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
|
import { verifyDevicePassword } from "../utils/crypto.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -75,6 +77,7 @@ router.post(
|
|||||||
appId: installation.appId,
|
appId: installation.appId,
|
||||||
token: installation.token,
|
token: installation.token,
|
||||||
note: installation.note,
|
note: installation.note,
|
||||||
|
name: installation.note, // 备注同时作为名称返回
|
||||||
installedAt: installation.createdAt,
|
installedAt: installation.createdAt,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -146,6 +149,7 @@ router.get(
|
|||||||
appId: install.appId,
|
appId: install.appId,
|
||||||
installedAt: install.installedAt,
|
installedAt: install.installedAt,
|
||||||
note: install.note,
|
note: install.note,
|
||||||
|
name: install.note, // 备注同时作为名称返回
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@ -156,4 +160,218 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /apps/auth/token
|
||||||
|
* 通过 namespace 和密码获取 token (自动授权)
|
||||||
|
* Body: { namespace: string, password: string, appId: string }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/auth/token",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { namespace, password, appId } = req.body;
|
||||||
|
|
||||||
|
if (!namespace) {
|
||||||
|
return next(errors.createError(400, "需要提供 namespace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appId) {
|
||||||
|
return next(errors.createError(400, "需要提供 appId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 namespace 查找设备
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { namespace },
|
||||||
|
include: {
|
||||||
|
autoAuths: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在或 namespace 不正确"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找匹配的自动授权配置
|
||||||
|
let matchedAutoAuth = null;
|
||||||
|
|
||||||
|
// 如果提供了密码,查找匹配密码的自动授权
|
||||||
|
if (password) {
|
||||||
|
// 首先尝试直接匹配明文密码
|
||||||
|
matchedAutoAuth = device.autoAuths.find(auth => auth.password === password);
|
||||||
|
|
||||||
|
// 如果没有匹配到,尝试验证哈希密码(向后兼容)
|
||||||
|
if (!matchedAutoAuth) {
|
||||||
|
for (const autoAuth of device.autoAuths) {
|
||||||
|
if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头
|
||||||
|
try {
|
||||||
|
if (await verifyDevicePassword(password, autoAuth.password)) {
|
||||||
|
matchedAutoAuth = autoAuth;
|
||||||
|
|
||||||
|
// 自动迁移:将哈希密码更新为明文密码
|
||||||
|
await prisma.autoAuth.update({
|
||||||
|
where: { id: autoAuth.id },
|
||||||
|
data: { password: password }, // 保存明文密码
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 如果验证失败,继续尝试下一个
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedAutoAuth) {
|
||||||
|
return next(errors.createError(401, "密码不正确"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有提供密码,查找密码为空的自动授权
|
||||||
|
matchedAutoAuth = device.autoAuths.find(auth => !auth.password);
|
||||||
|
|
||||||
|
if (!matchedAutoAuth) {
|
||||||
|
return next(errors.createError(401, "需要提供密码"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据自动授权配置创建 AppInstall
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
const installation = await prisma.appInstall.create({
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
appId: appId,
|
||||||
|
token,
|
||||||
|
note: null,
|
||||||
|
isReadOnly: matchedAutoAuth.isReadOnly,
|
||||||
|
deviceType: matchedAutoAuth.deviceType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
token: installation.token,
|
||||||
|
deviceType: installation.deviceType,
|
||||||
|
isReadOnly: installation.isReadOnly,
|
||||||
|
installedAt: installation.installedAt,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /apps/tokens/:token/set-student-name
|
||||||
|
* 设置学生名称 (仅限学生类型的 token)
|
||||||
|
* Body: { name: string }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/tokens/:token/set-student-name",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { token } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return next(errors.createError(400, "需要提供学生名称"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找 token 对应的应用安装记录
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
device: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appInstall) {
|
||||||
|
return next(errors.createError(404, "Token 不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token 类型是否为 student
|
||||||
|
if (appInstall.deviceType !== 'student') {
|
||||||
|
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取设备的 classworks-list-main 键值
|
||||||
|
const kvRecord = await prisma.kVStore.findUnique({
|
||||||
|
where: {
|
||||||
|
deviceId_key: {
|
||||||
|
deviceId: appInstall.deviceId,
|
||||||
|
key: 'classworks-list-main',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!kvRecord) {
|
||||||
|
return next(errors.createError(404, "设备未设置学生列表"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析学生列表
|
||||||
|
let studentList;
|
||||||
|
try {
|
||||||
|
studentList = kvRecord.value;
|
||||||
|
if (!Array.isArray(studentList)) {
|
||||||
|
return next(errors.createError(500, "学生列表格式错误"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return next(errors.createError(500, "无法解析学生列表"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证名称是否在学生列表中
|
||||||
|
const studentExists = studentList.some(student => student.name === name);
|
||||||
|
|
||||||
|
if (!studentExists) {
|
||||||
|
return next(errors.createError(400, "该名称不在学生列表中"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 AppInstall 的 note 字段
|
||||||
|
const updatedInstall = await prisma.appInstall.update({
|
||||||
|
where: { id: appInstall.id },
|
||||||
|
data: { note: name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
token: updatedInstall.token,
|
||||||
|
name: updatedInstall.note,
|
||||||
|
deviceType: updatedInstall.deviceType,
|
||||||
|
updatedAt: updatedInstall.updatedAt,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /apps/tokens/:token/note
|
||||||
|
* 更新令牌的备注信息
|
||||||
|
* Body: { note: string }
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/tokens/:token/note",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { token } = req.params;
|
||||||
|
const { note } = req.body;
|
||||||
|
|
||||||
|
// 查找 token 对应的应用安装记录
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appInstall) {
|
||||||
|
return next(errors.createError(404, "Token 不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 AppInstall 的 note 字段
|
||||||
|
const updatedInstall = await prisma.appInstall.update({
|
||||||
|
where: { id: appInstall.id },
|
||||||
|
data: { note: note || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
token: updatedInstall.token,
|
||||||
|
note: updatedInstall.note,
|
||||||
|
updatedAt: updatedInstall.updatedAt,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
343
routes/auto-auth.js
Normal file
343
routes/auto-auth.js
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auto-auth/devices/:uuid/auth-configs
|
||||||
|
* 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/devices/:uuid/auth-configs",
|
||||||
|
jwtAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
const account = res.locals.account;
|
||||||
|
|
||||||
|
// 查找设备并验证是否属于当前账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备是否绑定到当前账户
|
||||||
|
if (!device.accountId || device.accountId !== account.id) {
|
||||||
|
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoAuths = await prisma.autoAuth.findMany({
|
||||||
|
where: { deviceId: device.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回配置,智能处理密码显示
|
||||||
|
const configs = autoAuths.map(auth => {
|
||||||
|
// 检查是否是 bcrypt 哈希密码
|
||||||
|
const isHashedPassword = auth.password && auth.password.startsWith('$2');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: auth.id,
|
||||||
|
password: isHashedPassword ? null : auth.password, // 哈希密码不返回
|
||||||
|
isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码
|
||||||
|
deviceType: auth.deviceType,
|
||||||
|
isReadOnly: auth.isReadOnly,
|
||||||
|
createdAt: auth.createdAt,
|
||||||
|
updatedAt: auth.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
configs,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auto-auth/devices/:uuid/auth-configs
|
||||||
|
* 创建新的自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
|
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/devices/:uuid/auth-configs",
|
||||||
|
jwtAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
const account = res.locals.account;
|
||||||
|
const { password, deviceType, isReadOnly } = req.body;
|
||||||
|
|
||||||
|
// 查找设备并验证是否属于当前账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备是否绑定到当前账户
|
||||||
|
if (!device.accountId || device.accountId !== account.id) {
|
||||||
|
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 deviceType 如果提供的话
|
||||||
|
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||||
|
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||||
|
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化密码:空字符串视为 null
|
||||||
|
const plainPassword = (password !== undefined && password !== '') ? password : null;
|
||||||
|
|
||||||
|
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
||||||
|
const allAuths = await prisma.autoAuth.findMany({
|
||||||
|
where: { deviceId: device.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
||||||
|
|
||||||
|
if (existingAuth) {
|
||||||
|
return next(errors.createError(400, "该密码的自动授权配置已存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的自动授权配置(密码明文存储)
|
||||||
|
const autoAuth = await prisma.autoAuth.create({
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
password: plainPassword,
|
||||||
|
deviceType: deviceType || null,
|
||||||
|
isReadOnly: isReadOnly || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
config: {
|
||||||
|
id: autoAuth.id,
|
||||||
|
password: autoAuth.password, // 返回明文密码
|
||||||
|
deviceType: autoAuth.deviceType,
|
||||||
|
isReadOnly: autoAuth.isReadOnly,
|
||||||
|
createdAt: autoAuth.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);/**
|
||||||
|
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
||||||
|
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
|
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/devices/:uuid/auth-configs/:configId",
|
||||||
|
jwtAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid, configId } = req.params;
|
||||||
|
const account = res.locals.account;
|
||||||
|
const { password, deviceType, isReadOnly } = req.body;
|
||||||
|
|
||||||
|
// 查找设备并验证是否属于当前账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备是否绑定到当前账户
|
||||||
|
if (!device.accountId || device.accountId !== account.id) {
|
||||||
|
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找自动授权配置
|
||||||
|
const autoAuth = await prisma.autoAuth.findUnique({
|
||||||
|
where: { id: configId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!autoAuth) {
|
||||||
|
return next(errors.createError(404, "自动授权配置不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保配置属于当前设备
|
||||||
|
if (autoAuth.deviceId !== device.id) {
|
||||||
|
return next(errors.createError(403, "无权操作此配置"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 deviceType
|
||||||
|
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||||
|
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||||
|
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备更新数据
|
||||||
|
const updateData = {};
|
||||||
|
|
||||||
|
if (password !== undefined) {
|
||||||
|
// 规范化密码:空字符串视为 null
|
||||||
|
const plainPassword = (password !== '') ? password : null;
|
||||||
|
|
||||||
|
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
||||||
|
const allAuths = await prisma.autoAuth.findMany({
|
||||||
|
where: { deviceId: device.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflictAuth = allAuths.find(auth =>
|
||||||
|
auth.id !== configId && auth.password === plainPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conflictAuth) {
|
||||||
|
return next(errors.createError(400, "该密码已被其他配置使用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.password = plainPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceType !== undefined) {
|
||||||
|
updateData.deviceType = deviceType || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnly !== undefined) {
|
||||||
|
updateData.isReadOnly = isReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
const updatedAuth = await prisma.autoAuth.update({
|
||||||
|
where: { id: configId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
config: {
|
||||||
|
id: updatedAuth.id,
|
||||||
|
password: updatedAuth.password, // 返回明文密码
|
||||||
|
deviceType: updatedAuth.deviceType,
|
||||||
|
isReadOnly: updatedAuth.isReadOnly,
|
||||||
|
updatedAt: updatedAuth.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /auto-auth/devices/:uuid/auth-configs/:configId
|
||||||
|
* 删除自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/devices/:uuid/auth-configs/:configId",
|
||||||
|
jwtAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid, configId } = req.params;
|
||||||
|
const account = res.locals.account;
|
||||||
|
|
||||||
|
// 查找设备并验证是否属于当前账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备是否绑定到当前账户
|
||||||
|
if (!device.accountId || device.accountId !== account.id) {
|
||||||
|
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找自动授权配置
|
||||||
|
const autoAuth = await prisma.autoAuth.findUnique({
|
||||||
|
where: { id: configId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!autoAuth) {
|
||||||
|
return next(errors.createError(404, "自动授权配置不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保配置属于当前设备
|
||||||
|
if (autoAuth.deviceId !== device.id) {
|
||||||
|
return next(errors.createError(403, "无权操作此配置"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除配置
|
||||||
|
await prisma.autoAuth.delete({
|
||||||
|
where: { id: configId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(204).end();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /auto-auth/devices/:uuid/namespace
|
||||||
|
* 修改设备的 namespace (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
|
* Body: { namespace: string }
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/devices/:uuid/namespace",
|
||||||
|
jwtAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
const account = res.locals.account;
|
||||||
|
const { namespace } = req.body;
|
||||||
|
|
||||||
|
if (!namespace) {
|
||||||
|
return next(errors.createError(400, "需要提供 namespace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 namespace:去除首尾空格
|
||||||
|
const trimmedNamespace = namespace.trim();
|
||||||
|
|
||||||
|
if (!trimmedNamespace) {
|
||||||
|
return next(errors.createError(400, "namespace 不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找设备并验证是否属于当前账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备是否绑定到当前账户
|
||||||
|
if (!device.accountId || device.accountId !== account.id) {
|
||||||
|
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查新的 namespace 是否已被其他设备使用
|
||||||
|
if (device.namespace !== trimmedNamespace) {
|
||||||
|
const existingDevice = await prisma.device.findUnique({
|
||||||
|
where: { namespace: trimmedNamespace },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDevice) {
|
||||||
|
return next(errors.createError(409, "该 namespace 已被其他设备使用"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新设备的 namespace
|
||||||
|
const updatedDevice = await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { namespace: trimmedNamespace },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
id: updatedDevice.id,
|
||||||
|
uuid: updatedDevice.uuid,
|
||||||
|
name: updatedDevice.name,
|
||||||
|
namespace: updatedDevice.namespace,
|
||||||
|
updatedAt: updatedDevice.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -5,6 +5,7 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
||||||
|
import { getOnlineDevices } from "../utils/socket.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ const prisma = new PrismaClient();
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid, deviceName } = req.body;
|
const { uuid, deviceName, namespace } = req.body;
|
||||||
|
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return next(errors.createError(400, "设备UUID是必需的"));
|
return next(errors.createError(400, "设备UUID是必需的"));
|
||||||
@ -34,11 +35,24 @@ router.post(
|
|||||||
return next(errors.createError(409, "设备UUID已存在"));
|
return next(errors.createError(409, "设备UUID已存在"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 namespace:如果没有提供,则使用 uuid
|
||||||
|
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
||||||
|
|
||||||
|
// 检查 namespace 是否已被使用
|
||||||
|
const existingNamespace = await prisma.device.findUnique({
|
||||||
|
where: { namespace: deviceNamespace },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingNamespace) {
|
||||||
|
return next(errors.createError(409, "该 namespace 已被使用"));
|
||||||
|
}
|
||||||
|
|
||||||
// 创建设备
|
// 创建设备
|
||||||
const device = await prisma.device.create({
|
const device = await prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
uuid,
|
uuid,
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
|
namespace: deviceNamespace,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,6 +62,7 @@ router.post(
|
|||||||
id: device.id,
|
id: device.id,
|
||||||
uuid: device.uuid,
|
uuid: device.uuid,
|
||||||
name: device.name,
|
name: device.name,
|
||||||
|
namespace: device.namespace,
|
||||||
createdAt: device.createdAt,
|
createdAt: device.createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -96,6 +111,7 @@ router.get(
|
|||||||
avatarUrl: device.account.avatarUrl,
|
avatarUrl: device.account.avatarUrl,
|
||||||
} : null,
|
} : null,
|
||||||
isBoundToAccount: !!device.account,
|
isBoundToAccount: !!device.account,
|
||||||
|
namespace: device.namespace,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);/**
|
);/**
|
||||||
@ -322,4 +338,36 @@ router.delete(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /devices/online
|
||||||
|
* 查询在线设备(WebSocket 已连接)
|
||||||
|
* 返回:[{ uuid, connections, name? }]
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/online",
|
||||||
|
errors.catchAsync(async (req, res) => {
|
||||||
|
const list = getOnlineDevices();
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return res.json({ success: true, devices: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补充设备名称
|
||||||
|
const uuids = list.map((x) => x.uuid);
|
||||||
|
const rows = await prisma.device.findMany({
|
||||||
|
where: { uuid: { in: uuids } },
|
||||||
|
select: { uuid: true, name: true },
|
||||||
|
});
|
||||||
|
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
|
||||||
|
|
||||||
|
const devices = list.map((x) => ({
|
||||||
|
uuid: x.uuid,
|
||||||
|
connections: x.connections,
|
||||||
|
name: nameMap.get(x.uuid) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, devices });
|
||||||
|
})
|
||||||
|
);
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
import kvStore from "../utils/kvStore.js";
|
import kvStore from "../utils/kvStore.js";
|
||||||
|
import { broadcastKeyChanged } from "../utils/socket.js";
|
||||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
@ -55,6 +56,54 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /_token
|
||||||
|
* 获取当前 KV Token 的详细信息(类型、备注等)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/_token",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const token = res.locals.token;
|
||||||
|
const deviceId = res.locals.deviceId;
|
||||||
|
|
||||||
|
// 查找当前 token 对应的应用安装记录
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
device: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
name: true,
|
||||||
|
namespace: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appInstall) {
|
||||||
|
return next(errors.createError(404, "Token 信息不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
token: appInstall.token,
|
||||||
|
appId: appInstall.appId,
|
||||||
|
deviceType: appInstall.deviceType,
|
||||||
|
isReadOnly: appInstall.isReadOnly,
|
||||||
|
note: appInstall.note,
|
||||||
|
installedAt: appInstall.installedAt,
|
||||||
|
updatedAt: appInstall.updatedAt,
|
||||||
|
device: {
|
||||||
|
id: appInstall.device.id,
|
||||||
|
uuid: appInstall.device.uuid,
|
||||||
|
name: appInstall.device.name,
|
||||||
|
namespace: appInstall.device.namespace,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /_keys
|
* GET /_keys
|
||||||
* 获取当前token对应设备的键名列表(分页,不包括内容)
|
* 获取当前token对应设备的键名列表(分页,不包括内容)
|
||||||
@ -199,6 +248,11 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/_batchimport",
|
"/_batchimport",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
// 检查token是否为只读
|
||||||
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
|
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||||
|
}
|
||||||
|
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
@ -219,7 +273,7 @@ router.post(
|
|||||||
req.connection.socket?.remoteAddress ||
|
req.connection.socket?.remoteAddress ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
const errorList = [];
|
const errorList = [];
|
||||||
|
|
||||||
// 批量处理所有键值对
|
// 批量处理所有键值对
|
||||||
@ -230,6 +284,17 @@ router.post(
|
|||||||
key: result.key,
|
key: result.key,
|
||||||
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
||||||
});
|
});
|
||||||
|
// 广播每个键的变更
|
||||||
|
const uuid = res.locals.device?.uuid;
|
||||||
|
if (uuid) {
|
||||||
|
broadcastKeyChanged(uuid, {
|
||||||
|
key: result.key,
|
||||||
|
action: "upsert",
|
||||||
|
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
batch: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorList.push({
|
errorList.push({
|
||||||
key,
|
key,
|
||||||
@ -256,6 +321,11 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:key",
|
"/:key",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
// 检查token是否为只读
|
||||||
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
|
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||||
|
}
|
||||||
|
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { key } = req.params;
|
const { key } = req.params;
|
||||||
const value = req.body;
|
const value = req.body;
|
||||||
@ -273,6 +343,18 @@ router.post(
|
|||||||
"";
|
"";
|
||||||
|
|
||||||
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
|
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
|
||||||
|
|
||||||
|
// 广播单个键的变更
|
||||||
|
const uuid = res.locals.device?.uuid;
|
||||||
|
if (uuid) {
|
||||||
|
broadcastKeyChanged(uuid, {
|
||||||
|
key: result.key,
|
||||||
|
action: "upsert",
|
||||||
|
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
deviceId: result.deviceId,
|
deviceId: result.deviceId,
|
||||||
key: result.key,
|
key: result.key,
|
||||||
@ -289,6 +371,11 @@ router.post(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:key",
|
"/:key",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
// 检查token是否为只读
|
||||||
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
|
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||||
|
}
|
||||||
|
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { key } = req.params;
|
const { key } = req.params;
|
||||||
|
|
||||||
@ -300,6 +387,16 @@ router.delete(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 广播删除
|
||||||
|
const uuid = res.locals.device?.uuid;
|
||||||
|
if (uuid) {
|
||||||
|
broadcastKeyChanged(uuid, {
|
||||||
|
key,
|
||||||
|
action: "delete",
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 204状态码表示成功但无内容返回
|
// 204状态码表示成功但无内容返回
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
})
|
})
|
||||||
|
|||||||
206
utils/socket.js
Normal file
206
utils/socket.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Socket.IO 管理与事件转发
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 初始化 Socket.IO 并与 HTTP Server 绑定
|
||||||
|
* - 前端使用 KV token 加入设备频道(自动映射到对应设备 uuid 房间)
|
||||||
|
* - 同一设备的不同 token 会被归入同一频道
|
||||||
|
* - 维护在线设备列表
|
||||||
|
* - 提供广播 KV 键变更的工具方法
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
// Socket.IO 单例实例
|
||||||
|
let io = null;
|
||||||
|
|
||||||
|
// 在线设备映射:uuid -> Set<socketId>
|
||||||
|
const onlineMap = new Map();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 Socket.IO
|
||||||
|
* @param {import('http').Server} server HTTP Server 实例
|
||||||
|
*/
|
||||||
|
export function initSocket(server) {
|
||||||
|
if (io) return io;
|
||||||
|
|
||||||
|
const allowOrigin = process.env.FRONTEND_URL || "*";
|
||||||
|
|
||||||
|
io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: allowOrigin,
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
// 初始化每个连接所加入的设备房间集合
|
||||||
|
socket.data.deviceUuids = new Set();
|
||||||
|
|
||||||
|
// 仅允许通过 query.token/apptoken 加入
|
||||||
|
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
||||||
|
if (qToken && typeof qToken === "string") {
|
||||||
|
joinByToken(socket, qToken).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端使用 KV token 加入房间
|
||||||
|
socket.on("join-token", (payload) => {
|
||||||
|
const token = payload?.token || payload?.apptoken;
|
||||||
|
if (typeof token === "string" && token.length > 0) {
|
||||||
|
joinByToken(socket, token).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 客户端使用 token 离开房间
|
||||||
|
socket.on("leave-token", async (payload) => {
|
||||||
|
try {
|
||||||
|
const token = payload?.token || payload?.apptoken;
|
||||||
|
if (typeof token !== "string" || token.length === 0) return;
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { device: { select: { uuid: true } } },
|
||||||
|
});
|
||||||
|
const uuid = appInstall?.device?.uuid;
|
||||||
|
if (uuid) leaveDeviceRoom(socket, uuid);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 离开所有已加入的设备房间
|
||||||
|
socket.on("leave-all", () => {
|
||||||
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
|
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 聊天室:发送文本消息到加入的设备频道
|
||||||
|
socket.on("chat:send", (data) => {
|
||||||
|
try {
|
||||||
|
const text = typeof data === "string" ? data : data?.text;
|
||||||
|
if (typeof text !== "string") return;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// 限制消息最大长度,避免滥用
|
||||||
|
const MAX_LEN = 2000;
|
||||||
|
const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed;
|
||||||
|
|
||||||
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
|
if (uuids.length === 0) return;
|
||||||
|
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
const payload = { text: safeText, at, senderId: socket.id };
|
||||||
|
|
||||||
|
uuids.forEach((uuid) => {
|
||||||
|
io.to(uuid).emit("chat:message", { uuid, ...payload });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("chat:send error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
|
uuids.forEach((u) => removeOnline(u, socket.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 Socket.IO 实例 */
|
||||||
|
export function getIO() {
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让 socket 加入设备房间并记录在线
|
||||||
|
* @param {import('socket.io').Socket} socket
|
||||||
|
* @param {string} uuid
|
||||||
|
*/
|
||||||
|
function joinDeviceRoom(socket, uuid) {
|
||||||
|
socket.join(uuid);
|
||||||
|
if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set();
|
||||||
|
socket.data.deviceUuids.add(uuid);
|
||||||
|
// 记录在线
|
||||||
|
const set = onlineMap.get(uuid) || new Set();
|
||||||
|
set.add(socket.id);
|
||||||
|
onlineMap.set(uuid, set);
|
||||||
|
// 可选:通知加入
|
||||||
|
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 让 socket 离开设备房间并更新在线表
|
||||||
|
* @param {import('socket.io').Socket} socket
|
||||||
|
* @param {string} uuid
|
||||||
|
*/
|
||||||
|
function leaveDeviceRoom(socket, uuid) {
|
||||||
|
socket.leave(uuid);
|
||||||
|
if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid);
|
||||||
|
removeOnline(uuid, socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOnline(uuid, socketId) {
|
||||||
|
const set = onlineMap.get(uuid);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(socketId);
|
||||||
|
if (set.size === 0) {
|
||||||
|
onlineMap.delete(uuid);
|
||||||
|
} else {
|
||||||
|
onlineMap.set(uuid, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播某设备下 KV 键已变更
|
||||||
|
* @param {string} uuid 设备 uuid
|
||||||
|
* @param {object} payload { key, action: 'upsert'|'delete'|'batch', updatedAt?, created? }
|
||||||
|
*/
|
||||||
|
export function broadcastKeyChanged(uuid, payload) {
|
||||||
|
if (!io || !uuid) return;
|
||||||
|
io.to(uuid).emit("kv-key-changed", { uuid, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取在线设备列表
|
||||||
|
* @returns {Array<{uuid:string, connections:number}>}
|
||||||
|
*/
|
||||||
|
export function getOnlineDevices() {
|
||||||
|
const list = [];
|
||||||
|
for (const [uuid, set] of onlineMap.entries()) {
|
||||||
|
list.push({ uuid, connections: set.size });
|
||||||
|
}
|
||||||
|
// 默认按连接数降序
|
||||||
|
return list.sort((a, b) => b.connections - a.connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initSocket,
|
||||||
|
getIO,
|
||||||
|
broadcastKeyChanged,
|
||||||
|
getOnlineDevices,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 KV token 让 socket 加入对应设备的房间
|
||||||
|
* @param {import('socket.io').Socket} socket
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
async function joinByToken(socket, token) {
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { device: { select: { uuid: true } } },
|
||||||
|
});
|
||||||
|
const uuid = appInstall?.device?.uuid;
|
||||||
|
if (uuid) {
|
||||||
|
joinDeviceRoom(socket, uuid);
|
||||||
|
// 可选:回执
|
||||||
|
socket.emit("joined", { by: "token", uuid });
|
||||||
|
} else {
|
||||||
|
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user