From daa8125c58369b0535e45472216392d412f34234 Mon Sep 17 00:00:00 2001 From: 111 Date: Fri, 23 Jan 2026 14:32:09 +0800 Subject: [PATCH] Initial commit: 000-platform project skeleton --- .drone.yml | 69 ++++++++++++++ .gitignore | 25 ++++++ backend/app/config.py | 39 ++++++++ backend/app/database.py | 29 ++++++ backend/app/main.py | 38 ++++++++ backend/app/models/__init__.py | 13 +++ backend/app/models/logs.py | 31 +++++++ backend/app/models/stats.py | 41 +++++++++ backend/app/models/tenant.py | 47 ++++++++++ backend/app/routers/__init__.py | 12 +++ backend/app/routers/config.py | 49 ++++++++++ backend/app/routers/health.py | 17 ++++ backend/app/routers/logs.py | 45 ++++++++++ backend/app/routers/stats.py | 45 ++++++++++ backend/app/schemas/__init__.py | 12 +++ backend/app/schemas/config.py | 26 ++++++ backend/app/schemas/logs.py | 46 ++++++++++ backend/app/schemas/stats.py | 42 +++++++++ backend/app/services/__init__.py | 4 + backend/app/services/crypto.py | 37 ++++++++ backend/requirements.txt | 11 +++ deploy/Dockerfile.backend | 16 ++++ deploy/docker-compose.yml | 20 +++++ sdk/README.md | 129 +++++++++++++++++++++++++++ sdk/__init__.py | 46 ++++++++++ sdk/config_reader.py | 105 ++++++++++++++++++++++ sdk/http_client.py | 83 +++++++++++++++++ sdk/logger.py | 125 ++++++++++++++++++++++++++ sdk/middleware.py | 88 ++++++++++++++++++ sdk/stats_client.py | 148 +++++++++++++++++++++++++++++++ sdk/trace.py | 79 +++++++++++++++++ 31 files changed, 1517 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/logs.py create mode 100644 backend/app/models/stats.py create mode 100644 backend/app/models/tenant.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/config.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/routers/logs.py create mode 100644 backend/app/routers/stats.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/config.py create mode 100644 backend/app/schemas/logs.py create mode 100644 backend/app/schemas/stats.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/crypto.py create mode 100644 backend/requirements.txt create mode 100644 deploy/Dockerfile.backend create mode 100644 deploy/docker-compose.yml create mode 100644 sdk/README.md create mode 100644 sdk/__init__.py create mode 100644 sdk/config_reader.py create mode 100644 sdk/http_client.py create mode 100644 sdk/logger.py create mode 100644 sdk/middleware.py create mode 100644 sdk/stats_client.py create mode 100644 sdk/trace.py diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..91205d8 --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd7d3af --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e0a91b4 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..3eb176b --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5d7d958 --- /dev/null +++ b/backend/app/main.py @@ -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" + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e01eeba --- /dev/null +++ b/backend/app/models/__init__.py @@ -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" +] diff --git a/backend/app/models/logs.py b/backend/app/models/logs.py new file mode 100644 index 0000000..850c931 --- /dev/null +++ b/backend/app/models/logs.py @@ -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) diff --git a/backend/app/models/stats.py b/backend/app/models/stats.py new file mode 100644 index 0000000..d77006b --- /dev/null +++ b/backend/app/models/stats.py @@ -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) diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..1b2c03c --- /dev/null +++ b/backend/app/models/tenant.py @@ -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) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..beed845 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -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" +] diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py new file mode 100644 index 0000000..6e10f55 --- /dev/null +++ b/backend/app/routers/config.py @@ -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 + ) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..c865291 --- /dev/null +++ b/backend/app/routers/health.py @@ -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 + } diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..cd458b2 --- /dev/null +++ b/backend/app/routers/logs.py @@ -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)} diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..f9b377d --- /dev/null +++ b/backend/app/routers/stats.py @@ -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)} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..ea2b0a3 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,12 @@ +"""请求/响应模型""" +from .stats import AICallEventCreate, AICallEventResponse +from .logs import LogCreate, LogResponse +from .config import ConfigRead + +__all__ = [ + "AICallEventCreate", + "AICallEventResponse", + "LogCreate", + "LogResponse", + "ConfigRead" +] diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100644 index 0000000..8a29adf --- /dev/null +++ b/backend/app/schemas/config.py @@ -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 diff --git a/backend/app/schemas/logs.py b/backend/app/schemas/logs.py new file mode 100644 index 0000000..217a50a --- /dev/null +++ b/backend/app/schemas/logs.py @@ -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] diff --git a/backend/app/schemas/stats.py b/backend/app/schemas/stats.py new file mode 100644 index 0000000..6af44d1 --- /dev/null +++ b/backend/app/schemas/stats.py @@ -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] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..60261bc --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,4 @@ +"""业务服务""" +from .crypto import encrypt_value, decrypt_value + +__all__ = ["encrypt_value", "decrypt_value"] diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py new file mode 100644 index 0000000..3f2144d --- /dev/null +++ b/backend/app/services/crypto.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8e350f6 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/deploy/Dockerfile.backend b/deploy/Dockerfile.backend new file mode 100644 index 0000000..3316429 --- /dev/null +++ b/deploy/Dockerfile.backend @@ -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"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..825d940 --- /dev/null +++ b/deploy/docker-compose.yml @@ -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 diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..953ad59 --- /dev/null +++ b/sdk/README.md @@ -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中间件 +- 支持配置读取 diff --git a/sdk/__init__.py b/sdk/__init__.py new file mode 100644 index 0000000..2052dbb --- /dev/null +++ b/sdk/__init__.py @@ -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", +] diff --git a/sdk/config_reader.py b/sdk/config_reader.py new file mode 100644 index 0000000..0f52617 --- /dev/null +++ b/sdk/config_reader.py @@ -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() diff --git a/sdk/http_client.py b/sdk/http_client.py new file mode 100644 index 0000000..c664ebc --- /dev/null +++ b/sdk/http_client.py @@ -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) diff --git a/sdk/logger.py b/sdk/logger.py new file mode 100644 index 0000000..e09c0e0 --- /dev/null +++ b/sdk/logger.py @@ -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) diff --git a/sdk/middleware.py b/sdk/middleware.py new file mode 100644 index 0000000..92fe361 --- /dev/null +++ b/sdk/middleware.py @@ -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 diff --git a/sdk/stats_client.py b/sdk/stats_client.py new file mode 100644 index 0000000..1812650 --- /dev/null +++ b/sdk/stats_client.py @@ -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() diff --git a/sdk/trace.py b/sdk/trace.py new file mode 100644 index 0000000..61cd429 --- /dev/null +++ b/sdk/trace.py @@ -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