chore: initial commit — leaudit-platform project skeleton
17-table PostgreSQL schema with full Chinese column comments, FastAPI project structure (admin/common/modules), DSL rule files, and schema migration scripts.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""fastapi_common —— 共享框架层。"""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""日志模块 —— 通道由调用栈自动推断。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def _infer_channel() -> str:
|
||||
"""根据调用栈自动推断日志通道。"""
|
||||
frame = sys._getframe(2)
|
||||
filename = frame.f_code.co_filename
|
||||
if "/controllers/" in filename:
|
||||
return "CONTROLLER"
|
||||
if "/services/" in filename:
|
||||
return "SERVICE"
|
||||
if "/models/" in filename:
|
||||
return "MODEL"
|
||||
if "/leaudit_bridge/" in filename:
|
||||
return "BRIDGE"
|
||||
if "/handler/" in filename:
|
||||
return "HANDLER"
|
||||
if "/tasks/" in filename:
|
||||
return "TASK"
|
||||
if "/middleware/" in filename:
|
||||
return "MIDDLEWARE"
|
||||
if "postgrest" in filename:
|
||||
return "POSTGREST"
|
||||
if "uvicorn" in filename:
|
||||
return "UVICORN"
|
||||
return "APP"
|
||||
|
||||
|
||||
class _ChannelLogger:
|
||||
"""代理 logger,自动推断通道。"""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
channel = _infer_channel()
|
||||
logger = logging.getLogger(channel)
|
||||
return getattr(logger, name)
|
||||
|
||||
|
||||
logger: object = _ChannelLogger() # type: ignore[assignment]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""JWT 服务 —— Token 签发、验证、刷新、撤销。
|
||||
|
||||
合并自旧项目的 auth.py(create_jwt_token / decode_jwt_token)和
|
||||
jwt_manager.py(generate_tokens / verify_token / refresh_token / revoke_token)。
|
||||
逻辑不变,仅重组为服务类 + 使用项目统一配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from fastapi_common.fastapi_common_logger import logger
|
||||
from fastapi_admin.config import JWT_SECRET_KEY, JWT_ALGORITHM
|
||||
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
JWT_AUDIENCE = "leaudit-platform"
|
||||
JWT_ISSUER = "leaudit-platform"
|
||||
|
||||
|
||||
class JwtService:
|
||||
"""JWT Token 管理服务。"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token 生成
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def generate(
|
||||
userId: int,
|
||||
username: str,
|
||||
nickName: str = "",
|
||||
ouId: str = "",
|
||||
ouName: str = "",
|
||||
roles: list[str] | None = None,
|
||||
area: str | None = None,
|
||||
userRole: str | None = None,
|
||||
deviceId: str | None = None,
|
||||
deviceName: str | None = None,
|
||||
userAgent: str | None = None,
|
||||
ipAddress: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""生成 Access Token + Refresh Token。
|
||||
|
||||
返回格式与旧项目 auth.py 完全一致:
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900
|
||||
}
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
jti = str(uuid.uuid4())
|
||||
|
||||
# Access Token
|
||||
accessPayload = {
|
||||
"jti": jti,
|
||||
"user_id": userId,
|
||||
"username": username,
|
||||
"nick_name": nickName,
|
||||
"ou_id": ouId,
|
||||
"ou_name": ouName,
|
||||
"roles": roles or [],
|
||||
"area": area,
|
||||
"user_role": userRole,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"aud": JWT_AUDIENCE,
|
||||
"iss": JWT_ISSUER,
|
||||
"type": "access",
|
||||
}
|
||||
accessToken = jwt.encode(accessPayload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
# Refresh Token
|
||||
refreshJti = str(uuid.uuid4())
|
||||
refreshPayload = {
|
||||
"jti": refreshJti,
|
||||
"user_id": userId,
|
||||
"username": username,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
"aud": JWT_AUDIENCE,
|
||||
"iss": JWT_ISSUER,
|
||||
"type": "refresh",
|
||||
}
|
||||
refreshToken = jwt.encode(refreshPayload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
# Token 签发时间(格式化字符串,供前端展示)
|
||||
issuedTime = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
logger.info(f"JWT issued: user={userId}, jti={jti}")
|
||||
|
||||
return {
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
"issued_time": issuedTime,
|
||||
"jti": jti,
|
||||
"refresh_jti": refreshJti,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token 验证
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def verify(token: str) -> dict[str, Any]:
|
||||
"""验证并解码 JWT Token。
|
||||
|
||||
Raises:
|
||||
jwt.ExpiredSignatureError: Token 过期
|
||||
jwt.InvalidTokenError: Token 无效
|
||||
"""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET_KEY,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
audience=JWT_AUDIENCE,
|
||||
issuer=JWT_ISSUER,
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def decodeUnsafe(token: str) -> dict[str, Any] | None:
|
||||
"""不验证签名解码 Token(仅用于调试或获取过期 Token 信息)。"""
|
||||
try:
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token 撤销
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _hashToken(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def getJti(token: str) -> str | None:
|
||||
"""从 Token 中提取 JTI(不验证签名)。"""
|
||||
payload = JwtService.decodeUnsafe(token)
|
||||
return payload.get("jti") if payload else None
|
||||
@@ -0,0 +1,23 @@
|
||||
"""JWT 鉴权工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
|
||||
from fastapi_admin.config import JWT_SECRET_KEY, JWT_ALGORITHM
|
||||
|
||||
|
||||
def verify_access_token(RequestObj: Request) -> dict[str, Any]:
|
||||
"""验证 JWT access token 并返回 payload。"""
|
||||
auth = RequestObj.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return {}
|
||||
token = auth.removeprefix("Bearer ").strip()
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.PyJWTError:
|
||||
return {}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""SQLAlchemy 声明式基类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy 声明式基类。"""
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
"""SQLAlchemy 异步引擎和 session 工厂。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from fastapi_admin.config import ASYNCPG_DATABASE_URL
|
||||
|
||||
_engine = create_async_engine(ASYNCPG_DATABASE_URL, echo=False, pool_size=20, max_overflow=10)
|
||||
|
||||
_AsyncSessionFactory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def GetAsyncSession():
|
||||
"""获取异步数据库 session(上下文管理器)。"""
|
||||
async with _AsyncSessionFactory() as session:
|
||||
yield session
|
||||
await session.commit()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""BaseController —— 所有控制器继承此类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
class BaseController:
|
||||
"""控制器基类。
|
||||
|
||||
子类在 __init__ 中用 @self.router.get/post 注册路由。
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str = "", tags: list[str] | None = None):
|
||||
self.router = APIRouter(prefix=prefix, tags=tags or [])
|
||||
self.Init()
|
||||
|
||||
def Init(self) -> None:
|
||||
"""子类重写此方法注册路由。"""
|
||||
pass
|
||||
@@ -0,0 +1,57 @@
|
||||
"""统一响应格式。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StatusCodeEnum(Enum):
|
||||
"""HTTP 状态码枚举。"""
|
||||
|
||||
HTTP_200_OK = 200
|
||||
HTTP_201_CREATED = 201
|
||||
HTTP_400_BAD_REQUEST = 400
|
||||
HTTP_401_UNAUTHORIZED = 401
|
||||
HTTP_403_FORBIDDEN = 403
|
||||
HTTP_404_NOT_FOUND = 404
|
||||
HTTP_409_CONFLICT = 409
|
||||
HTTP_422_UNPROCESSABLE_ENTITY = 422
|
||||
HTTP_500_INTERNAL_SERVER_ERROR = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginationInfo:
|
||||
"""分页信息。"""
|
||||
|
||||
total: int
|
||||
page: int
|
||||
pageSize: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageResult(Generic[T]):
|
||||
"""分页结果。"""
|
||||
|
||||
list: list[T]
|
||||
pagination: PaginationInfo
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result(Generic[T]):
|
||||
"""统一响应。"""
|
||||
|
||||
code: int
|
||||
message: str
|
||||
data: T | None = None
|
||||
|
||||
@classmethod
|
||||
def success(cls, data: T | None = None, message: str = "ok") -> "Result[T]":
|
||||
return cls(code=200, message=message, data=data)
|
||||
|
||||
@classmethod
|
||||
def error(cls, status: StatusCodeEnum, message: str | None = None) -> "Result[None]":
|
||||
return cls(code=status.value, message=message or status.name, data=None)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""业务异常基类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
|
||||
|
||||
|
||||
class BusinessException(Exception):
|
||||
"""所有业务异常继承此类。"""
|
||||
|
||||
def __init__(self, status: StatusCodeEnum, message: str):
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""LeAudit 域异常。"""
|
||||
|
||||
from fastapi_common.fastapi_common_web.exception.Base.BusinessException import BusinessException
|
||||
|
||||
|
||||
class LeauditException(BusinessException):
|
||||
"""LeAudit 模块异常。"""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""BaseModel —— 所有业务模型的抽象基类。
|
||||
|
||||
自动提供三个公共时间字段:
|
||||
- create_time:INSERT 时由数据库写入当前时间
|
||||
- update_time:INSERT 和 UPDATE 时自动更新
|
||||
- delete_time:默认 NULL,非 NULL 表示已软删除
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.base import Base
|
||||
|
||||
|
||||
class BaseModel(Base):
|
||||
"""所有业务模型的抽象基类。"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间"
|
||||
)
|
||||
delete_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), default=None, comment="软删除时间"
|
||||
)
|
||||
Reference in New Issue
Block a user