1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-07 21:13:10 +00:00

Compare commits

...

15 Commits
v1.3.5 ... main

Author SHA1 Message Date
Sunwuyuan
8e3b3df1ae
1.3.8 2025-12-07 13:50:27 +08:00
Sunwuyuan
21d6ddf164
feat: 实现批量upsert方法,优化键值对处理性能 2025-12-07 13:33:47 +08:00
Sunwuyuan
e65f84aa22
feat: 更新CORS配置,允许跨域请求携带凭证和自定义请求头 2025-12-06 13:41:40 +08:00
Sunwuyuan
ab8904b549
feat: 修复POST /:key处理,确保kvStore.upsert操作为异步执行 2025-12-06 13:10:02 +08:00
Sunwuyuan
da633ca5b6
feat: 增强POST /:key处理,支持空值和JSON格式验证 2025-12-06 13:09:00 +08:00
Sunwuyuan
1f68aea39f
Merge branch 'main' of https://github.com/ZeroCatDev/ClassworksKV 2025-12-06 11:54:58 +08:00
Sunwuyuan
b782945674
feat: 更新Socket.IO CORS设置,支持更多HTTP方法 2025-12-06 11:54:41 +08:00
Sunwuyuan
1e1b99a070
feat: 更新Socket.IO初始化配置,优化CORS设置和传输方式 2025-12-06 11:53:57 +08:00
Sunwuyuan
63716e0429 1.3.7 2025-12-01 12:24:09 +00:00
Sunwuyuan
b582521fee
Merge pull request #54 from tempChanghong/main
Fix: 允许保存空数组,修复无法删除最后一条通知的Bug
2025-12-01 20:21:29 +08:00
Sunwuyuan
f985b6a11a
Update validation logic for request body
Allow empty arrays to pass validation while intercepting empty objects.
2025-12-01 20:20:17 +08:00
tempChanghong
f0de2cd59b Fix:修改语法错误 2025-12-01 16:01:20 +08:00
tempChanghong
d52ed81a29 Fix: 允许保存空数组,修复无法删除最后一条通知的Bug 2025-12-01 15:06:52 +08:00
Sunwuyuan
e73ff53f58
1.3.6 2025-11-29 19:55:06 +08:00
Sunwuyuan
79ec5b94a4
feat: 添加Dlass登录 2025-11-29 19:54:50 +08:00
12 changed files with 9256 additions and 79 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View File

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

4
app.js
View File

