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

+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")