mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-09 07:33:10 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3b3df1ae | ||
|
|
21d6ddf164 | ||
|
|
e65f84aa22 | ||
|
|
ab8904b549 | ||
|
|
da633ca5b6 | ||
|
|
1f68aea39f | ||
|
|
b782945674 | ||
|
|
1e1b99a070 | ||
|
|
63716e0429 | ||
|
|
b582521fee | ||
|
|
f985b6a11a | ||
|
|
f0de2cd59b | ||
|
|
d52ed81a29 | ||
|
|
e73ff53f58 | ||
|
|
79ec5b94a4 | ||
|
|
ddf001b1c1 | ||
|
|
7a010faa54 | ||
|
|
d6330c81fe | ||
|
|
c545612c9c | ||
|
|
4ec10acfcf | ||
|
|
398f79d5c9 | ||
|
|
4ae023afb0 | ||
|
|
4ff64ad514 | ||
|
|
a1deb5e6e3 | ||
|
|
78843418de | ||
|
|
43a49b6516 | ||
|
|
1d7078874b |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
12
.idea/FixClassworksKV.iml
generated
Normal file
12
.idea/FixClassworksKV.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?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
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?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
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
37
app.js
37
app.js
@ -1,8 +1,8 @@
|
|||||||
import "./utils/instrumentation.js";
|
import "./utils/instrumentation.js";
|
||||||
// import createError from "http-errors";
|
// import createError from "http-errors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { join, dirname } from "path";
|
import {dirname, join} from "path";
|
||||||
import { fileURLToPath } from "url";
|
import {fileURLToPath} from "url";
|
||||||
// import cookieParser from "cookie-parser";
|
// import cookieParser from "cookie-parser";
|
||||||
import logger from "morgan";
|
import logger from "morgan";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
@ -15,15 +15,20 @@ import deviceRouter from "./routes/device.js";
|
|||||||
import deviceAuthRouter from "./routes/device-auth.js";
|
import deviceAuthRouter from "./routes/device-auth.js";
|
||||||
import accountsRouter from "./routes/accounts.js";
|
import accountsRouter from "./routes/accounts.js";
|
||||||
import autoAuthRouter from "./routes/auto-auth.js";
|
import autoAuthRouter from "./routes/auto-auth.js";
|
||||||
|
import {register} from "./utils/metrics.js";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
import cors from "cors";
|
|
||||||
app.options("/{*path}", cors());
|
app.options("/{*path}", cors());
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
|
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
|
||||||
maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求
|
maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求
|
||||||
|
credentials: true, // 允许跨域请求携带凭证
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], // 允许的请求头
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], // 允许的HTTP方法
|
||||||
|
withCredentials: true, // 允许携带cookie等凭证信息
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
@ -35,11 +40,11 @@ const __dirname = dirname(__filename);
|
|||||||
// view engine setup
|
// view engine setup
|
||||||
app.set("views", join(__dirname, "views"));
|
app.set("views", join(__dirname, "views"));
|
||||||
app.set("view engine", "ejs");
|
app.set("view engine", "ejs");
|
||||||
app.use(bodyParser.urlencoded({ extended: true }));
|
app.use(bodyParser.urlencoded({extended: true}));
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
app.use(logger("dev"));
|
app.use(logger("dev"));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({extended: false}));
|
||||||
// app.use(cookieParser());
|
// app.use(cookieParser());
|
||||||
app.use(express.static(join(__dirname, "public")));
|
app.use(express.static(join(__dirname, "public")));
|
||||||
|
|
||||||
@ -76,6 +81,28 @@ app.get("/check", (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prometheus metrics endpoint with token auth
|
||||||
|
app.get("/metrics", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 检查 token 验证
|
||||||
|
const metricsToken = process.env.METRICS_TOKEN;
|
||||||
|
if (metricsToken) {
|
||||||
|
const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
|
||||||
|
if (!providedToken || providedToken !== metricsToken) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Valid metrics token required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set("Content-Type", register.contentType);
|
||||||
|
res.end(await register.metrics());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Mount the Apps router with API rate limiting
|
// Mount the Apps router with API rate limiting
|
||||||
app.use("/apps", appsRouter);
|
app.use("/apps", appsRouter);
|
||||||
|
|
||||||
|
|||||||
8
bin/www
8
bin/www
@ -5,8 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import app from '../app.js';
|
import app from '../app.js';
|
||||||
import { createServer } from 'http';
|
import {createServer} from 'http';
|
||||||
import { initSocket } from '../utils/socket.js';
|
import {initSocket} from '../utils/socket.js';
|
||||||
|
import {initializeMetrics} from '../utils/metrics.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get port from environment and store in Express.
|
* Get port from environment and store in Express.
|
||||||
@ -24,6 +25,9 @@ var server = createServer(app);
|
|||||||
// 初始化 Socket.IO 并绑定到 HTTP Server
|
// 初始化 Socket.IO 并绑定到 HTTP Server
|
||||||
initSocket(server);
|
initSocket(server);
|
||||||
|
|
||||||
|
// 初始化 Prometheus 指标
|
||||||
|
initializeMetrics();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen on provided port, on all network interfaces.
|
* Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { execSync } from "child_process";
|
import {execSync} from "child_process";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@ -9,7 +9,7 @@ dotenv.config();
|
|||||||
function runDatabaseMigration() {
|
function runDatabaseMigration() {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 执行数据库迁移...");
|
console.log("🔄 执行数据库迁移...");
|
||||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
execSync("npx prisma migrate deploy", {stdio: "inherit"});
|
||||||
console.log("✅ 数据库迁移完成");
|
console.log("✅ 数据库迁移完成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 数据库迁移失败:", error.message);
|
console.error("❌ 数据库迁移失败:", error.message);
|
||||||
@ -33,8 +33,8 @@ function buildLocal() {
|
|||||||
try {
|
try {
|
||||||
// 确保数据库迁移已执行
|
// 确保数据库迁移已执行
|
||||||
runDatabaseMigration();
|
runDatabaseMigration();
|
||||||
execSync("npm install", { stdio: "inherit" }); // 安装依赖
|
execSync("npm install", {stdio: "inherit"}); // 安装依赖
|
||||||
execSync("npx prisma generate", { stdio: "inherit" }); // 生成 Prisma 客户端
|
execSync("npx prisma generate", {stdio: "inherit"}); // 生成 Prisma 客户端
|
||||||
console.log("✅ 构建完成");
|
console.log("✅ 构建完成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 构建失败:", error.message);
|
console.error("❌ 构建失败:", error.message);
|
||||||
@ -45,7 +45,7 @@ function buildLocal() {
|
|||||||
// 🚀 启动服务函数
|
// 🚀 启动服务函数
|
||||||
function startServer() {
|
function startServer() {
|
||||||
try {
|
try {
|
||||||
execSync("npm run start", { stdio: "inherit" }); // 启动项目
|
execSync("npm run start", {stdio: "inherit"}); // 启动项目
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 服务启动失败:", error.message);
|
console.error("❌ 服务启动失败:", error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -56,7 +56,7 @@ function startServer() {
|
|||||||
function runPrismaCommand(args) {
|
function runPrismaCommand(args) {
|
||||||
try {
|
try {
|
||||||
const command = `npx prisma ${args.join(" ")}`;
|
const command = `npx prisma ${args.join(" ")}`;
|
||||||
execSync(command, { stdio: "inherit" });
|
execSync(command, {stdio: "inherit"});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Prisma 命令执行失败:", error.message);
|
console.error("❌ Prisma 命令执行失败:", error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import { randomBytes } from 'crypto';
|
import {randomBytes} from 'crypto';
|
||||||
|
|
||||||
// 配置
|
// 配置
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
@ -128,11 +128,11 @@ function createCallbackServer(state) {
|
|||||||
const parsedUrl = url.parse(req.url, true);
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
|
||||||
if (parsedUrl.pathname === CONFIG.callbackPath) {
|
if (parsedUrl.pathname === CONFIG.callbackPath) {
|
||||||
const { token, error, state: returnedState } = parsedUrl.query;
|
const {token, error, state: returnedState} = parsedUrl.query;
|
||||||
|
|
||||||
// 验证状态参数
|
// 验证状态参数
|
||||||
if (returnedState !== state) {
|
if (returnedState !== state) {
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||||
res.end(`
|
res.end(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@ -158,7 +158,7 @@ function createCallbackServer(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||||
res.end(`
|
res.end(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@ -184,7 +184,7 @@ function createCallbackServer(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
||||||
res.end(`
|
res.end(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@ -212,7 +212,7 @@ function createCallbackServer(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有token和error参数
|
// 如果没有token和error参数
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||||
res.end(`
|
res.end(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@ -236,7 +236,7 @@ function createCallbackServer(state) {
|
|||||||
reject(new Error('缺少必要的参数'));
|
reject(new Error('缺少必要的参数'));
|
||||||
} else {
|
} else {
|
||||||
// 404 for other paths
|
// 404 for other paths
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, {'Content-Type': 'text/plain'});
|
||||||
res.end('Not Found');
|
res.end('Not Found');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -271,7 +271,7 @@ function createCallbackServer(state) {
|
|||||||
|
|
||||||
// 打开浏览器
|
// 打开浏览器
|
||||||
async function openBrowser(url) {
|
async function openBrowser(url) {
|
||||||
const { spawn } = await import('child_process');
|
const {spawn} = await import('child_process');
|
||||||
|
|
||||||
let command;
|
let command;
|
||||||
let args;
|
let args;
|
||||||
@ -288,7 +288,7 @@ async function openBrowser(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
spawn(command, args, { detached: true, stdio: 'ignore' });
|
spawn(command, args, {detached: true, stdio: 'ignore'});
|
||||||
logSuccess('已尝试打开浏览器');
|
logSuccess('已尝试打开浏览器');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logWarning('无法自动打开浏览器,请手动打开授权链接');
|
logWarning('无法自动打开浏览器,请手动打开授权链接');
|
||||||
@ -323,7 +323,7 @@ async function saveToken(token) {
|
|||||||
try {
|
try {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if (!fs.existsSync(tokenDir)) {
|
if (!fs.existsSync(tokenDir)) {
|
||||||
fs.mkdirSync(tokenDir, { recursive: true });
|
fs.mkdirSync(tokenDir, {recursive: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入令牌
|
// 写入令牌
|
||||||
|
|||||||
@ -10,8 +10,6 @@
|
|||||||
* 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js
|
* 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import readline from 'readline';
|
|
||||||
|
|
||||||
// 配置
|
// 配置
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// API服务器地址
|
// API服务器地址
|
||||||
@ -166,7 +164,7 @@ async function saveToken(token) {
|
|||||||
try {
|
try {
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if (!fs.existsSync(tokenDir)) {
|
if (!fs.existsSync(tokenDir)) {
|
||||||
fs.mkdirSync(tokenDir, { recursive: true });
|
fs.mkdirSync(tokenDir, {recursive: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入令牌
|
// 写入令牌
|
||||||
@ -191,7 +189,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 生成设备代码
|
// 1. 生成设备代码
|
||||||
const { device_code, expires_in } = await generateDeviceCode();
|
const {device_code, expires_in} = await generateDeviceCode();
|
||||||
logSuccess('设备授权码生成成功!');
|
logSuccess('设备授权码生成成功!');
|
||||||
|
|
||||||
// 2. 显示设备代码和授权链接
|
// 2. 显示设备代码和授权链接
|
||||||
|
|||||||
@ -67,6 +67,24 @@ export const oauthProviders = {
|
|||||||
website: "https://houlang.cloud",
|
website: "https://houlang.cloud",
|
||||||
pkce: true, // 启用PKCE支持
|
pkce: true, // 启用PKCE支持
|
||||||
},
|
},
|
||||||
|
dlass: {
|
||||||
|
// Dlass(Casdoor)- 标准 OIDC Provider
|
||||||
|
clientId: process.env.DLASS_CLIENT_ID,
|
||||||
|
clientSecret: process.env.DLASS_CLIENT_SECRET,
|
||||||
|
// Casdoor 标准端点
|
||||||
|
authorizationURL: "https://auth.wiki.forum/login/oauth/authorize",
|
||||||
|
tokenURL: "https://auth.wiki.forum/api/login/oauth/access_token",
|
||||||
|
userInfoURL: "https://auth.wiki.forum/api/userinfo",
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
// 展示相关
|
||||||
|
name: "dlass",
|
||||||
|
displayName: "Dlass 账户",
|
||||||
|
icon: "casdoor",
|
||||||
|
color: "#3498db",
|
||||||
|
description: "使用Dlass账户登录",
|
||||||
|
website: "https://dlass.tech",
|
||||||
|
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取OAuth回调URL
|
// 获取OAuth回调URL
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
* 3. passwordMiddleware - 验证设备密码
|
* 3. passwordMiddleware - 验证设备密码
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
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";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -38,7 +39,7 @@ async function createDefaultAutoAuth(deviceId) {
|
|||||||
* 设备中间件 - 统一处理设备UUID
|
* 设备中间件 - 统一处理设备UUID
|
||||||
*
|
*
|
||||||
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
|
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
|
||||||
* 如果设备不存在则自动创建
|
* 如果设备不存在则自动创建,并智能生成设备名称
|
||||||
* 将设备信息存储到res.locals.device
|
* 将设备信息存储到res.locals.device
|
||||||
*
|
*
|
||||||
* 使用方式:
|
* 使用方式:
|
||||||
@ -54,15 +55,22 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
|||||||
|
|
||||||
// 查找或创建设备
|
// 查找或创建设备
|
||||||
let device = await prisma.device.findUnique({
|
let device = await prisma.device.findUnique({
|
||||||
where: { uuid: deviceUuid },
|
where: {uuid: deviceUuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
// 设备不存在,自动创建
|
// 设备不存在,自动创建并生成智能设备名称
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
const customDeviceType = req.body.deviceType || req.query.deviceType;
|
||||||
|
const note = req.body.note || req.query.note;
|
||||||
|
|
||||||
|
// 生成设备名称,确保不为空
|
||||||
|
const deviceName = analyzeDevice(userAgent, req.headers, customDeviceType, note).generatedName;
|
||||||
|
|
||||||
device = await prisma.device.create({
|
device = await prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
uuid: deviceUuid,
|
uuid: deviceUuid,
|
||||||
name: null,
|
name: deviceName,
|
||||||
password: null,
|
password: null,
|
||||||
passwordHint: null,
|
passwordHint: null,
|
||||||
accountId: null,
|
accountId: null,
|
||||||
@ -71,6 +79,9 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
|||||||
|
|
||||||
// 为新创建的设备添加默认的自动登录配置
|
// 为新创建的设备添加默认的自动登录配置
|
||||||
await createDefaultAutoAuth(device.id);
|
await createDefaultAutoAuth(device.id);
|
||||||
|
|
||||||
|
// 将设备分析结果添加到响应中
|
||||||
|
res.locals.deviceAnalysis = deviceAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将设备信息存储到res.locals
|
// 将设备信息存储到res.locals
|
||||||
@ -89,7 +100,7 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
|||||||
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
|
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
|
||||||
*/
|
*/
|
||||||
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
|
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceUuid = req.params.deviceUuid ;
|
const deviceUuid = req.params.deviceUuid;
|
||||||
|
|
||||||
if (!deviceUuid) {
|
if (!deviceUuid) {
|
||||||
return next(errors.createError(400, "缺少设备UUID"));
|
return next(errors.createError(400, "缺少设备UUID"));
|
||||||
@ -97,7 +108,7 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
|
|||||||
|
|
||||||
// 查找设备
|
// 查找设备
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid: deviceUuid },
|
where: {uuid: deviceUuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -123,7 +134,7 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
|
|||||||
*/
|
*/
|
||||||
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
|
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||||
const device = res.locals.device;
|
const device = res.locals.device;
|
||||||
const { password } = req.body;
|
const {password} = req.body;
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { isDevelopment } from "../utils/config.js";
|
import {isDevelopment} from "../utils/config.js";
|
||||||
|
|
||||||
const errorHandler = (err, req, res, next) => {
|
const errorHandler = (err, req, res, next) => {
|
||||||
// 判断响应是否已经发送
|
// 判断响应是否已经发送
|
||||||
@ -17,11 +17,13 @@ const errorHandler = (err, req, res, next) => {
|
|||||||
const statusCode = err.statusCode || err.status || 500;
|
const statusCode = err.statusCode || err.status || 500;
|
||||||
const message = err.message || "服务器错误";
|
const message = err.message || "服务器错误";
|
||||||
const details = err.details || null;
|
const details = err.details || null;
|
||||||
|
const code = err.code || undefined;
|
||||||
|
|
||||||
// 返回统一格式的错误响应
|
// 返回统一格式的错误响应
|
||||||
return res.status(statusCode).json({
|
return res.status(statusCode).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: message,
|
message: message,
|
||||||
|
code: code,
|
||||||
details: details,
|
details: details,
|
||||||
error:
|
error:
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
* 适用于只需要账户验证的接口
|
* 适用于只需要账户验证的接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyAccessToken, validateAccountToken, generateAccessToken } 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 {PrismaClient} from "@prisma/client";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@ -56,7 +56,7 @@ export const jwtAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
// 从数据库获取账户信息
|
// 从数据库获取账户信息
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: decoded.accountId },
|
where: {id: decoded.accountId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@ -76,7 +76,10 @@ export const jwtAuth = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') {
|
if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') {
|
||||||
return next(errors.createError(401, "JWT token已过期"));
|
// 统一的账户JWT过期返回
|
||||||
|
// message: JWT_EXPIRED(用于客户端稳定识别)
|
||||||
|
// code: AUTH_JWT_EXPIRED(业务错误码)
|
||||||
|
return next(errors.createError(401, "JWT_EXPIRED", null, "AUTH_JWT_EXPIRED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(errors.createError(401, "token验证失败"));
|
return next(errors.createError(401, "token验证失败"));
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* 适用于所有KV相关的接口
|
* 适用于所有KV相关的接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@ -25,7 +25,7 @@ export const kvTokenAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
// 查找token对应的应用安装信息
|
// 查找token对应的应用安装信息
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
where: { token },
|
where: {token},
|
||||||
include: {
|
include: {
|
||||||
device: true,
|
device: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,7 +56,6 @@ export const prepareTokenForRateLimit = (req, res, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 认证相关路由限速器(防止暴力破解)
|
// 认证相关路由限速器(防止暴力破解)
|
||||||
export const authLimiter = rateLimit({
|
export const authLimiter = rateLimit({
|
||||||
windowMs: 30 * 60 * 1000, // 30分钟
|
windowMs: 30 * 60 * 1000, // 30分钟
|
||||||
|
|||||||
@ -6,10 +6,10 @@
|
|||||||
* 3. 适用于需要设备上下文的接口
|
* 3. 适用于需要设备上下文的接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
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();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export const uuidAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
// 2. 查找设备并存储到locals
|
// 2. 查找设备并存储到locals
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -46,11 +46,11 @@ export const uuidAuth = async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const accountPayload = await verifyAccountJWT(jwt);
|
const accountPayload = await verifyAccountJWT(jwt);
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: accountPayload.accountId },
|
where: {id: accountPayload.accountId},
|
||||||
include: {
|
include: {
|
||||||
devices: {
|
devices: {
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
select: { id: true }
|
select: {id: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -93,6 +93,22 @@ export const uuidAuth = async (req, res, next) => {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
export const extractDeviceInfo = async (req, res, next) => {
|
||||||
|
var uuid = extractUuid(req);
|
||||||
|
|
||||||
|
if (!uuid) {
|
||||||
|
throw errors.createError(400, "需要提供设备UUID");
|
||||||
|
}
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: {uuid},
|
||||||
|
});
|
||||||
|
if (!device) {
|
||||||
|
throw errors.createError(404, "设备不存在");
|
||||||
|
}
|
||||||
|
res.locals.device = device;
|
||||||
|
res.locals.deviceId = device.id;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中提取UUID
|
* 从请求中提取UUID
|
||||||
|
|||||||
9032
package-lock.json
generated
Normal file
9032
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ClassworksKV",
|
"name": "ClassworksKV",
|
||||||
"version": "1.2.1",
|
"version": "1.3.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
@ -30,6 +30,8 @@
|
|||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "~1.10.0",
|
"morgan": "~1.10.0",
|
||||||
|
"node-device-detector": "^2.2.4",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@ -71,6 +71,12 @@ importers:
|
|||||||
morgan:
|
morgan:
|
||||||
specifier: ~1.10.0
|
specifier: ~1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
|
node-device-detector:
|
||||||
|
specifier: ^2.2.4
|
||||||
|
version: 2.2.4
|
||||||
|
prom-client:
|
||||||
|
specifier: ^15.1.3
|
||||||
|
version: 15.1.3
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.1
|
specifier: ^4.8.1
|
||||||
version: 4.8.1
|
version: 4.8.1
|
||||||
@ -1433,6 +1439,9 @@ packages:
|
|||||||
bignumber.js@9.3.0:
|
bignumber.js@9.3.0:
|
||||||
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
|
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
|
||||||
|
|
||||||
|
bintrees@1.0.2:
|
||||||
|
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
|
||||||
|
|
||||||
birpc@2.6.1:
|
birpc@2.6.1:
|
||||||
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||||
|
|
||||||
@ -2091,6 +2100,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
|
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
|
||||||
engines: {node: ^18 || ^20 || >= 21}
|
engines: {node: ^18 || ^20 || >= 21}
|
||||||
|
|
||||||
|
node-device-detector@2.2.4:
|
||||||
|
resolution: {integrity: sha512-0nhi8XWLViGKeQyLLlg3bcUGdhTKc56ARAHx6kKWvwy39ITk7BZn5Gy6AmTSX4slM35iQMJaKAIxagR/xXsS+Q==}
|
||||||
|
engines: {node: '>= 10.x', npm: '>= 6.x'}
|
||||||
|
|
||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
@ -2212,6 +2225,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
prom-client@15.1.3:
|
||||||
|
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
|
||||||
|
engines: {node: ^16 || ^18 || >=20}
|
||||||
|
|
||||||
protobufjs@7.5.4:
|
protobufjs@7.5.4:
|
||||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@ -2391,6 +2408,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
|
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tdigest@0.1.2:
|
||||||
|
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||||
|
|
||||||
tinyexec@1.0.1:
|
tinyexec@1.0.1:
|
||||||
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
|
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
|
||||||
|
|
||||||
@ -4035,6 +4055,8 @@ snapshots:
|
|||||||
|
|
||||||
bignumber.js@9.3.0: {}
|
bignumber.js@9.3.0: {}
|
||||||
|
|
||||||
|
bintrees@1.0.2: {}
|
||||||
|
|
||||||
birpc@2.6.1: {}
|
birpc@2.6.1: {}
|
||||||
|
|
||||||
body-parser@2.2.0:
|
body-parser@2.2.0:
|
||||||
@ -4691,6 +4713,8 @@ snapshots:
|
|||||||
|
|
||||||
node-addon-api@8.3.1: {}
|
node-addon-api@8.3.1: {}
|
||||||
|
|
||||||
|
node-device-detector@2.2.4: {}
|
||||||
|
|
||||||
node-fetch-native@1.6.7: {}
|
node-fetch-native@1.6.7: {}
|
||||||
|
|
||||||
node-fetch@2.7.0:
|
node-fetch@2.7.0:
|
||||||
@ -4792,6 +4816,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
|
prom-client@15.1.3:
|
||||||
|
dependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
tdigest: 0.1.2
|
||||||
|
|
||||||
protobufjs@7.5.4:
|
protobufjs@7.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@protobufjs/aspromise': 1.1.2
|
'@protobufjs/aspromise': 1.1.2
|
||||||
@ -5067,6 +5096,10 @@ snapshots:
|
|||||||
minizlib: 3.1.0
|
minizlib: 3.1.0
|
||||||
yallist: 5.0.0
|
yallist: 5.0.0
|
||||||
|
|
||||||
|
tdigest@0.1.2:
|
||||||
|
dependencies:
|
||||||
|
bintrees: 1.0.2
|
||||||
|
|
||||||
tinyexec@1.0.1: {}
|
tinyexec@1.0.1: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
<title>登录失败</title>
|
<title>登录失败</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
@ -50,9 +50,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%, 100% {
|
||||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
transform: translateX(0);
|
||||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
}
|
||||||
|
10%, 30%, 50%, 70%, 90% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
20%, 40%, 60%, 80% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@ -120,10 +126,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<svg fill="none" viewBox="0 0 24 24">
|
<svg fill="none" viewBox="0 0 24 24">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -134,7 +140,7 @@
|
|||||||
<div class="error-code" id="errorCode"></div>
|
<div class="error-code" id="errorCode"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="javascript:history.back()" class="retry-btn">返回重试</a>
|
<a class="retry-btn" href="javascript:history.back()">返回重试</a>
|
||||||
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
||||||
|
|
||||||
<div class="help-text">
|
<div class="help-text">
|
||||||
@ -143,9 +149,9 @@
|
|||||||
• 回调URL是否已添加到OAuth应用中<br>
|
• 回调URL是否已添加到OAuth应用中<br>
|
||||||
• 环境变量是否配置正确
|
• 环境变量是否配置正确
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 从URL获取错误信息
|
// 从URL获取错误信息
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const error = params.get('error');
|
const error = params.get('error');
|
||||||
@ -161,6 +167,6 @@
|
|||||||
document.getElementById('errorMsg').textContent = errorMsg;
|
document.getElementById('errorMsg').textContent = errorMsg;
|
||||||
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
|
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
<title>登录成功</title>
|
<title>登录成功</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
@ -132,10 +132,10 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="success-icon">
|
<div class="success-icon">
|
||||||
<svg fill="none" viewBox="0 0 24 24">
|
<svg fill="none" viewBox="0 0 24 24">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,9 +152,9 @@
|
|||||||
<div class="auto-close">
|
<div class="auto-close">
|
||||||
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
|
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 从URL获取参数
|
// 从URL获取参数
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
@ -249,6 +249,6 @@
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
document.querySelector('.auto-close').style.display = 'none';
|
document.querySelector('.auto-close').style.display = 'none';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { Router } from "express";
|
import {Router} from "express";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
|
import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js";
|
||||||
import { generateAccountToken, generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken } from "../utils/jwt.js";
|
import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.js";
|
||||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
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();
|
||||||
@ -27,7 +27,7 @@ function generatePkcePair() {
|
|||||||
.replace(/\+/g, "-")
|
.replace(/\+/g, "-")
|
||||||
.replace(/\//g, "_")
|
.replace(/\//g, "_")
|
||||||
.replace(/=+$/, "");
|
.replace(/=+$/, "");
|
||||||
return { codeVerifier, codeChallenge: challenge };
|
return {codeVerifier, codeChallenge: challenge};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,8 +81,8 @@ router.get("/oauth/providers", (req, res) => {
|
|||||||
* - redirect_uri: 前端回调地址(可选)
|
* - redirect_uri: 前端回调地址(可选)
|
||||||
*/
|
*/
|
||||||
router.get("/oauth/:provider", (req, res) => {
|
router.get("/oauth/:provider", (req, res) => {
|
||||||
const { provider } = req.params;
|
const {provider} = req.params;
|
||||||
const { redirect_uri } = req.query;
|
const {redirect_uri} = req.query;
|
||||||
|
|
||||||
const providerConfig = oauthProviders[provider];
|
const providerConfig = oauthProviders[provider];
|
||||||
if (!providerConfig) {
|
if (!providerConfig) {
|
||||||
@ -157,8 +157,8 @@ router.get("/oauth/:provider", (req, res) => {
|
|||||||
* GET /accounts/oauth/:provider/callback
|
* GET /accounts/oauth/:provider/callback
|
||||||
*/
|
*/
|
||||||
router.get("/oauth/:provider/callback", async (req, res) => {
|
router.get("/oauth/:provider/callback", async (req, res) => {
|
||||||
const { provider } = req.params;
|
const {provider} = req.params;
|
||||||
const { code, state, error } = req.query;
|
const {code, state, error} = req.query;
|
||||||
|
|
||||||
// 如果OAuth提供者返回错误
|
// 如果OAuth提供者返回错误
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -198,12 +198,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: providerConfig.clientId,
|
client_id: providerConfig.clientId,
|
||||||
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
|
...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}),
|
||||||
code: code,
|
code: code,
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: getCallbackURL(provider),
|
redirect_uri: getCallbackURL(provider),
|
||||||
// PKCE: 携带code_verifier
|
// PKCE: 携带code_verifier
|
||||||
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
|
...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -215,12 +215,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: providerConfig.clientId,
|
client_id: providerConfig.clientId,
|
||||||
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
|
...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}),
|
||||||
code: code,
|
code: code,
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: getCallbackURL(provider),
|
redirect_uri: getCallbackURL(provider),
|
||||||
// PKCE: 携带code_verifier
|
// PKCE: 携带code_verifier
|
||||||
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
|
...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -237,7 +237,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
if (provider === 'stcn') {
|
if (provider === 'stcn') {
|
||||||
const url = new URL(providerConfig.userInfoURL);
|
const url = new URL(providerConfig.userInfoURL);
|
||||||
url.searchParams.set('accessToken', tokenData.access_token);
|
url.searchParams.set('accessToken', tokenData.access_token);
|
||||||
userResponse = await fetch(url, { headers: { "Accept": "application/json" } });
|
userResponse = await fetch(url, {headers: {"Accept": "application/json"}});
|
||||||
} else {
|
} else {
|
||||||
userResponse = await fetch(providerConfig.userInfoURL, {
|
userResponse = await fetch(providerConfig.userInfoURL, {
|
||||||
headers: {
|
headers: {
|
||||||
@ -282,6 +282,14 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
name: userData.name || userData.preferred_username || userData.nickname,
|
name: userData.name || userData.preferred_username || userData.nickname,
|
||||||
avatarUrl: userData.picture,
|
avatarUrl: userData.picture,
|
||||||
};
|
};
|
||||||
|
} else if (provider === "dlass") {
|
||||||
|
// Dlass(Casdoor)标准OIDC用户信息
|
||||||
|
normalizedUser = {
|
||||||
|
providerId: userData.sub,
|
||||||
|
email: userData.email_verified ? userData.email : userData.email || null,
|
||||||
|
name: userData.name || userData.preferred_username || userData.nickname,
|
||||||
|
avatarUrl: userData.picture,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 名称为空时,用邮箱@前部分回填(若邮箱可用)
|
// 名称为空时,用邮箱@前部分回填(若邮箱可用)
|
||||||
@ -305,7 +313,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
if (account) {
|
if (account) {
|
||||||
// 更新账户信息
|
// 更新账户信息
|
||||||
account = await prisma.account.update({
|
account = await prisma.account.update({
|
||||||
where: { id: account.id },
|
where: {id: account.id},
|
||||||
data: {
|
data: {
|
||||||
email: normalizedUser.email || account.email,
|
email: normalizedUser.email || account.email,
|
||||||
name: normalizedUser.name || account.name,
|
name: normalizedUser.name || account.name,
|
||||||
@ -378,7 +386,7 @@ router.get("/profile", jwtAuth, async (req, res, next) => {
|
|||||||
const accountContext = res.locals.account;
|
const accountContext = res.locals.account;
|
||||||
|
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: accountContext.id },
|
where: {id: accountContext.id},
|
||||||
include: {
|
include: {
|
||||||
devices: {
|
devices: {
|
||||||
select: {
|
select: {
|
||||||
@ -439,7 +447,7 @@ router.get("/profile", jwtAuth, async (req, res, next) => {
|
|||||||
router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const accountContext = res.locals.account;
|
const accountContext = res.locals.account;
|
||||||
const { uuid } = req.body;
|
const {uuid} = req.body;
|
||||||
|
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@ -450,7 +458,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
|||||||
|
|
||||||
// 查找设备
|
// 查找设备
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -470,7 +478,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
|||||||
|
|
||||||
// 绑定设备到账户
|
// 绑定设备到账户
|
||||||
const updatedDevice = await prisma.device.update({
|
const updatedDevice = await prisma.device.update({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
data: {
|
data: {
|
||||||
accountId: accountContext.id,
|
accountId: accountContext.id,
|
||||||
},
|
},
|
||||||
@ -506,7 +514,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
|||||||
router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
|
router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const accountContext = res.locals.account;
|
const accountContext = res.locals.account;
|
||||||
const { uuid, uuids } = req.body;
|
const {uuid, uuids} = req.body;
|
||||||
|
|
||||||
// 支持单个解绑或批量解绑
|
// 支持单个解绑或批量解绑
|
||||||
const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
|
const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
|
||||||
@ -521,7 +529,7 @@ router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
|
|||||||
// 查找所有设备并验证所有权
|
// 查找所有设备并验证所有权
|
||||||
const devices = await prisma.device.findMany({
|
const devices = await prisma.device.findMany({
|
||||||
where: {
|
where: {
|
||||||
uuid: { in: uuidsToUnbind },
|
uuid: {in: uuidsToUnbind},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -547,7 +555,7 @@ router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
|
|||||||
// 批量解绑设备
|
// 批量解绑设备
|
||||||
await prisma.device.updateMany({
|
await prisma.device.updateMany({
|
||||||
where: {
|
where: {
|
||||||
uuid: { in: uuidsToUnbind },
|
uuid: {in: uuidsToUnbind},
|
||||||
accountId: accountContext.id,
|
accountId: accountContext.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@ -577,13 +585,14 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
|
|||||||
const accountContext = res.locals.account;
|
const accountContext = res.locals.account;
|
||||||
// 获取账户的设备列表
|
// 获取账户的设备列表
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: accountContext.id },
|
where: {id: accountContext.id},
|
||||||
include: {
|
include: {
|
||||||
devices: {
|
devices: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
uuid: true,
|
uuid: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
namespace: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
@ -608,11 +617,11 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/device/:uuid/account", async (req, res, next) => {
|
router.get("/device/:uuid/account", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
|
|
||||||
// 查找设备及其关联的账户
|
// 查找设备及其关联的账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
include: {
|
include: {
|
||||||
account: {
|
account: {
|
||||||
select: {
|
select: {
|
||||||
@ -666,7 +675,7 @@ router.get("/device/:uuid/account", async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/refresh", async (req, res, next) => {
|
router.post("/refresh", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { refresh_token } = req.body;
|
const {refresh_token} = req.body;
|
||||||
|
|
||||||
if (!refresh_token) {
|
if (!refresh_token) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { Router } from "express";
|
import {Router} from "express";
|
||||||
const router = Router();
|
import {uuidAuth} from "../middleware/uuidAuth.js";
|
||||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
|
||||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { verifyDevicePassword } from "../utils/crypto.js";
|
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -17,11 +16,11 @@ const prisma = new PrismaClient();
|
|||||||
router.get(
|
router.get(
|
||||||
"/devices/:uuid/apps",
|
"/devices/:uuid/apps",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
|
|
||||||
// 查找设备
|
// 查找设备
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -29,7 +28,7 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installations = await prisma.appInstall.findMany({
|
const installations = await prisma.appInstall.findMany({
|
||||||
where: { deviceId: device.id },
|
where: {deviceId: device.id},
|
||||||
});
|
});
|
||||||
|
|
||||||
const apps = installations.map(install => ({
|
const apps = installations.map(install => ({
|
||||||
@ -56,8 +55,8 @@ router.post(
|
|||||||
uuidAuth,
|
uuidAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const device = res.locals.device;
|
const device = res.locals.device;
|
||||||
const { appId } = req.params;
|
const {appId} = req.params;
|
||||||
const { note } = req.body;
|
const {note} = req.body;
|
||||||
|
|
||||||
// 生成token
|
// 生成token
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
@ -92,10 +91,10 @@ router.delete(
|
|||||||
uuidAuth,
|
uuidAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const device = res.locals.device;
|
const device = res.locals.device;
|
||||||
const { installId } = req.params;
|
const {installId} = req.params;
|
||||||
|
|
||||||
const installation = await prisma.appInstall.findUnique({
|
const installation = await prisma.appInstall.findUnique({
|
||||||
where: { id: installId },
|
where: {id: installId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!installation) {
|
if (!installation) {
|
||||||
@ -108,7 +107,7 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.appInstall.delete({
|
await prisma.appInstall.delete({
|
||||||
where: { id: installation.id },
|
where: {id: installation.id},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
@ -122,7 +121,7 @@ router.delete(
|
|||||||
router.get(
|
router.get(
|
||||||
"/tokens",
|
"/tokens",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.query;
|
const {uuid} = req.query;
|
||||||
|
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return next(errors.createError(400, "需要提供设备UUID"));
|
return next(errors.createError(400, "需要提供设备UUID"));
|
||||||
@ -130,7 +129,7 @@ router.get(
|
|||||||
|
|
||||||
// 查找设备
|
// 查找设备
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -139,8 +138,8 @@ router.get(
|
|||||||
|
|
||||||
// 获取该设备的所有应用安装记录(即token)
|
// 获取该设备的所有应用安装记录(即token)
|
||||||
const installations = await prisma.appInstall.findMany({
|
const installations = await prisma.appInstall.findMany({
|
||||||
where: { deviceId: device.id },
|
where: {deviceId: device.id},
|
||||||
orderBy: { installedAt: 'desc' },
|
orderBy: {installedAt: 'desc'},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = installations.map(install => ({
|
const tokens = installations.map(install => ({
|
||||||
@ -168,7 +167,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/auth/token",
|
"/auth/token",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { namespace, password, appId } = req.body;
|
const {namespace, password, appId} = req.body;
|
||||||
|
|
||||||
if (!namespace) {
|
if (!namespace) {
|
||||||
return next(errors.createError(400, "需要提供 namespace"));
|
return next(errors.createError(400, "需要提供 namespace"));
|
||||||
@ -180,7 +179,7 @@ router.post(
|
|||||||
|
|
||||||
// 通过 namespace 查找设备
|
// 通过 namespace 查找设备
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { namespace },
|
where: {namespace},
|
||||||
include: {
|
include: {
|
||||||
autoAuths: true,
|
autoAuths: true,
|
||||||
},
|
},
|
||||||
@ -208,8 +207,8 @@ router.post(
|
|||||||
|
|
||||||
// 自动迁移:将哈希密码更新为明文密码
|
// 自动迁移:将哈希密码更新为明文密码
|
||||||
await prisma.autoAuth.update({
|
await prisma.autoAuth.update({
|
||||||
where: { id: autoAuth.id },
|
where: {id: autoAuth.id},
|
||||||
data: { password: password }, // 保存明文密码
|
data: {password: password}, // 保存明文密码
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
||||||
@ -217,7 +216,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 如果验证失败,继续尝试下一个
|
// 如果验证失败,继续尝试下一个
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,8 +266,8 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/tokens/:token/set-student-name",
|
"/tokens/:token/set-student-name",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { token } = req.params;
|
const {token} = req.params;
|
||||||
const { name } = req.body;
|
const {name} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return next(errors.createError(400, "需要提供学生名称"));
|
return next(errors.createError(400, "需要提供学生名称"));
|
||||||
@ -276,7 +275,7 @@ router.post(
|
|||||||
|
|
||||||
// 查找 token 对应的应用安装记录
|
// 查找 token 对应的应用安装记录
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
where: { token },
|
where: {token},
|
||||||
include: {
|
include: {
|
||||||
device: true,
|
device: true,
|
||||||
},
|
},
|
||||||
@ -287,8 +286,8 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证 token 类型是否为 student
|
// 验证 token 类型是否为 student
|
||||||
if (appInstall.deviceType !== 'student') {
|
if (!['student', 'parent'].includes(appInstall.deviceType)) {
|
||||||
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
|
return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取设备的 classworks-list-main 键值
|
// 读取设备的 classworks-list-main 键值
|
||||||
@ -325,8 +324,8 @@ router.post(
|
|||||||
|
|
||||||
// 更新 AppInstall 的 note 字段
|
// 更新 AppInstall 的 note 字段
|
||||||
const updatedInstall = await prisma.appInstall.update({
|
const updatedInstall = await prisma.appInstall.update({
|
||||||
where: { id: appInstall.id },
|
where: {id: appInstall.id},
|
||||||
data: { note: name },
|
data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@ -347,12 +346,12 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/tokens/:token/note",
|
"/tokens/:token/note",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { token } = req.params;
|
const {token} = req.params;
|
||||||
const { note } = req.body;
|
const {note} = req.body;
|
||||||
|
|
||||||
// 查找 token 对应的应用安装记录
|
// 查找 token 对应的应用安装记录
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
where: { token },
|
where: {token},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!appInstall) {
|
if (!appInstall) {
|
||||||
@ -361,8 +360,8 @@ router.put(
|
|||||||
|
|
||||||
// 更新 AppInstall 的 note 字段
|
// 更新 AppInstall 的 note 字段
|
||||||
const updatedInstall = await prisma.appInstall.update({
|
const updatedInstall = await prisma.appInstall.update({
|
||||||
where: { id: appInstall.id },
|
where: {id: appInstall.id},
|
||||||
data: { note: note || null },
|
data: {note: note || null},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Router } from "express";
|
import {Router} from "express";
|
||||||
const router = Router();
|
import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -14,12 +15,12 @@ router.get(
|
|||||||
"/devices/:uuid/auth-configs",
|
"/devices/:uuid/auth-configs",
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
const account = res.locals.account;
|
const account = res.locals.account;
|
||||||
|
|
||||||
// 查找设备并验证是否属于当前账户
|
// 查找设备并验证是否属于当前账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -32,8 +33,8 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoAuths = await prisma.autoAuth.findMany({
|
const autoAuths = await prisma.autoAuth.findMany({
|
||||||
where: { deviceId: device.id },
|
where: {deviceId: device.id},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: {createdAt: 'desc'},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回配置,智能处理密码显示
|
// 返回配置,智能处理密码显示
|
||||||
@ -68,13 +69,13 @@ router.post(
|
|||||||
"/devices/:uuid/auth-configs",
|
"/devices/:uuid/auth-configs",
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
const account = res.locals.account;
|
const account = res.locals.account;
|
||||||
const { password, deviceType, isReadOnly } = req.body;
|
const {password, deviceType, isReadOnly} = req.body;
|
||||||
|
|
||||||
// 查找设备并验证是否属于当前账户
|
// 查找设备并验证是否属于当前账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -97,7 +98,7 @@ router.post(
|
|||||||
|
|
||||||
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
||||||
const allAuths = await prisma.autoAuth.findMany({
|
const allAuths = await prisma.autoAuth.findMany({
|
||||||
where: { deviceId: device.id },
|
where: {deviceId: device.id},
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
||||||
@ -127,7 +128,8 @@ router.post(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);/**
|
);
|
||||||
|
/**
|
||||||
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
||||||
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||||
@ -136,13 +138,13 @@ router.put(
|
|||||||
"/devices/:uuid/auth-configs/:configId",
|
"/devices/:uuid/auth-configs/:configId",
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid, configId } = req.params;
|
const {uuid, configId} = req.params;
|
||||||
const account = res.locals.account;
|
const account = res.locals.account;
|
||||||
const { password, deviceType, isReadOnly } = req.body;
|
const {password, deviceType, isReadOnly} = req.body;
|
||||||
|
|
||||||
// 查找设备并验证是否属于当前账户
|
// 查找设备并验证是否属于当前账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -156,7 +158,7 @@ router.put(
|
|||||||
|
|
||||||
// 查找自动授权配置
|
// 查找自动授权配置
|
||||||
const autoAuth = await prisma.autoAuth.findUnique({
|
const autoAuth = await prisma.autoAuth.findUnique({
|
||||||
where: { id: configId },
|
where: {id: configId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!autoAuth) {
|
if (!autoAuth) {
|
||||||
@ -183,7 +185,7 @@ router.put(
|
|||||||
|
|
||||||
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
||||||
const allAuths = await prisma.autoAuth.findMany({
|
const allAuths = await prisma.autoAuth.findMany({
|
||||||
where: { deviceId: device.id },
|
where: {deviceId: device.id},
|
||||||
});
|
});
|
||||||
|
|
||||||
const conflictAuth = allAuths.find(auth =>
|
const conflictAuth = allAuths.find(auth =>
|
||||||
@ -207,7 +209,7 @@ router.put(
|
|||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
const updatedAuth = await prisma.autoAuth.update({
|
const updatedAuth = await prisma.autoAuth.update({
|
||||||
where: { id: configId },
|
where: {id: configId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -232,12 +234,12 @@ router.delete(
|
|||||||
"/devices/:uuid/auth-configs/:configId",
|
"/devices/:uuid/auth-configs/:configId",
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid, configId } = req.params;
|
const {uuid, configId} = req.params;
|
||||||
const account = res.locals.account;
|
const account = res.locals.account;
|
||||||
|
|
||||||
// 查找设备并验证是否属于当前账户
|
// 查找设备并验证是否属于当前账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -251,7 +253,7 @@ router.delete(
|
|||||||
|
|
||||||
// 查找自动授权配置
|
// 查找自动授权配置
|
||||||
const autoAuth = await prisma.autoAuth.findUnique({
|
const autoAuth = await prisma.autoAuth.findUnique({
|
||||||
where: { id: configId },
|
where: {id: configId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!autoAuth) {
|
if (!autoAuth) {
|
||||||
@ -265,7 +267,7 @@ router.delete(
|
|||||||
|
|
||||||
// 删除配置
|
// 删除配置
|
||||||
await prisma.autoAuth.delete({
|
await prisma.autoAuth.delete({
|
||||||
where: { id: configId },
|
where: {id: configId},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
@ -281,9 +283,9 @@ router.put(
|
|||||||
"/devices/:uuid/namespace",
|
"/devices/:uuid/namespace",
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
const account = res.locals.account;
|
const account = res.locals.account;
|
||||||
const { namespace } = req.body;
|
const {namespace} = req.body;
|
||||||
|
|
||||||
if (!namespace) {
|
if (!namespace) {
|
||||||
return next(errors.createError(400, "需要提供 namespace"));
|
return next(errors.createError(400, "需要提供 namespace"));
|
||||||
@ -298,7 +300,7 @@ router.put(
|
|||||||
|
|
||||||
// 查找设备并验证是否属于当前账户
|
// 查找设备并验证是否属于当前账户
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -313,7 +315,7 @@ router.put(
|
|||||||
// 检查新的 namespace 是否已被其他设备使用
|
// 检查新的 namespace 是否已被其他设备使用
|
||||||
if (device.namespace !== trimmedNamespace) {
|
if (device.namespace !== trimmedNamespace) {
|
||||||
const existingDevice = await prisma.device.findUnique({
|
const existingDevice = await prisma.device.findUnique({
|
||||||
where: { namespace: trimmedNamespace },
|
where: {namespace: trimmedNamespace},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingDevice) {
|
if (existingDevice) {
|
||||||
@ -323,8 +325,8 @@ router.put(
|
|||||||
|
|
||||||
// 更新设备的 namespace
|
// 更新设备的 namespace
|
||||||
const updatedDevice = await prisma.device.update({
|
const updatedDevice = await prisma.device.update({
|
||||||
where: { id: device.id },
|
where: {id: device.id},
|
||||||
data: { namespace: trimmedNamespace },
|
data: {namespace: trimmedNamespace},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
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 {PrismaClient} from "@prisma/client";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /device/code
|
* POST /device/code
|
||||||
* 生成设备授权码
|
* 生成设备授权码
|
||||||
@ -55,7 +54,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/device/bind",
|
"/device/bind",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { device_code, token } = req.body;
|
const {device_code, token} = req.body;
|
||||||
|
|
||||||
if (!device_code || !token) {
|
if (!device_code || !token) {
|
||||||
return next(
|
return next(
|
||||||
@ -65,7 +64,7 @@ router.post(
|
|||||||
|
|
||||||
// 验证token是否有效(检查数据库)
|
// 验证token是否有效(检查数据库)
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
where: { token },
|
where: {token},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!appInstall) {
|
if (!appInstall) {
|
||||||
@ -119,7 +118,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/device/token",
|
"/device/token",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { device_code } = req.query;
|
const {device_code} = req.query;
|
||||||
|
|
||||||
if (!device_code) {
|
if (!device_code) {
|
||||||
return next(errors.createError(400, "请提供 device_code"));
|
return next(errors.createError(400, "请提供 device_code"));
|
||||||
@ -174,7 +173,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/device/status",
|
"/device/status",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { device_code } = req.query;
|
const {device_code} = req.query;
|
||||||
|
|
||||||
if (!device_code) {
|
if (!device_code) {
|
||||||
return next(errors.createError(400, "请提供 device_code"));
|
return next(errors.createError(400, "请提供 device_code"));
|
||||||
|
|||||||
245
routes/device.js
245
routes/device.js
@ -1,11 +1,11 @@
|
|||||||
import { Router } from "express";
|
import {Router} from "express";
|
||||||
const router = Router();
|
import {extractDeviceInfo} from "../middleware/uuidAuth.js";
|
||||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
import {getOnlineDevices} from "../utils/socket.js";
|
||||||
import { getOnlineDevices } from "../utils/socket.js";
|
import {registeredDevicesTotal} from "../utils/metrics.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ async function createDefaultAutoAuth(deviceId) {
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid, deviceName, namespace } = req.body;
|
const {uuid, deviceName, namespace} = req.body;
|
||||||
|
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return next(errors.createError(400, "设备UUID是必需的"));
|
return next(errors.createError(400, "设备UUID是必需的"));
|
||||||
@ -47,9 +47,10 @@ router.post(
|
|||||||
return next(errors.createError(400, "设备名称是必需的"));
|
return next(errors.createError(400, "设备名称是必需的"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// 检查UUID是否已存在
|
// 检查UUID是否已存在
|
||||||
const existingDevice = await prisma.device.findUnique({
|
const existingDevice = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingDevice) {
|
if (existingDevice) {
|
||||||
@ -61,7 +62,7 @@ router.post(
|
|||||||
|
|
||||||
// 检查 namespace 是否已被使用
|
// 检查 namespace 是否已被使用
|
||||||
const existingNamespace = await prisma.device.findUnique({
|
const existingNamespace = await prisma.device.findUnique({
|
||||||
where: { namespace: deviceNamespace },
|
where: {namespace: deviceNamespace},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingNamespace) {
|
if (existingNamespace) {
|
||||||
@ -80,6 +81,10 @@ router.post(
|
|||||||
// 为新设备创建默认的自动登录配置
|
// 为新设备创建默认的自动登录配置
|
||||||
await createDefaultAutoAuth(device.id);
|
await createDefaultAutoAuth(device.id);
|
||||||
|
|
||||||
|
// 更新注册设备总数指标
|
||||||
|
const totalDevices = await prisma.device.count();
|
||||||
|
registeredDevicesTotal.set(totalDevices);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
device: {
|
device: {
|
||||||
@ -90,6 +95,9 @@ router.post(
|
|||||||
createdAt: device.createdAt,
|
createdAt: device.createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -100,11 +108,11 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:uuid",
|
"/:uuid",
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { uuid } = req.params;
|
const {uuid} = req.params;
|
||||||
|
|
||||||
// 查找设备,包含绑定的账户信息
|
// 查找设备,包含绑定的账户信息
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findUnique({
|
||||||
where: { uuid },
|
where: {uuid},
|
||||||
include: {
|
include: {
|
||||||
account: {
|
account: {
|
||||||
select: {
|
select: {
|
||||||
@ -138,15 +146,16 @@ router.get(
|
|||||||
namespace: device.namespace,
|
namespace: device.namespace,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);/**
|
);
|
||||||
|
/**
|
||||||
* PUT /devices/:uuid/name
|
* PUT /devices/:uuid/name
|
||||||
* 设置设备名称 (需要UUID认证)
|
* 设置设备名称 (需要UUID认证)
|
||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
"/:uuid/name",
|
"/:uuid/name",
|
||||||
uuidAuth,
|
extractDeviceInfo,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { name } = req.body;
|
const {name} = req.body;
|
||||||
const device = res.locals.device;
|
const device = res.locals.device;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@ -154,8 +163,8 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedDevice = await prisma.device.update({
|
const updatedDevice = await prisma.device.update({
|
||||||
where: { id: device.id },
|
where: {id: device.id},
|
||||||
data: { name },
|
data: {name},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@ -171,198 +180,6 @@ router.put(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /devices/:uuid/password
|
|
||||||
* 初次设置设备密码 (无需认证,仅当设备未设置密码时)
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/:uuid/password",
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const { uuid } = req.params;
|
|
||||||
const newPassword = req.query.newPassword || req.body.newPassword;
|
|
||||||
|
|
||||||
if (!newPassword) {
|
|
||||||
return next(errors.createError(400, "新密码是必需的"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找设备
|
|
||||||
const device = await prisma.device.findUnique({
|
|
||||||
where: { uuid },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return next(errors.createError(404, "设备不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有在设备未设置密码时才允许无认证设置
|
|
||||||
if (device.password) {
|
|
||||||
return next(errors.createError(403, "设备已设置密码,请使用修改密码接口"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(newPassword);
|
|
||||||
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: {
|
|
||||||
password: hashedPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "密码设置成功",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /devices/:uuid/password
|
|
||||||
* 修改设备密码 (需要UUID认证和当前密码验证,账户拥有者除外)
|
|
||||||
*/
|
|
||||||
router.put(
|
|
||||||
"/:uuid/password",
|
|
||||||
uuidAuth,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const currentPassword = req.query.currentPassword;
|
|
||||||
const newPassword = req.query.newPassword || req.body.newPassword;
|
|
||||||
const passwordHint = req.query.passwordHint || req.body.passwordHint;
|
|
||||||
const device = res.locals.device;
|
|
||||||
const isAccountOwner = res.locals.isAccountOwner;
|
|
||||||
|
|
||||||
if (!newPassword) {
|
|
||||||
return next(errors.createError(400, "新密码是必需的"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是账户拥有者,无需验证当前密码
|
|
||||||
if (!isAccountOwner) {
|
|
||||||
if (!device.password) {
|
|
||||||
return next(errors.createError(400, "设备未设置密码,请使用设置密码接口"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentPassword) {
|
|
||||||
return next(errors.createError(400, "当前密码是必需的"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证当前密码
|
|
||||||
const isCurrentPasswordValid = await verifyDevicePassword(currentPassword, device.password);
|
|
||||||
if (!isCurrentPasswordValid) {
|
|
||||||
return next(errors.createError(401, "当前密码错误"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedNewPassword = await hashPassword(newPassword);
|
|
||||||
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: {
|
|
||||||
password: hashedNewPassword,
|
|
||||||
passwordHint: passwordHint !== undefined ? passwordHint : device.passwordHint,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "密码修改成功",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /devices/:uuid/password-hint
|
|
||||||
* 设置密码提示 (需要UUID认证)
|
|
||||||
*/
|
|
||||||
router.put(
|
|
||||||
"/:uuid/password-hint",
|
|
||||||
uuidAuth,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const { passwordHint } = req.body;
|
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: { passwordHint: passwordHint || null },
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "密码提示设置成功",
|
|
||||||
passwordHint: passwordHint || null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /devices/:uuid/password-hint
|
|
||||||
* 获取设备密码提示 (无需认证)
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/:uuid/password-hint",
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const { uuid } = req.params;
|
|
||||||
|
|
||||||
const device = await prisma.device.findUnique({
|
|
||||||
where: { uuid },
|
|
||||||
select: {
|
|
||||||
passwordHint: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return next(errors.createError(404, "设备不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
passwordHint: device.passwordHint || null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /devices/:uuid/password
|
|
||||||
* 删除设备密码 (需要UUID认证和密码验证,账户拥有者除外)
|
|
||||||
*/
|
|
||||||
router.delete(
|
|
||||||
"/:uuid/password",
|
|
||||||
uuidAuth,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const password = req.query.password;
|
|
||||||
const device = res.locals.device;
|
|
||||||
const isAccountOwner = res.locals.isAccountOwner;
|
|
||||||
|
|
||||||
if (!device.password) {
|
|
||||||
return next(errors.createError(400, "设备未设置密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不是账户拥有者,需要验证密码
|
|
||||||
if (!isAccountOwner) {
|
|
||||||
if (!password) {
|
|
||||||
return next(errors.createError(400, "密码是必需的"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证密码
|
|
||||||
const isPasswordValid = await verifyDevicePassword(password, device.password);
|
|
||||||
if (!isPasswordValid) {
|
|
||||||
return next(errors.createError(401, "密码错误"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: {
|
|
||||||
password: null,
|
|
||||||
passwordHint: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "密码删除成功",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /devices/online
|
* GET /devices/online
|
||||||
@ -375,14 +192,14 @@ router.get(
|
|||||||
const list = getOnlineDevices();
|
const list = getOnlineDevices();
|
||||||
|
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return res.json({ success: true, devices: [] });
|
return res.json({success: true, devices: []});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 补充设备名称
|
// 补充设备名称
|
||||||
const uuids = list.map((x) => x.uuid);
|
const uuids = list.map((x) => x.uuid);
|
||||||
const rows = await prisma.device.findMany({
|
const rows = await prisma.device.findMany({
|
||||||
where: { uuid: { in: uuids } },
|
where: {uuid: {in: uuids}},
|
||||||
select: { uuid: true, name: true },
|
select: {uuid: true, name: true},
|
||||||
});
|
});
|
||||||
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
|
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
|
||||||
|
|
||||||
@ -392,6 +209,8 @@ router.get(
|
|||||||
name: nameMap.get(x.uuid) || null,
|
name: nameMap.get(x.uuid) || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ success: true, devices });
|
res.json({success: true, devices});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Router } from "express";
|
import {Router} from "express";
|
||||||
|
|
||||||
var router = Router();
|
var router = Router();
|
||||||
|
|
||||||
/* GET home page. */
|
/* GET home page. */
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
const router = Router();
|
|
||||||
import kvStore from "../utils/kvStore.js";
|
import kvStore from "../utils/kvStore.js";
|
||||||
import { broadcastKeyChanged } from "../utils/socket.js";
|
import { broadcastKeyChanged } from "../utils/socket.js";
|
||||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||||
import {
|
import {
|
||||||
tokenReadLimiter,
|
prepareTokenForRateLimit,
|
||||||
tokenWriteLimiter,
|
|
||||||
tokenDeleteLimiter,
|
|
||||||
tokenBatchLimiter,
|
tokenBatchLimiter,
|
||||||
prepareTokenForRateLimit
|
tokenDeleteLimiter,
|
||||||
|
tokenReadLimiter,
|
||||||
|
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 { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// 使用KV专用token认证
|
// 使用KV专用token认证
|
||||||
@ -297,43 +298,25 @@ router.post(
|
|||||||
req.connection.socket?.remoteAddress ||
|
req.connection.socket?.remoteAddress ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const results = [];
|
// 使用优化的批量upsert方法
|
||||||
const errorList = [];
|
const { results, errors: errorList } = await kvStore.batchUpsert(deviceId, data, creatorIp);
|
||||||
|
|
||||||
// 批量处理所有键值对
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
try {
|
|
||||||
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
|
|
||||||
results.push({
|
|
||||||
key: result.key,
|
|
||||||
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
|
||||||
});
|
|
||||||
// 广播每个键的变更
|
|
||||||
const uuid = res.locals.device?.uuid;
|
|
||||||
if (uuid) {
|
|
||||||
broadcastKeyChanged(uuid, {
|
|
||||||
key: result.key,
|
|
||||||
action: "upsert",
|
|
||||||
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
|
||||||
updatedAt: result.updatedAt,
|
|
||||||
batch: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorList.push({
|
|
||||||
key,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
code: 200,
|
||||||
|
message: "批量导入成功",
|
||||||
|
data: {
|
||||||
deviceId,
|
deviceId,
|
||||||
|
summary: {
|
||||||
total: Object.keys(data).length,
|
total: Object.keys(data).length,
|
||||||
successful: results.length,
|
successful: results.length,
|
||||||
failed: errorList.length,
|
failed: errorList.length,
|
||||||
results,
|
},
|
||||||
errors: errorList.length > 0 ? errorList : undefined,
|
results: results.map(r => ({
|
||||||
|
key: r.key,
|
||||||
|
isNew: r.created,
|
||||||
|
})),
|
||||||
|
...(errorList.length > 0 && { errors: errorList }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -353,10 +336,20 @@ router.post(
|
|||||||
|
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { key } = req.params;
|
const { key } = req.params;
|
||||||
const value = req.body;
|
let value = req.body;
|
||||||
|
|
||||||
if (!value || Object.keys(value).length === 0) {
|
// 处理空值,转换为空对象
|
||||||
return next(errors.createError(400, "请提供有效的JSON值"));
|
if (value === null || value === undefined || value === '') {
|
||||||
|
value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证是否能被 JSON 序列化
|
||||||
|
try {
|
||||||
|
JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
errors.createError(400, "无效的数据格式")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取客户端IP
|
// 获取客户端IP
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
export const siteKey = process.env.SITE_KEY || "";
|
export const siteKey = process.env.SITE_KEY || "";
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { Base64 } from "js-base64";
|
import {Base64} from "js-base64";
|
||||||
|
|
||||||
const SALT_ROUNDS = 8;
|
const SALT_ROUNDS = 8;
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* 创建标准错误对象
|
* 创建标准错误对象
|
||||||
* @param {number} statusCode - HTTP状态码
|
* @param {number} statusCode - HTTP状态码
|
||||||
* @param {string} [message] - 错误消息
|
* @param {string} [message] - 错误消息(推荐使用稳定的机器可读文本,如 JWT_EXPIRED)
|
||||||
* @param {object} [details] - 附加信息
|
* @param {object} [details] - 附加信息
|
||||||
|
* @param {string} [code] - 业务错误码(如 AUTH_JWT_EXPIRED)
|
||||||
* @returns {object} 标准错误对象
|
* @returns {object} 标准错误对象
|
||||||
*/
|
*/
|
||||||
const createError = (statusCode, message, details = null) => {
|
const createError = (statusCode, message, details = null, code = null) => {
|
||||||
// 直接返回错误对象,不抛出异常
|
// 直接返回错误对象,不抛出异常
|
||||||
const error = {
|
const error = {
|
||||||
statusCode: statusCode,
|
statusCode: statusCode,
|
||||||
message: message || '服务器错误',
|
message: message || '服务器错误',
|
||||||
details: details
|
details: details,
|
||||||
|
code: code || undefined,
|
||||||
};
|
};
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
import {NodeSDK} from "@opentelemetry/sdk-node";
|
||||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
import {getNodeAutoInstrumentations} from "@opentelemetry/auto-instrumentations-node";
|
||||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
import {OTLPTraceExporter} from "@opentelemetry/exporter-trace-otlp-proto";
|
||||||
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
import {BatchSpanProcessor} from "@opentelemetry/sdk-trace-base";
|
||||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
import {resourceFromAttributes} from "@opentelemetry/resources";
|
||||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
import {SemanticResourceAttributes} from "@opentelemetry/semantic-conventions";
|
||||||
|
|
||||||
if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) {
|
if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) {
|
||||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||||
|
|||||||
12
utils/jwt.js
12
utils/jwt.js
@ -1,11 +1,11 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import {
|
import {
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
verifyAccessToken,
|
|
||||||
generateTokenPair,
|
generateTokenPair,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
revokeAllTokens,
|
revokeAllTokens,
|
||||||
revokeRefreshToken,
|
revokeRefreshToken,
|
||||||
|
verifyAccessToken,
|
||||||
} from './tokenManager.js';
|
} from './tokenManager.js';
|
||||||
|
|
||||||
// JWT 配置(支持 HS256 与 RS256)
|
// JWT 配置(支持 HS256 与 RS256)
|
||||||
@ -24,10 +24,10 @@ function getSignVerifyKeys() {
|
|||||||
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
|
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
|
||||||
throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY');
|
throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
return { signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY };
|
return {signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY};
|
||||||
}
|
}
|
||||||
// 默认 HS256
|
// 默认 HS256
|
||||||
return { signKey: JWT_SECRET, verifyKey: JWT_SECRET };
|
return {signKey: JWT_SECRET, verifyKey: JWT_SECRET};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,7 +35,7 @@ function getSignVerifyKeys() {
|
|||||||
* @deprecated 建议使用 generateAccessToken
|
* @deprecated 建议使用 generateAccessToken
|
||||||
*/
|
*/
|
||||||
export function signToken(payload) {
|
export function signToken(payload) {
|
||||||
const { signKey } = getSignVerifyKeys();
|
const {signKey} = getSignVerifyKeys();
|
||||||
return jwt.sign(payload, signKey, {
|
return jwt.sign(payload, signKey, {
|
||||||
expiresIn: JWT_EXPIRES_IN,
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
algorithm: JWT_ALG,
|
algorithm: JWT_ALG,
|
||||||
@ -47,8 +47,8 @@ export function signToken(payload) {
|
|||||||
* @deprecated 建议使用 verifyAccessToken
|
* @deprecated 建议使用 verifyAccessToken
|
||||||
*/
|
*/
|
||||||
export function verifyToken(token) {
|
export function verifyToken(token) {
|
||||||
const { verifyKey } = getSignVerifyKeys();
|
const {verifyKey} = getSignVerifyKeys();
|
||||||
return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] });
|
return jwt.verify(token, verifyKey, {algorithms: [JWT_ALG]});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
113
utils/kvStore.js
113
utils/kvStore.js
@ -1,5 +1,8 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
|
import {keysTotal} from "./metrics.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
class KVStore {
|
class KVStore {
|
||||||
/**
|
/**
|
||||||
* 通过设备ID和键名获取值
|
* 通过设备ID和键名获取值
|
||||||
@ -74,7 +77,7 @@ class KVStore {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
value,
|
value,
|
||||||
...(creatorIp && { creatorIp }),
|
...(creatorIp && {creatorIp}),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
@ -84,6 +87,10 @@ class KVStore {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新键总数指标
|
||||||
|
const totalKeys = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(totalKeys);
|
||||||
|
|
||||||
// 返回带有设备ID和原始键的结果
|
// 返回带有设备ID和原始键的结果
|
||||||
return {
|
return {
|
||||||
deviceId,
|
deviceId,
|
||||||
@ -95,6 +102,62 @@ class KVStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量创建或更新键值对(优化性能)
|
||||||
|
* @param {number} deviceId - 设备ID
|
||||||
|
* @param {object} data - 键值对数据 {key1: value1, key2: value2, ...}
|
||||||
|
* @param {string} creatorIp - 创建者IP,可选
|
||||||
|
* @returns {object} {results: Array, errors: Array}
|
||||||
|
*/
|
||||||
|
async batchUpsert(deviceId, data, creatorIp = "") {
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// 使用事务处理所有操作
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
try {
|
||||||
|
const item = await tx.kVStore.upsert({
|
||||||
|
where: {
|
||||||
|
deviceId_key: {
|
||||||
|
deviceId: deviceId,
|
||||||
|
key: key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value,
|
||||||
|
...(creatorIp && {creatorIp}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
deviceId: deviceId,
|
||||||
|
key: key,
|
||||||
|
value,
|
||||||
|
creatorIp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
key: item.key,
|
||||||
|
created: item.createdAt.getTime() === item.updatedAt.getTime(),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
key,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在事务完成后,一次性更新指标
|
||||||
|
const totalKeys = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(totalKeys);
|
||||||
|
|
||||||
|
return { results, errors };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过设备ID和键名删除
|
* 通过设备ID和键名删除
|
||||||
* @param {number} deviceId - 设备ID
|
* @param {number} deviceId - 设备ID
|
||||||
@ -111,10 +174,17 @@ class KVStore {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return item ? { ...item, deviceId, key } : null;
|
|
||||||
|
// 更新键总数指标
|
||||||
|
const totalKeys = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(totalKeys);
|
||||||
|
|
||||||
|
return item ? {...item, deviceId, key} : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 忽略记录不存在的错误
|
// 忽略记录不存在的错误
|
||||||
if (error.code === "P2025") return null;
|
if (error.code === "P2025") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,7 +196,7 @@ class KVStore {
|
|||||||
* @returns {Array} 键名和元数据数组
|
* @returns {Array} 键名和元数据数组
|
||||||
*/
|
*/
|
||||||
async list(deviceId, options = {}) {
|
async list(deviceId, options = {}) {
|
||||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
|
||||||
|
|
||||||
// 构建排序条件
|
// 构建排序条件
|
||||||
const orderBy = {};
|
const orderBy = {};
|
||||||
@ -169,7 +239,7 @@ class KVStore {
|
|||||||
* @returns {Array} 键名列表
|
* @returns {Array} 键名列表
|
||||||
*/
|
*/
|
||||||
async listKeysOnly(deviceId, options = {}) {
|
async listKeysOnly(deviceId, options = {}) {
|
||||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
|
||||||
|
|
||||||
// 构建排序条件
|
// 构建排序条件
|
||||||
const orderBy = {};
|
const orderBy = {};
|
||||||
@ -205,6 +275,37 @@ class KVStore {
|
|||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定设备的统计信息
|
||||||
|
* @param {number} deviceId - 设备ID
|
||||||
|
* @returns {object} 统计信息
|
||||||
|
*/
|
||||||
|
async getStats(deviceId) {
|
||||||
|
const [totalKeys, oldestKey, newestKey] = await Promise.all([
|
||||||
|
prisma.kVStore.count({
|
||||||
|
where: { deviceId },
|
||||||
|
}),
|
||||||
|
prisma.kVStore.findFirst({
|
||||||
|
where: { deviceId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { createdAt: true, key: true },
|
||||||
|
}),
|
||||||
|
prisma.kVStore.findFirst({
|
||||||
|
where: { deviceId },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: { updatedAt: true, key: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalKeys,
|
||||||
|
oldestKey: oldestKey?.key,
|
||||||
|
oldestCreatedAt: oldestKey?.createdAt,
|
||||||
|
newestKey: newestKey?.key,
|
||||||
|
newestUpdatedAt: newestKey?.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new KVStore();
|
export default new KVStore();
|
||||||
|
|||||||
49
utils/metrics.js
Normal file
49
utils/metrics.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import client from 'prom-client';
|
||||||
|
|
||||||
|
// 创建自定义注册表(不包含默认指标)
|
||||||
|
const register = new client.Registry();
|
||||||
|
|
||||||
|
// 当前在线设备数(连接了 SocketIO 的设备)
|
||||||
|
export const onlineDevicesGauge = new client.Gauge({
|
||||||
|
name: 'classworks_online_devices_total',
|
||||||
|
help: 'Total number of online devices (connected via SocketIO)',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已注册设备总数
|
||||||
|
export const registeredDevicesTotal = new client.Gauge({
|
||||||
|
name: 'classworks_registered_devices_total',
|
||||||
|
help: 'Total number of registered devices',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已创建键总数(不区分设备)
|
||||||
|
export const keysTotal = new client.Gauge({
|
||||||
|
name: 'classworks_keys_total',
|
||||||
|
help: 'Total number of keys across all devices',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化指标数据
|
||||||
|
export async function initializeMetrics() {
|
||||||
|
try {
|
||||||
|
const {PrismaClient} = await import('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 获取已注册设备总数
|
||||||
|
const deviceCount = await prisma.device.count();
|
||||||
|
registeredDevicesTotal.set(deviceCount);
|
||||||
|
|
||||||
|
// 获取已创建键总数
|
||||||
|
const keyCount = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(keyCount);
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize metrics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出注册表用于 /metrics 端点
|
||||||
|
export {register};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import {PrismaClient} from "@prisma/client";
|
||||||
import kvStore from "./kvStore.js";
|
import kvStore from "./kvStore.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@ -24,8 +24,8 @@ async function getSystemDeviceId() {
|
|||||||
if (systemDeviceId) return systemDeviceId;
|
if (systemDeviceId) return systemDeviceId;
|
||||||
|
|
||||||
let device = await prisma.device.findUnique({
|
let device = await prisma.device.findUnique({
|
||||||
where: { uuid: SYSTEM_DEVICE_UUID },
|
where: {uuid: SYSTEM_DEVICE_UUID},
|
||||||
select: { id: true },
|
select: {id: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -34,7 +34,7 @@ async function getSystemDeviceId() {
|
|||||||
uuid: SYSTEM_DEVICE_UUID,
|
uuid: SYSTEM_DEVICE_UUID,
|
||||||
name: "系统设备",
|
name: "系统设备",
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: {id: true},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ export const initReadme = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 确保在异常情况下也有默认值
|
// 确保在异常情况下也有默认值
|
||||||
readmeValue = { ...defaultReadme };
|
readmeValue = {...defaultReadme};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ export const initReadme = async () => {
|
|||||||
* @returns {Object} readme 值对象
|
* @returns {Object} readme 值对象
|
||||||
*/
|
*/
|
||||||
export const getReadmeValue = () => {
|
export const getReadmeValue = () => {
|
||||||
return readmeValue || { ...defaultReadme };
|
return readmeValue || {...defaultReadme};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
471
utils/socket.js
471
utils/socket.js
@ -7,18 +7,88 @@
|
|||||||
* - 同一设备的不同 token 会被归入同一频道
|
* - 同一设备的不同 token 会被归入同一频道
|
||||||
* - 维护在线设备列表
|
* - 维护在线设备列表
|
||||||
* - 提供广播 KV 键变更的工具方法
|
* - 提供广播 KV 键变更的工具方法
|
||||||
|
* - 支持任意类型事件转发:客户端可发送自定义事件类型和JSON内容到其他设备
|
||||||
|
* - 记录事件历史:包含时间戳、来源令牌、设备类型、权限等完整元数据
|
||||||
|
* - 令牌信息缓存:在连接时预加载令牌详细信息以提高性能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { onlineDevicesGauge } from "./metrics.js";
|
||||||
|
import DeviceDetector from "node-device-detector";
|
||||||
|
import ClientHints from "node-device-detector/client-hints.js";
|
||||||
|
|
||||||
// Socket.IO 单例实例
|
// Socket.IO 单例实例
|
||||||
let io = null;
|
let io = null;
|
||||||
|
|
||||||
|
// 设备检测器实例
|
||||||
|
const deviceDetector = new DeviceDetector({
|
||||||
|
clientIndexes: true,
|
||||||
|
deviceIndexes: true,
|
||||||
|
deviceAliasCode: false,
|
||||||
|
});
|
||||||
|
const clientHints = new ClientHints();
|
||||||
|
|
||||||
// 在线设备映射:uuid -> Set<socketId>
|
// 在线设备映射:uuid -> Set<socketId>
|
||||||
const onlineMap = new Map();
|
const onlineMap = new Map();
|
||||||
|
// 在线 token 映射:token -> Set<socketId> (用于指标统计)
|
||||||
|
const onlineTokens = new Map();
|
||||||
|
// 令牌信息缓存:token -> {appId, isReadOnly, deviceType, note, deviceUuid, deviceName}
|
||||||
|
const tokenInfoCache = new Map();
|
||||||
|
// 事件历史记录:每个设备最多保存1000条事件记录
|
||||||
|
const eventHistory = new Map(); // uuid -> Array<EventRecord>
|
||||||
|
const MAX_EVENT_HISTORY = 1000;
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测设备并生成友好的设备名称
|
||||||
|
* @param {string} userAgent 用户代理字符串
|
||||||
|
* @param {object} headers HTTP headers对象
|
||||||
|
* @returns {string} 生成的设备名称
|
||||||
|
*/
|
||||||
|
function detectDeviceName(userAgent, headers = {}) {
|
||||||
|
if (!userAgent) return "Unknown Device";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clientHintsData = clientHints.parse(headers);
|
||||||
|
const deviceInfo = deviceDetector.detect(userAgent, clientHintsData);
|
||||||
|
const botInfo = deviceDetector.parseBot(userAgent);
|
||||||
|
|
||||||
|
// 如果是bot,返回bot名称
|
||||||
|
if (botInfo && botInfo.name) {
|
||||||
|
return `Bot: ${botInfo.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建设备名称
|
||||||
|
let deviceName = "";
|
||||||
|
|
||||||
|
if (deviceInfo.device && deviceInfo.device.brand && deviceInfo.device.model) {
|
||||||
|
deviceName = `${deviceInfo.device.brand} ${deviceInfo.device.model}`;
|
||||||
|
} else if (deviceInfo.os && deviceInfo.os.name) {
|
||||||
|
deviceName = deviceInfo.os.name;
|
||||||
|
if (deviceInfo.os.version) {
|
||||||
|
deviceName += ` ${deviceInfo.os.version}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加客户端信息
|
||||||
|
if (deviceInfo.client && deviceInfo.client.name) {
|
||||||
|
deviceName += deviceName ? ` (${deviceInfo.client.name}` : deviceInfo.client.name;
|
||||||
|
if (deviceInfo.client.version) {
|
||||||
|
deviceName += ` ${deviceInfo.client.version}`;
|
||||||
|
}
|
||||||
|
if (deviceName.includes("(")) {
|
||||||
|
deviceName += ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceName || "Unknown Device";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Device detection error:", error);
|
||||||
|
return "Unknown Device";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 Socket.IO
|
* 初始化 Socket.IO
|
||||||
* @param {import('http').Server} server HTTP Server 实例
|
* @param {import('http').Server} server HTTP Server 实例
|
||||||
@ -26,14 +96,16 @@ const prisma = new PrismaClient();
|
|||||||
export function initSocket(server) {
|
export function initSocket(server) {
|
||||||
if (io) return io;
|
if (io) return io;
|
||||||
|
|
||||||
const allowOrigin = process.env.FRONTEND_URL || "*";
|
|
||||||
|
|
||||||
io = new Server(server, {
|
io = new Server(server, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: allowOrigin,
|
origin: "*",
|
||||||
methods: ["GET", "POST"],
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
credentials: true,
|
allowedHeaders: ["*"],
|
||||||
|
credentials: false
|
||||||
},
|
},
|
||||||
|
// 传输方式回退策略:优先使用WebSocket,回退到轮询
|
||||||
|
transports: ["polling", "websocket"],
|
||||||
});
|
});
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
@ -43,14 +115,16 @@ export function initSocket(server) {
|
|||||||
// 仅允许通过 query.token/apptoken 加入
|
// 仅允许通过 query.token/apptoken 加入
|
||||||
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
||||||
if (qToken && typeof qToken === "string") {
|
if (qToken && typeof qToken === "string") {
|
||||||
joinByToken(socket, qToken).catch(() => {});
|
joinByToken(socket, qToken).catch(() => {
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端使用 KV token 加入房间
|
// 客户端使用 KV token 加入房间
|
||||||
socket.on("join-token", (payload) => {
|
socket.on("join-token", (payload) => {
|
||||||
const token = payload?.token || payload?.apptoken;
|
const token = payload?.token || payload?.apptoken;
|
||||||
if (typeof token === "string" && token.length > 0) {
|
if (typeof token === "string" && token.length > 0) {
|
||||||
joinByToken(socket, token).catch(() => {});
|
joinByToken(socket, token).catch(() => {
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,7 +138,12 @@ export function initSocket(server) {
|
|||||||
include: { device: { select: { uuid: true } } },
|
include: { device: { select: { uuid: true } } },
|
||||||
});
|
});
|
||||||
const uuid = appInstall?.device?.uuid;
|
const uuid = appInstall?.device?.uuid;
|
||||||
if (uuid) leaveDeviceRoom(socket, uuid);
|
if (uuid) {
|
||||||
|
leaveDeviceRoom(socket, uuid);
|
||||||
|
// 移除 token 连接跟踪
|
||||||
|
removeTokenConnection(token, socket.id);
|
||||||
|
if (socket.data.tokens) socket.data.tokens.delete(token);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -76,35 +155,148 @@ export function initSocket(server) {
|
|||||||
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 聊天室:发送文本消息到加入的设备频道
|
// 获取事件历史记录
|
||||||
socket.on("chat:send", (data) => {
|
socket.on("get-event-history", (data) => {
|
||||||
try {
|
try {
|
||||||
const text = typeof data === "string" ? data : data?.text;
|
const { limit = 50, offset = 0 } = data || {};
|
||||||
if (typeof text !== "string") return;
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
|
|
||||||
// 限制消息最大长度,避免滥用
|
|
||||||
const MAX_LEN = 2000;
|
|
||||||
const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed;
|
|
||||||
|
|
||||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
if (uuids.length === 0) return;
|
|
||||||
|
|
||||||
const at = new Date().toISOString();
|
if (uuids.length === 0) {
|
||||||
const payload = { text: safeText, at, senderId: socket.id };
|
socket.emit("event-history-error", { reason: "not_joined_any_device" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回所有加入设备的事件历史
|
||||||
|
const historyData = {};
|
||||||
uuids.forEach((uuid) => {
|
uuids.forEach((uuid) => {
|
||||||
io.to(uuid).emit("chat:message", { uuid, ...payload });
|
historyData[uuid] = getEventHistory(uuid, limit, offset);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.emit("event-history", {
|
||||||
|
devices: historyData,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
requestedBy: {
|
||||||
|
deviceType: socket.data.tokenInfo?.deviceType,
|
||||||
|
deviceName: socket.data.tokenInfo?.deviceName,
|
||||||
|
isReadOnly: socket.data.tokenInfo?.isReadOnly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("chat:send error:", err);
|
console.error("get-event-history error:", err);
|
||||||
|
socket.emit("event-history-error", { reason: "internal_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用事件转发:允许发送任意类型事件到其他设备
|
||||||
|
socket.on("send-event", (data) => {
|
||||||
|
try {
|
||||||
|
// 验证数据结构
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
socket.emit("event-error", { reason: "invalid_data_format" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, content } = data;
|
||||||
|
|
||||||
|
// 验证事件类型
|
||||||
|
if (typeof type !== "string" || type.trim().length === 0) {
|
||||||
|
socket.emit("event-error", { reason: "invalid_event_type" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证内容格式(必须是对象或null)
|
||||||
|
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
|
||||||
|
socket.emit("event-error", { reason: "content_must_be_object_or_null" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前socket所在的设备房间
|
||||||
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
|
if (uuids.length === 0) {
|
||||||
|
socket.emit("event-error", { reason: "not_joined_any_device" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查只读权限
|
||||||
|
const tokenInfo = socket.data.tokenInfo;
|
||||||
|
if (tokenInfo?.isReadOnly) {
|
||||||
|
socket.emit("event-error", { reason: "readonly_token_cannot_send_events" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制序列化后内容大小,避免滥用
|
||||||
|
const MAX_SIZE = 10240; // 10KB
|
||||||
|
const serializedContent = JSON.stringify(content);
|
||||||
|
if (serializedContent.length > MAX_SIZE) {
|
||||||
|
socket.emit("event-error", { reason: "content_too_large", maxSize: MAX_SIZE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const eventId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// 构建完整的事件载荷,包含发送者信息
|
||||||
|
const eventPayload = {
|
||||||
|
eventId,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
senderId: socket.id,
|
||||||
|
senderInfo: {
|
||||||
|
appId: tokenInfo?.appId,
|
||||||
|
deviceType: tokenInfo?.deviceType,
|
||||||
|
deviceName: tokenInfo?.deviceName,
|
||||||
|
isReadOnly: tokenInfo?.isReadOnly || false,
|
||||||
|
note: tokenInfo?.note
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录事件到历史记录(包含type用于历史记录)
|
||||||
|
const historyPayload = {
|
||||||
|
...eventPayload,
|
||||||
|
type: type.trim()
|
||||||
|
};
|
||||||
|
uuids.forEach((uuid) => {
|
||||||
|
recordEventHistory(uuid, historyPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 直接使用事件名称发送到所有相关设备房间(排除发送者所在的socket)
|
||||||
|
uuids.forEach((uuid) => {
|
||||||
|
socket.to(uuid).emit(type.trim(), eventPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送确认回执给发送者
|
||||||
|
socket.emit("event-sent", {
|
||||||
|
eventId: eventPayload.eventId,
|
||||||
|
eventName: type.trim(),
|
||||||
|
timestamp: eventPayload.timestamp,
|
||||||
|
targetDevices: uuids.length,
|
||||||
|
senderInfo: eventPayload.senderInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("send-event error:", err);
|
||||||
|
socket.emit("event-error", { reason: "internal_error" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
uuids.forEach((u) => removeOnline(u, socket.id));
|
uuids.forEach((u) => removeOnline(u, socket.id));
|
||||||
|
|
||||||
|
// 清理 token 连接跟踪
|
||||||
|
const tokens = Array.from(socket.data.tokens || []);
|
||||||
|
tokens.forEach((token) => removeTokenConnection(token, socket.id));
|
||||||
|
|
||||||
|
// 清理socket相关缓存
|
||||||
|
if (socket.data.currentToken) {
|
||||||
|
// 如果这是该token的最后一个连接,考虑清理缓存
|
||||||
|
const tokenSet = onlineTokens.get(socket.data.currentToken);
|
||||||
|
if (!tokenSet || tokenSet.size === 0) {
|
||||||
|
// 可以选择保留缓存一段时间,这里暂时保留
|
||||||
|
// tokenInfoCache.delete(socket.data.currentToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,6 +325,24 @@ function joinDeviceRoom(socket, uuid) {
|
|||||||
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪 token 连接用于指标统计
|
||||||
|
* @param {import('socket.io').Socket} socket
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
function trackTokenConnection(socket, token) {
|
||||||
|
if (!socket.data.tokens) socket.data.tokens = new Set();
|
||||||
|
socket.data.tokens.add(token);
|
||||||
|
|
||||||
|
// 记录 token 连接
|
||||||
|
const set = onlineTokens.get(token) || new Set();
|
||||||
|
set.add(socket.id);
|
||||||
|
onlineTokens.set(token, set);
|
||||||
|
|
||||||
|
// 更新在线设备数指标(基于不同的 token 数量)
|
||||||
|
onlineDevicesGauge.set(onlineTokens.size);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让 socket 离开设备房间并更新在线表
|
* 让 socket 离开设备房间并更新在线表
|
||||||
* @param {import('socket.io').Socket} socket
|
* @param {import('socket.io').Socket} socket
|
||||||
@ -155,6 +365,24 @@ function removeOnline(uuid, socketId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 token 连接跟踪
|
||||||
|
* @param {string} token
|
||||||
|
* @param {string} socketId
|
||||||
|
*/
|
||||||
|
function removeTokenConnection(token, socketId) {
|
||||||
|
const set = onlineTokens.get(token);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(socketId);
|
||||||
|
if (set.size === 0) {
|
||||||
|
onlineTokens.delete(token);
|
||||||
|
} else {
|
||||||
|
onlineTokens.set(token, set);
|
||||||
|
}
|
||||||
|
// 更新在线设备数指标(基于不同的 token 数量)
|
||||||
|
onlineDevicesGauge.set(onlineTokens.size);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 广播某设备下 KV 键已变更
|
* 广播某设备下 KV 键已变更
|
||||||
* @param {string} uuid 设备 uuid
|
* @param {string} uuid 设备 uuid
|
||||||
@ -162,17 +390,144 @@ function removeOnline(uuid, socketId) {
|
|||||||
*/
|
*/
|
||||||
export function broadcastKeyChanged(uuid, payload) {
|
export function broadcastKeyChanged(uuid, payload) {
|
||||||
if (!io || !uuid) return;
|
if (!io || !uuid) return;
|
||||||
io.to(uuid).emit("kv-key-changed", { uuid, ...payload });
|
|
||||||
|
// 发送KV变更事件
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const eventId = `kv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const eventPayload = {
|
||||||
|
eventId,
|
||||||
|
content: payload,
|
||||||
|
timestamp,
|
||||||
|
senderId: "realtime",
|
||||||
|
senderInfo: {
|
||||||
|
appId: "5c2a54d553951a37b47066ead68c8642",
|
||||||
|
deviceType: "server",
|
||||||
|
deviceName: "realtime",
|
||||||
|
isReadOnly: false,
|
||||||
|
note: "Database realtime sync"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录到事件历史(包含type用于历史记录)
|
||||||
|
const historyPayload = {
|
||||||
|
...eventPayload,
|
||||||
|
type: "kv-key-changed"
|
||||||
|
};
|
||||||
|
recordEventHistory(uuid, historyPayload);
|
||||||
|
|
||||||
|
// 直接发送kv-key-changed事件
|
||||||
|
io.to(uuid).emit("kv-key-changed", eventPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向指定设备广播自定义事件
|
||||||
|
* @param {string} uuid 设备 uuid
|
||||||
|
* @param {string} type 事件类型
|
||||||
|
* @param {object|null} content 事件内容(JSON对象或null)
|
||||||
|
* @param {string} [senderId] 发送者ID(可选)
|
||||||
|
*/
|
||||||
|
export function broadcastDeviceEvent(uuid, type, content = null, senderId = "system") {
|
||||||
|
if (!io || !uuid || typeof type !== "string" || type.trim().length === 0) return;
|
||||||
|
|
||||||
|
// 验证内容格式
|
||||||
|
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
|
||||||
|
console.warn("broadcastDeviceEvent: content must be object or null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const eventId = `sys-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const eventPayload = {
|
||||||
|
eventId,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
senderId,
|
||||||
|
senderInfo: {
|
||||||
|
appId: "system",
|
||||||
|
deviceType: "system",
|
||||||
|
deviceName: "System",
|
||||||
|
isReadOnly: false,
|
||||||
|
note: "System broadcast"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录系统事件到历史(包含type用于历史记录)
|
||||||
|
const historyPayload = {
|
||||||
|
...eventPayload,
|
||||||
|
type: type.trim()
|
||||||
|
};
|
||||||
|
recordEventHistory(uuid, historyPayload);
|
||||||
|
|
||||||
|
io.to(uuid).emit(type.trim(), eventPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录事件到历史记录
|
||||||
|
* @param {string} uuid 设备UUID
|
||||||
|
* @param {object} eventPayload 事件载荷
|
||||||
|
*/
|
||||||
|
function recordEventHistory(uuid, eventPayload) {
|
||||||
|
if (!eventHistory.has(uuid)) {
|
||||||
|
eventHistory.set(uuid, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = eventHistory.get(uuid);
|
||||||
|
history.push({
|
||||||
|
...eventPayload,
|
||||||
|
recordedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保持历史记录在限制范围内
|
||||||
|
if (history.length > MAX_EVENT_HISTORY) {
|
||||||
|
history.splice(0, history.length - MAX_EVENT_HISTORY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备事件历史记录
|
||||||
|
* @param {string} uuid 设备UUID
|
||||||
|
* @param {number} [limit=50] 返回记录数量限制
|
||||||
|
* @param {number} [offset=0] 偏移量
|
||||||
|
* @returns {Array} 事件历史记录
|
||||||
|
*/
|
||||||
|
export function getEventHistory(uuid, limit = 50, offset = 0) {
|
||||||
|
const history = eventHistory.get(uuid) || [];
|
||||||
|
return history.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取令牌信息
|
||||||
|
* @param {string} token 令牌
|
||||||
|
* @returns {object|null} 令牌信息
|
||||||
|
*/
|
||||||
|
export function getTokenInfo(token) {
|
||||||
|
return tokenInfoCache.get(token) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理设备相关缓存
|
||||||
|
* @param {string} uuid 设备UUID
|
||||||
|
*/
|
||||||
|
function cleanupDeviceCache(uuid) {
|
||||||
|
// 清理事件历史
|
||||||
|
eventHistory.delete(uuid);
|
||||||
|
|
||||||
|
// 清理相关令牌缓存
|
||||||
|
for (const [token, info] of tokenInfoCache.entries()) {
|
||||||
|
if (info.deviceUuid === uuid) {
|
||||||
|
tokenInfoCache.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取在线设备列表
|
* 获取在线设备列表
|
||||||
* @returns {Array<{uuid:string, connections:number}>}
|
* @returns {Array<{token:string, connections:number}>}
|
||||||
*/
|
*/
|
||||||
export function getOnlineDevices() {
|
export function getOnlineDevices() {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [uuid, set] of onlineMap.entries()) {
|
for (const [token, set] of onlineTokens.entries()) {
|
||||||
list.push({ uuid, connections: set.size });
|
list.push({ token, connections: set.size });
|
||||||
}
|
}
|
||||||
// 默认按连接数降序
|
// 默认按连接数降序
|
||||||
return list.sort((a, b) => b.connections - a.connections);
|
return list.sort((a, b) => b.connections - a.connections);
|
||||||
@ -182,7 +537,10 @@ export default {
|
|||||||
initSocket,
|
initSocket,
|
||||||
getIO,
|
getIO,
|
||||||
broadcastKeyChanged,
|
broadcastKeyChanged,
|
||||||
|
broadcastDeviceEvent,
|
||||||
getOnlineDevices,
|
getOnlineDevices,
|
||||||
|
getEventHistory,
|
||||||
|
getTokenInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -191,16 +549,69 @@ export default {
|
|||||||
* @param {string} token
|
* @param {string} token
|
||||||
*/
|
*/
|
||||||
async function joinByToken(socket, token) {
|
async function joinByToken(socket, token) {
|
||||||
|
try {
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
where: { token },
|
where: { token },
|
||||||
include: { device: { select: { uuid: true } } },
|
include: {
|
||||||
|
device: {
|
||||||
|
select: {
|
||||||
|
uuid: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const uuid = appInstall?.device?.uuid;
|
const uuid = appInstall?.device?.uuid;
|
||||||
if (uuid) {
|
if (uuid && appInstall) {
|
||||||
|
// 检测设备信息
|
||||||
|
const userAgent = socket.handshake?.headers?.['user-agent'];
|
||||||
|
const detectedDeviceName = detectDeviceName(userAgent, socket.handshake?.headers);
|
||||||
|
|
||||||
|
// 拼接设备名称:检测到的设备信息 + token的note
|
||||||
|
let finalDeviceName = detectedDeviceName;
|
||||||
|
if (appInstall.note && appInstall.note.trim()) {
|
||||||
|
finalDeviceName = `${detectedDeviceName} - ${appInstall.note.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存令牌信息,使用拼接后的设备名称
|
||||||
|
const tokenInfo = {
|
||||||
|
appId: appInstall.appId,
|
||||||
|
isReadOnly: appInstall.isReadOnly,
|
||||||
|
deviceType: appInstall.deviceType,
|
||||||
|
note: appInstall.note,
|
||||||
|
deviceUuid: uuid,
|
||||||
|
deviceName: finalDeviceName, // 使用拼接后的设备名称
|
||||||
|
detectedDevice: detectedDeviceName, // 保留检测到的设备信息
|
||||||
|
originalNote: appInstall.note, // 保留原始备注
|
||||||
|
installedAt: appInstall.installedAt
|
||||||
|
};
|
||||||
|
tokenInfoCache.set(token, tokenInfo);
|
||||||
|
|
||||||
|
// 在socket上记录当前令牌信息
|
||||||
|
socket.data.currentToken = token;
|
||||||
|
socket.data.tokenInfo = tokenInfo;
|
||||||
|
|
||||||
joinDeviceRoom(socket, uuid);
|
joinDeviceRoom(socket, uuid);
|
||||||
|
// 跟踪 token 连接用于指标统计
|
||||||
|
trackTokenConnection(socket, token);
|
||||||
// 可选:回执
|
// 可选:回执
|
||||||
socket.emit("joined", { by: "token", uuid });
|
socket.emit("joined", {
|
||||||
|
by: "token",
|
||||||
|
uuid,
|
||||||
|
token,
|
||||||
|
tokenInfo: {
|
||||||
|
isReadOnly: tokenInfo.isReadOnly,
|
||||||
|
deviceType: tokenInfo.deviceType,
|
||||||
|
deviceName: tokenInfo.deviceName,
|
||||||
|
userAgent: userAgent
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("joinByToken error:", error);
|
||||||
|
socket.emit("join-error", { by: "token", reason: "database_error" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import {PrismaClient} from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -32,19 +32,19 @@ function getKeys(tokenType = 'access') {
|
|||||||
if (!privateKey || !publicKey) {
|
if (!privateKey || !publicKey) {
|
||||||
throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`);
|
throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`);
|
||||||
}
|
}
|
||||||
return { signKey: privateKey, verifyKey: publicKey };
|
return {signKey: privateKey, verifyKey: publicKey};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认 HS256
|
// 默认 HS256
|
||||||
const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
|
const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
|
||||||
return { signKey: secret, verifyKey: secret };
|
return {signKey: secret, verifyKey: secret};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成访问令牌
|
* 生成访问令牌
|
||||||
*/
|
*/
|
||||||
export function generateAccessToken(account) {
|
export function generateAccessToken(account) {
|
||||||
const { signKey } = getKeys('access');
|
const {signKey} = getKeys('access');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'access',
|
type: 'access',
|
||||||
@ -68,7 +68,7 @@ export function generateAccessToken(account) {
|
|||||||
* 生成刷新令牌
|
* 生成刷新令牌
|
||||||
*/
|
*/
|
||||||
export function generateRefreshToken(account) {
|
export function generateRefreshToken(account) {
|
||||||
const { signKey } = getKeys('refresh');
|
const {signKey} = getKeys('refresh');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
@ -90,7 +90,7 @@ export function generateRefreshToken(account) {
|
|||||||
* 验证访问令牌
|
* 验证访问令牌
|
||||||
*/
|
*/
|
||||||
export function verifyAccessToken(token) {
|
export function verifyAccessToken(token) {
|
||||||
const { verifyKey } = getKeys('access');
|
const {verifyKey} = getKeys('access');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, verifyKey, {
|
const decoded = jwt.verify(token, verifyKey, {
|
||||||
@ -113,7 +113,7 @@ export function verifyAccessToken(token) {
|
|||||||
* 验证刷新令牌
|
* 验证刷新令牌
|
||||||
*/
|
*/
|
||||||
export function verifyRefreshToken(token) {
|
export function verifyRefreshToken(token) {
|
||||||
const { verifyKey } = getKeys('refresh');
|
const {verifyKey} = getKeys('refresh');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, verifyKey, {
|
const decoded = jwt.verify(token, verifyKey, {
|
||||||
@ -146,7 +146,7 @@ export async function generateTokenPair(account) {
|
|||||||
|
|
||||||
// 更新数据库中的刷新令牌
|
// 更新数据库中的刷新令牌
|
||||||
await prisma.account.update({
|
await prisma.account.update({
|
||||||
where: { id: account.id },
|
where: {id: account.id},
|
||||||
data: {
|
data: {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
refreshTokenExpiry,
|
refreshTokenExpiry,
|
||||||
@ -172,7 +172,7 @@ export async function refreshAccessToken(refreshToken) {
|
|||||||
|
|
||||||
// 从数据库获取账户信息
|
// 从数据库获取账户信息
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: decoded.accountId },
|
where: {id: decoded.accountId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@ -218,9 +218,9 @@ export async function refreshAccessToken(refreshToken) {
|
|||||||
*/
|
*/
|
||||||
export async function revokeAllTokens(accountId) {
|
export async function revokeAllTokens(accountId) {
|
||||||
await prisma.account.update({
|
await prisma.account.update({
|
||||||
where: { id: accountId },
|
where: {id: accountId},
|
||||||
data: {
|
data: {
|
||||||
tokenVersion: { increment: 1 },
|
tokenVersion: {increment: 1},
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
refreshTokenExpiry: null,
|
refreshTokenExpiry: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@ -233,7 +233,7 @@ export async function revokeAllTokens(accountId) {
|
|||||||
*/
|
*/
|
||||||
export async function revokeRefreshToken(accountId) {
|
export async function revokeRefreshToken(accountId) {
|
||||||
await prisma.account.update({
|
await prisma.account.update({
|
||||||
where: { id: accountId },
|
where: {id: accountId},
|
||||||
data: {
|
data: {
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
refreshTokenExpiry: null,
|
refreshTokenExpiry: null,
|
||||||
@ -259,11 +259,16 @@ function parseExpirationToMs(expiresIn) {
|
|||||||
const unit = match[2];
|
const unit = match[2];
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 's': return value * 1000;
|
case 's':
|
||||||
case 'm': return value * 60 * 1000;
|
return value * 1000;
|
||||||
case 'h': return value * 60 * 60 * 1000;
|
case 'm':
|
||||||
case 'd': return value * 24 * 60 * 60 * 1000;
|
return value * 60 * 1000;
|
||||||
default: throw new Error('Invalid time unit');
|
case 'h':
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
case 'd':
|
||||||
|
return value * 24 * 60 * 60 * 1000;
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid time unit');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,7 +277,7 @@ function parseExpirationToMs(expiresIn) {
|
|||||||
*/
|
*/
|
||||||
export async function validateAccountToken(decoded) {
|
export async function validateAccountToken(decoded) {
|
||||||
const account = await prisma.account.findUnique({
|
const account = await prisma.account.findUnique({
|
||||||
where: { id: decoded.accountId },
|
where: {id: decoded.accountId},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@ -8,8 +8,10 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Classworks 服务端</h1>
|
<h1>Classworks 服务端</h1>
|
||||||
<p>服务运行中</p>
|
<p>服务运行中</p>
|
||||||
</body>
|
</body>
|
||||||
|
<script>
|
||||||
|
window.open('https://kv.houlang.cloud')
|
||||||
|
</script>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user