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:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1 @@
|
||||
"""衍水集团营销人员工资管理系统 —— 后端应用包。"""
|
||||
@@ -0,0 +1,4 @@
|
||||
"""HTTP 接口层:路由声明、依赖注入、参数校验、调用 service、返回统一响应。
|
||||
|
||||
禁止在此层写业务判断或直接拼 SQL。
|
||||
"""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""FastAPI 公共依赖注入。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.exceptions import AuthError
|
||||
from app.core.security import decode_token
|
||||
from app.models import User
|
||||
from app.repositories import user_repository
|
||||
from app.services import token_blacklist
|
||||
|
||||
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
||||
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
||||
_bearer_scheme = HTTPBearer(auto_error=False, description="Bearer 访问令牌")
|
||||
|
||||
BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def get_raw_token(credentials: BearerCredentials) -> str:
|
||||
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthError()
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
def get_current_user(credentials: BearerCredentials, session: DbSession) -> User:
|
||||
"""从 Bearer 令牌解析当前登录用户。
|
||||
|
||||
**三重校验,缺一不可**:
|
||||
|
||||
1. 签名与有效期(`decode_token`)
|
||||
2. 是否已被登出拉黑(Redis `ys:token:blacklist:{jti}`)
|
||||
3. **令牌版本与库中是否一致** —— 禁用 / 改密 / 强制下线会把
|
||||
`users.token_version` 加一,使此前签发的所有令牌立即失效。
|
||||
这一步**不依赖 Redis**,因此 Redis 故障或数据被清空时,
|
||||
离职员工依然无法继续访问
|
||||
|
||||
Redis 不可用时第 2 步会抛异常而非放行 —— **有意的 fail-closed**:
|
||||
对能查看全员薪酬的系统,宁可拒绝请求也不能放行状态不明的令牌。
|
||||
|
||||
:raises AuthError: 任一项校验未通过(401)
|
||||
"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthError()
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and token_blacklist.is_revoked(jti):
|
||||
raise AuthError("登录状态已失效,请重新登录")
|
||||
|
||||
subject = payload.get("sub")
|
||||
user = user_repository.get_by_id(session, int(subject)) if _is_digits(subject) else None
|
||||
if user is None or user.status != 1:
|
||||
raise AuthError()
|
||||
|
||||
if payload.get("ver") != user.token_version:
|
||||
raise AuthError("登录状态已失效,请重新登录")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _is_digits(value: object) -> bool:
|
||||
"""判断令牌中的 sub 是否为合法的主键字符串。
|
||||
|
||||
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
||||
"""
|
||||
return isinstance(value, str) and value.isdigit()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""健康检查接口。
|
||||
|
||||
路由写 `def`(同步)而非 `async def`:FastAPI 会把同步路由放入线程池执行,
|
||||
因此这里的数据库与 Redis 探测不会阻塞事件循环。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.redis_client import get_redis
|
||||
from app.core.response import ApiResponse, ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["系统"])
|
||||
|
||||
|
||||
def _check_db() -> bool:
|
||||
"""探测数据库连通性。失败只记日志并返回 False,不抛出。"""
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("数据库健康检查未通过", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _check_redis() -> bool:
|
||||
"""探测 Redis 连通性。失败只记日志并返回 False,不抛出。"""
|
||||
try:
|
||||
return bool(get_redis().ping())
|
||||
except Exception:
|
||||
logger.warning("Redis 健康检查未通过", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[dict[str, Any]], summary="健康检查")
|
||||
def health() -> ApiResponse[dict[str, Any]]:
|
||||
"""探活接口:上报应用、数据库、Redis 三项状态。
|
||||
|
||||
即使依赖不可用,本接口本身仍返回 200 并在 data 中标记 false ——
|
||||
这样运维能区分「服务没起来」与「服务起来了但依赖挂了」两种情况。
|
||||
|
||||
注意:生产部署应限制本接口仅内网可达,避免对外暴露依赖拓扑。
|
||||
"""
|
||||
return ok({"app": True, "db": _check_db(), "redis": _check_redis()})
|
||||
@@ -0,0 +1,20 @@
|
||||
"""API 路由汇总。
|
||||
|
||||
各业务模块的路由在此挂载。业务接口统一放在 `/api/v1/` 之下,
|
||||
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 运维探针:/api/health
|
||||
api_router.include_router(health.router)
|
||||
|
||||
# 业务接口:/api/v1/*
|
||||
v1_router = APIRouter(prefix="/v1")
|
||||
v1_router.include_router(auth.router)
|
||||
api_router.include_router(v1_router)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""API v1 版本化路由。
|
||||
|
||||
业务接口按模块拆分为独立文件挂载于此(如 `auth.py`);
|
||||
跨模块共享的依赖(get_db、get_current_user 等)放在上一级 `api/deps.py`。
|
||||
|
||||
健康检查是运维探针,固定挂在 `/api/health`,不带版本号。
|
||||
"""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""认证接口:验证码、登录、登出、当前用户。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.deps import DbSession, get_current_user, get_raw_token
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||
from app.services import auth_service, captcha_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
"""取客户端 IP。
|
||||
|
||||
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
||||
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
||||
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
||||
"""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
||||
def get_captcha() -> ApiResponse[CaptchaOut]:
|
||||
"""签发图形验证码。无需认证。
|
||||
|
||||
图片以 base64 内联返回而非单独 URL:保证 id 与图片成对产生,
|
||||
避免前端分两次请求时拿到不匹配的组合。
|
||||
"""
|
||||
captcha_id, image_base64, expires_in = captcha_service.issue()
|
||||
return ok(CaptchaOut(captcha_id=captcha_id, image_base64=image_base64, expires_in=expires_in))
|
||||
|
||||
|
||||
@router.post("/login", response_model=ApiResponse[LoginOut], summary="登录")
|
||||
def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse[LoginOut]:
|
||||
"""账号 + 密码 + 图形验证码登录。
|
||||
|
||||
失败一律返回 **400 或 403,绝不使用 401**:前端的 401 拦截器会清空令牌
|
||||
并跳转登录页,若登录接口自身返回 401,用户输错一次密码就会触发
|
||||
「清空 + 跳转 + 报错」的组合动作,登录页反复重挂载、验证码反复刷新。
|
||||
|
||||
另注意:验证码经 GETDEL 消费后即失效,**前端必须在登录失败后刷新验证码**,
|
||||
否则第二次提交必然报「验证码错误」。
|
||||
"""
|
||||
result = auth_service.login(
|
||||
session,
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
captcha_id=payload.captcha_id,
|
||||
captcha_code=payload.captcha_code,
|
||||
ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return ok(result, message="登录成功")
|
||||
|
||||
|
||||
@router.post("/logout", response_model=ApiResponse[None], summary="登出")
|
||||
def logout(
|
||||
token: Annotated[str, Depends(get_raw_token)],
|
||||
_current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> ApiResponse[None]:
|
||||
"""登出:把当前令牌加入黑名单,使其立即失效。
|
||||
|
||||
同时依赖 `get_current_user` 做真实鉴权 —— 仅凭原始令牌字符串不能证明
|
||||
它是有效的(可能已过期或已被拉黑)。
|
||||
"""
|
||||
auth_service.logout(token)
|
||||
return ok(message="已退出登录")
|
||||
|
||||
|
||||
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
||||
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
|
||||
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
|
||||
return ok(
|
||||
CurrentUserOut(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
real_name=current_user.real_name,
|
||||
roles=get_role_codes(current_user),
|
||||
last_login_time=current_user.last_login_time,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""命令行管理工具。
|
||||
|
||||
用法(在 backend/ 目录下执行)::
|
||||
|
||||
uv run python -m app.cli create-admin --username admin
|
||||
|
||||
密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史,
|
||||
在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.security import hash_password
|
||||
from app.models import Role, User, UserRole
|
||||
|
||||
|
||||
def _prompt_password() -> str:
|
||||
"""交互式读取并二次确认密码,循环直到符合策略。"""
|
||||
while True:
|
||||
password = getpass.getpass(
|
||||
f"请输入密码({PASSWORD_MIN_BYTES}-{PASSWORD_MAX_BYTES} 字节): "
|
||||
)
|
||||
length = len(password.encode("utf-8"))
|
||||
if length < PASSWORD_MIN_BYTES:
|
||||
print(f" 密码不足 {PASSWORD_MIN_BYTES} 字节,请重新输入。")
|
||||
continue
|
||||
if length > PASSWORD_MAX_BYTES:
|
||||
print(f" 密码超过 {PASSWORD_MAX_BYTES} 字节上限(中文一字占 3 字节),请重新输入。")
|
||||
continue
|
||||
if password != getpass.getpass("请再次输入以确认: "):
|
||||
print(" 两次输入不一致,请重新输入。")
|
||||
continue
|
||||
return password
|
||||
|
||||
|
||||
def _create_admin(args: argparse.Namespace) -> int:
|
||||
"""幂等创建管理员账号。
|
||||
|
||||
已存在时**不覆盖密码** —— 意外重跑脚本不应把已有账号的密码改掉。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
existing = session.execute(
|
||||
select(User).where(User.username == args.username)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
print(f"用户 {args.username!r} 已存在(id={existing.id}),未做任何修改。")
|
||||
return 0
|
||||
|
||||
role = session.execute(
|
||||
select(Role).where(Role.role_code == ROLE_ADMIN)
|
||||
).scalar_one_or_none()
|
||||
if role is None:
|
||||
print(
|
||||
"错误:未找到 admin 角色,请先执行数据库迁移(uv run alembic upgrade head)。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
password = args.password or _prompt_password()
|
||||
user = User(
|
||||
username=args.username,
|
||||
password=hash_password(password),
|
||||
real_name=args.real_name,
|
||||
status=1,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush() # 取得自增 id 后才能建关联
|
||||
session.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
session.commit()
|
||||
|
||||
print(f"已创建管理员 {args.username!r}(id={user.id}),角色:{role.role_name}。")
|
||||
print("首次登录后请立即修改密码。")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="app.cli", description="衍水集团营销人员工资管理系统 - 命令行工具"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_admin = sub.add_parser("create-admin", help="创建初始管理员账号(幂等)")
|
||||
p_admin.add_argument("--username", default="admin", help="登录用户名,默认 admin")
|
||||
p_admin.add_argument("--real-name", default="系统管理员", help="真实姓名")
|
||||
p_admin.add_argument(
|
||||
"--password",
|
||||
default=None,
|
||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return _create_admin(args)
|
||||
finally:
|
||||
dispose_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。"""
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 = "资源不存在"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
"""应用入口。
|
||||
|
||||
启动:``uv run uvicorn app.main:app --reload --port 8000``
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.redis_client import close_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期管理。
|
||||
|
||||
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
||||
但退出时必须显式释放连接池,否则 uvicorn 热重载会残留连接。
|
||||
"""
|
||||
settings = get_settings()
|
||||
logger.info(
|
||||
"应用启动 | 环境=%s | 数据库=%s/%s",
|
||||
settings.app_env,
|
||||
settings.mysql_host,
|
||||
settings.mysql_database,
|
||||
)
|
||||
yield
|
||||
dispose_engine()
|
||||
close_redis()
|
||||
logger.info("应用已停止,连接池已释放")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""构造并配置 FastAPI 应用实例。
|
||||
|
||||
独立成函数便于测试中构造互不干扰的实例。
|
||||
"""
|
||||
settings = get_settings()
|
||||
setup_logging(level="DEBUG" if settings.app_env == "dev" else "INFO")
|
||||
|
||||
app = FastAPI(
|
||||
title="衍水集团营销人员工资管理系统",
|
||||
description="营销人员绩效工资核算系统 —— 后端服务",
|
||||
version="0.1.0",
|
||||
# 接口文档挂在 /api 下,开发期可通过 Vite 代理从同一个端口访问
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
register_exception_handlers(app)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""ORM 模型层:只做表结构映射。
|
||||
|
||||
分层约束:
|
||||
- 不含业务逻辑
|
||||
- 不得被 `api/` 层直接使用 —— ORM 对象必须经 `schemas/` 转换后才能出参,
|
||||
否则表结构变更会直接泄漏到接口契约上
|
||||
|
||||
**本模块的导入列表必须保持完整**:Alembic autogenerate 依赖
|
||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||
"""
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"LoginLog",
|
||||
"Permission",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ORM 基类、命名约定与公共列。
|
||||
|
||||
命名约定按 PROJECT_STANDARDS 红线设置,使 Alembic autogenerate 产出的
|
||||
约束名自动符合规范(唯一键 `uk_表_字段`、索引 `idx_表_字段`),
|
||||
无需在每张表上手工指定 —— 手工指定迟早会漏。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
# 约束命名模板。`column_0_name` 取约束涉及的第一列,
|
||||
# 对单列唯一键/索引即为字段名,符合规范的 uk_表_字段 / idx_表_字段
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "idx_%(table_name)s_%(column_0_name)s",
|
||||
"uq": "uk_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
# 主键与外键统一使用无符号 BIGINT(规范模板为 BIGINT UNSIGNED)。
|
||||
# 两边类型必须完全一致,否则 MySQL 拒绝建立外键约束。
|
||||
BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
||||
|
||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||
TINYINT_TYPE = TINYINT
|
||||
|
||||
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""全部 ORM 模型的基类。"""
|
||||
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""创建时间 / 更新时间公共列。
|
||||
|
||||
取默认值交给数据库而非应用层:即使有人直接用 SQL 改数据,时间戳依然准确;
|
||||
应用层赋值则会被绕过。
|
||||
|
||||
`update_time` 必须用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`
|
||||
而非 `server_onupdate=func.current_timestamp()` —— 后者只是告诉 ORM
|
||||
「这个值由服务端维护」,**不会生成 ON UPDATE 子句**,结果是更新时间永远不刷新。
|
||||
"""
|
||||
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
comment="创建时间",
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
|
||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||
|
||||
显式指定引擎、字符集与排序规则:compose 已把服务端排序规则覆盖为
|
||||
`utf8mb4_general_ci`,若某处按 MySQL 8.0 默认的 `utf8mb4_0900_ai_ci` 建表,
|
||||
跨表 JOIN 会报 `Illegal mix of collations`。
|
||||
|
||||
:param comment: 表注释(规范红线要求中文 COMMENT)
|
||||
:param extra: 额外的约束对象(UniqueConstraint / Index 等)
|
||||
"""
|
||||
return (
|
||||
*extra,
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_general_ci",
|
||||
"comment": comment,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""登录日志模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
"""登录日志。需求 2.5.1 要求记录用户登录行为。
|
||||
|
||||
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义,
|
||||
也不使用 update_time 的 ON UPDATE 子句。
|
||||
|
||||
记录原则:
|
||||
- **只记登录尝试,绝不记录密码**(明文或哈希都不记)
|
||||
- 用户名原样记录而非仅存 user_id —— 账号不存在时也要能追溯
|
||||
- 写入使用独立事务,失败不阻断登录(审计可丢,业务不可断)
|
||||
"""
|
||||
|
||||
__tablename__ = "login_logs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"登录日志表",
|
||||
Index("idx_login_logs_username", "username"),
|
||||
Index("idx_login_logs_create_time", "create_time"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
comment="用户ID(账号不存在时为空)",
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="登录用户名(原样记录)"
|
||||
)
|
||||
ip: Mapped[str | None] = mapped_column(String(45), comment="客户端IP(45位兼容IPv6)")
|
||||
user_agent: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="客户端User-Agent(截断存储)"
|
||||
)
|
||||
success: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
|
||||
)
|
||||
fail_reason: Mapped[str | None] = mapped_column(
|
||||
String(64), comment="失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""角色、权限及其关联模型(RBAC 标准五表的后四张)。
|
||||
|
||||
权限与角色是多对多:一个权限(如「计提参数配置」)可授予多个角色,
|
||||
因此关联必须独立成表,不能把 role_id 塞进 permissions 表。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class Role(Base, TimestampMixin):
|
||||
"""角色表。本期仅 admin / user 两个角色,数据随迁移脚本初始化。"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = mysql_table_args("角色表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
role_code: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, unique=True, comment="角色编码:admin系统管理员 user普通用户"
|
||||
)
|
||||
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
|
||||
description: Mapped[str | None] = mapped_column(String(255), comment="角色说明")
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
|
||||
user_roles: Mapped[list["UserRole"]] = relationship(back_populates="role")
|
||||
|
||||
|
||||
class Permission(Base, TimestampMixin):
|
||||
"""权限表。
|
||||
|
||||
perm_type 区分菜单/按钮/接口三类,parent_id 支撑树形结构。
|
||||
本期只建表不填数据 —— 具体权限清单待需求澄清后确定。
|
||||
"""
|
||||
|
||||
__tablename__ = "permissions"
|
||||
__table_args__ = mysql_table_args("权限表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
perm_code: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, unique=True, comment="权限编码,如 system:user:create"
|
||||
)
|
||||
perm_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="权限名称")
|
||||
perm_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="类型:1菜单 2按钮 3接口"
|
||||
)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("permissions.id", ondelete="CASCADE"),
|
||||
comment="父权限ID(permissions.id),顶级为空",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色-权限关联表。"""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = mysql_table_args(
|
||||
"角色权限关联表",
|
||||
UniqueConstraint(
|
||||
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
|
||||
),
|
||||
Index("idx_role_permissions_permission_id", "permission_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="角色ID(roles.id)",
|
||||
)
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("permissions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="权限ID(permissions.id)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""系统用户与用户-角色关联模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
if TYPE_CHECKING: # 仅用于类型标注,避免运行期循环导入
|
||||
from app.models.role import Role
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""系统登录账号。
|
||||
|
||||
与营销人员业务数据**严格分离**:营销人员是核算对象,数据来自 T+ 同步,
|
||||
其中多数不需要登录系统(内勤、离职人员、仅参与核算的业务员)。
|
||||
合并成一张表会让 T+ 全量同步覆盖掉 password,导致全员无法登录。
|
||||
|
||||
采用「删除即禁用」(status=0)而非软删除列:软删除与
|
||||
`uk_users_username` 唯一键会冲突(删除后的用户名无法重新启用)。
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = mysql_table_args("系统用户表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True, comment="登录用户名"
|
||||
)
|
||||
password: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, comment="密码哈希(BCrypt,固定60字符)"
|
||||
)
|
||||
real_name: Mapped[str | None] = mapped_column(String(64), comment="真实姓名")
|
||||
phone: Mapped[str | None] = mapped_column(String(20), index=True, comment="手机号")
|
||||
email: Mapped[str | None] = mapped_column(String(128), comment="邮箱")
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
# 「离职即失效」的核心:每次鉴权比对 JWT 的 ver 声明与本列。
|
||||
# 相比只在 Redis 维护黑名单,这种方式在 Redis 不可用时依然有效
|
||||
token_version: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
|
||||
)
|
||||
last_login_time: Mapped[datetime | None] = mapped_column(DateTime, comment="最后登录时间")
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
|
||||
user_roles: Mapped[list["UserRole"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户-角色关联表。
|
||||
|
||||
不继承 TimestampMixin(关联关系不会被"更新",只有建立与删除),
|
||||
但保留 create_time 以便审计「角色何时授予」。
|
||||
"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
__table_args__ = mysql_table_args(
|
||||
"用户角色关联表",
|
||||
UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
|
||||
Index("idx_user_roles_role_id", "role_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID(users.id)",
|
||||
)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="角色ID(roles.id)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="user_roles")
|
||||
role: Mapped["Role"] = relationship(back_populates="user_roles", lazy="selectin")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""数据访问层:只负责数据读写。
|
||||
|
||||
分层约束:
|
||||
- **只 flush,不 commit** —— 事务边界由 `services/` 层控制
|
||||
- 不抛业务异常;查不到数据返回 None 或空集合,由 service 决定如何处置
|
||||
- 不在此层做权限判断或业务规则校验
|
||||
"""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import LoginLog
|
||||
|
||||
# user_agent 列宽,超长需截断否则插入报错
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
def add(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
"""写入一条登录日志。只 flush 不 commit。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||||
success=1 if success else 0,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""用户与角色数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
def get_by_username(session: Session, username: str) -> User | None:
|
||||
"""按用户名查询用户。不存在返回 None。"""
|
||||
return session.execute(select(User).where(User.username == username)).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_by_id(session: Session, user_id: int) -> User | None:
|
||||
"""按主键查询用户。不存在返回 None。"""
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def get_role_codes(user: User) -> list[str]:
|
||||
"""取用户的角色编码列表。
|
||||
|
||||
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
|
||||
故此处遍历不会触发 N+1 查询。
|
||||
"""
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None]
|
||||
|
||||
|
||||
def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
"""更新最后登录时间。只 flush,提交由 service 决定。"""
|
||||
user.last_login_time = moment
|
||||
session.flush()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""请求/响应模型层(Pydantic)。
|
||||
|
||||
分层约束:
|
||||
- 禁止把 ORM 对象直接作为 `response_model` 返回,必须在本层完成转换
|
||||
- 入参校验(含长度、格式、取值范围)在此层声明,业务校验放 `services/`
|
||||
|
||||
本项目特有要求:密码字段必须按**字节数**校验而非字符数 ——
|
||||
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,而中文一个字符占 3 字节。
|
||||
"""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""认证相关的请求与响应模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
|
||||
|
||||
|
||||
class CaptchaOut(BaseModel):
|
||||
"""验证码响应。
|
||||
|
||||
图片以 base64 内联返回而非单独的 URL:让 id 与图片在同一响应中成对产生,
|
||||
避免前端分两次请求时拿到不匹配的组合。
|
||||
"""
|
||||
|
||||
captcha_id: str = Field(description="验证码标识,登录时需回传")
|
||||
image_base64: str = Field(description="PNG 图片的 base64,不含 data: 前缀")
|
||||
expires_in: int = Field(description="有效期(秒)")
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
"""登录请求。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
username: str = Field(min_length=1, max_length=64, description="登录用户名")
|
||||
password: str = Field(min_length=1, description="密码")
|
||||
captcha_id: str = Field(min_length=1, max_length=64, description="验证码标识")
|
||||
captcha_code: str = Field(
|
||||
min_length=CAPTCHA_LENGTH,
|
||||
max_length=CAPTCHA_LENGTH,
|
||||
description="用户填写的验证码",
|
||||
)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _check_password_bytes(cls, value: str) -> str:
|
||||
"""按**字节数**校验密码长度。
|
||||
|
||||
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
|
||||
而中文一个字符占 3 字节——按字符数校验(如 max_length=20)根本拦不住,
|
||||
24 个汉字就会让哈希函数抛异常、登录接口 500。
|
||||
"""
|
||||
if len(value.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
|
||||
return value
|
||||
|
||||
|
||||
class UserBrief(BaseModel):
|
||||
"""用户简要信息。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
real_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
"""登录成功响应。"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = Field(description="有效期(秒)")
|
||||
user: UserBrief
|
||||
|
||||
|
||||
class CurrentUserOut(UserBrief):
|
||||
"""当前登录用户信息。"""
|
||||
|
||||
last_login_time: datetime | None = None
|
||||
@@ -0,0 +1,8 @@
|
||||
"""业务服务层:业务编排与事务边界。
|
||||
|
||||
分层约束:
|
||||
- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交,
|
||||
这样一次请求内的多次数据写入能共处同一事务
|
||||
- 可调用多个 repository 组合业务逻辑
|
||||
- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应
|
||||
"""
|
||||
@@ -0,0 +1,167 @@
|
||||
"""认证业务编排。
|
||||
|
||||
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
|
||||
|
||||
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
|
||||
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
|
||||
from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 统一凭据错误文案:不区分「用户不存在」与「密码错误」,防止账号枚举。
|
||||
# 攻击者据此无法判断某个用户名是否真实存在。
|
||||
INVALID_CREDENTIALS = "用户名或密码错误"
|
||||
|
||||
|
||||
def _record(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def login(
|
||||
session: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
captcha_id: str,
|
||||
captcha_code: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> LoginOut:
|
||||
"""执行登录,返回访问令牌与用户信息。
|
||||
|
||||
判定顺序是有意设计的,每一步对应一类攻击面:
|
||||
|
||||
1. **锁定检查放在最前** —— 否则攻击者无需通过验证码即可消耗失败计数
|
||||
2. **验证码校验并消费** —— 阻断自动化撞库
|
||||
3. **凭据校验** —— 用户不存在与密码错误共用同一文案
|
||||
4. **状态检查放在最后** —— 若提前到密码校验之前,会向未持有正确密码者
|
||||
暴露「该账号存在但已禁用」
|
||||
|
||||
:raises ForbiddenError: 账号锁定或已禁用(403)
|
||||
:raises BadRequestError: 验证码错误或凭据错误(400)
|
||||
"""
|
||||
# ---- 1. 锁定检查 ----
|
||||
if login_limiter.is_locked(username):
|
||||
remaining = login_limiter.remaining_lock_seconds(username)
|
||||
minutes = max(1, (remaining + 59) // 60) # 向上取整,避免显示"0 分钟"
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号锁定",
|
||||
)
|
||||
raise ForbiddenError(f"账号已锁定,请 {minutes} 分钟后重试")
|
||||
|
||||
# ---- 2. 验证码校验(无论成败,该验证码都已失效)----
|
||||
if not captcha_service.verify_and_consume(captcha_id, captcha_code):
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="验证码错误",
|
||||
)
|
||||
raise BadRequestError("验证码错误或已过期,请刷新验证码")
|
||||
|
||||
# ---- 3. 凭据校验 ----
|
||||
user = user_repository.get_by_username(session, username)
|
||||
if user is None or not verify_password(password, user.password):
|
||||
login_limiter.record_failure(username)
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id if user else None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason=INVALID_CREDENTIALS,
|
||||
)
|
||||
raise BadRequestError(INVALID_CREDENTIALS)
|
||||
|
||||
# ---- 4. 状态检查 ----
|
||||
if user.status != 1:
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号禁用",
|
||||
)
|
||||
raise ForbiddenError("账号已被禁用,请联系管理员")
|
||||
|
||||
# ---- 成功 ----
|
||||
login_limiter.clear(username)
|
||||
role_codes = user_repository.get_role_codes(user)
|
||||
# 库列存本地时间(容器 TZ=Asia/Shanghai);JWT 内部另用 UTC,两者不混算
|
||||
user_repository.touch_last_login(session, user, datetime.now())
|
||||
token, _, expires_in = create_access_token(
|
||||
user_id=user.id, token_version=user.token_version, roles=role_codes
|
||||
)
|
||||
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
|
||||
|
||||
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
|
||||
return LoginOut(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def logout(access_token: str) -> None:
|
||||
"""登出:把当前令牌加入黑名单。
|
||||
|
||||
令牌已自然过期或格式非法时**静默返回** —— 此时没有需要撤销的凭证,
|
||||
对调用方而言登出都是"已达成";返回 401 只会让前端多弹一次无意义的报错。
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(access_token)
|
||||
except AuthError:
|
||||
return
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if not jti or not exp:
|
||||
return
|
||||
|
||||
# 黑名单 TTL 必须等于令牌**剩余**有效期,写死固定值会导致"登出后又活了"
|
||||
remaining = int(exp - datetime.now(UTC).timestamp())
|
||||
token_blacklist.revoke(jti, remaining)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""图形验证码的签发与校验。
|
||||
|
||||
校验使用 Redis `GETDEL`——**原子取出即删**,同一验证码用两次必然失败,防重放。
|
||||
若写成「先 GET 再 DEL」,两条命令之间存在竞态窗口,并发请求可用同一验证码
|
||||
重复提交。
|
||||
|
||||
这是设计代价而非缺陷:用户输错一次密码后验证码已被消费,必须换一张。
|
||||
**前端必须在登录失败后自动刷新验证码**,否则第二次提交必然报「验证码错误」。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from app.core.captcha_image import generate_text, render_png_base64
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_CAPTCHA
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def issue() -> tuple[str, str, int]:
|
||||
"""签发验证码。
|
||||
|
||||
:returns: ``(captcha_id, PNG 的 base64, 有效期秒数)``
|
||||
图片与 ID 在同一次调用中成对产生,避免前端分两次请求导致错配。
|
||||
"""
|
||||
settings = get_settings()
|
||||
captcha_id = uuid.uuid4().hex
|
||||
text = generate_text()
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(
|
||||
REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id),
|
||||
text.upper(),
|
||||
ex=settings.captcha_expire_seconds,
|
||||
)
|
||||
return captcha_id, render_png_base64(text), settings.captcha_expire_seconds
|
||||
|
||||
|
||||
def verify_and_consume(captcha_id: str, code: str) -> bool:
|
||||
"""校验验证码并消费它。
|
||||
|
||||
**无论校验成败,该验证码都会失效** —— 通过 GETDEL 取出后键已被删除。
|
||||
这阻断了「用一个验证码反复试密码」的路径。
|
||||
|
||||
:returns: 校验是否通过。验证码不存在(过期或已被使用)时返回 False。
|
||||
"""
|
||||
expected = get_redis().getdel(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
if expected is None:
|
||||
return False
|
||||
return expected == code.strip().upper()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""登录失败限流。
|
||||
|
||||
键 `ys:login:fail:{username}` 记录连续失败次数,TTL 等于锁定时长。
|
||||
达到阈值后即使密码正确也拒绝登录,须等锁定期结束。
|
||||
|
||||
**为什么按用户名而非 IP**:本系统用户是确定的几十个营销人员,
|
||||
按用户名限流才能防住针对性撞库;按 IP 会被内网 NAT 和攻击者换 IP 绕过。
|
||||
代价是攻击者可故意把他人账号锁 15 分钟 —— 该风险在内部系统中可接受,
|
||||
且管理员可通过 `clear()` 手动解锁。
|
||||
"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_LOGIN_FAIL
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def _key(username: str) -> str:
|
||||
return REDIS_KEY_LOGIN_FAIL.format(username=username)
|
||||
|
||||
|
||||
def failure_count(username: str) -> int:
|
||||
"""当前连续失败次数。键不存在时为 0。"""
|
||||
value = get_redis().get(_key(username))
|
||||
return int(value) if value else 0
|
||||
|
||||
|
||||
def is_locked(username: str) -> bool:
|
||||
"""账号当前是否处于锁定期。"""
|
||||
return failure_count(username) >= get_settings().login_fail_limit
|
||||
|
||||
|
||||
def remaining_lock_seconds(username: str) -> int:
|
||||
"""锁定期剩余秒数。未锁定时为 0。"""
|
||||
ttl = get_redis().ttl(_key(username))
|
||||
return ttl if ttl and ttl > 0 else 0
|
||||
|
||||
|
||||
def record_failure(username: str) -> int:
|
||||
"""记录一次登录失败,返回累计次数。
|
||||
|
||||
每次失败都刷新 TTL,形成**滑动窗口**:任意 15 分钟区间内累计 5 次即锁定。
|
||||
若不刷新,则"4 次失败 + 等 14 分钟 + 1 次失败"就不会触发锁定,
|
||||
正好给撞库留出缝隙。
|
||||
"""
|
||||
key = _key(username)
|
||||
redis = get_redis()
|
||||
count = int(redis.incr(key))
|
||||
redis.expire(key, get_settings().login_lock_minutes * 60)
|
||||
return count
|
||||
|
||||
|
||||
def clear(username: str) -> None:
|
||||
"""登录成功后清除失败计数。"""
|
||||
get_redis().delete(_key(username))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""JWT 登出黑名单。
|
||||
|
||||
**TTL 必须等于令牌剩余有效期**:写死固定值会导致「登出 N 分钟后又活了」;
|
||||
不设过期则 Redis 会随登出次数无限增长。两者都是错的。
|
||||
|
||||
**范围限定**:本模块只解决「用户主动登出」。
|
||||
「禁用账号 / 改密 / 强制下线」由 `users.token_version` 承担 ——
|
||||
那种场景按用户维度写入 N 个黑名单键既低效,又会在 Redis 故障时完全失效
|
||||
(那时离职员工仍能用旧令牌查看全员薪酬)。鉴权时二者都要检查。
|
||||
"""
|
||||
|
||||
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def _key(jti: str) -> str:
|
||||
return REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti)
|
||||
|
||||
|
||||
def revoke(jti: str, remaining_seconds: int) -> None:
|
||||
"""把令牌加入黑名单。
|
||||
|
||||
:param jti: 令牌唯一标识
|
||||
:param remaining_seconds: 令牌剩余有效期;**必须传真实剩余值**。
|
||||
非正数表示令牌已自然过期,无需拉黑,本函数直接忽略。
|
||||
"""
|
||||
if remaining_seconds <= 0:
|
||||
return
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(_key(jti), "1", ex=remaining_seconds)
|
||||
|
||||
|
||||
def is_revoked(jti: str) -> bool:
|
||||
"""令牌是否已被拉黑。
|
||||
|
||||
Redis 不可用时本函数会抛异常 —— 这是**有意的 fail-closed**:
|
||||
对能查看全员薪酬的系统,宁可拒绝请求也不能放行无法确定状态的令牌。
|
||||
"""
|
||||
return bool(get_redis().exists(_key(jti)))
|
||||
Reference in new issue
Block a user