diff --git a/.env.oauth.example b/.env.oauth.example
new file mode 100644
index 0000000..e288a0d
--- /dev/null
+++ b/.env.oauth.example
@@ -0,0 +1,18 @@
+# OAuth配置示例
+# 复制此文件为 .env 并填入实际的值
+
+# 服务基础URL(用于生成回调地址)
+BASE_URL=http://localhost:3000
+
+# GitHub OAuth
+# 在 https://github.com/settings/developers 创建OAuth App
+# Authorization callback URL: http://localhost:3000/accounts/oauth/github/callback
+GITHUB_CLIENT_ID=your_github_client_id
+GITHUB_CLIENT_SECRET=your_github_client_secret
+
+# ZeroCat OAuth
+# 在 ZeroCat 开发者平台创建应用
+# 回调地址: http://localhost:3000/accounts/oauth/zerocat/callback
+# 权限范围: user:basic user:email
+ZEROCAT_CLIENT_ID=your_zerocat_client_id
+ZEROCAT_CLIENT_SECRET=your_zerocat_client_secret
\ No newline at end of file
diff --git a/app.js b/app.js
index 2217d39..4c56089 100644
--- a/app.js
+++ b/app.js
@@ -17,7 +17,9 @@ import {
import kvRouter from "./routes/kv-token.js";
import appsRouter from "./routes/apps.js";
+import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js";
+import accountsRouter from "./routes/accounts.js";
var app = express();
@@ -85,12 +87,18 @@ app.get("/check", apiLimiter, (req, res) => {
// Mount the Apps router with API rate limiting
app.use("/apps", apiLimiter, appsRouter);
+// Mount the Device router with API rate limiting
+app.use("/devices", apiLimiter, deviceRouter);
+
// Mount the KV store router with token-based rate limiting (更宽松的限速)
app.use("/kv", tokenBasedRateLimiter, kvRouter);
// Mount the Device Authorization router with API rate limiting
app.use("/auth", apiLimiter, deviceAuthRouter);
+// Mount the Accounts router with API rate limiting
+app.use("/accounts", apiLimiter, accountsRouter);
+
// 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => {
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
diff --git a/cli/README.md b/cli/README.md
index eefc38c..fac040f 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -1,22 +1,33 @@
# 设备授权流程 - CLI 工具
-命令行工具,用于通过设备授权流程获取访问令牌。
+命令行工具,用于通过设备授权流程获取访问令牌。支持两种授权模式:
+
+- **设备代码模式** (`get-token.js`) - 用户手动输入设备代码完成授权
+- **回调模式** (`get-token-callback.js`) - 通过浏览器回调自动完成授权
## 使用方法
-### 基本使用
+### 1. 设备代码模式(推荐用于无GUI环境)
```bash
node cli/get-token.js
```
-### 配置环境变量
+### 2. 回调模式(推荐用于桌面环境)
+
+```bash
+node cli/get-token-callback.js
+```
+
+### 环境变量配置
+
+两种模式都支持以下环境变量:
```bash
# 设置API服务器地址(默认: http://localhost:3030)
export API_BASE_URL=https://your-api-server.com
-# 设置授权页面地址(默认: https://classworks.xiaomo.tech/authorize)
+# 设置授权页面地址(默认: http://localhost:5173/authorize)
export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize
# 设置应用ID(默认: 1)
@@ -25,8 +36,13 @@ export APP_ID=1
# 设置站点密钥(如果需要)
export SITE_KEY=your-site-key
+# 回调模式特有配置
+export CALLBACK_PORT=8080 # 回调服务器端口(默认: 8080)
+export TIMEOUT=300 # 授权超时时间(默认: 300秒)
+
# 运行工具
-node cli/get-token.js
+node cli/get-token.js # 设备代码模式
+node cli/get-token-callback.js # 回调模式
```
### 使其可执行(Linux/Mac)
@@ -38,15 +54,28 @@ chmod +x cli/get-token.js
## 工作流程
+### 设备代码模式 (`get-token.js`)
+
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
2. **显示授权链接** - 在终端显示完整的授权URL,包含设备代码
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
4. **获取令牌** - 工具自动轮询并获取令牌
5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt`
+### 回调模式 (`get-token-callback.js`)
+
+1. **获取设备UUID** - 自动获取或生成设备UUID
+2. **启动回调服务器** - 在本地启动HTTP服务器监听回调
+3. **打开授权页面** - 自动在浏览器中打开授权页面
+4. **用户授权** - 用户在浏览器中完成授权操作
+5. **接收回调** - 本地服务器接收授权回调并获取令牌
+6. **保存令牌** - 令牌会保存到 `~/.classworks/token-callback.txt`
+
## 输出示例
-```
+### 设备代码模式输出
+
+```text
设备授权流程 - 令牌获取工具
✓ 设备授权码生成成功!
@@ -80,19 +109,83 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
```
+### 回调模式输出
+
+```text
+回调授权流程 - 令牌获取工具
+
+ℹ 正在获取设备UUID...
+✓ 设备UUID: 1234567890abcdef1234567890abcdef
+
+============================================================
+ 请访问以下地址完成授权:
+
+ http://localhost:5173/authorize?app_id=1&mode=callback&callback_url=http://localhost:8080/callback&state=abc123
+
+ 设备UUID: 1234567890abcdef1234567890abcdef
+ 状态参数: abc123
+============================================================
+ℹ 回调地址: http://localhost:8080/callback
+ℹ API服务器: http://localhost:3030
+ℹ 超时时间: 300 秒
+
+ℹ 正在启动回调服务器...
+✓ 回调服务器已启动: http://localhost:8080/callback
+ℹ 正在尝试打开浏览器...
+✓ 已尝试打开浏览器
+ℹ 等待授权完成...
+
+==================================================
+✓ 授权成功!令牌获取完成
+==================================================
+
+您的访问令牌:
+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+
+✓ 令牌已保存到: /home/user/.classworks/token-callback.txt
+
+使用示例:
+ curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
+```
+
## 配置选项
-可以通过修改 `cli/get-token.js` 中的 `CONFIG` 对象或设置环境变量来调整:
+### 通用配置
-- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: http://localhost:3030)
-- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: https://classworks.xiaomo.tech/authorize)
+可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整:
+
+- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`)
+- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`)
- `appId` / `APP_ID` - 应用ID(默认: 1)
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
+
+### 设备代码模式专用配置
+
- `pollInterval` - 轮询间隔(秒,默认3秒)
- `maxPolls` - 最大轮询次数(默认100次)
+### 回调模式专用配置
+
+- `callbackPort` / `CALLBACK_PORT` - 回调服务器端口(默认: 8080)
+- `timeout` / `TIMEOUT` - 授权超时时间(秒,默认: 300)
+- `callbackPath` - 回调路径(默认: /callback)
+
## 错误处理
+### 设备代码模式
+
- 如果设备代码过期,会显示错误并退出
- 如果轮询超时(默认5分钟),会显示超时错误
- 如果无法连接到服务器,会显示连接错误
+
+### 回调模式
+
+- 如果回调端口被占用,会提示更换端口
+- 如果授权超时,会显示超时错误并提示延长超时时间
+- 如果状态参数不匹配,会拒绝授权防止CSRF攻击
+- 如果无法连接到服务器,会显示连接错误
+
+## 选择模式建议
+
+- **设备代码模式** - 适用于无GUI环境、服务器环境、或无法启动本地服务器的场景
+- **回调模式** - 适用于桌面环境、开发环境、或希望更流畅授权体验的场景
diff --git a/cli/get-token-callback.js b/cli/get-token-callback.js
new file mode 100644
index 0000000..11be220
--- /dev/null
+++ b/cli/get-token-callback.js
@@ -0,0 +1,422 @@
+#!/usr/bin/env node
+
+/**
+ * 回调授权流程 - 命令行工具
+ *
+ * 用于演示回调授权流程,获取访问令牌
+ * 通过启动本地HTTP服务器接收回调来获取令牌
+ *
+ * 使用方法:
+ * node cli/get-token-callback.js
+ * 或配置为可执行:chmod +x cli/get-token-callback.js && ./cli/get-token-callback.js
+ */
+
+import http from 'http';
+import url from 'url';
+import { randomBytes } from 'crypto';
+
+// 配置
+const CONFIG = {
+ // API服务器地址
+ baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
+ // 站点密钥
+ siteKey: process.env.SITE_KEY || '',
+ // 应用ID
+ appId: process.env.APP_ID || '1',
+ // 授权页面地址(Classworks前端)
+ authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize',
+ // 本地回调服务器端口
+ callbackPort: process.env.CALLBACK_PORT || '8080',
+ // 回调路径
+ callbackPath: '/callback',
+ // 超时时间(秒)
+ timeout: 300,
+};
+
+// 颜色输出
+const colors = {
+ reset: '\x1b[0m',
+ bright: '\x1b[1m',
+ dim: '\x1b[2m',
+ red: '\x1b[31m',
+ green: '\x1b[32m',
+ yellow: '\x1b[33m',
+ blue: '\x1b[34m',
+ cyan: '\x1b[36m',
+};
+
+function log(message, color = '') {
+ console.log(`${color}${message}${colors.reset}`);
+}
+
+function logSuccess(message) {
+ log(`✓ ${message}`, colors.green);
+}
+
+function logError(message) {
+ log(`✗ ${message}`, colors.red);
+}
+
+function logInfo(message) {
+ log(`ℹ ${message}`, colors.cyan);
+}
+
+function logWarning(message) {
+ log(`⚠ ${message}`, colors.yellow);
+}
+
+// HTTP请求封装
+async function request(path, options = {}) {
+ const requestUrl = `${CONFIG.baseUrl}${path}`;
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ };
+
+ if (CONFIG.siteKey) {
+ headers['X-Site-Key'] = CONFIG.siteKey;
+ }
+
+ try {
+ const response = await fetch(requestUrl, {
+ ...options,
+ headers,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.message || `HTTP ${response.status}`);
+ }
+
+ return data;
+ } catch (error) {
+ if (error.message.includes('fetch')) {
+ throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
+ }
+ throw error;
+ }
+}
+
+// 生成随机状态字符串
+function generateState() {
+ return randomBytes(16).toString('hex');
+}
+
+// 获取设备UUID
+async function getDeviceUuid() {
+ try {
+ const deviceInfo = await request('/device/info');
+ return deviceInfo.uuid;
+ } catch (error) {
+ // 如果设备不存在,生成新的UUID
+ const uuid = randomBytes(16).toString('hex');
+ logInfo(`生成新的设备UUID: ${uuid}`);
+ return uuid;
+ }
+}
+
+// 创建回调服务器
+function createCallbackServer(state) {
+ return new Promise((resolve, reject) => {
+ let server;
+ let resolved = false;
+
+ const handleRequest = (req, res) => {
+ if (resolved) return;
+
+ const parsedUrl = url.parse(req.url, true);
+
+ if (parsedUrl.pathname === CONFIG.callbackPath) {
+ const { token, error, state: returnedState } = parsedUrl.query;
+
+ // 验证状态参数
+ if (returnedState !== state) {
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(`
+
+
+
+
+ 授权失败
+
+
+
+ 授权失败
+ 状态参数不匹配,可能存在安全风险。
+ 请重新尝试授权流程。
+
+
+ `);
+ resolved = true;
+ server.close();
+ reject(new Error('状态参数不匹配'));
+ return;
+ }
+
+ if (error) {
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(`
+
+
+
+
+ 授权失败
+
+
+
+ 授权失败
+ ${error}
+ 您可以关闭此页面并重新尝试。
+
+
+ `);
+ resolved = true;
+ server.close();
+ reject(new Error(error));
+ return;
+ }
+
+ if (token) {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(`
+
+
+
+
+ 授权成功
+
+
+
+ 授权成功!
+ 令牌已成功获取,您可以关闭此页面。
+ ${token}
+ 令牌已自动复制到命令行界面
+
+
+ `);
+ resolved = true;
+ server.close();
+ resolve(token);
+ return;
+ }
+
+ // 如果没有token和error参数
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(`
+
+
+
+
+ 无效请求
+
+
+
+ 无效请求
+ 缺少必要的参数。
+ 请重新尝试授权流程。
+
+
+ `);
+ resolved = true;
+ server.close();
+ reject(new Error('缺少必要的参数'));
+ } else {
+ // 404 for other paths
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not Found');
+ }
+ };
+
+ server = http.createServer(handleRequest);
+
+ server.listen(CONFIG.callbackPort, (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
+ }
+ });
+
+ // 设置超时
+ setTimeout(() => {
+ if (!resolved) {
+ resolved = true;
+ server.close();
+ reject(new Error('授权超时'));
+ }
+ }, CONFIG.timeout * 1000);
+
+ server.on('error', (err) => {
+ if (!resolved) {
+ resolved = true;
+ reject(err);
+ }
+ });
+ });
+}
+
+// 打开浏览器
+async function openBrowser(url) {
+ const { spawn } = await import('child_process');
+
+ let command;
+ let args;
+
+ if (process.platform === 'win32') {
+ command = 'cmd';
+ args = ['/c', 'start', url];
+ } else if (process.platform === 'darwin') {
+ command = 'open';
+ args = [url];
+ } else {
+ command = 'xdg-open';
+ args = [url];
+ }
+
+ try {
+ spawn(command, args, { detached: true, stdio: 'ignore' });
+ logSuccess('已尝试打开浏览器');
+ } catch (error) {
+ logWarning('无法自动打开浏览器,请手动打开授权链接');
+ }
+}
+
+// 显示授权信息
+function displayAuthInfo(authUrl, deviceUuid, state) {
+ console.log('\n' + '='.repeat(60));
+ log(` 请访问以下地址完成授权:`, colors.bright);
+ console.log('');
+ log(` ${authUrl}`, colors.cyan + colors.bright);
+ console.log('');
+ log(` 设备UUID: ${deviceUuid}`, colors.green);
+ log(` 状态参数: ${state}`, colors.dim);
+ console.log('='.repeat(60));
+ logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
+ logInfo(`API服务器: ${CONFIG.baseUrl}`);
+ logInfo(`超时时间: ${CONFIG.timeout} 秒`);
+ console.log('');
+}
+
+// 保存令牌到文件
+async function saveToken(token) {
+ const fs = await import('fs');
+ const path = await import('path');
+ const os = await import('os');
+
+ const tokenDir = path.join(os.homedir(), '.classworks');
+ const tokenFile = path.join(tokenDir, 'token-callback.txt');
+
+ try {
+ // 确保目录存在
+ if (!fs.existsSync(tokenDir)) {
+ fs.mkdirSync(tokenDir, { recursive: true });
+ }
+
+ // 写入令牌
+ fs.writeFileSync(tokenFile, token, 'utf8');
+ logSuccess(`令牌已保存到: ${tokenFile}`);
+ } catch (error) {
+ logWarning(`无法保存令牌到文件: ${error.message}`);
+ logInfo('您可以手动保存令牌');
+ }
+}
+
+// 主函数
+async function main() {
+ console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
+
+ try {
+ // 检查配置
+ if (!CONFIG.siteKey) {
+ logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
+ logInfo('设置方法: export SITE_KEY=your-site-key');
+ console.log('');
+ }
+
+ // 1. 获取设备UUID
+ logInfo('正在获取设备UUID...');
+ const deviceUuid = await getDeviceUuid();
+ logSuccess(`设备UUID: ${deviceUuid}`);
+
+ // 2. 生成状态参数
+ const state = generateState();
+
+ // 3. 构建回调URL
+ const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`;
+
+ // 4. 构建授权URL
+ const authUrl = new URL(CONFIG.authPageUrl);
+ authUrl.searchParams.set('app_id', CONFIG.appId);
+ authUrl.searchParams.set('mode', 'callback');
+ authUrl.searchParams.set('callback_url', callbackUrl);
+ authUrl.searchParams.set('state', state);
+
+ // 5. 显示授权信息
+ displayAuthInfo(authUrl.toString(), deviceUuid, state);
+
+ // 6. 启动回调服务器
+ logInfo('正在启动回调服务器...');
+ const serverPromise = createCallbackServer(state);
+
+ // 7. 打开浏览器
+ logInfo('正在尝试打开浏览器...');
+ await openBrowser(authUrl.toString());
+
+ // 8. 等待授权完成
+ logInfo('等待授权完成...\n');
+ const token = await serverPromise;
+
+ // 9. 显示令牌
+ console.log('\n' + '='.repeat(50));
+ logSuccess('授权成功!令牌获取完成');
+ console.log('='.repeat(50));
+ console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
+ log(token, colors.green);
+ console.log('');
+
+ // 10. 保存令牌
+ await saveToken(token);
+
+ // 11. 使用示例
+ console.log('\n' + colors.bright + '使用示例:' + colors.reset);
+ console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
+ console.log('');
+
+ process.exit(0);
+ } catch (error) {
+ console.log('');
+ logError(`错误: ${error.message}`);
+
+ // 提供一些常见问题的解决方案
+ if (error.message.includes('EADDRINUSE')) {
+ logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`);
+ logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`);
+ } else if (error.message.includes('无法连接到服务器')) {
+ logInfo('请检查API服务器是否正在运行');
+ logInfo(`当前API地址: ${CONFIG.baseUrl}`);
+ } else if (error.message.includes('授权超时')) {
+ logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`);
+ logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js');
+ }
+
+ console.log('');
+ process.exit(1);
+ }
+}
+
+// 运行
+main();
\ No newline at end of file
diff --git a/config/oauth.js b/config/oauth.js
new file mode 100644
index 0000000..4a3529f
--- /dev/null
+++ b/config/oauth.js
@@ -0,0 +1,39 @@
+// OAuth 提供者配置
+export const oauthProviders = {
+ github: {
+ clientId: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ authorizationURL: "https://github.com/login/oauth/authorize",
+ tokenURL: "https://github.com/login/oauth/access_token",
+ userInfoURL: "https://api.github.com/user",
+ scope: "read:user user:email",
+ name: "GitHub",
+ icon: "github",
+ color: "#24292e",
+ description: "使用 GitHub 账号登录",
+ },
+ zerocat: {
+ clientId: process.env.ZEROCAT_CLIENT_ID,
+ clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
+ authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
+ tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
+ userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
+ scope: "user:basic user:email",
+ name: "ZeroCat",
+ icon: "zerocat",
+ color: "#6366f1",
+ description: "使用 ZeroCat 账号登录",
+ },
+};
+
+// 获取OAuth回调URL
+export function getCallbackURL(provider) {
+ const baseUrl = process.env.BASE_URL;
+ return `${baseUrl}/accounts/oauth/${provider}/callback`;
+}
+
+// 生成随机state参数
+export function generateState() {
+ return Math.random().toString(36).substring(2, 15) +
+ Math.random().toString(36).substring(2, 15);
+}
\ No newline at end of file
diff --git a/docs/API_CURL_EXAMPLES.md b/docs/API_CURL_EXAMPLES.md
deleted file mode 100644
index c2c9ed8..0000000
--- a/docs/API_CURL_EXAMPLES.md
+++ /dev/null
@@ -1,671 +0,0 @@
-# API 使用示例 - cURL
-
-本文档提供所有API接口的完整cURL示例。
-
-## 环境变量设置
-
-```bash
-# 设置基础URL和站点密钥
-export BASE_URL="http://localhost:3030"
-export SITE_KEY="your-site-key-here"
-```
-
-## 1. 应用管理 API
-
-### 1.1 获取应用列表
-
-```bash
-# 基本查询
-curl -X GET "http://localhost:3030/apps" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 带分页和搜索
-curl -X GET "http://localhost:3030/apps?limit=10&skip=0&search=my-app" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "apps": [
- {
- "id": 1,
- "name": "我的应用",
- "description": "应用描述",
- "developerName": "开发者名称",
- "developerLink": "https://developer.com",
- "homepageLink": "https://app.com",
- "iconHash": "abc123",
- "metadata": {},
- "createdAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T00:00:00.000Z"
- }
- ],
- "total": 1,
- "limit": 10,
- "skip": 0
-}
-```
-
-### 1.2 获取单个应用详情
-
-```bash
-curl -X GET "http://localhost:3030/apps/1" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "id": 1,
- "name": "我的应用",
- "description": "应用描述",
- "developerName": "开发者名称",
- "developerLink": "https://developer.com",
- "homepageLink": "https://app.com",
- "iconHash": "abc123",
- "metadata": {},
- "createdAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T00:00:00.000Z"
-}
-```
-
-### 1.4 获取应用的所有安装记录
-
-```bash
-curl -X GET "http://localhost:3030/apps/1/installations?limit=10&skip=0" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "appId": 1,
- "installations": [
- {
- "id": "clx1234567890",
- "token": "a1b2c3d4e5f6...",
- "device": {
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "name": "我的设备"
- },
- "note": "完整访问",
- "installedAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T00:00:00.000Z"
- }
- ],
- "total": 1,
- "limit": 10,
- "skip": 0
-}
-```
-
-## 2. Token 管理 API
-
-### 2.1 获取设备的所有Token
-
-```bash
-curl -X GET "http://localhost:3030/apps/devices/550e8400-e29b-41d4-a716-446655440000/tokens" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
- "deviceName": "我的设备",
- "tokens": [
- {
- "id": "clx1234567890",
- "token": "a1b2c3d4e5f6...",
- "app": {
- "id": 1,
- "name": "我的应用",
- "description": "应用描述",
- "developerName": "开发者",
- "iconHash": "abc123"
- },
- "note": "完整访问",
- "installedAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T00:00:00.000Z"
- }
- ],
- "total": 1
-}
-```
-
-### 2.2 撤销Token
-
-```bash
-curl -X DELETE "http://localhost:3030/apps/tokens/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**成功响应:** HTTP 204 No Content
-
-**错误响应:**
-```json
-{
- "statusCode": 404,
- "message": "Token不存在"
-}
-```
-
-## 3. KV 操作 API(需要Token)
-
-**设置Token环境变量:**
-```bash
-export TOKEN="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
-```
-
-### 3.1 获取所有键(含元数据)
-
-```bash
-# 基本查询
-curl -X GET "http://localhost:3030/kv" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 带分页和排序
-curl -X GET "http://localhost:3030/kv?sortBy=key&sortDir=asc&limit=50&skip=0" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 按更新时间排序
-curl -X GET "http://localhost:3030/kv?sortBy=updatedAt&sortDir=desc&limit=20" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "items": [
- {
- "deviceId": 1,
- "key": "user.profile",
- "metadata": {
- "creatorIp": "192.168.1.1",
- "createdAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T00:00:00.000Z"
- }
- }
- ],
- "total_rows": 1,
- "load_more": "/kv?sortBy=key&sortDir=asc&limit=50&skip=50"
-}
-```
-
-### 3.2 获取键名列表(仅键名)
-
-```bash
-curl -X GET "http://localhost:3030/kv/_keys" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 带分页
-curl -X GET "http://localhost:3030/kv/_keys?limit=100&skip=0" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "keys": [
- "user.profile",
- "user.settings",
- "app.config"
- ],
- "total_rows": 3,
- "current_page": {
- "limit": 100,
- "skip": 0,
- "count": 3
- }
-}
-```
-
-### 3.3 获取键值
-
-```bash
-# 使用 Authorization header(推荐)
-curl -X GET "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 使用 query 参数
-curl -X GET "http://localhost:3030/kv/user.profile?token=${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "name": "张三",
- "email": "zhangsan@example.com",
- "avatar": "https://example.com/avatar.jpg",
- "preferences": {
- "theme": "dark",
- "language": "zh-CN"
- }
-}
-```
-
-**错误响应(键不存在):**
-```json
-{
- "statusCode": 404,
- "message": "未找到键名为 'user.profile' 的记录"
-}
-```
-
-### 3.4 获取键的元数据
-
-```bash
-curl -X GET "http://localhost:3030/kv/user.profile/metadata" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应示例:**
-```json
-{
- "deviceId": 1,
- "key": "user.profile",
- "metadata": {
- "creatorIp": "192.168.1.1",
- "createdAt": "2025-01-01T00:00:00.000Z",
- "updatedAt": "2025-01-01T12:30:00.000Z"
- }
-}
-```
-
-### 3.5 创建/更新键值
-
-```bash
-# 创建新键
-curl -X POST "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "name": "张三",
- "email": "zhangsan@example.com",
- "avatar": "https://example.com/avatar.jpg"
- }'
-
-# 更新已存在的键
-curl -X POST "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "name": "张三",
- "email": "newemail@example.com",
- "avatar": "https://example.com/new-avatar.jpg",
- "updatedBy": "admin"
- }'
-
-# 存储数组
-curl -X POST "http://localhost:3030/kv/user.tags" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '["developer", "admin", "vip"]'
-
-# 存储嵌套对象
-curl -X POST "http://localhost:3030/kv/app.config" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "database": {
- "host": "localhost",
- "port": 3306,
- "name": "mydb"
- },
- "cache": {
- "enabled": true,
- "ttl": 3600
- }
- }'
-```
-
-**响应示例(创建):**
-```json
-{
- "deviceId": 1,
- "key": "user.profile",
- "created": true,
- "updatedAt": "2025-01-01T00:00:00.000Z"
-}
-```
-
-**响应示例(更新):**
-```json
-{
- "deviceId": 1,
- "key": "user.profile",
- "created": false,
- "updatedAt": "2025-01-01T12:30:00.000Z"
-}
-```
-
-### 3.6 批量导入键值对
-
-```bash
-curl -X POST "http://localhost:3030/kv/_batchimport" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "user.profile": {
- "name": "张三",
- "email": "zhangsan@example.com"
- },
- "user.settings": {
- "theme": "dark",
- "language": "zh-CN"
- },
- "app.config": {
- "version": "1.0.0",
- "debug": false
- }
- }'
-```
-
-**响应示例:**
-```json
-{
- "deviceId": 1,
- "total": 3,
- "successful": 3,
- "failed": 0,
- "results": [
- {
- "key": "user.profile",
- "created": true
- },
- {
- "key": "user.settings",
- "created": true
- },
- {
- "key": "app.config",
- "created": false
- }
- ]
-}
-```
-
-**部分失败响应:**
-```json
-{
- "deviceId": 1,
- "total": 3,
- "successful": 2,
- "failed": 1,
- "results": [
- {
- "key": "user.profile",
- "created": true
- },
- {
- "key": "user.settings",
- "created": true
- }
- ],
- "errors": [
- {
- "key": "invalid.key",
- "error": "验证失败"
- }
- ]
-}
-```
-
-### 3.7 删除键值对
-
-```bash
-curl -X DELETE "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**成功响应:** HTTP 204 No Content
-
-**错误响应(键不存在):**
-```json
-{
- "statusCode": 404,
- "message": "未找到键名为 'user.profile' 的记录"
-}
-```
-
-## 4. 完整工作流示例
-
-### 场景:应用首次访问设备的KV存储
-
-```bash
-#!/bin/bash
-
-# 1. 设置环境变量
-export BASE_URL="http://localhost:3030"
-export SITE_KEY="your-site-key"
-export APP_ID="1"
-export DEVICE_UUID="550e8400-e29b-41d4-a716-446655440000"
-export DEVICE_PASSWORD="my-password"
-
-# 2. 为应用授权获取token
-echo "正在授权应用..."
-RESPONSE=$(curl -s -X POST "http://localhost:3030/apps/${APP_ID}/authorize" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d "{
- \"deviceUuid\": \"${DEVICE_UUID}\",
- \"password\": \"${DEVICE_PASSWORD}\",
- \"readOnly\": false,
- \"note\": \"自动授权\"
- }")
-
-# 3. 提取token
-TOKEN=$(echo $RESPONSE | jq -r '.token')
-echo "获取到Token: ${TOKEN:0:20}..."
-
-# 4. 写入数据
-echo "写入用户配置..."
-curl -X POST "http://localhost:3030/kv/user.config" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "theme": "dark",
- "notifications": true,
- "language": "zh-CN"
- }'
-
-# 5. 读取数据
-echo "读取用户配置..."
-curl -X GET "http://localhost:3030/kv/user.config" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 6. 获取所有键名
-echo "获取所有键名..."
-curl -X GET "http://localhost:3030/kv/_keys" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}"
-
-# 7. 批量导入数据
-echo "批量导入数据..."
-curl -X POST "http://localhost:3030/kv/_batchimport" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "user.profile": {"name": "张三", "age": 25},
- "user.preferences": {"color": "blue"},
- "app.version": {"current": "1.0.0"}
- }'
-
-echo "完成!"
-```
-
-## 5. 错误处理示例
-
-### 5.1 Token认证失败
-
-```bash
-# 使用无效token
-curl -X GET "http://localhost:3030/kv/mykey" \
- -H "Authorization: Bearer invalid-token" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应:**
-```json
-{
- "statusCode": 401,
- "message": "无效的身份验证令牌"
-}
-```
-
-### 5.2 缺少Token
-
-```bash
-curl -X GET "http://localhost:3030/kv/mykey" \
- -H "x-site-key: ${SITE_KEY}"
-```
-
-**响应:**
-```json
-{
- "statusCode": 401,
- "message": "未提供身份验证令牌"
-}
-```
-
-### 5.3 站点密钥错误
-
-```bash
-curl -X GET "http://localhost:3030/apps" \
- -H "x-site-key: wrong-key"
-```
-
-**响应:**
-```json
-{
- "statusCode": 401,
- "message": "无效的站点密钥"
-}
-```
-
-### 5.4 设备不存在
-
-```bash
-curl -X POST "http://localhost:3030/apps/1/authorize" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{
- "deviceUuid": "non-existent-uuid"
- }'
-```
-
-**响应:**
-```json
-{
- "statusCode": 404,
- "message": "设备不存在"
-}
-```
-
-## 6. 高级用例
-
-### 6.1 使用jq处理响应
-
-```bash
-# 提取所有键名
-curl -s -X GET "http://localhost:3030/kv/_keys" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}" \
- | jq -r '.keys[]'
-
-# 获取token并保存
-TOKEN=$(curl -s -X POST "http://localhost:3030/apps/1/authorize" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d '{"deviceUuid":"550e8400-e29b-41d4-a716-446655440000"}' \
- | jq -r '.token')
-
-# 格式化输出
-curl -s -X GET "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}" \
- | jq '.'
-```
-
-### 6.2 循环批量操作
-
-```bash
-# 批量创建键值对
-for i in {1..10}; do
- curl -X POST "http://localhost:3030/kv/item.${i}" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d "{\"id\": ${i}, \"name\": \"Item ${i}\"}"
- echo "Created item.${i}"
-done
-
-# 批量读取
-for key in user.profile user.settings app.config; do
- echo "Reading ${key}:"
- curl -s -X GET "http://localhost:3030/kv/${key}" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}" \
- | jq '.'
-done
-```
-
-### 6.3 条件更新模式
-
-```bash
-# 读取当前值
-CURRENT=$(curl -s -X GET "http://localhost:3030/kv/counter" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}")
-
-# 修改值
-NEW_VALUE=$(echo $CURRENT | jq '.count += 1')
-
-# 写回
-curl -X POST "http://localhost:3030/kv/counter" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "Content-Type: application/json" \
- -H "x-site-key: ${SITE_KEY}" \
- -d "${NEW_VALUE}"
-```
-
-## 7. 性能测试
-
-### 7.1 并发请求测试
-
-```bash
-# 使用 xargs 进行并发测试
-seq 1 10 | xargs -P 10 -I {} curl -s -X GET "http://localhost:3030/kv/test.key" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}" \
- -o /dev/null -w "Request {}: %{http_code} in %{time_total}s\n"
-```
-
-### 7.2 响应时间测试
-
-```bash
-# 测量单个请求时间
-curl -X GET "http://localhost:3030/kv/user.profile" \
- -H "Authorization: Bearer ${TOKEN}" \
- -H "x-site-key: ${SITE_KEY}" \
- -w "\nTotal time: %{time_total}s\n" \
- -o /dev/null -s
-```
\ No newline at end of file
diff --git a/docs/API_REFACTOR.md b/docs/API_REFACTOR.md
deleted file mode 100644
index d5875fa..0000000
--- a/docs/API_REFACTOR.md
+++ /dev/null
@@ -1,226 +0,0 @@
-# API 重构文档
-
-## 概述
-
-本次重构将数据库从基于 `namespace` (UUID字符串) 的架构迁移到基于 `deviceId` (自增整数) 的架构,并实现了完整的token授权系统。
-
-## 数据库变更
-
-### Device 表
-- **主键变更**: `uuid` (VARCHAR) → `id` (INT AUTO_INCREMENT)
-- **uuid**: 改为 UNIQUE 索引
-- **新增字段**: `accountId` (用于未来关联社区账户)
-
-### KVStore 表
-- **外键变更**: `namespace` (VARCHAR) → `deviceId` (INT)
-- **主键**: `(deviceId, key)` 复合主键
-- **关联**: 外键关联 `Device.id`,支持级联删除
-
-### 新增表
-
-#### App 表
-```sql
-CREATE TABLE `App` (
- `id` INT AUTO_INCREMENT PRIMARY KEY,
- `name` VARCHAR(191) NOT NULL,
- `description` VARCHAR(191),
- `developerName` VARCHAR(191) NOT NULL,
- `developerLink` VARCHAR(191),
- `homepageLink` VARCHAR(191),
- `iconHash` VARCHAR(191),
- `metadata` JSON,
- `createdAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
- `updatedAt` DATETIME(3) NOT NULL
-);
-```
-
-#### AppInstall 表
-```sql
-CREATE TABLE `AppInstall` (
- `id` VARCHAR(191) PRIMARY KEY,
- `deviceId` INT NOT NULL,
- `appId` INT NOT NULL,
- `token` VARCHAR(191) UNIQUE NOT NULL,
- `note` VARCHAR(191),
- `installedAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
- `updatedAt` DATETIME(3) NOT NULL,
- FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE,
- FOREIGN KEY (`appId`) REFERENCES `App`(`id`) ON DELETE CASCADE
-);
-```
-
-## API 架构
-
-### 1. 应用授权流程
-
-#### POST /apps/:appId/authorize
-为应用获取访问token
-
-**请求体:**
-```json
-{
- "deviceUuid": "设备UUID",
- "password": "设备密码(如需要)",
- "readOnly": false,
- "note": "备注信息"
-}
-```
-
-**响应:**
-```json
-{
- "token": "生成的访问token",
- "appId": 1,
- "appName": "应用名称",
- "deviceUuid": "设备UUID",
- "deviceName": "设备名称",
- "readOnly": false,
- "note": "读写访问",
- "authorizedAt": "2025-01-01T00:00:00.000Z"
-}
-```
-
-### 2. Token-based KV 操作(唯一方式)
-
-⚠️ **重要变更**: 所有KV操作现在仅支持基于token的访问,旧的 `/kv/:namespace/:key` API已移除。
-
-#### Token提供方式
-1. Authorization Header: `Authorization: Bearer `
-2. Query 参数: `?token=`
-3. Request Body: `{"token": ""}`
-
-#### KV API端点
-```
-GET /kv - 列出所有键(含元数据)
-GET /kv/_keys - 列出所有键名(仅键名)
-GET /kv/:key - 获取键值
-GET /kv/:key/metadata - 获取键元数据
-POST /kv/:key - 创建/更新键值
-POST /kv/_batchimport - 批量导入
-DELETE /kv/:key - 删除键值
-```
-
-### 3. 主要接口
-
-#### 应用管理
-- `GET /apps` - 获取应用列表
-- `GET /apps/:id` - 获取应用详情
-- `POST /apps/:id/authorize` - 授权应用获取token
-- `GET /apps/:id/installations` - 获取应用的所有安装记录
-
-#### Token管理
-- `GET /apps/devices/:deviceUuid/tokens` - 获取设备的所有token
-- `DELETE /apps/tokens/:token` - 撤销token
-
-#### KV操作(仅Token方式)
-- `GET /kv` - 列出所有键(含元数据)
-- `GET /kv/_keys` - 列出所有键名(仅键名)
-- `GET /kv/:key` - 获取键值
-- `GET /kv/:key/metadata` - 获取键元数据
-- `POST /kv/:key` - 创建/更新键值
-- `POST /kv/_batchimport` - 批量导入
-- `DELETE /kv/:key` - 删除键值
-
-## 使用示例
-
-### 1. 授权应用
-```javascript
-const response = await fetch('http://localhost:3000/apps/1/authorize', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-site-key': 'your-site-key'
- },
- body: JSON.stringify({
- deviceUuid: 'your-device-uuid',
- password: 'device-password-if-needed',
- readOnly: false,
- note: '我的应用授权'
- })
-});
-
-const { token } = await response.json();
-```
-
-### 2. 使用Token读取KV
-```javascript
-const response = await fetch('http://localhost:3000/kv/mykey', {
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'x-site-key': 'your-site-key'
- }
-});
-
-const value = await response.json();
-```
-
-### 3. 使用Token写入KV
-```javascript
-const response = await fetch('http://localhost:3000/kv/mykey', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- 'x-site-key': 'your-site-key'
- },
- body: JSON.stringify({
- data: 'my value',
- timestamp: Date.now()
- })
-});
-```
-
-## 迁移指南
-
-### 数据库迁移
-
-1. 标记旧迁移为已应用:
-```bash
-npx prisma migrate resolve --applied 20250524123414_2025_05_25
-```
-
-2. 执行新迁移:
-```bash
-npx prisma migrate deploy
-```
-
-### 代码更新
-
-⚠️ **破坏性变更**: 旧的基于namespace的API已完全移除。
-
-**旧代码(不再支持):**
-```javascript
-// ❌ 已移除
-GET /kv/:namespace/:key
-POST /kv/:namespace/:key
-DELETE /kv/:namespace/:key
-```
-
-**新代码(唯一方式):**
-```javascript
-// ✅ 使用token-based API
-GET /kv/:key
-POST /kv/:key
-DELETE /kv/:key
-
-// 必须在header中提供token
-Headers: {
- 'Authorization': 'Bearer ',
- 'x-site-key': 'your-site-key'
-}
-```
-
-**迁移步骤:**
-1. 为每个需要访问KV的应用调用 `POST /apps/:id/authorize` 获取token
-2. 将所有KV API调用从 `/kv/:namespace/:key` 改为 `/kv/:key`
-3. 在所有请求中添加 `Authorization: Bearer ` header
-4. 测试确保所有功能正常
-
-## 优势
-
-1. **安全性提升**: Token-based认证,无需在URL中暴露namespace
-2. **多设备支持**: 同一UUID可在不同设备上使用不同token
-3. **细粒度权限**: 可为每个应用授权只读或读写权限
-4. **易于管理**: 可随时撤销token,不影响其他授权
-5. **性能优化**: 使用整数ID作为外键,查询效率更高
-6. **简化API**: 统一的token认证方式,无需在URL中指定namespace
\ No newline at end of file
diff --git a/docs/FRONTEND_MIGRATION_GUIDE.md b/docs/FRONTEND_MIGRATION_GUIDE.md
deleted file mode 100644
index 0f0379f..0000000
--- a/docs/FRONTEND_MIGRATION_GUIDE.md
+++ /dev/null
@@ -1,600 +0,0 @@
-# 前端迁移指南
-
-## 概述
-
-本文档描述了后端中间件系统的重构,以及前端需要如何适配这些变化。核心变化是统一了设备信息获取和权限验证流程。
-
----
-
-## 核心变化
-
-### 1. 统一的设备中间件系统
-
-后端现在使用统一的中间件处理所有与设备UUID相关的操作:
-
-- **`deviceMiddleware`**: 自动获取或创建设备,设备不存在时自动创建
-- **`requireWriteAuth`**: 验证写权限,检查设备密码
-- **`tokenAuth`**: Token认证,用于应用访问
-
-### 2. 设备自动创建
-
-**重要变化**: 当使用一个新的UUID访问API时,后端会自动创建该设备,无需手动调用创建设备接口。
-
-### 3. 权限模型
-
-- **读操作**: 永远不需要密码
-- **写操作**: 如果设备设置了密码则需要验证,否则直接允许
-
----
-
-
-## 场景1: 基于UUID的直接访问
-
-适用于:用户直接操作设备数据(设备配置、设备管理等)
-
-### 读操作(无需密码)
-
-**请求方式**: `GET /device/:deviceUuid/*`
-
-**特点**:
-- 设备不存在时自动创建
-- 无需提供密码
-- 任何知道UUID的人都可以读取
-
-**请求示例**:
-```http
-GET /device/550e8400-e29b-41d4-a716-446655440000/info
-Headers:
- x-site-key: your-site-key
-```
-
-**成功响应** (200):
-```json
-{
- "id": 1,
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
- "name": null,
- "password": null,
- "passwordHint": null,
- "accountId": null,
- "createdAt": "2025-01-30T10:00:00.000Z",
- "updatedAt": "2025-01-30T10:00:00.000Z"
-}
-```
-
-### 写操作(需要密码验证)
-
-**请求方式**: `POST|PUT|DELETE /device/:deviceUuid/*`
-
-**特点**:
-- 设备不存在时自动创建
-- 如果设备设置了密码,必须提供正确密码
-- 如果设备没有密码,直接允许写入
-
-#### 密码提供方式
-
-**方式1: 通过请求体(推荐)**
-
-```http
-POST /device/550e8400-e29b-41d4-a716-446655440000/config
-Headers:
- Content-Type: application/json
- x-site-key: your-site-key
-Body:
-{
- "password": "device-password",
- "data": {
- "theme": "dark",
- "language": "zh-CN"
- }
-}
-```
-
-**方式2: 通过查询参数**
-
-```http
-POST /device/550e8400-e29b-41d4-a716-446655440000/config?password=device-password
-Headers:
- Content-Type: application/json
- x-site-key: your-site-key
-Body:
-{
- "data": {
- "theme": "dark",
- "language": "zh-CN"
- }
-}
-```
-
-**成功响应** (200):
-```json
-{
- "message": "数据已更新",
- "updatedAt": "2025-01-30T10:05:00.000Z"
-}
-```
-
-**错误响应 - 需要密码** (401):
-```json
-{
- "statusCode": 401,
- "message": "此操作需要密码",
- "passwordHint": "您的生日(8位数字)"
-}
-```
-
-**错误响应 - 密码错误** (401):
-```json
-{
- "statusCode": 401,
- "message": "密码错误"
-}
-```
-
----
-
-## 场景2: 基于Token的应用访问
-
-适用于:应用访问KV存储数据
-
-### 步骤1: 获取Token
-
-**请求方式**: `POST /apps/:appId/authorize`
-
-**请求示例**:
-```http
-POST /apps/1/authorize
-Headers:
- Content-Type: application/json
- x-site-key: your-site-key
-Body:
-{
- "deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
- "password": "device-password",
- "note": "我的应用授权"
-}
-```
-
-**说明**:
-- `deviceUuid`: 必填,设备UUID
-- `password`: 如果设备有密码则必填
-- `note`: 可选,授权备注
-
-**成功响应** (200):
-```json
-{
- "token": "clxxx123456789abcdefg",
- "appId": 1,
- "appName": "我的应用",
- "deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
- "deviceName": null,
- "note": "我的应用授权",
- "authorizedAt": "2025-01-30T10:00:00.000Z"
-}
-```
-
-**错误响应 - 需要密码** (401):
-```json
-{
- "statusCode": 401,
- "message": "此操作需要密码",
- "passwordHint": "您的生日(8位数字)"
-}
-```
-
-### 步骤2: 使用Token访问KV存储
-
-#### Token提供方式
-
-**方式1: Authorization Header(推荐)**
-```http
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
-```
-
-**方式2: Query参数**
-```http
-?token=clxxx123456789abcdefg
-```
-
-**方式3: Request Body**
-```json
-{
- "token": "clxxx123456789abcdefg",
- ...
-}
-```
-
----
-
-### KV API端点
-
-| 方法 | 端点 | 说明 |
-|------|------|------|
-| GET | `/kv` | 列出所有键(含元数据) |
-| GET | `/kv/_keys` | 列出所有键名(仅键名) |
-| GET | `/kv/:key` | 获取键值 |
-| GET | `/kv/:key/metadata` | 获取键元数据 |
-| POST | `/kv/:key` | 创建/更新键值 |
-| POST | `/kv/_batchimport` | 批量导入 |
-| DELETE | `/kv/:key` | 删除键值 |
-
----
-
-### GET /kv - 列出所有键(含元数据)
-
-**请求示例**:
-```http
-GET /kv?sortBy=key&sortDir=asc&limit=10&skip=0
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- x-site-key: your-site-key
-```
-
-**查询参数**:
-- `sortBy`: 排序字段(key/createdAt/updatedAt),默认 key
-- `sortDir`: 排序方向(asc/desc),默认 asc
-- `limit`: 每页数量,默认 100
-- `skip`: 跳过数量,默认 0
-
-**成功响应** (200):
-```json
-{
- "items": [
- {
- "deviceId": 1,
- "key": "config",
- "metadata": {
- "creatorIp": "192.168.1.1",
- "createdAt": "2025-01-30T10:00:00.000Z",
- "updatedAt": "2025-01-30T10:00:00.000Z"
- }
- },
- {
- "deviceId": 1,
- "key": "user.name",
- "metadata": {
- "creatorIp": "192.168.1.1",
- "createdAt": "2025-01-30T10:01:00.000Z",
- "updatedAt": "2025-01-30T10:01:00.000Z"
- }
- }
- ],
- "total_rows": 25,
- "load_more": "/kv?sortBy=key&sortDir=asc&limit=10&skip=10"
-}
-```
-
----
-
-### GET /kv/_keys - 列出所有键名
-
-**请求示例**:
-```http
-GET /kv/_keys?limit=50&skip=0
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- x-site-key: your-site-key
-```
-
-**成功响应** (200):
-```json
-{
- "keys": ["config", "user.name", "user.theme", "app.settings"],
- "total_rows": 4,
- "current_page": {
- "limit": 50,
- "skip": 0,
- "count": 4
- }
-}
-```
-
----
-
-### GET /kv/:key - 获取键值
-
-**请求示例**:
-```http
-GET /kv/config
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- x-site-key: your-site-key
-```
-
-**成功响应** (200):
-```json
-{
- "theme": "dark",
- "language": "zh-CN",
- "fontSize": 14
-}
-```
-
-**错误响应 - 键不存在** (404):
-```json
-{
- "statusCode": 404,
- "message": "未找到键名为 'config' 的记录"
-}
-```
-
----
-
-### GET /kv/:key/metadata - 获取键元数据
-
-**请求示例**:
-```http
-GET /kv/config/metadata
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- x-site-key: your-site-key
-```
-
-**成功响应** (200):
-```json
-{
- "deviceId": 1,
- "key": "config",
- "metadata": {
- "creatorIp": "192.168.1.1",
- "createdAt": "2025-01-30T10:00:00.000Z",
- "updatedAt": "2025-01-30T10:05:00.000Z"
- }
-}
-```
-
----
-
-### POST /kv/:key - 创建/更新键值
-
-**请求示例**:
-```http
-POST /kv/config
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- Content-Type: application/json
- x-site-key: your-site-key
-Body:
-{
- "theme": "dark",
- "language": "zh-CN",
- "fontSize": 14
-}
-```
-
-**成功响应** (200):
-```json
-{
- "deviceId": 1,
- "key": "config",
- "created": false,
- "updatedAt": "2025-01-30T10:10:00.000Z"
-}
-```
-
-**说明**:
-- `created`: true表示新建,false表示更新
-
-**错误响应 - 空值** (400):
-```json
-{
- "statusCode": 400,
- "message": "请提供有效的JSON值"
-}
-```
-
----
-
-### POST /kv/_batchimport - 批量导入
-
-**请求示例**:
-```http
-POST /kv/_batchimport
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- Content-Type: application/json
- x-site-key: your-site-key
-Body:
-{
- "config": {
- "theme": "dark",
- "language": "zh-CN"
- },
- "user.name": {
- "firstName": "John",
- "lastName": "Doe"
- },
- "app.settings": {
- "notifications": true
- }
-}
-```
-
-**成功响应** (200):
-```json
-{
- "deviceId": 1,
- "total": 3,
- "successful": 3,
- "failed": 0,
- "results": [
- {
- "key": "config",
- "created": false
- },
- {
- "key": "user.name",
- "created": true
- },
- {
- "key": "app.settings",
- "created": true
- }
- ]
-}
-```
-
-**部分失败响应** (200):
-```json
-{
- "deviceId": 1,
- "total": 3,
- "successful": 2,
- "failed": 1,
- "results": [
- {
- "key": "config",
- "created": false
- },
- {
- "key": "user.name",
- "created": true
- }
- ],
- "errors": [
- {
- "key": "app.settings",
- "error": "Invalid value"
- }
- ]
-}
-```
-
----
-
-### DELETE /kv/:key - 删除键值
-
-**请求示例**:
-```http
-DELETE /kv/config
-Headers:
- Authorization: Bearer clxxx123456789abcdefg
- x-site-key: your-site-key
-```
-
-**成功响应** (204):
-```
-无响应体
-```
-
-**错误响应 - 键不存在** (404):
-```json
-{
- "statusCode": 404,
- "message": "未找到键名为 'config' 的记录"
-}
-```
-
----
-
-## 错误码参考
-
-| 状态码 | 说明 | 场景 |
-|--------|------|------|
-| 200 | 成功 | 操作成功 |
-| 204 | 成功(无内容) | 删除成功 |
-| 400 | 请求错误 | 参数缺失或格式错误 |
-| 401 | 未授权 | 需要密码、密码错误、Token无效 |
-| 403 | 禁止访问 | 权限不足 |
-| 404 | 未找到 | 资源不存在 |
-| 500 | 服务器错误 | 服务器内部错误 |
-
----
-
-## 401错误详解
-
-### 需要密码
-```json
-{
- "statusCode": 401,
- "message": "此操作需要密码",
- "passwordHint": "您的生日(8位数字)"
-}
-```
-
-**处理方式**: 提示用户输入密码,使用 `passwordHint` 作为提示信息
-
-### 密码错误
-```json
-{
- "statusCode": 401,
- "message": "密码错误"
-}
-```
-
-**处理方式**: 提示用户密码错误,允许重试
-
-### Token无效
-```json
-{
- "statusCode": 401,
- "message": "未提供身份验证令牌"
-}
-```
-
-或
-
-```json
-{
- "statusCode": 401,
- "message": "无效的身份验证令牌"
-}
-```
-
-**处理方式**: 清除本地Token,引导用户重新授权
-
----
-
-## 迁移检查清单
-
-### Phase 1: 基础适配
-- [ ] 移除手动创建设备的逻辑(设备会自动创建)
-- [ ] 更新密码提供方式(从header改为body/query)
-- [ ] 实现统一的错误处理
-- [ ] 更新API端点路径
-
-### Phase 2: Token集成
-- [ ] 实现应用授权流程(POST /apps/:appId/authorize)
-- [ ] 集成Token到KV操作
-- [ ] 实现Token存储和管理(localStorage)
-- [ ] 处理Token过期/无效场景
-
-### Phase 3: 优化
-- [ ] 封装统一的API客户端
-- [ ] 实现请求重试机制
-- [ ] 添加Loading状态管理
-- [ ] 优化错误提示用户体验
-
-### Phase 4: 测试
-- [ ] 测试设备自动创建
-- [ ] 测试密码验证流程(需要密码、密码错误、密码正确)
-- [ ] 测试Token授权流程
-- [ ] 测试各种错误场景(404、401、400等)
-
----
-
-## 关键注意事项
-
-### 1. 设备自动创建
-- ✅ 无需手动创建设备,首次访问自动创建
-- ✅ 简化前端流程,减少API调用
-- ⚠️ 确保UUID使用正确的格式(建议使用uuidv4)
-
-### 2. 密码处理
-- ✅ 读操作永远不需要密码
-- ✅ 写操作只在设备设置了密码时才需要
-- ⚠️ 密码通过body或query提供,不要放在header中
-- ⚠️ 注意区分"需要密码"和"密码错误"两种情况
-
-### 3. Token管理
-- ✅ Token一次获取,可重复使用
-- ✅ Token与设备和应用绑定
-- ⚠️ Token需要安全存储(localStorage/sessionStorage)
-- ⚠️ Token失效时需要重新授权
-
-### 4. Header要求
-- 所有请求必须携带 `x-site-key` header
-- Token认证使用 `Authorization: Bearer ` header(推荐)
-
----
\ No newline at end of file
diff --git a/docs/apps.md b/docs/apps.md
deleted file mode 100644
index f17ad25..0000000
--- a/docs/apps.md
+++ /dev/null
@@ -1,257 +0,0 @@
-# Apps API
-
-## 1. 获取应用权限信息 (Get Application Permissions)
-
-- **GET** `/apps/token/:token/permissions`
-
-通过token获取应用权限信息。
-
-**路径参数:**
-
-- `token` (string, required): 应用访问令牌
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3000/api/apps/token/your-app-token/permissions" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "appId": 1,
- "permissionPrefix": "myapp",
- "specialPermissions": [],
- "permissionKey": [],
- "app": {
- "id": 1,
- "name": "应用名称",
- "description": "应用描述",
- "developerName": "开发者"
- }
-}
-```
-
-## 2. 获取应用列表 (Get App List)
-
-- **GET** `/apps`
-
-获取应用列表,支持搜索和分页。
-
-**查询参数:**
-
-- `limit` (integer, optional, default: 20): 每页数量
-- `skip` (integer, optional, default: 0): 跳过数量
-- `search` (string, optional): 搜索关键词
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3000/api/apps?limit=10&skip=0&search=test" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "apps": [
- {
- "id": 1,
- "name": "Test App",
- "description": "An application for testing.",
- "developerName": "Test Developer",
- "permissionPrefix": "testapp",
- "specialPermissions": [],
- "permissionKey": [],
- "version": "1.0.0",
- "createdAt": "2023-10-27T10:00:00.000Z",
- "updatedAt": "2023-10-27T10:00:00.000Z"
- }
- ],
- "total": 1,
- "limit": 10,
- "skip": 0
-}
-```
-
-## 3. 获取单个应用详情 (Get App Details)
-
-- **GET** `/apps/:id`
-
-获取单个应用详情。
-
-**路径参数:**
-
-- `id` (integer, required): 应用ID
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3000/api/apps/1" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "id": 1,
- "name": "Test App",
- "description": "An application for testing.",
- "developerName": "Test Developer",
- "permissionPrefix": "testapp",
- "specialPermissions": [],
- "permissionKey": [],
- "version": "1.0.0",
- "createdAt": "2023-10-27T10:00:00.000Z",
- "updatedAt": "2023-10-27T10:00:00.000Z"
-}
-```
-
-## 4. 为设备授权或升级应用 (Install or Upgrade App for Device)
-
-- **POST** `/apps/:id/install/:deviceUuid`
-
-为设备授权应用。如果应用已安装,则会将其升级到最新版本。
-
-**路径参数:**
-
-- `id` (integer, required): 应用ID
-- `deviceUuid` (string, required): 设备UUID
-
-**Curl 示例:**
-
-```bash
-curl -X POST "http://localhost:3000/api/apps/1/install/your-device-uuid" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "token": "a-unique-token-for-this-installation",
- "appId": 1,
- "permissionPrefix": "testapp",
- "permissionKey": [],
- "version": "1.1.0",
- "authorizedAt": "2023-10-27T11:00:00.000Z"
-}
-```
-
-## 6. 卸载设备上的应用 (Uninstall App from Device)
-
-- **DELETE** `/apps/:id/uninstall/:deviceUuid`
-
-卸载设备上已安装的应用。
-
-**路径参数:**
-
-- `id` (integer, required): 应用ID
-- `deviceUuid` (string, required): 设备UUID
-
-**Curl 示例:**
-
-```bash
-curl -X DELETE "http://localhost:3000/api/apps/1/uninstall/your-device-uuid" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "message": "应用卸载成功",
- "appId": 1,
- "uninstalledAt": "2023-10-27T12:00:00.000Z"
-}
-```
-
-## 7. 获取设备已安装的应用列表 (Get Installed Apps on Device)
-
-- **GET** `/devices/:deviceUuid/apps`
-
-获取指定设备上已安装的所有应用列表。
-
-**路径参数:**
-
-- `deviceUuid` (string, required): 设备UUID
-
-**查询参数:**
-
-- `limit` (integer, optional, default: 20): 每页数量
-- `skip` (integer, optional, default: 0): 跳过数量
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3000/api/devices/your-device-uuid/apps?limit=10" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "installs": [
- {
- "id": 1,
- "appId": 1,
- "token": "a-unique-token-for-this-installation",
- "permissionPrefix": "testapp",
- "specialPermissions": [],
- "permissionKey": [],
- "version": "1.0.0",
- "installedAt": "2023-10-27T10:00:00.000Z",
- "updatedAt": "2023-10-27T10:00:00.000Z",
- "app": {
- "id": 1,
- "name": "Test App",
- "description": "An application for testing.",
- "developerName": "Test Developer",
- "permissionPrefix": "testapp"
- }
- }
- ],
- "total": 1,
- "limit": 10,
- "skip": 0
-}
-```
-
-## 8. 获取所有带有 permissionKey 的应用列表 (Get Apps with Permission Key)
-
-- **GET** `/apps/with-permission-key`
-
-获取所有设置了 `permissionKey` 的应用列表。
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3000/api/apps/with-permission-key" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "apps": [
- {
- "id": 2,
- "name": "App With Keys",
- "description": "An application that uses permission keys.",
- "developerName": "Key Developer",
- "permissionPrefix": "keyapp",
- "specialPermissions": [],
- "permissionKey": ["read:data", "write:data"],
- "version": "1.0.0",
- "createdAt": "2023-10-26T10:00:00.000Z",
- "updatedAt": "2023-10-26T10:00:00.000Z"
- }
- ]
-}
-```
\ No newline at end of file
diff --git a/docs/device-auth-frontend.md b/docs/device-auth-frontend.md
deleted file mode 100644
index 790dec2..0000000
--- a/docs/device-auth-frontend.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# 设备授权流程 - 前端接口文档
-
-## 概述
-
-类似 Device Authorization Grant 的授权流程,允许应用通过设备代码获取用户的访问令牌。
-
-## 前端相关接口
-
-### 1. 绑定令牌到设备代码
-
-将用户的访问令牌绑定到应用提供的设备代码。
-
-**接口地址:** `POST /auth/device/bind`
-
-**请求头:**
-```
-Content-Type: application/json
-X-Site-Key: your-site-key
-```
-
-**请求体:**
-```json
-{
- "device_code": "1234-ABCD",
- "token": "user-access-token-string"
-}
-```
-
-**参数说明:**
-- `device_code` (必填): 应用提供给用户的设备授权码,格式如 `1234-ABCD`
-- `token` (必填): 用户在系统中已有的有效访问令牌
-
-**成功响应:** `200 OK`
-```json
-{
- "success": true,
- "message": "令牌已成功绑定到设备代码"
-}
-```
-
-**错误响应:**
-
-400 Bad Request - 参数错误
-```json
-{
- "statusCode": 400,
- "message": "请提供 device_code 和 token"
-}
-```
-
-400 Bad Request - 无效的令牌
-```json
-{
- "statusCode": 400,
- "message": "无效的令牌"
-}
-```
-
-400 Bad Request - 设备代码不存在或已过期
-```json
-{
- "statusCode": 400,
- "message": "设备代码不存在或已过期"
-}
-```
-
----
-
-### 2. 查询设备代码状态(可选,用于调试)
-
-查询设备代码的当前状态,不会删除或修改数据。
-
-**接口地址:** `GET /auth/device/status`
-
-**请求头:**
-```
-X-Site-Key: your-site-key
-```
-
-**查询参数:**
-- `device_code` (必填): 设备授权码
-
-**请求示例:**
-```
-GET /auth/device/status?device_code=1234-ABCD
-```
-
-**成功响应:** `200 OK`
-
-设备代码存在:
-```json
-{
- "device_code": "1234-ABCD",
- "exists": true,
- "has_token": false,
- "expires_in": 850,
- "created_at": 1234567890000
-}
-```
-
-设备代码不存在或已过期:
-```json
-{
- "device_code": "1234-ABCD",
- "exists": false,
- "message": "设备代码不存在或已过期"
-}
-```
-
-**字段说明:**
-- `exists`: 设备代码是否存在且有效
-- `has_token`: 是否已绑定令牌
-- `expires_in`: 剩余有效时间(秒)
-- `created_at`: 创建时间戳(毫秒)
-
----
-
-## 使用流程
-
-1. **应用端**生成设备代码并展示给用户
-2. **用户**在前端页面输入设备代码
-3. **前端**调用 `/auth/device/bind` 接口,将用户的 token 绑定到设备代码
-4. **应用端**轮询获取到令牌,完成授权
-
-## 注意事项
-
-- 设备代码有效期为 15 分钟
-- 令牌必须是系统中已存在的有效令牌
-- 设备代码格式固定为 `XXXX-XXXX` (4位数字-4位字母/数字)
-- 令牌获取后会从服务器内存中删除,只能获取一次
-- 如果需要站点密钥,需在请求头中添加 `X-Site-Key`
diff --git a/docs/kv-token.md b/docs/kv-token.md
deleted file mode 100644
index 1989d04..0000000
--- a/docs/kv-token.md
+++ /dev/null
@@ -1,224 +0,0 @@
-# KV 存储 Token API
-
-本文档描述了基于令牌的 KV 存储 API。这些 API 端点使用应用程序安装令牌进行身份验证,而不是直接使用设备 UUID。
-
-## 身份验证
-
-所有请求都需要提供一个有效的应用程序安装令牌。令牌可以通过以下方式之一提供:
-
-1. **Authorization Header**:
-```
-Authorization: Bearer YOUR_TOKEN
-```
-
-2. **Query Parameter**:
-```
-?token=YOUR_TOKEN
-```
-
-3. **Request Body**:
-```json
-{
- "token": "YOUR_TOKEN"
-}
-```
-
-## API 端点
-
-### 列出键名
-
-获取命名空间下的所有键名(不包括值)。
-
-```http
-GET /kv/token/_keys
-```
-
-查询参数:
-- `sortBy`: 排序字段(默认:'key')
-- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc')
-- `limit`: 每页记录数(默认:100)
-- `skip`: 跳过的记录数(默认:0)
-
-响应示例:
-```json
-{
- "keys": ["key1", "key2", "key3"],
- "total_rows": 3,
- "current_page": {
- "limit": 100,
- "skip": 0,
- "count": 3
- }
-}
-```
-
-### 列出所有键值对
-
-获取命名空间下的所有键值对及其元数据。
-
-```http
-GET /kv/token/
-```
-
-查询参数:
-- `sortBy`: 排序字段(默认:'key')
-- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc')
-- `limit`: 每页记录数(默认:100)
-- `skip`: 跳过的记录数(默认:0)
-
-响应示例:
-```json
-{
- "items": [
- {
- "key": "key1",
- "value": { "data": "value1" },
- "createdAt": "2024-01-01T00:00:00Z",
- "updatedAt": "2024-01-01T00:00:00Z"
- }
- ],
- "total_rows": 1
-}
-```
-
-### 获取单个键值
-
-获取特定键的值。
-
-```http
-GET /kv/token/:key
-```
-
-响应示例:
-```json
-{
- "data": "value1"
-}
-```
-
-### 获取键的元数据
-
-获取特定键的元数据信息。
-
-```http
-GET /kv/token/:key/metadata
-```
-
-响应示例:
-```json
-{
- "key": "key1",
- "createdAt": "2024-01-01T00:00:00Z",
- "updatedAt": "2024-01-01T00:00:00Z",
- "creatorIp": "127.0.0.1"
-}
-```
-
-### 批量导入
-
-批量导入多个键值对。
-
-```http
-POST /kv/token/_batchimport
-```
-
-请求体示例:
-```json
-{
- "key1": { "data": "value1" },
- "key2": { "data": "value2" }
-}
-```
-
-响应示例:
-```json
-{
- "namespace": "device-uuid",
- "total": 2,
- "successful": 2,
- "failed": 0,
- "results": [
- {
- "key": "key1",
- "created": true
- },
- {
- "key": "key2",
- "created": true
- }
- ]
-}
-```
-
-### 创建或更新键值
-
-创建新的键值对或更新现有的键值对。
-
-```http
-POST /kv/token/:key
-```
-
-请求体示例:
-```json
-{
- "data": "value1"
-}
-```
-
-响应示例:
-```json
-{
- "namespace": "device-uuid",
- "key": "key1",
- "created": true,
- "updatedAt": "2024-01-01T00:00:00Z"
-}
-```
-
-### 删除命名空间
-
-删除整个命名空间及其所有键值对。
-
-```http
-DELETE /kv/token/
-```
-
-成功时返回 204 No Content。
-
-### 删除键值对
-
-删除特定的键值对。
-
-```http
-DELETE /kv/token/:key
-```
-
-成功时返回 204 No Content。
-
-## 错误处理
-
-所有错误响应都遵循以下格式:
-
-```json
-{
- "statusCode": 400,
- "message": "错误描述"
-}
-```
-
-常见错误代码:
-- 400: 请求参数错误
-- 401: 未提供令牌或令牌无效
-- 403: 权限不足
-- 404: 资源不存在
-- 429: 请求过于频繁
-- 500: 服务器内部错误
-
-## 权限
-
-API 使用以下权限系统:
-- `appReadAuthMiddleware`: 用于读取操作
-- `appWriteAuthMiddleware`: 用于写入操作
-- `appListAuthMiddleware`: 用于列表操作
-
-这些权限基于应用程序的安装记录中的 `permissionPrefix` 和 `permissionKey` 字段进行验证。
\ No newline at end of file
diff --git a/docs/kv.md b/docs/kv.md
deleted file mode 100644
index d116b29..0000000
--- a/docs/kv.md
+++ /dev/null
@@ -1,614 +0,0 @@
-# KV Store API
-
-## 1. 获取设备信息 (Get Device Info)
-
-- **GET** `/:namespace/_info`
-
-获取指定命名空间(设备)的详细信息。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/_info" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-read-token"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "uuid": "your-device-uuid",
- "name": "My Device",
- "accessType": "PROTECTED",
- "hasPassword": true
-}
-```
-
-## 2. 检查设备状态 (Check Device Status)
-
-- **GET** `/:namespace/_check`
-
-检查设备是否存在及基本信息。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/_check" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "status": "success",
- "uuid": "your-device-uuid",
- "name": "My Device",
- "accessType": "PROTECTED",
- "hasPassword": true
-}
-```
-
-## 3. 校验设备密码 (Check Device Password)
-
-- **POST** `/:namespace/_checkpassword`
-
-校验设备密码是否正确。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-
-**请求体:**
-
-```json
-{
- "password": "your-device-password"
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X POST "http://localhost:3030/kv/your-device-uuid/_checkpassword" \
- -H "X-Site-Key: your-site-key" \
- -H "Content-Type: application/json" \
- -d '{"password": "your-device-password"}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "status": "success",
- "uuid": "your-device-uuid",
- "name": "My Device",
- "accessType": "PROTECTED",
- "hasPassword": true
-}
-```
-
-## 4. 获取密码提示 (Get Password Hint)
-
-- **GET** `/:namespace/_hint`
-
-获取设备的密码提示。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/_hint" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "passwordHint": "My favorite pet's name"
-}
-```
-
-## 5. 更新密码提示 (Update Password Hint)
-
-- **PUT** `/:namespace/_hint`
-
-更新设备的密码提示。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**请求体:**
-
-```json
-{
- "hint": "New password hint"
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X PUT "http://localhost:3030/kv/your-device-uuid/_hint" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token" \
- -H "Content-Type: application/json" \
- -d '{"hint": "New password hint"}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "message": "密码提示已更新",
- "passwordHint": "New password hint"
-}
-```
-
-## 6. 更新设备信息 (Update Device Info)
-
-- **PUT** `/:namespace/_info`
-
-更新设备名称或访问类型。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**请求体:**
-
-```json
-{
- "name": "New Device Name",
- "accessType": "PRIVATE"
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X PUT "http://localhost:3030/kv/your-device-uuid/_info" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token" \
- -H "Content-Type: application/json" \
- -d '{"name": "New Device Name", "accessType": "PRIVATE"}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "uuid": "your-device-uuid",
- "name": "New Device Name",
- "accessType": "PRIVATE",
- "hasPassword": true
-}
-```
-
-## 7. 移除设备密码 (Remove Device Password)
-
-- **DELETE** `/:namespace/_password`
-
-移除设备的密码。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**请求体:**
-
-```json
-{
- "password": "current-password"
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X DELETE "http://localhost:3030/kv/your-device-uuid/_password" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token" \
- -H "Content-Type: application/json" \
- -d '{"password": "current-password"}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "message": "密码已成功移除"
-}
-```
-
-## 8. 获取键名列表 (List Keys)
-
-- **GET** `/:namespace/_keys`
-
-获取指定命名空间下的键名列表(不包含值)。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**查询参数:**
-
-- `sortBy` (string, optional, default: `key`): 排序字段
-- `sortDir` (string, optional, default: `asc`): 排序方向
-- `limit` (integer, optional, default: 100): 每页数量
-- `skip` (integer, optional, default: 0): 跳过数量
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/_keys?limit=50&sortDir=desc" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-read-token"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "keys": [
- "key3",
- "key2",
- "key1"
- ],
- "total_rows": 3,
- "current_page": {
- "limit": 50,
- "skip": 0,
- "count": 3
- }
-}
-```
-
-## 9. 获取所有键值对 (List All Key-Value Pairs)
-
-- **GET** `/:namespace`
-
-获取指定命名空间下的所有键值对及元数据。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**查询参数:**
-
-- `sortBy` (string, optional, default: `key`): 排序字段
-- `sortDir` (string, optional, default: `asc`): 排序方向
-- `limit` (integer, optional, default: 100): 每页数量
-- `skip` (integer, optional, default: 0): 跳过数量
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid?limit=1" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-read-token"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "items": [
- {
- "key": "key1",
- "value": {"data": "some value"},
- "createdAt": "2023-10-27T10:00:00.000Z",
- "updatedAt": "2023-10-27T10:00:00.000Z",
- "creatorIp": "::1"
- }
- ],
- "total_rows": 1,
- "load_more": "/api/kv/your-device-uuid?sortBy=key&sortDir=asc&limit=1&skip=1"
-}
-```
-
-## 10. 获取单个键值 (Get Value by Key)
-
-- **GET** `/:namespace/:key`
-
-通过键名获取单个键值。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-- `key` (string, required): 键名
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-read-token"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "some_data": "value",
- "nested": {
- "is_supported": true
- }
-}
-```
-
-## 11. 获取键的元数据 (Get Key Metadata)
-
-- **GET** `/:namespace/:key/metadata`
-
-获取单个键的元数据。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-- `key` (string, required): 键名
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key/metadata" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-read-token"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "key": "my-key",
- "createdAt": "2023-10-27T10:00:00.000Z",
- "updatedAt": "2023-10-27T10:00:00.000Z",
- "creatorIp": "::1"
-}
-```
-
-## 12. 批量导入键值对 (Batch Import Key-Value Pairs)
-
-- **POST** `/:namespace/_batchimport`
-
-批量导入多个键值对。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**请求体:**
-
-```json
-{
- "key1": {"data": "value1"},
- "key2": {"data": "value2"}
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X POST "http://localhost:3030/kv/your-device-uuid/_batchimport" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token" \
- -H "Content-Type: application/json" \
- -d '{"key1": {"data": "value1"}, "key2": {"data": "value2"}}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "namespace": "your-device-uuid",
- "total": 2,
- "successful": 2,
- "failed": 0,
- "results": [
- {
- "key": "key1",
- "created": true
- },
- {
- "key": "key2",
- "created": true
- }
- ]
-}
-```
-
-## 13. 创建或更新键值对 (Create/Update Key-Value Pair)
-
-- **POST** `/:namespace/:key`
-
-创建或更新单个键值对。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-- `key` (string, required): 键名
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**请求体:**
-
-```json
-{
- "new_data": "is here"
-}
-```
-
-**Curl 示例:**
-
-```bash
-curl -X POST "http://localhost:3030/kv/your-device-uuid/my-key" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token" \
- -H "Content-Type: application/json" \
- -d '{"new_data": "is here"}'
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "namespace": "your-device-uuid",
- "key": "my-key",
- "created": false,
- "updatedAt": "2023-10-27T11:00:00.000Z"
-}
-```
-
-## 14. 删除命名空间 (Delete Namespace)
-
-- **DELETE** `/:namespace`
-
-删除整个命名空间及其所有数据。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X DELETE "http://localhost:3030/kv/your-device-uuid" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token"
-```
-
-**响应 (204 No Content):**
-
-无响应体。
-
-## 15. 删除键 (Delete Key)
-
-- **DELETE** `/:namespace/:key`
-
-删除单个键值对。
-
-**路径参数:**
-
-- `namespace` (string, required): 设备UUID
-- `key` (string, required): 键名
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-- `Authorization` (string, required): `Bearer `
-
-**Curl 示例:**
-
-```bash
-curl -X DELETE "http://localhost:3030/kv/your-device-uuid/my-key" \
- -H "X-Site-Key: your-site-key" \
- -H "Authorization: Bearer your-write-token"
-```
-
-**响应 (204 No Content):**
-
-无响应体。
-
-## 16. 生成 UUID (Generate UUID)
-
-- **GET** `/uuid`
-
-生成一个新的 UUID,可用作命名空间。
-
-**Headers:**
-
-- `X-Site-Key` (string, required): 站点密钥
-
-**Curl 示例:**
-
-```bash
-curl -X GET "http://localhost:3030/kv/uuid" \
- -H "X-Site-Key: your-site-key"
-```
-
-**响应示例 (200 OK):**
-
-```json
-{
- "namespace": "a-newly-generated-uuid"
-}
-```
\ No newline at end of file
diff --git a/docs/middleware.md b/docs/middleware.md
deleted file mode 100644
index 2743a33..0000000
--- a/docs/middleware.md
+++ /dev/null
@@ -1,479 +0,0 @@
-# 中间件系统文档
-
-## 概述
-
-本项目使用中间件系统来处理设备信息获取、权限验证和Token认证。所有与UUID相关的操作都通过统一的中间件处理。
-
-## 中间件架构
-
-### 1. 设备信息中间件 (`deviceMiddleware`)
-
-**文件位置**: `middleware/device.js`
-
-**功能**: 统一处理设备UUID,自动获取或创建设备
-
-**使用场景**:
-- 所有需要设备信息的接口
-- 不需要密码验证的读操作
-- 需要在后续中间件中访问设备信息的场景
-
-**工作流程**:
-1. 从 `req.params.deviceUuid`、`req.params.namespace` 或 `req.body.deviceUuid` 获取UUID
-2. 在数据库中查找设备
-3. 如果设备不存在,自动创建新设备
-4. 将设备信息存储到 `res.locals.device`
-
-**代码示例**:
-```javascript
-import { deviceMiddleware } from './middleware/device.js';
-
-// 基本用法
-router.get('/device/:deviceUuid/info', deviceMiddleware, (req, res) => {
- // 设备信息可从 res.locals.device 访问
- res.json(res.locals.device);
-});
-
-// 从body获取UUID
-router.post('/device/create', deviceMiddleware, (req, res) => {
- // req.body.deviceUuid 会被自动处理
- res.json({ message: '设备已创建', device: res.locals.device });
-});
-```
-
-**数据访问**:
-```javascript
-const device = res.locals.device;
-// device: {
-// id: 1,
-// uuid: 'device-uuid-123',
-// name: 'My Device',
-// password: 'hashed-password',
-// passwordHint: '提示信息',
-// accountId: null,
-// createdAt: Date,
-// updatedAt: Date
-// }
-```
-
----
-
-### 2. 写权限验证中间件 (`requireWriteAuth`)
-
-**文件位置**: `middleware/tokenAuth.js`
-
-**功能**: 验证设备密码,控制写权限
-
-**依赖**: 必须在 `deviceMiddleware` 之后使用
-
-**使用场景**:
-- 所有需要修改数据的操作(POST、PUT、DELETE)
-- 需要验证设备密码的操作
-
-**工作流程**:
-1. 从 `res.locals.device` 获取设备信息
-2. 如果设备没有设置密码,直接允许操作
-3. 如果设备设置了密码:
- - 从 `req.body.password` 或 `req.query.password` 获取密码
- - 验证密码是否正确
- - 密码正确:继续执行
- - 密码错误或未提供:返回 401 错误
-
-**代码示例**:
-```javascript
-import { deviceMiddleware } from './middleware/device.js';
-import { requireWriteAuth } from './middleware/tokenAuth.js';
-
-// 写操作需要密码验证
-router.post('/device/:deviceUuid/data',
- deviceMiddleware, // 第一步:获取设备信息
- requireWriteAuth, // 第二步:验证写权限
- (req, res) => {
- // 验证通过,执行写操作
- res.json({ message: '数据已更新' });
- }
-);
-
-// 读操作不需要密码
-router.get('/device/:deviceUuid/data',
- deviceMiddleware, // 只需要设备信息
- (req, res) => {
- res.json({ data: 'some data' });
- }
-);
-```
-
-**密码提供方式**:
-```javascript
-// 方式1: 通过请求体
-fetch('/device/uuid-123/data', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- password: 'device-password',
- data: 'new value'
- })
-});
-
-// 方式2: 通过查询参数
-fetch('/device/uuid-123/data?password=device-password', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ data: 'new value' })
-});
-```
-
-**错误响应**:
-```json
-// 需要密码但未提供
-{
- "statusCode": 401,
- "message": "此操作需要密码",
- "passwordHint": "提示信息"
-}
-
-// 密码错误
-{
- "statusCode": 401,
- "message": "密码错误"
-}
-```
-
----
-
-### 3. Token认证中间件 (`tokenAuth`)
-
-**文件位置**: `middleware/tokenAuth.js`
-
-**功能**: 基于应用安装Token进行认证
-
-**使用场景**:
-- 应用访问KV数据
-- 需要应用级别认证的接口
-- 不依赖设备UUID的操作
-
-**工作流程**:
-1. 从 Header、Query 或 Body 中获取 token
-2. 在数据库中查找对应的应用安装记录
-3. 验证 token 是否有效
-4. 将应用、设备信息存储到 `res.locals`
-
-**Token提供方式**:
-1. **Authorization Header** (推荐):
- ```javascript
- headers: {
- 'Authorization': 'Bearer '
- }
- ```
-
-2. **Query参数**:
- ```javascript
- ?token=
- ```
-
-3. **Request Body**:
- ```javascript
- {
- "token": "",
- "data": "..."
- }
- ```
-
-**代码示例**:
-```javascript
-import { tokenAuth } from './middleware/tokenAuth.js';
-
-// Token认证的接口
-router.get('/kv/:key', tokenAuth, (req, res) => {
- // 可访问:
- // - res.locals.appInstall (应用安装记录)
- // - res.locals.app (应用信息)
- // - res.locals.device (设备信息)
- // - res.locals.deviceId (设备ID)
-
- res.json({
- key: req.params.key,
- device: res.locals.device.uuid,
- app: res.locals.app.name
- });
-});
-```
-
-**数据访问**:
-```javascript
-const appInstall = res.locals.appInstall;
-// appInstall: {
-// id: 'cuid',
-// deviceId: 1,
-// appId: 1,
-// token: 'unique-token',
-// note: '备注',
-// installedAt: Date,
-// updatedAt: Date,
-// app: { ... },
-// device: { ... }
-// }
-
-const app = res.locals.app;
-// app: { id, name, description, developerName, ... }
-
-const device = res.locals.device;
-// device: { id, uuid, name, password, ... }
-```
-
----
-
-## 中间件组合使用
-
-### 场景1: 基于UUID的读操作(无需密码)
-```javascript
-router.get('/device/:deviceUuid/data',
- deviceMiddleware,
- (req, res) => {
- const device = res.locals.device;
- res.json({ device, data: '...' });
- }
-);
-```
-
-### 场景2: 基于UUID的写操作(需要密码)
-```javascript
-router.post('/device/:deviceUuid/data',
- deviceMiddleware, // 获取设备信息
- requireWriteAuth, // 验证密码
- (req, res) => {
- // 执行写操作
- res.json({ message: '成功' });
- }
-);
-```
-
-### 场景3: 基于Token的操作
-```javascript
-router.get('/kv/:key',
- tokenAuth, // Token认证,自动获取设备信息
- (req, res) => {
- const device = res.locals.device;
- const app = res.locals.app;
- res.json({ device, app, data: '...' });
- }
-);
-```
-
-### 场景4: 批量路由保护
-```javascript
-const router = express.Router();
-
-// 所有该路由下的接口都需要设备信息
-router.use(deviceMiddleware);
-
-// 具体接口
-router.get('/info', (req, res) => {
- res.json(res.locals.device);
-});
-
-router.post('/update', requireWriteAuth, (req, res) => {
- res.json({ message: '更新成功' });
-});
-```
-
----
-
-## 最佳实践
-
-### 1. 中间件顺序很重要
-```javascript
-// ✅ 正确:先获取设备信息,再验证权限
-router.post('/data', deviceMiddleware, requireWriteAuth, handler);
-
-// ❌ 错误:requireWriteAuth 依赖 deviceMiddleware
-router.post('/data', requireWriteAuth, deviceMiddleware, handler);
-```
-
-### 2. 选择合适的认证方式
-```javascript
-// 用户直接操作设备 → 使用 deviceMiddleware + requireWriteAuth
-router.post('/device/:deviceUuid/config', deviceMiddleware, requireWriteAuth, handler);
-
-// 应用代表用户操作 → 使用 tokenAuth
-router.post('/kv/:key', tokenAuth, handler);
-```
-
-### 3. 读操作不需要密码
-```javascript
-// ✅ 读操作只需要设备信息
-router.get('/device/:deviceUuid/data', deviceMiddleware, handler);
-
-// ❌ 读操作不需要密码验证
-router.get('/device/:deviceUuid/data', deviceMiddleware, requireWriteAuth, handler);
-```
-
-### 4. 错误处理
-```javascript
-router.post('/data', deviceMiddleware, requireWriteAuth,
- async (req, res, next) => {
- try {
- // 业务逻辑
- const device = res.locals.device;
- // ...
- res.json({ success: true });
- } catch (error) {
- next(error); // 传递给全局错误处理器
- }
- }
-);
-```
-
-### 5. 密码提示信息
-```javascript
-// 设置设备时提供密码提示
-await prisma.device.update({
- where: { uuid: deviceUuid },
- data: {
- password: hashedPassword,
- passwordHint: '您的生日(8位数字)' // 提供友好的提示
- }
-});
-```
-
----
-
-## 常见问题
-
-### Q1: 为什么设备不存在时会自动创建?
-**A**: 这是为了简化客户端逻辑。客户端只需要生成UUID并使用,无需先调用创建接口。首次访问时会自动创建设备记录。
-
-### Q2: 读操作为什么不需要密码?
-**A**: 根据项目需求,只有写操作需要密码保护。读操作允许任何知道UUID的人访问。如果需要保护读操作,可以在路由中添加 `requireWriteAuth` 中间件。
-
-### Q3: deviceMiddleware 和 tokenAuth 有什么区别?
-**A**:
-- `deviceMiddleware`: 基于UUID获取设备信息,适合用户直接操作
-- `tokenAuth`: 基于应用Token认证,适合应用代表用户操作,包含应用级别的权限控制
-
-### Q4: 如何撤销某个设备的访问权限?
-**A**:
-1. 基于UUID的访问:修改设备密码
-2. 基于Token的访问:删除对应的 `AppInstall` 记录
-
-### Q5: 密码错误但操作不需要密码是否可以继续?
-**A**: 不可以。`requireWriteAuth` 中间件会检查:
-- 如果设备没有密码 → 直接通过
-- 如果设备有密码但未提供 → 拒绝
-- 如果设备有密码但错误 → 拒绝
-
-如果操作不需要密码,不要使用 `requireWriteAuth` 中间件。
-
----
-
-## 迁移指南
-
-### 从旧的认证系统迁移
-
-**旧代码**:
-```javascript
-router.post('/kv/:namespace/:key', authMiddleware, handler);
-```
-
-**新代码**:
-```javascript
-// 选项1: 使用 deviceMiddleware (如果通过URL传递UUID)
-router.post('/device/:deviceUuid/kv/:key',
- deviceMiddleware,
- requireWriteAuth,
- handler
-);
-
-// 选项2: 使用 tokenAuth (推荐,更安全)
-router.post('/kv/:key', tokenAuth, handler);
-```
-
-### 客户端更新
-
-**旧方式**:
-```javascript
-// UUID + 密码
-fetch('/kv/device-uuid/mykey', {
- method: 'POST',
- headers: {
- 'x-namespace-password': 'password'
- },
- body: JSON.stringify({ data: 'value' })
-});
-```
-
-**新方式(选项1 - UUID)**:
-```javascript
-fetch('/device/device-uuid/kv/mykey', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- password: 'password',
- data: 'value'
- })
-});
-```
-
-**新方式(选项2 - Token,推荐)**:
-```javascript
-// 先获取token
-const authResponse = await fetch('/apps/1/authorize', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- deviceUuid: 'device-uuid',
- password: 'password'
- })
-});
-const { token } = await authResponse.json();
-
-// 使用token操作
-fetch('/kv/mykey', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({ data: 'value' })
-});
-```
-
----
-
-## 技术细节
-
-### 密码存储
-密码使用 `bcrypt` 进行哈希处理,存储在 `Device.password` 字段。
-
-**加密函数** (`utils/crypto.js`):
-```javascript
-import bcrypt from 'bcryptjs';
-
-export async function hashDevicePassword(password) {
- return await bcrypt.hash(password, 10);
-}
-
-export async function verifyDevicePassword(password, hash) {
- return await bcrypt.compare(password, hash);
-}
-```
-
-### 性能优化
-- 使用整数ID (`deviceId`) 作为外键,查询效率高于字符串UUID
-- 设备信息查询结果缓存在 `res.locals`,避免重复查询
-- 密码验证使用 bcrypt 的异步方法,不阻塞事件循环
-
-### 安全考虑
-1. 密码使用 bcrypt 加密存储
-2. Token 使用 `cuid` 生成,具有高随机性
-3. 支持密码提示功能,不暴露实际密码
-4. 写操作强制密码验证(如果设置了密码)
-5. 所有中间件使用 `errors.catchAsync` 包装,统一错误处理
-
----
-
-## 参考
-
-- [API重构文档](./API_REFACTOR.md)
-- [Token认证示例](./token-auth-examples.md)
-- [KV存储文档](./kv.md)
-- [应用管理文档](./apps.md)
\ No newline at end of file
diff --git a/docs/token-auth-examples.md b/docs/token-auth-examples.md
deleted file mode 100644
index b32a569..0000000
--- a/docs/token-auth-examples.md
+++ /dev/null
@@ -1,214 +0,0 @@
-# Token认证系统使用示例
-
-本文档展示了如何使用重构后的基于Token的认证系统。
-
-## 1. 基本Token认证
-
-### 路由配置示例
-
-```javascript
-import express from 'express';
-import {
- tokenOnlyAuthMiddleware,
- tokenOnlyReadAuthMiddleware,
- tokenOnlyWriteAuthMiddleware
-} from './middleware/auth.js';
-
-const router = express.Router();
-
-// 需要完整认证的接口
-router.use('/secure', tokenOnlyAuthMiddleware);
-router.get('/secure/profile', (req, res) => {
- // res.locals.device, res.locals.appInstall, res.locals.app 已可用
- res.json({
- device: res.locals.device,
- app: res.locals.app
- });
-});
-
-// 只读接口
-router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => {
- // 处理读取逻辑
- res.json({ key: req.params.key, value: 'some-value' });
-});
-
-// 写入接口
-router.post('/data/:key', tokenOnlyWriteAuthMiddleware, (req, res) => {
- // 处理写入逻辑
- res.json({ success: true, key: req.params.key });
-});
-```
-
-## 2. 客户端请求示例
-
-### 通过HTTP Header传递Token
-
-```javascript
-// 使用fetch
-fetch('/api/secure/profile', {
- headers: {
- 'x-app-token': 'your-app-token-here',
- 'Content-Type': 'application/json'
- }
-})
-.then(response => response.json())
-.then(data => console.log(data));
-
-// 使用axios
-axios.get('/api/secure/profile', {
- headers: {
- 'x-app-token': 'your-app-token-here'
- }
-});
-```
-
-### 通过查询参数传递Token
-
-```javascript
-// GET请求
-fetch('/api/data/mykey?apptoken=your-app-token-here')
- .then(response => response.json())
- .then(data => console.log(data));
-```
-
-### 通过请求体传递Token
-
-```javascript
-// POST请求
-fetch('/api/data/mykey', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- apptoken: 'your-app-token-here',
- value: 'new-value'
- })
-});
-```
-
-## 3. 错误处理
-
-### 常见错误响应
-
-```json
-// 缺少Token
-{
- "statusCode": 401,
- "message": "缺少应用访问令牌,请提供有效的token"
-}
-
-// 无效Token
-{
- "statusCode": 401,
- "message": "无效的应用访问令牌"
-}
-
-// 权限不足
-{
- "statusCode": 403,
- "message": "应用令牌无权访问此命名空间"
-}
-```
-
-### 客户端错误处理示例
-
-```javascript
-async function apiRequest(url, options = {}) {
- try {
- const response = await fetch(url, {
- ...options,
- headers: {
- 'x-app-token': 'your-app-token-here',
- 'Content-Type': 'application/json',
- ...options.headers
- }
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(`API错误 ${error.statusCode}: ${error.message}`);
- }
-
- return await response.json();
- } catch (error) {
- console.error('API请求失败:', error.message);
- throw error;
- }
-}
-
-// 使用示例
-try {
- const data = await apiRequest('/api/secure/profile');
- console.log('用户数据:', data);
-} catch (error) {
- // 处理认证错误
- if (error.message.includes('401')) {
- // 重新获取token或跳转到登录页
- }
-}
-```
-
-## 4. 迁移指南
-
-### 从UUID认证迁移到Token认证
-
-```javascript
-// 旧的UUID认证方式(已弃用)
-router.get('/data/:namespace/:key', authMiddleware, (req, res) => {
- // 使用req.params.namespace作为设备标识
-});
-
-// 新的Token认证方式(推荐)
-router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => {
- // 设备信息通过token自动获取,存储在res.locals.device中
- const deviceUuid = res.locals.device.uuid;
-});
-```
-
-### 客户端迁移
-
-```javascript
-// 旧方式:使用UUID和密码
-fetch('/api/data/device-uuid-123/mykey', {
- headers: {
- 'x-namespace-password': 'device-password'
- }
-});
-
-// 新方式:使用Token
-fetch('/api/data/mykey', {
- headers: {
- 'x-app-token': 'app-token-from-installation'
- }
-});
-```
-
-## 5. 最佳实践
-
-1. **优先使用Token认证**:新项目应该直接使用`tokenOnlyAuthMiddleware`等纯Token认证中间件
-
-2. **安全存储Token**:在客户端安全存储应用Token,避免在URL中暴露
-
-3. **错误处理**:实现完善的错误处理机制,特别是认证失败的情况
-
-4. **Token刷新**:实现Token过期和刷新机制(如果需要)
-
-5. **日志记录**:记录认证相关的操作日志,便于调试和安全审计
-
-## 6. 权限前缀系统
-
-Token认证系统支持基于前缀的权限控制:
-
-```javascript
-// 应用只能访问以特定前缀开头的键
-// 例如:app.permissionPrefix = "myapp"
-// 则只能访问 "myapp.config", "myapp.data" 等键
-
-// 使用appReadAuthMiddleware自动进行前缀检查
-router.get('/kv/:key', appReadAuthMiddleware, (req, res) => {
- // 自动检查req.params.key是否符合权限前缀
-});
-```
-
-这个系统提供了更安全、更灵活的认证机制,建议所有新项目都采用Token认证方式。
\ No newline at end of file
diff --git a/kv-admin/index.html b/kv-admin/index.html
index 2584da2..98d8148 100644
--- a/kv-admin/index.html
+++ b/kv-admin/index.html
@@ -4,7 +4,7 @@
- KV 服务授权管理
+ Classworks KV
diff --git a/kv-admin/package.json b/kv-admin/package.json
index 2261cca..e1863aa 100644
--- a/kv-admin/package.json
+++ b/kv-admin/package.json
@@ -20,6 +20,7 @@
"lucide-react": "^0.544.0",
"lucide-vue-next": "^0.544.0",
"marked": "^16.3.0",
+ "pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.5.1",
"tailwind-merge": "^3.3.1",
diff --git a/kv-admin/pnpm-lock.yaml b/kv-admin/pnpm-lock.yaml
index 6153697..b47b14d 100644
--- a/kv-admin/pnpm-lock.yaml
+++ b/kv-admin/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
marked:
specifier: ^16.3.0
version: 16.3.0
+ pinia:
+ specifier: ^3.0.3
+ version: 3.0.3(vue@3.5.22)
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.22)
@@ -1573,6 +1576,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ pinia@3.0.3:
+ resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
+ peerDependencies:
+ typescript: '>=4.4.4'
+ vue: ^2.7.0 || ^3.5.11
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -3460,6 +3472,11 @@ snapshots:
picomatch@4.0.3: {}
+ pinia@3.0.3(vue@3.5.22):
+ dependencies:
+ '@vue/devtools-api': 7.7.7
+ vue: 3.5.22
+
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
diff --git a/kv-admin/src/App.vue b/kv-admin/src/App.vue
index a78fb6f..0270f57 100644
--- a/kv-admin/src/App.vue
+++ b/kv-admin/src/App.vue
@@ -1,7 +1,10 @@
+
diff --git a/kv-admin/src/components/AppCard.vue b/kv-admin/src/components/AppCard.vue
index 0d5256e..472c5f6 100644
--- a/kv-admin/src/components/AppCard.vue
+++ b/kv-admin/src/components/AppCard.vue
@@ -61,7 +61,7 @@ const renderedReadme = computed(() => {
// 获取应用信息
const fetchApp = async () => {
try {
- app.value = await axios.get(`/apps/${props.appId}`);
+ app.value = await axios.get(`/apps/info/${props.appId}`);
if (app.value.repositoryUrl) {
await fetchReadme();
diff --git a/kv-admin/src/components/DeviceRegisterDialog.vue b/kv-admin/src/components/DeviceRegisterDialog.vue
new file mode 100644
index 0000000..be9e136
--- /dev/null
+++ b/kv-admin/src/components/DeviceRegisterDialog.vue
@@ -0,0 +1,400 @@
+
+
+
+ !val && (props.required ? isOpen = true : handleClose())">
+
+
+ 设备管理
+
+ 加载账户设备或注册新设备
+
+
+
+
+
+
+
+
请先注册或加载设备
+
+ 您需要注册或加载一个设备才能继续使用。
+
+
+
+
+
+
+
+
+
+
+ 加载设备
+
+
+
+ 注册设备
+
+
+
+
+
+
+
请先登录以查看您的设备列表
+
+ 登录账户
+
+
+
+
+
+
+
您的账户暂未绑定任何设备
+
+
+ 注册新设备
+
+
+
+
+
+
+
+
+ {{ device.name || '未命名设备' }}
+
+
+ {{ device.uuid }}
+
+
+ 创建时间: {{ new Date(device.createdAt).toLocaleString('zh-CN') }}
+
+
+
+ 加载
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备名称
+
+
+
+
+
+
+
+
+
+
+ 绑定到账户
+
+
+ {{ accountStore.isAuthenticated
+ ? `将此设备绑定到账户 ${accountStore.userName},绑定后可在其他设备上快速加载`
+ : '登录后可以将设备绑定到您的账户'
+ }}
+
+
+
+
+
+
+
提示:
+
+ UUID将保存到本地浏览器存储
+ 设备名称将帮助您快速识别不同的设备
+ 绑定后可在任何设备上通过账户加载
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/EditDeviceNameDialog.vue b/kv-admin/src/components/EditDeviceNameDialog.vue
new file mode 100644
index 0000000..76d34e4
--- /dev/null
+++ b/kv-admin/src/components/EditDeviceNameDialog.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+ 编辑设备名称
+
+
+
+ 为设备设置一个易于识别的名称
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ isSubmitting ? '更新中...' : '确认' }}
+
+
+
+
+
diff --git a/kv-admin/src/components/LoginDialog.vue b/kv-admin/src/components/LoginDialog.vue
new file mode 100644
index 0000000..b106ed1
--- /dev/null
+++ b/kv-admin/src/components/LoginDialog.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+ 账户登录
+
+ 选择一个OAuth提供者进行登录
+
+
+
+
+ 正在加载登录方式...
+
+
+
+
+
+
+
{{ provider.name }}
+
{{ provider.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/kv-admin/src/components/PasswordInput.vue b/kv-admin/src/components/PasswordInput.vue
new file mode 100644
index 0000000..55e5657
--- /dev/null
+++ b/kv-admin/src/components/PasswordInput.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+ {{ label }}
+ *
+
+
+
+
+
+
+ 密码提示
+
+
+
+
+
+
+
+
+
密码提示
+
{{ passwordHint }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ passwordHint }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/kv-admin/src/components/ResetDevicePasswordDialog.vue b/kv-admin/src/components/ResetDevicePasswordDialog.vue
new file mode 100644
index 0000000..e7ef977
--- /dev/null
+++ b/kv-admin/src/components/ResetDevicePasswordDialog.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+ 重置设备密码
+
+ 为设备 {{ deviceName || deviceUuid }} 设置新密码
+
+
+
+
+
+
+ 您已登录绑定的账户,可以直接重置密码而无需输入当前密码
+
+
+
+
+
+
+
+
+
+ 删除密码
+
+
+ 设置提示
+
+
+
+
+ 取消
+
+
+ {{ isSubmitting ? '重置中...' : '确认重置' }}
+
+
+
+
+
+
+
+
+
+
+ 确认删除密码
+
+ 确定要删除设备 "{{ deviceName || deviceUuid }}" 的密码吗?删除后任何人都可以访问该设备。
+
+
+
+ 取消
+
+ {{ isSubmitting ? '删除中...' : '确认删除' }}
+
+
+
+
+
+
+
+
+
+ 设置密码提示
+
+ 为设备 {{ deviceName || deviceUuid }} 设置密码提示
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ isSettingHint ? '设置中...' : '确认设置' }}
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue
new file mode 100644
index 0000000..266f5c7
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue
new file mode 100644
index 0000000..98526b9
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue
new file mode 100644
index 0000000..1c7fd6d
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue
new file mode 100644
index 0000000..54237cf
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue
new file mode 100644
index 0000000..6617af9
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue
new file mode 100644
index 0000000..c216b25
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue
new file mode 100644
index 0000000..00c5aaf
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue
new file mode 100644
index 0000000..5c48c2d
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue
new file mode 100644
index 0000000..2d546bd
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/alert-dialog/index.js b/kv-admin/src/components/ui/alert-dialog/index.js
new file mode 100644
index 0000000..c48e47b
--- /dev/null
+++ b/kv-admin/src/components/ui/alert-dialog/index.js
@@ -0,0 +1,9 @@
+export { default as AlertDialog } from "./AlertDialog.vue";
+export { default as AlertDialogAction } from "./AlertDialogAction.vue";
+export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
+export { default as AlertDialogContent } from "./AlertDialogContent.vue";
+export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
+export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
+export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
+export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
+export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";
diff --git a/kv-admin/src/components/ui/checkbox/Checkbox.vue b/kv-admin/src/components/ui/checkbox/Checkbox.vue
new file mode 100644
index 0000000..32519de
--- /dev/null
+++ b/kv-admin/src/components/ui/checkbox/Checkbox.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/checkbox/index.js b/kv-admin/src/components/ui/checkbox/index.js
new file mode 100644
index 0000000..75be342
--- /dev/null
+++ b/kv-admin/src/components/ui/checkbox/index.js
@@ -0,0 +1 @@
+export { default as Checkbox } from "./Checkbox.vue";
diff --git a/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue b/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue
new file mode 100644
index 0000000..48e8abf
--- /dev/null
+++ b/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue b/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue
new file mode 100644
index 0000000..86333fb
--- /dev/null
+++ b/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/kv-admin/src/components/ui/separator/Separator.vue b/kv-admin/src/components/ui/separator/Separator.vue
new file mode 100644
index 0000000..680a1d2
--- /dev/null
+++ b/kv-admin/src/components/ui/separator/Separator.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/separator/index.js b/kv-admin/src/components/ui/separator/index.js
new file mode 100644
index 0000000..aae7f1a
--- /dev/null
+++ b/kv-admin/src/components/ui/separator/index.js
@@ -0,0 +1 @@
+export { default as Separator } from "./Separator.vue";
diff --git a/kv-admin/src/components/ui/sonner/Sonner.vue b/kv-admin/src/components/ui/sonner/Sonner.vue
new file mode 100644
index 0000000..8802e9a
--- /dev/null
+++ b/kv-admin/src/components/ui/sonner/Sonner.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/sonner/index.js b/kv-admin/src/components/ui/sonner/index.js
new file mode 100644
index 0000000..39a59dd
--- /dev/null
+++ b/kv-admin/src/components/ui/sonner/index.js
@@ -0,0 +1 @@
+export { default as Toaster } from "./Sonner.vue";
diff --git a/kv-admin/src/components/ui/tabs/Tabs.vue b/kv-admin/src/components/ui/tabs/Tabs.vue
new file mode 100644
index 0000000..31ba139
--- /dev/null
+++ b/kv-admin/src/components/ui/tabs/Tabs.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/tabs/TabsContent.vue b/kv-admin/src/components/ui/tabs/TabsContent.vue
new file mode 100644
index 0000000..b9331fd
--- /dev/null
+++ b/kv-admin/src/components/ui/tabs/TabsContent.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/tabs/TabsList.vue b/kv-admin/src/components/ui/tabs/TabsList.vue
new file mode 100644
index 0000000..791ab3d
--- /dev/null
+++ b/kv-admin/src/components/ui/tabs/TabsList.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/tabs/TabsTrigger.vue b/kv-admin/src/components/ui/tabs/TabsTrigger.vue
new file mode 100644
index 0000000..03e10df
--- /dev/null
+++ b/kv-admin/src/components/ui/tabs/TabsTrigger.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/kv-admin/src/components/ui/tabs/index.js b/kv-admin/src/components/ui/tabs/index.js
new file mode 100644
index 0000000..3b3741b
--- /dev/null
+++ b/kv-admin/src/components/ui/tabs/index.js
@@ -0,0 +1,4 @@
+export { default as Tabs } from "./Tabs.vue";
+export { default as TabsContent } from "./TabsContent.vue";
+export { default as TabsList } from "./TabsList.vue";
+export { default as TabsTrigger } from "./TabsTrigger.vue";
diff --git a/kv-admin/src/composables/useOAuthCallback.js b/kv-admin/src/composables/useOAuthCallback.js
new file mode 100644
index 0000000..f01d598
--- /dev/null
+++ b/kv-admin/src/composables/useOAuthCallback.js
@@ -0,0 +1,126 @@
+import { onMounted, onUnmounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useAccountStore } from '@/stores/account'
+import { toast } from 'vue-sonner'
+
+/**
+ * 处理OAuth回调
+ * 检查URL参数中是否有OAuth回调信息
+ */
+export function useOAuthCallback() {
+ const route = useRoute()
+ const router = useRouter()
+ const accountStore = useAccountStore()
+
+ const handleOAuthCallback = async () => {
+ const { token, provider, success, error } = route.query
+
+ // 检查是否是OAuth回调
+ if (!success && !error) {
+ return
+ }
+
+ // 处理成功回调
+ if (success === 'true' && token) {
+ try {
+ // 保存token到localStorage
+ localStorage.setItem('auth_token', token)
+ localStorage.setItem('auth_provider', provider)
+
+ // 登录到store
+ await accountStore.login(token)
+
+ // 显示成功提示
+ toast.success('登录成功', {
+ description: `已通过 ${provider} 登录`
+ })
+
+ // 清除URL参数
+ router.replace({ query: {} })
+
+ // 触发storage事件,通知其他窗口
+ window.dispatchEvent(new StorageEvent('storage', {
+ key: 'auth_token',
+ newValue: token,
+ url: window.location.href
+ }))
+
+ // 如果是在新窗口中打开的OAuth回调,自动关闭窗口
+ if (window.opener) {
+ // 通知父窗口登录成功
+ window.opener.postMessage({
+ type: 'oauth_success',
+ token,
+ provider
+ }, window.location.origin)
+
+ // 延迟关闭窗口,确保消息已发送
+ setTimeout(() => {
+ window.close()
+ }, 1000)
+ }
+
+ } catch (err) {
+ toast.error('登录失败', {
+ description: err.message || '处理登录信息时出错'
+ })
+ }
+ }
+
+ // 处理错误回调
+ if (success === 'false' || error) {
+ const errorMessages = {
+ 'invalid_state': 'State验证失败,可能存在安全风险',
+ 'access_denied': '用户拒绝了授权请求',
+ 'temporarily_unavailable': '服务暂时不可用,请稍后重试'
+ }
+
+ const errorMsg = errorMessages[error] || error || '登录过程中出现错误'
+
+ toast.error('登录失败', {
+ description: errorMsg
+ })
+
+ // 清除URL参数
+ router.replace({ query: {} })
+
+ // 如果是在新窗口中打开的OAuth回调,自动关闭窗口
+ if (window.opener) {
+ // 通知父窗口登录失败
+ window.opener.postMessage({
+ type: 'oauth_error',
+ error: errorMsg
+ }, window.location.origin)
+
+ // 延迟关闭窗口
+ setTimeout(() => {
+ window.close()
+ }, 1000)
+ }
+ }
+ }
+
+ onMounted(() => {
+ handleOAuthCallback()
+ })
+
+ // 监听storage事件,处理其他标签页的登录
+ const handleStorageChange = (e) => {
+ if (e.key === 'auth_token' && e.newValue) {
+ // 其他标签页已登录,刷新当前页面的状态
+ accountStore.login(e.newValue)
+ }
+ }
+
+ onMounted(() => {
+ window.addEventListener('storage', handleStorageChange)
+ })
+
+ onUnmounted(() => {
+ window.removeEventListener('storage', handleStorageChange)
+ })
+
+ return {
+ handleOAuthCallback
+ }
+}
\ No newline at end of file
diff --git a/kv-admin/src/lib/api.js b/kv-admin/src/lib/api.js
index 4e553c7..cc03606 100644
--- a/kv-admin/src/lib/api.js
+++ b/kv-admin/src/lib/api.js
@@ -31,6 +31,23 @@ class ApiClient {
return response.json()
}
+ // 带认证的fetch
+ async authenticatedFetch(endpoint, options = {}, token = null) {
+ const headers = {
+ ...options.headers,
+ }
+
+ // 如果提供了token,添加Authorization头
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+
+ return this.fetch(endpoint, {
+ ...options,
+ headers,
+ })
+ }
+
// 应用相关 API
async getApps(params = {}) {
const query = new URLSearchParams(params).toString()
@@ -38,53 +55,177 @@ class ApiClient {
}
async getApp(appId) {
- return this.fetch(`/apps/${appId}`)
+ return this.fetch(`/apps/info/${appId}`)
}
- async getAppInstallations(appId, params = {}) {
+ async getAppInstallations(appId, deviceUuid, params = {}) {
const query = new URLSearchParams(params).toString()
- return this.fetch(`/apps/${appId}/installations${query ? `?${query}` : ''}`)
- }
-
- // 授权相关 API
- async authorizeApp(appId, data) {
- return this.fetch(`/apps/${appId}/authorize`, {
- method: 'POST',
- body: JSON.stringify(data),
+ return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, {
+ headers: {
+ 'x-device-uuid': deviceUuid,
+ },
})
}
// Token 管理 API
- async getDeviceTokens(deviceUuid) {
- return this.fetch(`/apps/devices/${deviceUuid}/tokens`)
+ async getDeviceTokens(deviceUuid, options = {}) {
+ const params = new URLSearchParams({
+ uuid: deviceUuid,
+ });
+
+ return this.fetch(`/apps/tokens?${params}`);
}
- async revokeToken(token) {
- return this.fetch(`/apps/tokens/${token}`, {
+ async revokeToken(targetToken, authOptions = {}) {
+ const { deviceUuid, password, usePathParam = true, bearerToken } = authOptions;
+
+ if (usePathParam) {
+ // 使用路径参数方式 (推荐)
+ const headers = {};
+
+ if (bearerToken) {
+ headers['Authorization'] = `Bearer ${bearerToken}`;
+ } else if (deviceUuid) {
+ headers['x-device-uuid'] = deviceUuid;
+ if (password) {
+ headers['x-device-password'] = password;
+ }
+ }
+
+ return this.fetch(`/apps/tokens/${targetToken}`, {
+ method: 'DELETE',
+ headers,
+ });
+ } else {
+ // 使用查询参数方式 (向后兼容)
+ const params = new URLSearchParams({ token: targetToken });
+ const headers = {};
+
+ if (bearerToken) {
+ headers['Authorization'] = `Bearer ${bearerToken}`;
+ } else if (deviceUuid) {
+ headers['x-device-uuid'] = deviceUuid;
+ if (password) {
+ headers['x-device-password'] = password;
+ }
+ }
+
+ return this.fetch(`/apps/tokens?${params}`, {
+ method: 'DELETE',
+ headers,
+ });
+ }
+ }
+
+ // 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
+ async authorizeApp(appId, deviceUuid, options = {}) {
+ const { password, note, token } = options;
+
+ const headers = {
+ 'x-device-uuid': deviceUuid,
+ };
+
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ // 使用新的安装接口
+ return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}?password=${password}`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ note: note || '应用授权' }),
+ });
+ }
+
+ // 设备级别的应用卸载,使用新的 uninstall 接口
+ async revokeDeviceToken(deviceUuid, installId, password = null, token = null) {
+ const params = new URLSearchParams({ uuid: deviceUuid });
+ const headers = {};
+
+ if (password) {
+ params.set('password', password);
+ }
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, {
method: 'DELETE',
- })
+ headers,
+ });
}
// 设备密码管理 API
- async setDevicePassword(deviceUuid, data) {
- return this.fetch(`/apps/devices/${deviceUuid}/password`, {
- method: 'PUT',
- body: JSON.stringify(data),
- })
+ async setDevicePassword(deviceUuid, data, token = null) {
+ const { newPassword, currentPassword, passwordHint } = data;
+
+ // 检查设备是否已设置密码
+ const deviceInfo = await this.getDeviceInfo(deviceUuid);
+ const hasPassword = deviceInfo.hasPassword;
+
+ if (hasPassword) {
+ // 使用PUT修改密码
+ const params = new URLSearchParams();
+ params.set('uuid', deviceUuid);
+ params.set('newPassword', newPassword);
+ if (currentPassword) {
+ params.set('currentPassword', currentPassword);
+ }
+ if (passwordHint !== undefined) {
+ params.set('passwordHint', passwordHint);
+ }
+
+ const headers = {};
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
+ method: 'PUT',
+ headers,
+ });
+ } else {
+ // 使用POST初次设置密码
+ const params = new URLSearchParams();
+ params.set('newPassword', newPassword);
+ if (passwordHint !== undefined) {
+ params.set('passwordHint', passwordHint);
+ }
+
+ return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
+ method: 'POST',
+ });
+ }
}
- async deleteDevicePassword(deviceUuid, password) {
- return this.fetch(`/apps/devices/${deviceUuid}/password`, {
+ async deleteDevicePassword(deviceUuid, password, token = null) {
+ const params = new URLSearchParams({ uuid: deviceUuid });
+ const headers = {};
+
+ // 如果提供了账户token,使用JWT认证
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ } else if (password) {
+ params.set('password', password);
+ }
+
+ return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
method: 'DELETE',
- body: JSON.stringify({ password }),
- })
+ headers,
+ });
}
- async verifyDevicePassword(deviceUuid, password) {
- return this.fetch(`/apps/devices/${deviceUuid}/password/verify`, {
- method: 'POST',
- body: JSON.stringify({ password }),
- })
+ async setDevicePasswordHint(deviceUuid, hint, password = null, token = null) {
+ return this.authenticatedFetch(`/devices/${deviceUuid}/password-hint`, {
+ method: 'PUT',
+ body: JSON.stringify({ hint, password }),
+ }, token)
+ }
+
+ async getDevicePasswordHint(deviceUuid) {
+ return this.fetch(`/devices/${deviceUuid}/password-hint`)
}
// 设备授权相关 API
@@ -98,6 +239,295 @@ class ApiClient {
async getDeviceCodeStatus(deviceCode) {
return this.fetch(`/auth/device/status?device_code=${deviceCode}`)
}
+
+ // KV 存储管理 API
+ async listKVItems(token, params = {}) {
+ const query = new URLSearchParams(params).toString()
+ return this.fetch(`/kv${query ? `?${query}` : ''}`, {
+ headers: { 'x-app-token': token }
+ })
+ }
+
+ async getKVItem(token, key) {
+ return this.fetch(`/kv/${encodeURIComponent(key)}`, {
+ headers: { 'x-app-token': token }
+ })
+ }
+
+ async setKVItem(token, key, value) {
+ return this.fetch(`/kv/${encodeURIComponent(key)}`, {
+ method: 'POST',
+ headers: { 'x-app-token': token },
+ body: JSON.stringify(value),
+ })
+ }
+
+ async deleteKVItem(token, key) {
+ return this.fetch(`/kv/${encodeURIComponent(key)}`, {
+ method: 'DELETE',
+ headers: { 'x-app-token': token }
+ })
+ }
+
+ async getKVKeys(token, pattern = '*') {
+ return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, {
+ headers: { 'x-app-token': token }
+ })
+ }
+
+ // 设备信息 API
+ async getDeviceInfo(deviceUuid) {
+ return this.fetch(`/devices/${deviceUuid}`)
+ }
+
+ // 获取设备应用列表 API (公开接口,无需认证)
+ async getDeviceApps(deviceUuid) {
+ return this.fetch(`/apps/devices/${deviceUuid}/apps`)
+ }
+
+ // 密码提示管理 API
+ async getPasswordHint(deviceUuid) {
+ try {
+ const response = await this.fetch(`/devices/${deviceUuid}`)
+ return { hint: response.device?.passwordHint || '' }
+ } catch (error) {
+ // 如果接口不存在,返回空提示
+ return { hint: '' }
+ }
+ }
+
+ async setPasswordHint(deviceUuid, hint, password) {
+ try {
+ return await this.fetch(`/devices/${deviceUuid}/password-hint?password=${encodeURIComponent(password)}`, {
+ method: 'PUT',
+ headers: {
+ 'x-device-uuid': deviceUuid,
+ },
+ body: JSON.stringify({ passwordHint: hint }),
+ })
+ } catch (error) {
+ // 如果接口不存在,忽略错误
+ console.log('Password hint API not available')
+ return { success: false }
+ }
+ }
+
+ // 账户相关 API
+ async getOAuthProviders() {
+ return this.fetch('/accounts/oauth/providers')
+ }
+
+ async getAccountProfile(token) {
+ return this.fetch('/accounts/profile', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ })
+ }
+
+ async getAccountDevices(token) {
+ return this.fetch('/accounts/devices', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ })
+ }
+
+ async bindDevice(token, deviceUuid) {
+ return this.fetch('/accounts/devices/bind', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ uuid: deviceUuid }),
+ })
+ }
+
+ async unbindDevice(token, deviceUuid) {
+ return this.fetch('/accounts/devices/unbind', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ uuid: deviceUuid }),
+ })
+ }
+
+ async getDeviceAccount(deviceUuid) {
+ return this.fetch(`/accounts/device/${deviceUuid}/account`)
+ }
+
+ // 绑定设备到当前账户
+ async bindDeviceToAccount(token, deviceUuid) {
+ return this.authenticatedFetch('/accounts/devices/bind', {
+ method: 'POST',
+ body: JSON.stringify({ uuid: deviceUuid }),
+ }, token)
+ }
+
+ // 解绑设备
+ async unbindDeviceFromAccount(token, deviceUuid) {
+ return this.authenticatedFetch('/accounts/devices/unbind', {
+ method: 'POST',
+ body: JSON.stringify({ uuid: deviceUuid }),
+ }, token)
+ }
+
+ // 批量解绑设备
+ async batchUnbindDevices(token, deviceUuids) {
+ return this.authenticatedFetch('/accounts/devices/unbind', {
+ method: 'POST',
+ body: JSON.stringify({ uuids: deviceUuids }),
+ }, token)
+ }
+
+ // 设备名称管理 API
+ async setDeviceName(deviceUuid, name, password = null, token = null) {
+ const headers = {
+ 'x-device-uuid': deviceUuid,
+ };
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+ if (password) {
+ headers['x-device-password'] = password;
+ }
+
+ return this.fetch(`/devices/${deviceUuid}/name`, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify({ name }),
+ });
+ }
+
+ // 修改设备密码 API
+ async updateDevicePassword(deviceUuid, currentPassword, newPassword, passwordHint = null, token = null) {
+ const headers = {
+ 'x-device-uuid': deviceUuid,
+ };
+
+ // 如果提供了账户token,使用JWT认证(账户拥有者无需当前密码)
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ } else if (currentPassword) {
+ headers['x-device-password'] = currentPassword;
+ }
+
+ const body = { newPassword, passwordHint };
+ // 只有在非账户拥有者时才需要发送当前密码
+ if (!token && currentPassword) {
+ body.currentPassword = currentPassword;
+ }
+
+ return this.fetch(`/devices/${deviceUuid}/password`, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(body),
+ });
+ }
+
+ // 验证设备密码 API
+ async verifyDevicePassword(deviceUuid, password) {
+ return this.fetch(`/devices/${deviceUuid}`, {
+ method: 'GET',
+ headers: {
+ 'x-device-uuid': deviceUuid,
+ 'x-device-password': password,
+ },
+ });
+ }
+
+ // 设备注册 API
+ async registerDevice(uuid, deviceName, token = null) {
+ return this.authenticatedFetch('/devices', {
+ method: 'POST',
+ body: JSON.stringify({ uuid, deviceName }),
+ }, token)
+ }
+
+ // 账户拥有者重置设备密码 API
+ async resetDevicePasswordAsOwner(deviceUuid, newPassword, passwordHint = null, token) {
+ return this.fetch(`/devices/${deviceUuid}/password`, {
+ method: 'PUT',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'x-device-uuid': deviceUuid,
+ },
+ body: JSON.stringify({ newPassword, passwordHint }),
+ });
+ }
+
+ // 兼容性方法 - 保持旧的API调用方式
+ async getTokens(deviceUuid, options = {}) {
+ return this.getDeviceTokens(deviceUuid, options);
+ }
+
+ async deleteToken(targetToken, deviceUuid = null) {
+ // 向后兼容的删除方法
+ return this.revokeToken(targetToken, { deviceUuid, usePathParam: true });
+ }
+
+ // 便捷方法:使用设备UUID和密码删除token
+ async revokeTokenByDevice(targetToken, deviceUuid, password = null) {
+ return this.revokeToken(targetToken, {
+ deviceUuid,
+ password,
+ usePathParam: true
+ });
+ }
+
+ // 便捷方法:使用账户token删除token
+ async revokeTokenByAccount(targetToken, bearerToken) {
+ return this.revokeToken(targetToken, {
+ bearerToken,
+ usePathParam: true
+ });
+ }
+
+ // 便捷方法:应用自撤销
+ async revokeOwnToken(targetToken) {
+ return this.fetch(`/apps/tokens/${targetToken}`, {
+ method: 'DELETE',
+ headers: {
+ 'x-app-token': targetToken,
+ },
+ });
+ }
+
+ // 新的便捷方法
+ async getTokensWithAuth(authType, authValue, options = {}) {
+ const headers = {};
+ const params = new URLSearchParams(options);
+
+ switch (authType) {
+ case 'uuid':
+ headers['x-device-uuid'] = authValue;
+ params.set('uuid', authValue);
+ break;
+ case 'token':
+ headers['x-app-token'] = authValue;
+ break;
+ case 'bearer':
+ headers['Authorization'] = `Bearer ${authValue}`;
+ break;
+ }
+
+ return this.fetch(`/apps/tokens?${params}`, { headers });
+ }
+
+ async revokeTokenWithAuth(targetToken, authType, authValue) {
+ const headers = {};
+ const params = new URLSearchParams({ token: targetToken });
+
+ switch (authType) {
+ case 'uuid':
+ headers['x-device-uuid'] = authValue;
+ break;
+ case 'token':
+ headers['x-app-token'] = authValue;
+ break;
+ case 'bearer':
+ headers['Authorization'] = `Bearer ${authValue}`;
+ break;
+ }
+
+ return this.fetch(`/apps/tokens?${params}`, {
+ method: 'DELETE',
+ headers,
+ });
+ }
}
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)
diff --git a/kv-admin/src/lib/deviceStore.js b/kv-admin/src/lib/deviceStore.js
index 3ad3308..7877e02 100644
--- a/kv-admin/src/lib/deviceStore.js
+++ b/kv-admin/src/lib/deviceStore.js
@@ -7,16 +7,64 @@ export function generateUUID() {
})
}
-// 设备 UUID 管理
+// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
export const deviceStore = {
- // 获取当前设备 UUID
+ // 存储键名
+ STORAGE_KEY: 'device_uuid',
+ BACKUP_KEY: 'device_uuid_backup',
+ SESSION_KEY: 'device_uuid_session',
+
+ // 获取当前设备 UUID(从多个存储位置尝试读取)
getDeviceUuid() {
- return localStorage.getItem('device_uuid')
+ // 1. 首先从 localStorage 获取
+ let uuid = localStorage.getItem(this.STORAGE_KEY)
+
+ // 2. 如果没有,尝试从备份位置获取
+ if (!uuid) {
+ uuid = localStorage.getItem(this.BACKUP_KEY)
+ if (uuid) {
+ // 恢复到主存储位置
+ this.setDeviceUuid(uuid)
+ }
+ }
+
+ // 3. 如果还没有,尝试从 sessionStorage 获取
+ if (!uuid) {
+ uuid = sessionStorage.getItem(this.SESSION_KEY)
+ if (uuid) {
+ // 恢复到主存储位置
+ this.setDeviceUuid(uuid)
+ }
+ }
+
+ // 4. 如果还没有,尝试从 cookie 获取(如果有的话)
+ if (!uuid) {
+ uuid = this.getFromCookie()
+ if (uuid) {
+ // 恢复到所有存储位置
+ this.setDeviceUuid(uuid)
+ }
+ }
+
+ return uuid
},
- // 设置设备 UUID
+ // 设置设备 UUID(同时存储到多个位置)
setDeviceUuid(uuid) {
- localStorage.setItem('device_uuid', uuid)
+ // 1. 存储到 localStorage 主位置
+ localStorage.setItem(this.STORAGE_KEY, uuid)
+
+ // 2. 存储到备份位置
+ localStorage.setItem(this.BACKUP_KEY, uuid)
+
+ // 3. 存储到 sessionStorage
+ sessionStorage.setItem(this.SESSION_KEY, uuid)
+
+ // 4. 存储到 cookie(设置较长的过期时间)
+ this.saveToCookie(uuid)
+
+ // 5. 尝试存储到 IndexedDB(异步)
+ this.saveToIndexedDB(uuid)
},
// 生成并保存新的设备 UUID
@@ -31,26 +79,113 @@ export const deviceStore = {
let uuid = this.getDeviceUuid()
if (!uuid) {
uuid = this.generateAndSave()
+ } else {
+ // 确保UUID被保存到所有位置
+ this.setDeviceUuid(uuid)
}
return uuid
},
- // 清除设备 UUID
+ // 清除设备 UUID(从所有存储位置清除)
clear() {
- localStorage.removeItem('device_uuid')
- localStorage.removeItem('device_password')
+ localStorage.removeItem(this.STORAGE_KEY)
+ localStorage.removeItem(this.BACKUP_KEY)
+ sessionStorage.removeItem(this.SESSION_KEY)
+ this.clearCookie()
+ this.clearIndexedDB()
},
- // 设备密码管理
- hasPassword() {
- return localStorage.getItem('device_password') === 'true'
+ // Cookie 操作
+ saveToCookie(uuid) {
+ try {
+ const expires = new Date()
+ expires.setFullYear(expires.getFullYear() + 10) // 10年过期
+ document.cookie = `device_uuid=${uuid}; expires=${expires.toUTCString()}; path=/; SameSite=Strict`
+ } catch (e) {
+ console.log('Failed to save UUID to cookie:', e)
+ }
},
- setHasPassword(hasPassword) {
- if (hasPassword) {
- localStorage.setItem('device_password', 'true')
- } else {
- localStorage.removeItem('device_password')
+ getFromCookie() {
+ try {
+ const match = document.cookie.match(/(?:^|; )device_uuid=([^;]*)/)
+ return match ? match[1] : null
+ } catch (e) {
+ console.log('Failed to get UUID from cookie:', e)
+ return null
+ }
+ },
+
+ clearCookie() {
+ try {
+ document.cookie = 'device_uuid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
+ } catch (e) {
+ console.log('Failed to clear UUID cookie:', e)
+ }
+ },
+
+ // IndexedDB 操作(异步,作为额外的备份)
+ async saveToIndexedDB(uuid) {
+ try {
+ const db = await this.openDB()
+ const transaction = db.transaction(['device'], 'readwrite')
+ const store = transaction.objectStore('device')
+ await store.put({ id: 'uuid', value: uuid })
+ } catch (e) {
+ console.log('Failed to save UUID to IndexedDB:', e)
+ }
+ },
+
+ async getFromIndexedDB() {
+ try {
+ const db = await this.openDB()
+ const transaction = db.transaction(['device'], 'readonly')
+ const store = transaction.objectStore('device')
+ const result = await store.get('uuid')
+ return result?.value || null
+ } catch (e) {
+ console.log('Failed to get UUID from IndexedDB:', e)
+ return null
+ }
+ },
+
+ async clearIndexedDB() {
+ try {
+ const db = await this.openDB()
+ const transaction = db.transaction(['device'], 'readwrite')
+ const store = transaction.objectStore('device')
+ await store.delete('uuid')
+ } catch (e) {
+ console.log('Failed to clear UUID from IndexedDB:', e)
+ }
+ },
+
+ openDB() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('ClassworksKV', 1)
+
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => resolve(request.result)
+
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result
+ if (!db.objectStoreNames.contains('device')) {
+ db.createObjectStore('device', { keyPath: 'id' })
+ }
+ }
+ })
+ },
+
+ // 尝试从 IndexedDB 恢复 UUID(在初始化时调用)
+ async tryRestoreFromIndexedDB() {
+ const uuid = await this.getFromIndexedDB()
+ if (uuid && !this.getDeviceUuid()) {
+ this.setDeviceUuid(uuid)
}
}
}
+
+// 在页面加载时尝试从 IndexedDB 恢复
+if (typeof window !== 'undefined') {
+ deviceStore.tryRestoreFromIndexedDB()
+}
diff --git a/kv-admin/src/main.js b/kv-admin/src/main.js
index 8b4ea5e..7389876 100644
--- a/kv-admin/src/main.js
+++ b/kv-admin/src/main.js
@@ -1,11 +1,26 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
+import { createPinia } from 'pinia'
import { routes } from 'vue-router/auto-routes'
import { tokenStore } from './lib/tokenStore'
+import { deviceStore } from './lib/deviceStore'
import './style.css'
import App from './App.vue'
+// 检查 URL 参数中的 uuid 并设置到本地存储
+const urlParams = new URLSearchParams(window.location.search)
+const urlUuid = urlParams.get('uuid')
+if (urlUuid) {
+ deviceStore.setDeviceUuid(urlUuid)
+ // 清除 URL 中的 uuid 参数
+ urlParams.delete('uuid')
+ const newUrl = urlParams.toString()
+ ? `${window.location.pathname}?${urlParams.toString()}`
+ : window.location.pathname
+ window.history.replaceState({}, '', newUrl)
+}
+const pinia = createPinia()
const router = createRouter({
history: createWebHistory(),
@@ -24,4 +39,7 @@ router.beforeEach((to, _from, next) => {
}
})
-createApp(App).use(router).mount('#app')
+const app = createApp(App)
+app.use(pinia)
+app.use(router)
+app.mount('#app')
diff --git a/kv-admin/src/pages/authorize.vue b/kv-admin/src/pages/authorize.vue
index e1c3379..02ba687 100644
--- a/kv-admin/src/pages/authorize.vue
+++ b/kv-admin/src/pages/authorize.vue
@@ -3,16 +3,21 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiClient } from '@/lib/api'
import { deviceStore } from '@/lib/deviceStore'
+import { useAccountStore } from '@/stores/account'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
-import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle } from 'lucide-vue-next'
+import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, Check } from 'lucide-vue-next'
import AppCard from '@/components/AppCard.vue'
+import PasswordInput from '@/components/PasswordInput.vue'
+import LoginDialog from '@/components/LoginDialog.vue'
+import { toast } from 'vue-sonner'
const route = useRoute()
const router = useRouter()
+const accountStore = useAccountStore()
// URL 参数
const appId = ref(route.query.app_id || '')
@@ -24,7 +29,14 @@ const callbackUrl = ref(route.query.callback_url || '')
const step = ref('input') // 'input' | 'loading' | 'success' | 'error'
const errorMessage = ref('')
const deviceUuid = ref('')
-const hasPassword = ref(false)
+const deviceInfo = ref(null)
+const deviceAccount = ref(null)
+const showLoginDialog = ref(false)
+const showDeviceList = ref(false)
+const customDeviceUuid = ref('')
+
+// 计算属性获取是否有密码
+const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
// 表单数据
const inputDeviceCode = ref('')
@@ -50,6 +62,73 @@ const loadAppInfo = async () => {
}
}
+// 加载设备账户信息
+const loadDeviceAccount = async () => {
+ if (!deviceUuid.value) return
+
+ try {
+ const response = await apiClient.getDeviceAccount(deviceUuid.value)
+ deviceAccount.value = response.data
+ } catch (error) {
+ console.log('Failed to load device account:', error)
+ deviceAccount.value = null
+ }
+}
+
+// 选择设备UUID
+const selectDevice = async (uuid) => {
+ deviceUuid.value = uuid
+ deviceStore.setUuid(uuid)
+ showDeviceList.value = false
+ customDeviceUuid.value = ''
+
+ // 重新加载设备信息
+ await loadDeviceInfo()
+ await loadDeviceAccount()
+}
+
+// 使用自定义UUID
+const useCustomUuid = () => {
+ if (!customDeviceUuid.value) return
+
+ selectDevice(customDeviceUuid.value)
+}
+
+// 一键绑定当前设备
+const bindCurrentDevice = async () => {
+ if (!accountStore.isAuthenticated || !deviceUuid.value) return
+
+ try {
+ await accountStore.bindDevice(deviceUuid.value)
+ await loadDeviceAccount()
+ toast.success('绑定成功', {
+ description: '设备已绑定到您的账户'
+ })
+ } catch (error) {
+ toast.error('绑定失败', {
+ description: error.message || '无法绑定设备'
+ })
+ }
+}
+
+// 登录成功回调
+const handleLoginSuccess = async (token) => {
+ showLoginDialog.value = false
+ await accountStore.login(token)
+ await loadDeviceAccount()
+
+ // 如果当前设备未绑定,提示是否绑定
+ if (!deviceAccount.value) {
+ toast('登录成功', {
+ description: '您可以将当前设备绑定到账户',
+ action: {
+ label: '立即绑定',
+ onClick: bindCurrentDevice,
+ },
+ })
+ }
+}
+
// 授权应用并绑定到设备代码
const authorizeWithDeviceCode = async () => {
if (!currentDeviceCode.value || !deviceUuid.value) return
@@ -60,7 +139,6 @@ const authorizeWithDeviceCode = async () => {
try {
// 1. 授权应用并获取 token
const authData = {
- deviceUuid: deviceUuid.value,
note: authNote.value || '设备代码授权',
}
@@ -68,7 +146,7 @@ const authorizeWithDeviceCode = async () => {
authData.password = authPassword.value
}
- const authResult = await apiClient.authorizeApp(appId.value, authData)
+ const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData)
const token = authResult.token
// 2. 绑定 token 到设备代码
@@ -90,7 +168,6 @@ const authorizeWithCallback = async () => {
try {
const authData = {
- deviceUuid: deviceUuid.value,
note: authNote.value || '回调授权',
}
@@ -98,7 +175,7 @@ const authorizeWithCallback = async () => {
authData.password = authPassword.value
}
- const authResult = await apiClient.authorizeApp(appId.value, authData)
+ const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData)
const token = authResult.token
// 如果有回调 URL,跳转并携带 token
@@ -136,10 +213,33 @@ const retry = () => {
authPassword.value = ''
}
-onMounted(() => {
+// 加载设备信息
+const loadDeviceInfo = async () => {
+ try {
+ const info = await apiClient.getDeviceInfo(deviceUuid.value)
+ deviceInfo.value = info
+ } catch (error) {
+ console.log('Failed to load device info:', error)
+ deviceInfo.value = null
+ }
+}
+
+onMounted(async () => {
deviceUuid.value = deviceStore.getOrGenerate()
- hasPassword.value = deviceStore.hasPassword()
- loadAppInfo()
+
+ // 加载设备信息
+ await loadDeviceInfo()
+
+ // 加载设备账户信息
+ await loadDeviceAccount()
+
+ // 加载应用信息
+ await loadAppInfo()
+
+ // 如果已登录,加载设备列表
+ if (accountStore.isAuthenticated) {
+ await accountStore.loadDevices()
+ }
// 如果是 devicecode 模式且已有设备代码,自动填充
if (isDeviceCodeMode.value && deviceCode.value) {
@@ -179,8 +279,15 @@ onMounted(() => {
-
设备 UUID
-
+
+ 设备 UUID
+
+
+
+
+
+
+
{{ deviceUuid }}
@@ -189,6 +296,23 @@ onMounted(() => {
已保护
+
+
+
+
+ 已绑定至: {{ deviceAccount.name }}
+
+
@@ -231,14 +355,17 @@ onMounted(() => {
/>
-
-
-
设备密码
-
+
@@ -337,5 +464,8 @@ onMounted(() => {
+
+
+
diff --git a/kv-admin/src/pages/dashboard.vue b/kv-admin/src/pages/dashboard.vue
deleted file mode 100644
index 3375509..0000000
--- a/kv-admin/src/pages/dashboard.vue
+++ /dev/null
@@ -1,422 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ appName }} - 数据管理
-
{{ totalRows }} 条键值对
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 按键名
- 按创建时间
- 按更新时间
-
-
-
-
-
-
-
- 升序
- 降序
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 键名
- 创建时间
- 更新时间
- 创建者 IP
- 操作
-
-
-
-
- {{ item.key }}
-
- {{ formatDate(item.createdAt) }}
-
-
- {{ formatDate(item.updatedAt) }}
-
-
- {{ item.creatorIp }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 显示 {{ currentPage * pageSize + 1 }} - {{ Math.min((currentPage + 1) * pageSize, totalRows) }} / {{ totalRows }}
-
-
-
- 上一页
-
-
- 下一页
-
-
-
-
-
-
-
-
-
- 新建键值对
- 创建一个新的键值对
-
-
-
- 键名
-
-
-
- 值 (JSON)
-
-
-
-
- 取消
- 创建
-
-
-
-
-
-
-
-
- 查看键值对
- {{ selectedItem?.key }}
-
-
-
-
{{ JSON.stringify(selectedItem.value, null, 2) }}
-
-
-
-
设备 ID:
-
{{ selectedItem.deviceId }}
-
-
-
创建者 IP:
-
{{ selectedItem.creatorIp }}
-
-
-
创建时间:
-
{{ formatDate(selectedItem.createdAt) }}
-
-
-
更新时间:
-
{{ formatDate(selectedItem.updatedAt) }}
-
-
-
-
- 关闭
-
-
-
-
-
-
-
-
- 编辑键值对
- {{ selectedItem?.key }}
-
-
-
- 取消
- 保存
-
-
-
-
-
-
-
-
- 确认删除
-
- 确定要删除键名为 {{ selectedItem?.key }} 的记录吗?此操作无法撤销。
-
-
-
- 取消
- 删除
-
-
-
-
-
diff --git a/kv-admin/src/pages/device-management.vue b/kv-admin/src/pages/device-management.vue
new file mode 100644
index 0000000..ca35341
--- /dev/null
+++ b/kv-admin/src/pages/device-management.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ accountStore.userName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂无绑定设备
+ 您可以在主页面注册并绑定新设备
+
+ 返回主页
+
+
+
+
+
+
+
+
+ 共 {{ devices.length }} 个设备
+
+
+
+
+
+
+
+
+
+ {{ device.name || '未命名设备' }}
+
+
+ {{ device.uuid }}
+
+
+
+
+
+
+
+ 创建时间: {{ formatDate(device.createdAt) }}
+
+
+
+
+
+ 重命名
+
+
+
+ 重置密码
+
+
+
+
+
+ 解绑设备
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确认解绑设备
+
+ 确定要解绑设备 "{{ currentDevice?.name || currentDevice?.uuid }}" 吗?
+ 此操作无法撤销。
+
+
+
+ 取消
+
+ 确认解绑
+
+
+
+
+
+
diff --git a/kv-admin/src/pages/index.vue b/kv-admin/src/pages/index.vue
index eee95dd..809ed72 100644
--- a/kv-admin/src/pages/index.vue
+++ b/kv-admin/src/pages/index.vue
@@ -2,48 +2,69 @@
import { ref, computed, onMounted } from 'vue'
import { apiClient } from '@/lib/api'
import { deviceStore } from '@/lib/deviceStore'
+import { useAccountStore } from '@/stores/account'
+import { useOAuthCallback } from '@/composables/useOAuthCallback'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Package, Clock } from 'lucide-vue-next'
+import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Package, Clock, AlertCircle, Lock, Info, User, LogOut, Layers, ChevronDown } from 'lucide-vue-next'
+import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
+import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
import AppCard from '@/components/AppCard.vue'
+import PasswordInput from '@/components/PasswordInput.vue'
+import LoginDialog from '@/components/LoginDialog.vue'
+import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
+import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
+import { toast } from 'vue-sonner'
const deviceUuid = ref('')
const tokens = ref([])
const isLoading = ref(false)
const copied = ref(null)
+const deviceInfo = ref(null) // 存储设备信息
+const deviceAccount = ref(null) // 设备账户信息
+const accountStore = useAccountStore()
// Dialogs
const showAuthorizeDialog = ref(false)
const showRevokeDialog = ref(false)
-const showUuidDialog = ref(false)
+const showRegisterDialog = ref(false)
const showPasswordDialog = ref(false)
+const showLoginDialog = ref(false)
+const showEditNameDialog = ref(false)
+const showUserMenu = ref(false)
+const deviceRequired = ref(false) // 标记是否必须注册设备
const selectedToken = ref(null)
// Form data
const appIdToAuthorize = ref('')
const authPassword = ref('')
const authNote = ref('')
-const newUuid = ref('')
const devicePassword = ref('')
const newPassword = ref('')
const currentPassword = ref('')
+const passwordHint = ref('')
+const revokePassword = ref('') // 撤销授权时的密码
-const hasPassword = ref(false)
+// 使用OAuth回调处理
+const { handleOAuthCallback } = useOAuthCallback()
+
+// 使用计算属性来获取是否有密码
+const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
// Group tokens by appId
const groupedByApp = computed(() => {
const groups = {}
tokens.value.forEach(token => {
- const appId = token.app.id
+ const appId = token.appId
if (!groups[appId]) {
groups[appId] = {
appId: appId,
- appName: token.app.name || appId,
- description: token.app.description || '',
+ appName: token.appName || appId,
+ description: token.appDescription || '',
tokens: []
}
}
@@ -52,6 +73,34 @@ const groupedByApp = computed(() => {
return Object.values(groups)
})
+// 加载设备信息
+const loadDeviceInfo = async () => {
+ try {
+ const info = await apiClient.getDeviceInfo(deviceUuid.value)
+ deviceInfo.value = info
+ // 如果有密码提示,设置它
+ if (info.passwordHint) {
+ passwordHint.value = info.passwordHint
+ }
+ } catch (error) {
+ console.log('Failed to load device info:', error)
+ // 设备不存在时,deviceInfo为null,hasPassword会返回false
+ deviceInfo.value = null
+ }
+}
+
+// 加载密码提示
+const loadPasswordHint = async () => {
+ try {
+ const data = await apiClient.getPasswordHint(deviceUuid.value)
+ if (data.hint) {
+ passwordHint.value = data.hint
+ }
+ } catch (error) {
+ console.log('Failed to load password hint')
+ }
+}
+
const loadTokens = async () => {
if (!deviceUuid.value) return
@@ -73,24 +122,34 @@ const authorizeApp = async () => {
if (!appIdToAuthorize.value) return
try {
- const data = {
- deviceUuid: deviceUuid.value,
+ const options = {
note: authNote.value || '授权访问',
}
if (hasPassword.value && authPassword.value) {
- data.password = authPassword.value
+ options.password = authPassword.value
}
- await apiClient.authorizeApp(appIdToAuthorize.value, data)
+ if (accountStore.isAuthenticated) {
+ options.token = accountStore.token
+ }
+
+ // 调用授权接口
+ await apiClient.authorizeApp(
+ appIdToAuthorize.value,
+ deviceUuid.value,
+ options
+ )
+
showAuthorizeDialog.value = false
appIdToAuthorize.value = ''
authPassword.value = ''
authNote.value = ''
await loadTokens()
+ toast.success('授权成功')
} catch (error) {
- alert('授权失败:' + error.message)
+ toast.error('授权失败:' + error.message)
}
}
@@ -102,13 +161,27 @@ const confirmRevoke = (token) => {
const revokeToken = async () => {
if (!selectedToken.value) return
+ // 如果没有登录账户且设备有密码,检查是否输入了密码
+ if (!accountStore.isAuthenticated && hasPassword.value && !revokePassword.value) {
+ alert('请输入设备密码')
+ return
+ }
+
try {
- await apiClient.revokeToken(selectedToken.value.token)
+ // 使用安装记录ID撤销授权
+ await apiClient.revokeDeviceToken(
+ deviceUuid.value,
+ selectedToken.value.id,
+ accountStore.isAuthenticated ? null : revokePassword.value,
+ accountStore.isAuthenticated ? accountStore.token : null
+ )
showRevokeDialog.value = false
selectedToken.value = null
+ revokePassword.value = ''
await loadTokens()
+ toast.success('撤销成功')
} catch (error) {
- alert('撤销失败:' + error.message)
+ toast.error('撤销失败:' + error.message)
}
}
@@ -125,14 +198,10 @@ const copyToClipboard = async (text, id) => {
}
const updateUuid = () => {
- if (newUuid.value.trim()) {
- deviceStore.setDeviceUuid(newUuid.value.trim())
- } else {
- deviceStore.generateAndSave()
- }
+ showRegisterDialog.value = false
deviceUuid.value = deviceStore.getDeviceUuid()
- showUuidDialog.value = false
- newUuid.value = ''
+ loadDeviceInfo()
+ loadDeviceAccount()
loadTokens()
}
@@ -144,18 +213,25 @@ const setPassword = async () => {
newPassword: newPassword.value,
}
- if (hasPassword.value) {
+ if (hasPassword.value && !accountStore.isAuthenticated) {
data.currentPassword = currentPassword.value
}
- await apiClient.setDevicePassword(deviceUuid.value, data)
- deviceStore.setHasPassword(true)
- hasPassword.value = true
+ await apiClient.setDevicePassword(
+ deviceUuid.value,
+ data,
+ accountStore.isAuthenticated ? accountStore.token : null
+ )
+
+ // 重新加载设备信息以更新hasPassword状态
+ await loadDeviceInfo()
+
showPasswordDialog.value = false
newPassword.value = ''
currentPassword.value = ''
+ toast.success('密码设置成功')
} catch (error) {
- alert('设置密码失败:' + error.message)
+ toast.error('设置密码失败:' + error.message)
}
}
@@ -163,54 +239,322 @@ const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('zh-CN')
}
-onMounted(() => {
- deviceUuid.value = deviceStore.getOrGenerate()
- hasPassword.value = deviceStore.hasPassword()
- loadTokens()
+// 加载设备账户信息
+const loadDeviceAccount = async () => {
+ if (!deviceUuid.value) return
+
+ try {
+ const response = await apiClient.getDeviceAccount(deviceUuid.value)
+ deviceAccount.value = response.data
+ } catch (error) {
+ console.log('Failed to load device account:', error)
+ deviceAccount.value = null
+ }
+}
+
+// 登录成功回调
+const handleLoginSuccess = async (token) => {
+ showLoginDialog.value = false
+ await accountStore.login(token)
+ await loadDeviceAccount()
+
+ // 如果当前设备未绑定,提示是否绑定
+ if (!deviceAccount.value) {
+ toast('登录成功', {
+ description: '您可以将当前设备绑定到账户'
+ })
+ }
+
+ // 登录完成后,根据需要决定是否继续显示设备弹框
+
+ // 如果设备已经注册,即使是必需模式也不再显示设备弹框
+ if (deviceUuid.value) {
+ deviceRequired.value = false
+ } else if (deviceRequired.value) {
+ // 如果仍然需要设备,再次显示设备管理弹框
+ showRegisterDialog.value = true
+ }
+}
+
+// 退出登录
+const handleLogout = () => {
+ accountStore.logout()
+ deviceAccount.value = null
+ toast('已退出登录')
+}
+
+// 绑定当前设备到账户
+const bindCurrentDevice = async () => {
+ if (!accountStore.isAuthenticated) {
+ toast.error('请先登录')
+ showLoginDialog.value = true
+ return
+ }
+
+ try {
+ await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
+ await loadDeviceInfo()
+ toast.success('设备已绑定到您的账户')
+ } catch (error) {
+ // 如果设备不存在,先注册再绑定
+ if (error.message.includes('设备不存在')) {
+ try {
+ await apiClient.registerDevice(
+ deviceUuid.value,
+ deviceInfo.value?.deviceName || null,
+ accountStore.token
+ )
+ await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
+ await loadDeviceInfo()
+ toast.success('设备已注册并绑定到您的账户')
+ } catch (retryError) {
+ toast.error('绑定失败:' + retryError.message)
+ }
+ } else {
+ toast.error('绑定失败:' + error.message)
+ }
+ }
+}
+
+// 解绑当前设备
+const unbindCurrentDevice = async () => {
+ if (!accountStore.isAuthenticated) {
+ toast.error('请先登录')
+ return
+ }
+
+ try {
+ await apiClient.unbindDeviceFromAccount(accountStore.token, deviceUuid.value)
+ await loadDeviceInfo()
+ toast.success('设备已解绑')
+ } catch (error) {
+ toast.error('解绑失败:' + error.message)
+ }
+}
+
+// 更新设备名称成功回调
+const handleDeviceNameUpdated = async (newName) => {
+ await loadDeviceInfo()
+}
+
+onMounted(async () => {
+ // 检查是否存在设备UUID
+ const existingUuid = deviceStore.getDeviceUuid()
+ if (!existingUuid) {
+ deviceRequired.value = true
+ // 如果没有设备UUID,显示设备管理弹框
+ showRegisterDialog.value = true
+ } else {
+ deviceUuid.value = existingUuid
+
+ // 先加载设备信息
+ await loadDeviceInfo()
+
+ // 加载设备账户信息
+ await loadDeviceAccount()
+
+ // 如果有密码但密码提示不存在,单独加载密码提示
+ if (hasPassword.value && !passwordHint.value) {
+ await loadPasswordHint()
+ }
+
+ // 加载tokens
+ await loadTokens()
+ }
})
-
-
-
-
-
-
-
-
- 设备授权管理
-
-
- 管理您的设备 UUID 和应用授权令牌
-
+
+
+
+
+
+
+
+
-
-
-
- {{ hasPassword ? '修改密码' : '设置密码' }}
-
-
-
- 更换 UUID
-
+
+
+ Classworks KV
+
+
云原生键值数据库
+
+
+
+
+
+
+
+
+
+
+ {{ accountStore.userName }}
+
+
+
+
+
+
+ 设备管理
+
+
+
+ 高级设置
+
+
+
+ 退出登录
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ deviceInfo?.name || '设备标识' }}
+
+
+
+
+
+
您的唯一设备标识符
+
+
+
+
+
+
+ {{ hasPassword ? '已设密码保护' : '未设密码' }}
+
+
+
+
+
+ {{ deviceInfo.account.name }}
+
+
+
+ 绑定到账户
+
+ 高级设置
-
-
设备 UUID:
-
- {{ deviceUuid }}
-
-
-
-
-
+
+
+
+
+
+
+ {{ deviceUuid }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ groupedByApp.length }}
+
应用数
+
+
+
{{ tokens.length }}
+
令牌数
+
+
+
+ {{ hasPassword ? '安全' : '未设置' }}
+
+
安全状态
+
+
+
+
+
+
+
+
+
密码提示
+
{{ passwordHint }}
+
+
+
@@ -332,14 +676,19 @@ onMounted(() => {
placeholder="为此授权添加备注"
/>
-
-
设备密码
-
+
+
+ 已登录绑定账户,无需输入密码
+
@@ -359,20 +708,47 @@ onMounted(() => {
撤销授权
- 确定要撤销此令牌的授权吗?此操作无法撤销。
+ 确定要撤销此令牌的授权吗?此操作无法撤销。{{selectedToken}}
-
+
应用:
- {{ selectedToken.app.name }}
+ {{ selectedToken.appName }}
令牌:
{{ selectedToken.token.slice(0, 16) }}...
+
+
+
+
+
+
+
+
+ 已登录账户,无需输入密码
+
+
+
+ 设备未设置密码
+
+
+
+ 需要验证设备密码
+
+
@@ -386,39 +762,6 @@ onMounted(() => {
-
-
-
- 更换设备 UUID
-
- 输入新的 UUID 或留空以生成随机 UUID
-
-
-
-
- 新 UUID(可选)
-
-
-
- 警告: 更换 UUID 后,所有现有授权将失效
-
-
-
-
- 取消
-
-
- 确认更换
-
-
-
-
-
-
@@ -428,22 +771,35 @@ onMounted(() => {
-
-
-
新密码
-
+
+
+
+
账户已登录
+
您已登录绑定的账户,无需输入当前密码
+
+
+
+
@@ -458,5 +814,30 @@ onMounted(() => {
+
+
+ {
+ if (!val && deviceRequired.value) {
+ showRegisterDialog.value = true
+ }
+ }"
+ />
+
+
+
+
\ No newline at end of file
diff --git a/kv-admin/src/pages/kv-manager.vue b/kv-admin/src/pages/kv-manager.vue
new file mode 100644
index 0000000..ca8713e
--- /dev/null
+++ b/kv-admin/src/pages/kv-manager.vue
@@ -0,0 +1,615 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
加载中...
+
+
+
{{ error }}
+
+
+
+
+
+
+ 键名
+ 值
+ 操作
+
+
+
+
+ {{ key }}
+
+ 加载中...
+
+
{{ formatValue(values[key]) }}
+
+ 查看
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ editingKey ? '编辑键值' : '添加键值' }}
+
+ 键名
+
+
+
+ 值(JSON 格式)
+
+
+
{{ dialogError }}
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
diff --git a/kv-admin/src/pages/password-manager.vue b/kv-admin/src/pages/password-manager.vue
new file mode 100644
index 0000000..e19e2d2
--- /dev/null
+++ b/kv-admin/src/pages/password-manager.vue
@@ -0,0 +1,591 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 密码状态
+ 当前设备的密码保护状态
+
+
+
+
+
+ {{ hasPassword ? '已设置密码' : '未设置密码' }}
+
+
+
+
+
+
+
+ 设备 UUID
+ {{ deviceUuid }}
+
+
+
+
+
+
+
+
密码提示
+
{{ passwordHint }}
+
+
+
+
+
+
+
+
+ 设置密码
+
+
+
+
+ 修改密码
+
+
+
+
+ {{ passwordHint ? '修改提示' : '设置提示' }}
+
+
+
+
+ 删除密码
+
+
+
+
+
+
+
+
设备管理
+
+
+
+
+
+
+ 重置设备
+
+
+ 重置或换新设备标识。此操作无法撤销,您将失去当前设备的所有授权。
+
+
+
+
+
+
+
+
+
警告:此操作不可逆
+
+ 重置设备后,您将获得全新的设备标识,现有的所有授权将被撤销,无法恢复。
+
+
+
+
+
+
+
+ 重置设备
+
+
+
+
+
+
+
+
+
+
+
+ {{ hasPassword ? '修改密码' : '设置密码' }}
+
+ {{ hasPassword ? '请输入当前密码和新密码' : '为您的设备设置一个安全的密码' }}
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ isLoading ? '处理中...' : (hasPassword ? '修改密码' : '设置密码') }}
+
+
+
+
+
+
+
+
+
+ 删除密码
+
+ 删除密码后,您的设备将不再受密码保护。此操作无法撤销。
+
+
+
+
+
+
+
+
+
警告
+
删除密码后,任何拥有您设备 UUID 的人都可以管理您的授权应用。
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ isLoading ? '删除中...' : '确认删除' }}
+
+
+
+
+
+
+
+
+
+ {{ passwordHint ? '修改密码提示' : '设置密码提示' }}
+
+ 密码提示可以帮助您在忘记密码时回忆起密码
+
+
+
+
+
+
当前提示
+
{{ passwordHint }}
+
+
+
+
新的密码提示
+
+
+ 提示不应包含密码本身,而是能帮助您回忆密码的信息
+
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ isLoading ? '保存中...' : '保存提示' }}
+
+
+
+
+
+
+
showResetDeviceDialog = val"
+ />
+
+
\ No newline at end of file
diff --git a/kv-admin/src/stores/account.js b/kv-admin/src/stores/account.js
new file mode 100644
index 0000000..3aed301
--- /dev/null
+++ b/kv-admin/src/stores/account.js
@@ -0,0 +1,116 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { apiClient } from '@/lib/api'
+
+export const useAccountStore = defineStore('account', () => {
+ // 状态
+ const token = ref(localStorage.getItem('auth_token') || null)
+ const profile = ref(null)
+ const devices = ref([])
+ const loading = ref(false)
+
+ // 计算属性
+ const isAuthenticated = computed(() => !!token.value)
+ const userName = computed(() => profile.value?.name || '')
+ const userAvatar = computed(() => profile.value?.avatarUrl || '')
+
+ // 方法
+ const setToken = (newToken) => {
+ token.value = newToken
+ if (newToken) {
+ localStorage.setItem('auth_token', newToken)
+ } else {
+ localStorage.removeItem('auth_token')
+ localStorage.removeItem('auth_provider')
+ }
+ }
+
+ const loadProfile = async () => {
+ if (!token.value) return
+
+ loading.value = true
+ try {
+ const response = await apiClient.getAccountProfile(token.value)
+ profile.value = response.data
+ } catch (error) {
+ console.error('Failed to load profile:', error)
+ // Token可能无效,清除
+ if (error.message.includes('401')) {
+ logout()
+ }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const loadDevices = async () => {
+ if (!token.value) return
+
+ try {
+ const response = await apiClient.getAccountDevices(token.value)
+ devices.value = response.data || []
+ return devices.value
+ } catch (error) {
+ console.error('Failed to load devices:', error)
+ return []
+ }
+ }
+
+ const bindDevice = async (deviceUuid) => {
+ if (!token.value) throw new Error('未登录')
+
+ const response = await apiClient.bindDevice(token.value, deviceUuid)
+ // 重新加载设备列表
+ await loadDevices()
+ return response
+ }
+
+ const unbindDevice = async (deviceUuid) => {
+ if (!token.value) throw new Error('未登录')
+
+ const response = await apiClient.unbindDevice(token.value, deviceUuid)
+ // 重新加载设备列表
+ await loadDevices()
+ return response
+ }
+
+ const login = async (authToken) => {
+ setToken(authToken)
+ await loadProfile()
+ await loadDevices()
+ }
+
+ const logout = () => {
+ token.value = null
+ profile.value = null
+ devices.value = []
+ localStorage.removeItem('auth_token')
+ localStorage.removeItem('auth_provider')
+ }
+
+ // 初始化时加载用户信息
+ if (token.value) {
+ loadProfile()
+ loadDevices()
+ }
+
+ return {
+ // 状态
+ token,
+ profile,
+ devices,
+ loading,
+ // 计算属性
+ isAuthenticated,
+ userName,
+ userAvatar,
+ // 方法
+ setToken,
+ loadProfile,
+ loadDevices,
+ bindDevice,
+ unbindDevice,
+ login,
+ logout,
+ }
+})
\ No newline at end of file
diff --git a/middleware/auth.js b/middleware/auth.js
deleted file mode 100644
index f35e1c1..0000000
--- a/middleware/auth.js
+++ /dev/null
@@ -1,521 +0,0 @@
-/**
- * Token认证中间件系统
- *
- * 本系统完全基于Token进行认证,不再支持UUID+密码的认证方式。
- *
- * ## 推荐使用的认证中间件:
- *
- * ### 1. 纯Token认证中间件(推荐)
- * - `tokenOnlyAuthMiddleware`: 完整的Token认证,要求设备匹配
- * - `tokenOnlyReadAuthMiddleware`: Token读取权限认证
- * - `tokenOnlyWriteAuthMiddleware`: Token写入权限认证
- * - `appTokenAuthMiddleware`: 应用Token认证,不要求设备匹配
- *
- * ### 2. 应用权限认证中间件
- * - `appReadAuthMiddleware`: 应用读取权限(Token + 权限前缀检查)
- * - `appWriteAuthMiddleware`: 应用写入权限(Token + 权限前缀检查)
- * - `appListAuthMiddleware`: 应用列表权限(Token + 键过滤)
- *
- * ## Token获取方式:
- * Token可通过以下三种方式提供:
- * 1. HTTP Header: `x-app-token: your-token`
- * 2. 查询参数: `?apptoken=your-token`
- * 3. 请求体: `{\"apptoken\": \"your-token\"}`
- *
- * ## 认证成功响应:
- * 认证成功后,中间件会在 `res.locals` 中设置:
- * - `device`: 设备信息
- * - `appInstall`: 应用安装信息
- * - `app`: 应用信息
- * - `filterKeys`: 键过滤函数(仅限应用权限中间件)
- *
- * ## 认证失败响应:
- * - 401: Token无效或不存在
- * - 403: 权限不足或设备不匹配
- * - 404: 设备或应用不存在
- */
-
-import { PrismaClient } from "@prisma/client";
-import errors from "../utils/errors.js";
-
-const prisma = new PrismaClient();
-
-// 全局可读键列表
-const GLOBAL_READABLE_KEYS = [
- "_info",
- "_check",
- "_hint",
- "_keys",
-];
-
-/**
- * 检查站点密钥
- */
-export const checkSiteKey = (req, res, next) => {
- const siteKey = req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
- const expectedSiteKey = process.env.SITE_KEY;
-
- if (expectedSiteKey && siteKey !== expectedSiteKey) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的站点密钥",
- });
- }
-
- next();
-};
-
-/**
- * 通过Token获取设备信息
- * @param {string} token - 应用安装Token
- * @returns {Promise
} 设备信息或null
- */
-export const getDeviceByToken = async (token) => {
- if (!token) {
- return null;
- }
-
- try {
- const appInstall = await prisma.appInstall.findUnique({
- where: { token },
- include: {
- device: true,
- app: true,
- },
- });
-
- return appInstall;
- } catch (error) {
- console.error("获取设备信息时出错:", error);
- return null;
- }
-};
-
-/**
- * 从请求中提取Token
- * @param {Object} req - Express请求对象
- * @returns {string|null} Token或null
- */
-const extractToken = (req) => {
- // 优先级:Header > Query > Body
- return (
- req.headers["x-app-token"] ||
- req.query.apptoken ||
- req.body?.apptoken ||
- null
- );
-};
-
-/**
- * 设备信息中间件(仅检查设备存在性,不进行认证)
- */
-export const deviceInfoMiddleware = async (req, res, next) => {
- try {
- const { deviceUuid,namespace } = req.params;
-
- if (!deviceUuid&&!namespace) {
- return res.status(400).json({
- statusCode: 400,
- message: "缺少命名空间参数",
- });
- }
-
- // 查找设备
- const device = await prisma.device.findUnique({
- where: { uuid: deviceUuid||namespace },
- });
-
- if (!device) {
- return res.status(404).json({
- statusCode: 404,
- message: "设备不存在",
- });
- }
-
- res.locals.device = device;
- next();
- } catch (error) {
- console.error("设备信息中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
-
-/**
- * 纯Token认证中间件(推荐使用)
- * 要求Token存在且对应的设备与请求的命名空间匹配
- */
-export const tokenOnlyAuthMiddleware = async (req, res, next) => {
- try {
- const token = extractToken(req);
- const { namespace } = req.params;
-
- if (!token) {
- return res.status(401).json({
- statusCode: 401,
- message: "缺少认证Token",
- });
- }
-
- const appInstall = await getDeviceByToken(token);
- if (!appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的Token",
- });
- }
-
- // 验证设备匹配
- if (namespace && appInstall.device.uuid !== namespace) {
- return res.status(403).json({
- statusCode: 403,
- message: "Token对应的设备与请求的命名空间不匹配",
- });
- }
-
- res.locals.device = appInstall.device;
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
- next();
- } catch (error) {
- console.error("Token认证中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
-
-/**
- * 纯Token读取认证中间件
- */
-export const tokenOnlyReadAuthMiddleware = async (req, res, next) => {
- try {
- const token = extractToken(req);
- const { namespace } = req.params;
-
- if (!token) {
- return res.status(401).json({
- statusCode: 401,
- message: "缺少认证Token",
- });
- }
-
- const appInstall = await getDeviceByToken(token);
- if (!appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的Token",
- });
- }
-
- // 验证设备匹配
- if (namespace && appInstall.device.uuid !== namespace) {
- return res.status(403).json({
- statusCode: 403,
- message: "Token对应的设备与请求的命名空间不匹配",
- });
- }
-
- // 检查读取权限
- if (!appInstall.permissions?.read) {
- return res.status(403).json({
- statusCode: 403,
- message: "无读取权限",
- });
- }
-
- res.locals.device = appInstall.device;
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
- next();
- } catch (error) {
- console.error("Token读取认证中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
-
-/**
- * 纯Token写入认证中间件
- */
-export const tokenOnlyWriteAuthMiddleware = async (req, res, next) => {
- try {
- const token = extractToken(req);
- const { namespace } = req.params;
-
- if (!token) {
- return res.status(401).json({
- statusCode: 401,
- message: "缺少认证Token",
- });
- }
-
- const appInstall = await getDeviceByToken(token);
- if (!appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的Token",
- });
- }
-
- // 验证设备匹配
- if (namespace && appInstall.device.uuid !== namespace) {
- return res.status(403).json({
- statusCode: 403,
- message: "Token对应的设备与请求的命名空间不匹配",
- });
- }
-
- // 检查写入权限
- if (!appInstall.permissions?.write) {
- return res.status(403).json({
- statusCode: 403,
- message: "无写入权限",
- });
- }
-
- res.locals.device = appInstall.device;
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
- next();
- } catch (error) {
- console.error("Token写入认证中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
-
-/**
- * 应用Token认证中间件
- * 不要求设备匹配,适用于应用级别的操作
- */
-export const appTokenAuthMiddleware = async (req, res, next) => {
- try {
- const token = extractToken(req);
-
- if (!token) {
- return res.status(401).json({
- statusCode: 401,
- message: "缺少应用Token",
- });
- }
-
- const appInstall = await getDeviceByToken(token);
- if (!appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的应用Token",
- });
- }
-
- res.locals.device = appInstall.device;
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
- next();
- } catch (error) {
- console.error("应用Token认证中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
-
-/**
- * 应用权限前缀检查中间件
- */
-export const appPrefixAuthMiddleware = (req, res, next) => {
- const { key } = req.params;
- const app = res.locals.app;
- const appInstall = res.locals.appInstall;
-
- if (!app || !appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "未认证的应用",
- });
- }
-
- // 检查是否为全局可读键
- if (GLOBAL_READABLE_KEYS.includes(key)) {
- return next();
- }
-
- // 检查权限前缀
- const permissionPrefix = app.permissionPrefix;
- if (!key.startsWith(permissionPrefix + ".")) {
- // 检查特殊权限
- const specialPermissions = appInstall.specialPermissions || [];
- const hasSpecialPermission = specialPermissions.some(permission =>
- key.startsWith(permission + ".") || key === permission
- );
-
- if (!hasSpecialPermission) {
- return res.status(403).json({
- statusCode: 403,
- message: `无权限访问键 '${key}'。需要权限前缀 '${permissionPrefix}.' 或特殊权限。`,
- });
- }
- }
-
- next();
-};
-
-/**
- * 应用读取权限中间件
- * 结合Token认证和权限前缀检查
- */
-export const appReadAuthMiddleware = async (req, res, next) => {
- // 先进行Token认证
- await new Promise((resolve, reject) => {
- tokenOnlyReadAuthMiddleware(req, res, (err) => {
- if (err) reject(err);
- else resolve();
- });
- }).catch(() => {
- return; // 错误已经在tokenOnlyReadAuthMiddleware中处理
- });
-
- // 如果Token认证失败,直接返回
- if (res.headersSent) {
- return;
- }
-
- // 进行权限前缀检查
- appPrefixAuthMiddleware(req, res, next);
-};
-
-/**
- * 应用写入权限中间件
- * 结合Token认证和权限前缀检查
- */
-export const appWriteAuthMiddleware = async (req, res, next) => {
- // 先进行Token认证
- await new Promise((resolve, reject) => {
- tokenOnlyWriteAuthMiddleware(req, res, (err) => {
- if (err) reject(err);
- else resolve();
- });
- }).catch(() => {
- return; // 错误已经在tokenOnlyWriteAuthMiddleware中处理
- });
-
- // 如果Token认证失败,直接返回
- if (res.headersSent) {
- return;
- }
-
- // 进行权限前缀检查
- appPrefixAuthMiddleware(req, res, next);
-};
-
-/**
- * 应用列表权限中间件
- * 用于过滤键列表,只显示应用有权限访问的键
- */
-export const appListAuthMiddleware = async (req, res, next) => {
- // 先进行Token认证
- await new Promise((resolve, reject) => {
- tokenOnlyReadAuthMiddleware(req, res, (err) => {
- if (err) reject(err);
- else resolve();
- });
- }).catch(() => {
- return; // 错误已经在tokenOnlyReadAuthMiddleware中处理
- });
-
- // 如果Token认证失败,直接返回
- if (res.headersSent) {
- return;
- }
-
- const app = res.locals.app;
- const appInstall = res.locals.appInstall;
-
- if (app && appInstall) {
- // 设置键过滤函数
- res.locals.filterKeys = (keys) => {
- const permissionPrefix = app.permissionPrefix;
- const specialPermissions = appInstall.specialPermissions || [];
-
- return keys.filter(key => {
- // 全局可读键
- if (GLOBAL_READABLE_KEYS.includes(key)) {
- return true;
- }
-
- // 权限前缀匹配
- if (key.startsWith(permissionPrefix + ".")) {
- return true;
- }
-
- // 特殊权限匹配
- return specialPermissions.some(permission =>
- key.startsWith(permission + ".") || key === permission
- );
- });
- };
- }
-
- next();
-};
-
-/**
- * Token认证中间件,并将设备UUID注入为命名空间
- *
- * 这个中间件专门用于处理那些URL中不包含 `:namespace` 参数的路由。
- * 它会从Token中解析出设备信息,然后将设备的UUID(即命名空间)
- * 注入到 `req.params.namespace` 中。
- *
- * 这使得后续的中间件(如权限检查中间件)和路由处理器可以统一
- * 从 `req.params.namespace` 获取命名空间,而无需关心它最初是
- * 来自URL还是来自Token。
- *
- * 认证成功后,除了注入 `req.params.namespace`,还会在 `res.locals` 中设置:
- * - `device`: 设备信息
- * - `appInstall`: 应用安装信息
- * - `app`: 应用信息
- */
-export const tokenAuthMiddleware = async (req, res, next) => {
- try {
- const token = extractToken(req);
-
- if (!token) {
- return res.status(401).json({
- statusCode: 401,
- message: "缺少认证Token",
- });
- }
-
- const appInstall = await getDeviceByToken(token);
- if (!appInstall) {
- return res.status(401).json({
- statusCode: 401,
- message: "无效的Token",
- });
- }
-
- // 核心逻辑:将设备UUID注入req.params.namespace
- req.params.namespace = appInstall.device.uuid;
-
- // 存储认证信息以供后续使用
- res.locals.device = appInstall.device;
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
-
- next();
- } catch (error) {
- console.error("Token认证与命名空间注入中间件错误:", error);
- return res.status(500).json({
- statusCode: 500,
- message: "服务器内部错误",
- });
- }
-};
diff --git a/middleware/device.js b/middleware/device.js
index f3c80fc..1623d4e 100644
--- a/middleware/device.js
+++ b/middleware/device.js
@@ -92,6 +92,8 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
* 从req.body.password获取密码
* 如果设备有密码但未提供或密码错误,则返回401错误
*
+ * 特殊规则:如果设备绑定了账户,且req.account存在且匹配,则跳过密码验证
+ *
* 使用方式:
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
*/
@@ -103,6 +105,11 @@ export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
}
+ // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
+ if (device.accountId && req.account && req.account.id === device.accountId) {
+ return next();
+ }
+
// 如果设备有密码,验证密码
if (device.password) {
if (!password) {
diff --git a/middleware/jwt-auth.js b/middleware/jwt-auth.js
new file mode 100644
index 0000000..646fbef
--- /dev/null
+++ b/middleware/jwt-auth.js
@@ -0,0 +1,54 @@
+/**
+ * 纯账户JWT认证中间件
+ *
+ * 只验证账户JWT是否正确,不需要设备上下文
+ * 适用于只需要账户验证的接口
+ */
+
+import { verifyToken } from "../utils/jwt.js";
+import { PrismaClient } from "@prisma/client";
+import errors from "../utils/errors.js";
+
+const prisma = new PrismaClient();
+
+/**
+ * 纯JWT认证中间件
+ * 只验证Bearer token并将账户信息存储到res.locals
+ */
+export const jwtAuth = async (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ return next(errors.createError(401, "需要提供有效的JWT token"));
+ }
+
+ const token = authHeader.substring(7);
+
+ // 验证JWT token
+ const decoded = verifyToken(token);
+
+ // 从数据库获取账户信息
+ const account = await prisma.account.findUnique({
+ where: { id: decoded.accountId },
+ });
+
+ if (!account) {
+ return next(errors.createError(401, "账户不存在"));
+ }
+
+ // 将账户信息存储到res.locals
+ res.locals.account = account;
+ next();
+ } catch (error) {
+ if (error.name === 'JsonWebTokenError') {
+ return next(errors.createError(401, "无效的JWT token"));
+ }
+
+ if (error.name === 'TokenExpiredError') {
+ return next(errors.createError(401, "JWT token已过期"));
+ }
+
+ return next(errors.createError(500, "认证过程出错"));
+ }
+};
\ No newline at end of file
diff --git a/middleware/kvTokenAuth.js b/middleware/kvTokenAuth.js
new file mode 100644
index 0000000..d1721d7
--- /dev/null
+++ b/middleware/kvTokenAuth.js
@@ -0,0 +1,66 @@
+/**
+ * KV接口专用Token认证中间件
+ *
+ * 仅验证app token,设置设备和应用信息到res.locals
+ * 适用于所有KV相关的接口
+ */
+
+import { PrismaClient } from "@prisma/client";
+import errors from "../utils/errors.js";
+
+const prisma = new PrismaClient();
+
+/**
+ * KV Token认证中间件
+ * 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals
+ */
+export const kvTokenAuth = async (req, res, next) => {
+ try {
+ // 从多种途径获取token
+ const token = extractToken(req);
+
+ if (!token) {
+ return next(errors.createError(401, "需要提供有效的token"));
+ }
+
+ // 查找token对应的应用安装信息
+ const appInstall = await prisma.appInstall.findUnique({
+ where: { token },
+ include: {
+ app: true,
+ device: true,
+ },
+ });
+
+ if (!appInstall) {
+ return next(errors.createError(401, "无效的token"));
+ }
+
+ // 将信息存储到res.locals供后续使用
+ res.locals.device = appInstall.device;
+ res.locals.app = appInstall.app;
+ res.locals.appInstall = appInstall;
+ res.locals.deviceId = appInstall.device.id;
+
+ next();
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 从请求中提取token
+ * 支持的方式:
+ * 1. Header: x-app-token
+ * 2. Query: token 或 apptoken
+ * 3. Body: token 或 apptoken
+ */
+function extractToken(req) {
+ return (
+ req.headers["x-app-token"] ||
+ req.query.token ||
+ req.query.apptoken ||
+ (req.body && req.body.token) ||
+ (req.body && req.body.apptoken)
+ );
+}
\ No newline at end of file
diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js
index 0d096eb..19a1d65 100644
--- a/middleware/rateLimiter.js
+++ b/middleware/rateLimiter.js
@@ -45,7 +45,7 @@ export const globalLimiter = rateLimit({
// API限速器
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
- limit: 50, // 每个IP在windowMs时间内最多允许50个请求
+ limit: 100, // 每个IP在windowMs时间内最多允许100个请求
standardHeaders: "draft-7",
legacyHeaders: false,
message: "API请求过于频繁,请稍后再试",
diff --git a/middleware/tokenAuth.js b/middleware/tokenAuth.js
deleted file mode 100644
index fc5f0ab..0000000
--- a/middleware/tokenAuth.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import { PrismaClient } from "@prisma/client";
-import { verifyDevicePassword } from "../utils/crypto.js";
-import errors from "../utils/errors.js";
-
-const prisma = new PrismaClient();
-
-/**
- * Token认证中间件
- *
- * 从请求中提取token,验证后将设备信息注入到res.locals
- * 同时将deviceId注入到req.params,以便后续路由使用
- *
- * Token可通过以下方式提供:
- * 1. Authorization header: Bearer
- * 2. Query参数: ?token=
- * 3. Body: {"token": ""}
- */
-export const tokenAuth = errors.catchAsync(async (req, res, next) => {
- let token;
-
- // 尝试从 headers, query, body 中获取 token
- if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) {
- token = req.headers.authorization.split(" ")[1];
- } else if (req.query.token) {
- token = req.query.token;
- } else if (req.body.token) {
- token = req.body.token;
- }
-
- if (!token) {
- return next(errors.createError(401, "未提供身份验证令牌"));
- }
-
- const appInstall = await prisma.appInstall.findUnique({
- where: { token },
- include: {
- app: true,
- device: true,
- },
- });
-
- if (!appInstall) {
- return next(errors.createError(401, "无效的身份验证令牌"));
- }
-
- // 将认证信息存储到res.locals
- res.locals.appInstall = appInstall;
- res.locals.app = appInstall.app;
- res.locals.device = appInstall.device;
- res.locals.deviceId = appInstall.device.id;
-
- // 将deviceId注入到req.params(向后兼容,某些路由可能需要namespace参数)
- req.params.namespace = appInstall.device.uuid;
- req.params.deviceId = appInstall.device.id;
-
- next();
-});
-
-/**
- * 写权限验证中间件
- *
- * 依赖于deviceMiddleware,必须在其后使用
- * 验证设备密码和写权限
- *
- * 逻辑:
- * 1. 如果设备没有设置密码,直接允许写入
- * 2. 如果设备设置了密码:
- * - 验证提供的密码是否正确
- * - 密码正确则允许写入
- * - 密码错误或未提供则拒绝写入
- *
- * 使用方式:
- * router.post('/path', deviceMiddleware, requireWriteAuth, handler)
- * router.put('/path', deviceMiddleware, requireWriteAuth, handler)
- * router.delete('/path', deviceMiddleware, requireWriteAuth, handler)
- */
-export const requireWriteAuth = errors.catchAsync(async (req, res, next) => {
- const device = res.locals.device;
-
- if (!device) {
- return next(errors.createError(500, "设备信息未加载,请确保使用了deviceMiddleware"));
- }
-
- // 如果设备没有设置密码,直接通过
- if (!device.password) {
- return next();
- }
-
- // 设备有密码,需要验证
- const providedPassword = req.body.password || req.query.password;
-
- if (!providedPassword) {
- return res.status(401).json({
- statusCode: 401,
- message: "此操作需要密码",
- passwordHint: device.passwordHint,
- });
- }
-
- // 验证密码
- const isValid = await verifyDevicePassword(providedPassword, device.password);
-
- if (!isValid) {
- return res.status(401).json({
- statusCode: 401,
- message: "密码错误",
- });
- }
-
- // 密码正确,继续
- next();
-});
\ No newline at end of file
diff --git a/middleware/uuidAuth.js b/middleware/uuidAuth.js
new file mode 100644
index 0000000..bc6aef5
--- /dev/null
+++ b/middleware/uuidAuth.js
@@ -0,0 +1,131 @@
+/**
+ * UUID+密码/JWT混合认证中间件
+ *
+ * 1. 必须提供UUID,读取设备信息并存储到res.locals
+ * 2. 验证密码或账户JWT(二选一)
+ * 3. 适用于需要设备上下文的接口
+ */
+
+import { PrismaClient } from "@prisma/client";
+import errors from "../utils/errors.js";
+import { verifyToken as verifyAccountJWT } from "../utils/jwt.js";
+import { verifyDevicePassword } from "../utils/crypto.js";
+
+const prisma = new PrismaClient();
+
+/**
+ * UUID+密码/JWT混合认证中间件
+ */
+export const uuidAuth = async (req, res, next) => {
+ try {
+ // 1. 获取UUID(必需)
+ const uuid = extractUuid(req);
+ if (!uuid) {
+ return next(errors.createError(400, "需要提供设备UUID"));
+ }
+
+ // 2. 查找设备并存储到locals
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ });
+
+ if (!device) {
+ return next(errors.createError(404, "设备不存在"));
+ }
+
+ // 存储设备信息到locals
+ res.locals.device = device;
+ res.locals.deviceId = device.id;
+
+ // 3. 验证密码或JWT(二选一)
+ const password = extractPassword(req);
+ const jwt = extractJWT(req);
+
+ if (jwt) {
+ // 验证账户JWT
+ try {
+ const accountPayload = await verifyAccountJWT(jwt);
+ const account = await prisma.account.findUnique({
+ where: { id: accountPayload.accountId },
+ include: {
+ devices: {
+ where: { uuid },
+ select: { id: true }
+ }
+ }
+ });
+
+ if (!account) {
+ return next(errors.createError(401, "账户不存在"));
+ }
+
+ // 检查设备是否绑定到此账户
+ if (account.devices.length === 0) {
+ return next(errors.createError(403, "设备未绑定到此账户"));
+ }
+
+ res.locals.account = account;
+ res.locals.isAccountOwner = true; // 标记为账户拥有者
+ return next();
+ } catch (error) {
+ return next(errors.createError(401, "无效的JWT token"));
+ }
+ } else if (password) {
+ // 验证设备密码
+ if (!device.password) {
+ return next(); // 如果设备未设置密码,允许无密码访问
+ }
+
+ const isValid = await verifyDevicePassword(password, device.password);
+ if (!isValid) {
+ return next(errors.createError(401, "密码错误"));
+ }
+
+ return next();
+ } else {
+ // 如果设备未设置密码,允许无密码访问
+ if (!device.password) {
+ return next();
+ }
+ return next(errors.createError(401, "需要提供密码或JWT token"));
+ }
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 从请求中提取UUID
+ */
+function extractUuid(req) {
+ return (
+ req.headers["x-device-uuid"] ||
+ req.query.uuid ||
+ req.params.uuid ||
+ req.params.deviceUuid ||
+ (req.body && req.body.uuid) ||
+ (req.body && req.body.deviceUuid)
+ );
+}
+
+/**
+ * 从请求中提取密码
+ */
+function extractPassword(req) {
+ return (
+ req.headers["x-device-password"] ||
+ req.query.password ||
+ req.query.currentPassword
+ );
+}
+
+/**
+ * 从请求中提取JWT
+ */
+function extractJWT(req) {
+ const authHeader = req.headers.authorization;
+ if (authHeader && authHeader.startsWith("Bearer ")) {
+ return authHeader.substring(7);
+ }
+ return null;
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index af2c370..430b891 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"express-rate-limit": "^7.5.0",
"http-errors": "~2.0.0",
"js-base64": "^3.7.7",
+ "jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0",
"uuid": "^11.1.0"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fb51069..946659b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,6 +65,9 @@ importers:
js-base64:
specifier: ^3.7.7
version: 3.7.7
+ jsonwebtoken:
+ specifier: ^9.0.2
+ version: 9.0.2
morgan:
specifier: ~1.10.0
version: 1.10.0
@@ -1377,6 +1380,9 @@ packages:
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -1535,6 +1541,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -1794,6 +1803,16 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonwebtoken@9.0.2:
+ resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
+ engines: {node: '>=12', npm: '>=6'}
+
+ jwa@1.4.2:
+ resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
+
+ jws@3.2.2:
+ resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -1865,6 +1884,27 @@ packages:
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
@@ -2176,6 +2216,11 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
+ semver@7.7.2:
+ resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
@@ -3834,6 +3879,8 @@ snapshots:
dependencies:
balanced-match: 1.0.2
+ buffer-equal-constant-time@1.0.1: {}
+
bytes@3.1.2: {}
c12@3.1.0:
@@ -3966,6 +4013,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
ee-first@1.1.1: {}
effect@3.16.12:
@@ -4261,6 +4312,30 @@ snapshots:
json5@2.2.3: {}
+ jsonwebtoken@9.0.2:
+ dependencies:
+ jws: 3.2.2
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.2
+
+ jwa@1.4.2:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@3.2.2:
+ dependencies:
+ jwa: 1.4.2
+ safe-buffer: 5.2.1
+
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -4314,6 +4389,20 @@ snapshots:
lodash.camelcase@4.3.0: {}
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
+ lodash.once@4.1.1: {}
+
long@5.3.2: {}
lucide-react@0.544.0(react@19.2.0):
@@ -4646,6 +4735,8 @@ snapshots:
scule@1.3.0: {}
+ semver@7.7.2: {}
+
send@1.2.0:
dependencies:
debug: 4.4.1
diff --git a/prisma/migrations/20251002074755_update/migration.sql b/prisma/migrations/20251002074755_update/migration.sql
new file mode 100644
index 0000000..0321915
--- /dev/null
+++ b/prisma/migrations/20251002074755_update/migration.sql
@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE `Account` (
+ `id` VARCHAR(191) NOT NULL,
+ `provider` VARCHAR(191) NOT NULL,
+ `providerId` VARCHAR(191) NOT NULL,
+ `email` VARCHAR(191) NULL,
+ `name` VARCHAR(191) NULL,
+ `avatarUrl` VARCHAR(191) NULL,
+ `providerData` JSON NULL,
+ `accessToken` VARCHAR(191) NOT NULL,
+ `refreshToken` VARCHAR(191) NULL,
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updatedAt` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `Account_accessToken_key`(`accessToken`),
+ UNIQUE INDEX `Account_provider_providerId_key`(`provider`, `providerId`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- AddForeignKey
+ALTER TABLE `Device` ADD CONSTRAINT `Device_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `Account`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2e56c9a..b4c51e4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -21,17 +21,37 @@ model KVStore {
@@id([deviceId, key])
}
+model Account {
+ id String @id @default(cuid())
+ provider String // OAuth提供者 (例如: google, github, gitlab等)
+ providerId String // 提供者返回的用户唯一ID
+ email String? // 用户邮箱
+ name String? // 用户名称
+ avatarUrl String? // 用户头像URL
+ providerData Json? // OAuth提供者返回的完整信息
+ accessToken String @unique // 账户访问令牌
+ refreshToken String? // OAuth refresh token (如果提供者支持)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // 关联的设备
+ devices Device[]
+
+ @@unique([provider, providerId]) // 确保同一提供者的用户ID唯一
+}
+
model Device {
id Int @id @default(autoincrement())
uuid String @unique // 设备的唯一标识符
name String?
- accountId String? // 关联的社区账户ID,暂不添加相关代码
+ accountId String? // 关联的账户ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
password String?
passwordHint String?
- // 关联的应用安装记录
+ // 关联关系
+ account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
appInstalls AppInstall[]
kvStore KVStore[] // 设备相关的KV存储
}
diff --git a/public/auth-error.html b/public/auth-error.html
new file mode 100644
index 0000000..484c20a
--- /dev/null
+++ b/public/auth-error.html
@@ -0,0 +1,166 @@
+
+
+
+
+
+ 登录失败
+
+
+
+
+
+
+
登录失败
+
+
+
+
返回重试
+
关闭窗口
+
+
+ 如果问题持续存在,请检查:
+ • OAuth应用配置是否正确
+ • 回调URL是否已添加到OAuth应用中
+ • 环境变量是否配置正确
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/auth-success.html b/public/auth-success.html
new file mode 100644
index 0000000..43edce7
--- /dev/null
+++ b/public/auth-success.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+ 登录成功
+
+
+
+
+
+
+
登录成功
+
OAuth Provider
+
+
+
+
复制令牌
+
+
+ 窗口将在 10 秒后自动关闭
+
+
+
+
+
+
\ No newline at end of file
diff --git a/routes/accounts.js b/routes/accounts.js
new file mode 100644
index 0000000..2628fe2
--- /dev/null
+++ b/routes/accounts.js
@@ -0,0 +1,548 @@
+import { Router } from "express";
+import { PrismaClient } from "@prisma/client";
+import crypto from "crypto";
+import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
+import { generateAccountToken, verifyToken } from "../utils/jwt.js";
+import { jwtAuth } from "../middleware/jwt-auth.js";
+
+const router = Router();
+const prisma = new PrismaClient();
+
+// 存储OAuth state,防止CSRF攻击(生产环境应使用Redis等)
+const oauthStates = new Map();
+
+/**
+ * 生成安全的访问令牌
+ */
+function generateAccessToken() {
+ return crypto.randomBytes(32).toString("hex");
+}
+
+/**
+ * 获取支持的OAuth提供者列表
+ * GET /accounts/oauth/providers
+ */
+router.get("/oauth/providers", (req, res) => {
+ const providers = [];
+
+ for (const [key, config] of Object.entries(oauthProviders)) {
+ // 只返回已配置的提供者
+ if (config.clientId && config.clientSecret) {
+ providers.push({
+ id: key,
+ name: config.name,
+ icon: config.icon,
+ color: config.color,
+ description: config.description,
+ authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL
+ });
+ }
+ }
+
+ res.json({
+ success: true,
+ data: providers,
+ });
+});
+
+/**
+ * 发起OAuth认证
+ * GET /accounts/oauth/:provider
+ *
+ * Query参数:
+ * - redirect_uri: 前端回调地址(可选)
+ */
+router.get("/oauth/:provider", (req, res) => {
+ const { provider } = req.params;
+ const { redirect_uri } = req.query;
+
+ const providerConfig = oauthProviders[provider];
+ if (!providerConfig) {
+ return res.status(400).json({
+ success: false,
+ message: `不支持的OAuth提供者: ${provider}`,
+ });
+ }
+
+ if (!providerConfig.clientId || !providerConfig.clientSecret) {
+ return res.status(500).json({
+ success: false,
+ message: `OAuth提供者 ${provider} 未配置`,
+ });
+ }
+
+ // 生成state参数
+ const state = generateState();
+
+ // 保存state和redirect_uri(5分钟过期)
+ oauthStates.set(state, {
+ provider,
+ redirect_uri,
+ timestamp: Date.now(),
+ });
+
+ // 清理过期的state(超过5分钟)
+ for (const [key, value] of oauthStates.entries()) {
+ if (Date.now() - value.timestamp > 5 * 60 * 1000) {
+ oauthStates.delete(key);
+ }
+ }
+
+ // 构建授权URL
+ const params = new URLSearchParams({
+ client_id: providerConfig.clientId,
+ redirect_uri: getCallbackURL(provider),
+ scope: providerConfig.scope,
+ state: state,
+ response_type: "code",
+ });
+
+ // Google需要额外的参数
+ if (provider === "google") {
+ params.append("access_type", "offline");
+ params.append("prompt", "consent");
+ }
+
+ const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`;
+
+ // 重定向到OAuth提供者
+ res.redirect(authUrl);
+});
+
+/**
+ * OAuth回调处理
+ * GET /accounts/oauth/:provider/callback
+ */
+router.get("/oauth/:provider/callback", async (req, res) => {
+ const { provider } = req.params;
+ const { code, state, error } = req.query;
+
+ // 如果OAuth提供者返回错误
+ if (error) {
+ const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
+ const errorUrl = new URL(frontendBaseUrl);
+ errorUrl.searchParams.append("error", error);
+ errorUrl.searchParams.append("provider", provider);
+ errorUrl.searchParams.append("success", "false");
+ return res.redirect(errorUrl.toString());
+ }
+
+ // 验证state
+ const stateData = oauthStates.get(state);
+ if (!stateData || stateData.provider !== provider) {
+ const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
+ const errorUrl = new URL(frontendBaseUrl);
+ errorUrl.searchParams.append("error", "invalid_state");
+ errorUrl.searchParams.append("provider", provider);
+ errorUrl.searchParams.append("success", "false");
+ return res.redirect(errorUrl.toString());
+ }
+
+ // 删除已使用的state
+ oauthStates.delete(state);
+
+ const providerConfig = oauthProviders[provider];
+
+ try {
+ // 1. 使用授权码换取访问令牌
+ const tokenResponse = await fetch(providerConfig.tokenURL, {
+ method: "POST",
+ headers: {
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: providerConfig.clientId,
+ client_secret: providerConfig.clientSecret,
+ code: code,
+ grant_type: "authorization_code",
+ redirect_uri: getCallbackURL(provider),
+ }),
+ });
+
+ const tokenData = await tokenResponse.json();
+
+ if (!tokenData.access_token) {
+ throw new Error("获取访问令牌失败");
+ }
+
+ // 2. 使用访问令牌获取用户信息
+ const userResponse = await fetch(providerConfig.userInfoURL, {
+ headers: {
+ "Authorization": `Bearer ${tokenData.access_token}`,
+ "Accept": "application/json",
+ },
+ });
+
+ const userData = await userResponse.json();
+
+ // 3. 标准化用户数据(不同提供者返回的字段不同)
+ let normalizedUser = {};
+
+ if (provider === "github") {
+ normalizedUser = {
+ providerId: String(userData.id),
+ email: userData.email,
+ name: userData.name || userData.login,
+ avatarUrl: userData.avatar_url,
+ };
+ } else if (provider === "zerocat") {
+ normalizedUser = {
+ providerId: userData.openid,
+ email: userData.email_verified ? userData.email : null,
+ name: userData.nickname || userData.username,
+ avatarUrl: userData.avatar,
+ };
+ }
+
+ // 4. 查找或创建账户
+ let account = await prisma.account.findUnique({
+ where: {
+ provider_providerId: {
+ provider,
+ providerId: normalizedUser.providerId,
+ },
+ },
+ });
+
+ if (account) {
+ // 更新账户信息
+ account = await prisma.account.update({
+ where: { id: account.id },
+ data: {
+ email: normalizedUser.email || account.email,
+ name: normalizedUser.name || account.name,
+ avatarUrl: normalizedUser.avatarUrl || account.avatarUrl,
+ providerData: userData,
+ refreshToken: tokenData.refresh_token || account.refreshToken,
+ updatedAt: new Date(),
+ },
+ });
+ } else {
+ // 创建新账户
+ const accessToken = generateAccessToken();
+ account = await prisma.account.create({
+ data: {
+ provider,
+ providerId: normalizedUser.providerId,
+ email: normalizedUser.email,
+ name: normalizedUser.name,
+ avatarUrl: normalizedUser.avatarUrl,
+ providerData: userData,
+ accessToken,
+ refreshToken: tokenData.refresh_token,
+ },
+ });
+ }
+
+ // 5. 生成JWT token
+ const jwtToken = generateAccountToken(account);
+
+ // 6. 重定向到前端根路径,携带JWT token
+ const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
+ const callbackUrl = new URL(frontendBaseUrl);
+ callbackUrl.searchParams.append("token", jwtToken);
+ callbackUrl.searchParams.append("provider", provider);
+ callbackUrl.searchParams.append("success", "true");
+
+ res.redirect(callbackUrl.toString());
+
+ } catch (error) {
+ console.error(`OAuth回调处理失败 [${provider}]:`, error);
+
+ // 重定向到前端根路径,携带错误信息
+ const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
+ const errorUrl = new URL(frontendBaseUrl);
+ errorUrl.searchParams.append("error", error.message);
+ errorUrl.searchParams.append("provider", provider);
+ errorUrl.searchParams.append("success", "false");
+
+ res.redirect(errorUrl.toString());
+ }
+});
+
+/**
+ * 获取账户信息
+ * GET /api/accounts/profile
+ *
+ * Headers:
+ * Authorization: Bearer
+ */
+router.get("/profile", jwtAuth, async (req, res, next) => {
+ try {
+ const accountContext = res.locals.account;
+
+ const account = await prisma.account.findUnique({
+ where: { id: accountContext.id },
+ include: {
+ devices: {
+ select: {
+ id: true,
+ uuid: true,
+ name: true,
+ createdAt: true,
+ },
+ },
+ },
+ });
+
+ res.json({
+ success: true,
+ data: {
+ id: account.id,
+ provider: account.provider,
+ email: account.email,
+ name: account.name,
+ avatarUrl: account.avatarUrl,
+ devices: account.devices,
+ createdAt: account.createdAt,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+/**
+ * 绑定设备到账户
+ * POST /api/accounts/devices/bind
+ *
+ * Headers:
+ * Authorization: Bearer
+ *
+ * Body:
+ * {
+ * uuid: string // 设备UUID
+ * }
+ */
+router.post("/devices/bind", jwtAuth, async (req, res, next) => {
+ try {
+ const accountContext = res.locals.account;
+ const { uuid } = req.body;
+
+ if (!uuid) {
+ return res.status(400).json({
+ success: false,
+ message: "缺少设备UUID",
+ });
+ }
+
+ // 查找设备
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ });
+
+ if (!device) {
+ return res.status(404).json({
+ success: false,
+ message: "设备不存在 #1",
+ });
+ }
+
+ // 检查设备是否已绑定其他账户
+ if (device.accountId && device.accountId !== accountContext.id) {
+ return res.status(400).json({
+ success: false,
+ message: "设备已绑定其他账户",
+ });
+ }
+
+ // 绑定设备到账户
+ const updatedDevice = await prisma.device.update({
+ where: { uuid },
+ data: {
+ accountId: accountContext.id,
+ },
+ });
+
+ res.json({
+ success: true,
+ message: "设备绑定成功",
+ data: {
+ deviceId: updatedDevice.id,
+ uuid: updatedDevice.uuid,
+ name: updatedDevice.name,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+/**
+ * 解绑设备
+ * POST /api/accounts/devices/unbind
+ *
+ * Headers:
+ * Authorization: Bearer
+ *
+ * Body:
+ * {
+ * uuid: string // 设备UUID(单个解绑)
+ * uuids: string[] // 设备UUID数组(批量解绑)
+ * }
+ */
+router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
+ try {
+ const accountContext = res.locals.account;
+ const { uuid, uuids } = req.body;
+
+ // 支持单个解绑或批量解绑
+ const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
+
+ if (uuidsToUnbind.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: "请提供要解绑的设备UUID",
+ });
+ }
+
+ // 查找所有设备并验证所有权
+ const devices = await prisma.device.findMany({
+ where: {
+ uuid: { in: uuidsToUnbind },
+ },
+ });
+
+ // 检查是否有不存在的设备
+ if (devices.length !== uuidsToUnbind.length) {
+ const foundUuids = devices.map(d => d.uuid);
+ const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u));
+ return res.status(404).json({
+ success: false,
+ message: `以下设备不存在: ${notFoundUuids.join(', ')}`,
+ });
+ }
+
+ // 检查所有权
+ const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id);
+ if (unauthorizedDevices.length > 0) {
+ return res.status(403).json({
+ success: false,
+ message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`,
+ });
+ }
+
+ // 批量解绑设备
+ await prisma.device.updateMany({
+ where: {
+ uuid: { in: uuidsToUnbind },
+ accountId: accountContext.id,
+ },
+ data: {
+ accountId: null,
+ },
+ });
+
+ res.json({
+ success: true,
+ message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`,
+ unboundCount: uuidsToUnbind.length,
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+/**
+ * 获取账户绑定的设备列表
+ * GET /api/accounts/devices
+ *
+ * Headers:
+ * Authorization: Bearer
+ */
+router.get("/devices", jwtAuth, async (req, res, next) => {
+ try {
+ const accountContext = res.locals.account;
+ // 获取账户的设备列表
+ const account = await prisma.account.findUnique({
+ where: { id: accountContext.id },
+ include: {
+ devices: {
+ select: {
+ id: true,
+ uuid: true,
+ name: true,
+ createdAt: true,
+ updatedAt: true,
+ appInstalls: {
+ include: {
+ app: {
+ select: {
+ id: true,
+ name: true,
+ iconHash: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ res.json({
+ success: true,
+ data: account.devices,
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+/**
+ * 根据设备UUID获取账户公开信息
+ * GET /accounts/device/:uuid/account
+ *
+ * 无需认证,返回公开信息
+ */
+router.get("/device/:uuid/account", async (req, res, next) => {
+ try {
+ const { uuid } = req.params;
+
+ // 查找设备及其关联的账户
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ include: {
+ account: {
+ select: {
+ id: true,
+ provider: true,
+ name: true,
+ avatarUrl: true,
+ createdAt: true,
+ },
+ },
+ },
+ });
+
+ if (!device) {
+ return res.status(404).json({
+ success: false,
+ message: "设备不存在 #2",
+ });
+ }
+
+ if (!device.account) {
+ return res.json({
+ success: true,
+ data: null, // 设备未绑定账户
+ });
+ }
+
+ res.json({
+ success: true,
+ data: {
+ id: device.account.id,
+ provider: device.account.provider,
+ name: device.account.name,
+ avatarUrl: device.account.avatarUrl,
+ bindTime: device.updatedAt, // 绑定时间
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/routes/apps.js b/routes/apps.js
index aced87c..cbf408f 100644
--- a/routes/apps.js
+++ b/routes/apps.js
@@ -1,156 +1,176 @@
import { Router } from "express";
const router = Router();
-import {
- deviceMiddleware,
- passwordMiddleware,
- deviceInfoMiddleware,
-} from "../middleware/device.js";
-import { checkSiteKey } from "../middleware/auth.js";
+import { uuidAuth } from "../middleware/uuidAuth.js";
+import { jwtAuth } from "../middleware/jwt-auth.js";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import errors from "../utils/errors.js";
-import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
-router.use(checkSiteKey);
+/**
+ * GET /apps/devices/:uuid/apps
+ * 获取设备安装的应用列表 (公开接口,无需认证)
+ */
+router.get(
+ "/devices/:uuid/apps",
+ errors.catchAsync(async (req, res, next) => {
+ const { uuid } = req.params;
+
+ // 查找设备
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ });
+
+ if (!device) {
+ return next(errors.createError(404, "设备不存在"));
+ }
+
+ const installations = await prisma.appInstall.findMany({
+ where: { deviceId: device.id },
+ include: { app: true },
+ });
+
+ const apps = installations.map(install => ({
+ id: install.app.id,
+ name: install.app.name,
+ description: install.app.description,
+ token: install.token,
+ installedAt: install.createdAt,
+ }));
+
+ return res.json({
+ success: true,
+ apps,
+ });
+ })
+);
+
+/**
+ * POST /apps/devices/:uuid/install/:appId
+ * 为设备安装应用 (需要UUID认证)
+ */
+router.post(
+ "/devices/:uuid/install/:appId",
+ uuidAuth,
+ errors.catchAsync(async (req, res, next) => {
+ const device = res.locals.device;
+ const { appId } = req.params;
+ const { note } = req.body;
+
+ // 检查应用是否存在
+ const app = await prisma.app.findUnique({
+ where: { id: parseInt(appId) },
+ });
+
+ if (!app) {
+ return next(errors.createError(404, "应用不存在"));
+ }
+
+
+ // 生成token
+ const token = crypto.randomBytes(32).toString("hex");
+
+ // 创建安装记录
+ const installation = await prisma.appInstall.create({
+ data: {
+ deviceId: device.id,
+ appId: app.id,
+ token,
+ note: note || null,
+ },
+ });
+
+ return res.status(201).json({
+ id: installation.id,
+ appId: app.id,
+ appName: app.name,
+ token: installation.token,
+ note: installation.note,
+ installedAt: installation.createdAt,
+ });
+ })
+);
+
+/**
+ * DELETE /apps/devices/:uuid/uninstall/:installId
+ * 卸载设备应用 (需要UUID认证)
+ */
+router.delete(
+ "/devices/:uuid/uninstall/:installId",
+ uuidAuth,
+ errors.catchAsync(async (req, res, next) => {
+ const device = res.locals.device;
+ const { installId } = req.params;
+
+ const installation = await prisma.appInstall.findUnique({
+ where: { id: installId },
+ });
+
+ if (!installation) {
+ return next(errors.createError(404, "应用未安装"));
+ }
+
+ // 确保安装记录属于当前设备
+ if (installation.deviceId !== device.id) {
+ return next(errors.createError(403, "无权操作此安装记录"));
+ }
+
+ await prisma.appInstall.delete({
+ where: { id: installation.id },
+ });
+
+ return res.status(204).end();
+ })
+);
/**
* GET /apps
- * 获取应用列表
+ * 获取所有可用应用列表
*/
router.get(
"/",
errors.catchAsync(async (req, res) => {
- const { limit = 20, skip = 0, search } = req.query;
-
- const where = search
- ? {
- OR: [
- { name: { contains: search } },
- { description: { contains: search } },
- { developerName: { contains: search } },
- ],
- }
- : {};
-
- const [apps, total] = await Promise.all([
- prisma.app.findMany({
- where,
- take: parseInt(limit),
- skip: parseInt(skip),
- orderBy: { createdAt: "desc" },
- }),
- prisma.app.count({ where }),
- ]);
-
- res.json({
- apps,
- total,
- limit: parseInt(limit),
- skip: parseInt(skip),
- });
- })
-);
-
-/**
- * GET /apps/:id
- * 获取单个应用详情
- */
-router.get(
- "/:id",
- errors.catchAsync(async (req, res) => {
- const { id } = req.params;
-
- const app = await prisma.app.findUnique({
- where: { id: parseInt(id) },
- });
-
- if (!app) {
- return res.status(404).json({
- statusCode: 404,
- message: "应用不存在",
- });
- }
-
- res.json(app);
- })
-);
-
-/**
- * POST /apps/:id/authorize
- * 为应用授权获取token
- *
- * 使用统一的设备中间件:
- * 1. deviceMiddleware - 自动获取或创建设备
- * 2. passwordMiddleware - 验证密码(如果设备有密码)
- *
- * 请求体:
- * {
- * "deviceUuid": "设备UUID",
- * "password": "设备密码(如果设备有密码则必须提供)",
- * "note": "备注信息" // 可选
- * }
- */
-router.post(
- "/:id/authorize",
- deviceMiddleware,
- passwordMiddleware,
- errors.catchAsync(async (req, res) => {
- const { id: appId } = req.params;
- const { note } = req.body;
- const device = res.locals.device;
-
- // 检查应用是否存在
- const app = await prisma.app.findUnique({
- where: { id: Number(appId) },
- });
-
- if (!app) {
- return res.status(404).json({
- statusCode: 404,
- message: "应用不存在",
- });
- }
-
- // 生成token
- const randomBytes = crypto.randomBytes(32);
- const tokenData = `${appId}-${device.uuid}-${Date.now()}-${randomBytes.toString('hex')}`;
- const token = crypto.createHash("sha256").update(tokenData).digest("hex");
-
- // 创建应用安装记录
- const appInstall = await prisma.appInstall.create({
- data: {
- deviceId: device.id,
- appId: Number(appId),
- token,
- note: note || "授权访问",
+ const apps = await prisma.app.findMany({
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ createdAt: true,
},
});
- res.status(200).json({
- token: appInstall.token,
- appId: Number(appId),
- appName: app.name,
- deviceUuid: device.uuid,
- deviceName: device.name,
- note: appInstall.note,
- authorizedAt: appInstall.installedAt,
-
+ return res.json({
+ success: true,
+ apps,
});
})
);
+
/**
- * GET /apps/devices/:deviceUuid/tokens
- * 获取设备上的所有授权token
+ * GET /apps/tokens
+ * 获取设备的token列表 (需要设备UUID)
*/
router.get(
- "/devices/:deviceUuid/tokens",
- deviceInfoMiddleware,
- errors.catchAsync(async (req, res) => {
- const device = res.locals.device;
+ "/tokens",
+ errors.catchAsync(async (req, res, next) => {
+ const { uuid } = req.query;
+ if (!uuid) {
+ return next(errors.createError(400, "需要提供设备UUID"));
+ }
+
+ // 查找设备
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ });
+
+ if (!device) {
+ return next(errors.createError(404, "设备不存在"));
+ }
+
+ // 获取该设备的所有应用安装记录(即token)
const installations = await prisma.appInstall.findMany({
where: { deviceId: device.id },
include: {
@@ -159,236 +179,39 @@ router.get(
id: true,
name: true,
description: true,
- developerName: true,
- iconHash: true,
- repositoryUrl: true,
- createdAt: true,
- updatedAt: true,
},
},
},
- orderBy: { installedAt: "desc" },
+ orderBy: { installedAt: 'desc' },
});
- res.json({
- deviceUuid: device.uuid,
- deviceName: device.name,
- tokens: installations.map(install => ({
- id: install.id,
- token: install.token,
- app: install.app,
- note: install.note,
- installedAt: install.installedAt,
- updatedAt: install.updatedAt,
- createdAt: install.createdAt,
- repositoryUrl: install.app.repositoryUrl,
- })),
- total: installations.length,
- });
- })
-);
+ const tokens = installations.map(install => ({
+ id: install.id, // 安装记录ID
+ token: install.token,
+ appId: install.app.id,
+ appName: install.app.name,
+ appDescription: install.app.description,
+ installedAt: install.installedAt,
+ note: install.note,
+ }));
-/**
- * DELETE /apps/tokens/:token
- * 撤销特定token
- */
-router.delete(
- "/tokens/:token",
- errors.catchAsync(async (req, res) => {
- const { token } = req.params;
-
- const result = await prisma.appInstall.deleteMany({
- where: { token },
- });
-
- if (result.count === 0) {
- return res.status(404).json({
- statusCode: 404,
- message: "Token不存在",
- });
- }
-
- res.status(204).end();
- })
-);
-
-/**
- * GET /apps/:id/installations
- * 获取应用的所有安装记录
- */
-router.get(
- "/:id/installations",
- errors.catchAsync(async (req, res) => {
- const { id: appId } = req.params;
- const { limit = 20, skip = 0 } = req.query;
-
- const [installations, total] = await Promise.all([
- prisma.appInstall.findMany({
- where: { appId: Number(appId) },
- include: {
- device: {
- select: {
- uuid: true,
- name: true,
- },
- },
- },
- take: parseInt(limit),
- skip: parseInt(skip),
- orderBy: { installedAt: "desc" },
- }),
- prisma.appInstall.count({ where: { appId: Number(appId) } }),
- ]);
-
- res.json({
- appId: Number(appId),
- installations: installations.map(install => ({
- id: install.id,
- token: install.token,
- device: install.device,
- note: install.note,
- installedAt: install.installedAt,
- updatedAt: install.updatedAt,
- })),
- total,
- limit: parseInt(limit),
- skip: parseInt(skip),
- });
- })
-);
-
-/**
- * PUT /apps/devices/:deviceUuid/password
- * 设置或更新设备密码
- *
- * Request Body:
- * {
- * "newPassword": "新密码",
- * "passwordHint": "密码提示(可选)",
- * "currentPassword": "当前密码(如果已设置密码则必须提供)"
- * }
- */
-router.put(
- "/devices/:deviceUuid/password",
- deviceInfoMiddleware,
- errors.catchAsync(async (req, res, next) => {
- const { newPassword, passwordHint, currentPassword } = req.body;
- const device = res.locals.device;
-
- if (!newPassword) {
- return next(errors.createError(400, "请提供新密码"));
- }
-
- // 如果设备已有密码,必须先验证当前密码
- if (device.password) {
- if (!currentPassword) {
- return next(errors.createError(401, "设备已设置密码,请提供当前密码"));
- }
-
- const isValid = await verifyDevicePassword(currentPassword, device.password);
- if (!isValid) {
- return next(errors.createError(401, "当前密码错误"));
- }
- }
-
- // 哈希新密码
- const hashedPassword = await hashPassword(newPassword);
-
- // 更新设备密码
- await prisma.device.update({
- where: { id: device.id },
- data: {
- password: hashedPassword,
- passwordHint: passwordHint || device.passwordHint,
- },
- });
-
- res.json({
+ return res.json({
success: true,
- message: device.password ? "密码已更新" : "密码已设置",
- deviceUuid: device.uuid,
+ tokens,
+ deviceUuid: uuid,
});
})
);
-
-/**
- * DELETE /apps/devices/:deviceUuid/password
- * 删除设备密码
- *
- * Request Body:
- * {
- * "password": "当前密码(必须)"
- * }
- */
-router.delete(
- "/devices/:deviceUuid/password",
- deviceInfoMiddleware,
+router.get("/info/:appid",
errors.catchAsync(async (req, res, next) => {
- const { password } = req.body;
- const device = res.locals.device;
-
- if (!device.password) {
- return next(errors.createError(400, "设备未设置密码"));
- }
-
- if (!password) {
- return next(errors.createError(401, "请提供当前密码"));
- }
-
- // 验证密码
- const isValid = await verifyDevicePassword(password, device.password);
- if (!isValid) {
- return next(errors.createError(401, "密码错误"));
- }
-
- // 删除密码
- await prisma.device.update({
- where: { id: device.id },
- data: {
- password: null,
- passwordHint: null,
- },
- });
-
- res.json({
- success: true,
- message: "密码已删除",
- deviceUuid: device.uuid,
+ const { appid } = req.params;
+ const app = await prisma.app.findUnique({
+ where: { id: parseInt(appid) },
});
+ if (!app) {
+ return next(errors.createError(404, "应用不存在"));
+ }
+ return res.json(app);
})
);
-
-/**
- * POST /apps/devices/:deviceUuid/password/verify
- * 验证设备密码
- *
- * Request Body:
- * {
- * "password": "待验证的密码"
- * }
- */
-router.post(
- "/devices/:deviceUuid/password/verify",
- deviceInfoMiddleware,
- errors.catchAsync(async (req, res, next) => {
- const { password } = req.body;
- const device = res.locals.device;
-
- if (!device.password) {
- return next(errors.createError(400, "设备未设置密码"));
- }
-
- if (!password) {
- return next(errors.createError(400, "请提供密码"));
- }
-
- // 验证密码
- const isValid = await verifyDevicePassword(password, device.password);
-
- res.json({
- valid: isValid,
- });
- })
-);
-
export default router;
\ No newline at end of file
diff --git a/routes/device-auth.js b/routes/device-auth.js
index cc959b0..66f90a6 100644
--- a/routes/device-auth.js
+++ b/routes/device-auth.js
@@ -1,14 +1,12 @@
import { Router } from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js";
-import { checkSiteKey } from "../middleware/auth.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const router = Router();
const prisma = new PrismaClient();
-// 应用站点密钥验证
-router.use(checkSiteKey);
+
/**
* POST /device/code
diff --git a/routes/device.js b/routes/device.js
new file mode 100644
index 0000000..f4f4c1d
--- /dev/null
+++ b/routes/device.js
@@ -0,0 +1,327 @@
+import { Router } from "express";
+const router = Router();
+import { uuidAuth } from "../middleware/uuidAuth.js";
+import { PrismaClient } from "@prisma/client";
+import crypto from "crypto";
+import errors from "../utils/errors.js";
+import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
+
+const prisma = new PrismaClient();
+
+/**
+ * POST /devices
+ * 注册新设备
+ */
+router.post(
+ "/",
+ errors.catchAsync(async (req, res, next) => {
+ const { uuid, deviceName } = req.body;
+
+ if (!uuid) {
+ return next(errors.createError(400, "设备UUID是必需的"));
+ }
+
+ if (!deviceName) {
+ return next(errors.createError(400, "设备名称是必需的"));
+ }
+
+ // 检查UUID是否已存在
+ const existingDevice = await prisma.device.findUnique({
+ where: { uuid },
+ });
+
+ if (existingDevice) {
+ return next(errors.createError(409, "设备UUID已存在"));
+ }
+
+ // 创建设备
+ const device = await prisma.device.create({
+ data: {
+ uuid,
+ name: deviceName,
+ },
+ });
+
+ return res.status(201).json({
+ success: true,
+ device: {
+ id: device.id,
+ uuid: device.uuid,
+ name: device.name,
+ createdAt: device.createdAt,
+ },
+ });
+ })
+);
+
+/**
+ * GET /devices/:uuid
+ * 获取设备信息 (公开接口,无需认证)
+ */
+router.get(
+ "/:uuid",
+ errors.catchAsync(async (req, res, next) => {
+ const { uuid } = req.params;
+
+ // 查找设备,包含绑定的账户信息
+ const device = await prisma.device.findUnique({
+ where: { uuid },
+ include: {
+ account: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ avatarUrl: true,
+ },
+ },
+ },
+ });
+
+ if (!device) {
+ return next(errors.createError(404, "设备不存在"));
+ }
+
+ return res.json({
+ id: device.id,
+ uuid: device.uuid,
+ name: device.name,
+ hasPassword: !!device.password,
+ passwordHint: device.passwordHint,
+ createdAt: device.createdAt,
+ account: device.account ? {
+ id: device.account.id,
+ name: device.account.name,
+ email: device.account.email,
+ avatarUrl: device.account.avatarUrl,
+ } : null,
+ isBoundToAccount: !!device.account,
+ });
+ })
+);/**
+ * PUT /devices/:uuid/name
+ * 设置设备名称 (需要UUID认证)
+ */
+router.put(
+ "/:uuid/name",
+ uuidAuth,
+ errors.catchAsync(async (req, res, next) => {
+ const { name } = req.body;
+ const device = res.locals.device;
+
+ if (!name) {
+ return next(errors.createError(400, "设备名称是必需的"));
+ }
+
+ const updatedDevice = await prisma.device.update({
+ where: { id: device.id },
+ data: { name },
+ });
+
+ return res.json({
+ success: true,
+ device: {
+ id: updatedDevice.id,
+ uuid: updatedDevice.uuid,
+ name: updatedDevice.name,
+ hasPassword: !!updatedDevice.password,
+ passwordHint: updatedDevice.passwordHint,
+ },
+ });
+ })
+);
+
+/**
+ * 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;
+ const passwordHint = req.query.passwordHint || req.body.passwordHint;
+
+ 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,
+ passwordHint: passwordHint || null,
+ },
+ });
+
+ 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;
\ No newline at end of file
diff --git a/routes/kv-token.js b/routes/kv-token.js
index 4852abd..8bd9902 100644
--- a/routes/kv-token.js
+++ b/routes/kv-token.js
@@ -1,13 +1,11 @@
import { Router } from "express";
const router = Router();
import kvStore from "../utils/kvStore.js";
-import { tokenAuth } from "../middleware/tokenAuth.js";
+import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import errors from "../utils/errors.js";
-import { checkSiteKey } from "../middleware/auth.js";
-// 应用站点密钥验证和token认证
-router.use(checkSiteKey);
-router.use(tokenAuth);
+// 使用KV专用token认证
+router.use(kvTokenAuth);
/**
* GET /_keys
diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml
deleted file mode 100644
index 383f836..0000000
--- a/test/docker/docker-compose.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-version: '3.8'
-
-services:
- mysql:
- image: mysql:8.0
- container_name: classworks_mysql
- restart: unless-stopped
- environment:
- MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-classworks}
- MYSQL_DATABASE: ${MYSQL_DATABASE:-classworks}
- MYSQL_USER: ${MYSQL_USER:-classworks}
- MYSQL_PASSWORD: ${MYSQL_PASSWORD:-classworks}
- TZ: Asia/Shanghai
- ports:
- - "3306:3306"
- volumes:
- - mysql_data:/var/lib/mysql
- - ./mysql/conf.d:/etc/mysql/conf.d:ro
- - ./mysql/initdb.d:/docker-entrypoint-initdb.d:ro
- command:
- - --character-set-server=utf8mb4
- - --collation-server=utf8mb4_unicode_ci
- - --default-authentication-plugin=mysql_native_password
- healthcheck:
- test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]
- interval: 10s
- timeout: 5s
- retries: 5
- networks:
- - classworks_net
-
- postgres:
- image: postgres:15-alpine
- container_name: classworks_postgres
- restart: unless-stopped
- environment:
- POSTGRES_DB: ${POSTGRES_DB:-classworks}
- POSTGRES_USER: ${POSTGRES_USER:-classworks}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-classworks}
- TZ: Asia/Shanghai
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- - ./postgres/initdb.d:/docker-entrypoint-initdb.d:ro
- command:
- - "postgres"
- - "-c"
- - "max_connections=100"
- - "-c"
- - "shared_buffers=128MB"
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
- interval: 10s
- timeout: 5s
- retries: 5
- networks:
- - classworks_net
-
-volumes:
- mysql_data:
- name: classworks_mysql_data
- postgres_data:
- name: classworks_postgres_data
-
-networks:
- classworks_net:
- name: classworks_network
- driver: bridge
\ No newline at end of file
diff --git a/test/docker/mysql/conf.d/my.cnf b/test/docker/mysql/conf.d/my.cnf
deleted file mode 100644
index 0384d42..0000000
--- a/test/docker/mysql/conf.d/my.cnf
+++ /dev/null
@@ -1,33 +0,0 @@
-[mysqld]
-# 字符集设置
-character-set-server=utf8mb4
-collation-server=utf8mb4_unicode_ci
-
-# 连接设置
-max_connections=100
-max_allowed_packet=64M
-
-# InnoDB设置
-innodb_buffer_pool_size=256M
-innodb_log_file_size=64M
-innodb_flush_log_at_trx_commit=2
-innodb_flush_method=O_DIRECT
-
-# 优化设置
-query_cache_type=1
-query_cache_size=32M
-sort_buffer_size=4M
-read_buffer_size=2M
-read_rnd_buffer_size=4M
-join_buffer_size=2M
-
-# 日志设置
-slow_query_log=1
-slow_query_log_file=/var/log/mysql/slow.log
-long_query_time=2
-
-[client]
-default-character-set=utf8mb4
-
-[mysql]
-default-character-set=utf8mb4
\ No newline at end of file
diff --git a/test/docker/mysql/initdb.d/init.sql b/test/docker/mysql/initdb.d/init.sql
deleted file mode 100644
index f96ed98..0000000
--- a/test/docker/mysql/initdb.d/init.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- 设置时区
-SET GLOBAL time_zone = '+8:00';
-SET time_zone = '+8:00';
-
--- 创建数据库(如果不存在)
-CREATE DATABASE IF NOT EXISTS classworks
- CHARACTER SET utf8mb4
- COLLATE utf8mb4_unicode_ci;
-
--- 设置权限
-GRANT ALL PRIVILEGES ON classworks.* TO 'classworks'@'%';
-FLUSH PRIVILEGES;
\ No newline at end of file
diff --git a/test/docker/postgres/initdb.d/init.sql b/test/docker/postgres/initdb.d/init.sql
deleted file mode 100644
index 2507d7a..0000000
--- a/test/docker/postgres/initdb.d/init.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- 创建扩展
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-
--- 设置时区
-SET timezone = 'Asia/Shanghai';
-
--- 设置默认权限
-ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO classworks;
-ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO classworks;
\ No newline at end of file
diff --git a/utils/jwt.js b/utils/jwt.js
new file mode 100644
index 0000000..70eb47b
--- /dev/null
+++ b/utils/jwt.js
@@ -0,0 +1,40 @@
+import jwt from 'jsonwebtoken';
+
+// JWT密钥 - 生产环境应该从环境变量读取
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
+const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; // 默认7天过期
+
+/**
+ * 签发JWT token
+ * @param {Object} payload - 要编码的数据
+ * @returns {string} JWT token
+ */
+export function signToken(payload) {
+ return jwt.sign(payload, JWT_SECRET, {
+ expiresIn: JWT_EXPIRES_IN,
+ });
+}
+
+/**
+ * 验证JWT token
+ * @param {string} token - JWT token
+ * @returns {Object} 解码后的payload
+ */
+export function verifyToken(token) {
+ return jwt.verify(token, JWT_SECRET);
+}
+
+/**
+ * 为账户生成JWT token
+ * @param {Object} account - 账户对象
+ * @returns {string} JWT token
+ */
+export function generateAccountToken(account) {
+ return signToken({
+ accountId: account.id,
+ provider: account.provider,
+ email: account.email,
+ name: account.name,
+ avatarUrl: account.avatarUrl,
+ });
+}
\ No newline at end of file