feat: add admin UI frontend and complete backend APIs
Some checks failed
continuous-integration/drone/push Build is failing

- Add Vue 3 frontend with Element Plus
- Implement login, dashboard, tenant management
- Add app configuration, logs viewer, stats pages
- Add user management for admins
- Update Drone CI to build and deploy frontend
- Frontend ports: 3001 (test), 4001 (prod)
This commit is contained in:
111
2026-01-23 15:48:50 +08:00
parent daa8125c58
commit b89d5ddee9
31 changed files with 3115 additions and 8 deletions

223
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,223 @@
"""认证路由"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth import (
authenticate_user,
create_access_token,
decode_token,
update_last_login,
hash_password,
TokenData,
UserInfo
)
from ..models.user import User
router = APIRouter(prefix="/auth", tags=["认证"])
security = HTTPBearer()
class LoginRequest(BaseModel):
"""登录请求"""
username: str
password: str
class LoginResponse(BaseModel):
"""登录响应"""
success: bool
token: Optional[str] = None
user: Optional[UserInfo] = None
error: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str
new_password: str
# 权限依赖
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""获取当前用户"""
token = credentials.credentials
token_data = decode_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user = db.query(User).filter(User.id == token_data.user_id).first()
if not user or user.status != 1:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已禁用"
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""要求管理员权限"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return user
async def require_operator(user: User = Depends(get_current_user)) -> User:
"""要求操作员以上权限"""
if user.role not in ('admin', 'operator'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要操作员以上权限"
)
return user
# API 端点
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = authenticate_user(db, request.username, request.password)
if not user:
return LoginResponse(success=False, error="用户名或密码错误")
# 更新登录时间
update_last_login(db, user.id)
# 生成 Token
token = create_access_token({
"user_id": user.id,
"username": user.username,
"role": user.role
})
return LoginResponse(
success=True,
token=token,
user=UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
)
@router.get("/me", response_model=UserInfo)
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
@router.post("/change-password")
async def change_password(
request: ChangePasswordRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""修改密码"""
from ..services.auth import verify_password
if not verify_password(request.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
new_hash = hash_password(request.new_password)
db.query(User).filter(User.id == user.id).update({"password_hash": new_hash})
db.commit()
return {"success": True, "message": "密码修改成功"}
@router.get("/users")
async def list_users(
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
users = db.query(User).all()
return [
{
"id": u.id,
"username": u.username,
"nickname": u.nickname,
"role": u.role,
"status": u.status,
"last_login_at": u.last_login_at,
"created_at": u.created_at
}
for u in users
]
class CreateUserRequest(BaseModel):
username: str
password: str
nickname: Optional[str] = None
role: str = "viewer"
@router.post("/users")
async def create_user(
request: CreateUserRequest,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""创建用户(仅管理员)"""
# 检查用户名是否存在
exists = db.query(User).filter(User.username == request.username).first()
if exists:
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
username=request.username,
password_hash=hash_password(request.password),
nickname=request.nickname,
role=request.role,
status=1
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {"success": True, "id": new_user.id}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除用户(仅管理员)"""
if user_id == user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
db.delete(target)
db.commit()
return {"success": True}

View File

@@ -0,0 +1,240 @@
"""租户应用配置路由"""
import json
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator
from ..models.user import User
from ..services.crypto import encrypt_config, decrypt_config
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
# Schemas
class TenantAppCreate(BaseModel):
tenant_id: str
app_code: str = "tools"
app_name: Optional[str] = None
wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None # 明文,存储时加密
token_secret: Optional[str] = None # 如果不传则自动生成
token_required: bool = False
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
class TenantAppUpdate(BaseModel):
app_name: Optional[str] = None
wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None
token_secret: Optional[str] = None
token_required: Optional[bool] = None
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置列表"""
query = db.query(TenantApp)
if tenant_id:
query = query.filter(TenantApp.tenant_id == tenant_id)
if app_code:
query = query.filter(TenantApp.app_code == app_code)
total = query.count()
apps = query.order_by(TenantApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_tenant_app(app, mask_secret=True) for app in apps]
}
@router.get("/{app_id}")
async def get_tenant_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置详情"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
return format_tenant_app(app, mask_secret=True)
@router.post("")
async def create_tenant_app(
data: TenantAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用配置"""
# 检查是否重复
exists = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code
).first()
if exists:
raise HTTPException(status_code=400, detail="该租户应用配置已存在")
# 自动生成 token_secret
token_secret = data.token_secret or secrets.token_hex(32)
# 加密 wechat_secret
wechat_secret_encrypted = None
if data.wechat_secret:
wechat_secret_encrypted = encrypt_config(data.wechat_secret)
app = TenantApp(
tenant_id=data.tenant_id,
app_code=data.app_code,
app_name=data.app_name,
wechat_corp_id=data.wechat_corp_id,
wechat_agent_id=data.wechat_agent_id,
wechat_secret_encrypted=wechat_secret_encrypted,
token_secret=token_secret,
token_required=1 if data.token_required else 0,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id, "token_secret": token_secret}
@router.put("/{app_id}")
async def update_tenant_app(
app_id: int,
data: TenantAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 wechat_secret
if 'wechat_secret' in update_data:
if update_data['wechat_secret']:
app.wechat_secret_encrypted = encrypt_config(update_data['wechat_secret'])
del update_data['wechat_secret']
# 处理 JSON 字段
if 'allowed_origins' in update_data:
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
# 处理 token_required
if 'token_required' in update_data:
update_data['token_required'] = 1 if update_data['token_required'] else 0
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
db.delete(app)
db.commit()
return {"success": True}
@router.post("/{app_id}/regenerate-token")
async def regenerate_token(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新生成 token_secret"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
new_token = secrets.token_hex(32)
app.token_secret = new_token
db.commit()
return {"success": True, "token_secret": new_token}
@router.get("/{app_id}/wechat-secret")
async def get_wechat_secret(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取解密的 wechat_secret仅操作员以上"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
secret = None
if app.wechat_secret_encrypted:
secret = decrypt_config(app.wechat_secret_encrypted)
return {"wechat_secret": secret}
def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict:
"""格式化应用配置"""
result = {
"id": app.id,
"tenant_id": app.tenant_id,
"app_code": app.app_code,
"app_name": app.app_name,
"wechat_corp_id": app.wechat_corp_id,
"wechat_agent_id": app.wechat_agent_id,
"has_wechat_secret": bool(app.wechat_secret_encrypted),
"token_secret": "******" if mask_secret and app.token_secret else app.token_secret,
"token_required": bool(app.token_required),
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}
return result

View File

@@ -0,0 +1,301 @@
"""租户管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..models.tenant import Tenant, Subscription
from ..models.stats import TenantUsageDaily
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenants", tags=["租户管理"])
# Schemas
class TenantCreate(BaseModel):
code: str
name: str
contact_info: Optional[dict] = None
status: str = "active"
expired_at: Optional[date] = None
class TenantUpdate(BaseModel):
name: Optional[str] = None
contact_info: Optional[dict] = None
status: Optional[str] = None
expired_at: Optional[date] = None
class SubscriptionCreate(BaseModel):
tenant_id: int
app_code: str
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: str = "active"
class SubscriptionUpdate(BaseModel):
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: Optional[str] = None
# API Endpoints
@router.get("")
async def list_tenants(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
keyword: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户列表"""
query = db.query(Tenant)
if status:
query = query.filter(Tenant.status == status)
if keyword:
query = query.filter(
(Tenant.code.contains(keyword)) | (Tenant.name.contains(keyword))
)
total = query.count()
tenants = query.order_by(Tenant.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": t.id,
"code": t.code,
"name": t.name,
"contact_info": t.contact_info,
"status": t.status,
"expired_at": t.expired_at,
"created_at": t.created_at
}
for t in tenants
]
}
@router.get("/{tenant_id}")
async def get_tenant(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户详情"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 获取订阅
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
# 获取用量统计最近30天
usage = db.query(
func.sum(TenantUsageDaily.ai_calls).label('total_calls'),
func.sum(TenantUsageDaily.ai_tokens).label('total_tokens'),
func.sum(TenantUsageDaily.ai_cost).label('total_cost')
).filter(
TenantUsageDaily.tenant_id == tenant_id
).first()
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"contact_info": tenant.contact_info,
"status": tenant.status,
"expired_at": tenant.expired_at,
"created_at": tenant.created_at,
"updated_at": tenant.updated_at,
"subscriptions": [
{
"id": s.id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status
}
for s in subscriptions
],
"usage_summary": {
"total_calls": int(usage.total_calls or 0),
"total_tokens": int(usage.total_tokens or 0),
"total_cost": float(usage.total_cost or 0)
}
}
@router.post("")
async def create_tenant(
data: TenantCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建租户"""
# 检查 code 是否重复
exists = db.query(Tenant).filter(Tenant.code == data.code).first()
if exists:
raise HTTPException(status_code=400, detail="租户代码已存在")
tenant = Tenant(
code=data.code,
name=data.name,
contact_info=data.contact_info,
status=data.status,
expired_at=data.expired_at
)
db.add(tenant)
db.commit()
db.refresh(tenant)
return {"success": True, "id": tenant.id}
@router.put("/{tenant_id}")
async def update_tenant(
tenant_id: int,
data: TenantUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(tenant, key, value)
db.commit()
return {"success": True}
@router.delete("/{tenant_id}")
async def delete_tenant(
tenant_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 删除关联的订阅
db.query(Subscription).filter(Subscription.tenant_id == tenant_id).delete()
db.delete(tenant)
db.commit()
return {"success": True}
# 订阅管理
@router.get("/{tenant_id}/subscriptions")
async def list_subscriptions(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户订阅列表"""
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
return [
{
"id": s.id,
"tenant_id": s.tenant_id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status,
"created_at": s.created_at
}
for s in subscriptions
]
@router.post("/{tenant_id}/subscriptions")
async def create_subscription(
tenant_id: int,
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
subscription = Subscription(
tenant_id=tenant_id,
app_code=data.app_code,
start_date=data.start_date,
end_date=data.end_date,
quota=data.quota,
status=data.status
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return {"success": True, "id": subscription.id}
@router.put("/subscriptions/{subscription_id}")
async def update_subscription(
subscription_id: int,
data: SubscriptionUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(subscription, key, value)
db.commit()
return {"success": True}
@router.delete("/subscriptions/{subscription_id}")
async def delete_subscription(
subscription_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
db.delete(subscription)
db.commit()
return {"success": True}