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