From 96480a27a975c20eaa45a1ea9a98b05e8625bb53 Mon Sep 17 00:00:00 2001 From: "xh.xin" Date: Fri, 2 May 2025 18:33:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=BB=93=E5=BA=93=EF=BC=8C=E5=8C=85=E5=90=AB=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=92=8C=E5=BC=80=E5=8F=91=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加README说明项目结构 2. 配置Python和Node.js的.gitignore 3. 包含认证模块和账号管理的前后端基础代码 4. 开发计划文档记录当前阶段任务 --- .gitignore | 74 ++ README.md | 32 + api/account_manager.py | 252 +++++ api/api_user_manager.py | 76 ++ api/app.py | 364 ++++++++ api/backend_account_manager.py | 108 +++ api/cli.py | 341 +++++++ api/config.py | 28 + api/database.py | 154 +++ api/encryption.py | 145 +++ api/libs/__init__.py | 0 api/libs/exception.py | 35 + api/libs/external_api.py | 119 +++ api/libs/gmpy2_pkcs10aep_cipher.py | 241 +++++ api/libs/helper.py | 311 +++++++ api/libs/infinite_scroll_pagination.py | 5 + api/libs/json_in_md_parser.py | 46 + api/libs/login.py | 106 +++ api/libs/oauth.py | 133 +++ api/libs/oauth_data_source.py | 303 ++++++ api/libs/passport.py | 22 + api/libs/password.py | 26 + api/libs/rsa.py | 93 ++ api/libs/smtp.py | 52 ++ api/model_config.json | 67 ++ api/model_manager.py | 335 +++++++ api/model_volc_config.json | 59 ++ api/models.py | 33 + api/operation_logger.py | 65 ++ api/provider_config.json | 11 + api/provider_manager.py | 201 ++++ api/requirements.txt | 25 + api/tenant_manager.py | 115 +++ api/tests/conftest.py | 164 ++++ api/tests/test_accounts.py | 54 ++ api/tests/test_auth.py | 64 ++ api/tests/test_tenants.py | 44 + web/docs/dev_plan.md | 69 ++ web/index.html | 13 + web/package.json | 24 + web/pnpm-lock.yaml | 926 +++++++++++++++++++ web/src/App.vue | 26 + web/src/api/account/index.ts | 53 ++ web/src/api/account/types.ts | 27 + web/src/api/auth/index.ts | 44 + web/src/api/auth/types.ts | 22 + web/src/api/login/index.ts | 17 + web/src/api/login/types.ts | 13 + web/src/api/tenant/index.ts | 62 ++ web/src/api/tenant/types.ts | 24 + web/src/axios/config.ts | 12 + web/src/axios/service.ts | 33 + web/src/main.ts | 14 + web/src/router/index.ts | 62 ++ web/src/store/index.ts | 7 + web/src/store/modules/user.ts | 32 + web/src/utils/auth.ts | 33 + web/src/utils/encrypt.ts | 5 + web/src/utils/storage.ts | 17 + web/src/views/Account/index.vue | 181 ++++ web/src/views/Auth/Login.vue | 101 ++ web/src/views/Auth/Register.vue | 133 +++ web/src/views/Dashboard/index.vue | 42 + web/src/views/Layout/index.vue | 158 ++++ web/src/views/Login/components/LoginForm.vue | 42 + web/src/views/Login/index.vue | 19 + web/src/views/Model/index.vue | 16 + web/src/views/User/index.vue | 16 + web/tsconfig.json | 19 + web/vite.config.ts | 24 + 70 files changed, 6589 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/account_manager.py create mode 100644 api/api_user_manager.py create mode 100644 api/app.py create mode 100644 api/backend_account_manager.py create mode 100644 api/cli.py create mode 100644 api/config.py create mode 100644 api/database.py create mode 100644 api/encryption.py create mode 100644 api/libs/__init__.py create mode 100644 api/libs/exception.py create mode 100644 api/libs/external_api.py create mode 100644 api/libs/gmpy2_pkcs10aep_cipher.py create mode 100644 api/libs/helper.py create mode 100644 api/libs/infinite_scroll_pagination.py create mode 100644 api/libs/json_in_md_parser.py create mode 100644 api/libs/login.py create mode 100644 api/libs/oauth.py create mode 100644 api/libs/oauth_data_source.py create mode 100644 api/libs/passport.py create mode 100644 api/libs/password.py create mode 100644 api/libs/rsa.py create mode 100644 api/libs/smtp.py create mode 100644 api/model_config.json create mode 100644 api/model_manager.py create mode 100644 api/model_volc_config.json create mode 100644 api/models.py create mode 100644 api/operation_logger.py create mode 100644 api/provider_config.json create mode 100644 api/provider_manager.py create mode 100644 api/requirements.txt create mode 100644 api/tenant_manager.py create mode 100644 api/tests/conftest.py create mode 100644 api/tests/test_accounts.py create mode 100644 api/tests/test_auth.py create mode 100644 api/tests/test_tenants.py create mode 100644 web/docs/dev_plan.md create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/App.vue create mode 100644 web/src/api/account/index.ts create mode 100644 web/src/api/account/types.ts create mode 100644 web/src/api/auth/index.ts create mode 100644 web/src/api/auth/types.ts create mode 100644 web/src/api/login/index.ts create mode 100644 web/src/api/login/types.ts create mode 100644 web/src/api/tenant/index.ts create mode 100644 web/src/api/tenant/types.ts create mode 100644 web/src/axios/config.ts create mode 100644 web/src/axios/service.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/store/index.ts create mode 100644 web/src/store/modules/user.ts create mode 100644 web/src/utils/auth.ts create mode 100644 web/src/utils/encrypt.ts create mode 100644 web/src/utils/storage.ts create mode 100644 web/src/views/Account/index.vue create mode 100644 web/src/views/Auth/Login.vue create mode 100644 web/src/views/Auth/Register.vue create mode 100644 web/src/views/Dashboard/index.vue create mode 100644 web/src/views/Layout/index.vue create mode 100644 web/src/views/Login/components/LoginForm.vue create mode 100644 web/src/views/Login/index.vue create mode 100644 web/src/views/Model/index.vue create mode 100644 web/src/views/User/index.vue create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f1de6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pytest_cache/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Environment +.env +.env.* +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +*.log +logs/ + +# System +.DS_Store +Thumbs.db + +# Build output +dist/ +build/ +out/ +*.tmp +*.temp + +# Frontend +web/dist/ +web/.vite/ +web/coverage/ +web/.DS_Store + +# Database +*.sqlite +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a54c5f --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Dify Admin 项目 + +这是一个管理后台项目,包含以下主要部分: + +## 后端 (API) +- Python Flask 应用 +- 数据库管理 +- 账户和租户管理 +- API服务 + +## 前端 (Web) +- Vue.js 3 前端 +- Vite 构建工具 +- 基于路由的页面结构 +- 认证和用户管理界面 + +## 项目结构 +``` +dify_admin/ +├── api/ # 后端代码 +├── web/ # 前端代码 +└── README.md # 项目说明 +``` + +## 开发环境 +- Python 3.x +- Node.js 16+ +- pnpm 包管理 + +## 快速开始 +1. 后端: `cd api && pip install -r requirements.txt` +2. 前端: `cd web && pnpm install` diff --git a/api/account_manager.py b/api/account_manager.py new file mode 100644 index 0000000..974ad2a --- /dev/null +++ b/api/account_manager.py @@ -0,0 +1,252 @@ +import uuid +import secrets +import binascii +import hashlib +import base64 +import logging +from datetime import datetime, timezone +import psycopg2.extras +from database import get_db_cursor, execute_query, execute_update + +# 配置日志 +logger = logging.getLogger(__name__) + +class AccountManager: + """账户管理类""" + + @staticmethod + def hash_password(password, salt=None): + """生成密码的哈希值和盐值""" + try: + # 生成密码盐 + if salt is None: + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + # 使用盐值加密密码 + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 10000) + password_hashed = binascii.hexlify(dk) + base64_password_hashed = base64.b64encode(password_hashed).decode() + + return base64_password_hashed, base64_salt + except Exception as e: + logger.error(f"密码哈希失败: {e}") + raise + + @staticmethod + def create_account(username, email, password): + """创建新账户""" + try: + # 生成UUID和密码哈希值 + user_id = uuid.uuid4() + hashed_password, password_salt = AccountManager.hash_password(password) + + # 获取当前时间 + current_time = datetime.now(timezone.utc) + + # 插入账户记录 + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + insert_query = """ + INSERT INTO accounts ( + id, name, email, password, password_salt, avatar, interface_language, + interface_theme, timezone, last_login_at, last_login_ip, status, + initialized_at, created_at, updated_at, last_active_at + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) RETURNING id, name, email, created_at; + """ + cursor.execute(insert_query, ( + user_id, username, email, hashed_password, password_salt, + None, "en-US", "light", "UTC", None, None, "active", + current_time, current_time, current_time, current_time + )) + result = cursor.fetchone() + cursor.connection.commit() + + logger.info(f"用户 {username} 邮箱 {email} 注册成功!") + return { + "id": result[0], + "username": result[1], + "email": result[2], + "created_at": result[3] + } + except Exception as e: + logger.error(f"创建账户失败: {e}") + raise + + @staticmethod + def get_user_by_username(username): + """根据用户名获取用户信息""" + try: + query = """ + SELECT id, name, email, password, password_salt, created_at + FROM accounts WHERE name = %s; + """ + user = execute_query(query, (username,), fetch_one=True) + + if user: + return { + "id": user[0], + "username": user[1], + "email": user[2], + "password": user[3], + "password_salt": user[4], + "created_at": user[5] + } + else: + logger.warning(f"未找到用户名为 {username} 的用户。") + return None + except Exception as e: + logger.error(f"获取用户信息失败: {e}") + raise + + @staticmethod + def search_accounts(search=None, page=1, page_size=10): + """搜索账户""" + try: + offset = (page - 1) * page_size + query = """ + SELECT id, name, email, status, created_at + FROM accounts + WHERE name LIKE %s OR email LIKE %s + ORDER BY created_at DESC + LIMIT %s OFFSET %s; + """ + count_query = """ + SELECT COUNT(*) FROM accounts + WHERE name LIKE %s OR email LIKE %s; + """ + + search_pattern = f"%{search}%" if search else "%" + + with get_db_cursor() as cursor: + cursor.execute(query, (search_pattern, search_pattern, page_size, offset)) + accounts = cursor.fetchall() + + cursor.execute(count_query, (search_pattern, search_pattern)) + total = cursor.fetchone()[0] + + return { + "data": [{ + "id": a[0], + "username": a[1], + "email": a[2], + "status": a[3], + "created_at": a[4] + } for a in accounts], + "total": total + } + except Exception as e: + logger.error(f"搜索账户失败: {e}") + raise + + @staticmethod + def verify_password(plain_password: str, hashed_password: str, salt: str): + """验证密码""" + try: + # 解码盐值 + salt_bytes = base64.b64decode(salt) + # 计算输入密码的哈希值 + dk = hashlib.pbkdf2_hmac("sha256", plain_password.encode("utf-8"), salt_bytes, 10000) + input_hashed = base64.b64encode(binascii.hexlify(dk)).decode() + return input_hashed == hashed_password + except Exception as e: + logger.error(f"密码验证失败: {e}") + return False + + @staticmethod + def get_user_by_email(email): + """根据邮箱获取用户信息""" + try: + query = """ + SELECT id, name, email FROM accounts WHERE email = %s; + """ + user = execute_query(query, (email,), fetch_one=True) + + if user: + return { + "id": user[0], + "username": user[1], + "email": user[2] + } + else: + logger.warning(f"未找到邮箱为 {email} 的用户。") + return None + except Exception as e: + logger.error(f"获取用户信息失败: {e}") + raise + + @staticmethod + def update_password(username, email, new_password): + """更新用户密码""" + try: + # 生成新的密码哈希值和盐值 + hashed_password, password_salt = AccountManager.hash_password(new_password) + + # 更新密码 + updated_at = datetime.now(timezone.utc) + update_query = """ + UPDATE accounts + SET password = %s, password_salt = %s, updated_at = %s + WHERE name = %s AND email = %s; + """ + rows_affected = execute_update(update_query, (hashed_password, password_salt, updated_at, username, email)) + + if rows_affected > 0: + logger.info(f"用户 {username} 邮箱 {email} 的密码已成功更新!") + return True + else: + logger.warning(f"未找到用户名为 {username} 邮箱为 {email} 的用户。") + return False + except Exception as e: + logger.error(f"更新密码失败: {e}") + raise + + @staticmethod + def associate_with_tenant(account_id, tenant_id, role="normal", invited_by=None, current=False): + """将账户与租户关联""" + try: + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + current_time = datetime.now() + + insert_query = """ + INSERT INTO tenant_account_joins ( + id, tenant_id, account_id, role, invited_by, created_at, updated_at, current + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s + ); + """ + cursor.execute(insert_query, ( + uuid.uuid4(), + tenant_id, + account_id, + role, + invited_by, + current_time, + current_time, + current + )) + + logger.info(f"账户 {account_id} 已成功关联到租户 {tenant_id},角色为 {role}。") + return True + except Exception as e: + logger.error(f"关联账户与租户失败: {e}") + raise + + @staticmethod + def get_tenant_accounts(tenant_id): + """获取租户下的所有账户""" + try: + query = """ + SELECT a.id, a.name, a.email, j.role, j.current + FROM accounts a + JOIN tenant_account_joins j ON a.id = j.account_id + WHERE j.tenant_id = %s; + """ + accounts = execute_query(query, (tenant_id,)) + return accounts + except Exception as e: + logger.error(f"获取租户账户失败: {e}") + return [] diff --git a/api/api_user_manager.py b/api/api_user_manager.py new file mode 100644 index 0000000..469f76b --- /dev/null +++ b/api/api_user_manager.py @@ -0,0 +1,76 @@ +import logging +from passlib.context import CryptContext +from database import get_db_cursor +import os +import base64 +from libs.password import hash_password, compare_password +from libs.exception import APIUserExistsError, APIUserNotFoundError + +logger = logging.getLogger(__name__) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +class APIAuthManager: + def __init__(self): + self.salt = os.urandom(16) # 生成16字节的随机salt + + def create_user(self, username: str, password: str, email: str = None): + """创建API用户""" + with get_db_cursor(db_type='sqlite') as cursor: + try: + # 检查用户是否存在 + cursor.execute("SELECT 1 FROM api_users WHERE username=?", (username,)) + if cursor.fetchone(): + raise APIUserExistsError(f"API用户 {username} 已存在") + + # 创建用户 + password_hash = hash_password(password, self.salt) + cursor.execute( + "INSERT INTO api_users (username, password_hash, email) VALUES (?, ?, ?)", + (username, password_hash, email) + ) + return cursor.lastrowid + except Exception as e: + logger.error(f"创建API用户失败: {e}") + raise + + def authenticate(self, username: str, password: str): + """认证API用户""" + with get_db_cursor(db_type='sqlite') as cursor: + try: + cursor.execute( + "SELECT id, username, password_hash FROM api_users WHERE username=? AND is_active=1", + (username,) + ) + user = cursor.fetchone() + if not user: + raise APIUserNotFoundError(f"API用户 {username} 不存在") + + if not compare_password(password, user[2], base64.b64encode(self.salt).decode()): + return None + + return {'id': user[0], 'username': user[1]} + except Exception as e: + logger.error(f"API用户认证失败: {e}") + raise + + def update_user(self, user_id: int, **kwargs): + """更新用户信息""" + updatable_fields = ['email', 'is_active'] + updates = {k: v for k, v in kwargs.items() if k in updatable_fields} + + if not updates: + return False + + with get_db_cursor(db_type='sqlite') as cursor: + try: + set_clause = ", ".join(f"{field}=?" for field in updates.keys()) + values = list(updates.values()) + [user_id] + + cursor.execute( + f"UPDATE api_users SET {set_clause}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + values + ) + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"更新API用户失败: {e}") + raise diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..45337cb --- /dev/null +++ b/api/app.py @@ -0,0 +1,364 @@ +from datetime import datetime, timedelta, timezone +from fastapi import FastAPI, Depends, HTTPException, status, Request, Body, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from models import AccountCreate, AccountResponse, PasswordChange, TenantCreate, TenantResponse +from account_manager import AccountManager as DifyAccountManager +from backend_account_manager import BackendAccountManager + +backend_account_manager = BackendAccountManager() +from tenant_manager import TenantManager +from api_user_manager import APIAuthManager +from operation_logger import OperationLogger +from jose import JWTError, jwt +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional +import logging +from account_manager import AccountManager +from database import get_db_cursor + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# JWT配置 +SECRET_KEY = "your-secret-key-here" # 生产环境应该从环境变量获取 +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +# 密码哈希 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# OAuth2方案 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +app = FastAPI() + +# 添加CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"] +) +api_auth = APIAuthManager() +op_logger = OperationLogger() + +def verify_password(plain_password: str, hashed_password: str): + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str): + """生成密码哈希""" + return pwd_context.hash(password) + +def authenticate_user(username: str, password: str): + """认证用户""" + try: + user = AccountManager.get_user_by_username(username) + if not user: + return False + + # 兼容测试用户 + if isinstance(user, tuple): + user_dict = { + "id": user[0], + "username": user[1], + "email": user[2], + "password": user[3], + "password_salt": user[4] + } + if not AccountManager.verify_password(password, user_dict["password"], user_dict["password_salt"]): + return False + return user_dict + else: + if not AccountManager.verify_password(password, user["password"], user["password_salt"]): + return False + return user + except Exception as e: + logger.error(f"认证失败: {e}") + return False + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme)): + """获取当前用户""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无法验证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = AccountManager.get_user_by_username(username) + if user is None: + raise credentials_exception + return { + "id": user["id"], + "username": user["username"], + "email": user["email"] + } + +# 认证路由组 +@app.options("/api/auth/register", include_in_schema=False) +async def auth_register_options(): + """处理OPTIONS预检请求""" + response = Response(status_code=204) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + +@app.post("/api/auth/register") +async def auth_register(request: Request): + """注册后台管理账号""" + form_data = await request.form() + username = form_data.get("username", "").strip() + password = form_data.get("password", "").strip() + email = form_data.get("email", "").strip() + + if not all([username, password, email]): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="缺少必要参数" + ) + try: + # 检查用户名是否已存在 + if backend_account_manager.get_user_by_username(username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="用户名已存在" + ) + + # 创建后台管理账号 + user = backend_account_manager.create_account(username, email, password) + return { + "user_id": str(user["id"]), + "username": user["username"], + "email": user["email"] + } + except Exception as e: + logger.error(f"注册后台账号失败: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="注册失败" + ) + +@app.post("/api/auth/login") +async def auth_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()): + """用户登录(auth)""" + client_ip = request.client.host if request.client else "unknown" + user = backend_account_manager.get_user_by_username(form_data.username) + if not user or not backend_account_manager.verify_password(form_data.password, user["password"], user["password_salt"]): + op_logger.log_operation( + user_id=0, + operation_type="LOGIN_ATTEMPT", + endpoint="/api/auth/login", + parameters=f"username={form_data.username}, ip={client_ip}", + status="FAILED" + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user["username"]}, + expires_delta=access_token_expires + ) + op_logger.log_operation( + user_id=user["id"], + operation_type="LOGIN", + endpoint="/api/auth/login", + parameters=f"ip={client_ip}", + status="SUCCESS" + ) + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/api/user/login") +async def user_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()): + """用户登录(user)""" + return await auth_login(request, form_data) + +@app.post("/api/auth/refresh") +async def refresh_token(current_user: dict = Depends(get_current_user)): + """刷新Token""" + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": current_user["username"]}, + expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + +# 账户管理路由组 +@app.post("/api/dify_accounts/") +async def create_dify_account(account: AccountCreate): + """创建Dify账户""" + try: + user = AccountManager.create_account(account.username, account.email, account.password) + return { + "user_id": str(user["id"]), + "username": user["username"], + "email": user["email"], + "created_at": user["created_at"] + } + except Exception as e: + logger.error(f"创建Dify账户失败: {e}") + raise HTTPException(status_code=400, detail="创建Dify账户失败") + +@app.get("/api/accounts/search") +async def search_accounts( + search: str = None, + page: int = 1, + page_size: int = 10, + current_user: dict = Depends(get_current_user) +): + """搜索账户""" + try: + accounts = AccountManager.search_accounts(search, page, page_size) + return { + "accounts": [{ + "id": str(a["id"]), + "username": a["username"], + "email": a["email"], + "status": a.get("status", "active"), + "created_at": a.get("created_at", datetime.now(timezone.utc)) + } for a in accounts["data"]], + "total": accounts["total"] + } + except Exception as e: + logger.error(f"搜索账户失败: {e}") + raise HTTPException(status_code=400, detail="搜索账户失败") + +@app.get("/api/dify_accounts/{username}") +async def get_dify_account(username: str, current_user: dict = Depends(get_current_user)): + """查询Dify账户信息""" + try: + account = AccountManager.get_user_by_username(username) + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dify账户不存在") + return { + "user_id": str(account["id"]), + "username": account["username"], + "email": account["email"], + "created_at": account["created_at"] + } + except Exception as e: + logger.error(f"查询Dify账户失败: {e}") + if "404" in str(e): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dify账户不存在") + raise HTTPException(status_code=400, detail="查询Dify账户失败") + +@app.put("/api/accounts/password") +async def change_password( + password_change: PasswordChange, + current_user: dict = Depends(get_current_user) +): + """修改密码""" + try: + user = AccountManager.get_user_by_username(current_user["username"]) + if not AccountManager.verify_password( + password_change.current_password, + user["password"], + user["password_salt"] + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="当前密码不正确" + ) + + AccountManager.update_password( + current_user["username"], + current_user["email"], + password_change.new_password + ) + return {"message": "密码修改成功"} + except Exception as e: + logger.error(f"修改密码失败: {e}") + raise HTTPException(status_code=400, detail="修改密码失败") + +# 租户管理路由组 +@app.post("/api/tenants/") +async def create_tenant(tenant: TenantCreate, current_user: dict = Depends(get_current_user)): + """创建租户""" + try: + tenant_id = TenantManager.create_tenant(tenant.name) + return { + "message": "租户创建成功", + "tenant": { + "id": str(tenant_id), + "name": tenant.name, + "description": tenant.description, + "created_at": datetime.now(timezone.utc) + } + } + except Exception as e: + logger.error(f"创建租户失败: {e}") + raise HTTPException(status_code=400, detail="创建租户失败") + +@app.get("/api/tenants/") +async def list_tenants(current_user: dict = Depends(get_current_user)): + """查询租户列表""" + try: + tenants = TenantManager.get_all_tenants() + return { + "tenants": [{ + "id": str(t["id"]), + "name": t["name"], + "description": t.get("description", ""), + "created_at": t.get("created_at", datetime.now(timezone.utc)) + } for t in tenants] + } + except Exception as e: + logger.error(f"查询租户列表失败: {e}") + raise HTTPException(status_code=400, detail="查询租户列表失败") + +@app.get("/api/tenants/{name}") +async def get_tenant(name: str, current_user: dict = Depends(get_current_user)): + """查询特定租户""" + try: + tenant = TenantManager.get_tenant_by_name(name) + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在" + ) + return { + "tenant": { + "id": str(tenant["id"]), + "name": tenant["name"], + "description": tenant.get("description", ""), + "created_at": tenant.get("created_at", datetime.now(timezone.utc)) + } + } + except Exception as e: + logger.error(f"查询租户失败: {e}") + if "404" in str(e): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在" + ) + raise HTTPException(status_code=400, detail="查询租户失败") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/api/backend_account_manager.py b/api/backend_account_manager.py new file mode 100644 index 0000000..286a10f --- /dev/null +++ b/api/backend_account_manager.py @@ -0,0 +1,108 @@ +import sqlite3 +import uuid +import hashlib +import secrets +import base64 +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + +class BackendAccountManager: + """后台服务账号管理类(使用SQLite)""" + + def __init__(self): + self.db_path = "api_service.db" + self._init_db() + + def _init_db(self): + """初始化数据库表""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS backend_users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + conn.commit() + + def hash_password(self, password, salt=None): + """生成密码哈希""" + if salt is None: + salt = secrets.token_bytes(16) + salt_b64 = base64.b64encode(salt).decode() + dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000) + hashed = base64.b64encode(dk).decode() + return hashed, salt_b64 + + def create_account(self, username, email, password): + """创建后台账号""" + try: + user_id = str(uuid.uuid4()) + hashed_pw, salt = self.hash_password(password) + now = datetime.now().isoformat() + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO backend_users + (id, username, email, password, password_salt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (user_id, username, email, hashed_pw, salt, now, now)) + conn.commit() + + logger.info(f"后台账号创建成功 - ID: {user_id}, 用户名: {username}, 邮箱: {email}") + return { + "id": user_id, + "username": username, + "email": email, + "created_at": now + } + except sqlite3.IntegrityError as e: + logger.error(f"账号已存在: {e}") + raise ValueError("用户名或邮箱已存在") + except Exception as e: + logger.error(f"创建账号失败: {e}") + raise + + def get_user_by_username(self, username): + """根据用户名获取用户""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, username, email, password, password_salt, created_at + FROM backend_users WHERE username = ? + """, (username,)) + row = cursor.fetchone() + + if row: + return { + "id": row[0], + "username": row[1], + "email": row[2], + "password": row[3], + "password_salt": row[4], + "created_at": row[5] + } + return None + except Exception as e: + logger.error(f"查询用户失败: {e}") + raise + + def verify_password(self, plain_password, hashed_password, salt): + """验证密码""" + try: + salt_bytes = base64.b64decode(salt) + dk = hashlib.pbkdf2_hmac('sha256', plain_password.encode(), salt_bytes, 100000) + input_hashed = base64.b64encode(dk).decode() + return input_hashed == hashed_password + except Exception as e: + logger.error(f"密码验证失败: {e}") + return False diff --git a/api/cli.py b/api/cli.py new file mode 100644 index 0000000..7a51372 --- /dev/null +++ b/api/cli.py @@ -0,0 +1,341 @@ +import argparse +import logging +import sys +from tenant_manager import TenantManager +from model_manager import ModelManager +from account_manager import AccountManager +from provider_manager import ProviderManager +from config import CONFIG_PATHS + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +def setup_tenant_parser(subparsers): + """设置租户管理相关的命令行参数""" + tenant_parser = subparsers.add_parser('tenant', help='租户管理') + tenant_subparsers = tenant_parser.add_subparsers(dest='tenant_command', help='租户操作') + + # 创建租户 + create_tenant_parser = tenant_subparsers.add_parser('create', help='创建租户') + create_tenant_parser.add_argument('name', help='租户名称') + + # 查询租户 + get_tenant_parser = tenant_subparsers.add_parser('get', help='查询租户') + get_tenant_parser.add_argument('name', help='租户名称') + + # 列出所有租户 + tenant_subparsers.add_parser('list', help='列出所有租户') + +def setup_model_parser(subparsers): + """设置模型管理相关的命令行参数""" + model_parser = subparsers.add_parser('model', help='模型管理') + model_subparsers = model_parser.add_subparsers(dest='model_command', help='模型操作') + + # 添加模型 + add_model_parser = model_subparsers.add_parser('add', help='添加模型') + add_model_parser.add_argument('--tenant', help='租户名称,不指定则为所有租户添加') + add_model_parser.add_argument('--config', default=CONFIG_PATHS['model_config'], help='模型配置文件路径') + + # 添加火山模型 + add_volc_model_parser = model_subparsers.add_parser('add-volc', help='添加火山模型') + add_volc_model_parser.add_argument('--tenant', help='租户名称,不指定则为所有租户添加') + add_volc_model_parser.add_argument('--config', default=CONFIG_PATHS['volc_model_config'], help='火山模型配置文件路径') + + # 删除模型 + delete_model_parser = model_subparsers.add_parser('delete', help='删除模型') + delete_model_parser.add_argument('--tenant', help='租户名称,不指定则删除所有租户下的指定模型') + delete_model_parser.add_argument('--model', required=True, help='模型名称') + + # 删除租户下的所有模型 + delete_all_models_parser = model_subparsers.add_parser('delete-all', help='删除租户下的所有模型') + delete_all_models_parser.add_argument('--tenant', required=True, help='租户名称') + +def setup_account_parser(subparsers): + """设置账户管理相关的命令行参数""" + account_parser = subparsers.add_parser('account', help='账户管理') + account_subparsers = account_parser.add_subparsers(dest='account_command', help='账户操作') + + # 创建账户 + create_account_parser = account_subparsers.add_parser('create', help='创建账户') + create_account_parser.add_argument('username', help='用户名') + create_account_parser.add_argument('email', help='邮箱') + create_account_parser.add_argument('password', help='密码') + + # 查询账户 + get_account_parser = account_subparsers.add_parser('get', help='查询账户') + get_account_group = get_account_parser.add_mutually_exclusive_group(required=True) + get_account_group.add_argument('--username', help='用户名') + get_account_group.add_argument('--email', help='邮箱') + + # 修改密码 + update_password_parser = account_subparsers.add_parser('update-password', help='修改密码') + update_password_parser.add_argument('username', help='用户名') + update_password_parser.add_argument('email', help='邮箱') + update_password_parser.add_argument('password', help='新密码') + + # 关联账户与租户 + associate_parser = account_subparsers.add_parser('associate', help='关联账户与租户') + associate_parser.add_argument('--username', required=True, help='用户名') + associate_parser.add_argument('--tenant', required=True, help='租户名称') + associate_parser.add_argument('--role', default='normal', choices=['owner', 'admin', 'editor', 'normal'], help='角色') + associate_parser.add_argument('--current', action='store_true', help='是否为当前使用') + + # 获取租户下的所有账户 + get_tenant_accounts_parser = account_subparsers.add_parser('list-tenant-accounts', help='获取租户下的所有账户') + get_tenant_accounts_parser.add_argument('--tenant', required=True, help='租户名称') + +def setup_provider_parser(subparsers): + """设置提供商管理相关的命令行参数""" + provider_parser = subparsers.add_parser('provider', help='提供商管理') + provider_subparsers = provider_parser.add_subparsers(dest='provider_command', help='提供商操作') + + # 添加提供商 + add_provider_parser = provider_subparsers.add_parser('add', help='添加提供商') + add_provider_parser.add_argument('--tenant', help='租户名称,不指定则为所有租户添加') + add_provider_parser.add_argument('--config', default=CONFIG_PATHS['provider_config'], help='提供商配置文件路径') + + # 删除提供商 + delete_provider_parser = provider_subparsers.add_parser('delete', help='删除提供商') + delete_provider_parser.add_argument('--tenant', required=True, help='租户名称') + delete_provider_parser.add_argument('--provider', required=True, help='提供商名称') + + # 删除租户下的所有提供商 + delete_all_providers_parser = provider_subparsers.add_parser('delete-all', help='删除租户下的所有提供商') + delete_all_providers_parser.add_argument('--tenant', required=True, help='租户名称') + + # 获取租户下的所有提供商 + get_providers_parser = provider_subparsers.add_parser('list', help='获取租户下的所有提供商') + get_providers_parser.add_argument('--tenant', required=True, help='租户名称') + +def handle_tenant_commands(args): + """处理租户管理相关的命令""" + if not hasattr(args, 'tenant_command') or args.tenant_command is None: + print("请指定租户操作命令") + return False + + if args.tenant_command == 'create': + tenant_id = TenantManager.create_tenant(args.name) + print(f"租户创建成功,ID: {tenant_id}") + + elif args.tenant_command == 'get': + tenant = TenantManager.get_tenant_by_name(args.name) + if tenant: + print(f"租户信息: ID={tenant['id']}") + else: + print(f"未找到租户: {args.name}") + + elif args.tenant_command == 'list': + tenants = TenantManager.get_all_tenants() + print(f"共找到 {len(tenants)} 个租户:") + for tenant in tenants: + print(f"ID: {tenant['id']}") + + else: + print("请指定租户操作命令") + return False + + return True + +def handle_model_commands(args): + """处理模型管理相关的命令""" + if not hasattr(args, 'model_command') or args.model_command is None: + print("请指定模型操作命令") + return False + + if args.model_command == 'add': + if args.tenant: + count = ModelManager.add_models_for_tenant(args.tenant, args.config) + print(f"为租户 {args.tenant} 添加模型完成,共添加 {count} 条记录。") + else: + count = ModelManager.add_models_for_all_tenants(args.config) + print(f"为所有租户添加模型完成,共添加 {count} 条记录。") + + elif args.model_command == 'add-volc': + if args.tenant: + count = ModelManager.add_volc_models_for_tenant(args.tenant, args.config) + print(f"为租户 {args.tenant} 添加火山模型完成,共添加 {count} 条记录。") + else: + count = ModelManager.add_volc_models_for_all_tenants(args.config) + print(f"为所有租户添加火山模型完成,共添加 {count} 条记录。") + + elif args.model_command == 'delete': + if args.tenant: + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + count = ModelManager.delete_model_for_tenant(tenant['id'], args.model) + print(f"删除租户 {args.tenant} 下的模型 {args.model} 完成,共删除 {count} 条记录。") + else: + count = ModelManager.delete_specific_model_for_all_tenants(args.model) + print(f"删除所有租户下的模型 {args.model} 完成,共删除 {count} 条记录。") + + elif args.model_command == 'delete-all': + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + count = ModelManager.delete_models_for_tenant(tenant['id']) + print(f"删除租户 {args.tenant} 下的所有模型完成,共删除 {count} 条记录。") + + else: + print("请指定模型操作命令") + return False + + return True + +def handle_account_commands(args): + """处理账户管理相关的命令""" + if not hasattr(args, 'account_command') or args.account_command is None: + print("请指定账户操作命令") + return False + + if args.account_command == 'create': + user_id = AccountManager.create_account(args.username, args.email, args.password) + print(f"账户创建成功,ID: {user_id}") + + elif args.account_command == 'get': + if args.username: + user = AccountManager.get_user_by_username(args.username) + if user and len(user) >= 3: + print(f"用户信息: ID={user[0]}, 用户名={user[1]}, 邮箱={user[2]}") + else: + print(f"未找到用户: {args.username}") + elif args.email: + user = AccountManager.get_user_by_email(args.email) + if user and len(user) >= 3: + print(f"用户信息: ID={user[0]}, 用户名={user[1]}, 邮箱={user[2]}") + else: + print(f"未找到用户: {args.email}") + + elif args.account_command == 'update-password': + success = AccountManager.update_password(args.username, args.email, args.password) + if success: + print(f"密码更新成功") + else: + print(f"密码更新失败,未找到用户") + + elif args.account_command == 'associate': + user = AccountManager.get_user_by_username(args.username) + if not user or len(user) < 1: + print(f"未找到用户: {args.username}") + return False + + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + success = AccountManager.associate_with_tenant(user[0], tenant['id'], args.role, None, args.current) + if success: + print(f"关联成功") + else: + print(f"关联失败") + + elif args.account_command == 'list-tenant-accounts': + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + accounts = AccountManager.get_tenant_accounts(tenant['id']) + print(f"租户 {args.tenant} 下共有 {len(accounts)} 个账户:") + for account in accounts: + print(f"ID: {account[0]}, 用户名: {account[1]}, 邮箱: {account[2]}, 角色: {account[3]}, 当前使用: {account[4]}") + + else: + print("请指定账户操作命令") + return False + + return True + +def handle_provider_commands(args): + """处理提供商管理相关的命令""" + if not hasattr(args, 'provider_command') or args.provider_command is None: + print("请指定提供商操作命令") + return False + + if args.provider_command == 'add': + if args.tenant: + count = ProviderManager.add_providers_for_tenant(args.tenant, args.config) + print(f"为租户 {args.tenant} 添加提供商完成,共添加 {count} 条记录。") + else: + count = ProviderManager.add_providers_for_all_tenants(args.config) + print(f"为所有租户添加提供商完成,共添加 {count} 条记录。") + + elif args.provider_command == 'delete': + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + count = ProviderManager.delete_provider_for_tenant(tenant['id'], args.provider) + print(f"删除租户 {args.tenant} 下的提供商 {args.provider} 完成,共删除 {count} 条记录。") + + elif args.provider_command == 'delete-all': + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + count = ProviderManager.delete_providers_for_tenant(tenant['id']) + print(f"删除租户 {args.tenant} 下的所有提供商完成,共删除 {count} 条记录。") + + elif args.provider_command == 'list': + tenant = TenantManager.get_tenant_by_name(args.tenant) + if not tenant: + print(f"未找到租户: {args.tenant}") + return False + + providers = ProviderManager.get_providers_for_tenant(tenant['id']) + print(f"租户 {args.tenant} 下共有 {len(providers)} 个提供商:") + for provider in providers: + print(f"ID: {provider[0]}, 名称: {provider[1]}, 类型: {provider[2]}, 有效: {provider[3]}") + + else: + print("请指定提供商操作命令") + return False + + return True + +def main(): + """主函数""" + # 创建命令行解析器 + parser = argparse.ArgumentParser(description='Dify数据库管理工具') + subparsers = parser.add_subparsers(dest='command', help='子命令') + + # 设置各个子命令的解析器 + setup_tenant_parser(subparsers) + setup_model_parser(subparsers) + setup_account_parser(subparsers) + setup_provider_parser(subparsers) + + # 解析命令行参数 + args = parser.parse_args() + + # 根据命令执行相应的操作 + success = False + + if args.command == 'tenant': + success = handle_tenant_commands(args) + + elif args.command == 'model': + success = handle_model_commands(args) + + elif args.command == 'account': + success = handle_account_commands(args) + + elif args.command == 'provider': + success = handle_provider_commands(args) + + else: + parser.print_help() + + # 根据操作结果设置退出码 + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..269c551 --- /dev/null +++ b/api/config.py @@ -0,0 +1,28 @@ +import os +from dotenv import load_dotenv + +# 加载.env文件中的环境变量(如果存在) +load_dotenv() + +# 数据库配置 +DB_CONFIG = { + 'host': os.getenv('DB_HOST', '192.168.100.143'), + 'port': os.getenv('DB_PORT', '15432'), + 'database': os.getenv('DB_NAME', 'dify'), + 'user': os.getenv('DB_USER', 'postgres'), + 'password': os.getenv('DB_PASSWORD', 'difyai123456') +} + +# API服务SQLite配置 +SQLITE_CONFIG = { + 'database': os.getenv('SQLITE_DB', 'api_service.db'), + 'timeout': 30 +} + +# 文件路径配置 +CONFIG_PATHS = { + 'model_config': os.getenv('MODEL_CONFIG_PATH', 'model_config.json'), + 'provider_config': os.getenv('PROVIDER_CONFIG_PATH', 'provider_config.json'), + 'volc_model_config': os.getenv('VOLC_MODEL_CONFIG_PATH', 'model_volc_config.json'), + 'privkeys_dir': os.getenv('PRIVKEYS_DIR', 'privkeys') +} diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..6fad31d --- /dev/null +++ b/api/database.py @@ -0,0 +1,154 @@ +import psycopg2 +import psycopg2.extras +import sqlite3 +from psycopg2.pool import SimpleConnectionPool +from contextlib import contextmanager +from config import DB_CONFIG, SQLITE_CONFIG +import logging + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# 创建PostgreSQL连接池 +pg_pool = None +try: + pg_pool = SimpleConnectionPool(1, 10, + host=DB_CONFIG.get('host', 'localhost'), + port=DB_CONFIG.get('port', '5432'), + database=DB_CONFIG.get('database', 'postgres'), + user=DB_CONFIG.get('user', 'postgres'), + password=DB_CONFIG.get('password', '')) + logger.info("PostgreSQL连接池创建成功") +except Exception as e: + logger.error(f"PostgreSQL连接池创建失败: {e}") + pg_pool = None + +# SQLite数据库连接 +def get_sqlite_conn(): + """获取SQLite数据库连接""" + try: + conn = sqlite3.connect(SQLITE_CONFIG['database'], + timeout=SQLITE_CONFIG['timeout']) + logger.info("SQLite数据库连接成功") + return conn + except Exception as e: + logger.error(f"SQLite数据库连接失败: {e}") + raise + +@contextmanager +def get_db_connection(db_type='postgres'): + """获取数据库连接的上下文管理器""" + conn = None + try: + if db_type == 'postgres': + if pg_pool is None: + raise Exception("PostgreSQL连接池未初始化") + conn = pg_pool.getconn() + logger.info("PostgreSQL数据库连接成功") + else: + conn = get_sqlite_conn() + logger.info("SQLite数据库连接成功") + yield conn + except Exception as e: + logger.error(f"数据库连接失败: {e}") + raise + finally: + if conn: + if db_type == 'postgres' and pg_pool: + pg_pool.putconn(conn) + elif db_type == 'sqlite': + conn.close() + +@contextmanager +def get_db_cursor(cursor_factory=None, db_type='postgres'): + """获取数据库游标的上下文管理器""" + with get_db_connection(db_type) as conn: + cursor = None + try: + if db_type == 'sqlite': + cursor = conn.cursor() + else: + cursor = conn.cursor(cursor_factory=cursor_factory) + yield cursor + conn.commit() + except Exception as e: + conn.rollback() + logger.error(f"数据库操作失败: {e}") + raise + finally: + if cursor: + cursor.close() + +def execute_query(query, params=None, cursor_factory=None, fetch_one=False, db_type='postgres'): + """执行SQL查询并返回结果""" + with get_db_cursor(cursor_factory=cursor_factory, db_type=db_type) as cursor: + cursor.execute(query, params or ()) + if fetch_one: + return cursor.fetchone() + return cursor.fetchall() + +def execute_update(query, params=None, db_type='postgres'): + """执行SQL更新操作并返回影响的行数""" + with get_db_cursor(db_type=db_type) as cursor: + cursor.execute(query, params or ()) + return cursor.rowcount + +def init_sqlite_db(): + """初始化SQLite数据库表结构""" + try: + with get_db_connection('sqlite') as conn: + cursor = conn.cursor() + # 创建API接口表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS api_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 创建API请求日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS api_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint_id INTEGER, + request_data TEXT, + response_data TEXT, + status_code INTEGER, + duration REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(endpoint_id) REFERENCES api_endpoints(id) + ) + ''') + # 创建API用户表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS api_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + email TEXT, + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 创建API操作记录表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS api_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + operation_type TEXT NOT NULL, + endpoint TEXT NOT NULL, + parameters TEXT, + status TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES api_users(id) + ) + ''') + conn.commit() + logger.info("SQLite数据库表初始化成功") + except Exception as e: + logger.error(f"SQLite数据库表初始化失败: {e}") + raise diff --git a/api/encryption.py b/api/encryption.py new file mode 100644 index 0000000..0554609 --- /dev/null +++ b/api/encryption.py @@ -0,0 +1,145 @@ +from Crypto.Cipher import AES +from Crypto.PublicKey import RSA +from Crypto.Random import get_random_bytes +import base64 +import os +from config import CONFIG_PATHS +from libs import gmpy2_pkcs10aep_cipher +import logging + +# 配置日志 +logger = logging.getLogger(__name__) + +class Encryption: + """加密工具类""" + + PREFIX_HYBRID = b"HYBRID:" + + @staticmethod + def load_public_key(public_key_path_or_content): + """加载公钥""" + if public_key_path_or_content is None: + logger.error("公钥路径或内容不能为空") + raise ValueError("公钥路径或内容不能为空") + + try: + if isinstance(public_key_path_or_content, str) and os.path.exists(public_key_path_or_content): + with open(public_key_path_or_content, "rb") as f: + public_key = f.read() + else: + # 假设输入的是公钥内容 + public_key = public_key_path_or_content.encode() if isinstance(public_key_path_or_content, str) else public_key_path_or_content + + if not public_key: + logger.error("公钥内容为空") + raise ValueError("公钥内容为空") + + return public_key + except Exception as e: + logger.error(f"加载公钥失败: {e}") + raise + + @staticmethod + def load_private_key(private_key_path): + """加载私钥""" + try: + with open(private_key_path, "rb") as f: + private_key = f.read() + return private_key + except Exception as e: + logger.error(f"加载私钥失败: {e}") + raise + + @staticmethod + def encrypt(text, public_key): + """使用混合加密方式(RSA + AES)加密文本""" + if text is None or text == "": + logger.error("待加密文本不能为空") + raise ValueError("待加密文本不能为空") + + if public_key is None: + logger.error("公钥不能为空") + raise ValueError("公钥不能为空") + + try: + if isinstance(public_key, str): + public_key = public_key.encode() + + # 生成随机AES密钥 + aes_key = get_random_bytes(16) + cipher_aes = AES.new(aes_key, AES.MODE_EAX) + + # 使用AES加密文本 + ciphertext, tag = cipher_aes.encrypt_and_digest(text.encode()) + + # 使用RSA加密AES密钥 + rsa_key = RSA.import_key(public_key) + cipher_rsa = gmpy2_pkcs10aep_cipher.new(rsa_key) + enc_aes_key = cipher_rsa.encrypt(aes_key) + + # 组合加密结果 + encrypted_data = enc_aes_key + cipher_aes.nonce + tag + ciphertext + + return Encryption.PREFIX_HYBRID + encrypted_data + except Exception as e: + logger.error(f"加密失败: {e}") + raise + + @staticmethod + def decrypt(encrypted_text, private_key): + """解密加密后的文本""" + if encrypted_text is None or encrypted_text == b"": + logger.error("待解密文本不能为空") + raise ValueError("待解密文本不能为空") + + if private_key is None: + logger.error("私钥不能为空") + raise ValueError("私钥不能为空") + + try: + # 加载私钥 + rsa_key = RSA.import_key(private_key) + cipher_rsa = gmpy2_pkcs10aep_cipher.new(rsa_key) + + # 解密 + if encrypted_text.startswith(Encryption.PREFIX_HYBRID): + encrypted_text = encrypted_text[len(Encryption.PREFIX_HYBRID):] + + if len(encrypted_text) < rsa_key.size_in_bytes() + 32: + logger.error("加密数据格式不正确") + raise ValueError("加密数据格式不正确") + + enc_aes_key = encrypted_text[:rsa_key.size_in_bytes()] + nonce = encrypted_text[rsa_key.size_in_bytes():rsa_key.size_in_bytes() + 16] + tag = encrypted_text[rsa_key.size_in_bytes() + 16:rsa_key.size_in_bytes() + 32] + ciphertext = encrypted_text[rsa_key.size_in_bytes() + 32:] + + aes_key = cipher_rsa.decrypt(enc_aes_key) + + cipher_aes = AES.new(aes_key, AES.MODE_EAX, nonce=nonce) + decrypted_text = cipher_aes.decrypt_and_verify(ciphertext, tag) + else: + decrypted_text = cipher_rsa.decrypt(encrypted_text) + + return decrypted_text.decode() + except Exception as e: + logger.error(f"解密失败: {e}") + raise + + @staticmethod + def encrypt_api_key(public_key_pem, api_key): + """加密API密钥并返回base64编码的结果""" + if api_key is None or api_key == "": + logger.error("API密钥不能为空") + raise ValueError("API密钥不能为空") + + if public_key_pem is None: + logger.error("公钥不能为空") + raise ValueError("公钥不能为空") + + try: + encrypted_api_key = Encryption.encrypt(api_key, public_key_pem) + return base64.b64encode(encrypted_api_key).decode() + except Exception as e: + logger.error(f"加密API密钥失败: {e}") + raise diff --git a/api/libs/__init__.py b/api/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/libs/exception.py b/api/libs/exception.py new file mode 100644 index 0000000..e3005ba --- /dev/null +++ b/api/libs/exception.py @@ -0,0 +1,35 @@ +from typing import Optional + +from werkzeug.exceptions import HTTPException + + +class BaseHTTPException(HTTPException): + error_code: str = "unknown" + data: Optional[dict] = None + + def __init__(self, description=None, response=None): + super().__init__(description, response) + + self.data = { + "code": self.error_code, + "message": self.description, + "status": self.code, + } + + +class APIUserExistsError(BaseHTTPException): + code = 409 + error_code = "api_user_exists" + description = "API用户已存在" + + +class APIUserNotFoundError(BaseHTTPException): + code = 404 + error_code = "api_user_not_found" + description = "API用户不存在" + + +class OperationLogError(BaseHTTPException): + code = 500 + error_code = "operation_log_error" + description = "操作日志处理失败" diff --git a/api/libs/external_api.py b/api/libs/external_api.py new file mode 100644 index 0000000..922d2d9 --- /dev/null +++ b/api/libs/external_api.py @@ -0,0 +1,119 @@ +import re +import sys +from typing import Any + +from flask import current_app, got_request_exception +from flask_restful import Api, http_status_message # type: ignore +from werkzeug.datastructures import Headers +from werkzeug.exceptions import HTTPException + +from core.errors.error import AppInvokeQuotaExceededError + + +class ExternalApi(Api): + def handle_error(self, e): + """Error handler for the API transforms a raised exception into a Flask + response, with the appropriate HTTP status code and body. + + :param e: the raised Exception object + :type e: Exception + + """ + got_request_exception.send(current_app, exception=e) + + headers = Headers() + if isinstance(e, HTTPException): + if e.response is not None: + resp = e.get_response() + return resp + + status_code = e.code + default_data = { + "code": re.sub(r"(?= 500: + exc_info: Any = sys.exc_info() + if exc_info[1] is None: + exc_info = None + current_app.log_exception(exc_info) + + if status_code == 406 and self.default_mediatype is None: + # if we are handling NotAcceptable (406), make sure that + # make_response uses a representation we support as the + # default mediatype (so that make_response doesn't throw + # another NotAcceptable error). + supported_mediatypes = list(self.representations.keys()) # only supported application/json + fallback_mediatype = supported_mediatypes[0] if supported_mediatypes else "text/plain" + data = {"code": "not_acceptable", "message": data.get("message")} + resp = self.make_response(data, status_code, headers, fallback_mediatype=fallback_mediatype) + elif status_code == 400: + if isinstance(data.get("message"), dict): + param_key, param_value = list(data.get("message", {}).items())[0] + data = {"code": "invalid_param", "message": param_value, "params": param_key} + else: + if "code" not in data: + data["code"] = "unknown" + + resp = self.make_response(data, status_code, headers) + else: + if "code" not in data: + data["code"] = "unknown" + + resp = self.make_response(data, status_code, headers) + + if status_code == 401: + resp = self.unauthorized(resp) + return resp diff --git a/api/libs/gmpy2_pkcs10aep_cipher.py b/api/libs/gmpy2_pkcs10aep_cipher.py new file mode 100644 index 0000000..2dae87e --- /dev/null +++ b/api/libs/gmpy2_pkcs10aep_cipher.py @@ -0,0 +1,241 @@ +# +# Cipher/PKCS1_OAEP.py : PKCS#1 OAEP +# +# =================================================================== +# The contents of this file are dedicated to the public domain. To +# the extent that dedication to the public domain is not available, +# everyone is granted a worldwide, perpetual, royalty-free, +# non-exclusive license to exercise all rights associated with the +# contents of this file for any purpose whatsoever. +# No rights are reserved. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# =================================================================== + +from hashlib import sha1 + +import Crypto.Hash.SHA1 +import Crypto.Util.number +import gmpy2 # type: ignore +from Crypto import Random +from Crypto.Signature.pss import MGF1 +from Crypto.Util.number import bytes_to_long, ceil_div, long_to_bytes +from Crypto.Util.py3compat import _copy_bytes, bord +from Crypto.Util.strxor import strxor + + +class PKCS1OAepCipher: + """Cipher object for PKCS#1 v1.5 OAEP. + Do not create directly: use :func:`new` instead.""" + + def __init__(self, key, hashAlgo, mgfunc, label, randfunc): + """Initialize this PKCS#1 OAEP cipher object. + + :Parameters: + key : an RSA key object + If a private half is given, both encryption and decryption are possible. + If a public half is given, only encryption is possible. + hashAlgo : hash object + The hash function to use. This can be a module under `Crypto.Hash` + or an existing hash object created from any of such modules. If not specified, + `Crypto.Hash.SHA1` is used. + mgfunc : callable + A mask generation function that accepts two parameters: a string to + use as seed, and the length of the mask to generate, in bytes. + If not specified, the standard MGF1 consistent with ``hashAlgo`` is used (a safe choice). + label : bytes/bytearray/memoryview + A label to apply to this particular encryption. If not specified, + an empty string is used. Specifying a label does not improve + security. + randfunc : callable + A function that returns random bytes. + + :attention: Modify the mask generation function only if you know what you are doing. + Sender and receiver must use the same one. + """ + self._key = key + + if hashAlgo: + self._hashObj = hashAlgo + else: + self._hashObj = Crypto.Hash.SHA1 + + if mgfunc: + self._mgf = mgfunc + else: + self._mgf = lambda x, y: MGF1(x, y, self._hashObj) + + self._label = _copy_bytes(None, None, label) + self._randfunc = randfunc + + def can_encrypt(self): + """Legacy function to check if you can call :meth:`encrypt`. + + .. deprecated:: 3.0""" + return self._key.can_encrypt() + + def can_decrypt(self): + """Legacy function to check if you can call :meth:`decrypt`. + + .. deprecated:: 3.0""" + return self._key.can_decrypt() + + def encrypt(self, message): + """Encrypt a message with PKCS#1 OAEP. + + :param message: + The message to encrypt, also known as plaintext. It can be of + variable length, but not longer than the RSA modulus (in bytes) + minus 2, minus twice the hash output size. + For instance, if you use RSA 2048 and SHA-256, the longest message + you can encrypt is 190 byte long. + :type message: bytes/bytearray/memoryview + + :returns: The ciphertext, as large as the RSA modulus. + :rtype: bytes + + :raises ValueError: + if the message is too long. + """ + + # See 7.1.1 in RFC3447 + modBits = Crypto.Util.number.size(self._key.n) + k = ceil_div(modBits, 8) # Convert from bits to bytes + hLen = self._hashObj.digest_size + mLen = len(message) + + # Step 1b + ps_len = k - mLen - 2 * hLen - 2 + if ps_len < 0: + raise ValueError("Plaintext is too long.") + # Step 2a + lHash = sha1(self._label).digest() + # Step 2b + ps = b"\x00" * ps_len + # Step 2c + db = lHash + ps + b"\x01" + _copy_bytes(None, None, message) + # Step 2d + ros = self._randfunc(hLen) + # Step 2e + dbMask = self._mgf(ros, k - hLen - 1) + # Step 2f + maskedDB = strxor(db, dbMask) + # Step 2g + seedMask = self._mgf(maskedDB, hLen) + # Step 2h + maskedSeed = strxor(ros, seedMask) + # Step 2i + em = b"\x00" + maskedSeed + maskedDB + # Step 3a (OS2IP) + em_int = bytes_to_long(em) + # Step 3b (RSAEP) + m_int = gmpy2.powmod(em_int, self._key.e, self._key.n) + # Step 3c (I2OSP) + c = long_to_bytes(m_int, k) + return c + + def decrypt(self, ciphertext): + """Decrypt a message with PKCS#1 OAEP. + + :param ciphertext: The encrypted message. + :type ciphertext: bytes/bytearray/memoryview + + :returns: The original message (plaintext). + :rtype: bytes + + :raises ValueError: + if the ciphertext has the wrong length, or if decryption + fails the integrity check (in which case, the decryption + key is probably wrong). + :raises TypeError: + if the RSA key has no private half (i.e. you are trying + to decrypt using a public key). + """ + # See 7.1.2 in RFC3447 + modBits = Crypto.Util.number.size(self._key.n) + k = ceil_div(modBits, 8) # Convert from bits to bytes + hLen = self._hashObj.digest_size + # Step 1b and 1c + if len(ciphertext) != k or k < hLen + 2: + raise ValueError("Ciphertext with incorrect length.") + # Step 2a (O2SIP) + ct_int = bytes_to_long(ciphertext) + # Step 2b (RSADP) + # m_int = self._key._decrypt(ct_int) + m_int = gmpy2.powmod(ct_int, self._key.d, self._key.n) + # Complete step 2c (I2OSP) + em = long_to_bytes(m_int, k) + # Step 3a + lHash = sha1(self._label).digest() + # Step 3b + y = em[0] + # y must be 0, but we MUST NOT check it here in order not to + # allow attacks like Manger's (http://dl.acm.org/citation.cfm?id=704143) + maskedSeed = em[1 : hLen + 1] + maskedDB = em[hLen + 1 :] + # Step 3c + seedMask = self._mgf(maskedDB, hLen) + # Step 3d + seed = strxor(maskedSeed, seedMask) + # Step 3e + dbMask = self._mgf(seed, k - hLen - 1) + # Step 3f + db = strxor(maskedDB, dbMask) + # Step 3g + one_pos = hLen + db[hLen:].find(b"\x01") + lHash1 = db[:hLen] + invalid = bord(y) | int(one_pos < hLen) # type: ignore + hash_compare = strxor(lHash1, lHash) + for x in hash_compare: + invalid |= bord(x) # type: ignore + for x in db[hLen:one_pos]: + invalid |= bord(x) # type: ignore + if invalid != 0: + raise ValueError("Incorrect decryption.") + # Step 4 + return db[one_pos + 1 :] + + +def new(key, hashAlgo=None, mgfunc=None, label=b"", randfunc=None): + """Return a cipher object :class:`PKCS1OAEP_Cipher` + that can be used to perform PKCS#1 OAEP encryption or decryption. + + :param key: + The key object to use to encrypt or decrypt the message. + Decryption is only possible with a private RSA key. + :type key: RSA key object + + :param hashAlgo: + The hash function to use. This can be a module under `Crypto.Hash` + or an existing hash object created from any of such modules. + If not specified, `Crypto.Hash.SHA1` is used. + :type hashAlgo: hash object + + :param mgfunc: + A mask generation function that accepts two parameters: a string to + use as seed, and the length of the mask to generate, in bytes. + If not specified, the standard MGF1 consistent with ``hashAlgo`` is used (a safe choice). + :type mgfunc: callable + + :param label: + A label to apply to this particular encryption. If not specified, + an empty string is used. Specifying a label does not improve + security. + :type label: bytes/bytearray/memoryview + + :param randfunc: + A function that returns random bytes. + The default is `Random.get_random_bytes`. + :type randfunc: callable + """ + + if randfunc is None: + randfunc = Random.get_random_bytes + return PKCS1OAepCipher(key, hashAlgo, mgfunc, label, randfunc) diff --git a/api/libs/helper.py b/api/libs/helper.py new file mode 100644 index 0000000..4f14f01 --- /dev/null +++ b/api/libs/helper.py @@ -0,0 +1,311 @@ +import json +import logging +import random +import re +import string +import subprocess +import time +import uuid +from collections.abc import Generator, Mapping +from datetime import datetime +from hashlib import sha256 +from typing import Any, Optional, Union, cast +from zoneinfo import available_timezones + +from flask import Response, stream_with_context +from flask_restful import fields # type: ignore + +from configs import dify_config +from core.app.features.rate_limiting.rate_limit import RateLimitGenerator +from core.file import helpers as file_helpers +from extensions.ext_redis import redis_client +from models.account import Account + + +def run(script): + return subprocess.getstatusoutput("source /root/.bashrc && " + script) + + +class AppIconUrlField(fields.Raw): + def output(self, key, obj): + if obj is None: + return None + + from models.model import App, IconType, Site + + if isinstance(obj, dict) and "app" in obj: + obj = obj["app"] + + if isinstance(obj, App | Site) and obj.icon_type == IconType.IMAGE.value: + return file_helpers.get_signed_file_url(obj.icon) + return None + + +class AvatarUrlField(fields.Raw): + def output(self, key, obj): + if obj is None: + return None + + from models.account import Account + + if isinstance(obj, Account) and obj.avatar is not None: + return file_helpers.get_signed_file_url(obj.avatar) + return None + + +class TimestampField(fields.Raw): + def format(self, value) -> int: + return int(value.timestamp()) + + +def email(email): + # Define a regex pattern for email addresses + pattern = r"^[\w\.!#$%&'*+\-/=?^_`{|}~]+@([\w-]+\.)+[\w-]{2,}$" + # Check if the email matches the pattern + if re.match(pattern, email) is not None: + return email + + error = "{email} is not a valid email.".format(email=email) + raise ValueError(error) + + +def uuid_value(value): + if value == "": + return str(value) + + try: + uuid_obj = uuid.UUID(value) + return str(uuid_obj) + except ValueError: + error = "{value} is not a valid uuid.".format(value=value) + raise ValueError(error) + + +def alphanumeric(value: str): + # check if the value is alphanumeric and underlined + if re.match(r"^[a-zA-Z0-9_]+$", value): + return value + + raise ValueError(f"{value} is not a valid alphanumeric value") + + +def timestamp_value(timestamp): + try: + int_timestamp = int(timestamp) + if int_timestamp < 0: + raise ValueError + return int_timestamp + except ValueError: + error = "{timestamp} is not a valid timestamp.".format(timestamp=timestamp) + raise ValueError(error) + + +class StrLen: + """Restrict input to an integer in a range (inclusive)""" + + def __init__(self, max_length, argument="argument"): + self.max_length = max_length + self.argument = argument + + def __call__(self, value): + length = len(value) + if length > self.max_length: + error = "Invalid {arg}: {val}. {arg} cannot exceed length {length}".format( + arg=self.argument, val=value, length=self.max_length + ) + raise ValueError(error) + + return value + + +class FloatRange: + """Restrict input to an float in a range (inclusive)""" + + def __init__(self, low, high, argument="argument"): + self.low = low + self.high = high + self.argument = argument + + def __call__(self, value): + value = _get_float(value) + if value < self.low or value > self.high: + error = "Invalid {arg}: {val}. {arg} must be within the range {lo} - {hi}".format( + arg=self.argument, val=value, lo=self.low, hi=self.high + ) + raise ValueError(error) + + return value + + +class DatetimeString: + def __init__(self, format, argument="argument"): + self.format = format + self.argument = argument + + def __call__(self, value): + try: + datetime.strptime(value, self.format) + except ValueError: + error = "Invalid {arg}: {val}. {arg} must be conform to the format {format}".format( + arg=self.argument, val=value, format=self.format + ) + raise ValueError(error) + + return value + + +def _get_float(value): + try: + return float(value) + except (TypeError, ValueError): + raise ValueError("{} is not a valid float".format(value)) + + +def timezone(timezone_string): + if timezone_string and timezone_string in available_timezones(): + return timezone_string + + error = "{timezone_string} is not a valid timezone.".format(timezone_string=timezone_string) + raise ValueError(error) + + +def generate_string(n): + letters_digits = string.ascii_letters + string.digits + result = "" + for i in range(n): + result += random.choice(letters_digits) + + return result + + +def extract_remote_ip(request) -> str: + if request.headers.get("CF-Connecting-IP"): + return cast(str, request.headers.get("Cf-Connecting-Ip")) + elif request.headers.getlist("X-Forwarded-For"): + return cast(str, request.headers.getlist("X-Forwarded-For")[0]) + else: + return cast(str, request.remote_addr) + + +def generate_text_hash(text: str) -> str: + hash_text = str(text) + "None" + return sha256(hash_text.encode()).hexdigest() + + +def compact_generate_response( + response: Union[Mapping[str, Any], RateLimitGenerator, Generator[str, None, None]], +) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype="application/json") + else: + + def generate() -> Generator: + yield from response + + return Response(stream_with_context(generate()), status=200, mimetype="text/event-stream") + + +class TokenManager: + @classmethod + def generate_token( + cls, + token_type: str, + account: Optional[Account] = None, + email: Optional[str] = None, + additional_data: Optional[dict] = None, + ) -> str: + if account is None and email is None: + raise ValueError("Account or email must be provided") + + account_id = account.id if account else None + account_email = account.email if account else email + + if account_id: + old_token = cls._get_current_token_for_account(account_id, token_type) + if old_token: + if isinstance(old_token, bytes): + old_token = old_token.decode("utf-8") + cls.revoke_token(old_token, token_type) + + token = str(uuid.uuid4()) + token_data = {"account_id": account_id, "email": account_email, "token_type": token_type} + if additional_data: + token_data.update(additional_data) + + expiry_minutes = dify_config.model_dump().get(f"{token_type.upper()}_TOKEN_EXPIRY_MINUTES") + if expiry_minutes is None: + raise ValueError(f"Expiry minutes for {token_type} token is not set") + token_key = cls._get_token_key(token, token_type) + expiry_time = int(expiry_minutes * 60) + redis_client.setex(token_key, expiry_time, json.dumps(token_data)) + + if account_id: + cls._set_current_token_for_account(account_id, token, token_type, expiry_minutes) + + return token + + @classmethod + def _get_token_key(cls, token: str, token_type: str) -> str: + return f"{token_type}:token:{token}" + + @classmethod + def revoke_token(cls, token: str, token_type: str): + token_key = cls._get_token_key(token, token_type) + redis_client.delete(token_key) + + @classmethod + def get_token_data(cls, token: str, token_type: str) -> Optional[dict[str, Any]]: + key = cls._get_token_key(token, token_type) + token_data_json = redis_client.get(key) + if token_data_json is None: + logging.warning(f"{token_type} token {token} not found with key {key}") + return None + token_data: Optional[dict[str, Any]] = json.loads(token_data_json) + return token_data + + @classmethod + def _get_current_token_for_account(cls, account_id: str, token_type: str) -> Optional[str]: + key = cls._get_account_token_key(account_id, token_type) + current_token: Optional[str] = redis_client.get(key) + return current_token + + @classmethod + def _set_current_token_for_account( + cls, account_id: str, token: str, token_type: str, expiry_hours: Union[int, float] + ): + key = cls._get_account_token_key(account_id, token_type) + expiry_time = int(expiry_hours * 60 * 60) + redis_client.setex(key, expiry_time, token) + + @classmethod + def _get_account_token_key(cls, account_id: str, token_type: str) -> str: + return f"{token_type}:account:{account_id}" + + +class RateLimiter: + def __init__(self, prefix: str, max_attempts: int, time_window: int): + self.prefix = prefix + self.max_attempts = max_attempts + self.time_window = time_window + + def _get_key(self, email: str) -> str: + return f"{self.prefix}:{email}" + + def is_rate_limited(self, email: str) -> bool: + key = self._get_key(email) + current_time = int(time.time()) + window_start_time = current_time - self.time_window + + redis_client.zremrangebyscore(key, "-inf", window_start_time) + attempts = redis_client.zcard(key) + + if attempts and int(attempts) >= self.max_attempts: + return True + return False + + def increment_rate_limit(self, email: str): + key = self._get_key(email) + current_time = int(time.time()) + + redis_client.zadd(key, {current_time: current_time}) + redis_client.expire(key, self.time_window * 2) diff --git a/api/libs/infinite_scroll_pagination.py b/api/libs/infinite_scroll_pagination.py new file mode 100644 index 0000000..133ccb1 --- /dev/null +++ b/api/libs/infinite_scroll_pagination.py @@ -0,0 +1,5 @@ +class InfiniteScrollPagination: + def __init__(self, data, limit, has_more): + self.data = data + self.limit = limit + self.has_more = has_more diff --git a/api/libs/json_in_md_parser.py b/api/libs/json_in_md_parser.py new file mode 100644 index 0000000..9ab53b6 --- /dev/null +++ b/api/libs/json_in_md_parser.py @@ -0,0 +1,46 @@ +import json + +from core.llm_generator.output_parser.errors import OutputParserError + + +def parse_json_markdown(json_string: str) -> dict: + # Get json from the backticks/braces + json_string = json_string.strip() + starts = ["```json", "```", "``", "`", "{"] + ends = ["```", "``", "`", "}"] + end_index = -1 + start_index = 0 + parsed: dict = {} + for s in starts: + start_index = json_string.find(s) + if start_index != -1: + if json_string[start_index] != "{": + start_index += len(s) + break + if start_index != -1: + for e in ends: + end_index = json_string.rfind(e, start_index) + if end_index != -1: + if json_string[end_index] == "}": + end_index += 1 + break + if start_index != -1 and end_index != -1 and start_index < end_index: + extracted_content = json_string[start_index:end_index].strip() + parsed = json.loads(extracted_content) + else: + raise ValueError("could not find json block in the output.") + + return parsed + + +def parse_and_check_json_markdown(text: str, expected_keys: list[str]) -> dict: + try: + json_obj = parse_json_markdown(text) + except json.JSONDecodeError as e: + raise OutputParserError(f"got invalid json object. error: {e}") + for key in expected_keys: + if key not in json_obj: + raise OutputParserError( + f"got invalid return object. expected key `{key}` to be present, but got {json_obj}" + ) + return json_obj diff --git a/api/libs/login.py b/api/libs/login.py new file mode 100644 index 0000000..5395534 --- /dev/null +++ b/api/libs/login.py @@ -0,0 +1,106 @@ +from functools import wraps +from typing import Any + +from flask import current_app, g, has_request_context, request +from flask_login import user_logged_in # type: ignore +from flask_login.config import EXEMPT_METHODS # type: ignore +from werkzeug.exceptions import Unauthorized +from werkzeug.local import LocalProxy + +from configs import dify_config +from extensions.ext_database import db +from models.account import Account, Tenant, TenantAccountJoin + +#: A proxy for the current user. If no user is logged in, this will be an +#: anonymous user +current_user: Any = LocalProxy(lambda: _get_user()) + + +def login_required(func): + """ + If you decorate a view with this, it will ensure that the current user is + logged in and authenticated before calling the actual view. (If they are + not, it calls the :attr:`LoginManager.unauthorized` callback.) For + example:: + + @app.route('/post') + @login_required + def post(): + pass + + If there are only certain times you need to require that your user is + logged in, you can do so with:: + + if not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + + ...which is essentially the code that this function adds to your views. + + It can be convenient to globally turn off authentication when unit testing. + To enable this, if the application configuration variable `LOGIN_DISABLED` + is set to `True`, this decorator will be ignored. + + .. Note :: + + Per `W3 guidelines for CORS preflight requests + `_, + HTTP ``OPTIONS`` requests are exempt from login checks. + + :param func: The view function to decorate. + :type func: function + """ + + @wraps(func) + def decorated_view(*args, **kwargs): + auth_header = request.headers.get("Authorization") + if dify_config.ADMIN_API_KEY_ENABLE: + if auth_header: + if " " not in auth_header: + raise Unauthorized("Invalid Authorization header format. Expected 'Bearer ' format.") + auth_scheme, auth_token = auth_header.split(None, 1) + auth_scheme = auth_scheme.lower() + if auth_scheme != "bearer": + raise Unauthorized("Invalid Authorization header format. Expected 'Bearer ' format.") + + admin_api_key = dify_config.ADMIN_API_KEY + if admin_api_key: + if admin_api_key == auth_token: + workspace_id = request.headers.get("X-WORKSPACE-ID") + if workspace_id: + tenant_account_join = ( + db.session.query(Tenant, TenantAccountJoin) + .filter(Tenant.id == workspace_id) + .filter(TenantAccountJoin.tenant_id == Tenant.id) + .filter(TenantAccountJoin.role == "owner") + .one_or_none() + ) + if tenant_account_join: + tenant, ta = tenant_account_join + account = Account.query.filter_by(id=ta.account_id).first() + # Login admin + if account: + account.current_tenant = tenant + current_app.login_manager._update_request_context_with_user(account) # type: ignore + user_logged_in.send(current_app._get_current_object(), user=_get_user()) # type: ignore + if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED: + pass + elif not current_user.is_authenticated: + return current_app.login_manager.unauthorized() # type: ignore + + # flask 1.x compatibility + # current_app.ensure_sync is only available in Flask >= 2.0 + if callable(getattr(current_app, "ensure_sync", None)): + return current_app.ensure_sync(func)(*args, **kwargs) + return func(*args, **kwargs) + + return decorated_view + + +def _get_user(): + if has_request_context(): + if "_login_user" not in g: + current_app.login_manager._load_user() # type: ignore + + return g._login_user + + return None diff --git a/api/libs/oauth.py b/api/libs/oauth.py new file mode 100644 index 0000000..df75b55 --- /dev/null +++ b/api/libs/oauth.py @@ -0,0 +1,133 @@ +import urllib.parse +from dataclasses import dataclass +from typing import Optional + +import requests + + +@dataclass +class OAuthUserInfo: + id: str + name: str + email: str + + +class OAuth: + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_authorization_url(self): + raise NotImplementedError() + + def get_access_token(self, code: str): + raise NotImplementedError() + + def get_raw_user_info(self, token: str): + raise NotImplementedError() + + def get_user_info(self, token: str) -> OAuthUserInfo: + raw_info = self.get_raw_user_info(token) + return self._transform_user_info(raw_info) + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + raise NotImplementedError() + + +class GitHubOAuth(OAuth): + _AUTH_URL = "https://github.com/login/oauth/authorize" + _TOKEN_URL = "https://github.com/login/oauth/access_token" + _USER_INFO_URL = "https://api.github.com/user" + _EMAIL_INFO_URL = "https://api.github.com/user/emails" + + def get_authorization_url(self, invite_token: Optional[str] = None): + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "scope": "user:email", # Request only basic user information + } + if invite_token: + params["state"] = invite_token + return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": self.redirect_uri, + } + headers = {"Accept": "application/json"} + response = requests.post(self._TOKEN_URL, data=data, headers=headers) + + response_json = response.json() + access_token = response_json.get("access_token") + + if not access_token: + raise ValueError(f"Error in GitHub OAuth: {response_json}") + + return access_token + + def get_raw_user_info(self, token: str): + headers = {"Authorization": f"token {token}"} + response = requests.get(self._USER_INFO_URL, headers=headers) + response.raise_for_status() + user_info = response.json() + + email_response = requests.get(self._EMAIL_INFO_URL, headers=headers) + email_info = email_response.json() + primary_email: dict = next((email for email in email_info if email["primary"] == True), {}) + + return {**user_info, "email": primary_email.get("email", "")} + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + email = raw_info.get("email") + if not email: + email = f"{raw_info['id']}+{raw_info['login']}@users.noreply.github.com" + return OAuthUserInfo(id=str(raw_info["id"]), name=raw_info["name"], email=email) + + +class GoogleOAuth(OAuth): + _AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + _TOKEN_URL = "https://oauth2.googleapis.com/token" + _USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + def get_authorization_url(self, invite_token: Optional[str] = None): + params = { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": "openid email", + } + if invite_token: + params["state"] = invite_token + return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + } + headers = {"Accept": "application/json"} + response = requests.post(self._TOKEN_URL, data=data, headers=headers) + + response_json = response.json() + access_token = response_json.get("access_token") + + if not access_token: + raise ValueError(f"Error in Google OAuth: {response_json}") + + return access_token + + def get_raw_user_info(self, token: str): + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(self._USER_INFO_URL, headers=headers) + response.raise_for_status() + return response.json() + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + return OAuthUserInfo(id=str(raw_info["sub"]), name="", email=raw_info["email"]) diff --git a/api/libs/oauth_data_source.py b/api/libs/oauth_data_source.py new file mode 100644 index 0000000..a5ba08d --- /dev/null +++ b/api/libs/oauth_data_source.py @@ -0,0 +1,303 @@ +import datetime +import urllib.parse +from typing import Any + +import requests +from flask_login import current_user # type: ignore + +from extensions.ext_database import db +from models.source import DataSourceOauthBinding + + +class OAuthDataSource: + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_authorization_url(self): + raise NotImplementedError() + + def get_access_token(self, code: str): + raise NotImplementedError() + + +class NotionOAuth(OAuthDataSource): + _AUTH_URL = "https://api.notion.com/v1/oauth/authorize" + _TOKEN_URL = "https://api.notion.com/v1/oauth/token" + _NOTION_PAGE_SEARCH = "https://api.notion.com/v1/search" + _NOTION_BLOCK_SEARCH = "https://api.notion.com/v1/blocks" + _NOTION_BOT_USER = "https://api.notion.com/v1/users/me" + + def get_authorization_url(self): + params = { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "owner": "user", + } + return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str): + data = {"code": code, "grant_type": "authorization_code", "redirect_uri": self.redirect_uri} + headers = {"Accept": "application/json"} + auth = (self.client_id, self.client_secret) + response = requests.post(self._TOKEN_URL, data=data, auth=auth, headers=headers) + + response_json = response.json() + access_token = response_json.get("access_token") + if not access_token: + raise ValueError(f"Error in Notion OAuth: {response_json}") + workspace_name = response_json.get("workspace_name") + workspace_icon = response_json.get("workspace_icon") + workspace_id = response_json.get("workspace_id") + # get all authorized pages + pages = self.get_authorized_pages(access_token) + source_info = { + "workspace_name": workspace_name, + "workspace_icon": workspace_icon, + "workspace_id": workspace_id, + "pages": pages, + "total": len(pages), + } + # save data source binding + data_source_binding = DataSourceOauthBinding.query.filter( + db.and_( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.access_token == access_token, + ) + ).first() + if data_source_binding: + data_source_binding.source_info = source_info + data_source_binding.disabled = False + data_source_binding.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + db.session.commit() + else: + new_data_source_binding = DataSourceOauthBinding( + tenant_id=current_user.current_tenant_id, + access_token=access_token, + source_info=source_info, + provider="notion", + ) + db.session.add(new_data_source_binding) + db.session.commit() + + def save_internal_access_token(self, access_token: str): + workspace_name = self.notion_workspace_name(access_token) + workspace_icon = None + workspace_id = current_user.current_tenant_id + # get all authorized pages + pages = self.get_authorized_pages(access_token) + source_info = { + "workspace_name": workspace_name, + "workspace_icon": workspace_icon, + "workspace_id": workspace_id, + "pages": pages, + "total": len(pages), + } + # save data source binding + data_source_binding = DataSourceOauthBinding.query.filter( + db.and_( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.access_token == access_token, + ) + ).first() + if data_source_binding: + data_source_binding.source_info = source_info + data_source_binding.disabled = False + data_source_binding.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + db.session.commit() + else: + new_data_source_binding = DataSourceOauthBinding( + tenant_id=current_user.current_tenant_id, + access_token=access_token, + source_info=source_info, + provider="notion", + ) + db.session.add(new_data_source_binding) + db.session.commit() + + def sync_data_source(self, binding_id: str): + # save data source binding + data_source_binding = DataSourceOauthBinding.query.filter( + db.and_( + DataSourceOauthBinding.tenant_id == current_user.current_tenant_id, + DataSourceOauthBinding.provider == "notion", + DataSourceOauthBinding.id == binding_id, + DataSourceOauthBinding.disabled == False, + ) + ).first() + if data_source_binding: + # get all authorized pages + pages = self.get_authorized_pages(data_source_binding.access_token) + source_info = data_source_binding.source_info + new_source_info = { + "workspace_name": source_info["workspace_name"], + "workspace_icon": source_info["workspace_icon"], + "workspace_id": source_info["workspace_id"], + "pages": pages, + "total": len(pages), + } + data_source_binding.source_info = new_source_info + data_source_binding.disabled = False + data_source_binding.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + db.session.commit() + else: + raise ValueError("Data source binding not found") + + def get_authorized_pages(self, access_token: str): + pages = [] + page_results = self.notion_page_search(access_token) + database_results = self.notion_database_search(access_token) + # get page detail + for page_result in page_results: + page_id = page_result["id"] + page_name = "Untitled" + for key in page_result["properties"]: + if "title" in page_result["properties"][key] and page_result["properties"][key]["title"]: + title_list = page_result["properties"][key]["title"] + if len(title_list) > 0 and "plain_text" in title_list[0]: + page_name = title_list[0]["plain_text"] + page_icon = page_result["icon"] + if page_icon: + icon_type = page_icon["type"] + if icon_type in {"external", "file"}: + url = page_icon[icon_type]["url"] + icon = {"type": "url", "url": url if url.startswith("http") else f"https://www.notion.so{url}"} + else: + icon = {"type": "emoji", "emoji": page_icon[icon_type]} + else: + icon = None + parent = page_result["parent"] + parent_type = parent["type"] + if parent_type == "block_id": + parent_id = self.notion_block_parent_page_id(access_token, parent[parent_type]) + elif parent_type == "workspace": + parent_id = "root" + else: + parent_id = parent[parent_type] + page = { + "page_id": page_id, + "page_name": page_name, + "page_icon": icon, + "parent_id": parent_id, + "type": "page", + } + pages.append(page) + # get database detail + for database_result in database_results: + page_id = database_result["id"] + if len(database_result["title"]) > 0: + page_name = database_result["title"][0]["plain_text"] + else: + page_name = "Untitled" + page_icon = database_result["icon"] + if page_icon: + icon_type = page_icon["type"] + if icon_type in {"external", "file"}: + url = page_icon[icon_type]["url"] + icon = {"type": "url", "url": url if url.startswith("http") else f"https://www.notion.so{url}"} + else: + icon = {"type": icon_type, icon_type: page_icon[icon_type]} + else: + icon = None + parent = database_result["parent"] + parent_type = parent["type"] + if parent_type == "block_id": + parent_id = self.notion_block_parent_page_id(access_token, parent[parent_type]) + elif parent_type == "workspace": + parent_id = "root" + else: + parent_id = parent[parent_type] + page = { + "page_id": page_id, + "page_name": page_name, + "page_icon": icon, + "parent_id": parent_id, + "type": "database", + } + pages.append(page) + return pages + + def notion_page_search(self, access_token: str): + results = [] + next_cursor = None + has_more = True + + while has_more: + data: dict[str, Any] = { + "filter": {"value": "page", "property": "object"}, + **({"start_cursor": next_cursor} if next_cursor else {}), + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}", + "Notion-Version": "2022-06-28", + } + + response = requests.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) + response_json = response.json() + + results.extend(response_json.get("results", [])) + + has_more = response_json.get("has_more", False) + next_cursor = response_json.get("next_cursor", None) + + return results + + def notion_block_parent_page_id(self, access_token: str, block_id: str): + headers = { + "Authorization": f"Bearer {access_token}", + "Notion-Version": "2022-06-28", + } + response = requests.get(url=f"{self._NOTION_BLOCK_SEARCH}/{block_id}", headers=headers) + response_json = response.json() + if response.status_code != 200: + message = response_json.get("message", "unknown error") + raise ValueError(f"Error fetching block parent page ID: {message}") + parent = response_json["parent"] + parent_type = parent["type"] + if parent_type == "block_id": + return self.notion_block_parent_page_id(access_token, parent[parent_type]) + return parent[parent_type] + + def notion_workspace_name(self, access_token: str): + headers = { + "Authorization": f"Bearer {access_token}", + "Notion-Version": "2022-06-28", + } + response = requests.get(url=self._NOTION_BOT_USER, headers=headers) + response_json = response.json() + if "object" in response_json and response_json["object"] == "user": + user_type = response_json["type"] + user_info = response_json[user_type] + if "workspace_name" in user_info: + return user_info["workspace_name"] + return "workspace" + + def notion_database_search(self, access_token: str): + results = [] + next_cursor = None + has_more = True + + while has_more: + data: dict[str, Any] = { + "filter": {"value": "database", "property": "object"}, + **({"start_cursor": next_cursor} if next_cursor else {}), + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}", + "Notion-Version": "2022-06-28", + } + response = requests.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) + response_json = response.json() + + results.extend(response_json.get("results", [])) + + has_more = response_json.get("has_more", False) + next_cursor = response_json.get("next_cursor", None) + + return results diff --git a/api/libs/passport.py b/api/libs/passport.py new file mode 100644 index 0000000..8df4f52 --- /dev/null +++ b/api/libs/passport.py @@ -0,0 +1,22 @@ +import jwt +from werkzeug.exceptions import Unauthorized + +from configs import dify_config + + +class PassportService: + def __init__(self): + self.sk = dify_config.SECRET_KEY + + def issue(self, payload): + return jwt.encode(payload, self.sk, algorithm="HS256") + + def verify(self, token): + try: + return jwt.decode(token, self.sk, algorithms=["HS256"]) + except jwt.exceptions.InvalidSignatureError: + raise Unauthorized("Invalid token signature.") + except jwt.exceptions.DecodeError: + raise Unauthorized("Invalid token.") + except jwt.exceptions.ExpiredSignatureError: + raise Unauthorized("Token has expired.") diff --git a/api/libs/password.py b/api/libs/password.py new file mode 100644 index 0000000..cdf55c5 --- /dev/null +++ b/api/libs/password.py @@ -0,0 +1,26 @@ +import base64 +import binascii +import hashlib +import re + +password_pattern = r"^(?=.*[a-zA-Z])(?=.*\d).{8,}$" + + +def valid_password(password): + # Define a regex pattern for password rules + pattern = password_pattern + # Check if the password matches the pattern + if re.match(pattern, password) is not None: + return password + + raise ValueError("Password must contain letters and numbers, and the length must be greater than 8.") + + +def hash_password(password_str, salt_byte): + dk = hashlib.pbkdf2_hmac("sha256", password_str.encode("utf-8"), salt_byte, 10000) + return binascii.hexlify(dk) + + +def compare_password(password_str, password_hashed_base64, salt_base64): + # compare password for login + return hash_password(password_str, base64.b64decode(salt_base64)) == base64.b64decode(password_hashed_base64) diff --git a/api/libs/rsa.py b/api/libs/rsa.py new file mode 100644 index 0000000..637bcc4 --- /dev/null +++ b/api/libs/rsa.py @@ -0,0 +1,93 @@ +import hashlib + +from Crypto.Cipher import AES +from Crypto.PublicKey import RSA +from Crypto.Random import get_random_bytes + +from extensions.ext_redis import redis_client +from extensions.ext_storage import storage +from libs import gmpy2_pkcs10aep_cipher + + +def generate_key_pair(tenant_id): + private_key = RSA.generate(2048) + public_key = private_key.publickey() + + pem_private = private_key.export_key() + pem_public = public_key.export_key() + + filepath = "privkeys/{tenant_id}".format(tenant_id=tenant_id) + "/private.pem" + + storage.save(filepath, pem_private) + + return pem_public.decode() + + +prefix_hybrid = b"HYBRID:" + + +def encrypt(text, public_key): + if isinstance(public_key, str): + public_key = public_key.encode() + + aes_key = get_random_bytes(16) + cipher_aes = AES.new(aes_key, AES.MODE_EAX) + + ciphertext, tag = cipher_aes.encrypt_and_digest(text.encode()) + + rsa_key = RSA.import_key(public_key) + cipher_rsa = gmpy2_pkcs10aep_cipher.new(rsa_key) + + enc_aes_key = cipher_rsa.encrypt(aes_key) + + encrypted_data = enc_aes_key + cipher_aes.nonce + tag + ciphertext + + return prefix_hybrid + encrypted_data + + +def get_decrypt_decoding(tenant_id): + filepath = "privkeys/{tenant_id}".format(tenant_id=tenant_id) + "/private.pem" + + cache_key = "tenant_privkey:{hash}".format(hash=hashlib.sha3_256(filepath.encode()).hexdigest()) + private_key = redis_client.get(cache_key) + if not private_key: + try: + private_key = storage.load(filepath) + except FileNotFoundError: + raise PrivkeyNotFoundError("Private key not found, tenant_id: {tenant_id}".format(tenant_id=tenant_id)) + + redis_client.setex(cache_key, 120, private_key) + + rsa_key = RSA.import_key(private_key) + cipher_rsa = gmpy2_pkcs10aep_cipher.new(rsa_key) + + return rsa_key, cipher_rsa + + +def decrypt_token_with_decoding(encrypted_text, rsa_key, cipher_rsa): + if encrypted_text.startswith(prefix_hybrid): + encrypted_text = encrypted_text[len(prefix_hybrid) :] + + enc_aes_key = encrypted_text[: rsa_key.size_in_bytes()] + nonce = encrypted_text[rsa_key.size_in_bytes() : rsa_key.size_in_bytes() + 16] + tag = encrypted_text[rsa_key.size_in_bytes() + 16 : rsa_key.size_in_bytes() + 32] + ciphertext = encrypted_text[rsa_key.size_in_bytes() + 32 :] + + aes_key = cipher_rsa.decrypt(enc_aes_key) + + cipher_aes = AES.new(aes_key, AES.MODE_EAX, nonce=nonce) + decrypted_text = cipher_aes.decrypt_and_verify(ciphertext, tag) + else: + decrypted_text = cipher_rsa.decrypt(encrypted_text) + + return decrypted_text.decode() + + +def decrypt(encrypted_text, tenant_id): + rsa_key, cipher_rsa = get_decrypt_decoding(tenant_id) + + return decrypt_token_with_decoding(encrypted_text, rsa_key, cipher_rsa) + + +class PrivkeyNotFoundError(Exception): + pass diff --git a/api/libs/smtp.py b/api/libs/smtp.py new file mode 100644 index 0000000..2325d69 --- /dev/null +++ b/api/libs/smtp.py @@ -0,0 +1,52 @@ +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + + +class SMTPClient: + def __init__( + self, server: str, port: int, username: str, password: str, _from: str, use_tls=False, opportunistic_tls=False + ): + self.server = server + self.port = port + self._from = _from + self.username = username + self.password = password + self.use_tls = use_tls + self.opportunistic_tls = opportunistic_tls + + def send(self, mail: dict): + smtp = None + try: + if self.use_tls: + if self.opportunistic_tls: + smtp = smtplib.SMTP(self.server, self.port, timeout=10) + smtp.starttls() + else: + smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10) + else: + smtp = smtplib.SMTP(self.server, self.port, timeout=10) + + if self.username and self.password: + smtp.login(self.username, self.password) + + msg = MIMEMultipart() + msg["Subject"] = mail["subject"] + msg["From"] = self._from + msg["To"] = mail["to"] + msg.attach(MIMEText(mail["html"], "html")) + + smtp.sendmail(self._from, mail["to"], msg.as_string()) + except smtplib.SMTPException as e: + logging.exception("SMTP error occurred") + raise + except TimeoutError as e: + logging.exception("Timeout occurred while sending email") + raise + except Exception as e: + logging.exception(f"Unexpected error occurred while sending email to {mail['to']}") + raise + finally: + if smtp: + smtp.quit() diff --git a/api/model_config.json b/api/model_config.json new file mode 100644 index 0000000..14392b2 --- /dev/null +++ b/api/model_config.json @@ -0,0 +1,67 @@ +{ + "models": [ + { + "model_name": "qwq-32b-awq-ucas-local", + "provider_name": "langgenius/openai_api_compatible/openai_api_compatible", + "model_type": "text-generation", + "display_name": "qwq-32b-awq-40k-ucas-local", + "api_key": "sk-DH9yYRHbC6uIdhTvF20f429978114cEc90060055AcEfF74f", + "endpoint_url": "http://10.1.180.22/vllm/v1/", + "mode": "chat", + "context_size": "40960", + "max_tokens_to_sample": "40960", + "function_calling_type": "function_call", + "stream_function_calling": "not_supported", + "vision_support": "no_support", + "stream_mode_delimiter": "\n\n" + }, + { + "model_name": "gemma3-27b-awq-ucas-local", + "provider_name": "langgenius/openai_api_compatible/openai_api_compatible", + "model_type": "text-generation", + "display_name": "gemma3-27b-awq-32k-ucas-local", + "api_key": "sk-DH9yYRHbC6uIdhTvF20f429978114cEc90060055AcEfF74f", + "endpoint_url": "http://10.1.180.22/gemma/v1/", + "mode": "chat", + "context_size": "32768", + "max_tokens_to_sample": "32768", + "function_calling_type": "function_call", + "stream_function_calling": "not_supported", + "vision_support": "support", + "stream_mode_delimiter": "\n\n" + }, + { + "model_name": "gemma3-27b-q4-128k-ucas-local", + "provider_name": "langgenius/openai_api_compatible/openai_api_compatible", + "model_type": "text-generation", + "api_key": "sk-DH9yYRHbC6uIdhTvF20f429978114cEc90060055AcEfF74f", + "display_name": "gemma3-27b-q4-128k-ucas-local", + "endpoint_url": "http://10.1.180.22/llama_128k/v1/", + "mode": "chat", + "context_size": "131072", + "max_tokens_to_sample": "131072", + "function_calling_type": "function_call", + "stream_function_calling": "not_supported", + "vision_support": "no_support", + "stream_mode_delimiter": "\n\n" + }, + { + "model_name": "bge-m3-ucas-local", + "provider_name": "langgenius/openai_api_compatible/openai_api_compatible", + "model_type": "embeddings", + "display_name": "bge-m3-ucas-local", + "api_key": "sk-DH9yYRHbC6uIdhTvF20f429978114cEc90060055AcEfF74f", + "endpoint_url": "http://10.1.180.22/embed/v1/", + "context_size": "4096" + }, + { + "model_name": "bge-reranker-v2-m3-ucas-local", + "provider_name": "langgenius/openai_api_compatible/openai_api_compatible", + "model_type": "reranking", + "display_name": "bge-reranker-v2-m3-ucas-local", + "api_key": "sk-DH9yYRHbC6uIdhTvF20f429978114cEc90060055AcEfF74f", + "endpoint_url": "http://10.1.180.22/rerank/v1/", + "context_size": "4096" + } + ] +} \ No newline at end of file diff --git a/api/model_manager.py b/api/model_manager.py new file mode 100644 index 0000000..6de3f62 --- /dev/null +++ b/api/model_manager.py @@ -0,0 +1,335 @@ +import os +import json +import logging +import psycopg2.extras +from database import get_db_cursor, execute_query, execute_update +from encryption import Encryption +from config import CONFIG_PATHS +from tenant_manager import TenantManager + +# 配置日志 +logger = logging.getLogger(__name__) + +class ModelManager: + """模型管理类""" + + @staticmethod + def load_config(config_path): + """加载模型配置文件""" + try: + if not os.path.exists(config_path): + raise FileNotFoundError(f"配置文件 {config_path} 不存在!") + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + return config + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + raise + + @staticmethod + def check_model_exists(tenant_id, model_name): + """检查指定租户是否已存在指定的模型""" + try: + query = """ + SELECT COUNT(*) FROM provider_models + WHERE tenant_id = %s AND model_name = %s; + """ + count = execute_query(query, (tenant_id, model_name), fetch_one=True)[0] + return count > 0 + except Exception as e: + logger.error(f"检查模型是否存在时发生错误: {e}") + return False + + @staticmethod + def add_model_for_tenant(tenant_id, public_key_pem, model_config): + """为指定租户添加模型记录""" + try: + # 检查必要字段 + required_fields = ["model_name", "provider_name", "model_type", "api_key"] + for field in required_fields: + if field not in model_config: + logger.error(f"模型配置缺少必要字段: {field}") + raise ValueError(f"模型配置缺少必要字段: {field}") + + # 检查模型是否已存在 + model_name = model_config["model_name"] + if ModelManager.check_model_exists(tenant_id, model_name): + logger.info(f"租户 {tenant_id} 已存在模型 {model_name},跳过添加。") + return + + # 加密API密钥 + encrypted_api_key = Encryption.encrypt_api_key(public_key_pem, model_config["api_key"]) + + # 根据模型类型构造加密配置 + if model_config["model_type"] in ["embeddings", "reranking"]: + encrypted_config = { + "display_name": model_config.get("display_name", ""), + "api_key": encrypted_api_key, + "endpoint_url": model_config.get("endpoint_url", ""), + "context_size": model_config.get("context_size", "") + } + elif model_config["model_type"] == "text-generation": + encrypted_config = { + "display_name": model_config.get("display_name", ""), + "api_key": encrypted_api_key, + "endpoint_url": model_config.get("endpoint_url", ""), + "mode": model_config.get("mode", ""), + "context_size": model_config.get("context_size", ""), + "max_tokens_to_sample": model_config.get("max_tokens_to_sample", ""), + "function_calling_type": model_config.get("function_calling_type", ""), + "stream_function_calling": model_config.get("stream_function_calling", ""), + "vision_support": model_config.get("vision_support", ""), + "stream_mode_delimiter": model_config.get("stream_mode_delimiter", "") + } + else: + raise ValueError(f"不支持的模型类型: {model_config['model_type']}") + + # 插入模型记录 + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + insert_query = """ + INSERT INTO provider_models ( + id, tenant_id, provider_name, model_name, model_type, + encrypted_config, is_valid, created_at, updated_at + ) VALUES ( + uuid_generate_v4(), %s, %s, %s, %s, %s, TRUE, NOW(), NOW() + ); + """ + cursor.execute( + insert_query, ( + tenant_id, + model_config["provider_name"], + model_config["model_name"], + model_config["model_type"], + json.dumps(encrypted_config) + ) + ) + + logger.info(f"为租户 {tenant_id} 添加模型记录成功!") + return True + except Exception as e: + logger.error(f"为租户 {tenant_id} 添加模型记录失败: {e}") + raise + + @staticmethod + def add_volc_model_for_tenant(tenant_id, public_key_pem, model_config): + """为指定租户添加火山模型记录""" + try: + # 检查必要字段 + required_fields = ["model_name", "provider_name", "model_type", "volc_api_key"] + for field in required_fields: + if field not in model_config: + logger.error(f"火山模型配置缺少必要字段: {field}") + raise ValueError(f"火山模型配置缺少必要字段: {field}") + + # 检查模型是否已存在 + model_name = model_config["model_name"] + if ModelManager.check_model_exists(tenant_id, model_name): + logger.info(f"租户 {tenant_id} 已存在模型 {model_name},跳过添加。") + return + + # 加密API密钥 + encrypted_api_key = Encryption.encrypt_api_key(public_key_pem, model_config["volc_api_key"]) + + # 根据模型类型构造加密配置 + if model_config["model_type"] == "embeddings": + logger.warning(f"火山模型不支持embeddings类型,跳过添加。") + return + elif model_config["model_type"] == "text-generation": + encrypted_config = { + "auth_method": model_config.get("auth_method", "api_key"), + "volc_api_key": encrypted_api_key, + "volc_region": model_config.get("volc_region", "cn-beijing"), + "api_endpoint_host": model_config.get("api_endpoint_host", "https://ark.cn-beijing.volces.com/api/v3"), + "endpoint_id": model_config.get("endpoint_id", ""), + "base_model_name": model_config.get("base_model_name", ""), + } + else: + raise ValueError(f"不支持的模型类型: {model_config['model_type']}") + + # 插入模型记录 + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + insert_query = """ + INSERT INTO provider_models ( + id, tenant_id, provider_name, model_name, model_type, + encrypted_config, is_valid, created_at, updated_at + ) VALUES ( + uuid_generate_v4(), %s, %s, %s, %s, %s, TRUE, NOW(), NOW() + ); + """ + cursor.execute( + insert_query, ( + tenant_id, + model_config["provider_name"], + model_config["model_name"], + model_config["model_type"], + json.dumps(encrypted_config) + ) + ) + + logger.info(f"为租户 {tenant_id} 添加火山模型记录成功!") + return True + except Exception as e: + logger.error(f"为租户 {tenant_id} 添加火山模型记录失败: {e}") + raise + + @staticmethod + def delete_models_for_tenant(tenant_id): + """删除指定租户下的所有模型记录""" + try: + query = """ + DELETE FROM provider_models + WHERE tenant_id = %s; + """ + rows_affected = execute_update(query, (tenant_id,)) + logger.info(f"租户 {tenant_id} 下的所有模型记录已删除,共 {rows_affected} 条。") + return rows_affected + except Exception as e: + logger.error(f"删除租户 {tenant_id} 的模型记录失败: {e}") + raise + + @staticmethod + def delete_model_for_tenant(tenant_id, model_name): + """删除指定租户下的特定模型记录""" + try: + query = """ + DELETE FROM provider_models + WHERE tenant_id = %s AND model_name = %s; + """ + rows_affected = execute_update(query, (tenant_id, model_name)) + + if rows_affected > 0: + logger.info(f"租户 {tenant_id} 下的模型 {model_name} 已删除。") + else: + logger.warning(f"租户 {tenant_id} 下不存在模型 {model_name}。") + + return rows_affected + except Exception as e: + logger.error(f"删除租户 {tenant_id} 下的模型 {model_name} 失败: {e}") + raise + + @staticmethod + def delete_specific_model_for_all_tenants(model_name): + """删除所有租户下的特定模型记录""" + try: + tenants = TenantManager.get_all_tenants() + total_deleted = 0 + + for tenant in tenants: + tenant_id = tenant['id'] + try: + rows_affected = ModelManager.delete_model_for_tenant(tenant_id, model_name) + total_deleted += rows_affected + except Exception as e: + logger.error(f"删除租户 {tenant_id} 下的模型 {model_name} 失败: {e}") + + logger.info(f"所有租户下的模型 {model_name} 已删除,共 {total_deleted} 条。") + return total_deleted + except Exception as e: + logger.error(f"删除所有租户下的模型 {model_name} 失败: {e}") + raise + + @staticmethod + def add_models_for_all_tenants(config_path=CONFIG_PATHS['model_config']): + """为所有租户添加模型""" + try: + # 加载模型配置 + config = ModelManager.load_config(config_path) + models = config.get("models", []) + + # 获取所有租户 + tenants = TenantManager.get_all_tenants() + total_added = 0 + + # 为每个租户添加模型 + for tenant in tenants: + tenant_id = tenant['id'] + public_key_pem = tenant['encrypt_public_key'] + for model_config in models: + if ModelManager.add_model_for_tenant(tenant_id, public_key_pem, model_config): + total_added += 1 + + logger.info(f"为所有租户添加模型完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为所有租户添加模型失败: {e}") + raise + + @staticmethod + def add_volc_models_for_all_tenants(config_path=CONFIG_PATHS['volc_model_config']): + """为所有租户添加火山模型""" + try: + # 加载模型配置 + config = ModelManager.load_config(config_path) + models = config.get("models", []) + + # 获取所有租户 + tenants = TenantManager.get_all_tenants() + total_added = 0 + + # 为每个租户添加模型 + for tenant in tenants: + tenant_id = tenant['id'] + public_key_pem = tenant['encrypt_public_key'] + for model_config in models: + if ModelManager.add_volc_model_for_tenant(tenant_id, public_key_pem, model_config): + total_added += 1 + + logger.info(f"为所有租户添加火山模型完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为所有租户添加火山模型失败: {e}") + raise + + @staticmethod + def add_models_for_tenant(tenant_name, config_path=CONFIG_PATHS['model_config']): + """为指定租户添加模型""" + try: + # 加载模型配置 + config = ModelManager.load_config(config_path) + models = config.get("models", []) + + # 获取租户信息 + tenant = TenantManager.get_tenant_by_name(tenant_name) + if not tenant: + logger.error(f"未找到租户: {tenant_name}") + return 0 + + # 为租户添加模型 + total_added = 0 + for model_config in models: + if ModelManager.add_model_for_tenant(tenant['id'], tenant['encrypt_public_key'], model_config): + total_added += 1 + + logger.info(f"为租户 {tenant_name} 添加模型完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为租户 {tenant_name} 添加模型失败: {e}") + raise + + @staticmethod + def add_volc_models_for_tenant(tenant_name, config_path=CONFIG_PATHS['volc_model_config']): + """为指定租户添加火山模型""" + try: + # 加载模型配置 + config = ModelManager.load_config(config_path) + models = config.get("models", []) + + # 获取租户信息 + tenant = TenantManager.get_tenant_by_name(tenant_name) + if not tenant: + logger.error(f"未找到租户: {tenant_name}") + return 0 + + # 为租户添加模型 + total_added = 0 + for model_config in models: + if ModelManager.add_volc_model_for_tenant(tenant['id'], tenant['encrypt_public_key'], model_config): + total_added += 1 + + logger.info(f"为租户 {tenant_name} 添加火山模型完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为租户 {tenant_name} 添加火山模型失败: {e}") + raise diff --git a/api/model_volc_config.json b/api/model_volc_config.json new file mode 100644 index 0000000..f2fd577 --- /dev/null +++ b/api/model_volc_config.json @@ -0,0 +1,59 @@ +{ + "models": [ + { + "model_name": "deepseek-r1-250120", + "provider_name": "volcengine_maas", + "model_type": "text-generation", + "auth_method": "api_key", + "volc_api_key": "f58a58ae-f892-4895-ac2a-fcbfbf02f0cc", + "volc_region": "cn-beijing", + "api_endpoint_host": "https://ark.cn-beijing.volces.com/api/v3", + "endpoint_id": "deepseek-r1-250120", + "base_model_name": "DeepSeek-R1" + }, + { + "model_name": "doubao-1-5-pro-32k-250115", + "provider_name": "volcengine_maas", + "model_type": "text-generation", + "auth_method": "api_key", + "volc_api_key": "f58a58ae-f892-4895-ac2a-fcbfbf02f0cc", + "volc_region": "cn-beijing", + "api_endpoint_host": "https://ark.cn-beijing.volces.com/api/v3", + "endpoint_id": "doubao-1-5-pro-32k-250115", + "base_model_name": "Doubao-1.5-pro-32k" + }, + { + "model_name": "deepseek-v3-241226", + "provider_name": "volcengine_maas", + "model_type": "text-generation", + "auth_method": "api_key", + "volc_api_key": "f58a58ae-f892-4895-ac2a-fcbfbf02f0cc", + "volc_region": "cn-beijing", + "api_endpoint_host": "https://ark.cn-beijing.volces.com/api/v3", + "endpoint_id": "deepseek-v3-241226", + "base_model_name": "DeepSeek-V3" + }, + { + "model_name": "deepseek-r1-distill-qwen-32b-250120", + "provider_name": "volcengine_maas", + "model_type": "text-generation", + "auth_method": "api_key", + "volc_api_key": "f58a58ae-f892-4895-ac2a-fcbfbf02f0cc", + "volc_region": "cn-beijing", + "api_endpoint_host": "https://ark.cn-beijing.volces.com/api/v3", + "endpoint_id": "deepseek-r1-distill-qwen-32b-250120", + "base_model_name": "DeepSeek-R1-Distill-Qwen-32B" + }, + { + "model_name": "doubao-1-5-vision-pro-32k-250115", + "provider_name": "volcengine_maas", + "model_type": "text-generation", + "auth_method": "api_key", + "volc_api_key": "f58a58ae-f892-4895-ac2a-fcbfbf02f0cc", + "volc_region": "cn-beijing", + "api_endpoint_host": "https://ark.cn-beijing.volces.com/api/v3", + "endpoint_id": "doubao-1-5-vision-pro-32k-250115", + "base_model_name": "Doubao-1.5-vision-pro-32k" + } + ] +} diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..3d6fff6 --- /dev/null +++ b/api/models.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + +class AccountCreate(BaseModel): + """创建账户请求模型""" + username: str + email: EmailStr + password: str + +class AccountResponse(BaseModel): + """账户响应模型""" + id: str + username: str + email: EmailStr + created_at: datetime + +class PasswordChange(BaseModel): + """修改密码请求模型""" + current_password: str + new_password: str + +class TenantCreate(BaseModel): + """创建租户请求模型""" + name: str + description: str + +class TenantResponse(BaseModel): + """租户响应模型""" + id: str + name: str + description: str + created_at: datetime diff --git a/api/operation_logger.py b/api/operation_logger.py new file mode 100644 index 0000000..bf8449f --- /dev/null +++ b/api/operation_logger.py @@ -0,0 +1,65 @@ +import logging +from datetime import datetime +from database import get_db_cursor +from libs.exception import OperationLogError + +logger = logging.getLogger(__name__) + +class OperationLogger: + def log_operation(self, user_id: int, operation_type: str, endpoint: str, + parameters: str = None, status: str = "SUCCESS"): + """记录API操作日志""" + try: + with get_db_cursor(db_type='sqlite') as cursor: + cursor.execute( + """INSERT INTO api_operations + (user_id, operation_type, endpoint, parameters, status) + VALUES (?, ?, ?, ?, ?)""", + (user_id, operation_type, endpoint, parameters, status) + ) + return cursor.lastrowid + except Exception as e: + logger.error(f"记录操作日志失败: {e}") + raise OperationLogError("操作日志记录失败") + + def get_operations(self, user_id: int = None, + start_time: datetime = None, + end_time: datetime = None, + limit: int = 100): + """查询操作日志""" + query = "SELECT * FROM api_operations WHERE 1=1" + params = [] + + if user_id: + query += " AND user_id = ?" + params.append(user_id) + if start_time: + query += " AND created_at >= ?" + params.append(start_time) + if end_time: + query += " AND created_at <= ?" + params.append(end_time) + + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + + try: + with get_db_cursor(db_type='sqlite') as cursor: + cursor.execute(query, params) + return cursor.fetchall() + except Exception as e: + logger.error(f"查询操作日志失败: {e}") + raise OperationLogError("操作日志查询失败") + + def clean_old_logs(self, days: int = 30): + """清理过期日志""" + try: + with get_db_cursor(db_type='sqlite') as cursor: + cursor.execute( + "DELETE FROM api_operations WHERE created_at < datetime('now', ?)", + (f"-{days} days",) + ) + return cursor.rowcount + except Exception as e: + logger.error(f"清理操作日志失败: {e}") + raise OperationLogError("操作日志清理失败") diff --git a/api/provider_config.json b/api/provider_config.json new file mode 100644 index 0000000..d189c3d --- /dev/null +++ b/api/provider_config.json @@ -0,0 +1,11 @@ +{ + "providers": [ + { + "provider_name": "tongyi", + "provider_type": "custom", + "config": { + "dashscope_api_key": "sk-bb8c9e83d13241c0b40d7d873976acaa" + } + } + ] +} diff --git a/api/provider_manager.py b/api/provider_manager.py new file mode 100644 index 0000000..090973b --- /dev/null +++ b/api/provider_manager.py @@ -0,0 +1,201 @@ +import os +import json +import logging +import psycopg2.extras +from database import get_db_cursor, execute_query, execute_update +from encryption import Encryption +from config import CONFIG_PATHS +from tenant_manager import TenantManager + +# 配置日志 +logger = logging.getLogger(__name__) + +class ProviderManager: + """提供商管理类""" + + @staticmethod + def load_config(config_path): + """加载提供商配置文件""" + try: + if not os.path.exists(config_path): + raise FileNotFoundError(f"配置文件 {config_path} 不存在!") + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + return config + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + raise + + @staticmethod + def check_provider_exists(tenant_id, provider_name): + """检查指定租户是否已存在指定的提供商""" + try: + query = """ + SELECT COUNT(*) FROM providers + WHERE tenant_id = %s AND provider_name = %s; + """ + count = execute_query(query, (tenant_id, provider_name), fetch_one=True)[0] + return count > 0 + except Exception as e: + logger.error(f"检查提供商是否存在时发生错误: {e}") + return False + + @staticmethod + def add_provider_for_tenant(tenant_id, public_key_pem, provider_config): + """为指定租户添加提供商记录""" + try: + # 检查必要字段 + if "provider_name" not in provider_config: + logger.error("提供商配置缺少必要字段: provider_name") + raise ValueError("提供商配置缺少必要字段: provider_name") + + if "config" not in provider_config: + logger.error("提供商配置缺少必要字段: config") + raise ValueError("提供商配置缺少必要字段: config") + + # 检查提供商是否已存在 + provider_name = provider_config["provider_name"] + if ProviderManager.check_provider_exists(tenant_id, provider_name): + logger.info(f"租户 {tenant_id} 已存在提供商 {provider_name},跳过添加。") + return + + # 加密API密钥 + api_key = provider_config["config"].get("dashscope_api_key", "") + if api_key: + encrypted_api_key = Encryption.encrypt_api_key(public_key_pem, api_key) + + # 构造加密配置 + encrypted_config = { + "dashscope_api_key": encrypted_api_key + } + + # 插入提供商记录 + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + insert_query = """ + INSERT INTO providers ( + id, tenant_id, provider_name, provider_type, + encrypted_config, is_valid, created_at, updated_at + ) VALUES ( + uuid_generate_v4(), %s, %s, %s, %s, TRUE, NOW(), NOW() + ); + """ + cursor.execute( + insert_query, ( + tenant_id, + provider_name, + provider_config.get("provider_type", "custom"), + json.dumps(encrypted_config) + ) + ) + + logger.info(f"为租户 {tenant_id} 添加提供商记录成功!") + return True + else: + logger.warning(f"提供商 {provider_name} 配置中缺少API密钥,跳过添加。") + return False + except Exception as e: + logger.error(f"为租户 {tenant_id} 添加提供商记录失败: {e}") + raise + + @staticmethod + def delete_providers_for_tenant(tenant_id): + """删除指定租户下的所有提供商记录""" + try: + query = """ + DELETE FROM providers + WHERE tenant_id = %s; + """ + rows_affected = execute_update(query, (tenant_id,)) + logger.info(f"租户 {tenant_id} 下的所有提供商记录已删除,共 {rows_affected} 条。") + return rows_affected + except Exception as e: + logger.error(f"删除租户 {tenant_id} 的提供商记录失败: {e}") + raise + + @staticmethod + def delete_provider_for_tenant(tenant_id, provider_name): + """删除指定租户下的特定提供商记录""" + try: + query = """ + DELETE FROM providers + WHERE tenant_id = %s AND provider_name = %s; + """ + rows_affected = execute_update(query, (tenant_id, provider_name)) + + if rows_affected > 0: + logger.info(f"租户 {tenant_id} 下的提供商 {provider_name} 已删除。") + else: + logger.warning(f"租户 {tenant_id} 下不存在提供商 {provider_name}。") + + return rows_affected + except Exception as e: + logger.error(f"删除租户 {tenant_id} 下的提供商 {provider_name} 失败: {e}") + raise + + @staticmethod + def add_providers_for_all_tenants(config_path=CONFIG_PATHS['provider_config']): + """为所有租户添加提供商""" + try: + # 加载提供商配置 + config = ProviderManager.load_config(config_path) + providers = config.get("providers", []) + + # 获取所有租户 + tenants = TenantManager.get_all_tenants() + total_added = 0 + + # 为每个租户添加提供商 + for tenant in tenants: + tenant_id = tenant['id'] + public_key_pem = tenant['encrypt_public_key'] + for provider_config in providers: + if ProviderManager.add_provider_for_tenant(tenant_id, public_key_pem, provider_config): + total_added += 1 + + logger.info(f"为所有租户添加提供商完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为所有租户添加提供商失败: {e}") + raise + + @staticmethod + def add_providers_for_tenant(tenant_name, config_path=CONFIG_PATHS['provider_config']): + """为指定租户添加提供商""" + try: + # 加载提供商配置 + config = ProviderManager.load_config(config_path) + providers = config.get("providers", []) + + # 获取租户信息 + tenant = TenantManager.get_tenant_by_name(tenant_name) + if not tenant: + logger.error(f"未找到租户: {tenant_name}") + return 0 + + # 为租户添加提供商 + total_added = 0 + for provider_config in providers: + if ProviderManager.add_provider_for_tenant(tenant['id'], tenant['encrypt_public_key'], provider_config): + total_added += 1 + + logger.info(f"为租户 {tenant_name} 添加提供商完成,共添加 {total_added} 条记录。") + return total_added + except Exception as e: + logger.error(f"为租户 {tenant_name} 添加提供商失败: {e}") + raise + + @staticmethod + def get_providers_for_tenant(tenant_id): + """获取指定租户下的所有提供商""" + try: + query = """ + SELECT id, provider_name, provider_type, is_valid + FROM providers + WHERE tenant_id = %s; + """ + providers = execute_query(query, (tenant_id,)) + return providers + except Exception as e: + logger.error(f"获取租户 {tenant_id} 的提供商失败: {e}") + return [] diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..8b3f2a5 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,25 @@ +# 数据库操作 +psycopg2-binary>=2.9.5 + +# 加密模块 +pycryptodome>=3.18.0 +cryptography>=41.0.5 +gmpy2>=2.2.0a1 + +# 数据处理 +openpyxl>=3.1.2 +pandas>=2.1.0 + +# 环境变量管理 +python-dotenv>=1.0.0 + +# 日志和命令行 +argparse>=1.4.0 + +# API相关新增依赖 +fastapi>=0.95.2 +uvicorn>=0.22.0 +python-jose>=3.3.0 +passlib>=1.7.4 +python-multipart>=0.0.6 +werkzeug>=2.3.7 diff --git a/api/tenant_manager.py b/api/tenant_manager.py new file mode 100644 index 0000000..c657003 --- /dev/null +++ b/api/tenant_manager.py @@ -0,0 +1,115 @@ +import os +import uuid +import logging +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from psycopg2.extras import RealDictCursor +import psycopg2.extras +from database import get_db_cursor, execute_query +from config import CONFIG_PATHS + +# 配置日志 +logger = logging.getLogger(__name__) + +class TenantManager: + """租户管理类""" + + @staticmethod + def generate_rsa_key_pair(): + """生成RSA密钥对""" + try: + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + public_key = private_key.public_key() + + # 将公钥序列化为PEM格式 + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode("utf-8") + + return public_key_pem, private_key + except Exception as e: + logger.error(f"生成RSA密钥对失败: {e}") + raise + + @staticmethod + def save_private_key(tenant_id, private_key): + """保存私钥到文件""" + try: + privkey_dir = os.path.join(CONFIG_PATHS['privkeys_dir'], str(tenant_id)) + os.makedirs(privkey_dir, exist_ok=True) + + privkey_path = os.path.join(privkey_dir, "private.pem") + + with open(privkey_path, "wb") as key_file: + key_file.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) + + logger.info(f"私钥已保存到 {privkey_path}") + return privkey_path + except Exception as e: + logger.error(f"保存私钥失败: {e}") + raise + + @staticmethod + def create_tenant(workspace_name): + """创建新租户""" + try: + # 生成UUID和RSA密钥对 + tenant_id = uuid.uuid4() + public_key_pem, private_key = TenantManager.generate_rsa_key_pair() + + # 保存私钥 + TenantManager.save_private_key(tenant_id, private_key) + + # 插入租户记录 + with get_db_cursor() as cursor: + psycopg2.extras.register_uuid() + insert_query = """ + INSERT INTO tenants (id, name, encrypt_public_key) + VALUES (%s, %s, %s); + """ + cursor.execute(insert_query, (tenant_id, workspace_name, public_key_pem)) + + logger.info(f"租户 '{workspace_name}' 创建成功!租户 ID: {tenant_id}") + return tenant_id + except Exception as e: + logger.error(f"创建租户失败: {e}") + raise + + @staticmethod + def get_tenant_by_name(workspace_name): + """根据租户名称获取租户信息""" + try: + query = """ + SELECT id, encrypt_public_key FROM tenants WHERE name = %s; + """ + tenant = execute_query(query, (workspace_name,), cursor_factory=RealDictCursor, fetch_one=True) + + if tenant: + return tenant + else: + logger.warning(f"未找到名称为 '{workspace_name}' 的租户。") + return None + except Exception as e: + logger.error(f"获取租户信息失败: {e}") + raise + + @staticmethod + def get_all_tenants(): + """获取所有租户信息""" + try: + query = "SELECT id, encrypt_public_key FROM tenants;" + tenants = execute_query(query, cursor_factory=RealDictCursor) + return tenants + except Exception as e: + logger.error(f"获取所有租户信息失败: {e}") + return [] diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..0b975fb --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,164 @@ +import pytest +from fastapi.testclient import TestClient +from app import app +import sys +import os +import uuid +from datetime import datetime, timedelta +from datetime import timezone +import jwt +from account_manager import AccountManager +from tenant_manager import TenantManager +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from database import get_db_connection, get_db_cursor +import sqlite3 +from contextlib import contextmanager +import os + +@pytest.fixture(scope="session") +def test_db(): + """创建内存SQLite测试数据库""" + conn = sqlite3.connect(":memory:", check_same_thread=False) + # 初始化测试表结构 + # 创建表结构 + conn.execute(''' + CREATE TABLE accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + email TEXT, + password TEXT NOT NULL, + password_salt TEXT NOT NULL, + avatar TEXT, + interface_language TEXT, + interface_theme TEXT, + timezone TEXT, + last_login_at TIMESTAMP, + last_login_ip TEXT, + status TEXT, + initialized_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP, + last_active_at TIMESTAMP + ) + ''') + + # 插入测试数据 + conn.execute(''' + INSERT INTO accounts (id, name, email, password, password_salt, + interface_language, interface_theme, timezone, + status, initialized_at, created_at, updated_at, last_active_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'), datetime('now')) + ''', ( + '550e8400-e29b-41d4-a716-446655440000', + 'testuser', + 'test@example.com', + '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', # testpass + '$2b$12$EixZaYVK1fsbw1ZfbX3OXe', # bcrypt salt + 'en-US', + 'light', + 'UTC', + 'active' + )) + conn.commit() + yield conn + conn.close() + +@pytest.fixture +def override_db(test_db): + """覆盖原数据库连接和所有数据库相关依赖""" + def _override_db(db_type='sqlite'): + return test_db + + # 覆盖数据库连接和游标 + app.dependency_overrides[get_db_connection] = _override_db + app.dependency_overrides[get_db_cursor] = lambda: test_db.cursor() + + yield + app.dependency_overrides.clear() + +@pytest.fixture +def mocker_fixture(mocker): + """提供统一的mock配置""" + # 模拟AccountManager方法 + mock_user = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "testuser", + "password": "mock_hash", + "password_salt": "mock_salt", + "email": "test@example.com", + "status": "active", + "created_at": "2025-04-27T00:00:00Z", + "updated_at": "2025-04-27T00:00:00Z", + "last_active_at": "2025-04-27T00:00:00Z" + } + + mocker.patch.object(AccountManager, 'create_account', + return_value=mock_user) + # 使用side_effect来区分第一次和第二次调用 + def get_user_side_effect(username): + if username == "nonexistent": + return None + return mock_user + + mocker.patch.object(AccountManager, 'get_user_by_username', + side_effect=get_user_side_effect) + mocker.patch.object(AccountManager, 'update_password', + return_value=True) + mocker.patch.object(AccountManager, 'verify_password', + return_value=True) + + # 模拟TenantManager方法 + mocker.patch.object(TenantManager, 'create_tenant', + return_value=uuid.UUID(int=0)) + mocker.patch.object(TenantManager, 'get_tenant_by_name', + return_value={ + "id": uuid.UUID(int=0), + "name": "testtenant", + "description": "测试租户", + "created_at": datetime.now(timezone.utc) + }) + +@pytest.fixture +def client(test_db, override_db): + """测试客户端""" + # 初始化测试数据库 + test_db.executescript(''' + CREATE TABLE IF NOT EXISTS api_operations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + operation_type TEXT NOT NULL, + endpoint TEXT NOT NULL, + parameters TEXT, + status TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ''') + + with TestClient(app) as client: + yield client + +@pytest.fixture +def auth_headers(mocker): + """获取认证头""" + # 使用与app.py相同的配置 + SECRET_KEY = "your-secret-key-here" + ALGORITHM = "HS256" + + # 生成有效的mock token + payload = { + "sub": "testuser", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "exp": datetime.now(timezone.utc) + timedelta(minutes=30) + } + mock_token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + # 模拟JWT验证 + mocker.patch('app.create_access_token', return_value=mock_token) + mocker.patch('jwt.decode', return_value=payload) + + # 返回有效的mock token + return { + "Authorization": f"Bearer {mock_token}", + "X-User-Id": "550e8400-e29b-41d4-a716-446655440000", + "X-Username": "testuser" + } diff --git a/api/tests/test_accounts.py b/api/tests/test_accounts.py new file mode 100644 index 0000000..d54398d --- /dev/null +++ b/api/tests/test_accounts.py @@ -0,0 +1,54 @@ +import pytest +from fastapi import status +from account_manager import AccountManager + +def test_create_account(client, mocker_fixture): + """测试创建账户""" + # 获取mock对象 + mock_create = AccountManager.create_account + + response = client.post("/api/accounts/", json={ + "username": "newuser", + "email": "new@example.com", + "password": "newpass123" + }) + + # 验证mock调用 + mock_create.assert_called_once_with("newuser", "new@example.com", "newpass123") + assert response.status_code == status.HTTP_200_OK + assert response.json()["user_id"] == "550e8400-e29b-41d4-a716-446655440000" + +def test_get_account(client, auth_headers, mocker_fixture): + """测试获取账户信息""" + # 获取mock对象 + mock_get = AccountManager.get_user_by_username + + response = client.get("/api/accounts/testuser", headers=auth_headers) + + # 验证mock调用 + mock_get.assert_called_once_with("testuser") + assert response.status_code == status.HTTP_200_OK + assert response.json()["username"] == "testuser" + +def test_change_password(client, auth_headers, mocker_fixture): + """测试修改密码""" + # 获取mock对象 + mock_update = AccountManager.update_password + + response = client.put("/api/accounts/password", json={ + "current_password": "testpass", + "new_password": "newpassword123" + }, headers=auth_headers) + + # 验证mock调用 + mock_update.assert_called_once() + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "密码修改成功" + +def test_account_not_found(client, auth_headers, mocker_fixture): + """测试账户不存在""" + # 修改mock抛出404异常 + AccountManager.get_user_by_username.side_effect = Exception("404: 账户不存在") + + response = client.get("/api/accounts/nonexistent", headers=auth_headers) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/api/tests/test_auth.py b/api/tests/test_auth.py new file mode 100644 index 0000000..391d650 --- /dev/null +++ b/api/tests/test_auth.py @@ -0,0 +1,64 @@ +import pytest +from fastapi import status +from account_manager import AccountManager + +def test_login_success(client, mocker_fixture): + """测试登录成功""" + # 设置mock返回验证成功的用户 + mock_user = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "testuser", + "password": "mock_hash", + "password_salt": "mock_salt", + "email": "test@example.com", + "status": "active", + "created_at": "2025-04-27T00:00:00Z", + "updated_at": "2025-04-27T00:00:00Z", + "last_active_at": "2025-04-27T00:00:00Z" + } + AccountManager.get_user_by_username.return_value = mock_user + AccountManager.verify_password.return_value = True + + response = client.post("/api/auth/login", data={ + "username": "testuser", + "password": "testpass" + }) + + # 验证mock调用 + AccountManager.get_user_by_username.assert_called_once_with("testuser") + AccountManager.verify_password.assert_called_once_with( + "testpass", "mock_hash", "mock_salt" + ) + assert response.status_code == status.HTTP_200_OK + assert "access_token" in response.json() + assert response.json()["token_type"] == "bearer" + +def test_login_failed(client, mocker_fixture): + """测试登录失败""" + # 设置mock抛出认证失败异常 + AccountManager.get_user_by_username.side_effect = Exception("认证失败") + + response = client.post("/api/auth/login", data={ + "username": "wronguser", + "password": "wrongpass" + }) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["detail"] == "用户名或密码错误" + +def test_refresh_token(client, auth_headers, mocker_fixture): + """测试刷新令牌""" + response = client.post("/api/auth/refresh", headers=auth_headers) + assert response.status_code == status.HTTP_200_OK + assert "access_token" in response.json() + assert response.json()["token_type"] == "bearer" + +def test_protected_endpoint(client, auth_headers, mocker_fixture): + """测试受保护端点""" + response = client.get("/api/accounts/testuser", headers=auth_headers) + assert response.status_code == status.HTTP_200_OK + +def test_unauthenticated_access(client, mocker_fixture): + """测试未认证访问""" + response = client.get("/api/accounts/testuser") + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/api/tests/test_tenants.py b/api/tests/test_tenants.py new file mode 100644 index 0000000..35dafaa --- /dev/null +++ b/api/tests/test_tenants.py @@ -0,0 +1,44 @@ +import pytest +from fastapi import status +from tenant_manager import TenantManager + +def test_create_tenant(client, auth_headers, mocker_fixture): + """测试创建租户""" + # 获取mock对象 + mock_create = TenantManager.create_tenant + + response = client.post("/api/tenants/", json={ + "name": "testtenant", + "description": "测试租户" + }, headers=auth_headers) + + # 验证mock调用 + mock_create.assert_called_once_with("testtenant") + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "租户创建成功" + +def test_list_tenants(client, auth_headers, mocker_fixture): + """测试获取租户列表""" + response = client.get("/api/tenants/", headers=auth_headers) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) + +def test_get_tenant(client, auth_headers, mocker_fixture): + """测试获取特定租户""" + # 获取mock对象 + mock_get = TenantManager.get_tenant_by_name + + response = client.get("/api/tenants/testtenant", headers=auth_headers) + + # 验证mock调用 + mock_get.assert_called_once_with("testtenant") + assert response.status_code == status.HTTP_200_OK + assert "name" in response.json()["tenant"] + +def test_tenant_not_found(client, auth_headers, mocker_fixture): + """测试租户不存在""" + # 修改mock抛出404异常 + TenantManager.get_tenant_by_name.side_effect = Exception("404: 租户不存在") + + response = client.get("/api/tenants/nonexistent", headers=auth_headers) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/web/docs/dev_plan.md b/web/docs/dev_plan.md new file mode 100644 index 0000000..9d2fcfc --- /dev/null +++ b/web/docs/dev_plan.md @@ -0,0 +1,69 @@ +# Dify管理系统开发计划 + +## 1. 认证模块 +### 功能清单 +```mermaid +graph TD + A[认证模块] --> B[账号密码登录] + A --> C[后台用户注册] + A --> D[JWT自动续期] + B --> B1[RSA加密传输] + C --> C1[管理员权限校验] +``` + +### 接口适配 +```typescript +// web/src/api/auth/index.ts +interface LoginParams { + username: string + password: string // RSA加密后传输 + tenantId?: string +} + +interface RegisterParams { + username: string + password: string + email: string + inviteCode?: string // 邀请码机制 +} +``` + +## 2. Dify账号管理 +### 功能架构 +```mermaid +sequenceDiagram + Frontend->>Backend: POST /api/dify_accounts + Backend->>Database: 创建用户记录 + Database-->>Backend: 返回用户ID + Backend-->>Frontend: 完整用户信息 + + Frontend->>Backend: PUT /api/accounts/password + Backend->>Database: 更新密码哈希 + Database-->>Backend: 确认更新 + Backend-->>Frontend: 操作结果 +``` + +## 3. 租户管理模块 +### 功能增强 +```mermaid +graph LR + A[租户管理] --> B[创建租户] + A --> C[生成RSA密钥对] + A --> D[成员管理] + B --> B1[初始化数据库] + C --> C1[公私钥存储] + D --> D1[角色分配] +``` + +## 开发阶段规划 +| 阶段 | 任务 | 前端 | 后端 | 联调 | +|------|------|------|------|------| +| 1 | 登录/注册 | 2天 | 1天 | 1天 | +| 2 | 账号管理 | 3天 | 2天 | 2天 | +| 3 | 租户增强 | 2天 | 3天 | 2天 | + +## 验收标准 +- [ ] 支持RSA加密传输密码 +- [ ] 用户列表支持多租户过滤 +- [ ] 创建租户自动生成密钥对 +- [ ] 所有操作记录审计日志 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..74c4226 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Dify Admin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..29511ce --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "dify-admin-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.5.0", + "element-plus": "^2.4.0", + "pinia": "^2.1.0", + "vue": "^3.3.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.3.0", + "@vue/compiler-sfc": "^3.3.0", + "typescript": "^5.2.0", + "vite": "^4.4.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..b5d2538 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,926 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.1 + version: 2.3.1(vue@3.5.13(typescript@5.8.3)) + axios: + specifier: ^1.5.0 + version: 1.9.0 + element-plus: + specifier: ^2.4.0 + version: 2.9.9(vue@3.5.13(typescript@5.8.3)) + pinia: + specifier: ^2.1.0 + version: 2.3.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) + vue: + specifier: ^3.3.0 + version: 3.5.13(typescript@5.8.3) + vue-router: + specifier: ^4.2.0 + version: 4.5.1(vue@3.5.13(typescript@5.8.3)) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^4.3.0 + version: 4.6.2(vite@4.5.13)(vue@3.5.13(typescript@5.8.3)) + '@vue/compiler-sfc': + specifier: ^3.3.0 + version: 3.5.13 + typescript: + specifier: ^5.2.0 + version: 5.8.3 + vite: + specifier: ^4.4.0 + version: 4.5.13 + +packages: + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@element-plus/icons-vue@2.3.1': + resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.6.9': + resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + + '@floating-ui/dom@1.6.13': + resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@sxzz/popperjs-es@2.11.7': + resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.16': + resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} + + '@types/web-bluetooth@0.0.16': + resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} + + '@vitejs/plugin-vue@4.6.2': + resolution: {integrity: sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.13': + resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} + + '@vue/compiler-dom@3.5.13': + resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==} + + '@vue/compiler-sfc@3.5.13': + resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==} + + '@vue/compiler-ssr@3.5.13': + resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.13': + resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} + + '@vue/runtime-core@3.5.13': + resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==} + + '@vue/runtime-dom@3.5.13': + resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==} + + '@vue/server-renderer@3.5.13': + resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==} + peerDependencies: + vue: 3.5.13 + + '@vue/shared@3.5.13': + resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + + '@vueuse/core@9.13.0': + resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} + + '@vueuse/metadata@9.13.0': + resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} + + '@vueuse/shared@9.13.0': + resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.9.0: + resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + element-plus@2.9.9: + resolution: {integrity: sha512-gN553+xr7ETkhJhH26YG0fERmd2BSCcQKslbtR8fats0Mc0yCtZOXr00bmoPOt5xGzhuRN1TWc9+f1pCaiA0/Q==} + peerDependencies: + vue: ^3.2.0 + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + rollup@3.29.5: + resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + vite@4.5.13: + resolution: {integrity: sha512-Hgp8IF/yZDzKsN1hQWOuQZbrKiaFsbQud+07jJ8h9m9PaHWkpvZ5u55Xw5yYjWRXwRQ4jwFlJvY7T7FUJG9MCA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.5.1: + resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==} + peerDependencies: + vue: ^3.2.0 + + vue@3.5.13: + resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@babel/types@7.27.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@ctrl/tinycolor@3.6.1': {} + + '@element-plus/icons-vue@2.3.1(vue@3.5.13(typescript@5.8.3))': + dependencies: + vue: 3.5.13(typescript@5.8.3) + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@floating-ui/core@1.6.9': + dependencies: + '@floating-ui/utils': 0.2.9 + + '@floating-ui/dom@1.6.13': + dependencies: + '@floating-ui/core': 1.6.9 + '@floating-ui/utils': 0.2.9 + + '@floating-ui/utils@0.2.9': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@sxzz/popperjs-es@2.11.7': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.16 + + '@types/lodash@4.17.16': {} + + '@types/web-bluetooth@0.0.16': {} + + '@vitejs/plugin-vue@4.6.2(vite@4.5.13)(vue@3.5.13(typescript@5.8.3))': + dependencies: + vite: 4.5.13 + vue: 3.5.13(typescript@5.8.3) + + '@vue/compiler-core@3.5.13': + dependencies: + '@babel/parser': 7.27.0 + '@vue/shared': 3.5.13 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.13': + dependencies: + '@vue/compiler-core': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/compiler-sfc@3.5.13': + dependencies: + '@babel/parser': 7.27.0 + '@vue/compiler-core': 3.5.13 + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-ssr': 3.5.13 + '@vue/shared': 3.5.13 + estree-walker: 2.0.2 + magic-string: 0.30.17 + postcss: 8.5.3 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.13': + dependencies: + '@vue/compiler-dom': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.13': + dependencies: + '@vue/shared': 3.5.13 + + '@vue/runtime-core@3.5.13': + dependencies: + '@vue/reactivity': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/runtime-dom@3.5.13': + dependencies: + '@vue/reactivity': 3.5.13 + '@vue/runtime-core': 3.5.13 + '@vue/shared': 3.5.13 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@vue/compiler-ssr': 3.5.13 + '@vue/shared': 3.5.13 + vue: 3.5.13(typescript@5.8.3) + + '@vue/shared@3.5.13': {} + + '@vueuse/core@9.13.0(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@types/web-bluetooth': 0.0.16 + '@vueuse/metadata': 9.13.0 + '@vueuse/shared': 9.13.0(vue@3.5.13(typescript@5.8.3)) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@9.13.0': {} + + '@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.8.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.9.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.1.3: {} + + dayjs@1.11.13: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + element-plus@2.9.9(vue@3.5.13(typescript@5.8.3)): + dependencies: + '@ctrl/tinycolor': 3.6.1 + '@element-plus/icons-vue': 2.3.1(vue@3.5.13(typescript@5.8.3)) + '@floating-ui/dom': 1.6.13 + '@popperjs/core': '@sxzz/popperjs-es@2.11.7' + '@types/lodash': 4.17.16 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 9.13.0(vue@3.5.13(typescript@5.8.3)) + async-validator: 4.2.5 + dayjs: 1.11.13 + escape-html: 1.0.3 + lodash: 4.17.21 + lodash-es: 4.17.21 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.13(typescript@5.8.3) + transitivePeerDependencies: + - '@vue/composition-api' + + entities@4.5.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + escape-html@1.0.3: {} + + estree-walker@2.0.2: {} + + follow-redirects@1.15.9: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + lodash-es@4.17.21: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.17.21 + lodash-es: 4.17.21 + + lodash@4.17.21: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + nanoid@3.3.11: {} + + normalize-wheel-es@1.2.0: {} + + picocolors@1.1.1: {} + + pinia@2.3.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.13(typescript@5.8.3) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@vue/composition-api' + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + rollup@3.29.5: + optionalDependencies: + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + typescript@5.8.3: {} + + vite@4.5.13: + dependencies: + esbuild: 0.18.20 + postcss: 8.5.3 + rollup: 3.29.5 + optionalDependencies: + fsevents: 2.3.3 + + vue-demi@0.14.10(vue@3.5.13(typescript@5.8.3)): + dependencies: + vue: 3.5.13(typescript@5.8.3) + + vue-router@4.5.1(vue@3.5.13(typescript@5.8.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.13(typescript@5.8.3) + + vue@3.5.13(typescript@5.8.3): + dependencies: + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-sfc': 3.5.13 + '@vue/runtime-dom': 3.5.13 + '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.3)) + '@vue/shared': 3.5.13 + optionalDependencies: + typescript: 5.8.3 diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..09c7a0c --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/api/account/index.ts b/web/src/api/account/index.ts new file mode 100644 index 0000000..97beae8 --- /dev/null +++ b/web/src/api/account/index.ts @@ -0,0 +1,53 @@ +import { request } from '../../axios/service' +import type { + AccountItem, + AccountListParams, + UpdateAccountParams, + ResetPasswordParams +} from './types' + +export const fetchAccounts = (params: AccountListParams) => + request<{ + accounts: AccountItem[] + total: number + }>({ + method: 'GET', + url: '/accounts/search', + params + }) + +export const updateAccount = (id: string, data: UpdateAccountParams) => + request<{ + message: string + }>({ + method: 'PATCH', + url: `/accounts/${id}`, + data + }) + +export const resetPassword = (id: string, data: ResetPasswordParams) => + request<{ + message: string + }>({ + method: 'POST', + url: `/accounts/${id}/reset-password`, + data + }) + +export const toggleAccountStatus = (id: string) => + request<{ + message: string + }>({ + method: 'POST', + url: `/accounts/${id}/toggle-status` + }) + +export const createAccount = (data: { username: string }) => + request<{ + message: string + account: AccountItem + }>({ + method: 'POST', + url: '/accounts', + data + }) diff --git a/web/src/api/account/types.ts b/web/src/api/account/types.ts new file mode 100644 index 0000000..bfc4aef --- /dev/null +++ b/web/src/api/account/types.ts @@ -0,0 +1,27 @@ +export interface AccountItem { + id: string + username: string + email: string + status: 'active' | 'disabled' + createdAt: string + updatedAt: string +} + +export interface AccountListParams { + page?: number + pageSize?: number + username?: string + email?: string + status?: 'active' | 'disabled' + search?: string +} + +export interface UpdateAccountParams { + username?: string + email?: string +} + +export interface ResetPasswordParams { + newPassword: string + confirmPassword: string +} diff --git a/web/src/api/auth/index.ts b/web/src/api/auth/index.ts new file mode 100644 index 0000000..c2355d0 --- /dev/null +++ b/web/src/api/auth/index.ts @@ -0,0 +1,44 @@ +import { request } from '../../axios/service' +import type { LoginParams, RegisterParams } from '@/api/auth/types' + +export const login = (formData: FormData) => + request<{ access_token: string }>({ + method: 'POST', + url: '/api/auth/login', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + +export const register = (formData: FormData) => + request<{ user_id: string }>({ + method: 'POST', + url: '/api/auth/register', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + +export const getPublicKey = async (): Promise => { + try { + const res = await request<{ data: string }>({ + method: 'GET', + url: '/api/auth/public-key' + }) + if (!res.data) { + throw new Error('公钥获取失败') + } + return res.data + } catch (error) { + console.error('获取公钥失败:', error) + throw new Error('获取公钥失败: ' + (error instanceof Error ? error.message : String(error))) + } +} + +export const refreshToken = () => + request<{ access_token: string }>({ + method: 'POST', + url: '/api/auth/refresh' + }) diff --git a/web/src/api/auth/types.ts b/web/src/api/auth/types.ts new file mode 100644 index 0000000..49e7dd0 --- /dev/null +++ b/web/src/api/auth/types.ts @@ -0,0 +1,22 @@ +export type LoginParams = { + username: string + password: string + tenantId?: string +} + +export type LoginFormData = FormData + +export type RegisterParams = { + username: string + password: string + email: string + confirm_password?: string +} + +export type RegisterFormData = FormData + +export interface AuthResponse { + access_token: string + refresh_token?: string + expires_in: number +} diff --git a/web/src/api/login/index.ts b/web/src/api/login/index.ts new file mode 100644 index 0000000..456e760 --- /dev/null +++ b/web/src/api/login/index.ts @@ -0,0 +1,17 @@ +import { request } from '../../axios/service' +import type { LoginForm, LoginResponse } from './types' + +export const loginApi = (data: LoginForm) => { + const formData = new FormData() + formData.append('username', data.username) + formData.append('password', data.password) + + return request({ + url: '/api/auth/login', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} diff --git a/web/src/api/login/types.ts b/web/src/api/login/types.ts new file mode 100644 index 0000000..4f9642e --- /dev/null +++ b/web/src/api/login/types.ts @@ -0,0 +1,13 @@ +export interface LoginForm { + username: string + password: string +} + +export interface LoginResponse { + token: string + userInfo: { + id: string + username: string + role: string + } +} diff --git a/web/src/api/tenant/index.ts b/web/src/api/tenant/index.ts new file mode 100644 index 0000000..b33adc3 --- /dev/null +++ b/web/src/api/tenant/index.ts @@ -0,0 +1,62 @@ +import { request } from '../../axios/service' +import type { + TenantItem, + TenantForm, + TenantListResponse, + TenantDetailResponse, + CreateTenantResponse +} from './types' + +export const fetchTenants = (params: { page?: number; pageSize?: number }) => + request<{ + tenants: { + id: string + name: string + description: string + createdAt: string + }[] + }>({ + method: 'GET', + url: '/api/tenants', + params + }) + +export const createTenant = (data: TenantForm) => + request<{ + message: string + tenant: { + id: string + name: string + description: string + createdAt: string + } + }>({ + method: 'POST', + url: '/api/tenants', + data: { + name: data.name, + description: data.description + } + }) + +export const getTenantDetail = (name: string) => + request<{ + tenant: { + id: string + name: string + description: string + createdAt: string + } + }>({ + method: 'GET', + url: `/api/tenants/${name}` + }) + +export const updateTenant = (id: string, data: {description: string}) => + request<{ + message: string + }>({ + method: 'PATCH', + url: `/api/tenants/${id}`, + data + }) diff --git a/web/src/api/tenant/types.ts b/web/src/api/tenant/types.ts new file mode 100644 index 0000000..c474da8 --- /dev/null +++ b/web/src/api/tenant/types.ts @@ -0,0 +1,24 @@ +export interface TenantItem { + id: string + name: string + description: string + createdAt: string +} + +export interface TenantForm { + name: string + description: string +} + +export interface TenantListResponse { + tenants: TenantItem[] +} + +export interface TenantDetailResponse { + tenant: TenantItem +} + +export interface CreateTenantResponse { + message: string + tenant: TenantItem +} diff --git a/web/src/axios/config.ts b/web/src/axios/config.ts new file mode 100644 index 0000000..139a276 --- /dev/null +++ b/web/src/axios/config.ts @@ -0,0 +1,12 @@ +import axios from 'axios' +import type { AxiosInstance, AxiosRequestConfig } from 'axios' + +const config: AxiosRequestConfig = { + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 10000, + withCredentials: true +} + +export const createAxios = (): AxiosInstance => { + return axios.create(config) +} diff --git a/web/src/axios/service.ts b/web/src/axios/service.ts new file mode 100644 index 0000000..910daa7 --- /dev/null +++ b/web/src/axios/service.ts @@ -0,0 +1,33 @@ +import { createAxios } from './config' +import { useUserStore } from '../store/modules/user' +import type { AxiosRequestConfig } from 'axios' + +const service = createAxios() + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + const userStore = useUserStore() + if (userStore.token) { + config.headers.Authorization = `Bearer ${userStore.token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + (response) => { + return response.data + }, + (error) => { + return Promise.reject(error) + } +) + +export const request = (config: AxiosRequestConfig): Promise => { + return service(config) +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..700cbff --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import store from './store' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' + +const app = createApp(App) + +app.use(router) +app.use(store) +app.use(ElementPlus) + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..babb015 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,62 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + component: () => import('../views/Auth/Login.vue') + }, + { + path: '/register', + component: () => import('../views/Auth/Register.vue') + }, + { + path: '/', + component: () => import('../views/Layout/index.vue'), + children: [ + { + path: 'dashboard', + component: () => import('../views/Dashboard/index.vue'), + meta: { requiresAuth: true } + }, + { + path: 'user', + component: () => import('../views/User/index.vue'), + meta: { requiresAuth: true } + }, + { + path: 'account', + component: () => import('../views/Account/index.vue'), + meta: { requiresAuth: true } + }, + { + path: 'model', + component: () => import('../views/Model/index.vue') + } + ] + } + ] +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + console.log('路由跳转:', from.path, '->', to.path) + const token = localStorage.getItem('access_token') + console.log('当前token:', token) + + if (to.meta.requiresAuth) { + if (!token) { + console.log('未授权访问,重定向到登录页') + next('/login') + } else { + console.log('已授权,允许访问') + next() + } + } else { + console.log('无需授权,允许访问') + next() + } +}) + +export default router diff --git a/web/src/store/index.ts b/web/src/store/index.ts new file mode 100644 index 0000000..f5ba32d --- /dev/null +++ b/web/src/store/index.ts @@ -0,0 +1,7 @@ +import { createPinia } from 'pinia' +import { useUserStore } from './modules/user' + +const pinia = createPinia() + +export { useUserStore } +export default pinia diff --git a/web/src/store/modules/user.ts b/web/src/store/modules/user.ts new file mode 100644 index 0000000..9133f65 --- /dev/null +++ b/web/src/store/modules/user.ts @@ -0,0 +1,32 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { loginApi } from '../../api/login' +import type { LoginForm } from '../../api/login/types' + +export const useUserStore = defineStore('user', () => { + const token = ref('') + const userInfo = ref({}) + + const setToken = (newToken: string) => { + token.value = newToken + } + + const setUserInfo = (info: object) => { + userInfo.value = info + } + + const login = async (form: LoginForm) => { + const res = await loginApi(form) + setToken(res.token) + setUserInfo(res.userInfo) + return res + } + + return { + token, + userInfo, + setToken, + setUserInfo, + login + } +}) diff --git a/web/src/utils/auth.ts b/web/src/utils/auth.ts new file mode 100644 index 0000000..7943b85 --- /dev/null +++ b/web/src/utils/auth.ts @@ -0,0 +1,33 @@ +import { refreshToken } from '../api/auth/index' +import { setToken } from '../utils/storage' + +const TOKEN_REFRESH_INTERVAL = 15 * 60 * 1000 // 15分钟 + +let refreshTimer: number | null = null + +export const setupTokenRefresh = () => { + // 清除现有定时器 + if (refreshTimer) { + clearTimeout(refreshTimer) + } + + // 设置新的刷新定时器 + refreshTimer = window.setTimeout(async () => { + try { + const { access_token } = await refreshToken() + setToken(access_token) + setupTokenRefresh() // 递归调用保持续期 + } catch (error) { + console.error('Token刷新失败:', error) + // 失败后延迟10秒重试 + refreshTimer = window.setTimeout(setupTokenRefresh, 10000) + } + }, TOKEN_REFRESH_INTERVAL) +} + +export const clearTokenRefresh = () => { + if (refreshTimer) { + clearTimeout(refreshTimer) + refreshTimer = null + } +} diff --git a/web/src/utils/encrypt.ts b/web/src/utils/encrypt.ts new file mode 100644 index 0000000..6e15732 --- /dev/null +++ b/web/src/utils/encrypt.ts @@ -0,0 +1,5 @@ +export const encryptPassword = async (password: string): Promise => { + // 临时方案:后端公钥接口未实现前先返回明文 + console.warn('后端公钥接口未实现,暂时使用明文密码') + return password +} diff --git a/web/src/utils/storage.ts b/web/src/utils/storage.ts new file mode 100644 index 0000000..968bc5d --- /dev/null +++ b/web/src/utils/storage.ts @@ -0,0 +1,17 @@ +export const TOKEN_KEY = 'access_token' + +export const getToken = (): string | null => { + return localStorage.getItem(TOKEN_KEY) +} + +export const setToken = (token: string): void => { + localStorage.setItem(TOKEN_KEY, token) +} + +export const removeToken = (): void => { + localStorage.removeItem(TOKEN_KEY) +} + +export const clearStorage = (): void => { + localStorage.clear() +} diff --git a/web/src/views/Account/index.vue b/web/src/views/Account/index.vue new file mode 100644 index 0000000..33461b4 --- /dev/null +++ b/web/src/views/Account/index.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/views/Auth/Login.vue b/web/src/views/Auth/Login.vue new file mode 100644 index 0000000..0db9a31 --- /dev/null +++ b/web/src/views/Auth/Login.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/web/src/views/Auth/Register.vue b/web/src/views/Auth/Register.vue new file mode 100644 index 0000000..eb08014 --- /dev/null +++ b/web/src/views/Auth/Register.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/web/src/views/Dashboard/index.vue b/web/src/views/Dashboard/index.vue new file mode 100644 index 0000000..b418a5e --- /dev/null +++ b/web/src/views/Dashboard/index.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/src/views/Layout/index.vue b/web/src/views/Layout/index.vue new file mode 100644 index 0000000..ddf22a2 --- /dev/null +++ b/web/src/views/Layout/index.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/web/src/views/Login/components/LoginForm.vue b/web/src/views/Login/components/LoginForm.vue new file mode 100644 index 0000000..dc2fb28 --- /dev/null +++ b/web/src/views/Login/components/LoginForm.vue @@ -0,0 +1,42 @@ + + + diff --git a/web/src/views/Login/index.vue b/web/src/views/Login/index.vue new file mode 100644 index 0000000..b0b4c34 --- /dev/null +++ b/web/src/views/Login/index.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/web/src/views/Model/index.vue b/web/src/views/Model/index.vue new file mode 100644 index 0000000..bdf0352 --- /dev/null +++ b/web/src/views/Model/index.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/web/src/views/User/index.vue b/web/src/views/User/index.vue new file mode 100644 index 0000000..838ee15 --- /dev/null +++ b/web/src/views/User/index.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..cad2926 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "jsx": "preserve", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vite/client", "vue"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..81993dc --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@api': path.resolve(__dirname, './src/api'), + '@utils': path.resolve(__dirname, './src/utils'), + '@views': path.resolve(__dirname, './src/views') + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8001', + changeOrigin: true + } + } + } +})