Compare commits

...

19 Commits

Author SHA1 Message Date
b6b5ac61af fix: 修复 Drone 配置和 nginx 多环境支持
All checks were successful
continuous-integration/drone/push Build is passing
- 分离测试/生产环境的前端镜像构建
- nginx 配置使用 BACKEND_HOST 变量区分环境
- 生产环境添加独立的 Docker network
- 生产环境使用独立的密钥配置 (xxx_prod)
- 修复前端空白问题:确保前后端在同一 network
2026-01-24 17:15:12 +08:00
111
6c6c48cf71 feat: 新增告警、成本、配额、微信模块及缓存服务
All checks were successful
continuous-integration/drone/push Build is passing
- 新增告警模块 (alerts): 告警规则配置与触发
- 新增成本管理模块 (cost): 成本统计与分析
- 新增配额模块 (quota): 配额管理与限制
- 新增微信模块 (wechat): 微信相关功能接口
- 新增缓存服务 (cache): Redis 缓存封装
- 新增请求日志中间件 (request_logger)
- 新增异常处理和链路追踪中间件
- 更新 dashboard 前端展示
- 更新 SDK stats_client 功能
2026-01-24 16:53:47 +08:00
111
eab2533c36 fix: 修复 nginx 代理地址,解决页面空白问题
All checks were successful
continuous-integration/drone/push Build is passing
Docker 网桥地址从 172.17 变为 172.18,导致 API 代理失败
2026-01-24 15:53:32 +08:00
111
b055be1bb6 feat: 所有租户选择改为下拉列表
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:31:57 +08:00
111
1c95fef01a fix: health 路由加上 /api 前缀
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:24:59 +08:00
111
63e38ceb60 fix: 兼容 corp_id 字段不存在 + 添加迁移 API
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:23:19 +08:00
111
ab84f6b87d feat: 企微应用租户下拉选择 + corp_id 自动填入
All checks were successful
continuous-integration/drone/push Build is passing
- 租户表增加 corp_id 字段(企业微信企业ID)
- 租户管理页面支持配置 corp_id
- 企微应用页面租户从下拉选择
- 选择租户后自动填入 corp_id 并禁用编辑
- 提示用户如租户未配置 corp_id 需先去配置
2026-01-24 10:20:02 +08:00
111
0a799ee276 fix: 完善菜单和应用验证
All checks were successful
continuous-integration/drone/push Build is passing
- 侧边栏菜单增加「应用管理」和「企微应用」
- 租户订阅页面应用选择增加前端验证
- 后端增加 app_code 存在性验证
2026-01-24 10:10:56 +08:00
111
6a93e05ec3 feat: 应用扁平化与 Token 验证 API
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 /api/auth/verify 接口供外部应用验证 token
- 简化应用管理:移除 tools 字段,每个应用独立存在
- 简化应用配置:移除 allowed_tools,专注于租户订阅
- 优化 Token 展示和复制功能
2026-01-24 10:05:24 +08:00
111
c4bd7c8251 feat: 租户级企微配置改造
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_tenant_wechat_apps 表(租户企微应用配置)
- platform_apps 增加 require_jssdk 字段
- platform_tenant_apps 增加 wechat_app_id 关联字段
- 新增企微应用管理 API 和页面
- 应用管理页面增加 JS-SDK 开关
- 应用配置页面增加企微应用选择
2026-01-23 19:05:00 +08:00
111
f815b29c51 feat: 静态 Token 鉴权改造
All checks were successful
continuous-integration/drone/push Build is passing
- 将 token_secret 改为 access_token(长期有效)
- 移除 token_required 字段,统一使用 token 验证
- 生成链接简化为 ?tid=xxx&token=xxx 格式
- 前端移除签名验证开关,链接永久有效
2026-01-23 18:43:04 +08:00
111
39f33d7ac5 feat: 添加应用管理和生成签名链接功能
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_apps 表和 App 模型
- 新增应用管理页面 /apps
- 应用配置页面添加"生成链接"功能
- 支持一键生成带签名的访问 URL
2026-01-23 18:22:17 +08:00
111
2a9f62bef8 fix: use correct token fields (input_tokens + output_tokens)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:14:55 +08:00
111
b018844078 fix: add GET endpoints for stats summary and logs query
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:12:18 +08:00
111
64f07a9af5 fix: use ports 3003/4003 (3002 used by Drone)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:04:40 +08:00
111
d108b168dd fix: change frontend ports to 3002/4002 (3001 used by Gitea)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 16:01:59 +08:00
111
4e954af55c fix: use npm install instead of npm ci (no lock file)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:56:36 +08:00
111
531d9522c5 fix: use China mirrors for pip and npm in Dockerfiles
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:54:22 +08:00
111
b89d5ddee9 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)
2026-01-23 15:51:37 +08:00
64 changed files with 9428 additions and 86 deletions

View File

@@ -10,6 +10,7 @@ trigger:
- push - push
steps: steps:
# 构建后端镜像
- name: build-backend - name: build-backend
image: docker:dind image: docker:dind
volumes: volumes:
@@ -19,6 +20,33 @@ steps:
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend . - 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 - docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
# 构建前端镜像(测试环境)
- name: build-frontend-test
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend-test:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-test .
- docker tag platform-frontend-test:${DRONE_COMMIT_SHA:0:8} platform-frontend-test:latest
when:
branch:
- develop
# 构建前端镜像(生产环境)
- name: build-frontend-prod
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend-prod:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-prod .
- docker tag platform-frontend-prod:${DRONE_COMMIT_SHA:0:8} platform-frontend-prod:latest
when:
branch:
- main
# 部署测试环境
- name: deploy-test - name: deploy-test
image: docker:dind image: docker:dind
volumes: volumes:
@@ -34,13 +62,16 @@ steps:
CONFIG_ENCRYPT_KEY: CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key from_secret: config_encrypt_key
commands: commands:
- docker stop platform-backend-test || true - docker network create platform-network 2>/dev/null || true
- docker rm platform-backend-test || true - docker stop 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 rm platform-backend-test platform-frontend-test || true
- docker run -d --name platform-backend-test --network platform-network -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 --network platform-network -p 3003:80 --restart unless-stopped platform-frontend-test:latest
when: when:
branch: branch:
- develop - develop
# 部署生产环境
- name: deploy-prod - name: deploy-prod
image: docker:dind image: docker:dind
volumes: volumes:
@@ -48,17 +79,19 @@ steps:
path: /var/run/docker.sock path: /var/run/docker.sock
environment: environment:
DATABASE_URL: DATABASE_URL:
from_secret: database_url from_secret: database_url_prod
API_KEY: API_KEY:
from_secret: api_key from_secret: api_key_prod
JWT_SECRET: JWT_SECRET:
from_secret: jwt_secret from_secret: jwt_secret_prod
CONFIG_ENCRYPT_KEY: CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key from_secret: config_encrypt_key_prod
commands: commands:
- docker stop platform-backend-prod || true - docker network create platform-network-prod 2>/dev/null || true
- docker rm platform-backend-prod || true - docker stop 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 rm platform-backend-prod platform-frontend-prod || true
- docker run -d --name platform-backend-prod --network platform-network-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 --network platform-network-prod -p 4003:80 --restart unless-stopped platform-frontend-prod:latest
when: when:
branch: branch:
- main - main

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@ venv/
*.swp *.swp
*.swo *.swo
*.log *.log
logs/ /logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
dist/ dist/

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# AI 对话启动指南
> 本文档用于快速让新 AI 了解项目背景和当前状态
---
## 项目概述
这是一个多租户 AI 应用平台,包含:
| 项目 | 说明 | 技术栈 |
|------|------|--------|
| 000-platform | 统一管理平台Admin UI | Python FastAPI + Vue 3 |
| 001-tools | PHP+n8n 工具迁移 | Python FastAPI + Vue 3 |
| 011-ai-interview | AI 面试应用 | Python FastAPI + Vue 3 |
---
## 当前状态
### 已完成
- DevOps: Gitea + Drone CI/CD + Docker 自动部署
- 多租户鉴权: URL 参数签名验证 (tid, aid, ts, sign)
- 应用管理: Admin UI 可配置应用和生成签名链接
- 5 个工具迁移: 头脑风暴、高情商回复、面诊方案、客户画像、医疗合规
- AI 调用统计: 记录到 platform_ai_call_events
### 待完成
- 企业微信 JS-SDK 集成
- n8n 返回 token 统计
- 生产环境部署
---
## 关键文件位置
```
AgentWD/
├── _shared/ # 共享文档
│ ├── AI对话启动指南.md # 本文件
│ ├── 项目进度/ # 进度文档
│ │ ├── 整体进度汇总.md
│ │ ├── 000-platform进度.md
│ │ └── 001-tools迁移进度.md
│ └── 数据库/
│ └── 测试环境配置.md # 服务器/数据库信息
├── 框架/
│ └── CICD配置.md # CI/CD 说明
├── projects/
│ ├── 000-platform/ # 管理平台
│ ├── 001-tools/ # 工具集
│ └── 011-ai-interview/ # AI 面试
└── scripts/ # 数据库脚本
```
---
## 服务器信息
| 服务 | 地址 | 说明 |
|------|------|------|
| 测试服务器 | 47.107.172.23 | root / Nj861021 |
| MySQL | 47.107.71.55 | scrm_reader / ScrmReader2024Pass |
| n8n | https://n8n.ireborn.com.cn | 工作流引擎 |
| Gitea | https://git.ai.ireborn.com.cn | 代码托管 |
| Drone CI | https://ci.ai.ireborn.com.cn | 自动部署 |
---
## 测试地址
| 服务 | 测试环境 |
|------|----------|
| 管理平台 | https://platform.test.ai.ireborn.com.cn/admin |
| 工具集 | https://tools.test.ai.ireborn.com.cn |
| AI 面试 | https://interview.test.ai.ireborn.com.cn |
---
## 鉴权机制
### 001-tools 租户鉴权
```
URL: https://tools.test.ai.ireborn.com.cn/brainstorm?tid=test&aid=tools
```
- `tid`: 租户ID必须
- `aid`: 应用代码(必须,默认 tools
- `ts`: 时间戳(需签名的租户必须)
- `sign`: HMAC-SHA256(tid+aid+ts, token_secret)
### 测试租户
- `test`: 免签名,直接 `?tid=test&aid=tools`
- `qiqi`: 需签名,通过 Admin UI 生成链接
---
## 开发规范
1. **分支策略**: develop → 测试环境, main → 生产环境
2. **代码提交**: 推送后自动触发 Drone CI/CD
3. **样式保留**: 001-tools 各工具保持原 PHP 样式,不统一
4. **数据库表**: 统一使用 `platform_` 前缀
---
## 常用命令
```bash
# 检查构建状态
curl "https://ci.ai.ireborn.com.cn/api/repos/admin/000-platform/builds?per_page=1"
# SSH 到服务器
ssh root@47.107.172.23 # 密码: Nj861021
# 执行 MySQL
mysql -h 47.107.71.55 -u scrm_reader -pScrmReader2024Pass new_qiqi
```
---
## 详细进度
请阅读以下文件获取更多信息:
1. `_shared/项目进度/整体进度汇总.md` - 整体架构和状态
2. `_shared/项目进度/001-tools迁移进度.md` - 工具迁移详情
3. `_shared/项目进度/000-platform进度.md` - 平台服务详情
4. `_shared/数据库/测试环境配置.md` - 环境配置详情

View File

@@ -2,6 +2,7 @@
import os import os
from functools import lru_cache from functools import lru_cache
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -14,6 +15,10 @@ class Settings(BaseSettings):
# 数据库 # 数据库
DATABASE_URL: str = "mysql+pymysql://scrm_reader:ScrmReader2024Pass@47.107.71.55:3306/new_qiqi" DATABASE_URL: str = "mysql+pymysql://scrm_reader:ScrmReader2024Pass@47.107.71.55:3306/new_qiqi"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_PREFIX: str = "platform:"
# API Key内部服务调用 # API Key内部服务调用
API_KEY: str = "platform_api_key_2026" API_KEY: str = "platform_api_key_2026"
@@ -29,6 +34,10 @@ class Settings(BaseSettings):
# 配置加密密钥 # 配置加密密钥
CONFIG_ENCRYPT_KEY: str = "platform_config_key_32bytes!!" CONFIG_ENCRYPT_KEY: str = "platform_config_key_32bytes!!"
# 企业微信配置
WECHAT_ACCESS_TOKEN_EXPIRE: int = 7000 # access_token缓存时间(秒)企微有效期7200秒
WECHAT_JSAPI_TICKET_EXPIRE: int = 7000 # jsapi_ticket缓存时间(秒)
class Config: class Config:
env_file = ".env" env_file = ".env"
extra = "ignore" extra = "ignore"

View File

@@ -1,9 +1,24 @@
"""平台服务入口""" """平台服务入口"""
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings from .config import get_settings
from .routers import stats_router, logs_router, config_router, health_router 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
from .routers.tenant_wechat_apps import router as tenant_wechat_apps_router
from .routers.apps import router as apps_router
from .routers.wechat import router as wechat_router
from .routers.alerts import router as alerts_router
from .routers.cost import router as cost_router
from .routers.quota import router as quota_router
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
from .middleware.trace import setup_logging
# 配置日志(包含 TraceID
setup_logging(level=logging.INFO, include_trace=True)
settings = get_settings() settings = get_settings()
@@ -13,6 +28,20 @@ app = FastAPI(
description="平台基础设施服务 - 统计/日志/配置管理" description="平台基础设施服务 - 统计/日志/配置管理"
) )
# 配置统一异常处理
setup_exception_handlers(app)
# 中间件按添加的反序执行,所以:
# 1. CORS 最后添加,最先执行
# 2. TraceMiddleware 在 RequestLoggerMiddleware 之后添加,这样先执行
# 3. RequestLoggerMiddleware 最先添加,最后执行(此时 trace_id 已设置)
# 请求日志中间件(自动记录到数据库)
app.add_middleware(RequestLoggerMiddleware, app_code="000-platform")
# TraceID 追踪中间件
app.add_middleware(TraceMiddleware, log_requests=True)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -20,13 +49,23 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["X-Trace-ID", "X-Response-Time"]
) )
# 注册路由 # 注册路由
app.include_router(health_router) app.include_router(health_router, prefix="/api")
app.include_router(stats_router) app.include_router(auth_router, prefix="/api")
app.include_router(logs_router) app.include_router(tenants_router, prefix="/api")
app.include_router(config_router) app.include_router(tenant_apps_router, prefix="/api")
app.include_router(tenant_wechat_apps_router, prefix="/api")
app.include_router(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.include_router(wechat_router, prefix="/api")
app.include_router(alerts_router, prefix="/api")
app.include_router(cost_router, prefix="/api")
app.include_router(quota_router, prefix="/api")
@app.get("/") @app.get("/")

View File

@@ -0,0 +1,19 @@
"""
中间件模块
提供:
- TraceID 追踪
- 统一异常处理
- 请求日志记录
"""
from .trace import TraceMiddleware, get_trace_id, set_trace_id
from .exception_handler import setup_exception_handlers
from .request_logger import RequestLoggerMiddleware
__all__ = [
"TraceMiddleware",
"get_trace_id",
"set_trace_id",
"setup_exception_handlers",
"RequestLoggerMiddleware"
]

View File

@@ -0,0 +1,128 @@
"""
统一异常处理
捕获所有异常,返回统一格式的错误响应,包含 TraceID。
"""
import logging
import traceback
from typing import Union
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from .trace import get_trace_id
logger = logging.getLogger(__name__)
class ErrorCode:
"""错误码常量"""
BAD_REQUEST = "BAD_REQUEST"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_FOUND = "NOT_FOUND"
VALIDATION_ERROR = "VALIDATION_ERROR"
RATE_LIMITED = "RATE_LIMITED"
INTERNAL_ERROR = "INTERNAL_ERROR"
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
GATEWAY_ERROR = "GATEWAY_ERROR"
STATUS_TO_ERROR_CODE = {
400: ErrorCode.BAD_REQUEST,
401: ErrorCode.UNAUTHORIZED,
403: ErrorCode.FORBIDDEN,
404: ErrorCode.NOT_FOUND,
422: ErrorCode.VALIDATION_ERROR,
429: ErrorCode.RATE_LIMITED,
500: ErrorCode.INTERNAL_ERROR,
502: ErrorCode.GATEWAY_ERROR,
503: ErrorCode.SERVICE_UNAVAILABLE,
}
def create_error_response(
status_code: int,
code: str,
message: str,
trace_id: str = None,
details: dict = None
) -> JSONResponse:
"""创建统一格式的错误响应"""
if trace_id is None:
trace_id = get_trace_id()
error_body = {
"code": code,
"message": message,
"trace_id": trace_id
}
if details:
error_body["details"] = details
return JSONResponse(
status_code=status_code,
content={"success": False, "error": error_body},
headers={"X-Trace-ID": trace_id}
)
async def http_exception_handler(request: Request, exc: Union[HTTPException, StarletteHTTPException]):
"""处理 HTTP 异常"""
trace_id = get_trace_id()
status_code = exc.status_code
error_code = STATUS_TO_ERROR_CODE.get(status_code, ErrorCode.INTERNAL_ERROR)
message = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
logger.warning(f"[{trace_id}] HTTP {status_code}: {message}")
return create_error_response(
status_code=status_code,
code=error_code,
message=message,
trace_id=trace_id
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误"""
trace_id = get_trace_id()
errors = exc.errors()
error_messages = [f"{'.'.join(str(l) for l in e['loc'])}: {e['msg']}" for e in errors]
logger.warning(f"[{trace_id}] 验证错误: {error_messages}")
return create_error_response(
status_code=422,
code=ErrorCode.VALIDATION_ERROR,
message="请求参数验证失败",
trace_id=trace_id,
details={"validation_errors": error_messages}
)
async def generic_exception_handler(request: Request, exc: Exception):
"""处理所有未捕获的异常"""
trace_id = get_trace_id()
logger.error(f"[{trace_id}] 未捕获异常: {type(exc).__name__}: {exc}")
logger.error(f"[{trace_id}] 堆栈:\n{traceback.format_exc()}")
return create_error_response(
status_code=500,
code=ErrorCode.INTERNAL_ERROR,
message="服务器内部错误,请稍后重试",
trace_id=trace_id
)
def setup_exception_handlers(app: FastAPI):
"""配置 FastAPI 应用的异常处理器"""
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
logger.info("异常处理器已配置")

View File

@@ -0,0 +1,190 @@
"""
请求日志中间件
自动将所有请求记录到数据库 platform_logs 表
"""
import time
import logging
from typing import Optional, Set
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from .trace import get_trace_id
from ..database import SessionLocal
from ..models.logs import PlatformLog
logger = logging.getLogger(__name__)
class RequestLoggerMiddleware(BaseHTTPMiddleware):
"""请求日志中间件
自动记录所有请求到数据库,便于后续查询和分析
使用示例:
app.add_middleware(RequestLoggerMiddleware, app_code="000-platform")
"""
# 默认排除的路径(不记录这些请求)
DEFAULT_EXCLUDE_PATHS: Set[str] = {
"/",
"/docs",
"/redoc",
"/openapi.json",
"/api/health",
"/api/health/",
"/favicon.ico",
}
def __init__(
self,
app,
app_code: str = "platform",
exclude_paths: Optional[Set[str]] = None,
log_request_body: bool = False,
log_response_body: bool = False,
max_body_length: int = 1000
):
"""初始化中间件
Args:
app: FastAPI应用
app_code: 应用代码,记录到日志中
exclude_paths: 排除的路径集合,这些路径不记录日志
log_request_body: 是否记录请求体
log_response_body: 是否记录响应体
max_body_length: 记录体的最大长度
"""
super().__init__(app)
self.app_code = app_code
self.exclude_paths = exclude_paths or self.DEFAULT_EXCLUDE_PATHS
self.log_request_body = log_request_body
self.log_response_body = log_response_body
self.max_body_length = max_body_length
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# 检查是否排除
if self._should_exclude(path):
return await call_next(request)
trace_id = get_trace_id()
method = request.method
start_time = time.time()
# 获取客户端IP
client_ip = self._get_client_ip(request)
# 获取租户ID从查询参数
tenant_id = request.query_params.get("tid") or request.query_params.get("tenant_id")
# 执行请求
response = None
error_message = None
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
except Exception as e:
error_message = str(e)
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
# 异步写入数据库(不阻塞响应)
try:
self._save_log(
trace_id=trace_id,
method=method,
path=path,
status_code=status_code,
duration_ms=duration_ms,
ip_address=client_ip,
tenant_id=tenant_id,
error_message=error_message
)
except Exception as e:
logger.error(f"Failed to save request log: {e}")
return response
def _should_exclude(self, path: str) -> bool:
"""检查路径是否应排除"""
# 精确匹配
if path in self.exclude_paths:
return True
# 前缀匹配(静态文件等)
exclude_prefixes = ["/static/", "/assets/", "/_next/"]
for prefix in exclude_prefixes:
if path.startswith(prefix):
return True
return False
def _get_client_ip(self, request: Request) -> str:
"""获取客户端真实IP"""
# 优先从代理头获取
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# 直连IP
if request.client:
return request.client.host
return "unknown"
def _save_log(
self,
trace_id: str,
method: str,
path: str,
status_code: int,
duration_ms: int,
ip_address: str,
tenant_id: Optional[str] = None,
error_message: Optional[str] = None
):
"""保存日志到数据库"""
from datetime import datetime
# 使用独立的数据库会话
db = SessionLocal()
try:
# 转换 tenant_id 为整数(如果是数字字符串)
tenant_id_int = None
if tenant_id:
try:
tenant_id_int = int(tenant_id)
except (ValueError, TypeError):
tenant_id_int = None
log_entry = PlatformLog(
log_type="request",
level="error" if status_code >= 500 else ("warn" if status_code >= 400 else "info"),
app_code=self.app_code,
tenant_id=tenant_id_int,
trace_id=trace_id,
message=f"{method} {path}" + (f" - {error_message}" if error_message else ""),
path=path,
method=method,
status_code=status_code,
duration_ms=duration_ms,
log_time=datetime.now(), # 必须设置 log_time
context={"ip": ip_address} # ip_address 放到 context 中
)
db.add(log_entry)
db.commit()
except Exception as e:
logger.error(f"Database error saving log: {e}")
db.rollback()
finally:
db.close()

View File

@@ -0,0 +1,114 @@
"""
TraceID 追踪中间件
为每个请求生成唯一的 TraceID用于日志追踪和问题排查。
功能:
- 自动生成 TraceID或从请求头获取
- 注入到响应头 X-Trace-ID
- 提供上下文变量供日志使用
- 支持请求耗时统计
"""
import time
import uuid
import logging
from contextvars import ContextVar
from typing import Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
# 上下文变量存储当前请求的 TraceID
_trace_id_var: ContextVar[Optional[str]] = ContextVar("trace_id", default=None)
# 请求头名称
TRACE_ID_HEADER = "X-Trace-ID"
REQUEST_ID_HEADER = "X-Request-ID"
def get_trace_id() -> str:
"""获取当前请求的 TraceID"""
trace_id = _trace_id_var.get()
return trace_id if trace_id else "no-trace"
def set_trace_id(trace_id: str) -> None:
"""设置当前请求的 TraceID"""
_trace_id_var.set(trace_id)
def generate_trace_id() -> str:
"""生成新的 TraceID格式: 时间戳-随机8位"""
timestamp = int(time.time())
random_part = uuid.uuid4().hex[:8]
return f"{timestamp}-{random_part}"
class TraceMiddleware(BaseHTTPMiddleware):
"""TraceID 追踪中间件"""
def __init__(self, app, log_requests: bool = True):
super().__init__(app)
self.log_requests = log_requests
async def dispatch(self, request: Request, call_next) -> Response:
# 从请求头获取 TraceID或生成新的
trace_id = (
request.headers.get(TRACE_ID_HEADER) or
request.headers.get(REQUEST_ID_HEADER) or
generate_trace_id()
)
set_trace_id(trace_id)
start_time = time.time()
method = request.method
path = request.url.path
if self.log_requests:
logger.info(f"[{trace_id}] --> {method} {path}")
try:
response = await call_next(request)
duration_ms = int((time.time() - start_time) * 1000)
response.headers[TRACE_ID_HEADER] = trace_id
response.headers["X-Response-Time"] = f"{duration_ms}ms"
if self.log_requests:
logger.info(f"[{trace_id}] <-- {response.status_code} ({duration_ms}ms)")
return response
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
logger.error(f"[{trace_id}] !!! 请求异常: {e} ({duration_ms}ms)")
raise
class TraceLogFilter(logging.Filter):
"""日志过滤器:自动添加 TraceID"""
def filter(self, record):
record.trace_id = get_trace_id()
return True
def setup_logging(level: int = logging.INFO, include_trace: bool = True):
"""配置日志格式"""
if include_trace:
format_str = "%(asctime)s [%(trace_id)s] %(levelname)s %(name)s: %(message)s"
else:
format_str = "%(asctime)s %(levelname)s %(name)s: %(message)s"
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S"))
if include_trace:
handler.addFilter(TraceLogFilter())
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.handlers = [handler]

View File

@@ -1,13 +1,26 @@
"""数据模型""" """数据模型"""
from .tenant import Tenant, Subscription, Config from .tenant import Tenant, Subscription, Config
from .tenant_app import TenantApp
from .tenant_wechat_app import TenantWechatApp
from .app import App
from .stats import AICallEvent, TenantUsageDaily from .stats import AICallEvent, TenantUsageDaily
from .logs import PlatformLog from .logs import PlatformLog
from .alert import AlertRule, AlertRecord, NotificationChannel
from .pricing import ModelPricing, TenantBilling
__all__ = [ __all__ = [
"Tenant", "Tenant",
"Subscription", "Subscription",
"Config", "Config",
"TenantApp",
"TenantWechatApp",
"App",
"AICallEvent", "AICallEvent",
"TenantUsageDaily", "TenantUsageDaily",
"PlatformLog" "PlatformLog",
"AlertRule",
"AlertRecord",
"NotificationChannel",
"ModelPricing",
"TenantBilling"
] ]

108
backend/app/models/alert.py Normal file
View File

@@ -0,0 +1,108 @@
"""告警相关模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Enum, SmallInteger, JSON, TIMESTAMP
from ..database import Base
class AlertRule(Base):
"""告警规则表"""
__tablename__ = "platform_alert_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) # 规则名称
description = Column(Text) # 规则描述
# 规则类型
rule_type = Column(Enum(
'error_rate', # 错误率告警
'call_count', # 调用次数告警
'token_usage', # Token使用量告警
'cost_threshold', # 费用阈值告警
'latency', # 延迟告警
'custom' # 自定义告警
), nullable=False)
# 作用范围
scope_type = Column(Enum('global', 'tenant', 'app'), default='global') # 作用范围类型
scope_value = Column(String(100)) # 作用范围值如租户ID或应用代码
# 告警条件
condition = Column(JSON, nullable=False) # 告警条件配置
# 示例: {"metric": "error_count", "operator": ">", "threshold": 10, "window": "5m"}
# 通知配置
notification_channels = Column(JSON) # 通知渠道列表
# 示例: [{"type": "wechat_bot", "webhook": "https://..."}, {"type": "email", "to": ["a@b.com"]}]
# 告警限制
cooldown_minutes = Column(Integer, default=30) # 冷却时间(分钟),避免重复告警
max_alerts_per_day = Column(Integer, default=10) # 每天最大告警次数
# 状态
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
priority = Column(Enum('low', 'medium', 'high', 'critical'), default='medium') # 优先级
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class AlertRecord(Base):
"""告警记录表"""
__tablename__ = "platform_alert_records"
id = Column(BigInteger, primary_key=True, autoincrement=True)
rule_id = Column(Integer, nullable=False, index=True) # 关联的规则ID
rule_name = Column(String(100)) # 规则名称(冗余,便于查询)
# 告警信息
alert_type = Column(String(50), nullable=False) # 告警类型
severity = Column(Enum('info', 'warning', 'error', 'critical'), default='warning') # 严重程度
title = Column(String(200), nullable=False) # 告警标题
message = Column(Text) # 告警详情
# 上下文
tenant_id = Column(String(50), index=True) # 相关租户
app_code = Column(String(50)) # 相关应用
metric_value = Column(String(100)) # 触发告警的指标值
threshold_value = Column(String(100)) # 阈值
# 通知状态
notification_status = Column(Enum('pending', 'sent', 'failed', 'skipped'), default='pending')
notification_result = Column(JSON) # 通知结果
notified_at = Column(TIMESTAMP) # 通知时间
# 处理状态
status = Column(Enum('active', 'acknowledged', 'resolved', 'ignored'), default='active')
acknowledged_by = Column(String(100)) # 确认人
acknowledged_at = Column(TIMESTAMP) # 确认时间
resolved_at = Column(TIMESTAMP) # 解决时间
created_at = Column(TIMESTAMP, default=datetime.now, index=True)
class NotificationChannel(Base):
"""通知渠道配置表"""
__tablename__ = "platform_notification_channels"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) # 渠道名称
channel_type = Column(Enum(
'wechat_bot', # 企微机器人
'email', # 邮件
'sms', # 短信
'webhook', # Webhook
'dingtalk' # 钉钉
), nullable=False)
# 渠道配置
config = Column(JSON, nullable=False)
# wechat_bot: {"webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"}
# email: {"smtp_host": "...", "smtp_port": 465, "username": "...", "password_encrypted": "..."}
# webhook: {"url": "https://...", "method": "POST", "headers": {...}}
# 状态
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

26
backend/app/models/app.py Normal file
View File

@@ -0,0 +1,26 @@
"""应用定义模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class App(Base):
"""应用定义表 - 定义可供租户使用的应用"""
__tablename__ = "platform_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
app_code = Column(String(50), nullable=False, unique=True) # 唯一标识,如 tools
app_name = Column(String(100), nullable=False) # 显示名称
base_url = Column(String(500)) # 基础URL如 https://tools.test.ai.ireborn.com.cn
description = Column(Text) # 应用描述
# 应用下的工具/功能列表JSON 数组)
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
tools = Column(Text)
# 是否需要企微JS-SDK
require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,70 @@
"""费用计算相关模型"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Text, DECIMAL, SmallInteger, JSON, TIMESTAMP
from ..database import Base
class ModelPricing(Base):
"""模型价格配置表"""
__tablename__ = "platform_model_pricing"
id = Column(Integer, primary_key=True, autoincrement=True)
# 模型标识
model_name = Column(String(100), nullable=False, unique=True) # 模型名称,如 gpt-4, claude-3-opus
provider = Column(String(50)) # 提供商,如 openai, anthropic, 4sapi
display_name = Column(String(100)) # 显示名称
# 价格配置(单位:元/1K tokens
input_price_per_1k = Column(DECIMAL(10, 6), default=0) # 输入价格
output_price_per_1k = Column(DECIMAL(10, 6), default=0) # 输出价格
# 或固定价格(每次调用)
fixed_price_per_call = Column(DECIMAL(10, 6), default=0)
# 计费方式
pricing_type = Column(String(20), default='token') # token / call / hybrid
# 备注
description = Column(Text)
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class TenantBilling(Base):
"""租户账单表(月度汇总)"""
__tablename__ = "platform_tenant_billing"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, index=True)
billing_month = Column(String(7), nullable=False) # 格式: YYYY-MM
# 使用量统计
total_calls = Column(Integer, default=0) # 总调用次数
total_input_tokens = Column(Integer, default=0) # 总输入token
total_output_tokens = Column(Integer, default=0) # 总输出token
# 费用统计
total_cost = Column(DECIMAL(12, 4), default=0) # 总费用
# 按模型分类的费用明细
cost_by_model = Column(JSON) # {"gpt-4": 10.5, "claude-3": 5.2}
# 按应用分类的费用明细
cost_by_app = Column(JSON) # {"tools": 8.0, "interview": 7.7}
# 状态
status = Column(String(20), default='pending') # pending / confirmed / paid
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class Config:
# 联合唯一索引
__table_args__ = (
{'mysql_charset': 'utf8mb4'}
)

