1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2026-02-04 07:44:40 +00:00

迁移到postgresql

This commit is contained in:
Sunwuyuan 2026-02-01 18:30:23 +08:00
parent fddaf0dffd
commit ce3c8ee41f
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
43 changed files with 780 additions and 2094 deletions

4
.gitignore vendored
View File

@ -145,3 +145,7 @@ dist
prisma/database/data prisma/database/data
data/ data/
/generated/prisma
/generated/prisma

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -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
View File

@ -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
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -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 管理接口

View File

View File

@ -1,4 +1,4 @@
FROM node:22-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

@ -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完成
- [ ] 单元测试通过
- [ ] 集成测试通过
- [ ] 性能测试通过
### 部署时
- [ ] 数据库迁移执行
- [ ] 环境变量配置
- [ ] 服务重启验证
- [ ] 健康检查通过
### 部署后
- [ ] 新用户登录测试
- [ ] 现有用户功能正常
- [ ] 监控指标正常
- [ ] 错误日志检查
## 🔄 回滚计划
### 紧急回滚
- [ ] 回滚代码到上一版本
- [ ] 恢复原环境变量
- [ ] 数据库回滚方案(如需要)
### 数据迁移回滚
- [ ] 备份新增字段数据
- [ ] 移除新增字段的迁移脚本
- [ ] 验证旧版功能正常
---
**检查完成人员**: ___________
**检查完成时间**: ___________
**环境**: [ ] 开发 [ ] 测试 [ ] 生产

View File

View File

@ -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逻辑

View File

@ -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 Token15分钟
- ✅ 长期Refresh Token7天
- ✅ Token版本控制
- ✅ 设备级登出
- ✅ 全局登出
- ✅ 自动刷新机制
- ✅ 向后兼容
## 🔄 迁移步骤
1. **更新环境变量**
2. **运行数据库迁移**
3. **更新前端OAuth回调处理**
4. **实现Token刷新逻辑**
5. **测试登出功能**
详细文档请参考:`REFRESH_TOKEN_API.md`

View File

@ -1,174 +0,0 @@
# 账户登录密钥系统重构完成报告
## 📋 项目概述
已成功重构ClassworksKV的账户登录密钥系统从单一JWT令牌升级为标准的Refresh Token系统大幅提升了安全性和用户体验。
## ✅ 完成的工作
### 1. 数据库架构更新
- 在`Account`模型中添加了`refreshToken``refreshTokenExpiry``tokenVersion`字段
- 支持令牌版本控制,可快速失效所有设备的令牌
- 向后兼容现有数据
### 2. 核心Token管理系统
- **创建 `utils/tokenManager.js`**: 全新的令牌管理核心
- 生成Access Token15分钟有效期
- 生成Refresh Token7天有效期
- 支持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
**兼容性**: 向后兼容,支持渐进式迁移

View File

@ -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 到项目仓库。

View File

@ -7,13 +7,11 @@
* 3. passwordMiddleware - 验证设备密码 * 3. passwordMiddleware - 验证设备密码
*/ */
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import {verifyDevicePassword} from "../utils/crypto.js"; import {verifyDevicePassword} from "../utils/crypto.js";
import {analyzeDevice} from "../utils/deviceDetector.js"; import {analyzeDevice} from "../utils/deviceDetector.js";
const prisma = new PrismaClient();
/** /**
* 为新设备创建默认的自动登录配置 * 为新设备创建默认的自动登录配置
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID

View File

@ -8,11 +8,9 @@
import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js"; import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
import {verifyToken} from "../utils/jwt.js"; import {verifyToken} from "../utils/jwt.js";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/** /**
* 新的JWT认证中间件支持refresh token系统 * 新的JWT认证中间件支持refresh token系统
*/ */

View File

@ -5,11 +5,9 @@
* 适用于所有KV相关的接口 * 适用于所有KV相关的接口
*/ */
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/** /**
* KV Token认证中间件 * KV Token认证中间件
* 从请求中提取token支持多种方式验证后将设备和应用信息注入到res.locals * 从请求中提取token支持多种方式验证后将设备和应用信息注入到res.locals

View File

@ -6,13 +6,11 @@
* 3. 适用于需要设备上下文的接口 * 3. 适用于需要设备上下文的接口
*/ */
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import {verifyToken as verifyAccountJWT} from "../utils/jwt.js"; import {verifyToken as verifyAccountJWT} from "../utils/jwt.js";
import {verifyDevicePassword} from "../utils/crypto.js"; import {verifyDevicePassword} from "../utils/crypto.js";
const prisma = new PrismaClient();
/** /**
* UUID+密码/JWT混合认证中间件 * UUID+密码/JWT混合认证中间件
*/ */

