mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2026-02-03 23:23:10 +00:00
迁移到postgresql
This commit is contained in:
parent
fddaf0dffd
commit
ce3c8ee41f
4
.gitignore
vendored
4
.gitignore
vendored
@ -145,3 +145,7 @@ dist
|
||||
|
||||
prisma/database/data
|
||||
data/
|
||||
|
||||
/generated/prisma
|
||||
|
||||
/generated/prisma
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/FixClassworksKV.iml
generated
12
.idea/FixClassworksKV.iml
generated
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/FixClassworksKV.iml" filepath="$PROJECT_DIR$/.idea/FixClassworksKV.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
276
API_AUTOAUTH.md
276
API_AUTOAUTH.md
@ -1,276 +0,0 @@
|
||||
# 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 管理接口
|
||||
@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
# Refresh Token系统迁移检查清单
|
||||
|
||||
## 🔧 服务端迁移
|
||||
|
||||
### 数据库
|
||||
- [ ] 运行Prisma迁移: `npx prisma migrate dev --name add_refresh_token_system`
|
||||
- [ ] 验证Account表新增字段: refreshToken, refreshTokenExpiry, tokenVersion
|
||||
|
||||
### 环境配置
|
||||
- [ ] 添加 `ACCESS_TOKEN_EXPIRES_IN=15m`
|
||||
- [ ] 添加 `REFRESH_TOKEN_EXPIRES_IN=7d`
|
||||
- [ ] 添加 `REFRESH_TOKEN_SECRET=your-refresh-token-secret`
|
||||
- [ ] (可选)配置RSA密钥对
|
||||
|
||||
### 代码验证
|
||||
- [ ] `utils/tokenManager.js` 文件已创建
|
||||
- [ ] `utils/jwt.js` 已更新(保持向后兼容)
|
||||
- [ ] `middleware/jwt-auth.js` 已升级
|
||||
- [ ] `routes/accounts.js` 新增refresh相关端点
|
||||
|
||||
## 🖥️ 前端迁移
|
||||
|
||||
### OAuth回调处理
|
||||
- [ ] 更新回调URL参数解析(支持access_token和refresh_token)
|
||||
- [ ] 保持对旧版token参数的兼容性
|
||||
- [ ] 实现TokenManager类
|
||||
|
||||
### Token管理
|
||||
- [ ] 实现Token刷新逻辑
|
||||
- [ ] 添加请求拦截器检查X-New-Access-Token响应头
|
||||
- [ ] 实现401错误自动重试机制
|
||||
- [ ] 添加登出功能(单设备/全设备)
|
||||
|
||||
### 存储策略
|
||||
- [ ] Access Token存储(localStorage/sessionStorage)
|
||||
- [ ] Refresh Token安全存储
|
||||
- [ ] 实现Token清理逻辑
|
||||
|
||||
## 🧪 测试验证
|
||||
|
||||
### 功能测试
|
||||
- [ ] OAuth登录流程测试
|
||||
- [ ] Token自动刷新测试
|
||||
- [ ] 手动refresh接口测试
|
||||
- [ ] 登出功能测试(单设备)
|
||||
- [ ] 登出功能测试(全设备)
|
||||
- [ ] Token信息查看测试
|
||||
|
||||
### 兼容性测试
|
||||
- [ ] 旧版JWT token仍然有效
|
||||
- [ ] 新旧token混合使用场景
|
||||
- [ ] API向后兼容性验证
|
||||
|
||||
### 错误处理测试
|
||||
- [ ] 过期token处理
|
||||
- [ ] 无效refresh token处理
|
||||
- [ ] 网络错误重试
|
||||
- [ ] 并发刷新场景
|
||||
|
||||
## 📊 监控配置
|
||||
|
||||
### 日志记录
|
||||
- [ ] Token生成日志
|
||||
- [ ] Token刷新日志
|
||||
- [ ] 认证失败日志
|
||||
- [ ] 登出操作日志
|
||||
|
||||
### 性能监控
|
||||
- [ ] Token刷新频率统计
|
||||
- [ ] API响应时间监控
|
||||
- [ ] 数据库查询性能
|
||||
|
||||
## 🔒 安全检查
|
||||
|
||||
### Token安全
|
||||
- [ ] 密钥强度验证
|
||||
- [ ] Token过期时间配置合理
|
||||
- [ ] HTTPS传输确认
|
||||
- [ ] 敏感信息不在日志中暴露
|
||||
|
||||
### 访问控制
|
||||
- [ ] Token撤销功能正常
|
||||
- [ ] 版本控制机制有效
|
||||
- [ ] 设备隔离正确
|
||||
|
||||
## 📚 文档检查
|
||||
|
||||
- [ ] API文档已更新
|
||||
- [ ] 前端集成指南已提供
|
||||
- [ ] 迁移步骤文档完整
|
||||
- [ ] 错误处理指南清晰
|
||||
|
||||
## 🚀 上线准备
|
||||
|
||||
### 部署前
|
||||
- [ ] 代码review完成
|
||||
- [ ] 单元测试通过
|
||||
- [ ] 集成测试通过
|
||||
- [ ] 性能测试通过
|
||||
|
||||
### 部署时
|
||||
- [ ] 数据库迁移执行
|
||||
- [ ] 环境变量配置
|
||||
- [ ] 服务重启验证
|
||||
- [ ] 健康检查通过
|
||||
|
||||
### 部署后
|
||||
- [ ] 新用户登录测试
|
||||
- [ ] 现有用户功能正常
|
||||
- [ ] 监控指标正常
|
||||
- [ ] 错误日志检查
|
||||
|
||||
## 🔄 回滚计划
|
||||
|
||||
### 紧急回滚
|
||||
- [ ] 回滚代码到上一版本
|
||||
- [ ] 恢复原环境变量
|
||||
- [ ] 数据库回滚方案(如需要)
|
||||
|
||||
### 数据迁移回滚
|
||||
- [ ] 备份新增字段数据
|
||||
- [ ] 移除新增字段的迁移脚本
|
||||
- [ ] 验证旧版功能正常
|
||||
|
||||
---
|
||||
|
||||
**检查完成人员**: ___________
|
||||
**检查完成时间**: ___________
|
||||
**环境**: [ ] 开发 [ ] 测试 [ ] 生产
|
||||
@ -1,489 +0,0 @@
|
||||
# Refresh Token系统API文档
|
||||
|
||||
## 概述
|
||||
|
||||
ClassworksKV现在支持标准的Refresh Token认证系统,提供更安全的用户认证机制。新系统包含:
|
||||
|
||||
- **Access Token**: 短期令牌(默认15分钟),用于API访问
|
||||
- **Refresh Token**: 长期令牌(默认7天),用于刷新Access Token
|
||||
- **Token版本控制**: 支持令牌失效和安全登出
|
||||
- **向后兼容**: 支持旧版JWT令牌
|
||||
|
||||
## 配置选项
|
||||
|
||||
可以通过环境变量配置token系统:
|
||||
|
||||
```bash
|
||||
# Access Token配置
|
||||
ACCESS_TOKEN_EXPIRES_IN=15m # Access Token过期时间
|
||||
REFRESH_TOKEN_EXPIRES_IN=7d # Refresh Token过期时间
|
||||
|
||||
# 密钥配置(HS256算法)
|
||||
JWT_SECRET=your-access-token-secret
|
||||
REFRESH_TOKEN_SECRET=your-refresh-token-secret
|
||||
|
||||
# RSA密钥配置(RS256算法,可选)
|
||||
JWT_ALG=RS256
|
||||
ACCESS_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
|
||||
ACCESS_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n..."
|
||||
REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
|
||||
REFRESH_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n..."
|
||||
```
|
||||
|
||||
## API端点
|
||||
|
||||
### 1. OAuth登录回调
|
||||
|
||||
OAuth登录成功后,系统会返回令牌对。
|
||||
|
||||
**回调URL参数(新版):**
|
||||
```
|
||||
https://your-frontend.com/?access_token=eyJ...&refresh_token=eyJ...&expires_in=15m&success=true&provider=github
|
||||
```
|
||||
|
||||
**旧版兼容参数:**
|
||||
```
|
||||
https://your-frontend.com/?token=eyJ...&success=true&provider=github
|
||||
```
|
||||
|
||||
### 2. 刷新访问令牌
|
||||
|
||||
当Access Token即将过期或已过期时,使用Refresh Token获取新的Access Token。
|
||||
|
||||
**端点:** `POST /api/accounts/refresh`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
**响应(成功):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "令牌刷新成功",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"expires_in": "15m",
|
||||
"account": {
|
||||
"id": "clxxxx",
|
||||
"provider": "github",
|
||||
"email": "user@example.com",
|
||||
"name": "User Name",
|
||||
"avatarUrl": "https://..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "刷新令牌已过期"
|
||||
}
|
||||
```
|
||||
|
||||
**错误状态码:**
|
||||
- `400`: 缺少刷新令牌
|
||||
- `401`: 无效的刷新令牌、令牌已过期、账户不存在、令牌版本不匹配
|
||||
|
||||
### 3. 登出(当前设备)
|
||||
|
||||
撤销当前设备的Refresh Token,但不影响其他设备。
|
||||
|
||||
**端点:** `POST /api/accounts/logout`
|
||||
|
||||
**请求头:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "登出成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 登出所有设备
|
||||
|
||||
撤销账户的所有令牌,强制所有设备重新登录。
|
||||
|
||||
**端点:** `POST /api/accounts/logout-all`
|
||||
|
||||
**请求头:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "已从所有设备登出"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取令牌信息
|
||||
|
||||
查看当前令牌的详细信息和状态。
|
||||
|
||||
**端点:** `GET /api/accounts/token-info`
|
||||
|
||||
**请求头:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"accountId": "clxxxx",
|
||||
"tokenType": "access",
|
||||
"tokenVersion": 1,
|
||||
"issuedAt": "2024-11-01T08:00:00.000Z",
|
||||
"expiresAt": "2024-11-01T08:15:00.000Z",
|
||||
"expiresIn": 900,
|
||||
"isExpired": false,
|
||||
"isLegacyToken": false,
|
||||
"hasRefreshToken": true,
|
||||
"refreshTokenExpiry": "2024-11-08T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 自动刷新机制
|
||||
|
||||
### 响应头刷新
|
||||
|
||||
当Access Token剩余有效期少于5分钟时,系统会在响应头中提供新的Access Token:
|
||||
|
||||
**响应头:**
|
||||
```
|
||||
X-New-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
X-Token-Refreshed: true
|
||||
```
|
||||
|
||||
前端应检查这些响应头并更新本地存储的token。
|
||||
|
||||
## 前端集成示例
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
class TokenManager {
|
||||
constructor() {
|
||||
this.accessToken = localStorage.getItem('access_token');
|
||||
this.refreshToken = localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
// 设置令牌对
|
||||
setTokens(accessToken, refreshToken) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
// 清除令牌
|
||||
clearTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
async refreshAccessToken() {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/accounts/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: this.refreshToken
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.accessToken = data.data.access_token;
|
||||
localStorage.setItem('access_token', this.accessToken);
|
||||
return this.accessToken;
|
||||
} else {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// API请求拦截器
|
||||
async request(url, options = {}) {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// 检查是否有新的访问令牌
|
||||
const newAccessToken = response.headers.get('X-New-Access-Token');
|
||||
if (newAccessToken) {
|
||||
this.accessToken = newAccessToken;
|
||||
localStorage.setItem('access_token', newAccessToken);
|
||||
}
|
||||
|
||||
// 如果token过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
await this.refreshAccessToken();
|
||||
// 重试请求
|
||||
return this.request(url, options);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,重定向到登录页
|
||||
window.location.href = '/login';
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
try {
|
||||
await this.request('/api/accounts/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
this.clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// 登出所有设备
|
||||
async logoutAll() {
|
||||
try {
|
||||
await this.request('/api/accounts/logout-all', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout all error:', error);
|
||||
} finally {
|
||||
this.clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const tokenManager = new TokenManager();
|
||||
|
||||
// OAuth回调处理
|
||||
function handleOAuthCallback() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
tokenManager.setTokens(accessToken, refreshToken);
|
||||
// 重定向到应用主页
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
// 处理旧版回调
|
||||
const legacyToken = params.get('token');
|
||||
if (legacyToken) {
|
||||
tokenManager.setTokens(legacyToken, null);
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API调用示例
|
||||
async function getUserProfile() {
|
||||
try {
|
||||
const response = await tokenManager.request('/api/accounts/profile');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React Hook
|
||||
|
||||
```jsx
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export function useAuth() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const tokenManager = new TokenManager();
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
if (!tokenManager.accessToken) {
|
||||
setIsAuthenticated(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tokenManager.request('/api/accounts/profile');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setUser(data.data);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const login = useCallback((accessToken, refreshToken) => {
|
||||
tokenManager.setTokens(accessToken, refreshToken);
|
||||
setIsAuthenticated(true);
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await tokenManager.logout();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
tokenManager,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 1. Token存储
|
||||
- **Access Token**: 可以存储在内存或localStorage中
|
||||
- **Refresh Token**: 建议存储在httpOnly cookie中(需要后端支持),或者安全的本地存储
|
||||
|
||||
### 2. HTTPS
|
||||
- 生产环境必须使用HTTPS传输令牌
|
||||
|
||||
### 3. Token轮换
|
||||
- 系统支持令牌版本控制,可以快速失效所有令牌
|
||||
|
||||
### 4. 过期时间
|
||||
- Access Token短期有效(15分钟)
|
||||
- Refresh Token长期有效(7天)
|
||||
- 可根据安全需求调整
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版JWT系统迁移
|
||||
|
||||
1. **前端更新**:
|
||||
- 更新OAuth回调处理逻辑
|
||||
- 实现Token刷新机制
|
||||
- 处理新的响应头
|
||||
|
||||
2. **向后兼容**:
|
||||
- 旧版JWT token仍然有效
|
||||
- 系统会在响应中标记`isLegacyToken: true`
|
||||
- 建议用户重新登录获取新令牌
|
||||
|
||||
3. **数据库迁移**:
|
||||
```bash
|
||||
# 运行Prisma迁移
|
||||
npm run prisma migrate dev --name add_refresh_token_support
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 错误代码 | 错误信息 | 处理方式 |
|
||||
|---------|---------|---------|
|
||||
| 401 | JWT token已过期 | 使用refresh token刷新 |
|
||||
| 401 | 无效的刷新令牌 | 重新登录 |
|
||||
| 401 | 令牌版本不匹配 | 重新登录 |
|
||||
| 401 | 账户不存在 | 重新登录 |
|
||||
|
||||
### 错误处理流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[API请求] --> B{Token有效?}
|
||||
B -->|是| C[返回数据]
|
||||
B -->|否,401错误| D{有Refresh Token?}
|
||||
D -->|否| E[重定向登录]
|
||||
D -->|是| F[尝试刷新Token]
|
||||
F --> G{刷新成功?}
|
||||
G -->|是| H[重试原请求]
|
||||
G -->|否| E
|
||||
H --> C
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 建议监控指标
|
||||
|
||||
- Token刷新频率
|
||||
- Token刷新失败率
|
||||
- 用户登出频率
|
||||
- 异常登录尝试
|
||||
|
||||
### 日志记录
|
||||
|
||||
系统会记录以下事件:
|
||||
- Token生成
|
||||
- Token刷新
|
||||
- Token撤销
|
||||
- 认证失败
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. Token缓存
|
||||
- 在内存中缓存已验证的token(适用于高并发场景)
|
||||
|
||||
### 2. 数据库优化
|
||||
- 为`refreshToken`字段添加索引
|
||||
- 定期清理过期的refresh token
|
||||
|
||||
### 3. 前端优化
|
||||
- 实现Token预刷新机制
|
||||
- 使用Web Workers处理Token逻辑
|
||||
@ -1,112 +0,0 @@
|
||||
# Refresh Token系统 - 快速使用指南
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 环境变量配置
|
||||
|
||||
```bash
|
||||
# 添加到 .env 文件
|
||||
ACCESS_TOKEN_EXPIRES_IN=15m
|
||||
REFRESH_TOKEN_EXPIRES_IN=7d
|
||||
REFRESH_TOKEN_SECRET=your-refresh-token-secret-change-this
|
||||
```
|
||||
|
||||
### 2. 数据库迁移
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name add_refresh_token_system
|
||||
```
|
||||
|
||||
### 3. 新的OAuth回调参数
|
||||
|
||||
登录成功后,回调URL现在包含:
|
||||
```
|
||||
?access_token=eyJ...&refresh_token=eyJ...&expires_in=15m&success=true
|
||||
```
|
||||
|
||||
## 📝 核心API
|
||||
|
||||
### 刷新Token
|
||||
```http
|
||||
POST /api/accounts/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
### 登出当前设备
|
||||
```http
|
||||
POST /api/accounts/logout
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 登出所有设备
|
||||
```http
|
||||
POST /api/accounts/logout-all
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
## 💻 前端集成
|
||||
|
||||
### 基础Token管理
|
||||
```javascript
|
||||
class TokenManager {
|
||||
setTokens(accessToken, refreshToken) {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const response = await fetch('/api/accounts/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('access_token', data.data.access_token);
|
||||
return data.data.access_token;
|
||||
}
|
||||
throw new Error(data.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自动刷新拦截器
|
||||
```javascript
|
||||
// 检查响应头中的新token
|
||||
const newToken = response.headers.get('X-New-Access-Token');
|
||||
if (newToken) {
|
||||
localStorage.setItem('access_token', newToken);
|
||||
}
|
||||
|
||||
// 401错误时自动刷新
|
||||
if (response.status === 401) {
|
||||
await tokenManager.refreshToken();
|
||||
// 重试请求
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- ✅ 短期Access Token(15分钟)
|
||||
- ✅ 长期Refresh Token(7天)
|
||||
- ✅ Token版本控制
|
||||
- ✅ 设备级登出
|
||||
- ✅ 全局登出
|
||||
- ✅ 自动刷新机制
|
||||
- ✅ 向后兼容
|
||||
|
||||
## 🔄 迁移步骤
|
||||
|
||||
1. **更新环境变量**
|
||||
2. **运行数据库迁移**
|
||||
3. **更新前端OAuth回调处理**
|
||||
4. **实现Token刷新逻辑**
|
||||
5. **测试登出功能**
|
||||
|
||||
详细文档请参考:`REFRESH_TOKEN_API.md`
|
||||
@ -1,174 +0,0 @@
|
||||
# 账户登录密钥系统重构完成报告
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
已成功重构ClassworksKV的账户登录密钥系统,从单一JWT令牌升级为标准的Refresh Token系统,大幅提升了安全性和用户体验。
|
||||
|
||||
## ✅ 完成的工作
|
||||
|
||||
### 1. 数据库架构更新
|
||||
- 在`Account`模型中添加了`refreshToken`、`refreshTokenExpiry`和`tokenVersion`字段
|
||||
- 支持令牌版本控制,可快速失效所有设备的令牌
|
||||
- 向后兼容现有数据
|
||||
|
||||
### 2. 核心Token管理系统
|
||||
- **创建 `utils/tokenManager.js`**: 全新的令牌管理核心
|
||||
- 生成Access Token(15分钟有效期)
|
||||
- 生成Refresh Token(7天有效期)
|
||||
- 支持HS256和RS256算法
|
||||
- 令牌刷新和撤销功能
|
||||
- 安全验证机制
|
||||
|
||||
- **重构 `utils/jwt.js`**: 保持向后兼容性
|
||||
- 重新导出新的令牌管理功能
|
||||
- 保留旧版API供现有代码使用
|
||||
|
||||
### 3. 认证中间件升级
|
||||
- **更新 `middleware/jwt-auth.js`**:
|
||||
- 支持新的Access Token验证
|
||||
- 自动检测即将过期的令牌并在响应头提供新令牌
|
||||
- 向后兼容旧版JWT令牌
|
||||
- 新增可选认证中间件
|
||||
|
||||
### 4. API端点扩展
|
||||
- **更新 `routes/accounts.js`**:
|
||||
- OAuth回调现在返回令牌对(access_token + refresh_token)
|
||||
- 新增 `/api/accounts/refresh` - 刷新访问令牌
|
||||
- 新增 `/api/accounts/logout` - 单设备登出
|
||||
- 新增 `/api/accounts/logout-all` - 全设备登出
|
||||
- 新增 `/api/accounts/token-info` - 查看令牌状态
|
||||
|
||||
### 5. 安全特性
|
||||
- **短期Access Token**: 默认15分钟,降低泄露风险
|
||||
- **长期Refresh Token**: 默认7天,用户体验友好
|
||||
- **令牌版本控制**: 支持立即失效所有设备的令牌
|
||||
- **自动刷新机制**: 在令牌即将过期时自动提供新令牌
|
||||
- **设备级管理**: 支持单设备或全设备登出
|
||||
|
||||
## 📚 文档输出
|
||||
|
||||
### 1. 详细API文档
|
||||
**文件**: `REFRESH_TOKEN_API.md`
|
||||
- 完整的API接口说明
|
||||
- 前端集成示例(JavaScript/React)
|
||||
- 安全考虑和最佳实践
|
||||
- 错误处理指南
|
||||
- 性能优化建议
|
||||
|
||||
### 2. 快速使用指南
|
||||
**文件**: `REFRESH_TOKEN_QUICKSTART.md`
|
||||
- 环境配置说明
|
||||
- 核心API使用方法
|
||||
- 前端集成代码示例
|
||||
- 迁移步骤指导
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量
|
||||
```bash
|
||||
# Access Token配置
|
||||
ACCESS_TOKEN_EXPIRES_IN=15m # 访问令牌过期时间
|
||||
REFRESH_TOKEN_EXPIRES_IN=7d # 刷新令牌过期时间
|
||||
|
||||
# 密钥配置
|
||||
JWT_SECRET=your-access-token-secret # Access Token密钥
|
||||
REFRESH_TOKEN_SECRET=your-refresh-token-secret # Refresh Token密钥
|
||||
|
||||
# 可选:RSA算法配置
|
||||
JWT_ALG=RS256
|
||||
ACCESS_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..."
|
||||
ACCESS_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----..."
|
||||
REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..."
|
||||
REFRESH_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----..."
|
||||
```
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 数据库迁移
|
||||
```bash
|
||||
npx prisma migrate dev --name add_refresh_token_system
|
||||
```
|
||||
|
||||
### 2. 环境变量更新
|
||||
```bash
|
||||
# 添加新的环境变量到 .env 文件
|
||||
echo "ACCESS_TOKEN_EXPIRES_IN=15m" >> .env
|
||||
echo "REFRESH_TOKEN_EXPIRES_IN=7d" >> .env
|
||||
echo "REFRESH_TOKEN_SECRET=your-refresh-token-secret-change-this" >> .env
|
||||
```
|
||||
|
||||
### 3. 前端更新
|
||||
- 更新OAuth回调处理逻辑
|
||||
- 实现Token刷新机制
|
||||
- 添加自动重试逻辑
|
||||
|
||||
## 🔄 向后兼容性
|
||||
|
||||
- ✅ 现有JWT令牌继续有效
|
||||
- ✅ 旧版API端点保持不变
|
||||
- ✅ 渐进式迁移支持
|
||||
- ✅ 中间件自动检测令牌类型
|
||||
|
||||
## 📊 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ 前端应用 │ │ ClassworksKV │ │ 数据库 │
|
||||
│ │ │ 服务端 │ │ │
|
||||
├─────────────────┤ ├──────────────────┤ ├─────────────────┤
|
||||
│ • Token存储 │◄──►│ • OAuth认证 │◄──►│ • Account表 │
|
||||
│ • 自动刷新 │ │ • Token生成 │ │ • refreshToken │
|
||||
│ • 请求拦截 │ │ • Token验证 │ │ • tokenVersion │
|
||||
│ • 错误处理 │ │ • Token刷新 │ │ • 过期时间 │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 🛡️ 安全增强
|
||||
|
||||
### 改进前(旧系统)
|
||||
- 单一JWT令牌
|
||||
- 长期有效(7天)
|
||||
- 泄露风险高
|
||||
- 无法远程登出
|
||||
|
||||
### 改进后(新系统)
|
||||
- 双令牌系统
|
||||
- Access Token短期(15分钟)
|
||||
- Refresh Token长期(7天)
|
||||
- 令牌版本控制
|
||||
- 设备级管理
|
||||
- 自动刷新机制
|
||||
|
||||
## 📈 性能考虑
|
||||
|
||||
- **数据库**: 为refreshToken字段添加索引
|
||||
- **内存**: Token缓存机制(可选)
|
||||
- **网络**: 预刷新机制减少延迟
|
||||
- **存储**: 定期清理过期令牌
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 功能测试
|
||||
1. OAuth登录流程测试
|
||||
2. Token刷新功能测试
|
||||
3. 登出功能测试
|
||||
4. 过期处理测试
|
||||
|
||||
### 安全测试
|
||||
1. 令牌篡改测试
|
||||
2. 过期令牌测试
|
||||
3. 并发刷新测试
|
||||
4. 版本不匹配测试
|
||||
|
||||
## 📞 后续支持
|
||||
|
||||
- 监控令牌刷新频率
|
||||
- 分析用户登录模式
|
||||
- 优化过期时间配置
|
||||
- 收集用户反馈
|
||||
|
||||
---
|
||||
|
||||
**重构完成时间**: 2025年11月1日
|
||||
**文档版本**: v1.0
|
||||
**兼容性**: 向后兼容,支持渐进式迁移
|
||||
565
SOCKET_API.md
565
SOCKET_API.md
@ -1,565 +0,0 @@
|
||||
# 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 到项目仓库。
|
||||
@ -7,13 +7,11 @@
|
||||
* 3. passwordMiddleware - 验证设备密码
|
||||
*/
|
||||
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
import {analyzeDevice} from "../utils/deviceDetector.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 为新设备创建默认的自动登录配置
|
||||
* @param {number} deviceId - 设备ID
|
||||
|
||||
@ -8,11 +8,9 @@
|
||||
|
||||
import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
|
||||
import {verifyToken} from "../utils/jwt.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 新的JWT认证中间件(支持refresh token系统)
|
||||
*/
|
||||
|
||||
@ -5,11 +5,9 @@
|
||||
* 适用于所有KV相关的接口
|
||||
*/
|
||||
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* KV Token认证中间件
|
||||
* 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals
|
||||
|
||||
@ -6,13 +6,11 @@
|
||||
* 3. 适用于需要设备上下文的接口
|
||||
*/
|
||||
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import {verifyToken as verifyAccountJWT} from "../utils/jwt.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* UUID+密码/JWT混合认证中间件
|
||||
*/
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"@opentelemetry/sdk-node": "^0.201.1",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@prisma/client": "6.16.2",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"axios": "^1.9.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@ -31,11 +32,12 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "~1.10.0",
|
||||
"node-device-detector": "^2.2.4",
|
||||
"pg": "^8.18.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.18.0"
|
||||
"prisma": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
584
pnpm-lock.yaml
generated
584
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
prisma.config.js
Normal file
14
prisma.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
110
prisma/migrations/0_init/migration.sql
Normal file
110
prisma/migrations/0_init/migration.sql
Normal file
@ -0,0 +1,110 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" VARCHAR(191) NOT NULL,
|
||||
"provider" VARCHAR(191) NOT NULL,
|
||||
"providerid" VARCHAR(191) NOT NULL,
|
||||
"email" VARCHAR(191),
|
||||
"name" VARCHAR(191),
|
||||
"avatarurl" VARCHAR(191),
|
||||
"providerdata" JSON,
|
||||
"accesstoken" TEXT,
|
||||
"createdat" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedat" TIMESTAMPTZ(6) NOT NULL,
|
||||
"refreshtoken" TEXT,
|
||||
"refreshtokenexpiry" TIMESTAMPTZ(6),
|
||||
"tokenversion" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "idx_18048_primary" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "appinstall" (
|
||||
"id" VARCHAR(191) NOT NULL,
|
||||
"deviceid" INTEGER NOT NULL,
|
||||
"appid" VARCHAR(191) NOT NULL,
|
||||
"token" VARCHAR(191) NOT NULL,
|
||||
"note" VARCHAR(191),
|
||||
"installedat" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedat" TIMESTAMPTZ(6) NOT NULL,
|
||||
"devicetype" VARCHAR(191),
|
||||
"isreadonly" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "idx_18055_primary" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "autoauth" (
|
||||
"id" VARCHAR(191) NOT NULL,
|
||||
"deviceid" INTEGER NOT NULL,
|
||||
"password" VARCHAR(191),
|
||||
"devicetype" VARCHAR(191),
|
||||
"isreadonly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdat" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedat" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "idx_18062_primary" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "device" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"uuid" VARCHAR(191) NOT NULL,
|
||||
"name" VARCHAR(191),
|
||||
"accountid" VARCHAR(191),
|
||||
"createdat" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedat" TIMESTAMPTZ(6) NOT NULL,
|
||||
"password" VARCHAR(191),
|
||||
"passwordhint" VARCHAR(191),
|
||||
"namespace" VARCHAR(191),
|
||||
|
||||
CONSTRAINT "idx_18069_primary" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kvstore" (
|
||||
"deviceid" INTEGER NOT NULL,
|
||||
"key" VARCHAR(191) NOT NULL,
|
||||
"value" JSON NOT NULL,
|
||||
"creatorip" VARCHAR(191) DEFAULT '',
|
||||
"createdat" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedat" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "idx_18075_primary" PRIMARY KEY ("deviceid","key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_18048_account_provider_providerid_key" ON "account"("provider", "providerid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_18055_appinstall_token_key" ON "appinstall"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_18055_appinstall_deviceid_fkey" ON "appinstall"("deviceid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_18062_autoauth_deviceid_password_key" ON "autoauth"("deviceid", "password");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_18069_device_uuid_key" ON "device"("uuid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_18069_device_namespace_key" ON "device"("namespace");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_18069_device_accountid_fkey" ON "device"("accountid");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "appinstall" ADD CONSTRAINT "appinstall_deviceid_fkey" FOREIGN KEY ("deviceid") REFERENCES "device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "autoauth" ADD CONSTRAINT "autoauth_deviceid_fkey" FOREIGN KEY ("deviceid") REFERENCES "device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "device" ADD CONSTRAINT "device_accountid_fkey" FOREIGN KEY ("accountid") REFERENCES "account"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kvstore" ADD CONSTRAINT "kvstore_deviceid_fkey" FOREIGN KEY ("deviceid") REFERENCES "device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `KVStore` (
|
||||
`deviceId` INTEGER NOT NULL,
|
||||
`key` VARCHAR(191) NOT NULL,
|
||||
`value` JSON NOT NULL,
|
||||
`creatorIp` VARCHAR(191) NULL DEFAULT '',
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`deviceId`, `key`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Account` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`provider` VARCHAR(191) NOT NULL,
|
||||
`providerId` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NULL,
|
||||
`name` VARCHAR(191) NULL,
|
||||
`avatarUrl` VARCHAR(191) NULL,
|
||||
`providerData` JSON NULL,
|
||||
`accessToken` VARCHAR(191) NOT NULL,
|
||||
`refreshToken` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Account_accessToken_key`(`accessToken`),
|
||||
UNIQUE INDEX `Account_provider_providerId_key`(`provider`, `providerId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Device` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`uuid` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NULL,
|
||||
`accountId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
`password` VARCHAR(191) NULL,
|
||||
`passwordHint` VARCHAR(191) NULL,
|
||||
|
||||
UNIQUE INDEX `Device_uuid_key`(`uuid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `AppInstall` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`deviceId` INTEGER NOT NULL,
|
||||
`appId` VARCHAR(191) NOT NULL,
|
||||
`token` VARCHAR(191) NOT NULL,
|
||||
`note` VARCHAR(191) NULL,
|
||||
`installedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `AppInstall_token_key`(`token`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `KVStore` ADD CONSTRAINT `KVStore_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Device` ADD CONSTRAINT `Device_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `Account`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AppInstall` ADD CONSTRAINT `AppInstall_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` MODIFY `refreshToken` TEXT NULL;
|
||||
@ -1,5 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX `Account_accessToken_key` ON `Account`;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` MODIFY `accessToken` TEXT NOT NULL;
|
||||
@ -1 +0,0 @@
|
||||
-- This is an empty migration.
|
||||
@ -1,9 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `refreshToken` on the `Account` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` DROP COLUMN `refreshToken`,
|
||||
MODIFY `accessToken` TEXT NULL;
|
||||
@ -1,47 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
@ -1,4 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` ADD COLUMN `refreshToken` TEXT NULL,
|
||||
ADD COLUMN `refreshTokenExpiry` DATETIME(3) NULL,
|
||||
ADD COLUMN `tokenVersion` INTEGER NOT NULL DEFAULT 1;
|
||||
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
@ -1,91 +1,85 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model KVStore {
|
||||
deviceId Int
|
||||
key String
|
||||
value Json
|
||||
creatorIp String? @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model account {
|
||||
id String @id(map: "idx_18048_primary") @db.VarChar(191)
|
||||
provider String @db.VarChar(191)
|
||||
providerid String @db.VarChar(191)
|
||||
email String? @db.VarChar(191)
|
||||
name String? @db.VarChar(191)
|
||||
avatarurl String? @db.VarChar(191)
|
||||
providerdata Json? @db.Json
|
||||
accesstoken String?
|
||||
createdat DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedat DateTime @db.Timestamptz(6)
|
||||
refreshtoken String?
|
||||
refreshtokenexpiry DateTime? @db.Timestamptz(6)
|
||||
tokenversion Int @default(1)
|
||||
device device[]
|
||||
|
||||
// 关联关系
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([deviceId, key])
|
||||
@@unique([provider, providerid], map: "idx_18048_account_provider_providerid_key")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
provider String // OAuth提供者 (例如: google, github, gitlab等)
|
||||
providerId String // 提供者返回的用户唯一ID
|
||||
email String? // 用户邮箱
|
||||
name String? // 用户名称
|
||||
avatarUrl String? // 用户头像URL
|
||||
providerData Json? // OAuth提供者返回的完整信息
|
||||
accessToken String? @db.Text // 账户访问令牌
|
||||
refreshToken String? @db.Text // 刷新令牌
|
||||
refreshTokenExpiry DateTime? // 刷新令牌过期时间
|
||||
tokenVersion Int @default(1) // 令牌版本,用于令牌失效
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model appinstall {
|
||||
id String @id(map: "idx_18055_primary") @db.VarChar(191)
|
||||
deviceid Int
|
||||
appid String @db.VarChar(191)
|
||||
token String @unique(map: "idx_18055_appinstall_token_key") @db.VarChar(191)
|
||||
note String? @db.VarChar(191)
|
||||
installedat DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedat DateTime @db.Timestamptz(6)
|
||||
devicetype String? @db.VarChar(191)
|
||||
isreadonly Boolean @default(false)
|
||||
device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
|
||||
|
||||
// 关联的设备
|
||||
devices Device[]
|
||||
|
||||
@@unique([provider, providerId]) // 确保同一提供者的用户ID唯一
|
||||
@@index([deviceid], map: "idx_18055_appinstall_deviceid_fkey")
|
||||
}
|
||||
|
||||
model Device {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique // 设备的唯一标识符
|
||||
name String?
|
||||
accountId String? // 关联的账户ID
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
password String?
|
||||
passwordHint String?
|
||||
namespace String? @unique // 用户自定义的唯一命名空间
|
||||
model autoauth {
|
||||
id String @id(map: "idx_18062_primary") @db.VarChar(191)
|
||||
deviceid Int
|
||||
password String? @db.VarChar(191)
|
||||
devicetype String? @db.VarChar(191)
|
||||
isreadonly Boolean @default(false)
|
||||
createdat DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedat DateTime @db.Timestamptz(6)
|
||||
device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
|
||||
|
||||
// 关联关系
|
||||
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
||||
appInstalls AppInstall[]
|
||||
kvStore KVStore[] // 设备相关的KV存储
|
||||
autoAuths AutoAuth[] // 自动授权配置
|
||||
@@unique([deviceid, password], map: "idx_18062_autoauth_deviceid_password_key")
|
||||
}
|
||||
|
||||
model AppInstall {
|
||||
id String @id @default(cuid())
|
||||
deviceId Int // 关联的设备ID
|
||||
appId String // 应用ID (SHA256 hash)
|
||||
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限
|
||||
note String? // 安装备注
|
||||
isReadOnly Boolean @default(false) // 是否只读
|
||||
deviceType String? // 设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长)
|
||||
installedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model device {
|
||||
id Int @id(map: "idx_18069_primary")
|
||||
uuid String @unique(map: "idx_18069_device_uuid_key") @db.VarChar(191)
|
||||
name String? @db.VarChar(191)
|
||||
accountid String? @db.VarChar(191)
|
||||
createdat DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedat DateTime @db.Timestamptz(6)
|
||||
password String? @db.VarChar(191)
|
||||
passwordhint String? @db.VarChar(191)
|
||||
namespace String? @unique(map: "idx_18069_device_namespace_key") @db.VarChar(191)
|
||||
appinstall appinstall[]
|
||||
autoauth autoauth[]
|
||||
account account? @relation(fields: [accountid], references: [id])
|
||||
kvstore kvstore[]
|
||||
|
||||
// 关联关系
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
@@index([accountid], map: "idx_18069_device_accountid_fkey")
|
||||
}
|
||||
|
||||
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
|
||||
model kvstore {
|
||||
deviceid Int
|
||||
key String @db.VarChar(191)
|
||||
value Json @db.Json
|
||||
creatorip String? @default("") @db.VarChar(191)
|
||||
createdat DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedat DateTime @db.Timestamptz(6)
|
||||
device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
|
||||
|
||||
// 关联关系
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([deviceId, password]) // 同一设备的密码必须唯一
|
||||
@@id([deviceid, key], map: "idx_18075_primary")
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {Router} from "express";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import crypto from "crypto";
|
||||
import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js";
|
||||
import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.js";
|
||||
@ -7,7 +7,6 @@ import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 存储OAuth state,防止CSRF攻击(生产环境应使用Redis等)
|
||||
const oauthStates = new Map();
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import {Router} from "express";
|
||||
import {uuidAuth} from "../middleware/uuidAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import crypto from "crypto";
|
||||
import errors from "../utils/errors.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* GET /apps/devices/:uuid/apps
|
||||
* 获取设备安装的应用列表 (公开接口,无需认证)
|
||||
@ -291,7 +289,7 @@ router.post(
|
||||
}
|
||||
|
||||
// 读取设备的 classworks-list-main 键值
|
||||
const kvRecord = await prisma.kVStore.findUnique({
|
||||
const kvRecord = await prisma.kvstore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: appInstall.deviceId,
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import {Router} from "express";
|
||||
import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* GET /auto-auth/devices/:uuid/auth-configs
|
||||
* 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import {Router} from "express";
|
||||
import deviceCodeStore from "../utils/deviceCodeStore.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import {Router} from "express";
|
||||
import {extractDeviceInfo} from "../middleware/uuidAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "../utils/prisma.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import {getOnlineDevices} from "../utils/socket.js";
|
||||
import {registeredDevicesTotal} from "../utils/metrics.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 为新设备创建默认的自动登录配置
|
||||
* @param {number} deviceId - 设备ID
|
||||
|
||||
@ -10,12 +10,10 @@ import {
|
||||
tokenWriteLimiter
|
||||
} from "../middleware/rateLimiter.js";
|
||||
import errors from "../utils/errors.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { prisma } from "../utils/prisma.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 使用KV专用token认证
|
||||
router.use(kvTokenAuth);
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "./prisma.js";
|
||||
import {keysTotal} from "./metrics.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class KVStore {
|
||||
/**
|
||||
* 通过设备ID和键名获取值
|
||||
@ -11,7 +9,7 @@ class KVStore {
|
||||
* @returns {object|null} 键对应的值或null
|
||||
*/
|
||||
async get(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
const item = await prisma.kvstore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
@ -29,7 +27,7 @@ class KVStore {
|
||||
* @returns {object|null} 键的完整信息或null
|
||||
*/
|
||||
async getMetadata(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
const item = await prisma.kvstore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
@ -68,7 +66,7 @@ class KVStore {
|
||||
* @returns {object} 创建或更新的记录
|
||||
*/
|
||||
async upsert(deviceId, key, value, creatorIp = "") {
|
||||
const item = await prisma.kVStore.upsert({
|
||||
const item = await prisma.kvstore.upsert({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
@ -88,7 +86,7 @@ class KVStore {
|
||||
});
|
||||
|
||||
// 更新键总数指标
|
||||
const totalKeys = await prisma.kVStore.count();
|
||||
const totalKeys = await prisma.kvstore.count();
|
||||
keysTotal.set(totalKeys);
|
||||
|
||||
// 返回带有设备ID和原始键的结果
|
||||
@ -117,7 +115,7 @@ class KVStore {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
try {
|
||||
const item = await tx.kVStore.upsert({
|
||||
const item = await tx.kvstore.upsert({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
@ -152,7 +150,7 @@ class KVStore {
|
||||
});
|
||||
|
||||
// 在事务完成后,一次性更新指标
|
||||
const totalKeys = await prisma.kVStore.count();
|
||||
const totalKeys = await prisma.kvstore.count();
|
||||
keysTotal.set(totalKeys);
|
||||
|
||||
return { results, errors };
|
||||
@ -166,7 +164,7 @@ class KVStore {
|
||||
*/
|
||||
async delete(deviceId, key) {
|
||||
try {
|
||||
const item = await prisma.kVStore.delete({
|
||||
const item = await prisma.kvstore.delete({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
@ -176,7 +174,7 @@ class KVStore {
|
||||
});
|
||||
|
||||
// 更新键总数指标
|
||||
const totalKeys = await prisma.kVStore.count();
|
||||
const totalKeys = await prisma.kvstore.count();
|
||||
keysTotal.set(totalKeys);
|
||||
|
||||
return item ? {...item, deviceId, key} : null;
|
||||
@ -203,7 +201,7 @@ class KVStore {
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
|
||||
// 查询设备的所有键
|
||||
const items = await prisma.kVStore.findMany({
|
||||
const items = await prisma.kvstore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
@ -246,7 +244,7 @@ class KVStore {
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
|
||||
// 查询设备的所有键,只选择键名
|
||||
const items = await prisma.kVStore.findMany({
|
||||
const items = await prisma.kvstore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
@ -268,7 +266,7 @@ class KVStore {
|
||||
* @returns {number} 键值对数量
|
||||
*/
|
||||
async count(deviceId) {
|
||||
const count = await prisma.kVStore.count({
|
||||
const count = await prisma.kvstore.count({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
@ -283,15 +281,15 @@ class KVStore {
|
||||
*/
|
||||
async getStats(deviceId) {
|
||||
const [totalKeys, oldestKey, newestKey] = await Promise.all([
|
||||
prisma.kVStore.count({
|
||||
prisma.kvstore.count({
|
||||
where: { deviceId },
|
||||
}),
|
||||
prisma.kVStore.findFirst({
|
||||
prisma.kvstore.findFirst({
|
||||
where: { deviceId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { createdAt: true, key: true },
|
||||
}),
|
||||
prisma.kVStore.findFirst({
|
||||
prisma.kvstore.findFirst({
|
||||
where: { deviceId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true, key: true },
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import client from 'prom-client';
|
||||
import { prisma } from './prisma.js';
|
||||
|
||||
// 创建自定义注册表(不包含默认指标)
|
||||
const register = new client.Registry();
|
||||
@ -27,18 +28,14 @@ export const keysTotal = new client.Gauge({
|
||||
// 初始化指标数据
|
||||
export async function initializeMetrics() {
|
||||
try {
|
||||
const {PrismaClient} = await import('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 获取已注册设备总数
|
||||
const deviceCount = await prisma.device.count();
|
||||
registeredDevicesTotal.set(deviceCount);
|
||||
|
||||
// 获取已创建键总数
|
||||
const keyCount = await prisma.kVStore.count();
|
||||
const keyCount = await prisma.kvstore.count();
|
||||
keysTotal.set(keyCount);
|
||||
|
||||
await prisma.$disconnect();
|
||||
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize metrics:', error);
|
||||
|
||||
10
utils/prisma.js
Normal file
10
utils/prisma.js
Normal file
@ -0,0 +1,10 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { PrismaClient } from '../generated/prisma/client.ts'
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`
|
||||
|
||||
const adapter = new PrismaPg({ connectionString })
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
export { prisma }
|
||||
@ -1,8 +1,6 @@
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {prisma} from "./prisma.js";
|
||||
import kvStore from "./kvStore.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 系统保留UUID用于存储站点信息
|
||||
const SYSTEM_DEVICE_UUID = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { Server } from "socket.io";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { prisma } from "./prisma.js";
|
||||
import { onlineDevicesGauge } from "./metrics.js";
|
||||
import DeviceDetector from "node-device-detector";
|
||||
import ClientHints from "node-device-detector/client-hints.js";
|
||||
@ -38,7 +38,6 @@ const tokenInfoCache = new Map();
|
||||
// 事件历史记录:每个设备最多保存1000条事件记录
|
||||
const eventHistory = new Map(); // uuid -> Array<EventRecord>
|
||||
const MAX_EVENT_HISTORY = 1000;
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 检测设备并生成友好的设备名称
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import {prisma} from './prisma.js';
|
||||
|
||||
// Token 配置
|
||||
const ACCESS_TOKEN_SECRET = process.env.JWT_SECRET || 'your-access-token-secret-change-this-in-production';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user