@ -25,6 +25,10 @@ app.use(
cors({
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
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");

View File

@ -67,6 +67,24 @@ export const oauthProviders = {
website: "https://houlang.cloud",
pkce: true, // 启用PKCE支持
},
dlass: {
// DlassCasdoor- 标准 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

9032
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ClassworksKV",
"version": "1.3.5",
"version": "1.3.8",
"private": true,
"scripts": {
"start": "node ./bin/www",

View File

@ -282,6 +282,14 @@ router.get("/oauth/:provider/callback", async (req, res) => {
name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture,
};
} else if (provider === "dlass") {
// DlassCasdoor标准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,
};
}
// 名称为空时,用邮箱@前部分回填(若邮箱可用)

View File

@ -1,7 +1,7 @@
import {Router} from "express";
import { Router } from "express";
import kvStore from "../utils/kvStore.js";
import {broadcastKeyChanged} from "../utils/socket.js";
import {kvTokenAuth} from "../middleware/kvTokenAuth.js";
import { broadcastKeyChanged } from "../utils/socket.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import {
prepareTokenForRateLimit,
tokenBatchLimiter,
@ -10,7 +10,7 @@ import {
tokenWriteLimiter
} from "../middleware/rateLimiter.js";
import errors from "../utils/errors.js";
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
const router = Router();
@ -34,7 +34,7 @@ router.get(
// 获取设备信息,包含关联的账号
const device = await prisma.device.findUnique({
where: {id: deviceId},
where: { id: deviceId },
include: {
account: true,
},
@ -88,7 +88,7 @@ router.get(
// 查找当前 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
include: {
device: {
select: {
@ -133,7 +133,7 @@ router.get(
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const {sortBy, sortDir, limit, skip} = req.query;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
@ -184,7 +184,7 @@ router.get(
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const {sortBy, sortDir, limit, skip} = req.query;
const { sortBy, sortDir, limit, skip } = req.query;
// 构建选项
const options = {
@ -230,7 +230,7 @@ router.get(
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const {key} = req.params;
const { key } = req.params;
const value = await kvStore.get(deviceId, key);
@ -253,7 +253,7 @@ router.get(
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const {key} = req.params;
const { key } = req.params;
const metadata = await kvStore.getMetadata(deviceId, key);
if (!metadata) {
@ -298,43 +298,25 @@ router.post(
req.connection.socket?.remoteAddress ||
"";
const results = [];
const errorList = [];
// 批量处理所有键值对
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,
});
}
}
// 使用优化的批量upsert方法
const { results, errors: errorList } = await kvStore.batchUpsert(deviceId, data, creatorIp);
return res.status(200).json({
deviceId,
total: Object.keys(data).length,
successful: results.length,
failed: errorList.length,
results,
errors: errorList.length > 0 ? errorList : undefined,
code: 200,
message: "批量导入成功",
data: {
deviceId,
summary: {
total: Object.keys(data).length,
successful: results.length,
failed: errorList.length,
},
results: results.map(r => ({
key: r.key,
isNew: r.created,
})),
...(errorList.length > 0 && { errors: errorList }),
},
});
})
);
@ -353,11 +335,21 @@ router.post(
}
const deviceId = res.locals.deviceId;
const {key} = req.params;
const value = req.body;
const { key } = req.params;
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
@ -404,7 +396,7 @@ router.delete(
}
const deviceId = res.locals.deviceId;
const {key} = req.params;
const { key } = req.params;
const result = await kvStore.delete(deviceId, key);
@ -429,4 +421,4 @@ router.delete(
})
);
export default router;
export default router;

View File

@ -102,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和键名删除
* @param {number} deviceId - 设备ID
@ -219,6 +275,37 @@ class KVStore {
});
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();

View File

@ -12,9 +12,9 @@
* - 令牌信息缓存在连接时预加载令牌详细信息以提高性能
*/
import {Server} from "socket.io";
import {PrismaClient} from "@prisma/client";
import {onlineDevicesGauge} from "./metrics.js";
import { Server } from "socket.io";
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";
@ -96,14 +96,16 @@ function detectDeviceName(userAgent, headers = {}) {
export function initSocket(server) {
if (io) return io;
const allowOrigin = process.env.FRONTEND_URL || "*";
io = new Server(server, {
cors: {
origin: allowOrigin,
methods: ["GET", "POST"],
credentials: true,
origin: "*",
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: ["*"],
credentials: false
},
// 传输方式回退策略优先使用WebSocket,回退到轮询
transports: ["polling", "websocket"],
});
io.on("connection", (socket) => {
@ -132,8 +134,8 @@ export function initSocket(server) {
const token = payload?.token || payload?.apptoken;
if (typeof token !== "string" || token.length === 0) return;
const appInstall = await prisma.appInstall.findUnique({
where: {token},
include: {device: {select: {uuid: true}}},
where: { token },
include: { device: { select: { uuid: true } } },
});
const uuid = appInstall?.device?.uuid;
if (uuid) {
@ -156,11 +158,11 @@ export function initSocket(server) {
// 获取事件历史记录
socket.on("get-event-history", (data) => {
try {
const {limit = 50, offset = 0} = data || {};
const { limit = 50, offset = 0 } = data || {};
const uuids = Array.from(socket.data.deviceUuids || []);
if (uuids.length === 0) {
socket.emit("event-history-error", {reason: "not_joined_any_device"});
socket.emit("event-history-error", { reason: "not_joined_any_device" });
return;
}
@ -182,7 +184,7 @@ export function initSocket(server) {
} catch (err) {
console.error("get-event-history error:", err);
socket.emit("event-history-error", {reason: "internal_error"});
socket.emit("event-history-error", { reason: "internal_error" });
}
});
@ -191,35 +193,35 @@ export function initSocket(server) {
try {
// 验证数据结构
if (!data || typeof data !== "object") {
socket.emit("event-error", {reason: "invalid_data_format"});
socket.emit("event-error", { reason: "invalid_data_format" });
return;
}
const {type, content} = data;
const { type, content } = data;
// 验证事件类型
if (typeof type !== "string" || type.trim().length === 0) {
socket.emit("event-error", {reason: "invalid_event_type"});
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"});
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"});
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"});
socket.emit("event-error", { reason: "readonly_token_cannot_send_events" });
return;
}
@ -227,7 +229,7 @@ export function initSocket(server) {
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});
socket.emit("event-error", { reason: "content_too_large", maxSize: MAX_SIZE });
return;
}
@ -274,7 +276,7 @@ export function initSocket(server) {
} catch (err) {
console.error("send-event error:", err);
socket.emit("event-error", {reason: "internal_error"});
socket.emit("event-error", { reason: "internal_error" });
}
});
@ -288,10 +290,10 @@ export function initSocket(server) {
// 清理socket相关缓存
if (socket.data.currentToken) {
// 如果这是该token的最后一个连接考虑清理缓存
// 如果这是该token的最后一个连接,考虑清理缓存
const tokenSet = onlineTokens.get(socket.data.currentToken);
if (!tokenSet || tokenSet.size === 0) {
// 可以选择保留缓存一段时间这里暂时保留
// 可以选择保留缓存一段时间,这里暂时保留
// tokenInfoCache.delete(socket.data.currentToken);
}
}
@ -320,7 +322,7 @@ function joinDeviceRoom(socket, uuid) {
set.add(socket.id);
onlineMap.set(uuid, set);
// 可选:通知加入
io.to(uuid).emit("device-joined", {uuid, connections: set.size});
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
}
/**
@ -525,7 +527,7 @@ function cleanupDeviceCache(uuid) {
export function getOnlineDevices() {
const list = [];
for (const [token, set] of onlineTokens.entries()) {
list.push({token, connections: set.size});
list.push({ token, connections: set.size });
}
// 默认按连接数降序
return list.sort((a, b) => b.connections - a.connections);
@ -549,7 +551,7 @@ export default {
async function joinByToken(socket, token) {
try {
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
include: {
device: {
select: {
@ -606,10 +608,10 @@ async function joinByToken(socket, token) {
}
});
} 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"});
socket.emit("join-error", { by: "token", reason: "database_error" });
}
}