Initial commit: 000-platform project skeleton
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
111
2026-01-23 14:32:09 +08:00
commit daa8125c58
31 changed files with 1517 additions and 0 deletions

69
.drone.yml Normal file
View 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
View 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
View 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
View 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
View 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"
}

View 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"
]

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

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

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

View 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"
]

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

View 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
}

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

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

View 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"
]

View 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

View 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]

View 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]

View File

@@ -0,0 +1,4 @@
"""业务服务"""
from .crypto import encrypt_value, decrypt_value
__all__ = ["encrypt_value", "decrypt_value"]

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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