From b89d5ddee97f28297d00e081f02efb81b19a9784 Mon Sep 17 00:00:00 2001 From: 111 Date: Fri, 23 Jan 2026 15:48:50 +0800 Subject: [PATCH] feat: add admin UI frontend and complete backend APIs - 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) --- .drone.yml | 23 +- .gitignore | 2 +- backend/app/main.py | 12 +- backend/app/models/tenant_app.py | 31 +++ backend/app/models/user.py | 19 ++ backend/app/routers/auth.py | 223 ++++++++++++++++++ backend/app/routers/tenant_apps.py | 240 +++++++++++++++++++ backend/app/routers/tenants.py | 301 ++++++++++++++++++++++++ backend/app/services/auth.py | 89 +++++++ backend/app/services/crypto.py | 5 + backend/requirements.txt | 1 + deploy/Dockerfile.frontend | 21 ++ deploy/nginx/frontend.conf | 34 +++ frontend/index.html | 13 + frontend/package.json | 26 ++ frontend/src/App.vue | 23 ++ frontend/src/api/index.js | 40 ++++ frontend/src/assets/styles/main.scss | 185 +++++++++++++++ frontend/src/components/Layout.vue | 105 +++++++++ frontend/src/main.js | 23 ++ frontend/src/router/index.js | 86 +++++++ frontend/src/stores/auth.js | 60 +++++ frontend/src/views/app-config/index.vue | 294 +++++++++++++++++++++++ frontend/src/views/dashboard/index.vue | 175 ++++++++++++++ frontend/src/views/login/index.vue | 137 +++++++++++ frontend/src/views/logs/index.vue | 236 +++++++++++++++++++ frontend/src/views/stats/index.vue | 181 ++++++++++++++ frontend/src/views/tenants/detail.vue | 105 +++++++++ frontend/src/views/tenants/index.vue | 239 +++++++++++++++++++ frontend/src/views/users/index.vue | 169 +++++++++++++ frontend/vite.config.js | 25 ++ 31 files changed, 3115 insertions(+), 8 deletions(-) create mode 100644 backend/app/models/tenant_app.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/tenant_apps.py create mode 100644 backend/app/routers/tenants.py create mode 100644 backend/app/services/auth.py create mode 100644 deploy/Dockerfile.frontend create mode 100644 deploy/nginx/frontend.conf create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/styles/main.scss create mode 100644 frontend/src/components/Layout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/views/app-config/index.vue create mode 100644 frontend/src/views/dashboard/index.vue create mode 100644 frontend/src/views/login/index.vue create mode 100644 frontend/src/views/logs/index.vue create mode 100644 frontend/src/views/stats/index.vue create mode 100644 frontend/src/views/tenants/detail.vue create mode 100644 frontend/src/views/tenants/index.vue create mode 100644 frontend/src/views/users/index.vue create mode 100644 frontend/vite.config.js diff --git a/.drone.yml b/.drone.yml index 91205d8..fa64e7b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -10,6 +10,7 @@ trigger: - push steps: + # 构建后端镜像 - name: build-backend image: docker:dind volumes: @@ -19,6 +20,17 @@ steps: - docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend . - docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest + # 构建前端镜像 + - name: build-frontend + image: docker:dind + volumes: + - name: docker-sock + path: /var/run/docker.sock + commands: + - docker build -t platform-frontend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend . + - docker tag platform-frontend:${DRONE_COMMIT_SHA:0:8} platform-frontend:latest + + # 部署测试环境 - name: deploy-test image: docker:dind volumes: @@ -34,13 +46,15 @@ steps: CONFIG_ENCRYPT_KEY: from_secret: config_encrypt_key commands: - - docker stop platform-backend-test || true - - docker rm platform-backend-test || true + - docker stop platform-backend-test platform-frontend-test || true + - docker rm platform-backend-test platform-frontend-test || true - docker run -d --name platform-backend-test -p 8001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest + - docker run -d --name platform-frontend-test -p 3001:80 --restart unless-stopped platform-frontend:latest when: branch: - develop + # 部署生产环境 - name: deploy-prod image: docker:dind volumes: @@ -56,9 +70,10 @@ steps: CONFIG_ENCRYPT_KEY: from_secret: config_encrypt_key commands: - - docker stop platform-backend-prod || true - - docker rm platform-backend-prod || true + - docker stop platform-backend-prod platform-frontend-prod || true + - docker rm platform-backend-prod platform-frontend-prod || true - docker run -d --name platform-backend-prod -p 9001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest + - docker run -d --name platform-frontend-prod -p 4001:80 --restart unless-stopped platform-frontend:latest when: branch: - main diff --git a/.gitignore b/.gitignore index bd7d3af..41c862d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ venv/ *.swp *.swo *.log -logs/ +/logs/ .DS_Store Thumbs.db dist/ diff --git a/backend/app/main.py b/backend/app/main.py index 5d7d958..640bb1e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware from .config import get_settings from .routers import stats_router, logs_router, config_router, health_router +from .routers.auth import router as auth_router +from .routers.tenants import router as tenants_router +from .routers.tenant_apps import router as tenant_apps_router settings = get_settings() @@ -24,9 +27,12 @@ app.add_middleware( # 注册路由 app.include_router(health_router) -app.include_router(stats_router) -app.include_router(logs_router) -app.include_router(config_router) +app.include_router(auth_router, prefix="/api") +app.include_router(tenants_router, prefix="/api") +app.include_router(tenant_apps_router, prefix="/api") +app.include_router(stats_router, prefix="/api") +app.include_router(logs_router, prefix="/api") +app.include_router(config_router, prefix="/api") @app.get("/") diff --git a/backend/app/models/tenant_app.py b/backend/app/models/tenant_app.py new file mode 100644 index 0000000..46b9101 --- /dev/null +++ b/backend/app/models/tenant_app.py @@ -0,0 +1,31 @@ +"""租户应用配置模型""" +from datetime import datetime +from sqlalchemy import Column, BigInteger, Integer, String, Text, SmallInteger, TIMESTAMP +from ..database import Base + + +class TenantApp(Base): + """租户应用配置表""" + __tablename__ = "platform_tenant_apps" + + id = Column(Integer, primary_key=True, autoincrement=True) + tenant_id = Column(String(50), nullable=False) + app_code = Column(String(50), nullable=False, default='tools') + app_name = Column(String(100)) + + # 企业微信配置 + wechat_corp_id = Column(String(100)) + wechat_agent_id = Column(String(50)) + wechat_secret_encrypted = Column(Text) + + # 鉴权配置 + token_secret = Column(String(64)) + token_required = Column(SmallInteger, default=0) + allowed_origins = Column(Text) # JSON 数组 + + # 功能权限 + allowed_tools = Column(Text) # JSON 数组 + + status = Column(SmallInteger, default=1) + created_at = Column(TIMESTAMP, default=datetime.now) + updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..fc72619 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +"""用户模型""" +from datetime import datetime +from sqlalchemy import Column, BigInteger, String, Enum, TIMESTAMP, SmallInteger +from ..database import Base + + +class User(Base): + """用户表""" + __tablename__ = "platform_users" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + nickname = Column(String(100)) + role = Column(Enum('admin', 'operator', 'viewer'), default='viewer') + status = Column(SmallInteger, default=1) # 1=启用, 0=禁用 + last_login_at = Column(TIMESTAMP, nullable=True) + created_at = Column(TIMESTAMP, default=datetime.now) + updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..6657556 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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} diff --git a/backend/app/routers/tenant_apps.py b/backend/app/routers/tenant_apps.py new file mode 100644 index 0000000..ef385ec --- /dev/null +++ b/backend/app/routers/tenant_apps.py @@ -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 diff --git a/backend/app/routers/tenants.py b/backend/app/routers/tenants.py new file mode 100644 index 0000000..67c2cbc --- /dev/null +++ b/backend/app/routers/tenants.py @@ -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} diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..b22cf4c --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,89 @@ +"""认证服务""" +import bcrypt +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..models.user import User + + +class TokenData(BaseModel): + """Token 数据""" + user_id: int + username: str + role: str + + +class UserInfo(BaseModel): + """用户信息""" + id: int + username: str + nickname: Optional[str] + role: str + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + + +def hash_password(password: str) -> str: + """哈希密码""" + return bcrypt.hashpw( + password.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + + +def create_access_token(data: dict, expires_delta: timedelta = None) -> str: + """创建 JWT Token""" + settings = get_settings() + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS) + + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> Optional[TokenData]: + """解析 JWT Token""" + settings = get_settings() + try: + payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) + return TokenData( + user_id=payload.get("user_id"), + username=payload.get("username"), + role=payload.get("role") + ) + except JWTError: + return None + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: + """认证用户""" + user = db.query(User).filter(User.username == username).first() + if not user: + return None + if not verify_password(password, user.password_hash): + return None + if user.status != 1: + return None + return user + + +def update_last_login(db: Session, user_id: int): + """更新最后登录时间""" + db.query(User).filter(User.id == user_id).update( + {"last_login_at": datetime.now()} + ) + db.commit() diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py index 3f2144d..a68e8a5 100644 --- a/backend/app/services/crypto.py +++ b/backend/app/services/crypto.py @@ -35,3 +35,8 @@ def decrypt_value(encrypted_value: str) -> str: encrypted = base64.urlsafe_b64decode(encrypted_value.encode()) decrypted = f.decrypt(encrypted) return decrypted.decode() + + +# 别名 +encrypt_config = encrypt_value +decrypt_config = decrypt_value diff --git a/backend/requirements.txt b/backend/requirements.txt index 8e350f6..c68d32b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,5 +7,6 @@ pydantic-settings>=2.0.0 cryptography>=42.0.0 python-jose[cryptography]>=3.3.0 passlib[bcrypt]>=1.7.4 +bcrypt>=4.0.0 python-multipart>=0.0.6 httpx>=0.26.0 diff --git a/deploy/Dockerfile.frontend b/deploy/Dockerfile.frontend new file mode 100644 index 0000000..5398a1c --- /dev/null +++ b/deploy/Dockerfile.frontend @@ -0,0 +1,21 @@ +FROM node:20-alpine as builder + +WORKDIR /app + +# 安装依赖 +COPY frontend/package*.json ./ +RUN npm ci + +# 构建 +COPY frontend/ . +RUN npm run build + +# 生产镜像 +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/nginx/frontend.conf b/deploy/nginx/frontend.conf new file mode 100644 index 0000000..045355f --- /dev/null +++ b/deploy/nginx/frontend.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Vue Router history mode + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://172.17.0.1:8001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d18ea62 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + 平台管理后台 + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d3cd9de --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "000-platform-admin", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "pinia": "^2.1.0", + "axios": "^1.6.0", + "element-plus": "^2.5.0", + "@element-plus/icons-vue": "^2.3.0", + "echarts": "^5.4.0", + "dayjs": "^1.11.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0", + "sass": "^1.69.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..1187c72 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..1b14bad --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,40 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +const api = axios.create({ + baseURL: '', + timeout: 30000 +}) + +// 请求拦截器 +api.interceptors.request.use( + config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => Promise.reject(error) +) + +// 响应拦截器 +api.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('user') + router.push('/login') + ElMessage.error('登录已过期,请重新登录') + } else if (error.response?.status === 403) { + ElMessage.error('没有权限执行此操作') + } else { + ElMessage.error(error.response?.data?.detail || error.message || '请求失败') + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss new file mode 100644 index 0000000..1593ee7 --- /dev/null +++ b/frontend/src/assets/styles/main.scss @@ -0,0 +1,185 @@ +// 全局样式 + +* { + box-sizing: border-box; +} + +body { + font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; + background-color: #f5f7fa; +} + +// 布局 +.layout { + height: 100vh; + display: flex; +} + +.sidebar { + width: 220px; + background: linear-gradient(180deg, #1e3a5f 0%, #0d2137 100%); + color: #fff; + flex-shrink: 0; + + .logo { + height: 60px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + border-bottom: 1px solid rgba(255,255,255,0.1); + } + + .el-menu { + border: none; + background: transparent; + + .el-menu-item { + color: rgba(255,255,255,0.7); + + &:hover { + background: rgba(255,255,255,0.1); + } + + &.is-active { + background: #409eff; + color: #fff; + } + } + } +} + +.main-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + height: 60px; + background: #fff; + border-bottom: 1px solid #e6e6e6; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + + .breadcrumb { + font-size: 14px; + } + + .user-info { + display: flex; + align-items: center; + gap: 12px; + + .username { + font-size: 14px; + color: #606266; + } + } +} + +.main-content { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +// 页面容器 +.page-container { + background: #fff; + border-radius: 8px; + padding: 20px; + min-height: 100%; +} + +// 页面头部 +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + .title { + font-size: 18px; + font-weight: 600; + color: #303133; + } +} + +// 搜索栏 +.search-bar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +// 统计卡片 +.stat-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.stat-card { + background: #fff; + border-radius: 8px; + padding: 20px; + + .stat-title { + font-size: 14px; + color: #909399; + margin-bottom: 12px; + } + + .stat-value { + font-size: 28px; + font-weight: 600; + color: #303133; + } + + .stat-trend { + font-size: 12px; + margin-top: 8px; + + &.up { + color: #67c23a; + } + + &.down { + color: #f56c6c; + } + } +} + +// 表格 +.el-table { + .cell { + padding: 8px 12px; + } +} + +// 对话框 +.el-dialog { + .el-dialog__body { + padding: 20px 24px; + } +} + +// 状态标签 +.status-active { + color: #67c23a; +} + +.status-expired { + color: #f56c6c; +} + +.status-trial { + color: #e6a23c; +} diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..3efa1c8 --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..affb372 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,23 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' + +import App from './App.vue' +import router from './router' +import './assets/styles/main.scss' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..e928747 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,86 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录', public: true } + }, + { + path: '/', + component: () => import('@/components/Layout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', icon: 'Odometer' } + }, + { + path: 'tenants', + name: 'Tenants', + component: () => import('@/views/tenants/index.vue'), + meta: { title: '租户管理', icon: 'OfficeBuilding' } + }, + { + path: 'tenants/:id', + name: 'TenantDetail', + component: () => import('@/views/tenants/detail.vue'), + meta: { title: '租户详情', hidden: true } + }, + { + path: 'app-config', + name: 'AppConfig', + component: () => import('@/views/app-config/index.vue'), + meta: { title: '应用配置', icon: 'Setting' } + }, + { + path: 'stats', + name: 'Stats', + component: () => import('@/views/stats/index.vue'), + meta: { title: '统计分析', icon: 'TrendCharts' } + }, + { + path: 'logs', + name: 'Logs', + component: () => import('@/views/logs/index.vue'), + meta: { title: '日志查看', icon: 'Document' } + }, + { + path: 'users', + name: 'Users', + component: () => import('@/views/users/index.vue'), + meta: { title: '用户管理', icon: 'User', role: 'admin' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + // 设置页面标题 + document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理' + + // 检查登录状态 + const authStore = useAuthStore() + + if (to.meta.public) { + next() + } else if (!authStore.isLoggedIn) { + next('/login') + } else if (to.meta.role && authStore.user?.role !== to.meta.role) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..483973e --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,60 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref('') + const user = ref(null) + + const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role === 'admin') + const isOperator = computed(() => ['admin', 'operator'].includes(user.value?.role)) + + function initFromStorage() { + const savedToken = localStorage.getItem('token') + const savedUser = localStorage.getItem('user') + if (savedToken) { + token.value = savedToken + api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}` + } + if (savedUser) { + try { + user.value = JSON.parse(savedUser) + } catch (e) { + // ignore + } + } + } + + async function login(username, password) { + const response = await api.post('/api/auth/login', { username, password }) + if (response.data.success) { + token.value = response.data.token + user.value = response.data.user + localStorage.setItem('token', token.value) + localStorage.setItem('user', JSON.stringify(user.value)) + api.defaults.headers.common['Authorization'] = `Bearer ${token.value}` + return true + } + throw new Error(response.data.error || '登录失败') + } + + function logout() { + token.value = '' + user.value = null + localStorage.removeItem('token') + localStorage.removeItem('user') + delete api.defaults.headers.common['Authorization'] + } + + return { + token, + user, + isLoggedIn, + isAdmin, + isOperator, + initFromStorage, + login, + logout + } +}) diff --git a/frontend/src/views/app-config/index.vue b/frontend/src/views/app-config/index.vue new file mode 100644 index 0000000..641bf88 --- /dev/null +++ b/frontend/src/views/app-config/index.vue @@ -0,0 +1,294 @@ + + + diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue new file mode 100644 index 0000000..6e49d46 --- /dev/null +++ b/frontend/src/views/dashboard/index.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue new file mode 100644 index 0000000..6bfbcfa --- /dev/null +++ b/frontend/src/views/login/index.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/views/logs/index.vue b/frontend/src/views/logs/index.vue new file mode 100644 index 0000000..669111c --- /dev/null +++ b/frontend/src/views/logs/index.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/views/stats/index.vue b/frontend/src/views/stats/index.vue new file mode 100644 index 0000000..dc17013 --- /dev/null +++ b/frontend/src/views/stats/index.vue @@ -0,0 +1,181 @@ + + + diff --git a/frontend/src/views/tenants/detail.vue b/frontend/src/views/tenants/detail.vue new file mode 100644 index 0000000..6ee042d --- /dev/null +++ b/frontend/src/views/tenants/detail.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/views/tenants/index.vue b/frontend/src/views/tenants/index.vue new file mode 100644 index 0000000..eb7cb4e --- /dev/null +++ b/frontend/src/views/tenants/index.vue @@ -0,0 +1,239 @@ + + + diff --git a/frontend/src/views/users/index.vue b/frontend/src/views/users/index.vue new file mode 100644 index 0000000..f8b61af --- /dev/null +++ b/frontend/src/views/users/index.vue @@ -0,0 +1,169 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..03c302c --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:8001', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + sourcemap: false + } +})