View File

@@ -11,6 +11,7 @@ class Tenant(Base):
id = Column(BigInteger, primary_key=True, autoincrement=True) id = Column(BigInteger, primary_key=True, autoincrement=True)
code = Column(String(50), unique=True, nullable=False) code = Column(String(50), unique=True, nullable=False)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
corp_id = Column(String(100)) # 企业微信企业ID
contact_info = Column(JSON) contact_info = Column(JSON)
status = Column(Enum('active', 'expired', 'trial'), default='active') status = Column(Enum('active', 'expired', 'trial'), default='active')
expired_at = Column(Date) expired_at = Column(Date)

View File

@@ -0,0 +1,28 @@
"""租户应用配置模型"""
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))
# 企业微信配置(关联 platform_tenant_wechat_apps
wechat_app_id = Column(Integer) # 关联的企微应用ID
# 鉴权配置
access_token = Column(String(64)) # 访问令牌(长期有效)
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)

View File

@@ -0,0 +1,23 @@
"""租户企业微信应用配置模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class TenantWechatApp(Base):
"""租户企业微信应用配置表
一个租户可以配置多个企微应用,供不同的平台应用关联使用
"""
__tablename__ = "platform_tenant_wechat_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, index=True)
name = Column(String(100), nullable=False) # 应用名称,如"工具集应用"
corp_id = Column(String(100), nullable=False) # 企业ID
agent_id = Column(String(50), nullable=False) # 应用AgentId
secret_encrypted = Column(Text) # 加密的Secret
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

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

View File

@@ -0,0 +1,430 @@
"""告警管理路由"""
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from ..database import get_db
from ..models.alert import AlertRule, AlertRecord, NotificationChannel
from ..services.alert import AlertService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/alerts", tags=["告警管理"])
# ============= Schemas =============
class AlertRuleCreate(BaseModel):
name: str
description: Optional[str] = None
rule_type: str
scope_type: str = "global"
scope_value: Optional[str] = None
condition: dict
notification_channels: Optional[List[dict]] = None
cooldown_minutes: int = 30
max_alerts_per_day: int = 10
priority: str = "medium"
class AlertRuleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
condition: Optional[dict] = None
notification_channels: Optional[List[dict]] = None
cooldown_minutes: Optional[int] = None
max_alerts_per_day: Optional[int] = None
priority: Optional[str] = None
status: Optional[int] = None
class NotificationChannelCreate(BaseModel):
name: str
channel_type: str
config: dict
class NotificationChannelUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
status: Optional[int] = None
# ============= Alert Rules API =============
@router.get("/rules")
async def list_alert_rules(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
rule_type: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则列表"""
query = db.query(AlertRule)
if rule_type:
query = query.filter(AlertRule.rule_type == rule_type)
if status is not None:
query = query.filter(AlertRule.status == status)
total = query.count()
rules = query.order_by(desc(AlertRule.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_rule(r) for r in rules]
}
@router.get("/rules/{rule_id}")
async def get_alert_rule(
rule_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则详情"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
return format_rule(rule)
@router.post("/rules")
async def create_alert_rule(
data: AlertRuleCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建告警规则"""
rule = AlertRule(
name=data.name,
description=data.description,
rule_type=data.rule_type,
scope_type=data.scope_type,
scope_value=data.scope_value,
condition=data.condition,
notification_channels=data.notification_channels,
cooldown_minutes=data.cooldown_minutes,
max_alerts_per_day=data.max_alerts_per_day,
priority=data.priority,
status=1
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"success": True, "id": rule.id}
@router.put("/rules/{rule_id}")
async def update_alert_rule(
rule_id: int,
data: AlertRuleUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rule, key, value)
db.commit()
return {"success": True}
@router.delete("/rules/{rule_id}")
async def delete_alert_rule(
rule_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
db.delete(rule)
db.commit()
return {"success": True}
# ============= Alert Records API =============
@router.get("/records")
async def list_alert_records(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
severity: Optional[str] = None,
alert_type: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录列表"""
query = db.query(AlertRecord)
if status:
query = query.filter(AlertRecord.status == status)
if severity:
query = query.filter(AlertRecord.severity == severity)
if alert_type:
query = query.filter(AlertRecord.alert_type == alert_type)
if tenant_id:
query = query.filter(AlertRecord.tenant_id == tenant_id)
if start_date:
query = query.filter(AlertRecord.created_at >= start_date)
if end_date:
query = query.filter(AlertRecord.created_at <= end_date + " 23:59:59")
total = query.count()
records = query.order_by(desc(AlertRecord.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_record(r) for r in records]
}
@router.get("/records/summary")
async def get_alert_summary(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警摘要统计"""
today = datetime.now().date()
week_start = today - timedelta(days=7)
# 今日告警数
today_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) == today
).scalar()
# 本周告警数
week_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) >= week_start
).scalar()
# 活跃告警数
active_count = db.query(func.count(AlertRecord.id)).filter(
AlertRecord.status == 'active'
).scalar()
# 按严重程度统计
severity_stats = db.query(
AlertRecord.severity,
func.count(AlertRecord.id)
).filter(
func.date(AlertRecord.created_at) >= week_start
).group_by(AlertRecord.severity).all()
return {
"today_count": today_count,
"week_count": week_count,
"active_count": active_count,
"by_severity": {s: c for s, c in severity_stats}
}
@router.get("/records/{record_id}")
async def get_alert_record(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录详情"""
record = db.query(AlertRecord).filter(AlertRecord.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return format_record(record)
@router.post("/records/{record_id}/acknowledge")
async def acknowledge_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""确认告警"""
service = AlertService(db)
record = service.acknowledge_alert(record_id, user.username)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
@router.post("/records/{record_id}/resolve")
async def resolve_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""解决告警"""
service = AlertService(db)
record = service.resolve_alert(record_id)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
# ============= Check Alerts API =============
@router.post("/check")
async def trigger_alert_check(
background_tasks: BackgroundTasks,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""手动触发告警检查"""
service = AlertService(db)
alerts = await service.check_all_rules()
# 异步发送通知
for alert in alerts:
rule = db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
if rule:
background_tasks.add_task(service.send_notification, alert, rule)
return {
"success": True,
"triggered_count": len(alerts),
"alerts": [format_record(a) for a in alerts]
}
# ============= Notification Channels API =============
@router.get("/channels")
async def list_notification_channels(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(desc(NotificationChannel.created_at)).all()
return [format_channel(c) for c in channels]
@router.post("/channels")
async def create_notification_channel(
data: NotificationChannelCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建通知渠道"""
channel = NotificationChannel(
name=data.name,
channel_type=data.channel_type,
config=data.config,
status=1
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/channels/{channel_id}")
async def update_notification_channel(
channel_id: int,
data: NotificationChannelUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(channel, key, value)
db.commit()
return {"success": True}
@router.delete("/channels/{channel_id}")
async def delete_notification_channel(
channel_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
# ============= Helper Functions =============
def format_rule(rule: AlertRule) -> dict:
return {
"id": rule.id,
"name": rule.name,
"description": rule.description,
"rule_type": rule.rule_type,
"scope_type": rule.scope_type,
"scope_value": rule.scope_value,
"condition": rule.condition,
"notification_channels": rule.notification_channels,
"cooldown_minutes": rule.cooldown_minutes,
"max_alerts_per_day": rule.max_alerts_per_day,
"priority": rule.priority,
"status": rule.status,
"created_at": rule.created_at,
"updated_at": rule.updated_at
}
def format_record(record: AlertRecord) -> dict:
return {
"id": record.id,
"rule_id": record.rule_id,
"rule_name": record.rule_name,
"alert_type": record.alert_type,
"severity": record.severity,
"title": record.title,
"message": record.message,
"tenant_id": record.tenant_id,
"app_code": record.app_code,
"metric_value": record.metric_value,
"threshold_value": record.threshold_value,
"notification_status": record.notification_status,
"status": record.status,
"acknowledged_by": record.acknowledged_by,
"acknowledged_at": record.acknowledged_at,
"resolved_at": record.resolved_at,
"created_at": record.created_at
}
def format_channel(channel: NotificationChannel) -> dict:
return {
"id": channel.id,
"name": channel.name,
"channel_type": channel.channel_type,
"config": channel.config,
"status": channel.status,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}

275
backend/app/routers/apps.py Normal file
View File

@@ -0,0 +1,275 @@
"""应用管理路由"""
import json
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.app import App
from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/apps", tags=["应用管理"])
# ============ Schemas ============
class ToolItem(BaseModel):
"""工具项"""
code: str
name: str
path: str
class AppCreate(BaseModel):
"""创建应用"""
app_code: str
app_name: str
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
require_jssdk: bool = False
class AppUpdate(BaseModel):
"""更新应用"""
app_name: Optional[str] = None
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
require_jssdk: Optional[bool] = None
status: Optional[int] = None
class GenerateUrlRequest(BaseModel):
"""生成链接请求"""
tenant_id: str
app_code: str
tool_code: Optional[str] = None # 不传则生成应用首页链接
# ============ API Endpoints ============
@router.get("")
async def list_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用列表"""
query = db.query(App)
if status is not None:
query = query.filter(App.status == status)
total = query.count()
apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_app(app) for app in apps]
}
@router.get("/all")
async def list_all_apps(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取所有启用的应用(用于下拉选择)"""
apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all()
return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps]
@router.get("/{app_id}")
async def get_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用详情"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
return format_app(app)
@router.post("")
async def create_app(
data: AppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用"""
# 检查 app_code 是否重复
exists = db.query(App).filter(App.app_code == data.app_code).first()
if exists:
raise HTTPException(status_code=400, detail="应用代码已存在")
app = App(
app_code=data.app_code,
app_name=data.app_name,
base_url=data.base_url,
description=data.description,
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
require_jssdk=1 if data.require_jssdk else 0,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_app(
app_id: int,
data: AppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 tools JSON
if 'tools' in update_data:
if update_data['tools']:
update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False)
else:
update_data['tools'] = None
# 处理 require_jssdk
if 'require_jssdk' in update_data:
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] 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_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否有租户在使用
tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count()
if tenant_count > 0:
raise HTTPException(status_code=400, detail=f"{tenant_count} 个租户正在使用此应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.post("/generate-url")
async def generate_url(
data: GenerateUrlRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
生成访问链接
返回完整的可直接使用的 URL使用静态 token长期有效
"""
# 获取应用信息
app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在或已禁用")
if not app.base_url:
raise HTTPException(status_code=400, detail="应用未配置基础URL")
# 获取租户配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户未配置此应用")
if not tenant_app.access_token:
raise HTTPException(status_code=400, detail="租户应用未配置访问令牌")
# 构建基础 URL
base_url = app.base_url.rstrip('/')
if data.tool_code:
# 查找工具路径
tools = json.loads(app.tools) if app.tools else []
tool = next((t for t in tools if t.get('code') == data.tool_code), None)
if tool:
base_url = f"{base_url}{tool.get('path', '')}"
else:
base_url = f"{base_url}/{data.tool_code}"
# 构建参数(静态 token长期有效
params = {
"tid": data.tenant_id,
"token": tenant_app.access_token
}
# 组装 URL
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
full_url = f"{base_url}?{query_string}"
return {
"success": True,
"url": full_url,
"params": params,
"note": "静态链接,长期有效"
}
@router.get("/{app_code}/tools")
async def get_app_tools(
app_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用的工具列表(用于配置权限时选择)"""
app = db.query(App).filter(App.app_code == app_code).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
tools = json.loads(app.tools) if app.tools else []
return tools
def format_app(app: App) -> dict:
"""格式化应用数据"""
return {
"id": app.id,
"app_code": app.app_code,
"app_name": app.app_name,
"base_url": app.base_url,
"description": app.description,
"tools": json.loads(app.tools) if app.tools else [],
"require_jssdk": bool(app.require_jssdk),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

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

@@ -0,0 +1,338 @@
"""认证路由"""
import hmac
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional, List
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 ..services.crypto import decrypt_config
from ..models.user import User
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
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}
# ============ Token 验证 API供外部应用调用 ============
class VerifyTokenRequest(BaseModel):
"""Token 验证请求"""
token: str
app_code: Optional[str] = None # 可选,用于验证 token 是否属于特定应用
class WechatConfig(BaseModel):
"""企微配置"""
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
class VerifyTokenResponse(BaseModel):
"""Token 验证响应"""
valid: bool
tenant_id: Optional[str] = None
app_code: Optional[str] = None
wechat_config: Optional[WechatConfig] = None
error: Optional[str] = None
@router.post("/verify", response_model=VerifyTokenResponse)
async def verify_token(
request: VerifyTokenRequest,
db: Session = Depends(get_db)
):
"""
验证 Token 有效性(供外部应用调用,无需登录)
外部应用收到用户请求后,可调用此接口验证 token
1. 验证 token 是否存在且有效
2. 如传入 app_code验证 token 是否属于该应用
3. 返回租户信息和企微配置
Args:
token: 访问令牌
app_code: 应用代码(可选,用于验证 token 是否属于特定应用)
Returns:
valid: 是否有效
tenant_id: 租户ID
app_code: 应用代码
wechat_config: 企微配置(如有)
"""
if not request.token:
return VerifyTokenResponse(valid=False, error="Token 不能为空")
# 根据 token 查询租户应用配置
query = db.query(TenantApp).filter(
TenantApp.access_token == request.token,
TenantApp.status == 1
)
# 如果指定了 app_code验证 token 是否属于该应用
if request.app_code:
query = query.filter(TenantApp.app_code == request.app_code)
tenant_app = query.first()
if not tenant_app:
return VerifyTokenResponse(valid=False, error="Token 无效或已过期")
# 获取关联的企微配置
wechat_config = None
if tenant_app.wechat_app_id:
wechat_app = db.query(TenantWechatApp).filter(
TenantWechatApp.id == tenant_app.wechat_app_id,
TenantWechatApp.status == 1
).first()
if wechat_app:
# 解密 secret
secret = None
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except:
pass
wechat_config = WechatConfig(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
return VerifyTokenResponse(
valid=True,
tenant_id=tenant_app.tenant_id,
app_code=tenant_app.app_code,
wechat_config=wechat_config
)
@router.get("/verify")
async def verify_token_get(
token: str,
app_code: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
验证 TokenGET 方式,便于简单测试)
"""
return await verify_token(
VerifyTokenRequest(token=token, app_code=app_code),
db
)

333
backend/app/routers/cost.py Normal file
View File

@@ -0,0 +1,333 @@
"""费用管理路由"""
from typing import Optional, List
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.pricing import ModelPricing, TenantBilling
from ..services.cost import CostCalculator
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/cost", tags=["费用管理"])
# ============= Schemas =============
class ModelPricingCreate(BaseModel):
model_name: str
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: float = 0
output_price_per_1k: float = 0
fixed_price_per_call: float = 0
pricing_type: str = "token"
description: Optional[str] = None
class ModelPricingUpdate(BaseModel):
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: Optional[float] = None
output_price_per_1k: Optional[float] = None
fixed_price_per_call: Optional[float] = None
pricing_type: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
class CostCalculateRequest(BaseModel):
model_name: str
input_tokens: int = 0
output_tokens: int = 0
# ============= Model Pricing API =============
@router.get("/pricing")
async def list_model_pricing(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
provider: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格配置列表"""
query = db.query(ModelPricing)
if provider:
query = query.filter(ModelPricing.provider == provider)
if status is not None:
query = query.filter(ModelPricing.status == status)
total = query.count()
items = query.order_by(ModelPricing.model_name).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_pricing(p) for p in items]
}
@router.get("/pricing/{pricing_id}")
async def get_model_pricing(
pricing_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格详情"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
return format_pricing(pricing)
@router.post("/pricing")
async def create_model_pricing(
data: ModelPricingCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建模型价格配置"""
# 检查是否已存在
existing = db.query(ModelPricing).filter(ModelPricing.model_name == data.model_name).first()
if existing:
raise HTTPException(status_code=400, detail="该模型价格配置已存在")
pricing = ModelPricing(
model_name=data.model_name,
provider=data.provider,
display_name=data.display_name,
input_price_per_1k=Decimal(str(data.input_price_per_1k)),
output_price_per_1k=Decimal(str(data.output_price_per_1k)),
fixed_price_per_call=Decimal(str(data.fixed_price_per_call)),
pricing_type=data.pricing_type,
description=data.description,
status=1
)
db.add(pricing)
db.commit()
db.refresh(pricing)
return {"success": True, "id": pricing.id}
@router.put("/pricing/{pricing_id}")
async def update_model_pricing(
pricing_id: int,
data: ModelPricingUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 转换价格字段
for field in ['input_price_per_1k', 'output_price_per_1k', 'fixed_price_per_call']:
if field in update_data and update_data[field] is not None:
update_data[field] = Decimal(str(update_data[field]))
for key, value in update_data.items():
setattr(pricing, key, value)
db.commit()
return {"success": True}
@router.delete("/pricing/{pricing_id}")
async def delete_model_pricing(
pricing_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
db.delete(pricing)
db.commit()
return {"success": True}
# ============= Cost Calculation API =============
@router.post("/calculate")
async def calculate_cost(
request: CostCalculateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""计算调用费用"""
calculator = CostCalculator(db)
cost = calculator.calculate_cost(
model_name=request.model_name,
input_tokens=request.input_tokens,
output_tokens=request.output_tokens
)
return {
"model": request.model_name,
"input_tokens": request.input_tokens,
"output_tokens": request.output_tokens,
"cost": float(cost)
}
@router.get("/summary")
async def get_cost_summary(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取费用汇总"""
calculator = CostCalculator(db)
return calculator.get_cost_summary(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
@router.get("/by-tenant")
async def get_cost_by_tenant(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按租户统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_tenant(
start_date=start_date,
end_date=end_date
)
@router.get("/by-model")
async def get_cost_by_model(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按模型统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_model(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
# ============= Billing API =============
@router.get("/billing")
async def list_billing(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
billing_month: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取账单列表"""
query = db.query(TenantBilling)
if tenant_id:
query = query.filter(TenantBilling.tenant_id == tenant_id)
if billing_month:
query = query.filter(TenantBilling.billing_month == billing_month)
total = query.count()
items = query.order_by(desc(TenantBilling.billing_month)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_billing(b) for b in items]
}
@router.post("/billing/generate")
async def generate_billing(
tenant_id: str = Query(...),
billing_month: str = Query(..., description="格式: YYYY-MM"),
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""生成月度账单"""
calculator = CostCalculator(db)
billing = calculator.generate_monthly_billing(tenant_id, billing_month)
return {
"success": True,
"billing": format_billing(billing)
}
@router.post("/recalculate")
async def recalculate_costs(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新计算事件费用"""
calculator = CostCalculator(db)
updated = calculator.update_event_costs(start_date, end_date)
return {
"success": True,
"updated_count": updated
}
# ============= Helper Functions =============
def format_pricing(pricing: ModelPricing) -> dict:
return {
"id": pricing.id,
"model_name": pricing.model_name,
"provider": pricing.provider,
"display_name": pricing.display_name,
"input_price_per_1k": float(pricing.input_price_per_1k or 0),
"output_price_per_1k": float(pricing.output_price_per_1k or 0),
"fixed_price_per_call": float(pricing.fixed_price_per_call or 0),
"pricing_type": pricing.pricing_type,
"description": pricing.description,
"status": pricing.status,
"created_at": pricing.created_at,
"updated_at": pricing.updated_at
}
def format_billing(billing: TenantBilling) -> dict:
return {
"id": billing.id,
"tenant_id": billing.tenant_id,
"billing_month": billing.billing_month,
"total_calls": billing.total_calls,
"total_input_tokens": billing.total_input_tokens,
"total_output_tokens": billing.total_output_tokens,
"total_cost": float(billing.total_cost or 0),
"cost_by_model": billing.cost_by_model,
"cost_by_app": billing.cost_by_app,
"status": billing.status,
"created_at": billing.created_at,
"updated_at": billing.updated_at
}

View File

@@ -1,7 +1,10 @@
"""健康检查路由""" """健康检查路由"""
from fastapi import APIRouter from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..config import get_settings from ..config import get_settings
from ..database import get_db
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
settings = get_settings() settings = get_settings()
@@ -15,3 +18,28 @@ async def health_check():
"app": settings.APP_NAME, "app": settings.APP_NAME,
"version": settings.APP_VERSION "version": settings.APP_VERSION
} }
@router.post("/migrate/add-corp-id")
async def migrate_add_corp_id(db: Session = Depends(get_db)):
"""迁移:添加租户 corp_id 字段"""
try:
# 检查列是否存在
result = db.execute(text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'platform_tenants' AND COLUMN_NAME = 'corp_id'"
))
exists = result.scalar()
if exists:
return {"success": True, "message": "字段 corp_id 已存在,无需迁移"}
# 添加列
db.execute(text(
"ALTER TABLE platform_tenants ADD COLUMN corp_id VARCHAR(100) AFTER name"
))
db.commit()
return {"success": True, "message": "字段 corp_id 添加成功"}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@@ -1,15 +1,38 @@
"""日志路由""" """日志路由"""
from fastapi import APIRouter, Depends, Header, HTTPException import csv
import io
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db from ..database import get_db
from ..config import get_settings from ..config import get_settings
from ..models.logs import PlatformLog from ..models.logs import PlatformLog
from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/logs", tags=["logs"]) router = APIRouter(prefix="/logs", tags=["logs"])
settings = get_settings() settings = get_settings()
# 尝试导入openpyxl
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
OPENPYXL_AVAILABLE = True
except ImportError:
OPENPYXL_AVAILABLE = False
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")): def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key""" """验证API Key"""
@@ -43,3 +66,213 @@ async def batch_write_logs(
db.add_all(logs) db.add_all(logs)
db.commit() db.commit()
return {"success": True, "count": len(logs)} return {"success": True, "count": len(logs)}
@router.get("")
async def query_logs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""查询日志列表"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if trace_id:
query = query.filter(PlatformLog.trace_id == trace_id)
if keyword:
query = query.filter(PlatformLog.message.like(f"%{keyword}%"))
total = query.count()
items = query.order_by(desc(PlatformLog.log_time)).offset((page-1)*size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": item.id,
"log_type": item.log_type,
"level": item.level,
"app_code": item.app_code,
"tenant_id": item.tenant_id,
"trace_id": item.trace_id,
"message": item.message,
"path": item.path,
"method": item.method,
"status_code": item.status_code,
"duration_ms": item.duration_ms,
"ip_address": item.ip_address,
"extra_data": item.extra_data,
"stack_trace": item.stack_trace,
"log_time": str(item.log_time) if item.log_time else None
}
for item in items
]
}
@router.get("/export")
async def export_logs(
format: str = Query("csv", description="导出格式: csv 或 excel"),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = Query(10000, ge=1, le=100000, description="最大导出记录数"),
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""导出日志
支持CSV和Excel格式最多导出10万条记录
"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if start_date:
query = query.filter(PlatformLog.log_time >= start_date)
if end_date:
query = query.filter(PlatformLog.log_time <= end_date + " 23:59:59")
items = query.order_by(desc(PlatformLog.log_time)).limit(limit).all()
if format.lower() == "excel":
return export_excel(items)
else:
return export_csv(items)
def export_csv(logs: list) -> StreamingResponse:
"""导出为CSV格式"""
output = io.StringIO()
writer = csv.writer(output)
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
writer.writerow(headers)
# 写入数据
for log in logs:
writer.writerow([
log.id,
log.log_type,
log.level,
log.app_code or "",
log.tenant_id or "",
log.trace_id or "",
log.message or "",
log.path or "",
log.method or "",
log.status_code or "",
log.duration_ms or "",
log.ip_address or "",
str(log.log_time) if log.log_time else ""
])
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8-sig"
}
)
def export_excel(logs: list) -> StreamingResponse:
"""导出为Excel格式"""
if not OPENPYXL_AVAILABLE:
raise HTTPException(status_code=400, detail="Excel导出功能不可用请安装openpyxl")
wb = Workbook()
ws = wb.active
ws.title = "日志导出"
# 表头样式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 写入数据
for row, log in enumerate(logs, 2):
ws.cell(row=row, column=1, value=log.id)
ws.cell(row=row, column=2, value=log.log_type)
ws.cell(row=row, column=3, value=log.level)
ws.cell(row=row, column=4, value=log.app_code or "")
ws.cell(row=row, column=5, value=log.tenant_id or "")
ws.cell(row=row, column=6, value=log.trace_id or "")
ws.cell(row=row, column=7, value=log.message or "")
ws.cell(row=row, column=8, value=log.path or "")
ws.cell(row=row, column=9, value=log.method or "")
ws.cell(row=row, column=10, value=log.status_code or "")
ws.cell(row=row, column=11, value=log.duration_ms or "")
ws.cell(row=row, column=12, value=log.ip_address or "")
ws.cell(row=row, column=13, value=str(log.log_time) if log.log_time else "")
# 调整列宽
column_widths = [8, 10, 10, 12, 12, 36, 50, 30, 8, 10, 10, 15, 20]
for col, width in enumerate(column_widths, 1):
ws.column_dimensions[chr(64 + col)].width = width
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
iter([output.getvalue()]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)

View File

@@ -0,0 +1,264 @@
"""配额管理路由"""
from typing import Optional, Dict, Any
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.tenant import Subscription
from ..services.quota import QuotaService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/quota", tags=["配额管理"])
# ============= Schemas =============
class QuotaConfigUpdate(BaseModel):
daily_calls: int = 0
daily_tokens: int = 0
monthly_calls: int = 0
monthly_tokens: int = 0
monthly_cost: float = 0
concurrent_calls: int = 0
class SubscriptionCreate(BaseModel):
tenant_id: str
app_code: str
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: QuotaConfigUpdate
class SubscriptionUpdate(BaseModel):
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: Optional[QuotaConfigUpdate] = None
status: Optional[str] = None
# ============= Quota Check API =============
@router.get("/check")
async def check_quota(
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
estimated_tokens: int = Query(0),
db: Session = Depends(get_db)
):
"""检查配额是否足够
用于调用前检查,返回是否允许继续调用
"""
service = QuotaService(db)
result = service.check_quota(tenant_id, app_code, estimated_tokens)
return {
"allowed": result.allowed,
"reason": result.reason,
"quota_type": result.quota_type,
"limit": result.limit,
"used": result.used,
"remaining": result.remaining
}
@router.get("/summary")
async def get_quota_summary(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用汇总"""
service = QuotaService(db)
return service.get_quota_summary(tenant_id, app_code)
@router.get("/usage")
async def get_quota_usage(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用情况"""
service = QuotaService(db)
usage = service.get_usage(tenant_id, app_code)
return {
"daily_calls": usage.daily_calls,
"daily_tokens": usage.daily_tokens,
"monthly_calls": usage.monthly_calls,
"monthly_tokens": usage.monthly_tokens,
"monthly_cost": round(usage.monthly_cost, 2)
}
# ============= Subscription API =============
@router.get("/subscriptions")
async def list_subscriptions(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
status: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅列表"""
query = db.query(Subscription)
if tenant_id:
query = query.filter(Subscription.tenant_id == tenant_id)
if app_code:
query = query.filter(Subscription.app_code == app_code)
if status:
query = query.filter(Subscription.status == status)
total = query.count()
items = query.order_by(desc(Subscription.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_subscription(s) for s in items]
}
@router.get("/subscriptions/{subscription_id}")
async def get_subscription(
subscription_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅详情"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
return format_subscription(subscription)
@router.post("/subscriptions")
async def create_subscription(
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
# 检查是否已存在
existing = db.query(Subscription).filter(
Subscription.tenant_id == data.tenant_id,
Subscription.app_code == data.app_code,
Subscription.status == 'active'
).first()
if existing:
raise HTTPException(status_code=400, detail="该租户应用已有活跃订阅")
subscription = Subscription(
tenant_id=data.tenant_id,
app_code=data.app_code,
start_date=data.start_date or date.today(),
end_date=data.end_date,
quota=data.quota.model_dump() if data.quota else {},
status='active'
)
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="订阅不存在")
if data.start_date:
subscription.start_date = data.start_date
if data.end_date:
subscription.end_date = data.end_date
if data.quota:
subscription.quota = data.quota.model_dump()
if data.status:
subscription.status = data.status
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
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}
@router.put("/subscriptions/{subscription_id}/quota")
async def update_quota(
subscription_id: int,
data: QuotaConfigUpdate,
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="订阅不存在")
subscription.quota = data.model_dump()
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
return {"success": True}
# ============= Helper Functions =============
def format_subscription(subscription: Subscription) -> dict:
return {
"id": subscription.id,
"tenant_id": subscription.tenant_id,
"app_code": subscription.app_code,
"start_date": str(subscription.start_date) if subscription.start_date else None,
"end_date": str(subscription.end_date) if subscription.end_date else None,
"quota": subscription.quota or {},
"status": subscription.status,
"created_at": subscription.created_at,
"updated_at": subscription.updated_at
}

View File

@@ -1,16 +1,28 @@
"""统计上报路由""" """统计上报路由"""
from fastapi import APIRouter, Depends, Header, HTTPException from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db from ..database import get_db
from ..config import get_settings from ..config import get_settings
from ..models.stats import AICallEvent from ..models.stats import AICallEvent
from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/stats", tags=["stats"]) router = APIRouter(prefix="/stats", tags=["stats"])
settings = get_settings() settings = get_settings()
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")): def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key""" """验证API Key"""
if x_api_key != settings.API_KEY: if x_api_key != settings.API_KEY:
@@ -43,3 +55,79 @@ async def batch_report_ai_calls(
db.add_all(events) db.add_all(events)
db.commit() db.commit()
return {"success": True, "count": len(events)} return {"success": True, "count": len(events)}
@router.get("/summary")
async def get_stats_summary(
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取统计摘要(用于仪表盘)"""
today = datetime.now().date()
# 今日调用次数和 token 消耗
today_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) == today
).first()
# 本周数据
week_start = today - timedelta(days=today.weekday())
week_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= week_start
).first()
return {
"today_calls": today_stats.calls if today_stats else 0,
"today_tokens": int(today_stats.tokens) if today_stats else 0,
"week_calls": week_stats.calls if week_stats else 0,
"week_tokens": int(week_stats.tokens) if week_stats else 0
}
@router.get("/trend")
async def get_stats_trend(
days: int = Query(7, ge=1, le=30),
tenant_id: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取调用趋势数据"""
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days-1)
query = db.query(
func.date(AICallEvent.created_at).label('date'),
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= start_date,
func.date(AICallEvent.created_at) <= end_date
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
results = query.group_by(func.date(AICallEvent.created_at)).all()
# 转换为字典便于查找
data_map = {str(r.date): {"calls": r.calls, "tokens": int(r.tokens)} for r in results}
# 填充所有日期
trend = []
current = start_date
while current <= end_date:
date_str = str(current)
trend.append({
"date": date_str,
"calls": data_map.get(date_str, {}).get("calls", 0),
"tokens": data_map.get(date_str, {}).get("tokens", 0)
})
current += timedelta(days=1)
return {"trend": trend}

View File

@@ -0,0 +1,214 @@
"""租户应用配置路由"""
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 ..models.app import App
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
# Schemas
class TenantAppCreate(BaseModel):
tenant_id: str
app_code: str = "tools"
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = None # 如果不传则自动生成
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
class TenantAppUpdate(BaseModel):
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = 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, db=db) 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, db=db)
@router.post("")
async def create_tenant_app(
data: TenantAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用配置"""
# 验证 app_code 是否存在于应用管理中
app_exists = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app_exists:
raise HTTPException(status_code=400, detail=f"应用 '{data.app_code}' 不存在,请先在应用管理中创建")
# 检查是否重复
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="该租户应用配置已存在")
# 自动生成 access_token
access_token = data.access_token or secrets.token_hex(32)
app = TenantApp(
tenant_id=data.tenant_id,
app_code=data.app_code,
app_name=data.app_name,
wechat_app_id=data.wechat_app_id,
access_token=access_token,
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, "access_token": access_token}
@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)
# 处理 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
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)
):
"""重新生成 access_token"""
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.access_token = new_token
db.commit()
return {"success": True, "access_token": new_token}
def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = None) -> dict:
"""格式化应用配置"""
# 获取关联的企微应用信息
wechat_app_info = None
if app.wechat_app_id and db:
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app.wechat_app_id).first()
if wechat_app:
wechat_app_info = {
"id": wechat_app.id,
"name": wechat_app.name,
"corp_id": wechat_app.corp_id,
"agent_id": wechat_app.agent_id
}
result = {
"id": app.id,
"tenant_id": app.tenant_id,
"app_code": app.app_code,
"app_name": app.app_name,
"wechat_app_id": app.wechat_app_id,
"wechat_app": wechat_app_info,
"access_token": "******" if mask_secret and app.access_token else app.access_token,
"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,198 @@
"""租户企业微信应用配置路由"""
import json
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_wechat_app import TenantWechatApp
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-wechat-apps", tags=["租户企微应用"])
# Schemas
class TenantWechatAppCreate(BaseModel):
tenant_id: str
name: str
corp_id: str
agent_id: str
secret: Optional[str] = None # 明文,存储时加密
class TenantWechatAppUpdate(BaseModel):
name: Optional[str] = None
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_wechat_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户企微应用列表"""
query = db.query(TenantWechatApp)
if tenant_id:
query = query.filter(TenantWechatApp.tenant_id == tenant_id)
total = query.count()
apps = query.order_by(TenantWechatApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_wechat_app(app) for app in apps]
}
@router.get("/by-tenant/{tenant_id}")
async def list_by_tenant(
tenant_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取指定租户的所有企微应用(用于下拉选择)"""
apps = db.query(TenantWechatApp).filter(
TenantWechatApp.tenant_id == tenant_id,
TenantWechatApp.status == 1
).order_by(TenantWechatApp.id.asc()).all()
return [{"id": app.id, "name": app.name, "corp_id": app.corp_id, "agent_id": app.agent_id} for app in apps]
@router.get("/{app_id}")
async def get_tenant_wechat_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取企微应用详情"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
return format_wechat_app(app)
@router.post("")
async def create_tenant_wechat_app(
data: TenantWechatAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建企微应用"""
# 加密 secret
secret_encrypted = None
if data.secret:
secret_encrypted = encrypt_config(data.secret)
app = TenantWechatApp(
tenant_id=data.tenant_id,
name=data.name,
corp_id=data.corp_id,
agent_id=data.agent_id,
secret_encrypted=secret_encrypted,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_tenant_wechat_app(
app_id: int,
data: TenantWechatAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 secret 加密
if 'secret' in update_data:
if update_data['secret']:
app.secret_encrypted = encrypt_config(update_data['secret'])
del update_data['secret']
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_wechat_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
# 检查是否有租户应用在使用
from ..models.tenant_app import TenantApp
usage_count = db.query(TenantApp).filter(TenantApp.wechat_app_id == app_id).count()
if usage_count > 0:
raise HTTPException(status_code=400, detail=f"{usage_count} 个应用配置正在使用此企微应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.get("/{app_id}/secret")
async def get_wechat_secret(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取解密的 secret仅操作员以上"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
secret = None
if app.secret_encrypted:
secret = decrypt_config(app.secret_encrypted)
return {"secret": secret}
def format_wechat_app(app: TenantWechatApp) -> dict:
"""格式化企微应用数据"""
return {
"id": app.id,
"tenant_id": app.tenant_id,
"name": app.name,
"corp_id": app.corp_id,
"agent_id": app.agent_id,
"has_secret": bool(app.secret_encrypted),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

View File

@@ -0,0 +1,306 @@
"""租户管理路由"""
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
corp_id: Optional[str] = None # 企业微信企业ID
contact_info: Optional[dict] = None
status: str = "active"
expired_at: Optional[date] = None
class TenantUpdate(BaseModel):
name: Optional[str] = None
corp_id: Optional[str] = None # 企业微信企业ID
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,
"corp_id": getattr(t, 'corp_id', None),
"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,
"corp_id": getattr(tenant, 'corp_id', None),
"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,
corp_id=data.corp_id,
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}

View File

@@ -0,0 +1,264 @@
"""企业微信JS-SDK路由"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
from ..services.wechat import WechatService, get_wechat_service_by_id
router = APIRouter(prefix="/wechat", tags=["企业微信"])
class JssdkSignatureRequest(BaseModel):
"""JS-SDK签名请求"""
url: str # 当前页面URL不含#及其后面部分)
class JssdkSignatureResponse(BaseModel):
"""JS-SDK签名响应"""
appId: str
agentId: str
timestamp: int
nonceStr: str
signature: str
class OAuth2UrlRequest(BaseModel):
"""OAuth2授权URL请求"""
redirect_uri: str
scope: str = "snsapi_base"
state: str = ""
class UserInfoRequest(BaseModel):
"""用户信息请求"""
code: str
@router.post("/jssdk/signature")
async def get_jssdk_signature(
request: JssdkSignatureRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名
用于前端初始化企业微信JS-SDK
Args:
request: 包含当前页面URL
tenant_id: 租户ID
app_code: 应用代码
Returns:
JS-SDK签名信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(request.url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.get("/jssdk/signature")
async def get_jssdk_signature_get(
url: str = Query(..., description="当前页面URL"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名GET方式
方便前端JSONP调用
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.post("/oauth2/url")
async def get_oauth2_url(
request: OAuth2UrlRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取OAuth2授权URL
用于企业微信内网页获取用户身份
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成OAuth2 URL
oauth_url = wechat_service.get_oauth2_url(
redirect_uri=request.redirect_uri,
scope=request.scope,
state=request.state
)
return {"url": oauth_url}
@router.post("/oauth2/userinfo")
async def get_user_info(
request: UserInfoRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息
在OAuth2回调后用code换取用户信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(request.code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/oauth2/userinfo")
async def get_user_info_get(
code: str = Query(..., description="OAuth2回调的code"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息GET方式"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/user/{user_id}")
async def get_user_detail(
user_id: str,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取企业微信成员详细信息"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户详情
user_detail = await wechat_service.get_user_detail(user_id)
if not user_detail:
raise HTTPException(status_code=404, detail="用户不存在")
return user_detail

View File

@@ -1,4 +1,22 @@
"""业务服务""" """业务服务"""
from .crypto import encrypt_value, decrypt_value from .crypto import encrypt_value, decrypt_value
from .cache import CacheService, get_cache, get_redis_client
from .wechat import WechatService, get_wechat_service_by_id
from .alert import AlertService
from .cost import CostCalculator, calculate_cost
from .quota import QuotaService, check_quota_middleware
__all__ = ["encrypt_value", "decrypt_value"] __all__ = [
"encrypt_value",
"decrypt_value",
"CacheService",
"get_cache",
"get_redis_client",
"WechatService",
"get_wechat_service_by_id",
"AlertService",
"CostCalculator",
"calculate_cost",
"QuotaService",
"check_quota_middleware"
]

View File

@@ -0,0 +1,455 @@
"""告警服务"""
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.alert import AlertRule, AlertRecord, NotificationChannel
from ..models.stats import AICallEvent
from ..models.logs import PlatformLog
from .cache import get_cache
logger = logging.getLogger(__name__)
class AlertService:
"""告警服务
提供告警规则检测、告警记录管理、通知发送等功能
"""
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
async def check_all_rules(self) -> List[AlertRecord]:
"""检查所有启用的告警规则
Returns:
触发的告警记录列表
"""
rules = self.db.query(AlertRule).filter(AlertRule.status == 1).all()
triggered_alerts = []
for rule in rules:
try:
alert = await self.check_rule(rule)
if alert:
triggered_alerts.append(alert)
except Exception as e:
logger.error(f"Failed to check rule {rule.id}: {e}")
return triggered_alerts
async def check_rule(self, rule: AlertRule) -> Optional[AlertRecord]:
"""检查单个告警规则
Args:
rule: 告警规则
Returns:
触发的告警记录或None
"""
# 检查冷却期
if self._is_in_cooldown(rule):
logger.debug(f"Rule {rule.id} is in cooldown")
return None
# 检查每日告警次数限制
if self._exceeds_daily_limit(rule):
logger.debug(f"Rule {rule.id} exceeds daily limit")
return None
# 根据规则类型检查
metric_value = None
threshold_value = None
triggered = False
condition = rule.condition or {}
if rule.rule_type == 'error_rate':
triggered, metric_value, threshold_value = self._check_error_rate(rule, condition)
elif rule.rule_type == 'call_count':
triggered, metric_value, threshold_value = self._check_call_count(rule, condition)
elif rule.rule_type == 'token_usage':
triggered, metric_value, threshold_value = self._check_token_usage(rule, condition)
elif rule.rule_type == 'cost_threshold':
triggered, metric_value, threshold_value = self._check_cost_threshold(rule, condition)
elif rule.rule_type == 'latency':
triggered, metric_value, threshold_value = self._check_latency(rule, condition)
if triggered:
alert = self._create_alert_record(rule, metric_value, threshold_value)
return alert
return None
def _is_in_cooldown(self, rule: AlertRule) -> bool:
"""检查规则是否在冷却期"""
cache_key = f"alert:cooldown:{rule.id}"
return self._cache.exists(cache_key)
def _set_cooldown(self, rule: AlertRule):
"""设置规则冷却期"""
cache_key = f"alert:cooldown:{rule.id}"
self._cache.set(cache_key, "1", ttl=rule.cooldown_minutes * 60)
def _exceeds_daily_limit(self, rule: AlertRule) -> bool:
"""检查是否超过每日告警次数限制"""
today = datetime.now().date()
count = self.db.query(func.count(AlertRecord.id)).filter(
AlertRecord.rule_id == rule.id,
func.date(AlertRecord.created_at) == today
).scalar()
return count >= rule.max_alerts_per_day
def _check_error_rate(self, rule: AlertRule, condition: dict) -> tuple:
"""检查错误率"""
window_minutes = self._parse_window(condition.get('window', '5m'))
threshold = condition.get('threshold', 10) # 错误次数阈值
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(func.count(AICallEvent.id)).filter(
AICallEvent.created_at >= since,
AICallEvent.status == 'error'
)
# 应用作用范围
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
error_count = query.scalar() or 0
triggered = self._compare(error_count, threshold, operator)
return triggered, str(error_count), str(threshold)
def _check_call_count(self, rule: AlertRule, condition: dict) -> tuple:
"""检查调用次数"""
window_minutes = self._parse_window(condition.get('window', '1h'))
threshold = condition.get('threshold', 1000)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(func.count(AICallEvent.id)).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
call_count = query.scalar() or 0
triggered = self._compare(call_count, threshold, operator)
return triggered, str(call_count), str(threshold)
def _check_token_usage(self, rule: AlertRule, condition: dict) -> tuple:
"""检查Token使用量"""
window_minutes = self._parse_window(condition.get('window', '1d'))
threshold = condition.get('threshold', 100000)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0)
).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
token_usage = query.scalar() or 0
triggered = self._compare(token_usage, threshold, operator)
return triggered, str(token_usage), str(threshold)
def _check_cost_threshold(self, rule: AlertRule, condition: dict) -> tuple:
"""检查费用阈值"""
window_minutes = self._parse_window(condition.get('window', '1d'))
threshold = condition.get('threshold', 100) # 费用阈值(元)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(
func.coalesce(func.sum(AICallEvent.cost), 0)
).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
total_cost = float(query.scalar() or 0)
triggered = self._compare(total_cost, threshold, operator)
return triggered, f"¥{total_cost:.2f}", f"¥{threshold:.2f}"
def _check_latency(self, rule: AlertRule, condition: dict) -> tuple:
"""检查延迟"""
window_minutes = self._parse_window(condition.get('window', '5m'))
threshold = condition.get('threshold', 5000) # 延迟阈值(ms)
operator = condition.get('operator', '>')
percentile = condition.get('percentile', 'avg') # avg, p95, p99, max
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(AICallEvent.latency_ms).filter(
AICallEvent.created_at >= since,
AICallEvent.latency_ms.isnot(None)
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
latencies = [r.latency_ms for r in query.all()]
if not latencies:
return False, "0", str(threshold)
if percentile == 'avg':
metric = sum(latencies) / len(latencies)
elif percentile == 'max':
metric = max(latencies)
elif percentile == 'p95':
latencies.sort()
idx = int(len(latencies) * 0.95)
metric = latencies[idx] if idx < len(latencies) else latencies[-1]
elif percentile == 'p99':
latencies.sort()
idx = int(len(latencies) * 0.99)
metric = latencies[idx] if idx < len(latencies) else latencies[-1]
else:
metric = sum(latencies) / len(latencies)
triggered = self._compare(metric, threshold, operator)
return triggered, f"{metric:.0f}ms", f"{threshold}ms"
def _parse_window(self, window: str) -> int:
"""解析时间窗口字符串为分钟数"""
if window.endswith('m'):
return int(window[:-1])
elif window.endswith('h'):
return int(window[:-1]) * 60
elif window.endswith('d'):
return int(window[:-1]) * 60 * 24
else:
return int(window)
def _compare(self, value: float, threshold: float, operator: str) -> bool:
"""比较值与阈值"""
if operator == '>':
return value > threshold
elif operator == '>=':
return value >= threshold
elif operator == '<':
return value < threshold
elif operator == '<=':
return value <= threshold
elif operator == '==':
return value == threshold
elif operator == '!=':
return value != threshold
return False
def _create_alert_record(
self,
rule: AlertRule,
metric_value: str,
threshold_value: str
) -> AlertRecord:
"""创建告警记录"""
title = f"[{rule.priority.upper()}] {rule.name}"
message = f"规则 '{rule.name}' 触发告警\n当前值: {metric_value}\n阈值: {threshold_value}"
if rule.scope_type == 'tenant':
message += f"\n租户: {rule.scope_value}"
elif rule.scope_type == 'app':
message += f"\n应用: {rule.scope_value}"
alert = AlertRecord(
rule_id=rule.id,
rule_name=rule.name,
alert_type=rule.rule_type,
severity=self._priority_to_severity(rule.priority),
title=title,
message=message,
tenant_id=rule.scope_value if rule.scope_type == 'tenant' else None,
app_code=rule.scope_value if rule.scope_type == 'app' else None,
metric_value=metric_value,
threshold_value=threshold_value,
notification_status='pending'
)
self.db.add(alert)
self.db.commit()
self.db.refresh(alert)
# 设置冷却期
self._set_cooldown(rule)
logger.info(f"Alert triggered: {title}")
return alert
def _priority_to_severity(self, priority: str) -> str:
"""将优先级转换为严重程度"""
mapping = {
'low': 'info',
'medium': 'warning',
'high': 'error',
'critical': 'critical'
}
return mapping.get(priority, 'warning')
async def send_notification(self, alert: AlertRecord, rule: AlertRule) -> bool:
"""发送告警通知
Args:
alert: 告警记录
rule: 告警规则
Returns:
是否发送成功
"""
if not rule.notification_channels:
alert.notification_status = 'skipped'
self.db.commit()
return True
results = []
success = True
for channel_config in rule.notification_channels:
try:
result = await self._send_to_channel(channel_config, alert)
results.append(result)
if not result.get('success'):
success = False
except Exception as e:
logger.error(f"Failed to send notification: {e}")
results.append({'success': False, 'error': str(e)})
success = False
alert.notification_status = 'sent' if success else 'failed'
alert.notification_result = results
alert.notified_at = datetime.now()
self.db.commit()
return success
async def _send_to_channel(self, channel_config: dict, alert: AlertRecord) -> dict:
"""发送到指定渠道"""
channel_type = channel_config.get('type')
if channel_type == 'wechat_bot':
return await self._send_wechat_bot(channel_config, alert)
elif channel_type == 'webhook':
return await self._send_webhook(channel_config, alert)
else:
return {'success': False, 'error': f'Unsupported channel type: {channel_type}'}
async def _send_wechat_bot(self, config: dict, alert: AlertRecord) -> dict:
"""发送到企微机器人"""
webhook = config.get('webhook')
if not webhook:
return {'success': False, 'error': 'Missing webhook URL'}
# 构建消息
content = f"**{alert.title}**\n\n{alert.message}\n\n时间: {alert.created_at}"
payload = {
"msgtype": "markdown",
"markdown": {
"content": content
}
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(webhook, json=payload)
result = response.json()
if result.get('errcode', 0) == 0:
return {'success': True, 'channel': 'wechat_bot'}
else:
return {'success': False, 'error': result.get('errmsg')}
except Exception as e:
return {'success': False, 'error': str(e)}
async def _send_webhook(self, config: dict, alert: AlertRecord) -> dict:
"""发送到Webhook"""
url = config.get('url')
if not url:
return {'success': False, 'error': 'Missing webhook URL'}
payload = {
"alert_id": alert.id,
"title": alert.title,
"message": alert.message,
"severity": alert.severity,
"alert_type": alert.alert_type,
"metric_value": alert.metric_value,
"threshold_value": alert.threshold_value,
"created_at": alert.created_at.isoformat()
}
headers = config.get('headers', {})
method = config.get('method', 'POST')
try:
async with httpx.AsyncClient(timeout=10) as client:
if method.upper() == 'POST':
response = await client.post(url, json=payload, headers=headers)
else:
response = await client.get(url, params=payload, headers=headers)
if response.status_code < 400:
return {'success': True, 'channel': 'webhook', 'status': response.status_code}
else:
return {'success': False, 'error': f'HTTP {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
def acknowledge_alert(self, alert_id: int, acknowledged_by: str) -> Optional[AlertRecord]:
"""确认告警"""
alert = self.db.query(AlertRecord).filter(AlertRecord.id == alert_id).first()
if not alert:
return None
alert.status = 'acknowledged'
alert.acknowledged_by = acknowledged_by
alert.acknowledged_at = datetime.now()
self.db.commit()
return alert
def resolve_alert(self, alert_id: int) -> Optional[AlertRecord]:
"""解决告警"""
alert = self.db.query(AlertRecord).filter(AlertRecord.id == alert_id).first()
if not alert:
return None
alert.status = 'resolved'
alert.resolved_at = datetime.now()
self.db.commit()
return alert

View File

@@ -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()

View File

@@ -0,0 +1,309 @@
"""Redis缓存服务"""
import json
import logging
from typing import Optional, Any, Union
from functools import lru_cache
try:
import redis
from redis import Redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
Redis = None
from ..config import get_settings
logger = logging.getLogger(__name__)
# 全局Redis连接池
_redis_pool: Optional[Any] = None
_redis_client: Optional[Any] = None
def get_redis_client() -> Optional[Any]:
"""获取Redis客户端单例"""
global _redis_pool, _redis_client
if not REDIS_AVAILABLE:
logger.warning("Redis module not installed, cache disabled")
return None
if _redis_client is not None:
return _redis_client
settings = get_settings()
try:
_redis_pool = redis.ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=20,
decode_responses=True
)
_redis_client = Redis(connection_pool=_redis_pool)
# 测试连接
_redis_client.ping()
logger.info(f"Redis connected: {settings.REDIS_URL}")
return _redis_client
except Exception as e:
logger.warning(f"Redis connection failed: {e}, cache disabled")
_redis_client = None
return None
class CacheService:
"""缓存服务
提供统一的缓存接口支持Redis和内存回退
使用示例:
cache = CacheService()
# 设置缓存
cache.set("user:123", {"name": "test"}, ttl=3600)
# 获取缓存
user = cache.get("user:123")
# 删除缓存
cache.delete("user:123")
"""
def __init__(self, prefix: Optional[str] = None):
"""初始化缓存服务
Args:
prefix: 键前缀默认使用配置中的REDIS_PREFIX
"""
settings = get_settings()
self.prefix = prefix or settings.REDIS_PREFIX
self._client = get_redis_client()
# 内存回退缓存当Redis不可用时使用
self._memory_cache: dict = {}
@property
def is_available(self) -> bool:
"""Redis是否可用"""
return self._client is not None
def _make_key(self, key: str) -> str:
"""生成完整的缓存键"""
return f"{self.prefix}{key}"
def get(self, key: str, default: Any = None) -> Any:
"""获取缓存值
Args:
key: 缓存键
default: 默认值
Returns:
缓存值或默认值
"""
full_key = self._make_key(key)
if self._client:
try:
value = self._client.get(full_key)
if value is None:
return default
# 尝试解析JSON
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
except Exception as e:
logger.error(f"Cache get error: {e}")
return default
else:
# 内存回退
return self._memory_cache.get(full_key, default)
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
nx: bool = False
) -> bool:
"""设置缓存值
Args:
key: 缓存键
value: 缓存值
ttl: 过期时间(秒)
nx: 只在键不存在时设置
Returns:
是否设置成功
"""
full_key = self._make_key(key)
# 序列化值
if isinstance(value, (dict, list)):
serialized = json.dumps(value, ensure_ascii=False)
else:
serialized = str(value) if value is not None else ""
if self._client:
try:
if nx:
result = self._client.set(full_key, serialized, ex=ttl, nx=True)
else:
result = self._client.set(full_key, serialized, ex=ttl)
return bool(result)
except Exception as e:
logger.error(f"Cache set error: {e}")
return False
else:
# 内存回退不支持TTL和NX
if nx and full_key in self._memory_cache:
return False
self._memory_cache[full_key] = value
return True
def delete(self, key: str) -> bool:
"""删除缓存
Args:
key: 缓存键
Returns:
是否删除成功
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.delete(full_key))
except Exception as e:
logger.error(f"Cache delete error: {e}")
return False
else:
return self._memory_cache.pop(full_key, None) is not None
def exists(self, key: str) -> bool:
"""检查键是否存在
Args:
key: 缓存键
Returns:
是否存在
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.exists(full_key))
except Exception as e:
logger.error(f"Cache exists error: {e}")
return False
else:
return full_key in self._memory_cache
def ttl(self, key: str) -> int:
"""获取键的剩余过期时间
Args:
key: 缓存键
Returns:
剩余秒数,-1表示永不过期-2表示键不存在
"""
full_key = self._make_key(key)
if self._client:
try:
return self._client.ttl(full_key)
except Exception as e:
logger.error(f"Cache ttl error: {e}")
return -2
else:
return -1 if full_key in self._memory_cache else -2
def incr(self, key: str, amount: int = 1) -> int:
"""递增计数器
Args:
key: 缓存键
amount: 递增量
Returns:
递增后的值
"""
full_key = self._make_key(key)
if self._client:
try:
return self._client.incrby(full_key, amount)
except Exception as e:
logger.error(f"Cache incr error: {e}")
return 0
else:
current = self._memory_cache.get(full_key, 0)
new_value = int(current) + amount
self._memory_cache[full_key] = new_value
return new_value
def expire(self, key: str, ttl: int) -> bool:
"""设置键的过期时间
Args:
key: 缓存键
ttl: 过期时间(秒)
Returns:
是否设置成功
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.expire(full_key, ttl))
except Exception as e:
logger.error(f"Cache expire error: {e}")
return False
else:
return full_key in self._memory_cache
def clear_prefix(self, prefix: str) -> int:
"""删除指定前缀的所有键
Args:
prefix: 键前缀
Returns:
删除的键数量
"""
full_prefix = self._make_key(prefix)
if self._client:
try:
keys = self._client.keys(f"{full_prefix}*")
if keys:
return self._client.delete(*keys)
return 0
except Exception as e:
logger.error(f"Cache clear_prefix error: {e}")
return 0
else:
count = 0
keys_to_delete = [k for k in self._memory_cache if k.startswith(full_prefix)]
for k in keys_to_delete:
del self._memory_cache[k]
count += 1
return count
# 全局缓存实例
_cache_instance: Optional[CacheService] = None
def get_cache() -> CacheService:
"""获取全局缓存实例"""
global _cache_instance
if _cache_instance is None:
_cache_instance = CacheService()
return _cache_instance

View File

@@ -0,0 +1,420 @@
"""费用计算服务"""
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, List
from functools import lru_cache
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.pricing import ModelPricing, TenantBilling
from ..models.stats import AICallEvent
from .cache import get_cache
logger = logging.getLogger(__name__)
class CostCalculator:
"""费用计算器
使用示例:
calculator = CostCalculator(db)
# 计算单次调用费用
cost = calculator.calculate_cost("gpt-4", input_tokens=100, output_tokens=200)
# 生成月度账单
billing = calculator.generate_monthly_billing("qiqi", "2026-01")
"""
# 默认模型价格(当数据库中无配置时使用)
DEFAULT_PRICING = {
# OpenAI
"gpt-4": {"input": 0.21, "output": 0.42}, # 元/1K tokens
"gpt-4-turbo": {"input": 0.07, "output": 0.21},
"gpt-4o": {"input": 0.035, "output": 0.105},
"gpt-4o-mini": {"input": 0.00105, "output": 0.0042},
"gpt-3.5-turbo": {"input": 0.0035, "output": 0.014},
# Anthropic
"claude-3-opus": {"input": 0.105, "output": 0.525},
"claude-3-sonnet": {"input": 0.021, "output": 0.105},
"claude-3-haiku": {"input": 0.00175, "output": 0.00875},
"claude-3.5-sonnet": {"input": 0.021, "output": 0.105},
# 国内模型
"qwen-max": {"input": 0.02, "output": 0.06},
"qwen-plus": {"input": 0.004, "output": 0.012},
"qwen-turbo": {"input": 0.002, "output": 0.006},
"glm-4": {"input": 0.01, "output": 0.01},
"glm-4-flash": {"input": 0.0001, "output": 0.0001},
"deepseek-chat": {"input": 0.001, "output": 0.002},
"deepseek-coder": {"input": 0.001, "output": 0.002},
# 默认
"default": {"input": 0.01, "output": 0.03}
}
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
self._pricing_cache: Dict[str, ModelPricing] = {}
def get_model_pricing(self, model_name: str) -> Optional[ModelPricing]:
"""获取模型价格配置
Args:
model_name: 模型名称
Returns:
ModelPricing实例或None
"""
# 尝试从缓存获取
cache_key = f"pricing:{model_name}"
cached = self._cache.get(cache_key)
if cached:
return self._dict_to_pricing(cached)
# 从数据库查询
pricing = self.db.query(ModelPricing).filter(
ModelPricing.model_name == model_name,
ModelPricing.status == 1
).first()
if pricing:
# 缓存1小时
self._cache.set(cache_key, self._pricing_to_dict(pricing), ttl=3600)
return pricing
return None
def _pricing_to_dict(self, pricing: ModelPricing) -> dict:
return {
"model_name": pricing.model_name,
"input_price_per_1k": str(pricing.input_price_per_1k),
"output_price_per_1k": str(pricing.output_price_per_1k),
"fixed_price_per_call": str(pricing.fixed_price_per_call),
"pricing_type": pricing.pricing_type
}
def _dict_to_pricing(self, d: dict) -> ModelPricing:
pricing = ModelPricing()
pricing.model_name = d.get("model_name")
pricing.input_price_per_1k = Decimal(d.get("input_price_per_1k", "0"))
pricing.output_price_per_1k = Decimal(d.get("output_price_per_1k", "0"))
pricing.fixed_price_per_call = Decimal(d.get("fixed_price_per_call", "0"))
pricing.pricing_type = d.get("pricing_type", "token")
return pricing
def calculate_cost(
self,
model_name: str,
input_tokens: int = 0,
output_tokens: int = 0,
call_count: int = 1
) -> Decimal:
"""计算调用费用
Args:
model_name: 模型名称
input_tokens: 输入token数
output_tokens: 输出token数
call_count: 调用次数
Returns:
费用(元)
"""
# 尝试获取数据库配置
pricing = self.get_model_pricing(model_name)
if pricing:
if pricing.pricing_type == 'call':
return pricing.fixed_price_per_call * call_count
elif pricing.pricing_type == 'hybrid':
token_cost = (
pricing.input_price_per_1k * Decimal(input_tokens) / 1000 +
pricing.output_price_per_1k * Decimal(output_tokens) / 1000
)
call_cost = pricing.fixed_price_per_call * call_count
return token_cost + call_cost
else: # token
return (
pricing.input_price_per_1k * Decimal(input_tokens) / 1000 +
pricing.output_price_per_1k * Decimal(output_tokens) / 1000
)
# 使用默认价格
default_prices = self.DEFAULT_PRICING.get(model_name) or self.DEFAULT_PRICING.get("default")
input_price = Decimal(str(default_prices["input"]))
output_price = Decimal(str(default_prices["output"]))
return (
input_price * Decimal(input_tokens) / 1000 +
output_price * Decimal(output_tokens) / 1000
)
def calculate_event_cost(self, event: AICallEvent) -> Decimal:
"""计算单个事件的费用
Args:
event: AI调用事件
Returns:
费用(元)
"""
return self.calculate_cost(
model_name=event.model or "default",
input_tokens=event.input_tokens or 0,
output_tokens=event.output_tokens or 0
)
def update_event_costs(self, start_date: str = None, end_date: str = None) -> int:
"""批量更新事件费用
对于cost为0或NULL的事件重新计算费用
Args:
start_date: 开始日期,格式 YYYY-MM-DD
end_date: 结束日期,格式 YYYY-MM-DD
Returns:
更新的记录数
"""
query = self.db.query(AICallEvent).filter(
(AICallEvent.cost == None) | (AICallEvent.cost == 0)
)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
events = query.all()
updated = 0
for event in events:
try:
cost = self.calculate_event_cost(event)
event.cost = cost
updated += 1
except Exception as e:
logger.error(f"Failed to calculate cost for event {event.id}: {e}")
self.db.commit()
logger.info(f"Updated {updated} event costs")
return updated
def generate_monthly_billing(
self,
tenant_id: str,
billing_month: str
) -> TenantBilling:
"""生成月度账单
Args:
tenant_id: 租户ID
billing_month: 账单月份,格式 YYYY-MM
Returns:
TenantBilling实例
"""
# 检查是否已存在
existing = self.db.query(TenantBilling).filter(
TenantBilling.tenant_id == tenant_id,
TenantBilling.billing_month == billing_month
).first()
if existing:
billing = existing
else:
billing = TenantBilling(
tenant_id=tenant_id,
billing_month=billing_month
)
self.db.add(billing)
# 计算统计数据
start_date = f"{billing_month}-01"
year, month = billing_month.split("-")
if int(month) == 12:
end_date = f"{int(year)+1}-01-01"
else:
end_date = f"{year}-{int(month)+1:02d}-01"
# 聚合查询
stats = self.db.query(
func.count(AICallEvent.id).label('total_calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('total_input'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('total_output'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('total_cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).first()
billing.total_calls = stats.total_calls or 0
billing.total_input_tokens = int(stats.total_input or 0)
billing.total_output_tokens = int(stats.total_output or 0)
billing.total_cost = stats.total_cost or Decimal("0")
# 按模型统计
model_stats = self.db.query(
AICallEvent.model,
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).group_by(AICallEvent.model).all()
billing.cost_by_model = {
m.model or "unknown": float(m.cost) for m in model_stats
}
# 按应用统计
app_stats = self.db.query(
AICallEvent.app_code,
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).group_by(AICallEvent.app_code).all()
billing.cost_by_app = {
a.app_code or "unknown": float(a.cost) for a in app_stats
}
self.db.commit()
self.db.refresh(billing)
return billing
def get_cost_summary(
self,
tenant_id: str = None,
start_date: str = None,
end_date: str = None
) -> Dict:
"""获取费用汇总
Args:
tenant_id: 租户ID可选
start_date: 开始日期
end_date: 结束日期
Returns:
费用汇总字典
"""
query = self.db.query(
func.count(AICallEvent.id).label('total_calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('total_input'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('total_output'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('total_cost')
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
stats = query.first()
return {
"total_calls": stats.total_calls or 0,
"total_input_tokens": int(stats.total_input or 0),
"total_output_tokens": int(stats.total_output or 0),
"total_cost": float(stats.total_cost or 0)
}
def get_cost_by_tenant(
self,
start_date: str = None,
end_date: str = None
) -> List[Dict]:
"""按租户统计费用
Returns:
租户费用列表
"""
query = self.db.query(
AICallEvent.tenant_id,
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
results = query.group_by(AICallEvent.tenant_id).order_by(
func.sum(AICallEvent.cost).desc()
).all()
return [
{
"tenant_id": r.tenant_id,
"calls": r.calls,
"cost": float(r.cost)
}
for r in results
]
def get_cost_by_model(
self,
tenant_id: str = None,
start_date: str = None,
end_date: str = None
) -> List[Dict]:
"""按模型统计费用
Returns:
模型费用列表
"""
query = self.db.query(
AICallEvent.model,
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('output_tokens'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
results = query.group_by(AICallEvent.model).order_by(
func.sum(AICallEvent.cost).desc()
).all()
return [
{
"model": r.model or "unknown",
"calls": r.calls,
"input_tokens": int(r.input_tokens),
"output_tokens": int(r.output_tokens),
"cost": float(r.cost)
}
for r in results
]
# 便捷函数
def calculate_cost(
db: Session,
model_name: str,
input_tokens: int = 0,
output_tokens: int = 0
) -> Decimal:
"""快速计算费用"""
calculator = CostCalculator(db)
return calculator.calculate_cost(model_name, input_tokens, output_tokens)

View File

@@ -35,3 +35,8 @@ def decrypt_value(encrypted_value: str) -> str:
encrypted = base64.urlsafe_b64decode(encrypted_value.encode()) encrypted = base64.urlsafe_b64decode(encrypted_value.encode())
decrypted = f.decrypt(encrypted) decrypted = f.decrypt(encrypted)
return decrypted.decode() return decrypted.decode()
# 别名
encrypt_config = encrypt_value
decrypt_config = decrypt_value

View File

@@ -0,0 +1,346 @@
"""配额管理服务"""
import logging
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, Tuple
from dataclasses import dataclass
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.tenant import Tenant, Subscription
from ..models.stats import AICallEvent
from .cache import get_cache
logger = logging.getLogger(__name__)
@dataclass
class QuotaConfig:
"""配额配置"""
daily_calls: int = 0 # 每日调用限制0表示无限制
daily_tokens: int = 0 # 每日Token限制
monthly_calls: int = 0 # 每月调用限制
monthly_tokens: int = 0 # 每月Token限制
monthly_cost: float = 0 # 每月费用限制(元)
concurrent_calls: int = 0 # 并发调用限制
@dataclass
class QuotaUsage:
"""配额使用情况"""
daily_calls: int = 0
daily_tokens: int = 0
monthly_calls: int = 0
monthly_tokens: int = 0
monthly_cost: float = 0
@dataclass
class QuotaCheckResult:
"""配额检查结果"""
allowed: bool
reason: Optional[str] = None
quota_type: Optional[str] = None
limit: int = 0
used: int = 0
remaining: int = 0
class QuotaService:
"""配额管理服务
使用示例:
quota_service = QuotaService(db)
# 检查配额
result = quota_service.check_quota("qiqi", "tools")
if not result.allowed:
raise HTTPException(status_code=429, detail=result.reason)
# 获取使用情况
usage = quota_service.get_usage("qiqi", "tools")
"""
# 默认配额(当无订阅配置时使用)
DEFAULT_QUOTA = QuotaConfig(
daily_calls=1000,
daily_tokens=100000,
monthly_calls=30000,
monthly_tokens=3000000,
monthly_cost=100
)
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
def get_subscription(self, tenant_id: str, app_code: str) -> Optional[Subscription]:
"""获取租户订阅配置"""
return self.db.query(Subscription).filter(
Subscription.tenant_id == tenant_id,
Subscription.app_code == app_code,
Subscription.status == 'active'
).first()
def get_quota_config(self, tenant_id: str, app_code: str) -> QuotaConfig:
"""获取配额配置
Args:
tenant_id: 租户ID
app_code: 应用代码
Returns:
QuotaConfig实例
"""
# 尝试从缓存获取
cache_key = f"quota:config:{tenant_id}:{app_code}"
cached = self._cache.get(cache_key)
if cached:
return QuotaConfig(**cached)
# 从订阅表获取
subscription = self.get_subscription(tenant_id, app_code)
if subscription and subscription.quota:
quota = subscription.quota
config = QuotaConfig(
daily_calls=quota.get('daily_calls', 0),
daily_tokens=quota.get('daily_tokens', 0),
monthly_calls=quota.get('monthly_calls', 0),
monthly_tokens=quota.get('monthly_tokens', 0),
monthly_cost=quota.get('monthly_cost', 0),
concurrent_calls=quota.get('concurrent_calls', 0)
)
else:
config = self.DEFAULT_QUOTA
# 缓存5分钟
self._cache.set(cache_key, config.__dict__, ttl=300)
return config
def get_usage(self, tenant_id: str, app_code: str) -> QuotaUsage:
"""获取配额使用情况
Args:
tenant_id: 租户ID
app_code: 应用代码
Returns:
QuotaUsage实例
"""
today = date.today()
month_start = today.replace(day=1)
# 今日使用量
daily_stats = self.db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.app_code == app_code,
func.date(AICallEvent.created_at) == today
).first()
# 本月使用量
monthly_stats = self.db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.app_code == app_code,
func.date(AICallEvent.created_at) >= month_start
).first()
return QuotaUsage(
daily_calls=daily_stats.calls or 0,
daily_tokens=int(daily_stats.tokens or 0),
monthly_calls=monthly_stats.calls or 0,
monthly_tokens=int(monthly_stats.tokens or 0),
monthly_cost=float(monthly_stats.cost or 0)
)
def check_quota(
self,
tenant_id: str,
app_code: str,
estimated_tokens: int = 0
) -> QuotaCheckResult:
"""检查配额是否足够
Args:
tenant_id: 租户ID
app_code: 应用代码
estimated_tokens: 预估Token消耗
Returns:
QuotaCheckResult实例
"""
config = self.get_quota_config(tenant_id, app_code)
usage = self.get_usage(tenant_id, app_code)
# 检查日调用次数
if config.daily_calls > 0:
if usage.daily_calls >= config.daily_calls:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每日调用限制 ({config.daily_calls} 次)",
quota_type="daily_calls",
limit=config.daily_calls,
used=usage.daily_calls,
remaining=0
)
# 检查日Token限制
if config.daily_tokens > 0:
if usage.daily_tokens + estimated_tokens > config.daily_tokens:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每日Token限制 ({config.daily_tokens:,})",
quota_type="daily_tokens",
limit=config.daily_tokens,
used=usage.daily_tokens,
remaining=max(0, config.daily_tokens - usage.daily_tokens)
)
# 检查月调用次数
if config.monthly_calls > 0:
if usage.monthly_calls >= config.monthly_calls:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月调用限制 ({config.monthly_calls} 次)",
quota_type="monthly_calls",
limit=config.monthly_calls,
used=usage.monthly_calls,
remaining=0
)
# 检查月Token限制
if config.monthly_tokens > 0:
if usage.monthly_tokens + estimated_tokens > config.monthly_tokens:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月Token限制 ({config.monthly_tokens:,})",
quota_type="monthly_tokens",
limit=config.monthly_tokens,
used=usage.monthly_tokens,
remaining=max(0, config.monthly_tokens - usage.monthly_tokens)
)
# 检查月费用限制
if config.monthly_cost > 0:
if usage.monthly_cost >= config.monthly_cost:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月费用限制 (¥{config.monthly_cost:.2f})",
quota_type="monthly_cost",
limit=int(config.monthly_cost * 100), # 转为分
used=int(usage.monthly_cost * 100),
remaining=max(0, int((config.monthly_cost - usage.monthly_cost) * 100))
)
# 所有检查通过
return QuotaCheckResult(
allowed=True,
quota_type="daily_calls",
limit=config.daily_calls,
used=usage.daily_calls,
remaining=max(0, config.daily_calls - usage.daily_calls) if config.daily_calls > 0 else -1
)
def get_quota_summary(self, tenant_id: str, app_code: str) -> Dict[str, Any]:
"""获取配额汇总信息
Returns:
包含配额配置和使用情况的字典
"""
config = self.get_quota_config(tenant_id, app_code)
usage = self.get_usage(tenant_id, app_code)
def calc_percentage(used: int, limit: int) -> float:
if limit <= 0:
return 0
return min(100, round(used / limit * 100, 1))
return {
"config": {
"daily_calls": config.daily_calls,
"daily_tokens": config.daily_tokens,
"monthly_calls": config.monthly_calls,
"monthly_tokens": config.monthly_tokens,
"monthly_cost": config.monthly_cost
},
"usage": {
"daily_calls": usage.daily_calls,
"daily_tokens": usage.daily_tokens,
"monthly_calls": usage.monthly_calls,
"monthly_tokens": usage.monthly_tokens,
"monthly_cost": round(usage.monthly_cost, 2)
},
"percentage": {
"daily_calls": calc_percentage(usage.daily_calls, config.daily_calls),
"daily_tokens": calc_percentage(usage.daily_tokens, config.daily_tokens),
"monthly_calls": calc_percentage(usage.monthly_calls, config.monthly_calls),
"monthly_tokens": calc_percentage(usage.monthly_tokens, config.monthly_tokens),
"monthly_cost": calc_percentage(int(usage.monthly_cost * 100), int(config.monthly_cost * 100))
}
}
def update_quota(
self,
tenant_id: str,
app_code: str,
quota_config: Dict[str, Any]
) -> Subscription:
"""更新配额配置
Args:
tenant_id: 租户ID
app_code: 应用代码
quota_config: 配额配置字典
Returns:
更新后的Subscription实例
"""
subscription = self.get_subscription(tenant_id, app_code)
if not subscription:
# 创建新订阅
subscription = Subscription(
tenant_id=tenant_id,
app_code=app_code,
start_date=date.today(),
quota=quota_config,
status='active'
)
self.db.add(subscription)
else:
# 更新现有订阅
subscription.quota = quota_config
self.db.commit()
self.db.refresh(subscription)
# 清除缓存
cache_key = f"quota:config:{tenant_id}:{app_code}"
self._cache.delete(cache_key)
return subscription
def check_quota_middleware(
db: Session,
tenant_id: str,
app_code: str,
estimated_tokens: int = 0
) -> QuotaCheckResult:
"""配额检查中间件函数
可在路由中使用:
result = check_quota_middleware(db, "qiqi", "tools")
if not result.allowed:
raise HTTPException(status_code=429, detail=result.reason)
"""
service = QuotaService(db)
return service.check_quota(tenant_id, app_code, estimated_tokens)

View File

@@ -0,0 +1,371 @@
"""企业微信服务"""
import hashlib
import time
import logging
from typing import Optional, Dict, Any
from dataclasses import dataclass
import httpx
from ..config import get_settings
from .cache import get_cache
from .crypto import decrypt_config
logger = logging.getLogger(__name__)
settings = get_settings()
@dataclass
class WechatConfig:
"""企业微信应用配置"""
corp_id: str
agent_id: str
secret: str
class WechatService:
"""企业微信服务
提供access_token获取、JS-SDK签名、OAuth2等功能
使用示例:
wechat = WechatService(corp_id="wwxxxx", agent_id="1000001", secret="xxx")
# 获取access_token
token = await wechat.get_access_token()
# 获取JS-SDK签名
signature = await wechat.get_jssdk_signature("https://example.com/page")
"""
# 企业微信API基础URL
BASE_URL = "https://qyapi.weixin.qq.com"
def __init__(self, corp_id: str, agent_id: str, secret: str):
"""初始化企业微信服务
Args:
corp_id: 企业ID
agent_id: 应用AgentId
secret: 应用Secret明文
"""
self.corp_id = corp_id
self.agent_id = agent_id
self.secret = secret
self._cache = get_cache()
@classmethod
def from_wechat_app(cls, wechat_app) -> "WechatService":
"""从TenantWechatApp模型创建服务实例
Args:
wechat_app: TenantWechatApp数据库模型
Returns:
WechatService实例
"""
secret = ""
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except Exception as e:
logger.error(f"Failed to decrypt secret: {e}")
return cls(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
def _cache_key(self, key_type: str) -> str:
"""生成缓存键"""
return f"wechat:{self.corp_id}:{self.agent_id}:{key_type}"
async def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
"""获取access_token
企业微信access_token有效期7200秒需要缓存
Args:
force_refresh: 是否强制刷新
Returns:
access_token或None
"""
cache_key = self._cache_key("access_token")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"Access token from cache: {cached[:20]}...")
return cached
# 从企业微信API获取
url = f"{self.BASE_URL}/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.secret
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get access_token failed: {result}")
return None
access_token = result.get("access_token")
expires_in = result.get("expires_in", 7200)
# 缓存提前200秒过期以确保安全
self._cache.set(
cache_key,
access_token,
ttl=min(expires_in - 200, settings.WECHAT_ACCESS_TOKEN_EXPIRE)
)
logger.info(f"Got new access_token for {self.corp_id}")
return access_token
except Exception as e:
logger.error(f"Get access_token error: {e}")
return None
async def get_jsapi_ticket(self, force_refresh: bool = False) -> Optional[str]:
"""获取jsapi_ticket
用于生成JS-SDK签名
Args:
force_refresh: 是否强制刷新
Returns:
jsapi_ticket或None
"""
cache_key = self._cache_key("jsapi_ticket")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"JSAPI ticket from cache: {cached[:20]}...")
return cached
# 先获取access_token
access_token = await self.get_access_token()
if not access_token:
return None
# 获取jsapi_ticket
url = f"{self.BASE_URL}/cgi-bin/get_jsapi_ticket"
params = {"access_token": access_token}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get jsapi_ticket failed: {result}")
return None
ticket = result.get("ticket")
expires_in = result.get("expires_in", 7200)
# 缓存
self._cache.set(
cache_key,
ticket,
ttl=min(expires_in - 200, settings.WECHAT_JSAPI_TICKET_EXPIRE)
)
logger.info(f"Got new jsapi_ticket for {self.corp_id}")
return ticket
except Exception as e:
logger.error(f"Get jsapi_ticket error: {e}")
return None
async def get_jssdk_signature(
self,
url: str,
noncestr: Optional[str] = None,
timestamp: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""生成JS-SDK签名
Args:
url: 当前页面URL不含#及其后面部分)
noncestr: 随机字符串,可选
timestamp: 时间戳,可选
Returns:
签名信息字典包含signature, noncestr, timestamp, appId等
"""
ticket = await self.get_jsapi_ticket()
if not ticket:
return None
# 生成随机字符串和时间戳
if noncestr is None:
import secrets
noncestr = secrets.token_hex(8)
if timestamp is None:
timestamp = int(time.time())
# 构建签名字符串
sign_str = f"jsapi_ticket={ticket}&noncestr={noncestr}&timestamp={timestamp}&url={url}"
# SHA1签名
signature = hashlib.sha1(sign_str.encode()).hexdigest()
return {
"appId": self.corp_id,
"agentId": self.agent_id,
"timestamp": timestamp,
"nonceStr": noncestr,
"signature": signature,
"url": url
}
def get_oauth2_url(
self,
redirect_uri: str,
scope: str = "snsapi_base",
state: str = ""
) -> str:
"""生成OAuth2授权URL
Args:
redirect_uri: 授权后重定向的URL
scope: 应用授权作用域
- snsapi_base: 静默授权,只能获取成员基础信息
- snsapi_privateinfo: 手动授权,可获取成员详细信息
state: 重定向后会带上state参数
Returns:
OAuth2授权URL
"""
import urllib.parse
encoded_uri = urllib.parse.quote(redirect_uri, safe='')
url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize"
f"?appid={self.corp_id}"
f"&redirect_uri={encoded_uri}"
f"&response_type=code"
f"&scope={scope}"
f"&state={state}"
f"&agentid={self.agent_id}"
f"#wechat_redirect"
)
return url
async def get_user_info_by_code(self, code: str) -> Optional[Dict[str, Any]]:
"""通过OAuth2 code获取用户信息
Args:
code: OAuth2回调返回的code
Returns:
用户信息字典包含UserId, DeviceId等
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/auth/getuserinfo"
params = {
"access_token": access_token,
"code": code
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user info by code failed: {result}")
return None
return {
"user_id": result.get("userid") or result.get("UserId"),
"device_id": result.get("deviceid") or result.get("DeviceId"),
"open_id": result.get("openid") or result.get("OpenId"),
"external_userid": result.get("external_userid"),
}
except Exception as e:
logger.error(f"Get user info by code error: {e}")
return None
async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]:
"""获取成员详细信息
Args:
user_id: 成员UserID
Returns:
成员详细信息
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/user/get"
params = {
"access_token": access_token,
"userid": user_id
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user detail failed: {result}")
return None
return {
"userid": result.get("userid"),
"name": result.get("name"),
"department": result.get("department"),
"position": result.get("position"),
"mobile": result.get("mobile"),
"email": result.get("email"),
"avatar": result.get("avatar"),
"status": result.get("status"),
}
except Exception as e:
logger.error(f"Get user detail error: {e}")
return None
async def get_wechat_service_by_id(
wechat_app_id: int,
db_session
) -> Optional[WechatService]:
"""根据企微应用ID获取服务实例
Args:
wechat_app_id: platform_tenant_wechat_apps表的ID
db_session: 数据库session
Returns:
WechatService实例或None
"""
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db_session.query(TenantWechatApp).filter(
TenantWechatApp.id == wechat_app_id,
TenantWechatApp.status == 1
).first()
if not wechat_app:
return None
return WechatService.from_wechat_app(wechat_app)

26
backend/env.template Normal file
View File

@@ -0,0 +1,26 @@
# 000-platform 环境配置模板
# 复制此文件为 .env 并填写实际值
# ==================== 应用配置 ====================
APP_NAME=platform
APP_VERSION=1.0.0
DEBUG=false
# ==================== 数据库配置 ====================
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your-password
DB_NAME=new_qiqi
# ==================== JWT 配置 ====================
JWT_SECRET_KEY=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# ==================== 安全配置 ====================
# 用于加密敏感数据(如企微 Secret
ENCRYPTION_KEY=your-encryption-key-32-bytes
# ==================== 可选Redis 缓存 ====================
# REDIS_URL=redis://localhost:6379/0

View File

@@ -7,5 +7,8 @@ pydantic-settings>=2.0.0
cryptography>=42.0.0 cryptography>=42.0.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
httpx>=0.26.0 httpx>=0.26.0
redis>=5.0.0
openpyxl>=3.1.0

View File

@@ -2,9 +2,9 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# 安装依赖 # 安装依赖(使用阿里云镜像)
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
# 复制代码 # 复制代码
COPY backend/app ./app COPY backend/app ./app

View File

@@ -0,0 +1,27 @@
FROM node:20-alpine as builder
WORKDIR /app
# 安装依赖(使用淘宝镜像)
COPY frontend/package.json ./
RUN npm config set registry https://registry.npmmirror.com && npm install
# 构建
COPY frontend/ .
RUN npm run build
# 生产镜像
FROM nginx:alpine
# 后端服务地址(通过 build-arg 传入,构建时替换)
ARG BACKEND_HOST=platform-backend-test
COPY --from=builder /app/dist /usr/share/nginx/html
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
# 在构建时替换后端地址(只替换 BACKEND_HOST 变量)
RUN sed -i "s/\${BACKEND_HOST}/${BACKEND_HOST}/g" /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Docker 内部 DNS 解析器
resolver 127.0.0.11 valid=30s;
# Vue Router history mode
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端
# 使用环境变量 BACKEND_HOST通过 Docker DNS 解析
location /api/ {
set $backend ${BACKEND_HOST}:8000;
proxy_pass http://$backend/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;
proxy_connect_timeout 10s;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>平台管理后台</title>
<link rel="icon" href="data:,">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

26
frontend/package.json Normal file
View File

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

23
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import { onMounted } from 'vue'
const authStore = useAuthStore()
onMounted(() => {
// 恢复登录状态
authStore.initFromStorage()
})
</script>
<template>
<router-view />
</template>
<style>
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
</style>

97
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,97 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '',
timeout: 30000
})
/**
* 解析 API 错误响应
*/
function parseApiError(error) {
const result = {
code: 'UNKNOWN_ERROR',
message: '发生了未知错误',
traceId: '',
status: 500
}
if (!error.response) {
result.code = 'NETWORK_ERROR'
result.message = '网络连接失败,请检查网络后重试'
return result
}
const { status, data, headers } = error.response
result.status = status
result.traceId = headers['x-trace-id'] || headers['X-Trace-ID'] || ''
if (data && data.error) {
result.code = data.error.code || result.code
result.message = data.error.message || result.message
result.traceId = data.error.trace_id || result.traceId
} else if (data && data.detail) {
result.message = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
}
return result
}
/**
* 跳转到错误页面
*/
function navigateToErrorPage(errorInfo) {
router.push({
name: 'Error',
query: {
code: errorInfo.code,
message: errorInfo.message,
trace_id: errorInfo.traceId,
status: String(errorInfo.status)
}
})
}
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器(集成 TraceID 追踪)
api.interceptors.response.use(
response => response,
error => {
const errorInfo = parseApiError(error)
const traceLog = errorInfo.traceId ? ` (trace: ${errorInfo.traceId})` : ''
console.error(`[API Error] ${errorInfo.code}: ${errorInfo.message}${traceLog}`)
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 if (['INTERNAL_ERROR', 'SERVICE_UNAVAILABLE', 'GATEWAY_ERROR'].includes(errorInfo.code)) {
// 严重错误跳转到错误页面
navigateToErrorPage(errorInfo)
} else {
// 普通错误显示消息
ElMessage.error(errorInfo.message)
}
return Promise.reject(error)
}
)
export default api

View File

@@ -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;
}

View File

@@ -0,0 +1,107 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 菜单项
const menuItems = computed(() => {
const items = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/tenants', title: '租户管理', icon: 'OfficeBuilding' },
{ path: '/apps', title: '应用管理', icon: 'Grid' },
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
]
// 管理员才能看到用户管理
if (authStore.isAdmin) {
items.push({ path: '/users', title: '用户管理', icon: 'User' })
}
return items
})
const activeMenu = computed(() => route.path)
function handleMenuSelect(path) {
router.push(path)
}
function handleLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<el-icon><Platform /></el-icon>
<span style="margin-left: 8px">平台管理</span>
</div>
<el-menu
:default-active="activeMenu"
background-color="transparent"
text-color="rgba(255,255,255,0.7)"
active-text-color="#fff"
@select="handleMenuSelect"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</aside>
<!-- 主内容区 -->
<div class="main-container">
<!-- 顶部栏 -->
<header class="header">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="user-info">
<span class="username">{{ authStore.user?.nickname || authStore.user?.username }}</span>
<el-dropdown trigger="click">
<el-avatar :size="32">
{{ (authStore.user?.nickname || authStore.user?.username || 'U')[0].toUpperCase() }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>

23
frontend/src/main.js Normal file
View File

@@ -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')

View File

@@ -0,0 +1,104 @@
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: '/error',
name: 'Error',
component: () => import('@/views/error/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: 'apps',
name: 'Apps',
component: () => import('@/views/apps/index.vue'),
meta: { title: '应用管理', icon: 'Grid' }
},
{
path: 'tenant-wechat-apps',
name: 'TenantWechatApps',
component: () => import('@/views/tenant-wechat-apps/index.vue'),
meta: { title: '企微应用', icon: 'ChatDotRound' }
},
{
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

View File

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

View File

@@ -0,0 +1,447 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: '',
app_code: ''
})
// 租户列表
const tenantList = ref([])
// 应用列表(从应用管理获取)
const appList = ref([])
const appRequireJssdk = ref({}) // app_code -> require_jssdk
const appBaseUrl = ref({}) // app_code -> base_url
// 企微应用列表(按租户)
const wechatAppList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
app_code: '',
app_name: '',
wechat_app_id: null
})
// 当前选择的应用是否需要 JS-SDK
const currentAppRequireJssdk = computed(() => {
return appRequireJssdk.value[form.app_code] || false
})
// 验证 app_code 必须是有效的应用
const validateAppCode = (rule, value, callback) => {
if (!value) {
callback(new Error('请选择应用'))
} else if (!appList.value.find(a => a.app_code === value)) {
callback(new Error('请从列表中选择有效的应用'))
} else {
callback()
}
}
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
app_code: [{ required: true, validator: validateAppCode, trigger: 'change' }]
}
// 监听租户选择变化
watch(() => form.tenant_id, async (newVal) => {
if (newVal) {
await fetchWechatApps(newVal)
} else {
wechatAppList.value = []
}
form.wechat_app_id = null
})
// 查看 Token 对话框
const tokenDialogVisible = ref(false)
const currentToken = ref('')
const currentAppUrl = ref('')
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
} catch (e) {
console.error('获取租户列表失败:', e)
}
}
async function fetchApps() {
try {
const res = await api.get('/api/apps', { params: { size: 100 } })
const apps = res.data.items || []
appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name }))
for (const app of apps) {
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
appBaseUrl.value[app.app_code] = app.base_url || ''
}
} catch (e) {
console.error('获取应用列表失败:', e)
}
}
async function fetchWechatApps(tenantId) {
if (!tenantId) {
wechatAppList.value = []
return
}
try {
const res = await api.get(`/api/tenant-wechat-apps/by-tenant/${tenantId}`)
wechatAppList.value = res.data || []
} catch (e) {
wechatAppList.value = []
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenant-apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建应用订阅'
Object.assign(form, {
tenant_id: '',
app_code: '',
app_name: '',
wechat_app_id: null
})
wechatAppList.value = []
dialogVisible.value = true
}
async function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用订阅'
Object.assign(form, {
tenant_id: row.tenant_id,
app_code: row.app_code,
app_name: row.app_name || '',
wechat_app_id: row.wechat_app_id || null
})
await fetchWechatApps(row.tenant_id)
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
try {
if (editingId.value) {
await api.put(`/api/tenant-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
const res = await api.post('/api/tenant-apps', data)
ElMessage.success(`创建成功`)
// 显示新生成的 token
showToken(res.data.access_token, form.app_code)
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除「${row.app_code}」的订阅配置吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleRegenerateToken(row) {
await ElMessageBox.confirm('重新生成 Token 将使旧 Token 失效,确定继续?', '提示', {
type: 'warning'
})
try {
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
showToken(res.data.access_token, row.app_code)
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function showToken(token, appCode) {
currentToken.value = token
currentAppUrl.value = appBaseUrl.value[appCode] || ''
tokenDialogVisible.value = true
}
function handleCopyToken() {
navigator.clipboard.writeText(currentToken.value).then(() => {
ElMessage.success('Token 已复制')
})
}
function handleCopyUrl() {
const url = `${currentAppUrl.value}?token=${currentToken.value}`
navigator.clipboard.writeText(url).then(() => {
ElMessage.success('链接已复制')
})
}
async function handleViewToken(row) {
// 这里需要后端返回真实 token暂时用 placeholder
// 实际生产中可能需要单独 API 获取
showToken(row.access_token === '******' ? '需要调用API获取' : row.access_token, row.app_code)
}
onMounted(() => {
fetchTenants()
fetchApps()
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">租户应用订阅</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建订阅
</el-button>
</div>
<div class="page-tip">
<el-alert type="info" :closable="false">
为租户订阅应用生成访问 Token外部应用可通过 Token 向平台验证身份
</el-alert>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.tenant_id" placeholder="选择租户" clearable filterable style="width: 200px">
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
<el-select v-model="query.app_code" placeholder="选择应用" clearable style="width: 150px">
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="备注名称" width="150" />
<el-table-column label="企微应用" width="180">
<template #default="{ row }">
<template v-if="row.wechat_app">
<el-tag type="success" size="small">{{ row.wechat_app.name }}</el-tag>
</template>
<el-tag v-else type="info" size="small">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="Token 状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.access_token" type="success" size="small">已生成</el-tag>
<el-tag v-else type="danger" size="small">未生成</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="success" link size="small" @click="handleViewToken(row)">查看Token</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleRegenerateToken(row)">重置Token</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户" prop="tenant_id">
<el-select
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="请选择租户"
filterable
style="width: 100%"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
</el-form-item>
<el-form-item label="应用" prop="app_code">
<el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择要订阅的应用" style="width: 100%">
<el-option v-for="app in appList" :key="app.app_code" :label="`${app.app_name} (${app.app_code})`" :value="app.app_code" />
</el-select>
</el-form-item>
<el-form-item label="备注名称">
<el-input v-model="form.app_name" placeholder="可选,用于区分同应用多配置" />
</el-form-item>
<template v-if="currentAppRequireJssdk">
<el-divider content-position="left">企业微信关联</el-divider>
<el-form-item label="关联企微应用">
<el-select
v-model="form.wechat_app_id"
placeholder="选择企微应用"
clearable
style="width: 100%"
>
<el-option
v-for="wa in wechatAppList"
:key="wa.id"
:label="`${wa.name} (${wa.corp_id})`"
:value="wa.id"
/>
</el-select>
<div v-if="wechatAppList.length === 0 && form.tenant_id" style="color: #909399; font-size: 12px; margin-top: 4px">
该租户暂无企微应用请先在企微应用中配置
</div>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- Token 显示对话框 -->
<el-dialog v-model="tokenDialogVisible" title="访问 Token" width="600px">
<div class="token-dialog-content">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">
请妥善保管 Token它是应用访问平台的凭证
</el-alert>
<div class="token-section">
<div class="token-label">Access Token:</div>
<el-input v-model="currentToken" readonly>
<template #append>
<el-button @click="handleCopyToken">复制</el-button>
</template>
</el-input>
</div>
<div v-if="currentAppUrl" class="token-section">
<div class="token-label">完整访问链接:</div>
<el-input :model-value="`${currentAppUrl}?token=${currentToken}`" readonly type="textarea" :rows="2" />
<el-button type="primary" style="margin-top: 8px" @click="handleCopyUrl">
<el-icon><CopyDocument /></el-icon>
复制链接
</el-button>
</div>
<el-divider />
<div class="token-section">
<div class="token-label">验证 API:</div>
<el-input
model-value="POST /api/auth/verify"
readonly
/>
<div style="color: #909399; font-size: 12px; margin-top: 4px">
外部应用可调用此接口验证 Token 有效性获取租户和企微配置
</div>
</div>
</div>
<template #footer>
<el-button @click="tokenDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
.token-dialog-content {
padding: 0 10px;
}
.token-section {
margin-bottom: 16px;
}
.token-label {
font-weight: 500;
margin-bottom: 8px;
color: #303133;
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false
})
const rules = {
app_code: [{ required: true, message: '请输入应用代码', trigger: 'blur' }],
app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建应用'
Object.assign(form, {
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用'
Object.assign(form, {
app_code: row.app_code,
app_name: row.app_name,
base_url: row.base_url || '',
description: row.description || '',
require_jssdk: row.require_jssdk || false
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
try {
if (editingId.value) {
await api.put(`/api/apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除应用 "${row.app_name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleToggleStatus(row) {
const newStatus = row.status === 1 ? 0 : 1
try {
await api.put(`/api/apps/${row.id}`, { status: newStatus })
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">应用管理</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建应用
</el-button>
</div>
<div class="page-tip">
<el-alert type="info" :closable="false">
应用管理每个应用是一个独立的服务有独立的访问地址
租户订阅应用后平台生成 Token 供应用鉴权使用
</el-alert>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="应用名称" width="180" />
<el-table-column prop="base_url" label="访问地址" min-width="300" show-overflow-tooltip />
<el-table-column label="JS-SDK" width="90">
<template #default="{ row }">
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
{{ row.require_jssdk ? '需要' : '不需要' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" :type="row.status === 1 ? 'warning' : 'success'" link size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用代码" prop="app_code">
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
</el-form-item>
<el-form-item label="应用名称" prop="app_name">
<el-input v-model="form.app_name" placeholder="显示名称,如: 头脑风暴" />
</el-form-item>
<el-form-item label="访问地址">
<el-input v-model="form.base_url" placeholder="如: https://brainstorm.example.com" />
<div style="color: #909399; font-size: 12px; margin-top: 4px">
应用的访问地址用于生成链接和跳转
</div>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" />
</el-form-item>
<el-form-item label="企微JS-SDK">
<el-switch v-model="form.require_jssdk" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
const stats = ref({
totalTenants: 0,
activeTenants: 0,
todayCalls: 0,
todayTokens: 0,
weekCalls: 0,
weekTokens: 0
})
const recentLogs = ref([])
const trendData = ref([])
const chartRef = ref(null)
const chartLoading = ref(false)
let chartInstance = null
async function fetchStats() {
try {
// 获取租户统计
const tenantsRes = await api.get('/api/tenants', { params: { size: 1 } })
stats.value.totalTenants = tenantsRes.data.total || 0
// 获取统计数据
const statsRes = await api.get('/api/stats/summary')
if (statsRes.data) {
stats.value.todayCalls = statsRes.data.today_calls || 0
stats.value.todayTokens = statsRes.data.today_tokens || 0
stats.value.weekCalls = statsRes.data.week_calls || 0
stats.value.weekTokens = statsRes.data.week_tokens || 0
}
} catch (e) {
console.error('获取统计失败:', e)
}
}
async function fetchRecentLogs() {
try {
const res = await api.get('/api/logs', { params: { size: 10, log_type: 'request' } })
recentLogs.value = res.data.items || []
} catch (e) {
console.error('获取日志失败:', e)
}
}
async function fetchTrendData() {
chartLoading.value = true
try {
const res = await api.get('/api/stats/trend', { params: { days: 7 } })
trendData.value = res.data.trend || []
updateChart()
} catch (e) {
console.error('获取趋势数据失败:', e)
// 如果API失败使用空数据
trendData.value = []
updateChart()
} finally {
chartLoading.value = false
}
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
}
function updateChart() {
if (!chartInstance) return
// 从API数据提取日期和调用次数
const dates = trendData.value.map(item => {
// 格式化日期为 MM-DD
const date = new Date(item.date)
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
})
const calls = trendData.value.map(item => item.calls || 0)
const tokens = trendData.value.map(item => item.tokens || 0)
const option = {
title: {
text: '近7天 AI 调用趋势',
textStyle: { fontSize: 14, fontWeight: 500 }
},
tooltip: {
trigger: 'axis',
formatter: function(params) {
let result = params[0].axisValue + '<br/>'
params.forEach(param => {
result += `${param.marker} ${param.seriesName}: ${param.value.toLocaleString()}<br/>`
})
return result
}
},
legend: {
data: ['调用次数', 'Token 消耗'],
top: 0,
right: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: 50,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates.length > 0 ? dates : ['暂无数据']
},
yAxis: [
{
type: 'value',
name: '调用次数',
position: 'left'
},
{
type: 'value',
name: 'Token',
position: 'right'
}
],
series: [
{
name: '调用次数',
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
])
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
data: calls.length > 0 ? calls : [0]
},
{
name: 'Token 消耗',
type: 'line',
yAxisIndex: 1,
smooth: true,
lineStyle: { color: '#67c23a' },
itemStyle: { color: '#67c23a' },
data: tokens.length > 0 ? tokens : [0]
}
]
}
chartInstance.setOption(option)
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
fetchStats()
fetchRecentLogs()
initChart()
fetchTrendData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">租户总数</div>
<div class="stat-value">{{ stats.totalTenants }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 AI 调用</div>
<div class="stat-value">{{ stats.todayCalls.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 Token 消耗</div>
<div class="stat-value">{{ stats.todayTokens.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">本周 AI 调用</div>
<div class="stat-value">{{ stats.weekCalls.toLocaleString() }}</div>
</div>
</div>
<!-- 图表区域 -->
<div class="chart-section" v-loading="chartLoading">
<div class="chart-container" ref="chartRef"></div>
</div>
<!-- 最近日志 -->
<div class="page-container" style="margin-top: 20px">
<div class="page-header">
<div class="title">最近请求日志</div>
</div>
<el-table :data="recentLogs" style="width: 100%" size="small">
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="status_code" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="100">
<template #default="{ row }">
{{ row.duration_ms }}ms
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
</el-table>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
.chart-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.chart-container {
height: 300px;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup>
/**
* 统一错误页面
*/
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const errorCode = computed(() => route.query.code || 'UNKNOWN_ERROR')
const errorMessage = computed(() => route.query.message || '发生了未知错误')
const traceId = computed(() => route.query.trace_id || '')
const statusCode = computed(() => route.query.status || '500')
const copied = ref(false)
const errorConfig = computed(() => {
const configs = {
'UNAUTHORIZED': { icon: 'Lock', title: '访问受限', color: '#e67e22' },
'FORBIDDEN': { icon: 'CircleClose', title: '权限不足', color: '#e74c3c' },
'NOT_FOUND': { icon: 'Search', title: '页面未找到', color: '#3498db' },
'VALIDATION_ERROR': { icon: 'Warning', title: '请求参数错误', color: '#f39c12' },
'INTERNAL_ERROR': { icon: 'Close', title: '服务器错误', color: '#e74c3c' },
'SERVICE_UNAVAILABLE': { icon: 'Tools', title: '服务暂时不可用', color: '#9b59b6' },
'NETWORK_ERROR': { icon: 'Connection', title: '网络连接失败', color: '#95a5a6' },
'UNKNOWN_ERROR': { icon: 'QuestionFilled', title: '未知错误', color: '#7f8c8d' }
}
return configs[errorCode.value] || configs['UNKNOWN_ERROR']
})
const copyTraceId = async () => {
if (!traceId.value) return
try {
await navigator.clipboard.writeText(traceId.value)
copied.value = true
ElMessage.success('追踪码已复制')
setTimeout(() => { copied.value = false }, 2000)
} catch {
ElMessage.error('复制失败')
}
}
const goHome = () => router.push('/dashboard')
const retry = () => router.back()
</script>
<template>
<div class="error-page">
<div class="error-container">
<div class="error-icon-wrapper" :style="{ background: errorConfig.color + '20', color: errorConfig.color }">
<el-icon :size="48">
<component :is="errorConfig.icon" />
</el-icon>
</div>
<h1 class="error-title">{{ errorConfig.title }}</h1>
<div class="status-code">HTTP {{ statusCode }}</div>
<p class="error-message">{{ errorMessage }}</p>
<div class="trace-section" v-if="traceId">
<div class="trace-label">问题追踪码</div>
<div class="trace-id-box" @click="copyTraceId">
<code class="trace-id">{{ traceId }}</code>
<el-button type="primary" link size="small">
{{ copied ? '已复制' : '复制' }}
</el-button>
</div>
<p class="trace-tip">如需技术支持请提供此追踪码</p>
</div>
<div class="action-buttons">
<el-button type="primary" @click="retry">重试</el-button>
<el-button @click="goHome">返回首页</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
padding: 20px;
}
.error-container {
background: white;
border-radius: 12px;
padding: 48px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.error-icon-wrapper {
width: 96px;
height: 96px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.error-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: #303133;
}
.status-code {
font-size: 14px;
color: #909399;
margin-bottom: 16px;
}
.error-message {
font-size: 15px;
color: #606266;
margin: 0 0 24px 0;
line-height: 1.6;
}
.trace-section {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.trace-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.trace-id-box {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 12px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.trace-id-box:hover {
border-color: #409eff;
}
.trace-id {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
color: #303133;
}
.trace-tip {
font-size: 12px;
color: #909399;
margin: 8px 0 0 0;
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,137 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const formRef = ref(null)
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>平台管理后台</h1>
<p>统一管理租户应用与数据</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
size="large"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>默认账号: admin / admin123</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
h1 {
font-size: 24px;
color: #303133;
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #909399;
margin: 0;
}
}
.login-footer {
text-align: center;
margin-top: 20px;
p {
font-size: 12px;
color: #c0c4cc;
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
// 详情对话框
const detailVisible = ref(false)
const currentLog = ref(null)
async function fetchList() {
loading.value = true
try {
const params = { ...query }
// 移除空值
Object.keys(params).forEach(key => {
if (params[key] === '') delete params[key]
})
const res = await api.get('/api/logs', { params })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error('获取日志失败:', e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handleReset() {
Object.assign(query, {
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function showDetail(row) {
currentLog.value = row
detailVisible.value = true
}
function getLevelType(level) {
const map = {
debug: 'info',
info: 'success',
warning: 'warning',
error: 'danger'
}
return map[level] || 'info'
}
function getLogTypeText(type) {
const map = {
request: '请求日志',
error: '错误日志',
app: '应用日志',
biz: '业务日志',
audit: '审计日志'
}
return map[type] || type
}
function formatJson(obj) {
if (!obj) return ''
try {
if (typeof obj === 'string') {
obj = JSON.parse(obj)
}
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">日志查看</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.log_type" placeholder="日志类型" clearable style="width: 120px">
<el-option label="请求日志" value="request" />
<el-option label="错误日志" value="error" />
<el-option label="应用日志" value="app" />
<el-option label="业务日志" value="biz" />
<el-option label="审计日志" value="audit" />
</el-select>
<el-select v-model="query.level" placeholder="级别" clearable style="width: 100px">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
<el-input
v-model="query.app_code"
placeholder="应用代码"
clearable
style="width: 120px"
/>
<el-input
v-model="query.trace_id"
placeholder="Trace ID"
clearable
style="width: 200px"
/>
<el-input
v-model="query.keyword"
placeholder="关键词搜索"
clearable
style="width: 180px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="log_type" label="类型" width="100">
<template #default="{ row }">
{{ getLogTypeText(row.log_type) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="message" label="消息" min-width="250" show-overflow-tooltip />
<el-table-column prop="trace_id" label="Trace ID" width="140" show-overflow-tooltip />
<el-table-column prop="path" label="路径" width="150" show-overflow-tooltip />
<el-table-column prop="status_code" label="状态码" width="80">
<template #default="{ row }">
<el-tag v-if="row.status_code" :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="80">
<template #default="{ row }">
{{ row.duration_ms ? row.duration_ms + 'ms' : '-' }}
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailVisible" title="日志详情" width="700px">
<template v-if="currentLog">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ getLogTypeText(currentLog.log_type) }}</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="getLevelType(currentLog.level)" size="small">
{{ currentLog.level?.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentLog.app_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ currentLog.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="Trace ID">{{ currentLog.trace_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">{{ currentLog.path || '-' }}</el-descriptions-item>
<el-descriptions-item label="方法">{{ currentLog.method || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态码">{{ currentLog.status_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="耗时">{{ currentLog.duration_ms ? currentLog.duration_ms + 'ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="IP">{{ currentLog.ip_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="时间" :span="2">{{ currentLog.log_time }}</el-descriptions-item>
<el-descriptions-item label="消息" :span="2">{{ currentLog.message || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentLog.extra_data" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">附加数据:</div>
<pre style="background: #f5f7fa; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ formatJson(currentLog.extra_data) }}</pre>
</div>
<div v-if="currentLog.stack_trace" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">堆栈信息:</div>
<pre style="background: #fef0f0; color: #f56c6c; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ currentLog.stack_trace }}</pre>
</div>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const query = reactive({
tenant_id: '',
app_code: '',
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
})
const stats = ref({
total_calls: 0,
total_tokens: 0,
total_cost: 0
})
const dailyData = ref([])
const chartRef = ref(null)
let chartInstance = null
async function fetchStats() {
loading.value = true
try {
const res = await api.get('/api/stats/daily', { params: query })
dailyData.value = res.data.items || []
// 计算汇总
let totalCalls = 0, totalTokens = 0, totalCost = 0
dailyData.value.forEach(item => {
totalCalls += item.ai_calls || 0
totalTokens += item.ai_tokens || 0
totalCost += parseFloat(item.ai_cost) || 0
})
stats.value = { total_calls: totalCalls, total_tokens: totalTokens, total_cost: totalCost }
updateChart()
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function updateChart() {
if (!chartInstance) return
const dates = dailyData.value.map(d => d.stat_date)
const calls = dailyData.value.map(d => d.ai_calls || 0)
const tokens = dailyData.value.map(d => d.ai_tokens || 0)
chartInstance.setOption({
title: { text: 'AI 调用趋势' },
tooltip: { trigger: 'axis' },
legend: { data: ['调用次数', 'Token 消耗'], top: 30 },
grid: { left: '3%', right: '4%', bottom: '3%', top: 80, containLabel: true },
xAxis: { type: 'category', data: dates },
yAxis: [
{ type: 'value', name: '调用次数' },
{ type: 'value', name: 'Token' }
],
series: [
{
name: '调用次数',
type: 'bar',
data: calls,
itemStyle: { color: '#409eff' }
},
{
name: 'Token 消耗',
type: 'line',
yAxisIndex: 1,
data: tokens,
smooth: true,
itemStyle: { color: '#67c23a' }
}
]
})
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
}
function handleSearch() {
fetchStats()
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
initChart()
fetchStats()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">统计分析</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="query.tenant_id" placeholder="租户ID" clearable style="width: 160px" />
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="tools" value="tools" />
<el-option label="interview" value="interview" />
</el-select>
<el-date-picker
v-model="query.start_date"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<span style="color: #909399"></span>
<el-date-picker
v-model="query.end_date"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">AI 调用总次数</div>
<div class="stat-value">{{ stats.total_calls.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">Token 消耗总量</div>
<div class="stat-value">{{ stats.total_tokens.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">累计费用</div>
<div class="stat-value">¥{{ stats.total_cost.toFixed(2) }}</div>
</div>
</div>
<!-- 图表 -->
<div style="background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px">
<div ref="chartRef" style="height: 350px" v-loading="loading"></div>
</div>
<!-- 数据表格 -->
<div style="background: #fff; border-radius: 8px; padding: 20px">
<h4 style="margin: 0 0 16px">日统计明细</h4>
<el-table :data="dailyData" style="width: 100%" v-loading="loading">
<el-table-column prop="stat_date" label="日期" width="120" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="ai_calls" label="调用次数" width="120">
<template #default="{ row }">{{ (row.ai_calls || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_tokens" label="Token 消耗" width="150">
<template #default="{ row }">{{ (row.ai_tokens || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_cost" label="费用" width="100">
<template #default="{ row }">¥{{ parseFloat(row.ai_cost || 0).toFixed(4) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</template>

View File

@@ -0,0 +1,300 @@
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: ''
})
// 租户列表(用于下拉选择)
const tenantList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
// 根据选中的租户获取 corp_id
const selectedTenant = computed(() => {
return tenantList.value.find(t => t.code === form.tenant_id)
})
// 监听租户选择变化,自动填入 corp_id
watch(() => form.tenant_id, (newVal) => {
if (newVal && !editingId.value) {
const tenant = tenantList.value.find(t => t.code === newVal)
if (tenant) {
form.corp_id = tenant.corp_id || ''
}
}
})
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入应用ID', trigger: 'blur' }]
}
// 获取租户列表
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
} catch (e) {
console.error(e)
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenant-wechat-apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建企微应用'
Object.assign(form, {
tenant_id: query.tenant_id || '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
// 如果已选择租户,自动填入 corp_id
if (form.tenant_id) {
const tenant = tenantList.value.find(t => t.code === form.tenant_id)
if (tenant) {
form.corp_id = tenant.corp_id || ''
}
}
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑企微应用'
Object.assign(form, {
tenant_id: row.tenant_id,
name: row.name,
corp_id: row.corp_id,
agent_id: row.agent_id,
secret: '' // 不回显密钥
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
// 如果没有输入新密钥,不传这个字段
if (!data.secret) {
delete data.secret
}
try {
if (editingId.value) {
await api.put(`/api/tenant-wechat-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenant-wechat-apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除企微应用「${row.name}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-wechat-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewSecret(row) {
try {
const res = await api.get(`/api/tenant-wechat-apps/${row.id}/secret`)
if (res.data.secret) {
ElMessageBox.alert(res.data.secret, '应用 Secret', {
confirmButtonText: '关闭'
})
} else {
ElMessage.info('未配置 Secret')
}
} catch (e) {
// 错误已在拦截器处理
}
}
onMounted(() => {
fetchTenants()
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">企微应用管理</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建企微应用
</el-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select
v-model="query.tenant_id"
placeholder="选择租户"
clearable
filterable
style="width: 200px"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="name" label="应用名称" width="180" />
<el-table-column prop="corp_id" label="企业ID" width="200" show-overflow-tooltip />
<el-table-column prop="agent_id" label="应用ID" width="120" />
<el-table-column label="Secret" width="100">
<template #default="{ row }">
<el-tag v-if="row.has_secret" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleViewSecret(row)">查看密钥</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="租户" prop="tenant_id">
<el-select
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="请选择租户"
filterable
style="width: 100%"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
</el-form-item>
<el-form-item label="应用名称" prop="name">
<el-input v-model="form.name" placeholder="如: 工具集企微应用" />
</el-form-item>
<el-form-item label="企业ID">
<el-input
v-model="form.corp_id"
disabled
:placeholder="selectedTenant?.corp_id ? '' : '请先在租户管理中配置企业ID'"
/>
<div v-if="form.tenant_id && !selectedTenant?.corp_id" class="el-form-item__error" style="position: static;">
该租户未配置企业ID请先到租户管理中配置
</div>
</el-form-item>
<el-form-item label="应用ID" prop="agent_id">
<el-input v-model="form.agent_id" placeholder="自建应用的 AgentId" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input
v-model="form.secret"
type="password"
show-password
:placeholder="editingId ? '留空则不修改' : '应用的 Secret'"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
const route = useRoute()
const router = useRouter()
const tenantId = route.params.id
const loading = ref(false)
const tenant = ref(null)
async function fetchDetail() {
loading.value = true
try {
const res = await api.get(`/api/tenants/${tenantId}`)
tenant.value = res.data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
onMounted(() => {
fetchDetail()
})
</script>
<template>
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="title">
<el-button link @click="router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
租户详情
</div>
</div>
<template v-if="tenant">
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ tenant.id }}</el-descriptions-item>
<el-descriptions-item label="租户代码">{{ tenant.code }}</el-descriptions-item>
<el-descriptions-item label="租户名称">{{ tenant.name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(tenant.status)" size="small">
{{ getStatusText(tenant.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ tenant.expired_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ tenant.created_at }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ tenant.contact_info?.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ tenant.contact_info?.phone || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 用量统计 -->
<el-descriptions title="用量统计" :column="3" border style="margin-bottom: 20px">
<el-descriptions-item label="AI 调用总次数">
{{ tenant.usage_summary?.total_calls?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="Token 消耗">
{{ tenant.usage_summary?.total_tokens?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="累计费用">
¥{{ tenant.usage_summary?.total_cost?.toFixed(2) || '0.00' }}
</el-descriptions-item>
</el-descriptions>
<!-- 订阅信息 -->
<div style="margin-bottom: 20px">
<h4 style="margin-bottom: 12px">应用订阅</h4>
<el-table :data="tenant.subscriptions" style="width: 100%">
<el-table-column prop="app_code" label="应用" width="150" />
<el-table-column prop="start_date" label="开始日期" width="120" />
<el-table-column prop="end_date" label="结束日期" width="120" />
<el-table-column prop="quota" label="配额">
<template #default="{ row }">
{{ row.quota ? JSON.stringify(row.quota) : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '有效' : '已过期' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!tenant.subscriptions?.length" description="暂无订阅" />
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,246 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
status: '',
keyword: ''
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
code: '',
name: '',
corp_id: '',
status: 'active',
expired_at: null,
contact_info: {
contact: '',
phone: '',
email: ''
}
})
const rules = {
code: [{ required: true, message: '请输入租户代码', trigger: 'blur' }],
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenants', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建租户'
Object.assign(form, {
code: '',
name: '',
corp_id: '',
status: 'active',
expired_at: null,
contact_info: { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑租户'
Object.assign(form, {
code: row.code,
name: row.name,
corp_id: row.corp_id || '',
status: row.status,
expired_at: row.expired_at,
contact_info: row.contact_info || { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
if (editingId.value) {
await api.put(`/api/tenants/${editingId.value}`, form)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenants', form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除租户 "${row.name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenants/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function handleDetail(row) {
router.push(`/tenants/${row.id}`)
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">租户管理</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建租户
</el-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="query.keyword"
placeholder="搜索租户代码或名称"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-select v-model="query.status" placeholder="状态" clearable style="width: 120px">
<el-option label="活跃" value="active" />
<el-option label="已过期" value="expired" />
<el-option label="试用" value="trial" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="code" label="代码" width="120" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="corp_id" label="企业ID" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="expired_at" label="过期时间" width="120" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="租户代码" prop="code">
<el-input v-model="form.code" :disabled="!!editingId" placeholder="唯一标识" />
</el-form-item>
<el-form-item label="租户名称" prop="name">
<el-input v-model="form.name" placeholder="公司/组织名称" />
</el-form-item>
<el-form-item label="企业ID">
<el-input v-model="form.corp_id" placeholder="企业微信企业IDww开头" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="活跃" value="active" />
<el-option label="试用" value="trial" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker v-model="form.expired_at" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contact_info.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.contact_info.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.contact_info.email" placeholder="邮箱地址" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref(null)
const form = reactive({
username: '',
password: '',
nickname: '',
role: 'viewer'
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/auth/users')
tableData.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleCreate() {
dialogTitle.value = '新建用户'
Object.assign(form, {
username: '',
password: '',
nickname: '',
role: 'viewer'
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
await api.post('/api/auth/users', form)
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
if (row.id === authStore.user?.id) {
ElMessage.warning('不能删除当前登录用户')
return
}
await ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/auth/users/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function getRoleTag(role) {
const map = {
admin: { type: 'danger', text: '管理员' },
operator: { type: 'warning', text: '操作员' },
viewer: { type: 'info', text: '只读' }
}
return map[role] || { type: 'info', text: role }
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">用户管理</div>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建用户
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="getRoleTag(row.role).type" size="small">
{{ getRoleTag(row.role).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最后登录" width="180" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
link
size="small"
:disabled="row.id === authStore.user?.id"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="登录密码" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="显示名称" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width: 100%">
<el-option label="管理员" value="admin" />
<el-option label="操作员" value="operator" />
<el-option label="只读" value="viewer" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>

25
frontend/vite.config.js Normal file
View File

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

View File

@@ -1,12 +1,25 @@
"""AI统计上报客户端""" """AI统计上报客户端"""
import os import os
import json
import asyncio
import logging
import threading
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional, List from typing import Optional, List
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from pathlib import Path
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
from .trace import get_trace_id, get_tenant_id, get_user_id from .trace import get_trace_id, get_tenant_id, get_user_id
logger = logging.getLogger(__name__)
@dataclass @dataclass
class AICallEvent: class AICallEvent:
@@ -33,6 +46,24 @@ class AICallEvent:
if self.user_id is None: if self.user_id is None:
self.user_id = get_user_id() self.user_id = get_user_id()
def to_dict(self) -> dict:
"""转换为可序列化的字典"""
return {
"tenant_id": self.tenant_id,
"app_code": self.app_code,
"module_code": self.module_code,
"prompt_name": self.prompt_name,
"model": self.model,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cost": str(self.cost),
"latency_ms": self.latency_ms,
"status": self.status,
"user_id": self.user_id,
"trace_id": self.trace_id,
"event_time": self.event_time.isoformat() if self.event_time else None
}
class StatsClient: class StatsClient:
"""统计上报客户端 """统计上报客户端
@@ -51,23 +82,37 @@ class StatsClient:
) )
""" """
# 失败事件持久化文件
FAILED_EVENTS_FILE = ".platform_failed_events.json"
def __init__( def __init__(
self, self,
tenant_id: int, tenant_id: int,
app_code: str, app_code: str,
platform_url: Optional[str] = None, platform_url: Optional[str] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
local_only: bool = True local_only: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
timeout: float = 10.0
): ):
self.tenant_id = tenant_id self.tenant_id = tenant_id
self.app_code = app_code self.app_code = app_code
self.platform_url = platform_url or os.getenv("PLATFORM_URL", "") self.platform_url = platform_url or os.getenv("PLATFORM_URL", "")
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "") self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
self.local_only = local_only or not self.platform_url self.local_only = local_only or not self.platform_url or not HTTPX_AVAILABLE
self.max_retries = max_retries
self.retry_delay = retry_delay
self.timeout = timeout
# 批量上报缓冲区 # 批量上报缓冲区
self._buffer: List[AICallEvent] = [] self._buffer: List[AICallEvent] = []
self._buffer_size = 10 # 达到此数量时自动上报 self._buffer_size = 10 # 达到此数量时自动上报
self._lock = threading.Lock()
# 在启动时尝试发送之前失败的事件
if not self.local_only:
self._retry_failed_events()
def report_ai_call( def report_ai_call(
self, self,
@@ -113,36 +158,172 @@ class StatsClient:
user_id=user_id user_id=user_id
) )
self._buffer.append(event) with self._lock:
self._buffer.append(event)
should_flush = flush or len(self._buffer) >= self._buffer_size
if flush or len(self._buffer) >= self._buffer_size: if should_flush:
self.flush() self.flush()
return event return event
def flush(self): def flush(self):
"""发送缓冲区中的所有事件""" """发送缓冲区中的所有事件"""
if not self._buffer: with self._lock:
return if not self._buffer:
return
events = self._buffer.copy() events = self._buffer.copy()
self._buffer.clear() self._buffer.clear()
if self.local_only: if self.local_only:
# 本地模式:仅打印 # 本地模式:仅打印
for event in events: for event in events:
print(f"[STATS] {event.app_code}/{event.module_code}: " logger.info(f"[STATS] {event.app_code}/{event.module_code}: "
f"{event.prompt_name} - {event.input_tokens}+{event.output_tokens} tokens") f"{event.prompt_name} - {event.input_tokens}+{event.output_tokens} tokens")
else: else:
# 远程上报 # 远程上报
self._send_to_platform(events) self._send_to_platform(events)
def _send_to_platform(self, events: List[AICallEvent]): def _send_to_platform(self, events: List[AICallEvent]):
"""发送事件到平台(异步,后续实现)""" """发送事件到平台"""
# TODO: 使用httpx异步发送 if not HTTPX_AVAILABLE:
pass logger.warning("httpx not installed, falling back to local mode")
return
# 转换事件为可序列化格式
payload = {"events": [e.to_dict() for e in events]}
# 尝试在事件循环中运行
try:
loop = asyncio.get_running_loop()
# 已在异步上下文中,创建任务
asyncio.create_task(self._send_async(payload, events))
except RuntimeError:
# 没有运行中的事件循环,使用同步方式
self._send_sync(payload, events)
def _send_sync(self, payload: dict, events: List[AICallEvent]):
"""同步发送事件"""
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
for attempt in range(self.max_retries):
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code == 200:
result = response.json()
logger.debug(f"Stats reported successfully: {result.get('count', len(events))} events")
return
else:
logger.warning(f"Stats report failed with status {response.status_code}: {response.text}")
except httpx.TimeoutException:
logger.warning(f"Stats report timeout (attempt {attempt + 1}/{self.max_retries})")
except httpx.RequestError as e:
logger.warning(f"Stats report request error (attempt {attempt + 1}/{self.max_retries}): {e}")
except Exception as e:
logger.error(f"Stats report unexpected error: {e}")
break
# 重试延迟
if attempt < self.max_retries - 1:
import time
time.sleep(self.retry_delay * (attempt + 1))
# 所有重试都失败,持久化到文件
self._persist_failed_events(events)
async def _send_async(self, payload: dict, events: List[AICallEvent]):
"""异步发送事件"""
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
for attempt in range(self.max_retries):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code == 200:
result = response.json()
logger.debug(f"Stats reported successfully: {result.get('count', len(events))} events")
return
else:
logger.warning(f"Stats report failed with status {response.status_code}: {response.text}")
except httpx.TimeoutException:
logger.warning(f"Stats report timeout (attempt {attempt + 1}/{self.max_retries})")
except httpx.RequestError as e:
logger.warning(f"Stats report request error (attempt {attempt + 1}/{self.max_retries}): {e}")
except Exception as e:
logger.error(f"Stats report unexpected error: {e}")
break
# 重试延迟
if attempt < self.max_retries - 1:
await asyncio.sleep(self.retry_delay * (attempt + 1))
# 所有重试都失败,持久化到文件
self._persist_failed_events(events)
def _persist_failed_events(self, events: List[AICallEvent]):
"""持久化失败的事件到文件"""
try:
failed_file = Path(self.FAILED_EVENTS_FILE)
existing = []
if failed_file.exists():
try:
existing = json.loads(failed_file.read_text())
except (json.JSONDecodeError, IOError):
existing = []
# 添加新的失败事件
for event in events:
existing.append(event.to_dict())
# 限制最多保存1000条
if len(existing) > 1000:
existing = existing[-1000:]
failed_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
logger.info(f"Persisted {len(events)} failed events to {self.FAILED_EVENTS_FILE}")
except Exception as e:
logger.error(f"Failed to persist events: {e}")
def _retry_failed_events(self):
"""重试之前失败的事件"""
try:
failed_file = Path(self.FAILED_EVENTS_FILE)
if not failed_file.exists():
return
events_data = json.loads(failed_file.read_text())
if not events_data:
return
logger.info(f"Retrying {len(events_data)} previously failed events")
# 尝试发送
payload = {"events": events_data}
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code == 200:
# 成功后删除文件
failed_file.unlink()
logger.info(f"Successfully sent {len(events_data)} previously failed events")
except Exception as e:
logger.warning(f"Failed to retry events: {e}")
except Exception as e:
logger.error(f"Error loading failed events: {e}")
def __del__(self): def __del__(self):
"""析构时发送剩余事件""" """析构时发送剩余事件"""
if self._buffer: try:
self.flush() if self._buffer:
self.flush()
except Exception:
pass # 忽略析构时的错误