Initial commit: 000-platform project skeleton
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
69
.drone.yml
Normal file
69
.drone.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build-and-deploy
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
|
||||||
|
- docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
|
||||||
|
|
||||||
|
- name: deploy-test
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
DATABASE_URL:
|
||||||
|
from_secret: database_url
|
||||||
|
API_KEY:
|
||||||
|
from_secret: api_key
|
||||||
|
JWT_SECRET:
|
||||||
|
from_secret: jwt_secret
|
||||||
|
CONFIG_ENCRYPT_KEY:
|
||||||
|
from_secret: config_encrypt_key
|
||||||
|
commands:
|
||||||
|
- docker stop platform-backend-test || true
|
||||||
|
- docker rm platform-backend-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
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
- name: deploy-prod
|
||||||
|
image: docker:dind
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
DATABASE_URL:
|
||||||
|
from_secret: database_url
|
||||||
|
API_KEY:
|
||||||
|
from_secret: api_key
|
||||||
|
JWT_SECRET:
|
||||||
|
from_secret: jwt_secret
|
||||||
|
CONFIG_ENCRYPT_KEY:
|
||||||
|
from_secret: config_encrypt_key
|
||||||
|
commands:
|
||||||
|
- docker stop platform-backend-prod || true
|
||||||
|
- docker rm platform-backend-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
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
39
backend/app/config.py
Normal file
39
backend/app/config.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""配置管理"""
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
# 应用信息
|
||||||
|
APP_NAME: str = "000-platform"
|
||||||
|
APP_VERSION: str = "0.1.0"
|
||||||
|
DEBUG: bool = False
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
DATABASE_URL: str = "mysql+pymysql://scrm_reader:ScrmReader2024Pass@47.107.71.55:3306/new_qiqi"
|
||||||
|
|
||||||
|
# API Key(内部服务调用)
|
||||||
|
API_KEY: str = "platform_api_key_2026"
|
||||||
|
|
||||||
|
# 管理员账号
|
||||||
|
ADMIN_USERNAME: str = "admin"
|
||||||
|
ADMIN_PASSWORD_HASH: str = "" # bcrypt hash
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET: str = "platform_jwt_secret_2026_change_in_prod"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS: int = 24
|
||||||
|
|
||||||
|
# 配置加密密钥
|
||||||
|
CONFIG_ENCRYPT_KEY: str = "platform_config_key_32bytes!!"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
29
backend/app/database.py
Normal file
29
backend/app/database.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""数据库连接"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
echo=settings.DEBUG
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""获取数据库会话"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
38
backend/app/main.py
Normal file
38
backend/app/main.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""平台服务入口"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .routers import stats_router, logs_router, config_router, health_router
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
description="平台基础设施服务 - 统计/日志/配置管理"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(stats_router)
|
||||||
|
app.include_router(logs_router)
|
||||||
|
app.include_router(config_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"service": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
13
backend/app/models/__init__.py
Normal file
13
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""数据模型"""
|
||||||
|
from .tenant import Tenant, Subscription, Config
|
||||||
|
from .stats import AICallEvent, TenantUsageDaily
|
||||||
|
from .logs import PlatformLog
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Tenant",
|
||||||
|
"Subscription",
|
||||||
|
"Config",
|
||||||
|
"AICallEvent",
|
||||||
|
"TenantUsageDaily",
|
||||||
|
"PlatformLog"
|
||||||
|
]
|
||||||
31
backend/app/models/logs.py
Normal file
31
backend/app/models/logs.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""日志相关模型"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, JSON, Enum, TIMESTAMP
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformLog(Base):
|
||||||
|
"""统一日志表"""
|
||||||
|
__tablename__ = "platform_logs"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
trace_id = Column(String(36))
|
||||||
|
tenant_id = Column(BigInteger)
|
||||||
|
user_id = Column(BigInteger)
|
||||||
|
app_code = Column(String(50), nullable=False)
|
||||||
|
log_type = Column(Enum('request', 'error', 'app', 'biz', 'audit'), nullable=False)
|
||||||
|
level = Column(Enum('debug', 'info', 'warn', 'error', 'fatal'), default='info')
|
||||||
|
category = Column(String(100))
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
context = Column(JSON)
|
||||||
|
method = Column(String(10))
|
||||||
|
path = Column(String(500))
|
||||||
|
status_code = Column(Integer)
|
||||||
|
duration_ms = Column(Integer)
|
||||||
|
error_type = Column(String(100))
|
||||||
|
stack_trace = Column(Text)
|
||||||
|
action = Column(String(100))
|
||||||
|
target_type = Column(String(50))
|
||||||
|
target_id = Column(String(100))
|
||||||
|
log_time = Column(TIMESTAMP, nullable=False)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
41
backend/app/models/stats.py
Normal file
41
backend/app/models/stats.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""统计相关模型"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Date, DECIMAL, TIMESTAMP
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AICallEvent(Base):
|
||||||
|
"""AI调用明细"""
|
||||||
|
__tablename__ = "platform_ai_call_events"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id = Column(BigInteger, nullable=False)
|
||||||
|
user_id = Column(BigInteger)
|
||||||
|
app_code = Column(String(50), nullable=False)
|
||||||
|
module_code = Column(String(50), nullable=False)
|
||||||
|
trace_id = Column(String(36))
|
||||||
|
prompt_name = Column(String(100), nullable=False)
|
||||||
|
model = Column(String(100), nullable=False)
|
||||||
|
input_tokens = Column(Integer, default=0)
|
||||||
|
output_tokens = Column(Integer, default=0)
|
||||||
|
cost = Column(DECIMAL(10, 6), default=0)
|
||||||
|
latency_ms = Column(Integer, default=0)
|
||||||
|
status = Column(String(20), default='success')
|
||||||
|
event_time = Column(TIMESTAMP, nullable=False)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUsageDaily(Base):
|
||||||
|
"""租户日用量汇总"""
|
||||||
|
__tablename__ = "platform_tenant_usage_daily"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id = Column(BigInteger, nullable=False)
|
||||||
|
app_code = Column(String(50), nullable=False)
|
||||||
|
stat_date = Column(Date, nullable=False)
|
||||||
|
ai_calls = Column(Integer, default=0)
|
||||||
|
ai_tokens = Column(BigInteger, default=0)
|
||||||
|
ai_cost = Column(DECIMAL(10, 4), default=0)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
|
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||||
47
backend/app/models/tenant.py
Normal file
47
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""租户相关模型"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Enum, Date, Text, Boolean, JSON, TIMESTAMP
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base):
|
||||||
|
"""租户表"""
|
||||||
|
__tablename__ = "platform_tenants"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
code = Column(String(50), unique=True, nullable=False)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
contact_info = Column(JSON)
|
||||||
|
status = Column(Enum('active', 'expired', 'trial'), default='active')
|
||||||
|
expired_at = Column(Date)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
|
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(Base):
|
||||||
|
"""订阅表"""
|
||||||
|
__tablename__ = "platform_subscriptions"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id = Column(BigInteger, nullable=False)
|
||||||
|
app_code = Column(String(50), nullable=False)
|
||||||
|
start_date = Column(Date)
|
||||||
|
end_date = Column(Date)
|
||||||
|
quota = Column(JSON)
|
||||||
|
status = Column(Enum('active', 'expired'), default='active')
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
|
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class Config(Base):
|
||||||
|
"""配置表"""
|
||||||
|
__tablename__ = "platform_configs"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id = Column(BigInteger, nullable=False)
|
||||||
|
config_type = Column(String(50), nullable=False)
|
||||||
|
config_key = Column(String(100), nullable=False)
|
||||||
|
config_value = Column(Text)
|
||||||
|
is_encrypted = Column(Boolean, default=False)
|
||||||
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
|
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||||
12
backend/app/routers/__init__.py
Normal file
12
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""API路由"""
|
||||||
|
from .stats import router as stats_router
|
||||||
|
from .logs import router as logs_router
|
||||||
|
from .config import router as config_router
|
||||||
|
from .health import router as health_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"stats_router",
|
||||||
|
"logs_router",
|
||||||
|
"config_router",
|
||||||
|
"health_router"
|
||||||
|
]
|
||||||
49
backend/app/routers/config.py
Normal file
49
backend/app/routers/config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""配置路由"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..models.tenant import Config
|
||||||
|
from ..schemas.config import ConfigValue, ConfigWrite
|
||||||
|
from ..services.crypto import decrypt_value
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
|
||||||
|
"""验证API Key"""
|
||||||
|
if x_api_key != settings.API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||||
|
return x_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_type}/{config_key}", response_model=ConfigValue)
|
||||||
|
async def get_config(
|
||||||
|
config_type: str,
|
||||||
|
config_key: str,
|
||||||
|
tenant_id: int = Query(..., description="租户ID"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""读取配置"""
|
||||||
|
config = db.query(Config).filter(
|
||||||
|
Config.tenant_id == tenant_id,
|
||||||
|
Config.config_type == config_type,
|
||||||
|
Config.config_key == config_key
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Config not found")
|
||||||
|
|
||||||
|
value = config.config_value
|
||||||
|
if config.is_encrypted:
|
||||||
|
value = decrypt_value(value)
|
||||||
|
|
||||||
|
return ConfigValue(
|
||||||
|
config_type=config.config_type,
|
||||||
|
config_key=config.config_key,
|
||||||
|
config_value=value
|
||||||
|
)
|
||||||
17
backend/app/routers/health.py
Normal file
17
backend/app/routers/health.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""健康检查路由"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"app": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION
|
||||||
|
}
|
||||||
45
backend/app/routers/logs.py
Normal file
45
backend/app/routers/logs.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""日志路由"""
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..models.logs import PlatformLog
|
||||||
|
from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
|
||||||
|
"""验证API Key"""
|
||||||
|
if x_api_key != settings.API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||||
|
return x_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/write", response_model=LogResponse)
|
||||||
|
async def write_log(
|
||||||
|
log: LogCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""写入日志"""
|
||||||
|
db_log = PlatformLog(**log.model_dump())
|
||||||
|
db.add(db_log)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_log)
|
||||||
|
return db_log
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/write/batch")
|
||||||
|
async def batch_write_logs(
|
||||||
|
request: BatchLogRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""批量写入日志"""
|
||||||
|
logs = [PlatformLog(**l.model_dump()) for l in request.logs]
|
||||||
|
db.add_all(logs)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "count": len(logs)}
|
||||||
45
backend/app/routers/stats.py
Normal file
45
backend/app/routers/stats.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""统计上报路由"""
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..models.stats import AICallEvent
|
||||||
|
from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
|
||||||
|
"""验证API Key"""
|
||||||
|
if x_api_key != settings.API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||||
|
return x_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report", response_model=AICallEventResponse)
|
||||||
|
async def report_ai_call(
|
||||||
|
event: AICallEventCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""上报AI调用事件"""
|
||||||
|
db_event = AICallEvent(**event.model_dump())
|
||||||
|
db.add(db_event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_event)
|
||||||
|
return db_event
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report/batch")
|
||||||
|
async def batch_report_ai_calls(
|
||||||
|
request: BatchReportRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""批量上报AI调用事件"""
|
||||||
|
events = [AICallEvent(**e.model_dump()) for e in request.events]
|
||||||
|
db.add_all(events)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "count": len(events)}
|
||||||
12
backend/app/schemas/__init__.py
Normal file
12
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""请求/响应模型"""
|
||||||
|
from .stats import AICallEventCreate, AICallEventResponse
|
||||||
|
from .logs import LogCreate, LogResponse
|
||||||
|
from .config import ConfigRead
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AICallEventCreate",
|
||||||
|
"AICallEventResponse",
|
||||||
|
"LogCreate",
|
||||||
|
"LogResponse",
|
||||||
|
"ConfigRead"
|
||||||
|
]
|
||||||
26
backend/app/schemas/config.py
Normal file
26
backend/app/schemas/config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""配置相关Schema"""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRead(BaseModel):
|
||||||
|
"""配置读取请求"""
|
||||||
|
tenant_id: int = Field(..., description="租户ID")
|
||||||
|
config_type: str = Field(..., description="配置类型")
|
||||||
|
config_key: str = Field(..., description="配置键")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValue(BaseModel):
|
||||||
|
"""配置值响应"""
|
||||||
|
config_type: str
|
||||||
|
config_key: str
|
||||||
|
config_value: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigWrite(BaseModel):
|
||||||
|
"""配置写入"""
|
||||||
|
tenant_id: int
|
||||||
|
config_type: str
|
||||||
|
config_key: str
|
||||||
|
config_value: str
|
||||||
|
encrypt: bool = False
|
||||||
46
backend/app/schemas/logs.py
Normal file
46
backend/app/schemas/logs.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""日志相关Schema"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LogCreate(BaseModel):
|
||||||
|
"""日志写入"""
|
||||||
|
trace_id: Optional[str] = Field(None, description="链路追踪ID")
|
||||||
|
tenant_id: Optional[int] = Field(None, description="租户ID")
|
||||||
|
user_id: Optional[int] = Field(None, description="用户ID")
|
||||||
|
app_code: str = Field(..., description="应用编码")
|
||||||
|
log_type: Literal['request', 'error', 'app', 'biz', 'audit'] = Field(..., description="日志类型")
|
||||||
|
level: Literal['debug', 'info', 'warn', 'error', 'fatal'] = Field('info', description="级别")
|
||||||
|
category: Optional[str] = Field(None, description="分类")
|
||||||
|
message: str = Field(..., description="消息")
|
||||||
|
context: Optional[dict] = Field(None, description="上下文")
|
||||||
|
method: Optional[str] = Field(None, description="HTTP方法")
|
||||||
|
path: Optional[str] = Field(None, description="请求路径")
|
||||||
|
status_code: Optional[int] = Field(None, description="HTTP状态码")
|
||||||
|
duration_ms: Optional[int] = Field(None, description="耗时(ms)")
|
||||||
|
error_type: Optional[str] = Field(None, description="错误类型")
|
||||||
|
stack_trace: Optional[str] = Field(None, description="堆栈")
|
||||||
|
action: Optional[str] = Field(None, description="操作")
|
||||||
|
target_type: Optional[str] = Field(None, description="目标类型")
|
||||||
|
target_id: Optional[str] = Field(None, description="目标ID")
|
||||||
|
log_time: datetime = Field(..., description="日志时间")
|
||||||
|
|
||||||
|
|
||||||
|
class LogResponse(BaseModel):
|
||||||
|
"""日志响应"""
|
||||||
|
id: int
|
||||||
|
trace_id: Optional[str]
|
||||||
|
app_code: str
|
||||||
|
log_type: str
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
log_time: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BatchLogRequest(BaseModel):
|
||||||
|
"""批量日志请求"""
|
||||||
|
logs: list[LogCreate]
|
||||||
42
backend/app/schemas/stats.py
Normal file
42
backend/app/schemas/stats.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""统计相关Schema"""
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AICallEventCreate(BaseModel):
|
||||||
|
"""AI调用事件上报"""
|
||||||
|
tenant_id: int = Field(..., description="租户ID")
|
||||||
|
user_id: Optional[int] = Field(None, description="用户ID")
|
||||||
|
app_code: str = Field(..., description="应用编码")
|
||||||
|
module_code: str = Field(..., description="模块编码")
|
||||||
|
trace_id: Optional[str] = Field(None, description="链路追踪ID")
|
||||||
|
prompt_name: str = Field(..., description="Prompt名称")
|
||||||
|
model: str = Field(..., description="模型名称")
|
||||||
|
input_tokens: int = Field(0, description="输入token")
|
||||||
|
output_tokens: int = Field(0, description="输出token")
|
||||||
|
cost: Decimal = Field(Decimal("0"), description="成本")
|
||||||
|
latency_ms: int = Field(0, description="延迟(ms)")
|
||||||
|
status: str = Field("success", description="状态")
|
||||||
|
event_time: datetime = Field(..., description="事件时间")
|
||||||
|
|
||||||
|
|
||||||
|
class AICallEventResponse(BaseModel):
|
||||||
|
"""AI调用事件响应"""
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
app_code: str
|
||||||
|
prompt_name: str
|
||||||
|
model: str
|
||||||
|
input_tokens: int
|
||||||
|
output_tokens: int
|
||||||
|
event_time: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BatchReportRequest(BaseModel):
|
||||||
|
"""批量上报请求"""
|
||||||
|
events: list[AICallEventCreate]
|
||||||
4
backend/app/services/__init__.py
Normal file
4
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""业务服务"""
|
||||||
|
from .crypto import encrypt_value, decrypt_value
|
||||||
|
|
||||||
|
__all__ = ["encrypt_value", "decrypt_value"]
|
||||||
37
backend/app/services/crypto.py
Normal file
37
backend/app/services/crypto.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""配置加密服务"""
|
||||||
|
import base64
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""获取Fernet实例"""
|
||||||
|
# 使用PBKDF2从密钥派生32字节密钥
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b'platform_salt_2026',
|
||||||
|
iterations=100000,
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(settings.CONFIG_ENCRYPT_KEY.encode()))
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(value: str) -> str:
|
||||||
|
"""加密配置值"""
|
||||||
|
f = _get_fernet()
|
||||||
|
encrypted = f.encrypt(value.encode())
|
||||||
|
return base64.urlsafe_b64encode(encrypted).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(encrypted_value: str) -> str:
|
||||||
|
"""解密配置值"""
|
||||||
|
f = _get_fernet()
|
||||||
|
encrypted = base64.urlsafe_b64decode(encrypted_value.encode())
|
||||||
|
decrypted = f.decrypt(encrypted)
|
||||||
|
return decrypted.decode()
|
||||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn>=0.27.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
cryptography>=42.0.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
httpx>=0.26.0
|
||||||
16
deploy/Dockerfile.backend
Normal file
16
deploy/Dockerfile.backend
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制代码
|
||||||
|
COPY backend/app ./app
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
20
deploy/docker-compose.yml
Normal file
20
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.backend
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-mysql+pymysql://scrm_reader:ScrmReader2024Pass@47.107.71.55:3306/new_qiqi}
|
||||||
|
- API_KEY=${API_KEY:-platform_api_key_2026}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-platform_jwt_secret_2026}
|
||||||
|
- CONFIG_ENCRYPT_KEY=${CONFIG_ENCRYPT_KEY:-platform_config_key_32bytes!!}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
129
sdk/README.md
Normal file
129
sdk/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Platform SDK
|
||||||
|
|
||||||
|
平台基础设施客户端SDK,提供统一的统计上报、日志记录、链路追踪等功能。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
SDK作为共享模块使用,通过软链接引用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 _shared 目录创建软链接
|
||||||
|
cd AgentWD/_shared
|
||||||
|
ln -s ../projects/000-platform/sdk platform
|
||||||
|
```
|
||||||
|
|
||||||
|
## 在项目中使用
|
||||||
|
|
||||||
|
### 1. 添加路径
|
||||||
|
|
||||||
|
在项目的 `main.py` 开头添加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加 _shared 到路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent / "_shared"))
|
||||||
|
|
||||||
|
from platform import get_logger, StatsClient, LoggingMiddleware
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 日志
|
||||||
|
|
||||||
|
```python
|
||||||
|
from platform import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("011-ai-interview")
|
||||||
|
|
||||||
|
# 基础日志
|
||||||
|
logger.info("用户开始面试", user_id=123)
|
||||||
|
logger.error("面试出错", error=e)
|
||||||
|
|
||||||
|
# 审计日志
|
||||||
|
logger.audit("create", "interview", "123", operator="admin")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. AI统计上报
|
||||||
|
|
||||||
|
```python
|
||||||
|
from platform import StatsClient
|
||||||
|
|
||||||
|
stats = StatsClient(tenant_id=1, app_code="011-ai-interview")
|
||||||
|
|
||||||
|
# 上报AI调用
|
||||||
|
stats.report_ai_call(
|
||||||
|
module_code="interview",
|
||||||
|
prompt_name="generate_question",
|
||||||
|
model="gpt-4",
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=200,
|
||||||
|
latency_ms=1500
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 链路追踪
|
||||||
|
|
||||||
|
```python
|
||||||
|
from platform import TraceContext, get_trace_id
|
||||||
|
|
||||||
|
# 在请求处理中
|
||||||
|
with TraceContext(tenant_id=1, user_id=100) as ctx:
|
||||||
|
print(f"当前trace_id: {ctx.trace_id}")
|
||||||
|
# 所有操作共享同一个trace_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. FastAPI中间件
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from platform import LoggingMiddleware, TraceMiddleware
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# 添加中间件(顺序重要)
|
||||||
|
app.add_middleware(LoggingMiddleware, app_code="011-ai-interview")
|
||||||
|
app.add_middleware(TraceMiddleware)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. HTTP客户端
|
||||||
|
|
||||||
|
```python
|
||||||
|
from platform import PlatformHttpClient
|
||||||
|
|
||||||
|
client = PlatformHttpClient(base_url="https://api.example.com")
|
||||||
|
|
||||||
|
# 自动传递trace_id和API Key
|
||||||
|
response = await client.get("/users/1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 配置读取
|
||||||
|
|
||||||
|
```python
|
||||||
|
from platform import ConfigReader
|
||||||
|
|
||||||
|
config = ConfigReader(tenant_id=1)
|
||||||
|
|
||||||
|
# 读取平台配置
|
||||||
|
app_id = await config.get("wechat", "app_id")
|
||||||
|
|
||||||
|
# 读取环境变量
|
||||||
|
debug = config.get_env("DEBUG", False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
SDK使用以下环境变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `PLATFORM_URL` | 平台服务地址 | - |
|
||||||
|
| `PLATFORM_API_KEY` | 平台API Key | - |
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- 初始版本
|
||||||
|
- 支持日志、统计、链路追踪
|
||||||
|
- 支持FastAPI中间件
|
||||||
|
- 支持配置读取
|
||||||
46
sdk/__init__.py
Normal file
46
sdk/__init__.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Platform SDK - 平台基础设施客户端SDK
|
||||||
|
|
||||||
|
提供统一的统计上报、日志记录、链路追踪等功能。
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
from platform import get_logger, StatsClient, LoggingMiddleware
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logger = get_logger("my-app")
|
||||||
|
logger.info("Hello")
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
stats = StatsClient(tenant_id=1, app_code="my-app")
|
||||||
|
stats.report_ai_call(...)
|
||||||
|
|
||||||
|
# FastAPI中间件
|
||||||
|
app.add_middleware(LoggingMiddleware)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .logger import get_logger, PlatformLogger
|
||||||
|
from .stats_client import StatsClient
|
||||||
|
from .trace import TraceContext, get_trace_id, generate_trace_id
|
||||||
|
from .middleware import LoggingMiddleware, TraceMiddleware
|
||||||
|
from .http_client import PlatformHttpClient
|
||||||
|
from .config_reader import ConfigReader
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Logger
|
||||||
|
"get_logger",
|
||||||
|
"PlatformLogger",
|
||||||
|
# Stats
|
||||||
|
"StatsClient",
|
||||||
|
# Trace
|
||||||
|
"TraceContext",
|
||||||
|
"get_trace_id",
|
||||||
|
"generate_trace_id",
|
||||||
|
# Middleware
|
||||||
|
"LoggingMiddleware",
|
||||||
|
"TraceMiddleware",
|
||||||
|
# HTTP
|
||||||
|
"PlatformHttpClient",
|
||||||
|
# Config
|
||||||
|
"ConfigReader",
|
||||||
|
]
|
||||||
105
sdk/config_reader.py
Normal file
105
sdk/config_reader.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""配置读取客户端"""
|
||||||
|
import os
|
||||||
|
from typing import Optional, Any, Dict
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from .http_client import PlatformHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigReader:
|
||||||
|
"""配置读取器
|
||||||
|
|
||||||
|
从平台服务读取租户配置
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
config = ConfigReader(tenant_id=1)
|
||||||
|
|
||||||
|
# 读取单个配置
|
||||||
|
value = await config.get("wechat", "app_id")
|
||||||
|
|
||||||
|
# 读取配置组
|
||||||
|
wechat_config = await config.get_group("wechat")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tenant_id: int,
|
||||||
|
platform_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
cache_ttl: int = 300 # 缓存时间(秒)
|
||||||
|
):
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.platform_url = platform_url or os.getenv("PLATFORM_URL", "")
|
||||||
|
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
|
||||||
|
self.cache_ttl = cache_ttl
|
||||||
|
|
||||||
|
self._client = PlatformHttpClient(
|
||||||
|
base_url=self.platform_url,
|
||||||
|
api_key=self.api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# 本地缓存
|
||||||
|
self._cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
config_type: str,
|
||||||
|
config_key: str,
|
||||||
|
default: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""读取配置值
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_type: 配置类型
|
||||||
|
config_key: 配置键
|
||||||
|
default: 默认值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置值
|
||||||
|
"""
|
||||||
|
cache_key = f"{config_type}:{config_key}"
|
||||||
|
|
||||||
|
# 检查缓存
|
||||||
|
if cache_key in self._cache:
|
||||||
|
return self._cache[cache_key]
|
||||||
|
|
||||||
|
# 从平台获取
|
||||||
|
try:
|
||||||
|
response = await self._client.get(
|
||||||
|
f"/api/config/{config_type}/{config_key}",
|
||||||
|
params={"tenant_id": self.tenant_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
value = data.get("config_value", default)
|
||||||
|
self._cache[cache_key] = value
|
||||||
|
return value
|
||||||
|
elif response.status_code == 404:
|
||||||
|
return default
|
||||||
|
else:
|
||||||
|
raise Exception(f"Config read failed: {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 失败时返回默认值
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_env(
|
||||||
|
self,
|
||||||
|
env_key: str,
|
||||||
|
default: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""从环境变量读取配置(同步方法)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env_key: 环境变量名
|
||||||
|
default: 默认值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置值
|
||||||
|
"""
|
||||||
|
return os.getenv(env_key, default)
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
"""清除缓存"""
|
||||||
|
self._cache.clear()
|
||||||
83
sdk/http_client.py
Normal file
83
sdk/http_client.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""HTTP客户端封装"""
|
||||||
|
import os
|
||||||
|
from typing import Optional, Any, Dict
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .trace import get_trace_id
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformHttpClient:
|
||||||
|
"""平台HTTP客户端
|
||||||
|
|
||||||
|
自动传递trace_id和API Key
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
client = PlatformHttpClient(base_url="https://api.example.com")
|
||||||
|
response = await client.get("/users/1")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
timeout: float = 30.0
|
||||||
|
):
|
||||||
|
self.base_url = base_url or os.getenv("PLATFORM_URL", "")
|
||||||
|
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _get_headers(self, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||||
|
"""获取请求头"""
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
# 添加trace_id
|
||||||
|
trace_id = get_trace_id()
|
||||||
|
if trace_id:
|
||||||
|
headers["X-Trace-ID"] = trace_id
|
||||||
|
|
||||||
|
# 添加API Key
|
||||||
|
if self.api_key:
|
||||||
|
headers["X-API-Key"] = self.api_key
|
||||||
|
|
||||||
|
# 合并额外header
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
**kwargs
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""发送HTTP请求"""
|
||||||
|
headers = self._get_headers(kwargs.pop("headers", None))
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=self.timeout
|
||||||
|
) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method=method,
|
||||||
|
url=path,
|
||||||
|
headers=headers,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get(self, path: str, **kwargs) -> httpx.Response:
|
||||||
|
"""GET请求"""
|
||||||
|
return await self.request("GET", path, **kwargs)
|
||||||
|
|
||||||
|
async def post(self, path: str, **kwargs) -> httpx.Response:
|
||||||
|
"""POST请求"""
|
||||||
|
return await self.request("POST", path, **kwargs)
|
||||||
|
|
||||||
|
async def put(self, path: str, **kwargs) -> httpx.Response:
|
||||||
|
"""PUT请求"""
|
||||||
|
return await self.request("PUT", path, **kwargs)
|
||||||
|
|
||||||
|
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
||||||
|
"""DELETE请求"""
|
||||||
|
return await self.request("DELETE", path, **kwargs)
|
||||||
125
sdk/logger.py
Normal file
125
sdk/logger.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""统一日志模块"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Any
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from .trace import get_trace_id, get_tenant_id, get_user_id
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformLogger:
|
||||||
|
"""平台日志器
|
||||||
|
|
||||||
|
支持本地输出和远程上报两种模式
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app_code: str,
|
||||||
|
platform_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
local_only: bool = True
|
||||||
|
):
|
||||||
|
self.app_code = app_code
|
||||||
|
self.platform_url = platform_url or os.getenv("PLATFORM_URL", "")
|
||||||
|
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
|
||||||
|
self.local_only = local_only or not self.platform_url
|
||||||
|
|
||||||
|
# 设置本地日志
|
||||||
|
self._logger = logging.getLogger(app_code)
|
||||||
|
self._logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
if not self._logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s | %(levelname)s | %(name)s | %(message)s'
|
||||||
|
))
|
||||||
|
self._logger.addHandler(handler)
|
||||||
|
|
||||||
|
def _format_message(self, message: str, **kwargs) -> str:
|
||||||
|
"""格式化日志消息,包含trace_id"""
|
||||||
|
trace_id = get_trace_id()
|
||||||
|
prefix = f"[{trace_id[:8]}] " if trace_id else ""
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
extra = " | " + " ".join(f"{k}={v}" for k, v in kwargs.items())
|
||||||
|
else:
|
||||||
|
extra = ""
|
||||||
|
|
||||||
|
return f"{prefix}{message}{extra}"
|
||||||
|
|
||||||
|
def _log(
|
||||||
|
self,
|
||||||
|
level: str,
|
||||||
|
message: str,
|
||||||
|
log_type: str = "app",
|
||||||
|
category: Optional[str] = None,
|
||||||
|
context: Optional[dict] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""内部日志方法"""
|
||||||
|
formatted = self._format_message(message, **kwargs)
|
||||||
|
|
||||||
|
# 本地日志
|
||||||
|
log_method = getattr(self._logger, level.lower(), self._logger.info)
|
||||||
|
log_method(formatted)
|
||||||
|
|
||||||
|
# TODO: 远程上报(异步)
|
||||||
|
if not self.local_only:
|
||||||
|
self._send_to_platform(level, message, log_type, category, context, kwargs)
|
||||||
|
|
||||||
|
def _send_to_platform(
|
||||||
|
self,
|
||||||
|
level: str,
|
||||||
|
message: str,
|
||||||
|
log_type: str,
|
||||||
|
category: Optional[str],
|
||||||
|
context: Optional[dict],
|
||||||
|
extra: dict
|
||||||
|
):
|
||||||
|
"""发送日志到平台(异步,后续实现)"""
|
||||||
|
# TODO: 使用httpx异步发送
|
||||||
|
pass
|
||||||
|
|
||||||
|
def debug(self, message: str, **kwargs):
|
||||||
|
"""调试日志"""
|
||||||
|
self._log("debug", message, **kwargs)
|
||||||
|
|
||||||
|
def info(self, message: str, **kwargs):
|
||||||
|
"""信息日志"""
|
||||||
|
self._log("info", message, **kwargs)
|
||||||
|
|
||||||
|
def warn(self, message: str, **kwargs):
|
||||||
|
"""警告日志"""
|
||||||
|
self._log("warn", message, **kwargs)
|
||||||
|
|
||||||
|
def warning(self, message: str, **kwargs):
|
||||||
|
"""警告日志(别名)"""
|
||||||
|
self.warn(message, **kwargs)
|
||||||
|
|
||||||
|
def error(self, message: str, error: Optional[Exception] = None, **kwargs):
|
||||||
|
"""错误日志"""
|
||||||
|
if error:
|
||||||
|
kwargs["error_type"] = type(error).__name__
|
||||||
|
kwargs["error_msg"] = str(error)
|
||||||
|
self._log("error", message, log_type="error", **kwargs)
|
||||||
|
|
||||||
|
def audit(self, action: str, target_type: str, target_id: str, **kwargs):
|
||||||
|
"""审计日志"""
|
||||||
|
self._log(
|
||||||
|
"info",
|
||||||
|
f"AUDIT: {action} {target_type}:{target_id}",
|
||||||
|
log_type="audit",
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_logger(app_code: str) -> PlatformLogger:
|
||||||
|
"""获取日志器(单例)"""
|
||||||
|
return PlatformLogger(app_code)
|
||||||
88
sdk/middleware.py
Normal file
88
sdk/middleware.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""FastAPI中间件"""
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from .trace import generate_trace_id, set_trace_id, get_trace_id
|
||||||
|
from .logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class TraceMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""链路追踪中间件
|
||||||
|
|
||||||
|
为每个请求生成或传递trace_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
# 从header获取或生成trace_id
|
||||||
|
trace_id = request.headers.get("X-Trace-ID") or generate_trace_id()
|
||||||
|
set_trace_id(trace_id)
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 在响应header中返回trace_id
|
||||||
|
response.headers["X-Trace-ID"] = trace_id
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""请求日志中间件
|
||||||
|
|
||||||
|
记录每个请求的基本信息和耗时
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, app_code: str = "unknown"):
|
||||||
|
super().__init__(app)
|
||||||
|
self.app_code = app_code
|
||||||
|
self.logger = get_logger(app_code)
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
# 确保有trace_id
|
||||||
|
trace_id = get_trace_id()
|
||||||
|
if not trace_id:
|
||||||
|
trace_id = request.headers.get("X-Trace-ID") or generate_trace_id()
|
||||||
|
set_trace_id(trace_id)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 记录请求开始
|
||||||
|
self.logger.info(
|
||||||
|
f"Request started: {request.method} {request.url.path}",
|
||||||
|
method=request.method,
|
||||||
|
path=str(request.url.path)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 计算耗时
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# 记录请求完成
|
||||||
|
self.logger.info(
|
||||||
|
f"Request completed: {response.status_code}",
|
||||||
|
method=request.method,
|
||||||
|
path=str(request.url.path),
|
||||||
|
status_code=response.status_code,
|
||||||
|
duration_ms=duration_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加trace_id到响应header
|
||||||
|
response.headers["X-Trace-ID"] = trace_id
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
self.logger.error(
|
||||||
|
f"Request failed: {str(e)}",
|
||||||
|
error=e,
|
||||||
|
method=request.method,
|
||||||
|
path=str(request.url.path),
|
||||||
|
duration_ms=duration_ms
|
||||||
|
)
|
||||||
|
raise
|
||||||
148
sdk/stats_client.py
Normal file
148
sdk/stats_client.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""AI统计上报客户端"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, List
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
from .trace import get_trace_id, get_tenant_id, get_user_id
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AICallEvent:
|
||||||
|
"""AI调用事件"""
|
||||||
|
tenant_id: int
|
||||||
|
app_code: str
|
||||||
|
module_code: str
|
||||||
|
prompt_name: str
|
||||||
|
model: str
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cost: Decimal = Decimal("0")
|
||||||
|
latency_ms: int = 0
|
||||||
|
status: str = "success"
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
trace_id: Optional[str] = None
|
||||||
|
event_time: datetime = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.event_time is None:
|
||||||
|
self.event_time = datetime.now()
|
||||||
|
if self.trace_id is None:
|
||||||
|
self.trace_id = get_trace_id()
|
||||||
|
if self.user_id is None:
|
||||||
|
self.user_id = get_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
class StatsClient:
|
||||||
|
"""统计上报客户端
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
stats = StatsClient(tenant_id=1, app_code="011-ai-interview")
|
||||||
|
|
||||||
|
# 上报AI调用
|
||||||
|
stats.report_ai_call(
|
||||||
|
module_code="interview",
|
||||||
|
prompt_name="generate_question",
|
||||||
|
model="gpt-4",
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=200,
|
||||||
|
latency_ms=1500
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tenant_id: int,
|
||||||
|
app_code: str,
|
||||||
|
platform_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
local_only: bool = True
|
||||||
|
):
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.app_code = app_code
|
||||||
|
self.platform_url = platform_url or os.getenv("PLATFORM_URL", "")
|
||||||
|
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
|
||||||
|
self.local_only = local_only or not self.platform_url
|
||||||
|
|
||||||
|
# 批量上报缓冲区
|
||||||
|
self._buffer: List[AICallEvent] = []
|
||||||
|
self._buffer_size = 10 # 达到此数量时自动上报
|
||||||
|
|
||||||
|
def report_ai_call(
|
||||||
|
self,
|
||||||
|
module_code: str,
|
||||||
|
prompt_name: str,
|
||||||
|
model: str,
|
||||||
|
input_tokens: int = 0,
|
||||||
|
output_tokens: int = 0,
|
||||||
|
cost: Decimal = Decimal("0"),
|
||||||
|
latency_ms: int = 0,
|
||||||
|
status: str = "success",
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
flush: bool = False
|
||||||
|
) -> AICallEvent:
|
||||||
|
"""上报AI调用事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: 模块编码
|
||||||
|
prompt_name: Prompt名称
|
||||||
|
model: 模型名称
|
||||||
|
input_tokens: 输入token数
|
||||||
|
output_tokens: 输出token数
|
||||||
|
cost: 成本
|
||||||
|
latency_ms: 延迟毫秒
|
||||||
|
status: 状态 (success/error)
|
||||||
|
user_id: 用户ID(可选,默认从上下文获取)
|
||||||
|
flush: 是否立即发送
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的事件对象
|
||||||
|
"""
|
||||||
|
event = AICallEvent(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
app_code=self.app_code,
|
||||||
|
module_code=module_code,
|
||||||
|
prompt_name=prompt_name,
|
||||||
|
model=model,
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
cost=cost,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
status=status,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self._buffer.append(event)
|
||||||
|
|
||||||
|
if flush or len(self._buffer) >= self._buffer_size:
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""发送缓冲区中的所有事件"""
|
||||||
|
if not self._buffer:
|
||||||
|
return
|
||||||
|
|
||||||
|
events = self._buffer.copy()
|
||||||
|
self._buffer.clear()
|
||||||
|
|
||||||
|
if self.local_only:
|
||||||
|
# 本地模式:仅打印
|
||||||
|
for event in events:
|
||||||
|
print(f"[STATS] {event.app_code}/{event.module_code}: "
|
||||||
|
f"{event.prompt_name} - {event.input_tokens}+{event.output_tokens} tokens")
|
||||||
|
else:
|
||||||
|
# 远程上报
|
||||||
|
self._send_to_platform(events)
|
||||||
|
|
||||||
|
def _send_to_platform(self, events: List[AICallEvent]):
|
||||||
|
"""发送事件到平台(异步,后续实现)"""
|
||||||
|
# TODO: 使用httpx异步发送
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""析构时发送剩余事件"""
|
||||||
|
if self._buffer:
|
||||||
|
self.flush()
|
||||||
79
sdk/trace.py
Normal file
79
sdk/trace.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""链路追踪模块"""
|
||||||
|
import uuid
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# 上下文变量存储trace_id
|
||||||
|
_trace_id_var: ContextVar[Optional[str]] = ContextVar("trace_id", default=None)
|
||||||
|
_tenant_id_var: ContextVar[Optional[int]] = ContextVar("tenant_id", default=None)
|
||||||
|
_user_id_var: ContextVar[Optional[int]] = ContextVar("user_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_trace_id() -> str:
|
||||||
|
"""生成新的trace_id"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def get_trace_id() -> Optional[str]:
|
||||||
|
"""获取当前trace_id"""
|
||||||
|
return _trace_id_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_trace_id(trace_id: str) -> None:
|
||||||
|
"""设置当前trace_id"""
|
||||||
|
_trace_id_var.set(trace_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_id() -> Optional[int]:
|
||||||
|
"""获取当前租户ID"""
|
||||||
|
return _tenant_id_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_tenant_id(tenant_id: int) -> None:
|
||||||
|
"""设置当前租户ID"""
|
||||||
|
_tenant_id_var.set(tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id() -> Optional[int]:
|
||||||
|
"""获取当前用户ID"""
|
||||||
|
return _user_id_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_id(user_id: int) -> None:
|
||||||
|
"""设置当前用户ID"""
|
||||||
|
_user_id_var.set(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TraceContext:
|
||||||
|
"""链路追踪上下文管理器
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
with TraceContext(tenant_id=1, user_id=100) as ctx:
|
||||||
|
print(ctx.trace_id)
|
||||||
|
# 在此上下文中的所有操作都会使用相同的trace_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
trace_id: Optional[str] = None,
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
):
|
||||||
|
self.trace_id = trace_id or generate_trace_id()
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self._tokens = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._tokens.append(_trace_id_var.set(self.trace_id))
|
||||||
|
if self.tenant_id is not None:
|
||||||
|
self._tokens.append(_tenant_id_var.set(self.tenant_id))
|
||||||
|
if self.user_id is not None:
|
||||||
|
self._tokens.append(_user_id_var.set(self.user_id))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
for token in self._tokens:
|
||||||
|
# 重置为之前的值
|
||||||
|
pass
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user