v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成

- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
View File
+26
View File
@@ -0,0 +1,26 @@
"""
Action Card 队列 —— Dify 工具端点 → SSE 生成器
使用全局队列(不区分用户),因为 Dify 工具调用发生在 SSE 流的生命周期内。
"""
from __future__ import annotations
import asyncio
from typing import Any
_pending_cards: list[dict[str, Any]] = []
_lock = asyncio.Lock()
async def push_card(card_data: dict[str, Any]) -> None:
"""工具端点调用:推入一个 action_card"""
async with _lock:
_pending_cards.append(card_data)
print(f"[ActionCardQueue] pushed card: {card_data.get('card_type', 'unknown')}")
async def pop_cards() -> list[dict[str, Any]]:
"""SSE 生成器调用:取出并清空所有 pending 的 action_card"""
async with _lock:
cards = list(_pending_cards)
_pending_cards.clear()
return cards
+43
View File
@@ -0,0 +1,43 @@
"""
应用全局配置 - 从 .env 读取环境变量
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/crm_erp"
# JWT
JWT_SECRET_KEY: str = "super-secret-key-change-in-production-please"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24h
# App
APP_NAME: str = "润滑油CRM/ERP系统"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# Dify AI 中枢配置
DIFY_API_BASE_URL: str = "" # 例如 http://192.168.1.88
DIFY_API_KEY: str = ""
DIFY_APP_ID: str = ""
DIFY_TIMEOUT_MS: int = 30000 # 30s
DIFY_WORKFLOW_PERSONA_KEY: str = "" # 销售日志→客户画像 Workflow
DIFY_WORKFLOW_REPORT_KEY: str = "" # 周报自动生成 Workflow
# Ollama / vLLM 算力节点
OLLAMA_4060_BASE_URL: str = "http://192.168.1.88:11435" # 4060: Qwen3.5-4B + BGE-M3
OLLAMA_4060_MODEL: str = "qwen3.5:4b" # 意图分类用
OLLAMA_3090_BASE_URL: str = "http://192.168.1.88:11434" # 3090: Qwen3.5-27B (with vision)
OLLAMA_3090_MODEL: str = "qwen3.5:27b" # OCR / 视觉理解用
settings = Settings()
+78
View File
@@ -0,0 +1,78 @@
"""
自定义异常 + 全局异常处理器
统一输出格式: { "code": int, "data": Any, "message": str }
"""
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
# ── 自定义业务异常 ─────────────────────────────────────────
class BizException(Exception):
"""通用业务异常,code 对应 HTTP 状态码"""
def __init__(self, code: int = 400, message: str = "业务异常", data: Any = None):
self.code = code
self.message = message
self.data = data
class UnauthorizedException(BizException):
def __init__(self, message: str = "未登录或 Token 已失效"):
super().__init__(code=401, message=message)
class ForbiddenException(BizException):
def __init__(self, message: str = "无权访问"):
super().__init__(code=403, message=message)
class NotFoundException(BizException):
def __init__(self, message: str = "资源不存在"):
super().__init__(code=404, message=message)
# ── 统一响应构造 ──────────────────────────────────────────
def _make_response(code: int, message: str, data: Any = None) -> JSONResponse:
return JSONResponse(
status_code=code,
content={"code": code, "data": data, "message": message},
)
# ── 注册全局异常处理器 ────────────────────────────────────
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(BizException)
async def biz_exception_handler(_req: Request, exc: BizException) -> JSONResponse:
return _make_response(exc.code, exc.message, exc.data)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
_req: Request, exc: StarletteHTTPException
) -> JSONResponse:
return _make_response(exc.status_code, str(exc.detail))
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
_req: Request, exc: RequestValidationError
) -> JSONResponse:
# 把 Pydantic 校验错误打平为可读字符串
errors = []
for e in exc.errors():
loc = " -> ".join(str(x) for x in e.get("loc", []))
errors.append(f"{loc}: {e.get('msg', '')}")
return _make_response(422, "请求参数校验失败", errors)
@app.exception_handler(Exception)
async def global_exception_handler(
_req: Request, exc: Exception
) -> JSONResponse:
# 兜底:未知异常统一 500
return _make_response(500, f"服务器内部错误: {exc!s}")
+48
View File
@@ -0,0 +1,48 @@
"""
JWT 签发与校验 + 密码哈希工具
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
# ── 密码哈希 ──────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT Token ─────────────────────────────────────────────
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM,
)
def decode_access_token(token: str) -> dict | None:
"""解析 JWT,失败返回 None"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None