初始化项目仓库,包含基础结构和开发计划
1. 添加README说明项目结构 2. 配置Python和Node.js的.gitignore 3. 包含认证模块和账号管理的前后端基础代码 4. 开发计划文档记录当前阶段任务
This commit is contained in:
commit
96480a27a9
74
.gitignore
vendored
Normal file
74
.gitignore
vendored
Normal file
@ -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
|
||||
32
README.md
Normal file
32
README.md
Normal file
@ -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`
|
||||
252
api/account_manager.py
Normal file
252
api/account_manager.py
Normal file
@ -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 []
|
||||
76
api/api_user_manager.py
Normal file
76
api/api_user_manager.py
Normal file
@ -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
|
||||
364
api/app.py
Normal file
364
api/app.py
Normal file
@ -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)
|
||||
108
api/backend_account_manager.py
Normal file
108
api/backend_account_manager.py
Normal file
@ -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
|
||||
341
api/cli.py
Normal file
341
api/cli.py
Normal file
@ -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()
|
||||
28
api/config.py
Normal file
28
api/config.py
Normal file
@ -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')
|
||||
}
|
||||
154
api/database.py
Normal file
154
api/database.py
Normal file
@ -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
|
||||
145
api/encryption.py
Normal file
145
api/encryption.py
Normal file
@ -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
|
||||
0
api/libs/__init__.py
Normal file
0
api/libs/__init__.py
Normal file
35
api/libs/exception.py
Normal file
35
api/libs/exception.py
Normal file
@ -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 = "操作日志处理失败"
|
||||
119
api/libs/external_api.py
Normal file
119
api/libs/external_api.py
Normal file
@ -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"(?<!^)(?=[A-Z])", "_", type(e).__name__).lower(),
|
||||
"message": getattr(e, "description", http_status_message(status_code)),
|
||||
"status": status_code,
|
||||
}
|
||||
|
||||
if (
|
||||
default_data["message"]
|
||||
and default_data["message"] == "Failed to decode JSON object: Expecting value: line 1 column 1 (char 0)"
|
||||
):
|
||||
default_data["message"] = "Invalid JSON payload received or JSON payload is empty."
|
||||
|
||||
headers = e.get_response().headers
|
||||
elif isinstance(e, ValueError):
|
||||
status_code = 400
|
||||
default_data = {
|
||||
"code": "invalid_param",
|
||||
"message": str(e),
|
||||
"status": status_code,
|
||||
}
|
||||
elif isinstance(e, AppInvokeQuotaExceededError):
|
||||
status_code = 429
|
||||
default_data = {
|
||||
"code": "too_many_requests",
|
||||
"message": str(e),
|
||||
"status": status_code,
|
||||
}
|
||||
else:
|
||||
status_code = 500
|
||||
default_data = {
|
||||
"message": http_status_message(status_code),
|
||||
}
|
||||
|
||||
# Werkzeug exceptions generate a content-length header which is added
|
||||
# to the response in addition to the actual content-length header
|
||||
# https://github.com/flask-restful/flask-restful/issues/534
|
||||
remove_headers = ("Content-Length",)
|
||||
|
||||
for header in remove_headers:
|
||||
headers.pop(header, None)
|
||||
|
||||
data = getattr(e, "data", default_data)
|
||||
|
||||
error_cls_name = type(e).__name__
|
||||
if error_cls_name in self.errors:
|
||||
custom_data = self.errors.get(error_cls_name, {})
|
||||
custom_data = custom_data.copy()
|
||||
status_code = custom_data.get("status", 500)
|
||||
|
||||
if "message" in custom_data:
|
||||
custom_data["message"] = custom_data["message"].format(
|
||||
message=str(e.description if hasattr(e, "description") else e)
|
||||
)
|
||||
data.update(custom_data)
|
||||
|
||||
# record the exception in the logs when we have a server error of status code: 500
|
||||
if status_code and status_code >= 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
|
||||
241
api/libs/gmpy2_pkcs10aep_cipher.py
Normal file
241
api/libs/gmpy2_pkcs10aep_cipher.py
Normal file
@ -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)
|
||||
311
api/libs/helper.py
Normal file
311
api/libs/helper.py
Normal file
@ -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)
|
||||
5
api/libs/infinite_scroll_pagination.py
Normal file
5
api/libs/infinite_scroll_pagination.py
Normal file
@ -0,0 +1,5 @@
|
||||
class InfiniteScrollPagination:
|
||||
def __init__(self, data, limit, has_more):
|
||||
self.data = data
|
||||
self.limit = limit
|
||||
self.has_more = has_more
|
||||
46
api/libs/json_in_md_parser.py
Normal file
46
api/libs/json_in_md_parser.py
Normal file
@ -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
|
||||
106
api/libs/login.py
Normal file
106
api/libs/login.py
Normal file
@ -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://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||
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 <api-key>' 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 <api-key>' 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
|
||||
133
api/libs/oauth.py
Normal file
133
api/libs/oauth.py
Normal file
@ -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"])
|
||||
303
api/libs/oauth_data_source.py
Normal file
303
api/libs/oauth_data_source.py
Normal file
@ -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
|
||||
22
api/libs/passport.py
Normal file
22
api/libs/passport.py
Normal file
@ -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.")
|
||||
26
api/libs/password.py
Normal file
26
api/libs/password.py
Normal file
@ -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)
|
||||
93
api/libs/rsa.py
Normal file
93
api/libs/rsa.py
Normal file
@ -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
|
||||
52
api/libs/smtp.py
Normal file
52
api/libs/smtp.py
Normal file
@ -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()
|
||||
67
api/model_config.json
Normal file
67
api/model_config.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
335
api/model_manager.py
Normal file
335
api/model_manager.py
Normal file
@ -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
|
||||
59
api/model_volc_config.json
Normal file
59
api/model_volc_config.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
33
api/models.py
Normal file
33
api/models.py
Normal file
@ -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
|
||||
65
api/operation_logger.py
Normal file
65
api/operation_logger.py
Normal file
@ -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("操作日志清理失败")
|
||||
11
api/provider_config.json
Normal file
11
api/provider_config.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"provider_name": "tongyi",
|
||||
"provider_type": "custom",
|
||||
"config": {
|
||||
"dashscope_api_key": "sk-bb8c9e83d13241c0b40d7d873976acaa"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
201
api/provider_manager.py
Normal file
201
api/provider_manager.py
Normal file
@ -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 []
|
||||
25
api/requirements.txt
Normal file
25
api/requirements.txt
Normal file
@ -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
|
||||
115
api/tenant_manager.py
Normal file
115
api/tenant_manager.py
Normal file
@ -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 []
|
||||
164
api/tests/conftest.py
Normal file
164
api/tests/conftest.py
Normal file
@ -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"
|
||||
}
|
||||
54
api/tests/test_accounts.py
Normal file
54
api/tests/test_accounts.py
Normal file
@ -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
|
||||
64
api/tests/test_auth.py
Normal file
64
api/tests/test_auth.py
Normal file
@ -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
|
||||
44
api/tests/test_tenants.py
Normal file
44
api/tests/test_tenants.py
Normal file
@ -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
|
||||
69
web/docs/dev_plan.md
Normal file
69
web/docs/dev_plan.md
Normal file
@ -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加密传输密码
|
||||
- [ ] 用户列表支持多租户过滤
|
||||
- [ ] 创建租户自动生成密钥对
|
||||
- [ ] 所有操作记录审计日志
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dify Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
web/package.json
Normal file
24
web/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
926
web/pnpm-lock.yaml
Normal file
926
web/pnpm-lock.yaml
Normal file
@ -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
|
||||
26
web/src/App.vue
Normal file
26
web/src/App.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from './store/modules/user'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化用户状态
|
||||
if (!userStore.token) {
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
</style>
|
||||
53
web/src/api/account/index.ts
Normal file
53
web/src/api/account/index.ts
Normal file
@ -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
|
||||
})
|
||||
27
web/src/api/account/types.ts
Normal file
27
web/src/api/account/types.ts
Normal file
@ -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
|
||||
}
|
||||
44
web/src/api/auth/index.ts
Normal file
44
web/src/api/auth/index.ts
Normal file
@ -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<string> => {
|
||||
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'
|
||||
})
|
||||
22
web/src/api/auth/types.ts
Normal file
22
web/src/api/auth/types.ts
Normal file
@ -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
|
||||
}
|
||||
17
web/src/api/login/index.ts
Normal file
17
web/src/api/login/index.ts
Normal file
@ -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<LoginResponse>({
|
||||
url: '/api/auth/login',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
13
web/src/api/login/types.ts
Normal file
13
web/src/api/login/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface LoginForm {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
userInfo: {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
62
web/src/api/tenant/index.ts
Normal file
62
web/src/api/tenant/index.ts
Normal file
@ -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
|
||||
})
|
||||
24
web/src/api/tenant/types.ts
Normal file
24
web/src/api/tenant/types.ts
Normal file
@ -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
|
||||
}
|
||||
12
web/src/axios/config.ts
Normal file
12
web/src/axios/config.ts
Normal file
@ -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)
|
||||
}
|
||||
33
web/src/axios/service.ts
Normal file
33
web/src/axios/service.ts
Normal file
@ -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 = <T = any>(config: AxiosRequestConfig): Promise<T> => {
|
||||
return service(config)
|
||||
}
|
||||
14
web/src/main.ts
Normal file
14
web/src/main.ts
Normal file
@ -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')
|
||||
62
web/src/router/index.ts
Normal file
62
web/src/router/index.ts
Normal file
@ -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
|
||||
7
web/src/store/index.ts
Normal file
7
web/src/store/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { useUserStore } from './modules/user'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export { useUserStore }
|
||||
export default pinia
|
||||
32
web/src/store/modules/user.ts
Normal file
32
web/src/store/modules/user.ts
Normal file
@ -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
|
||||
}
|
||||
})
|
||||
33
web/src/utils/auth.ts
Normal file
33
web/src/utils/auth.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
5
web/src/utils/encrypt.ts
Normal file
5
web/src/utils/encrypt.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const encryptPassword = async (password: string): Promise<string> => {
|
||||
// 临时方案:后端公钥接口未实现前先返回明文
|
||||
console.warn('后端公钥接口未实现,暂时使用明文密码')
|
||||
return password
|
||||
}
|
||||
17
web/src/utils/storage.ts
Normal file
17
web/src/utils/storage.ts
Normal file
@ -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()
|
||||
}
|
||||
181
web/src/views/Account/index.vue
Normal file
181
web/src/views/Account/index.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="account-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>账号管理</span>
|
||||
<el-button type="primary" @click="handleCreate">新增账号</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="search-container">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索用户名或邮箱"
|
||||
style="width: 300px; margin-right: 10px"
|
||||
clearable
|
||||
@clear="handleSearchClear"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredAccountList" border style="width: 100%">
|
||||
<el-table-column prop="username" label="用户名" width="180" />
|
||||
<el-table-column prop="email" label="邮箱" width="220" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
|
||||
{{ row.status === 'active' ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
@current-change="fetchAccounts"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
fetchAccounts,
|
||||
toggleAccountStatus,
|
||||
createAccount,
|
||||
updateAccount
|
||||
} from '@/api/account'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
status: 'active' | 'disabled'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const accountList = ref<Account[]>([])
|
||||
const filteredAccountList = ref<Account[]>([])
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const fetchAccountData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await fetchAccounts({
|
||||
page: pagination.value.current,
|
||||
pageSize: pagination.value.size
|
||||
})
|
||||
accountList.value = res.accounts
|
||||
pagination.value.total = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (account: Account) => {
|
||||
try {
|
||||
await toggleAccountStatus(account.id)
|
||||
await fetchAccountData()
|
||||
} catch (error) {
|
||||
console.error('状态切换失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
ElMessageBox.prompt('请输入新账号用户名', '创建账号', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /^[a-zA-Z0-9_]{4,20}$/,
|
||||
inputErrorMessage: '用户名必须为4-20位字母数字或下划线'
|
||||
}).then(async ({ value }) => {
|
||||
try {
|
||||
await createAccount({ username: value })
|
||||
ElMessage.success('账号创建成功')
|
||||
await fetchAccountData()
|
||||
} catch (error) {
|
||||
ElMessage.error('账号创建失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (account: Account) => {
|
||||
ElMessageBox.prompt('请输入新用户名', '编辑账号', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: account.username,
|
||||
inputPattern: /^[a-zA-Z0-9_]{4,20}$/,
|
||||
inputErrorMessage: '用户名必须为4-20位字母数字或下划线'
|
||||
}).then(async ({ value }) => {
|
||||
try {
|
||||
await updateAccount(account.id, { username: value })
|
||||
ElMessage.success('账号更新成功')
|
||||
await fetchAccountData()
|
||||
} catch (error) {
|
||||
ElMessage.error('账号更新失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchClear = () => {
|
||||
filteredAccountList.value = accountList.value
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await fetchAccounts({
|
||||
page: 1,
|
||||
pageSize: pagination.value.size,
|
||||
search: searchQuery.value
|
||||
})
|
||||
filteredAccountList.value = res.accounts
|
||||
pagination.value.total = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccountData().then(() => {
|
||||
filteredAccountList.value = accountList.value
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
101
web/src/views/Auth/Login.vue
Normal file
101
web/src/views/Auth/Login.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-form
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
ref="loginFormRef"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username" label="用户名">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" label="密码">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleLogin"
|
||||
:loading="loading"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
@click="$router.push('/register')"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { login } from '../../api/auth/index'
|
||||
import { encryptPassword } from '../../utils/encrypt'
|
||||
import { setupTokenRefresh } from '../../utils/auth'
|
||||
import { setToken, getToken } from '../../utils/storage'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { LoginParams, LoginFormData } from '@/api/auth/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loginForm = ref<LoginParams>({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loginRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const loginFormRef = ref()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await loginFormRef.value.validate()
|
||||
const encryptedPassword = await encryptPassword(loginForm.value.password)
|
||||
const formData = new FormData()
|
||||
formData.append('username', loginForm.value.username)
|
||||
formData.append('password', encryptedPassword)
|
||||
|
||||
const res = await login(formData as LoginFormData)
|
||||
console.log('登录成功:', res.access_token)
|
||||
setToken(res.access_token) // 存储token
|
||||
setupTokenRefresh() // 启动token自动续期
|
||||
console.log('存储的token:', getToken()) // 验证token是否存储成功
|
||||
// 使用router.push确保Vue路由正确处理跳转
|
||||
router.push('/dashboard')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
133
web/src/views/Auth/Register.vue
Normal file
133
web/src/views/Auth/Register.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<el-form
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
ref="registerFormRef"
|
||||
>
|
||||
<el-form-item prop="username" label="用户名">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="email" label="邮箱">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
prefix-icon="Message"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" label="密码">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirm_password" label="确认密码">
|
||||
<el-input
|
||||
v-model="registerForm.confirm_password"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleRegister"
|
||||
:loading="loading"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
@click="$router.push('/login')"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
已有账号,去登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { register } from '@/api/auth'
|
||||
import { encryptPassword } from '@/utils/encrypt'
|
||||
import type { RegisterParams, RegisterFormData } from '@/api/auth/types'
|
||||
|
||||
const registerForm = ref<RegisterParams>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 4, max: 16, message: '长度在4到16个字符', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/,
|
||||
message: '至少8位且包含字母和数字' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: (rule: any, value: string, callback: Function) => {
|
||||
if (value !== registerForm.value.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const registerFormRef = ref()
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await registerFormRef.value.validate()
|
||||
const encryptedPassword = await encryptPassword(registerForm.value.password)
|
||||
const formData = new FormData()
|
||||
formData.append('username', registerForm.value.username)
|
||||
formData.append('password', encryptedPassword)
|
||||
formData.append('email', registerForm.value.email)
|
||||
|
||||
const res = await register(formData)
|
||||
console.log('注册成功:', res.user_id)
|
||||
// 跳转到登录页或其他处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
42
web/src/views/Dashboard/index.vue
Normal file
42
web/src/views/Dashboard/index.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<h1>欢迎使用Dify管理后台</h1>
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统概览</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<p>当前版本: v1.0.0</p>
|
||||
<p>登录用户: 管理员</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Dashboard mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.box-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.card-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-content {
|
||||
line-height: 2;
|
||||
}
|
||||
</style>
|
||||
158
web/src/views/Layout/index.vue
Normal file
158
web/src/views/Layout/index.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar" :class="{ collapsed: isCollapse }">
|
||||
<div class="logo">
|
||||
<span>Dify Admin</span>
|
||||
</div>
|
||||
<el-menu
|
||||
router
|
||||
:default-active="$route.path"
|
||||
class="menu"
|
||||
background-color="#001529"
|
||||
text-color="#fff"
|
||||
active-text-color="#ffd04b"
|
||||
:collapse="isCollapse"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><icon-menu /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/account">
|
||||
<el-icon><user /></el-icon>
|
||||
<span>账号管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/model">
|
||||
<el-icon><cpu /></el-icon>
|
||||
<span>模型管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<el-icon @click="toggleCollapse">
|
||||
<expand v-if="isCollapse" />
|
||||
<fold v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="30" />
|
||||
<span class="username">管理员</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>个人中心</el-dropdown-item>
|
||||
<el-dropdown-item>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Menu as IconMenu,
|
||||
User,
|
||||
Cpu,
|
||||
Expand,
|
||||
Fold
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
background-color: #001529;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
42
web/src/views/Login/components/LoginForm.vue
Normal file
42
web/src/views/Login/components/LoginForm.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleLogin">登录</el-button>
|
||||
<el-button @click="handleRegister">注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../../store/modules/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
// 调用登录API
|
||||
await userStore.login(form)
|
||||
router.push('/dashboard')
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = () => {
|
||||
router.push('/register')
|
||||
}
|
||||
</script>
|
||||
19
web/src/views/Login/index.vue
Normal file
19
web/src/views/Login/index.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LoginForm from './components/LoginForm.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
16
web/src/views/Model/index.vue
Normal file
16
web/src/views/Model/index.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="model-container">
|
||||
<h1>模型管理</h1>
|
||||
<!-- 模型列表和配置表单将在这里实现 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// 模型管理逻辑将在这里实现
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
16
web/src/views/User/index.vue
Normal file
16
web/src/views/User/index.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="user-container">
|
||||
<h1>用户管理</h1>
|
||||
<!-- 用户列表和操作按钮将在这里实现 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// 用户管理逻辑将在这里实现
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
24
web/vite.config.ts
Normal file
24
web/vite.config.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user