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,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")
|
||||
Reference in new issue
Block a user