chore: 初始化项目结构与登录鉴权模块

后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-09 18:46:36 +08:00
commit 273ebf4657
101 files changed
+8725

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。"""
+160
View File
@@ -0,0 +1,160 @@
"""图形验证码渲染。
**为什么自绘而不用 captcha 库的默认渲染**:
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
在 Linux 容器里找不到系统字体的经典问题。
"""
import base64
import io
import math
import random
import secrets
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
_WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25
_PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
_INK = (15, 54, 70)
_INTERFERENCE = (126, 156, 170)
_SPECKLE = (168, 183, 192)
_PAPER = (247, 246, 242)
_SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
_FONT_CANDIDATES = (
"C:/Windows/Fonts/consola.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
)
def _font_path() -> Path | None:
"""定位可用的等宽字体文件。"""
import captcha
package_file = getattr(captcha, "__file__", None)
if package_file:
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
if bundled.is_file():
return bundled
for candidate in _FONT_CANDIDATES:
path = Path(candidate)
if path.is_file():
return path
return None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
path = _font_path()
if path is None:
return ImageFont.load_default(size)
return ImageFont.truetype(str(path), size)
def generate_text() -> str:
"""生成验证码文本。
用 `secrets` 而非 `random`:验证码是安全凭据,
`random` 的 Mersenne Twister 状态可被观测样本反推。
"""
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
"""撒噪点。
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
既显眼又容易被整体滤波抹掉,起不到干扰作用。
"""
for _ in range(count):
x = random.randint(0, _WIDTH - 2)
y = random.randint(0, _HEIGHT - 2)
if random.random() < 0.75:
draw.point((x, y), fill=_SPECKLE)
else:
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
"""绘制干扰曲线。
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
"""
for _ in range(_INTERFERENCE_LINES):
amplitude = random.uniform(5.0, 11.0)
phase = random.uniform(0, math.tau)
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
points = [
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
]
draw.line(points, fill=_INTERFERENCE, width=1)
def _draw_characters(image: Image.Image, text: str) -> None:
"""逐字绘制。
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
再叠加 ±2px 的垂直抖动,进一步打散基线。
"""
font = _load_font(_FONT_SIZE)
cell = (_WIDTH - 2 * _PADDING) / len(text)
for index, char in enumerate(text):
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
ImageDraw.Draw(glyph).text(
(8, 3),
char,
font=font,
fill=(*_INK, 255),
# 加 1 像素描边:等宽细体在小字号下笔画过细,
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
# 但相对于"用户反复输错"的代价是划算的。
stroke_width=1,
stroke_fill=(*_INK, 255),
)
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
def render_png_base64(text: str) -> str:
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
不含前缀是为了让前端自行拼接;服务端返回纯数据,
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
"""
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
draw = ImageDraw.Draw(image)
_draw_speckles(draw, _SPECKLE_COUNT // 2)
_draw_interference(draw)
_draw_characters(image, text)
# 字符之上再撒一层,干扰基于连通域的分割
_draw_speckles(draw, _SPECKLE_COUNT // 2)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
+103
View File
@@ -0,0 +1,103 @@
"""应用配置。
从 `backend/.env` 读取配置项,字段名与 `.env` 中的变量名一一对应
(pydantic-settings 默认大小写不敏感,`app_secret_key` ←→ `APP_SECRET_KEY`)。
设计取舍:
- `APP_SECRET_KEY` 无默认值 —— 缺失时启动即失败。空密钥签发 JWT 属安全问题
而非可用性问题,必须 fail-fast,不能容忍"后端起得来但令牌可被伪造"。
- 数据库/Redis 连接不在此处建立 —— 依赖不可用时仍应能启动应用、查看接口文档。
"""
from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
# backend/ 目录:本文件位于 backend/app/core/config.py,向上三级
BACKEND_DIR = Path(__file__).resolve().parents[2]
ENV_FILE = BACKEND_DIR / ".env"
class Settings(BaseSettings):
"""应用配置项。"""
model_config = SettingsConfigDict(
env_file=str(ENV_FILE),
# Windows 默认编码为 cp936,不显式指定会导致中文值解析出错
env_file_encoding="utf-8",
case_sensitive=False,
# .env 中可能含本服务用不到的变量(如 docker 编排专用项),忽略而非报错
extra="ignore",
)
# ===== 应用 =====
app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
# ===== 鉴权 =====
# 规范第 13 节:Token 有效期 ≤ 2 小时
jwt_expire_minutes: int = Field(default=120, ge=1, le=120)
# 规范第 6 节:验证码 300s
captcha_expire_seconds: int = Field(default=300, ge=60, le=300)
login_fail_limit: int = Field(default=5, ge=1)
login_lock_minutes: int = Field(default=15, ge=1)
# ===== MySQL =====
mysql_host: str = "127.0.0.1"
mysql_port: int = 3306
mysql_database: str = "payroll"
mysql_user: str = "payroll"
mysql_password: str = ""
# ===== Redis =====
redis_host: str = "127.0.0.1"
redis_port: int = 6379
redis_password: str = ""
redis_db: int = 0
@field_validator("app_secret_key")
@classmethod
def _check_secret_key(cls, v: str) -> str:
"""校验密钥强度:过短的密钥可被暴力破解,直接拒绝启动。"""
if len(v) < 32:
raise ValueError("APP_SECRET_KEY 长度不足 32 字符,请在 backend/.env 中重新生成")
return v
@property
def database_url(self) -> URL:
"""数据库连接 URL。运行期与 Alembic 迁移共用同一份。
用 SQLAlchemy 的 URL.create 而非手工拼接字符串 —— 密码含 @ : / 等
特殊字符时手工拼接会产生语法错误。
驱动为同步的 pymysql:本项目并发极低,异步无收益;而绩效核算与
pandas 报表都是同步阻塞代码,同步方案不会与之冲突。
"""
return URL.create(
drivername="mysql+pymysql",
username=self.mysql_user,
password=self.mysql_password,
host=self.mysql_host,
port=self.mysql_port,
database=self.mysql_database,
query={"charset": "utf8mb4"},
)
@property
def redis_url(self) -> str:
"""Redis 连接 URL。密码为空时不拼接认证段。"""
auth = f":{self.redis_password}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@lru_cache
def get_settings() -> Settings:
"""获取配置单例。
进程内只解析一次 .env;测试中如需覆盖,调用方应使用
`get_settings.cache_clear()` 配合环境变量注入。
"""
return Settings() # type: ignore[call-arg]
+38
View File
@@ -0,0 +1,38 @@
"""全局常量。
集中定义 Redis 键模板、响应码、角色编码等跨模块共享的字面量,
避免各处硬编码字符串导致拼写漂移。
"""
# ===== 响应码(与 HTTP 状态码保持一致,见 PROJECT_STANDARDS 第 9 节)=====
CODE_SUCCESS = 200
CODE_BAD_REQUEST = 400
CODE_UNAUTHORIZED = 401
CODE_FORBIDDEN = 403
CODE_NOT_FOUND = 404
CODE_SERVER_ERROR = 500
# ===== Redis 键模板 =====
# 规范第 6 节:键格式 {项目缩写}:{业务}:{标识},项目缩写为 ys
REDIS_KEY_CAPTCHA = "ys:captcha:{captcha_id}"
REDIS_KEY_LOGIN_FAIL = "ys:login:fail:{username}"
REDIS_KEY_TOKEN_BLACKLIST = "ys:token:blacklist:{jti}"
# ===== JWT =====
JWT_ALGORITHM = "HS256"
TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin"
ROLE_USER = "user"
# ===== 密码策略 =====
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
# 而中文一个字符占 3 字节,因此必须按字节数校验,按字符数校验是错的。
PASSWORD_MIN_BYTES = 8
PASSWORD_MAX_BYTES = 72
# ===== 验证码 =====
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
+64
View File
@@ -0,0 +1,64 @@
"""数据库连接管理。
**为什么用同步引擎**:本项目并发极低(内部系统,营销人员数十人),
异步拿不到吞吐收益;而重活——绩效批量核算、pandas 报表、Excel 导出——
全是同步阻塞代码,放在异步路由里会卡死事件循环,且这类问题在开发期
低并发下几乎不暴露。改用同步后,路由写 `def`,FastAPI 会**自动放入
线程池执行**,天然规避上述陷阱,也不会阻塞事件循环。
引擎采用**延迟创建**:应用启动时不连接 MySQL,
这样 Docker 未启动时仍能运行服务、查看接口文档。
"""
from collections.abc import Generator
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def get_engine() -> Engine:
"""获取同步引擎单例(首次调用时创建)。"""
global _engine, _session_factory
if _engine is None:
settings = get_settings()
_engine = create_engine(
settings.database_url,
# 连接池中的连接在使用前先探活,避免 MySQL 重启后取到死连接
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
_session_factory = sessionmaker(_engine, expire_on_commit=False)
return _engine
def get_session_factory() -> sessionmaker[Session]:
"""获取会话工厂单例。"""
get_engine()
assert _session_factory is not None # get_engine 已确保初始化
return _session_factory
def get_db() -> Generator[Session, None, None]:
"""FastAPI 依赖:提供一个数据库会话。
只负责会话的创建与关闭,**事务边界由 service 层控制** ——
这样一次请求内的多个仓储调用可以共处同一事务。
"""
factory = get_session_factory()
with factory() as session:
yield session
def dispose_engine() -> None:
"""关闭连接池。应用退出时调用。"""
global _engine, _session_factory
if _engine is not None:
_engine.dispose()
_engine = None
_session_factory = None
+80
View File
@@ -0,0 +1,80 @@
"""全局异常处理器。
把各类异常统一转换为 `{code, message, data}` 响应,
避免 FastAPI 默认的 422 `detail` 数组结构泄漏到前端。
注意:参数校验失败按规范映射为 **400**(FastAPI 默认是 422),
因为规范第 9 节只定义了 400/401/403/404/500 五个状态码。
"""
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
from app.core.exceptions import AppException
from app.core.response import ApiResponse
logger = logging.getLogger(__name__)
def _build_response(code: int, message: str) -> JSONResponse:
"""按统一格式构造 JSON 响应。"""
body = ApiResponse[None](code=code, message=message, data=None)
return JSONResponse(status_code=code, content=body.model_dump())
def _format_validation_errors(exc: RequestValidationError) -> str:
"""把 pydantic 的校验错误压缩成一行中文提示。
只取首条错误的字段名与说明,避免把完整的错误数组抛给前端。
"""
errors = exc.errors()
if not errors:
return "参数错误"
first = errors[0]
# loc 形如 ("body", "username"),去掉来源前缀只留字段名
location = ".".join(
str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path")
)
detail = first.get("msg", "参数错误")
return f"参数错误:{location} {detail}" if location else f"参数错误:{detail}"
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
"""处理业务异常,按其 code 返回。"""
return _build_response(exc.code, exc.message)
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
"""处理请求参数校验失败,映射为 400。"""
return _build_response(CODE_BAD_REQUEST, _format_validation_errors(exc))
async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""处理框架级 HTTP 异常(404 路由不存在、405 方法不允许等)。"""
message = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
return _build_response(exc.status_code, message)
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
"""
logger.exception("未预期的服务端异常")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
def register_exception_handlers(app: FastAPI) -> None:
"""注册全部全局异常处理器。
注册顺序不影响匹配 —— FastAPI 按异常类型精确查找,越具体的类型优先。
"""
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)
+54
View File
@@ -0,0 +1,54 @@
"""业务异常定义。
`code` 直接作为 HTTP 状态码返回,使前端拦截器既能读 HTTP 状态也能读响应体的 code。
重要约定:**401 只表示"受保护接口的鉴权失败"**,登录接口自身的失败一律用 400/403。
否则前端"401 即跳登录页"的拦截器会在用户输错密码时触发跳转,导致登录页反复重挂载。
"""
from app.core.constants import (
CODE_BAD_REQUEST,
CODE_FORBIDDEN,
CODE_NOT_FOUND,
CODE_SERVER_ERROR,
CODE_UNAUTHORIZED,
)
class AppException(Exception):
"""业务异常基类。业务逻辑中主动抛出,由全局处理器转换为统一响应。"""
code: int = CODE_SERVER_ERROR
message: str = "服务端异常"
def __init__(self, message: str | None = None) -> None:
self.message = message or self.message
super().__init__(self.message)
class BadRequestError(AppException):
"""参数错误或业务规则校验不通过。"""
code = CODE_BAD_REQUEST
message = "参数错误"
class AuthError(AppException):
"""未认证:令牌缺失、无效、过期或已登出。"""
code = CODE_UNAUTHORIZED
message = "登录状态已失效,请重新登录"
class ForbiddenError(AppException):
"""已认证但无权限,或账号被禁用/锁定。"""
code = CODE_FORBIDDEN
message = "无权限"
class NotFoundError(AppException):
"""请求的资源不存在。"""
code = CODE_NOT_FOUND
message = "资源不存在"
+94
View File
@@ -0,0 +1,94 @@
"""日志配置。
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
保留 30 天、禁止输出密码/手机号/身份证/Token。
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
否则中文日志会变成乱码。
"""
import logging
import re
import sys
from logging.handlers import TimedRotatingFileHandler
from app.core.config import BACKEND_DIR
LOG_DIR = BACKEND_DIR / "logs"
# 文本脱敏规则。日志是最容易泄漏敏感信息的地方,且多半来自异常堆栈里的
# 请求体字符串,无法靠"调用方注意"来保证,所以在这里统一兜底。
_SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
# 含 password / passwd / pwd / secret / token 的键名,或 app_key / access_key 之类的短名
(
re.compile(
r'((?:\w*(?:password|passwd|pwd|secret|token)\w*|app_?key|access_?key)["\']?\s*[:=]\s*["\']?)([^"\'\s,}&]+)',
re.IGNORECASE,
),
r"\1***",
),
# Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
# 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
)
def scrub(text: str) -> str:
"""对文本按规则脱敏。导出供单测直接验证。"""
for pattern, replacement in _SENSITIVE_RULES:
text = pattern.sub(replacement, text)
return text
class SensitiveFormatter(logging.Formatter):
"""脱敏格式化器。
脱敏必须在父类完成 %-格式化**之后**进行,而不是在 filter 阶段改写 ``record.msg``。
后者会把 ``"登录失败 password=%s"`` 里的占位符一并抹成 ``password=***``,
随后 logging 内部执行 ``msg % args`` 时因参数无处安放抛 TypeError,
导致整条日志丢失 —— 这比不脱敏更危险。
"""
def format(self, record: logging.LogRecord) -> str:
return scrub(super().format(record))
def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
"""初始化根日志配置。
重复调用是安全的:先清空已有 handler,避免 uvicorn --reload
或测试中重复挂载导致日志重复输出。
:param level: 日志级别
:param log_to_file: 是否写文件;测试环境可关闭以避免产生日志文件
"""
root = logging.getLogger()
root.setLevel(level)
for handler in list(root.handlers):
root.removeHandler(handler)
formatter = SensitiveFormatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 控制台:Windows 终端默认 cp936,中文日志可能报编码错误,显式重配为 utf-8
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
console = logging.StreamHandler(sys.stdout)
console.setFormatter(formatter)
root.addHandler(console)
if log_to_file:
LOG_DIR.mkdir(parents=True, exist_ok=True)
file_handler = TimedRotatingFileHandler(
filename=LOG_DIR / "app.log",
when="midnight",
backupCount=30, # 规范要求保留 30 天
encoding="utf-8",
)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
+41
View File
@@ -0,0 +1,41 @@
"""Redis 连接管理。
承担三类职责:图形验证码、登录失败限流、JWT 登出黑名单。
键名模板统一在 `app.core.constants` 中定义,禁止在业务代码里拼接字符串。
与其他基础设施一致采用**同步客户端**:路由写 `def` 由 FastAPI 放入线程池执行。
若 DB 同步而 Redis 异步,同一请求里就会出现"要么路由必须写 async(于是同步 DB
阻塞事件循环)、要么把 Redis 调用包进 run_in_threadpool"的两难,两头都别扭。
"""
from redis import Redis
from app.core.config import get_settings
_client: Redis | None = None
def get_redis() -> Redis:
"""获取 Redis 客户端单例(首次调用时创建)。
延迟创建:Redis 未启动时不影响应用启动。
返回的是**同步**客户端,直接调用即可,不要 await。
"""
global _client
if _client is None:
settings = get_settings()
_client = Redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=3,
)
return _client
def close_redis() -> None:
"""关闭连接池。应用退出时调用。"""
global _client
if _client is not None:
_client.close()
_client = None
+62
View File
@@ -0,0 +1,62 @@
"""统一响应模型。
所有接口响应体形如 `{code, message, data}`(PROJECT_STANDARDS 第 3 节)。
分页响应字段为 `list/total/pageNum/pageSize`(同规范第 14 节)。
字段名采用驼峰(规范约定)而 Python 属性用蛇形(语言惯例),
两者通过 pydantic 的 alias 映射,序列化时输出驼峰。
"""
from pydantic import BaseModel, ConfigDict, Field
from app.core.constants import CODE_SUCCESS
class ApiResponse[T](BaseModel):
"""统一响应包装。泛型参数为 `data` 的类型。"""
code: int = Field(default=CODE_SUCCESS, description="状态码,与 HTTP 状态码一致")
message: str = Field(default="success", description="提示信息")
data: T | None = Field(default=None, description="业务数据")
def ok[T](data: T | None = None, message: str = "success") -> ApiResponse[T]:
"""构造成功响应。
统一出口便于日后调整成功码或附加字段时只改一处。
"""
return ApiResponse(code=CODE_SUCCESS, message=message, data=data)
class PageResult[T](BaseModel):
"""分页结果。
Python 侧使用蛇形属性名,序列化后输出规范要求的驼峰字段名。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[T] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
class PageQuery(BaseModel):
"""分页查询入参基类。业务查询参数继承本类扩展。"""
model_config = ConfigDict(populate_by_name=True)
page_num: int = Field(default=1, ge=1, alias="pageNum", description="页码,从 1 开始")
page_size: int = Field(default=10, ge=1, le=200, alias="pageSize", description="每页条数")
@property
def offset(self) -> int:
"""转换为 SQL OFFSET。"""
return (self.page_num - 1) * self.page_size
+102
View File
@@ -0,0 +1,102 @@
"""密码哈希与 JWT 签发校验。
**为什么不用 passlib**:passlib 1.7.4 自 2020 年起停止维护,且会读取
bcrypt 4.1 起已删除的 `bcrypt.__about__` 属性(表现为 "(trapped) error
reading bcrypt version"),与 bcrypt 5.x 组合没有任何官方支持。
FastAPI 官方文档亦已改用 pwdlib。
**BCrypt 的 72 字节硬限制**:bcrypt 5.0 起对超过 72 字节的密码**直接抛
ValueError**(此前是静默截断)。中文一个字符占 3 字节,24 个汉字即到顶 ——
因此必须按字节数校验,按字符数校验是错的。
本模块做防御性兜底,调用方(schemas 层)应更早拦截以给出友好提示。
"""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from app.core.config import get_settings
from app.core.constants import JWT_ALGORITHM, PASSWORD_MAX_BYTES
from app.core.exceptions import AuthError
# **显式指定 bcrypt,不用 `PasswordHash.recommended()`**:
# 后者在 pwdlib 0.3.x 推荐的是 Argon2,且会随库版本变化 —— 结果是
# 同一批密码用不同算法哈希、与规范要求的「密码 BCrypt」不符。
# 哈希算法属于会被写入数据、影响长期兼容性的决策,必须钉死。
_password_hasher = PasswordHash((BcryptHasher(),))
def hash_password(plain: str) -> str:
"""计算密码哈希。返回值为 BCrypt 格式,定长 60 字符。
:raises ValueError: 密码超过 72 字节(bcrypt 算法上限)
"""
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限,无法哈希")
return _password_hasher.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""校验密码是否匹配哈希。
超长密码直接判为不匹配而不抛异常 —— 校验路径不应因输入长度而报错,
否则会给攻击者提供区分"密码过长"与"密码错误"的侧信道。
"""
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
return False
try:
return _password_hasher.verify(plain, hashed)
except Exception:
# 哈希串损坏或格式不符时视为校验失败,不向上抛
return False
# ==================== JWT ====================
def create_access_token(
*, user_id: int, token_version: int, roles: list[str]
) -> tuple[str, str, int]:
"""签发访问令牌。
载荷含 `ver`(对应用户的 `token_version`)—— 鉴权时与库中值比对,
使「禁用账号 / 改密 / 强制下线」能立即让已签发令牌失效,**不依赖 Redis**。
:returns: ``(token, jti, 有效期秒数)``。jti 供登出时拉黑使用。
"""
settings = get_settings()
now = datetime.now(UTC)
expires_in = settings.jwt_expire_minutes * 60
jti = uuid.uuid4().hex
payload = {
"sub": str(user_id), # PyJWT 要求 sub 为字符串
"jti": jti,
"ver": token_version,
"roles": roles, # 仅供前端展示菜单,鉴权时以数据库为准
"iat": now,
"exp": now + timedelta(seconds=expires_in),
}
token = jwt.encode(payload, settings.app_secret_key, algorithm=JWT_ALGORITHM)
return token, jti, expires_in
def decode_token(token: str) -> dict[str, Any]:
"""解析并校验令牌的签名与有效期。
时间一律使用 **UTC 感知时间**:JWT 的 exp 是 UTC 时间戳,若与本地时间
(容器 TZ=Asia/Shanghai)比较会出现 8 小时偏差,表现为「刚登录就过期」
或「过期了还能用」。
:raises AuthError: 签名无效、格式错误或已过期
"""
try:
return jwt.decode(token, get_settings().app_secret_key, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError as exc:
raise AuthError("登录已过期,请重新登录") from exc
except jwt.PyJWTError as exc:
raise AuthError("登录凭证无效,请重新登录") from exc