View File

@ -15,7 +15,8 @@
"@opentelemetry/sdk-node": "^0.201.1", "@opentelemetry/sdk-node": "^0.201.1",
"@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/sdk-trace-base": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0", "@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", "axios": "^1.9.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
@ -31,11 +32,12 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0", "morgan": "~1.10.0",
"node-device-detector": "^2.2.4", "node-device-detector": "^2.2.4",
"pg": "^8.18.0",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"prisma": "^6.18.0" "prisma": "^7.3.0"
} }
} }

584
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
prisma.config.js Normal file
View 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"],
},
});

View 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;

View File

@ -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;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Account` MODIFY `refreshToken` TEXT NULL;

View File

@ -1,5 +0,0 @@
-- DropIndex
DROP INDEX `Account_accessToken_key` ON `Account`;
-- AlterTable
ALTER TABLE `Account` MODIFY `accessToken` TEXT NOT NULL;

View File

@ -1 +0,0 @@
-- This is an empty migration.

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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"

View File

@ -1,91 +1,85 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "../generated/prisma"
} }
datasource db { datasource db {
provider = "mysql" provider = "postgresql"
url = env("DATABASE_URL")
} }
model KVStore { model account {
deviceId Int id String @id(map: "idx_18048_primary") @db.VarChar(191)
key String provider String @db.VarChar(191)
value Json providerid String @db.VarChar(191)
creatorIp String? @default("") email String? @db.VarChar(191)
createdAt DateTime @default(now()) name String? @db.VarChar(191)
updatedAt DateTime @updatedAt 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[]
// 关联关系 @@unique([provider, providerid], map: "idx_18048_account_provider_providerid_key")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@id([deviceId, key])
} }
model Account { model appinstall {
id String @id @default(cuid()) id String @id(map: "idx_18055_primary") @db.VarChar(191)
provider String // OAuth提供者 (例如: google, github, gitlab等) deviceid Int
providerId String // 提供者返回的用户唯一ID appid String @db.VarChar(191)
email String? // 用户邮箱 token String @unique(map: "idx_18055_appinstall_token_key") @db.VarChar(191)
name String? // 用户名称 note String? @db.VarChar(191)
avatarUrl String? // 用户头像URL installedat DateTime @default(now()) @db.Timestamptz(6)
providerData Json? // OAuth提供者返回的完整信息 updatedat DateTime @db.Timestamptz(6)
accessToken String? @db.Text // 账户访问令牌 devicetype String? @db.VarChar(191)
refreshToken String? @db.Text // 刷新令牌 isreadonly Boolean @default(false)
refreshTokenExpiry DateTime? // 刷新令牌过期时间 device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
tokenVersion Int @default(1) // 令牌版本,用于令牌失效
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联的设备 @@index([deviceid], map: "idx_18055_appinstall_deviceid_fkey")
devices Device[]
@@unique([provider, providerId]) // 确保同一提供者的用户ID唯一
} }
model Device { model autoauth {
id Int @id @default(autoincrement()) id String @id(map: "idx_18062_primary") @db.VarChar(191)
uuid String @unique // 设备的唯一标识符 deviceid Int
name String? password String? @db.VarChar(191)
accountId String? // 关联的账户ID devicetype String? @db.VarChar(191)
createdAt DateTime @default(now()) isreadonly Boolean @default(false)
updatedAt DateTime @updatedAt createdat DateTime @default(now()) @db.Timestamptz(6)
password String? updatedat DateTime @db.Timestamptz(6)
passwordHint String? device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
namespace String? @unique // 用户自定义的唯一命名空间
// 关联关系 @@unique([deviceid, password], map: "idx_18062_autoauth_deviceid_password_key")
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
appInstalls AppInstall[]
kvStore KVStore[] // 设备相关的KV存储
autoAuths AutoAuth[] // 自动授权配置
} }
model AppInstall { model device {
id String @id @default(cuid()) id Int @id(map: "idx_18069_primary")
deviceId Int // 关联的设备ID uuid String @unique(map: "idx_18069_device_uuid_key") @db.VarChar(191)
appId String // 应用ID (SHA256 hash) name String? @db.VarChar(191)
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限 accountid String? @db.VarChar(191)
note String? // 安装备注 createdat DateTime @default(now()) @db.Timestamptz(6)
isReadOnly Boolean @default(false) // 是否只读 updatedat DateTime @db.Timestamptz(6)
deviceType String? // 设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长) password String? @db.VarChar(191)
installedAt DateTime @default(now()) passwordhint String? @db.VarChar(191)
updatedAt DateTime @updatedAt 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[]
// 关联关系 @@index([accountid], map: "idx_18069_device_accountid_fkey")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
} }
model AutoAuth { model kvstore {
id String @id @default(cuid()) deviceid Int
deviceId Int // 关联的设备ID key String @db.VarChar(191)
password String? // 配置密码,可以为空 value Json @db.Json
deviceType String? // 自动设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长) creatorip String? @default("") @db.VarChar(191)
isReadOnly Boolean @default(false) // 是否只读 createdat DateTime @default(now()) @db.Timestamptz(6)
createdAt DateTime @default(now()) updatedat DateTime @db.Timestamptz(6)
updatedAt DateTime @updatedAt device device @relation(fields: [deviceid], references: [id], onDelete: Cascade)
// 关联关系 @@id([deviceid, key], map: "idx_18075_primary")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@unique([deviceId, password]) // 同一设备的密码必须唯一
} }

View File

@ -1,5 +1,5 @@
import {Router} from "express"; import {Router} from "express";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import crypto from "crypto"; import crypto from "crypto";
import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js"; import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js";
import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.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"; import errors from "../utils/errors.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
// 存储OAuth state防止CSRF攻击生产环境应使用Redis等 // 存储OAuth state防止CSRF攻击生产环境应使用Redis等
const oauthStates = new Map(); const oauthStates = new Map();

View File

@ -1,14 +1,12 @@
import {Router} from "express"; import {Router} from "express";
import {uuidAuth} from "../middleware/uuidAuth.js"; import {uuidAuth} from "../middleware/uuidAuth.js";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
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"; import {verifyDevicePassword} from "../utils/crypto.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
/** /**
* GET /apps/devices/:uuid/apps * GET /apps/devices/:uuid/apps
* 获取设备安装的应用列表 (公开接口无需认证) * 获取设备安装的应用列表 (公开接口无需认证)
@ -291,7 +289,7 @@ router.post(
} }
// 读取设备的 classworks-list-main 键值 // 读取设备的 classworks-list-main 键值
const kvRecord = await prisma.kVStore.findUnique({ const kvRecord = await prisma.kvstore.findUnique({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: appInstall.deviceId, deviceId: appInstall.deviceId,

View File

@ -1,12 +1,10 @@
import {Router} from "express"; import {Router} from "express";
import {jwtAuth} from "../middleware/jwt-auth.js"; import {jwtAuth} from "../middleware/jwt-auth.js";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
/** /**
* GET /auto-auth/devices/:uuid/auth-configs * GET /auto-auth/devices/:uuid/auth-configs
* 获取设备的所有自动授权配置 (需要 JWT 认证且设备必须绑定到该账户) * 获取设备的所有自动授权配置 (需要 JWT 认证且设备必须绑定到该账户)

View File

@ -1,10 +1,9 @@
import {Router} from "express"; import {Router} from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js"; import deviceCodeStore from "../utils/deviceCodeStore.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
/** /**

View File

@ -1,14 +1,12 @@
import {Router} from "express"; import {Router} from "express";
import {extractDeviceInfo} from "../middleware/uuidAuth.js"; import {extractDeviceInfo} from "../middleware/uuidAuth.js";
import {PrismaClient} from "@prisma/client"; import {prisma} from "../utils/prisma.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import {getOnlineDevices} from "../utils/socket.js"; import {getOnlineDevices} from "../utils/socket.js";
import {registeredDevicesTotal} from "../utils/metrics.js"; import {registeredDevicesTotal} from "../utils/metrics.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
/** /**
* 为新设备创建默认的自动登录配置 * 为新设备创建默认的自动登录配置
* @param {number} deviceId - 设备ID * @param {number} deviceId - 设备ID

View File

@ -10,12 +10,10 @@ import {
tokenWriteLimiter tokenWriteLimiter
} from "../middleware/rateLimiter.js"; } from "../middleware/rateLimiter.js";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client"; import { prisma } from "../utils/prisma.js";
const router = Router(); const router = Router();
const prisma = new PrismaClient();
// 使用KV专用token认证 // 使用KV专用token认证
router.use(kvTokenAuth); router.use(kvTokenAuth);

View File

@ -1,8 +1,6 @@
import {PrismaClient} from "@prisma/client"; import {prisma} from "./prisma.js";
import {keysTotal} from "./metrics.js"; import {keysTotal} from "./metrics.js";
const prisma = new PrismaClient();
class KVStore { class KVStore {
/** /**
* 通过设备ID和键名获取值 * 通过设备ID和键名获取值
@ -11,7 +9,7 @@ class KVStore {
* @returns {object|null} 键对应的值或null * @returns {object|null} 键对应的值或null
*/ */
async get(deviceId, key) { async get(deviceId, key) {
const item = await prisma.kVStore.findUnique({ const item = await prisma.kvstore.findUnique({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
@ -29,7 +27,7 @@ class KVStore {
* @returns {object|null} 键的完整信息或null * @returns {object|null} 键的完整信息或null
*/ */
async getMetadata(deviceId, key) { async getMetadata(deviceId, key) {
const item = await prisma.kVStore.findUnique({ const item = await prisma.kvstore.findUnique({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
@ -68,7 +66,7 @@ class KVStore {
* @returns {object} 创建或更新的记录 * @returns {object} 创建或更新的记录
*/ */
async upsert(deviceId, key, value, creatorIp = "") { async upsert(deviceId, key, value, creatorIp = "") {
const item = await prisma.kVStore.upsert({ const item = await prisma.kvstore.upsert({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
@ -88,7 +86,7 @@ class KVStore {
}); });
// 更新键总数指标 // 更新键总数指标
const totalKeys = await prisma.kVStore.count(); const totalKeys = await prisma.kvstore.count();
keysTotal.set(totalKeys); keysTotal.set(totalKeys);
// 返回带有设备ID和原始键的结果 // 返回带有设备ID和原始键的结果
@ -117,7 +115,7 @@ class KVStore {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
try { try {
const item = await tx.kVStore.upsert({ const item = await tx.kvstore.upsert({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
@ -152,7 +150,7 @@ class KVStore {
}); });
// 在事务完成后,一次性更新指标 // 在事务完成后,一次性更新指标
const totalKeys = await prisma.kVStore.count(); const totalKeys = await prisma.kvstore.count();
keysTotal.set(totalKeys); keysTotal.set(totalKeys);
return { results, errors }; return { results, errors };
@ -166,7 +164,7 @@ class KVStore {
*/ */
async delete(deviceId, key) { async delete(deviceId, key) {
try { try {
const item = await prisma.kVStore.delete({ const item = await prisma.kvstore.delete({
where: { where: {
deviceId_key: { deviceId_key: {
deviceId: deviceId, deviceId: deviceId,
@ -176,7 +174,7 @@ class KVStore {
}); });
// 更新键总数指标 // 更新键总数指标
const totalKeys = await prisma.kVStore.count(); const totalKeys = await prisma.kvstore.count();
keysTotal.set(totalKeys); keysTotal.set(totalKeys);
return item ? {...item, deviceId, key} : null; return item ? {...item, deviceId, key} : null;
@ -203,7 +201,7 @@ class KVStore {
orderBy[sortBy] = sortDir.toLowerCase(); orderBy[sortBy] = sortDir.toLowerCase();
// 查询设备的所有键 // 查询设备的所有键
const items = await prisma.kVStore.findMany({ const items = await prisma.kvstore.findMany({
where: { where: {
deviceId: deviceId, deviceId: deviceId,
}, },
@ -246,7 +244,7 @@ class KVStore {
orderBy[sortBy] = sortDir.toLowerCase(); orderBy[sortBy] = sortDir.toLowerCase();
// 查询设备的所有键,只选择键名 // 查询设备的所有键,只选择键名
const items = await prisma.kVStore.findMany({ const items = await prisma.kvstore.findMany({
where: { where: {
deviceId: deviceId, deviceId: deviceId,
}, },
@ -268,7 +266,7 @@ class KVStore {
* @returns {number} 键值对数量 * @returns {number} 键值对数量
*/ */
async count(deviceId) { async count(deviceId) {
const count = await prisma.kVStore.count({ const count = await prisma.kvstore.count({
where: { where: {
deviceId: deviceId, deviceId: deviceId,
}, },
@ -283,15 +281,15 @@ class KVStore {
*/ */
async getStats(deviceId) { async getStats(deviceId) {
const [totalKeys, oldestKey, newestKey] = await Promise.all([ const [totalKeys, oldestKey, newestKey] = await Promise.all([
prisma.kVStore.count({ prisma.kvstore.count({
where: { deviceId }, where: { deviceId },
}), }),
prisma.kVStore.findFirst({ prisma.kvstore.findFirst({
where: { deviceId }, where: { deviceId },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
select: { createdAt: true, key: true }, select: { createdAt: true, key: true },
}), }),
prisma.kVStore.findFirst({ prisma.kvstore.findFirst({
where: { deviceId }, where: { deviceId },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
select: { updatedAt: true, key: true }, select: { updatedAt: true, key: true },

View File

@ -1,4 +1,5 @@
import client from 'prom-client'; import client from 'prom-client';
import { prisma } from './prisma.js';
// 创建自定义注册表(不包含默认指标) // 创建自定义注册表(不包含默认指标)
const register = new client.Registry(); const register = new client.Registry();
@ -27,18 +28,14 @@ export const keysTotal = new client.Gauge({
// 初始化指标数据 // 初始化指标数据
export async function initializeMetrics() { export async function initializeMetrics() {
try { try {
const {PrismaClient} = await import('@prisma/client');
const prisma = new PrismaClient();
// 获取已注册设备总数 // 获取已注册设备总数
const deviceCount = await prisma.device.count(); const deviceCount = await prisma.device.count();
registeredDevicesTotal.set(deviceCount); registeredDevicesTotal.set(deviceCount);
// 获取已创建键总数 // 获取已创建键总数
const keyCount = await prisma.kVStore.count(); const keyCount = await prisma.kvstore.count();
keysTotal.set(keyCount); keysTotal.set(keyCount);
await prisma.$disconnect();
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount); console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
} catch (error) { } catch (error) {
console.error('Failed to initialize metrics:', error); console.error('Failed to initialize metrics:', error);

10
utils/prisma.js Normal file
View 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 }

View File

@ -1,8 +1,6 @@
import {PrismaClient} from "@prisma/client"; import {prisma} from "./prisma.js";
import kvStore from "./kvStore.js"; import kvStore from "./kvStore.js";
const prisma = new PrismaClient();
// 系统保留UUID用于存储站点信息 // 系统保留UUID用于存储站点信息
const SYSTEM_DEVICE_UUID = "00000000-0000-4000-8000-000000000000"; const SYSTEM_DEVICE_UUID = "00000000-0000-4000-8000-000000000000";

View File

@ -13,7 +13,7 @@
*/ */
import { Server } from "socket.io"; import { Server } from "socket.io";
import { PrismaClient } from "@prisma/client"; import { prisma } from "./prisma.js";
import { onlineDevicesGauge } from "./metrics.js"; import { onlineDevicesGauge } from "./metrics.js";
import DeviceDetector from "node-device-detector"; import DeviceDetector from "node-device-detector";
import ClientHints from "node-device-detector/client-hints.js"; import ClientHints from "node-device-detector/client-hints.js";
@ -38,7 +38,6 @@ const tokenInfoCache = new Map();
// 事件历史记录每个设备最多保存1000条事件记录 // 事件历史记录每个设备最多保存1000条事件记录
const eventHistory = new Map(); // uuid -> Array<EventRecord> const eventHistory = new Map(); // uuid -> Array<EventRecord>
const MAX_EVENT_HISTORY = 1000; const MAX_EVENT_HISTORY = 1000;
const prisma = new PrismaClient();
/** /**
* 检测设备并生成友好的设备名称 * 检测设备并生成友好的设备名称

View File

@ -1,8 +1,6 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import crypto from 'crypto'; import crypto from 'crypto';
import {PrismaClient} from '@prisma/client'; import {prisma} from './prisma.js';
const prisma = new PrismaClient();
// Token 配置 // Token 配置
const ACCESS_TOKEN_SECRET = process.env.JWT_SECRET || 'your-access-token-secret-change-this-in-production'; const ACCESS_TOKEN_SECRET = process.env.JWT_SECRET || 'your-access-token-secret-change-this-in-production';