mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-07 04:43:09 +00:00
feat: Add AutoAuth functionality and enhance Apps API
- Introduced AutoAuth model to manage automatic authorization configurations for devices. - Added new endpoint to obtain token via namespace and password for automatic authorization. - Implemented functionality to set student names for student-type tokens. - Enhanced AppInstall model to include deviceType and isReadOnly fields. - Updated device creation to allow custom namespaces and ensure uniqueness. - Added routes for managing AutoAuth configurations, including CRUD operations. - Implemented checks for read-only tokens in KV operations. - Created detailed API documentation for AutoAuth and new Apps API endpoints. - Added migration scripts to accommodate new database schema changes.
This commit is contained in:
parent
02c0da037f
commit
bb61e6e6f5
276
API_AUTOAUTH.md
Normal file
276
API_AUTOAUTH.md
Normal file
@ -0,0 +1,276 @@
|
||||
# AutoAuth 和新增 Apps API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了自动授权 (AutoAuth) 相关的 API 接口以及新增的应用管理接口。
|
||||
|
||||
---
|
||||
|
||||
## Apps API 新增接口
|
||||
|
||||
### 1. 通过 namespace 和密码获取 token
|
||||
|
||||
**端点**: `POST /apps/auth/token`
|
||||
|
||||
**描述**: 通过设备的 namespace 和密码进行自动授权,创建新的 AppInstall 并返回 token。
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"namespace": "string (必填)",
|
||||
"password": "string (可选,根据自动授权配置)",
|
||||
"appId": "string (必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** (201 Created):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "string",
|
||||
"deviceType": "string | null",
|
||||
"isReadOnly": boolean,
|
||||
"installedAt": "datetime"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
- `400 Bad Request`: 缺少必填字段
|
||||
- `404 Not Found`: 设备不存在或 namespace 不正确
|
||||
- `401 Unauthorized`: 密码不正确或需要提供密码
|
||||
|
||||
**说明**:
|
||||
- 该接口会查找匹配的 AutoAuth 配置
|
||||
- 如果提供了密码,会验证密码是否匹配任何自动授权配置
|
||||
- 如果没有提供密码,会查找无密码的自动授权配置
|
||||
- 根据匹配的 AutoAuth 配置设置 `deviceType` 和 `isReadOnly` 属性
|
||||
|
||||
---
|
||||
|
||||
### 2. 设置学生名称
|
||||
|
||||
**端点**: `POST /apps/tokens/:token/set-student-name`
|
||||
|
||||
**描述**: 为学生类型的 token 设置名称(更新 note 字段)。
|
||||
|
||||
**URL 参数**:
|
||||
- `token`: AppInstall 的 token
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"name": "string (必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "string",
|
||||
"name": "string",
|
||||
"updatedAt": "datetime"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
- `400 Bad Request`: 缺少名称或名称不在学生列表中
|
||||
- `403 Forbidden`: token 类型不是 student
|
||||
- `404 Not Found`: token 不存在或设备未设置学生列表
|
||||
|
||||
**说明**:
|
||||
- 只有 `deviceType` 为 `student` 的 token 才能使用此接口
|
||||
- 会验证提供的名称是否存在于设备的 `classworks-list-main` 键值中
|
||||
- 学生列表格式: `[{"id": 1, "name": "学生1"}, {"id": 2, "name": "学生2"}]`
|
||||
|
||||
---
|
||||
|
||||
## AutoAuth 管理 API
|
||||
|
||||
> 🔐 **所有 AutoAuth 管理接口都需要 JWT Account Token 认证**
|
||||
>
|
||||
> **重要**: 只有已绑定账户的设备才能使用这些接口。未绑定账户的设备无法管理 AutoAuth 配置。
|
||||
>
|
||||
> 通过 HTTP Headers 提供:
|
||||
> - `Authorization`: `Bearer {jwt_token}` - 账户的 JWT Token
|
||||
|
||||
### 1. 获取设备的自动授权配置列表
|
||||
|
||||
**端点**: `GET /auto-auth/devices/:uuid/auth-configs`
|
||||
|
||||
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||
|
||||
**URL 参数**:
|
||||
- `uuid`: 设备的 UUID
|
||||
|
||||
**成功响应** (200 OK):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"configs": [
|
||||
{
|
||||
"id": "string",
|
||||
"hasPassword": boolean,
|
||||
"deviceType": "string | null",
|
||||
"isReadOnly": boolean,
|
||||
"createdAt": "datetime",
|
||||
"updatedAt": "datetime"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 返回的配置不包含实际的密码哈希值,只显示是否有密码
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建自动授权配置
|
||||
|
||||
**端点**: `POST /auto-auth/devices/:uuid/auth-configs`
|
||||
|
||||
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||
|
||||
**URL 参数**:
|
||||
- `uuid`: 设备的 UUID
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"password": "string (可选)",
|
||||
"deviceType": "string (可选: teacher|student|classroom|parent)",
|
||||
"isReadOnly": boolean (可选,默认 false)
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** (201 Created):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"config": {
|
||||
"id": "string",
|
||||
"hasPassword": boolean,
|
||||
"deviceType": "string | null",
|
||||
"isReadOnly": boolean,
|
||||
"createdAt": "datetime"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
- `400 Bad Request`: 设备类型无效或密码配置已存在
|
||||
|
||||
**说明**:
|
||||
- 同一设备的密码必须唯一(包括空密码)
|
||||
- `deviceType` 必须是 `teacher`、`student`、`classroom`、`parent` 之一,或为空
|
||||
|
||||
---
|
||||
|
||||
### 3. 更新自动授权配置
|
||||
|
||||
**端点**: `PUT /auto-auth/devices/:uuid/auth-configs/:configId`
|
||||
|
||||
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||
|
||||
**URL 参数**:
|
||||
- `uuid`: 设备的 UUID
|
||||
- `configId`: 自动授权配置的 ID
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"password": "string (可选)",
|
||||
"deviceType": "string (可选: teacher|student|classroom|parent)",
|
||||
"isReadOnly": boolean (可选)
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** (200 OK):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"config": {
|
||||
"id": "string",
|
||||
"hasPassword": boolean,
|
||||
"deviceType": "string | null",
|
||||
"isReadOnly": boolean,
|
||||
"updatedAt": "datetime"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
- `400 Bad Request`: 设备类型无效或新密码与其他配置冲突
|
||||
- `403 Forbidden`: 无权操作此配置
|
||||
- `404 Not Found`: 配置不存在
|
||||
|
||||
**说明**:
|
||||
- 只能更新属于当前设备的配置
|
||||
- 更新密码时会检查是否与该设备的其他配置冲突
|
||||
|
||||
---
|
||||
|
||||
### 4. 删除自动授权配置
|
||||
|
||||
**端点**: `DELETE /auto-auth/devices/:uuid/auth-configs/:configId`
|
||||
|
||||
**认证**: 需要 JWT Token (账户必须是设备的拥有者)
|
||||
|
||||
**URL 参数**:
|
||||
- `uuid`: 设备的 UUID
|
||||
- `configId`: 自动授权配置的 ID
|
||||
|
||||
**成功响应** (204 No Content):
|
||||
- 无响应体
|
||||
|
||||
**错误响应**:
|
||||
- `403 Forbidden`: 无权操作此配置
|
||||
- `404 Not Found`: 配置不存在
|
||||
|
||||
**说明**:
|
||||
- 只能删除属于当前设备的配置
|
||||
|
||||
---
|
||||
|
||||
## 设备类型 (deviceType)
|
||||
|
||||
可选的设备类型值:
|
||||
- `teacher`: 教师
|
||||
- `student`: 学生
|
||||
- `classroom`: 班级一体机
|
||||
- `parent`: 家长
|
||||
- `null`: 未指定类型
|
||||
|
||||
---
|
||||
|
||||
## 使用流程示例
|
||||
|
||||
### 场景 1: 学生使用 namespace 登录
|
||||
|
||||
1. 学生输入班级的 namespace 和密码
|
||||
2. 调用 `POST /apps/auth/token` 获取 token
|
||||
3. 使用返回的 token 访问 KV 存储
|
||||
4. 如果是学生类型,调用 `POST /apps/tokens/:token/set-student-name` 设置自己的名称
|
||||
|
||||
### 场景 2: 管理员配置自动授权
|
||||
|
||||
1. 管理员通过账户登录获取 JWT Token
|
||||
2. 调用 `POST /auto-auth/devices/:uuid/auth-configs` 创建多个授权配置:
|
||||
- 教师密码(deviceType: teacher, isReadOnly: false)
|
||||
- 学生密码(deviceType: student, isReadOnly: false)
|
||||
- 家长密码(deviceType: parent, isReadOnly: true)
|
||||
3. 学生/教师/家长使用对应密码通过 namespace 登录
|
||||
|
||||
**注意**: 设备必须已绑定到管理员的账户才能配置 AutoAuth
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **密码安全**: 所有密码都使用 bcrypt 进行哈希存储
|
||||
2. **唯一性约束**:
|
||||
- 同一设备的 namespace 必须唯一
|
||||
- 同一设备的 AutoAuth 密码必须唯一(包括 null)
|
||||
3. **级联删除**: 删除设备会级联删除所有相关的 AutoAuth 配置和 AppInstall 记录
|
||||
4. **只读限制**: isReadOnly 为 true 的 token 在 KV 操作中会受到写入限制
|
||||
5. **账户绑定要求**: 只有已绑定账户的设备才能管理 AutoAuth 配置,未绑定账户的设备无法使用 AutoAuth 管理接口
|
||||
0
API_QUICK_REFERENCE.md
Normal file
0
API_QUICK_REFERENCE.md
Normal file
0
NEW_APIS_SUMMARY.md
Normal file
0
NEW_APIS_SUMMARY.md
Normal file
4
app.js
4
app.js
@ -20,6 +20,7 @@ import appsRouter from "./routes/apps.js";
|
||||
import deviceRouter from "./routes/device.js";
|
||||
import deviceAuthRouter from "./routes/device-auth.js";
|
||||
import accountsRouter from "./routes/accounts.js";
|
||||
import autoAuthRouter from "./routes/auto-auth.js";
|
||||
|
||||
var app = express();
|
||||
|
||||
@ -87,6 +88,9 @@ app.get("/check", apiLimiter, (req, res) => {
|
||||
// Mount the Apps router with API rate limiting
|
||||
app.use("/apps", apiLimiter, appsRouter);
|
||||
|
||||
// Mount the Auto Auth router with API rate limiting
|
||||
app.use("/auto-auth", apiLimiter, autoAuthRouter);
|
||||
|
||||
// Mount the Device router with API rate limiting
|
||||
app.use("/devices", apiLimiter, deviceRouter);
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ const prisma = new PrismaClient();
|
||||
/**
|
||||
* 设备中间件 - 统一处理设备UUID
|
||||
*
|
||||
* 从req.params.deviceUuid、req.params.namespace或req.body.deviceUuid获取UUID
|
||||
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
|
||||
* 如果设备不存在则自动创建
|
||||
* 将设备信息存储到res.locals.device
|
||||
*
|
||||
@ -25,7 +25,7 @@ const prisma = new PrismaClient();
|
||||
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
|
||||
*/
|
||||
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
const deviceUuid = req.params.deviceUuid || req.params.namespace || req.body.deviceUuid;
|
||||
const deviceUuid = req.params.deviceUuid || req.body.deviceUuid;
|
||||
|
||||
if (!deviceUuid) {
|
||||
return next(errors.createError(400, "缺少设备UUID"));
|
||||
@ -65,7 +65,7 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
|
||||
*/
|
||||
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
const deviceUuid = req.params.deviceUuid || req.params.namespace;
|
||||
const deviceUuid = req.params.deviceUuid ;
|
||||
|
||||
if (!deviceUuid) {
|
||||
return next(errors.createError(400, "缺少设备UUID"));
|
||||
|
||||
@ -39,7 +39,7 @@ export const kvTokenAuth = async (req, res, next) => {
|
||||
res.locals.device = appInstall.device;
|
||||
res.locals.appInstall = appInstall;
|
||||
res.locals.deviceId = appInstall.device.id;
|
||||
|
||||
res.locals.token = token;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@ -54,6 +54,13 @@ export const kvTokenAuth = async (req, res, next) => {
|
||||
* 3. Body: token 或 apptoken
|
||||
*/
|
||||
function extractToken(req) {
|
||||
// 优先从 Authorization header 提取 Bearer token(支持大小写)
|
||||
const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||
if (authHeader) {
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
|
||||
return (
|
||||
req.headers["x-app-token"] ||
|
||||
req.query.token ||
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"prisma": "prisma generate",
|
||||
"dev": "NODE_ENV=development nodemon node .bin/www",
|
||||
"get-token": "node ./cli/get-token.js"
|
||||
},
|
||||
@ -35,6 +34,6 @@
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "6.16.2"
|
||||
"prisma": "^6.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@ -28,7 +28,7 @@ importers:
|
||||
version: 1.34.0
|
||||
'@prisma/client':
|
||||
specifier: 6.16.2
|
||||
version: 6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)
|
||||
version: 6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3)
|
||||
axios:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(debug@4.4.1)
|
||||
@ -79,8 +79,8 @@ importers:
|
||||
version: 11.1.0
|
||||
devDependencies:
|
||||
prisma:
|
||||
specifier: 6.16.2
|
||||
version: 6.16.2(typescript@5.8.3)
|
||||
specifier: ^6.18.0
|
||||
version: 6.18.0(typescript@5.8.3)
|
||||
|
||||
kv-admin:
|
||||
dependencies:
|
||||
@ -902,23 +902,23 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@prisma/config@6.16.2':
|
||||
resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==}
|
||||
'@prisma/config@6.18.0':
|
||||
resolution: {integrity: sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==}
|
||||
|
||||
'@prisma/debug@6.16.2':
|
||||
resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==}
|
||||
'@prisma/debug@6.18.0':
|
||||
resolution: {integrity: sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==}
|
||||
|
||||
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43':
|
||||
resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==}
|
||||
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f':
|
||||
resolution: {integrity: sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==}
|
||||
|
||||
'@prisma/engines@6.16.2':
|
||||
resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==}
|
||||
'@prisma/engines@6.18.0':
|
||||
resolution: {integrity: sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==}
|
||||
|
||||
'@prisma/fetch-engine@6.16.2':
|
||||
resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==}
|
||||
'@prisma/fetch-engine@6.18.0':
|
||||
resolution: {integrity: sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==}
|
||||
|
||||
'@prisma/get-platform@6.16.2':
|
||||
resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==}
|
||||
'@prisma/get-platform@6.18.0':
|
||||
resolution: {integrity: sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
@ -1622,8 +1622,8 @@ packages:
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
effect@3.16.12:
|
||||
resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==}
|
||||
effect@3.18.4:
|
||||
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
|
||||
|
||||
ejs@3.1.10:
|
||||
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
|
||||
@ -1867,10 +1867,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.4.2:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@ -2206,8 +2202,8 @@ packages:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prisma@6.16.2:
|
||||
resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==}
|
||||
prisma@6.18.0:
|
||||
resolution: {integrity: sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==}
|
||||
engines: {node: '>=18.18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -3517,40 +3513,40 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0)
|
||||
|
||||
'@prisma/client@6.16.2(prisma@6.16.2(typescript@5.8.3))(typescript@5.8.3)':
|
||||
'@prisma/client@6.16.2(prisma@6.18.0(typescript@5.8.3))(typescript@5.8.3)':
|
||||
optionalDependencies:
|
||||
prisma: 6.16.2(typescript@5.8.3)
|
||||
prisma: 6.18.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
|
||||
'@prisma/config@6.16.2':
|
||||
'@prisma/config@6.18.0':
|
||||
dependencies:
|
||||
c12: 3.1.0
|
||||
deepmerge-ts: 7.1.5
|
||||
effect: 3.16.12
|
||||
effect: 3.18.4
|
||||
empathic: 2.0.0
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@prisma/debug@6.16.2': {}
|
||||
'@prisma/debug@6.18.0': {}
|
||||
|
||||
'@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': {}
|
||||
'@prisma/engines-version@6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f': {}
|
||||
|
||||
'@prisma/engines@6.16.2':
|
||||
'@prisma/engines@6.18.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 6.16.2
|
||||
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
||||
'@prisma/fetch-engine': 6.16.2
|
||||
'@prisma/get-platform': 6.16.2
|
||||
'@prisma/debug': 6.18.0
|
||||
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
|
||||
'@prisma/fetch-engine': 6.18.0
|
||||
'@prisma/get-platform': 6.18.0
|
||||
|
||||
'@prisma/fetch-engine@6.16.2':
|
||||
'@prisma/fetch-engine@6.18.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 6.16.2
|
||||
'@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
||||
'@prisma/get-platform': 6.16.2
|
||||
'@prisma/debug': 6.18.0
|
||||
'@prisma/engines-version': 6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f
|
||||
'@prisma/get-platform': 6.18.0
|
||||
|
||||
'@prisma/get-platform@6.16.2':
|
||||
'@prisma/get-platform@6.18.0':
|
||||
dependencies:
|
||||
'@prisma/debug': 6.16.2
|
||||
'@prisma/debug': 6.18.0
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
@ -4076,7 +4072,7 @@ snapshots:
|
||||
dotenv: 16.6.1
|
||||
exsolve: 1.0.7
|
||||
giget: 2.0.0
|
||||
jiti: 2.4.2
|
||||
jiti: 2.6.1
|
||||
ohash: 2.0.11
|
||||
pathe: 2.0.3
|
||||
perfect-debounce: 1.0.0
|
||||
@ -4208,7 +4204,7 @@ snapshots:
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.16.12:
|
||||
effect@3.18.4:
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
fast-check: 3.23.2
|
||||
@ -4507,8 +4503,6 @@ snapshots:
|
||||
filelist: 1.0.4
|
||||
minimatch: 3.1.2
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-base64@3.7.7: {}
|
||||
@ -4789,10 +4783,10 @@ snapshots:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prisma@6.16.2(typescript@5.8.3):
|
||||
prisma@6.18.0(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@prisma/config': 6.16.2
|
||||
'@prisma/engines': 6.16.2
|
||||
'@prisma/config': 6.18.0
|
||||
'@prisma/engines': 6.18.0
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[namespace]` on the table `Device` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `AppInstall` ADD COLUMN `deviceType` VARCHAR(191) NULL,
|
||||
ADD COLUMN `isReadOnly` BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `Device` ADD COLUMN `namespace` VARCHAR(191) NULL;
|
||||
|
||||
-- 将所有设备的 namespace 设置为对应的 uuid 值,避免唯一键冲突
|
||||
UPDATE `Device` SET `namespace` = `uuid` WHERE `namespace` IS NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `AutoAuth` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`deviceId` INTEGER NOT NULL,
|
||||
`password` VARCHAR(191) NULL,
|
||||
`deviceType` VARCHAR(191) NULL,
|
||||
`isReadOnly` BOOLEAN NOT NULL DEFAULT false,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `AutoAuth_deviceId_password_key`(`deviceId`, `password`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 为每个设备创建默认的 AutoAuth 记录,将 Device.password 复制为 AutoAuth.password
|
||||
INSERT INTO `AutoAuth` (`id`, `deviceId`, `password`, `deviceType`, `isReadOnly`, `createdAt`, `updatedAt`)
|
||||
SELECT
|
||||
CONCAT('autoauth_', UUID()),
|
||||
`id`,
|
||||
`password`,
|
||||
NULL,
|
||||
false,
|
||||
CURRENT_TIMESTAMP(3),
|
||||
CURRENT_TIMESTAMP(3)
|
||||
FROM `Device`;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `Device_namespace_key` ON `Device`(`namespace`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `AutoAuth` ADD CONSTRAINT `AutoAuth_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -8,7 +8,7 @@ datasource db {
|
||||
}
|
||||
|
||||
model KVStore {
|
||||
deviceId Int // 设备ID,作为namespace的一部分
|
||||
deviceId Int
|
||||
key String
|
||||
value Json
|
||||
creatorIp String? @default("")
|
||||
@ -48,22 +48,41 @@ model Device {
|
||||
updatedAt DateTime @updatedAt
|
||||
password String?
|
||||
passwordHint String?
|
||||
namespace String? @unique // 用户自定义的唯一命名空间
|
||||
|
||||
// 关联关系
|
||||
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
||||
appInstalls AppInstall[]
|
||||
kvStore KVStore[] // 设备相关的KV存储
|
||||
autoAuths AutoAuth[] // 自动授权配置
|
||||
}
|
||||
|
||||
model AppInstall {
|
||||
id String @id @default(cuid())
|
||||
deviceId Int // 关联的设备ID
|
||||
appId String // 应用ID (SHA256 hash)
|
||||
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限
|
||||
token String @unique // 应用安装的唯一访问令牌,拥有完整KV读写权限
|
||||
note String? // 安装备注
|
||||
isReadOnly Boolean @default(false) // 是否只读
|
||||
deviceType String? // 设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长)
|
||||
installedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 关联关系
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AutoAuth {
|
||||
id String @id @default(cuid())
|
||||
deviceId Int // 关联的设备ID
|
||||
password String? // 配置密码,可以为空
|
||||
deviceType String? // 自动设备类型: teacher(教师), student(学生), classroom(班级一体机), parent(家长)
|
||||
isReadOnly Boolean @default(false) // 是否只读
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 关联关系
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([deviceId, password]) // 同一设备的密码必须唯一
|
||||
}
|
||||
|
||||
216
routes/apps.js
216
routes/apps.js
@ -2,9 +2,11 @@ import { Router } from "express";
|
||||
const router = Router();
|
||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import errors from "../utils/errors.js";
|
||||
import { verifyDevicePassword } from "../utils/crypto.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -158,4 +160,218 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /apps/auth/token
|
||||
* 通过 namespace 和密码获取 token (自动授权)
|
||||
* Body: { namespace: string, password: string, appId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/auth/token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { namespace, password, appId } = req.body;
|
||||
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
|
||||
if (!appId) {
|
||||
return next(errors.createError(400, "需要提供 appId"));
|
||||
}
|
||||
|
||||
// 通过 namespace 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { namespace },
|
||||
include: {
|
||||
autoAuths: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在或 namespace 不正确"));
|
||||
}
|
||||
|
||||
// 查找匹配的自动授权配置
|
||||
let matchedAutoAuth = null;
|
||||
|
||||
// 如果提供了密码,查找匹配密码的自动授权
|
||||
if (password) {
|
||||
// 首先尝试直接匹配明文密码
|
||||
matchedAutoAuth = device.autoAuths.find(auth => auth.password === password);
|
||||
|
||||
// 如果没有匹配到,尝试验证哈希密码(向后兼容)
|
||||
if (!matchedAutoAuth) {
|
||||
for (const autoAuth of device.autoAuths) {
|
||||
if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头
|
||||
try {
|
||||
if (await verifyDevicePassword(password, autoAuth.password)) {
|
||||
matchedAutoAuth = autoAuth;
|
||||
|
||||
// 自动迁移:将哈希密码更新为明文密码
|
||||
await prisma.autoAuth.update({
|
||||
where: { id: autoAuth.id },
|
||||
data: { password: password }, // 保存明文密码
|
||||
});
|
||||
|
||||
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果验证失败,继续尝试下一个
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "密码不正确"));
|
||||
}
|
||||
} else {
|
||||
// 如果没有提供密码,查找密码为空的自动授权
|
||||
matchedAutoAuth = device.autoAuths.find(auth => !auth.password);
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "需要提供密码"));
|
||||
}
|
||||
}
|
||||
|
||||
// 根据自动授权配置创建 AppInstall
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const installation = await prisma.appInstall.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
appId: appId,
|
||||
token,
|
||||
note: null,
|
||||
isReadOnly: matchedAutoAuth.isReadOnly,
|
||||
deviceType: matchedAutoAuth.deviceType,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
token: installation.token,
|
||||
deviceType: installation.deviceType,
|
||||
isReadOnly: installation.isReadOnly,
|
||||
installedAt: installation.installedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /apps/tokens/:token/set-student-name
|
||||
* 设置学生名称 (仅限学生类型的 token)
|
||||
* Body: { name: string }
|
||||
*/
|
||||
router.post(
|
||||
"/tokens/:token/set-student-name",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return next(errors.createError(400, "需要提供学生名称"));
|
||||
}
|
||||
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
|
||||
// 验证 token 类型是否为 student
|
||||
if (appInstall.deviceType !== 'student') {
|
||||
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
|
||||
}
|
||||
|
||||
// 读取设备的 classworks-list-main 键值
|
||||
const kvRecord = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: appInstall.deviceId,
|
||||
key: 'classworks-list-main',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!kvRecord) {
|
||||
return next(errors.createError(404, "设备未设置学生列表"));
|
||||
}
|
||||
|
||||
// 解析学生列表
|
||||
let studentList;
|
||||
try {
|
||||
studentList = kvRecord.value;
|
||||
if (!Array.isArray(studentList)) {
|
||||
return next(errors.createError(500, "学生列表格式错误"));
|
||||
}
|
||||
} catch (error) {
|
||||
return next(errors.createError(500, "无法解析学生列表"));
|
||||
}
|
||||
|
||||
// 验证名称是否在学生列表中
|
||||
const studentExists = studentList.some(student => student.name === name);
|
||||
|
||||
if (!studentExists) {
|
||||
return next(errors.createError(400, "该名称不在学生列表中"));
|
||||
}
|
||||
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: { id: appInstall.id },
|
||||
data: { note: name },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
name: updatedInstall.note,
|
||||
deviceType: updatedInstall.deviceType,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /apps/tokens/:token/note
|
||||
* 更新令牌的备注信息
|
||||
* Body: { note: string }
|
||||
*/
|
||||
router.put(
|
||||
"/tokens/:token/note",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
const { note } = req.body;
|
||||
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: { id: appInstall.id },
|
||||
data: { note: note || null },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
note: updatedInstall.note,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
343
routes/auto-auth.js
Normal file
343
routes/auto-auth.js
Normal file
@ -0,0 +1,343 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* GET /auto-auth/devices/:uuid/auth-configs
|
||||
* 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
*/
|
||||
router.get(
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
const autoAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// 返回配置,智能处理密码显示
|
||||
const configs = autoAuths.map(auth => {
|
||||
// 检查是否是 bcrypt 哈希密码
|
||||
const isHashedPassword = auth.password && auth.password.startsWith('$2');
|
||||
|
||||
return {
|
||||
id: auth.id,
|
||||
password: isHashedPassword ? null : auth.password, // 哈希密码不返回
|
||||
isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码
|
||||
deviceType: auth.deviceType,
|
||||
isReadOnly: auth.isReadOnly,
|
||||
createdAt: auth.createdAt,
|
||||
updatedAt: auth.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
configs,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /auto-auth/devices/:uuid/auth-configs
|
||||
* 创建新的自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||
*/
|
||||
router.post(
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { password, deviceType, isReadOnly } = req.body;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 验证 deviceType 如果提供的话
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== undefined && password !== '') ? password : null;
|
||||
|
||||
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
});
|
||||
|
||||
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
||||
|
||||
if (existingAuth) {
|
||||
return next(errors.createError(400, "该密码的自动授权配置已存在"));
|
||||
}
|
||||
|
||||
// 创建新的自动授权配置(密码明文存储)
|
||||
const autoAuth = await prisma.autoAuth.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
password: plainPassword,
|
||||
deviceType: deviceType || null,
|
||||
isReadOnly: isReadOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
config: {
|
||||
id: autoAuth.id,
|
||||
password: autoAuth.password, // 返回明文密码
|
||||
deviceType: autoAuth.deviceType,
|
||||
isReadOnly: autoAuth.isReadOnly,
|
||||
createdAt: autoAuth.createdAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);/**
|
||||
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
||||
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||
*/
|
||||
router.put(
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, configId } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { password, deviceType, isReadOnly } = req.body;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
|
||||
// 验证 deviceType
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = {};
|
||||
|
||||
if (password !== undefined) {
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== '') ? password : null;
|
||||
|
||||
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
});
|
||||
|
||||
const conflictAuth = allAuths.find(auth =>
|
||||
auth.id !== configId && auth.password === plainPassword
|
||||
);
|
||||
|
||||
if (conflictAuth) {
|
||||
return next(errors.createError(400, "该密码已被其他配置使用"));
|
||||
}
|
||||
|
||||
updateData.password = plainPassword;
|
||||
}
|
||||
|
||||
if (deviceType !== undefined) {
|
||||
updateData.deviceType = deviceType || null;
|
||||
}
|
||||
|
||||
if (isReadOnly !== undefined) {
|
||||
updateData.isReadOnly = isReadOnly;
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const updatedAuth = await prisma.autoAuth.update({
|
||||
where: { id: configId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
config: {
|
||||
id: updatedAuth.id,
|
||||
password: updatedAuth.password, // 返回明文密码
|
||||
deviceType: updatedAuth.deviceType,
|
||||
isReadOnly: updatedAuth.isReadOnly,
|
||||
updatedAt: updatedAuth.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /auto-auth/devices/:uuid/auth-configs/:configId
|
||||
* 删除自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
*/
|
||||
router.delete(
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, configId } = req.params;
|
||||
const account = res.locals.account;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
await prisma.autoAuth.delete({
|
||||
where: { id: configId },
|
||||
});
|
||||
|
||||
return res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /auto-auth/devices/:uuid/namespace
|
||||
* 修改设备的 namespace (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
* Body: { namespace: string }
|
||||
*/
|
||||
router.put(
|
||||
"/devices/:uuid/namespace",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { namespace } = req.body;
|
||||
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
|
||||
// 规范化 namespace:去除首尾空格
|
||||
const trimmedNamespace = namespace.trim();
|
||||
|
||||
if (!trimmedNamespace) {
|
||||
return next(errors.createError(400, "namespace 不能为空"));
|
||||
}
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 检查新的 namespace 是否已被其他设备使用
|
||||
if (device.namespace !== trimmedNamespace) {
|
||||
const existingDevice = await prisma.device.findUnique({
|
||||
where: { namespace: trimmedNamespace },
|
||||
});
|
||||
|
||||
if (existingDevice) {
|
||||
return next(errors.createError(409, "该 namespace 已被其他设备使用"));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新设备的 namespace
|
||||
const updatedDevice = await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: { namespace: trimmedNamespace },
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
device: {
|
||||
id: updatedDevice.id,
|
||||
uuid: updatedDevice.uuid,
|
||||
name: updatedDevice.name,
|
||||
namespace: updatedDevice.namespace,
|
||||
updatedAt: updatedDevice.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -16,7 +16,7 @@ const prisma = new PrismaClient();
|
||||
router.post(
|
||||
"/",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, deviceName } = req.body;
|
||||
const { uuid, deviceName, namespace } = req.body;
|
||||
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "设备UUID是必需的"));
|
||||
@ -35,11 +35,24 @@ router.post(
|
||||
return next(errors.createError(409, "设备UUID已存在"));
|
||||
}
|
||||
|
||||
// 处理 namespace:如果没有提供,则使用 uuid
|
||||
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
||||
|
||||
// 检查 namespace 是否已被使用
|
||||
const existingNamespace = await prisma.device.findUnique({
|
||||
where: { namespace: deviceNamespace },
|
||||
});
|
||||
|
||||
if (existingNamespace) {
|
||||
return next(errors.createError(409, "该 namespace 已被使用"));
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
uuid,
|
||||
name: deviceName,
|
||||
namespace: deviceNamespace,
|
||||
},
|
||||
});
|
||||
|
||||
@ -49,6 +62,7 @@ router.post(
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
namespace: device.namespace,
|
||||
createdAt: device.createdAt,
|
||||
},
|
||||
});
|
||||
@ -97,6 +111,7 @@ router.get(
|
||||
avatarUrl: device.account.avatarUrl,
|
||||
} : null,
|
||||
isBoundToAccount: !!device.account,
|
||||
namespace: device.namespace,
|
||||
});
|
||||
})
|
||||
);/**
|
||||
|
||||
@ -56,6 +56,54 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /_token
|
||||
* 获取当前 KV Token 的详细信息(类型、备注等)
|
||||
*/
|
||||
router.get(
|
||||
"/_token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const token = res.locals.token;
|
||||
const deviceId = res.locals.deviceId;
|
||||
|
||||
// 查找当前 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: {
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
name: true,
|
||||
namespace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 信息不存在"));
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: appInstall.token,
|
||||
appId: appInstall.appId,
|
||||
deviceType: appInstall.deviceType,
|
||||
isReadOnly: appInstall.isReadOnly,
|
||||
note: appInstall.note,
|
||||
installedAt: appInstall.installedAt,
|
||||
updatedAt: appInstall.updatedAt,
|
||||
device: {
|
||||
id: appInstall.device.id,
|
||||
uuid: appInstall.device.uuid,
|
||||
name: appInstall.device.name,
|
||||
namespace: appInstall.device.namespace,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /_keys
|
||||
* 获取当前token对应设备的键名列表(分页,不包括内容)
|
||||
@ -200,6 +248,11 @@ router.get(
|
||||
router.post(
|
||||
"/_batchimport",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const data = req.body;
|
||||
|
||||
@ -268,6 +321,11 @@ router.post(
|
||||
router.post(
|
||||
"/:key",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
const value = req.body;
|
||||
@ -313,6 +371,11 @@ router.post(
|
||||
router.delete(
|
||||
"/:key",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user