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 @@
"""衍水集团营销人员工资管理系统 —— 后端应用包。"""
+4
View File
@@ -0,0 +1,4 @@
"""HTTP 接口层:路由声明、依赖注入、参数校验、调用 service、返回统一响应。
禁止在此层写业务判断或直接拼 SQL。
"""
+73
View File
@@ -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()
+52
View File
@@ -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()})
+20
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""API v1 版本化路由。
业务接口按模块拆分为独立文件挂载于此(如 `auth.py`);
跨模块共享的依赖(get_db、get_current_user 等)放在上一级 `api/deps.py`。
健康检查是运维探针,固定挂在 `/api/health`,不带版本号。
"""
+86
View File
@@ -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,
)
)
+108
View File
@@ -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())
+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
+66
View File
@@ -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()
+26
View File
@@ -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",
]
+83
View File
@@ -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,
},
)
+53
View File
@@ -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="创建时间"
)
+105
View File
@@ -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="创建时间"
)
+102
View File
@@ -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")
+7
View File
@@ -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()
+9
View File
@@ -0,0 +1,9 @@
"""请求/响应模型层(Pydantic)。
分层约束:
- 禁止把 ORM 对象直接作为 `response_model` 返回,必须在本层完成转换
- 入参校验(含长度、格式、取值范围)在此层声明,业务校验放 `services/`
本项目特有要求:密码字段必须按**字节数**校验而非字符数 ——
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,而中文一个字符占 3 字节。
"""
+73
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
"""业务服务层:业务编排与事务边界。
分层约束:
- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交,
这样一次请求内的多次数据写入能共处同一事务
- 可调用多个 repository 组合业务逻辑
- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应
"""
+167
View File
@@ -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)
+48
View File
@@ -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()
+54
View File
@@ -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))
+39
View File
@@ -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)))