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

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