初始化项目仓库,包含基础结构和开发计划

1. 添加README说明项目结构
2. 配置Python和Node.js的.gitignore
3. 包含认证模块和账号管理的前后端基础代码
4. 开发计划文档记录当前阶段任务
This commit is contained in:
xh.xin 2025-05-02 18:33:06 +08:00
commit 96480a27a9
70 changed files with 6589 additions and 0 deletions

74
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View File

35
api/libs/exception.py Normal file
View 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
View 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

View 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
View 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)

View 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

View 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
View 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
View 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"])

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
})

View 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
View 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
View 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
}

View 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'
}
})
}

View File

@ -0,0 +1,13 @@
export interface LoginForm {
username: string
password: string
}
export interface LoginResponse {
token: string
userInfo: {
id: string
username: string
role: string
}
}

View 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
})

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
import { useUserStore } from './modules/user'
const pinia = createPinia()
export { useUserStore }
export default pinia

View 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
View 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
View 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
View 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()
}

View 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>

View 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.pushVue
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
}
}
}
})