From 2cdf7f9d7a11135fc02e21deea6e2675553a3e60 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Tue, 25 Aug 2026 22:47:59 +0800 Subject: [PATCH] feat: add admin session rbac foundation --- WonderQ-Admin/.env.example | 8 + WonderQ-Admin/README.md | 7 + .../alembic/versions/0025_admin_rbac.py | 149 +++++++ .../alembic/versions/0026_admin_ownership.py | 93 +++++ WonderQ-Admin/app/auth.py | 82 +++- WonderQ-Admin/app/config.py | 9 + WonderQ-Admin/app/main.py | 3 +- WonderQ-Admin/app/models.py | 130 ++++++- WonderQ-Admin/app/rbac.py | 193 +++++++++ WonderQ-Admin/app/redis_session.py | 342 ++++++++++++++++ WonderQ-Admin/app/routers/admin.py | 235 +++++++++-- WonderQ-Admin/app/routers/shared.py | 21 +- WonderQ-Admin/app/routers/system.py | 365 ++++++++++++++++++ WonderQ-Admin/app/schemas.py | 81 ++++ WonderQ-Admin/docker-compose.yml | 1 + WonderQ-Admin/pyproject.toml | 1 + WonderQ-Admin/requirements.txt | 1 + WonderQ-Admin/tests/test_admin_rbac.py | 222 +++++++++++ docs/README.md | 1 + docs/admin-api-requirements.md | 17 +- docs/admin-business-function-matrix.md | 39 ++ docs/integration-workflow.md | 13 +- 22 files changed, 1945 insertions(+), 68 deletions(-) create mode 100644 WonderQ-Admin/alembic/versions/0025_admin_rbac.py create mode 100644 WonderQ-Admin/alembic/versions/0026_admin_ownership.py create mode 100644 WonderQ-Admin/app/rbac.py create mode 100644 WonderQ-Admin/app/redis_session.py create mode 100644 WonderQ-Admin/app/routers/system.py create mode 100644 WonderQ-Admin/tests/test_admin_rbac.py create mode 100644 docs/admin-business-function-matrix.md diff --git a/WonderQ-Admin/.env.example b/WonderQ-Admin/.env.example index 65d9425..47a8e7b 100644 --- a/WonderQ-Admin/.env.example +++ b/WonderQ-Admin/.env.example @@ -1,5 +1,13 @@ DATABASE_URL="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp" JWT_SECRET="replace-with-a-long-random-secret-before-production" +REDIS_URL="redis://localhost:6380/0" +ADMIN_ACCESS_EXPIRES_MINUTES=15 +ADMIN_REFRESH_EXPIRES_DAYS=7 +ADMIN_REFRESH_COOKIE_SECURE=false +ADMIN_REFRESH_COOKIE_PATH="/api/admin/auth" +ADMIN_PERMISSION_CACHE_SECONDS=300 +ADMIN_LOGIN_RATE_LIMIT=5 +ADMIN_LOGIN_RATE_WINDOW_SECONDS=60 PORT=4000 LOG_LEVEL="info" CORS_ORIGINS="*" diff --git a/WonderQ-Admin/README.md b/WonderQ-Admin/README.md index f6c241e..6ce3455 100644 --- a/WonderQ-Admin/README.md +++ b/WonderQ-Admin/README.md @@ -18,6 +18,10 @@ Copy-Item .env.example .env - `DATABASE_URL`:本地 Docker PostgreSQL 默认使用 `localhost:5433`。 - `JWT_SECRET`:生产环境必须替换为高强度随机值。 +- `REDIS_URL`:管理员会话、Refresh Token 和权限缓存使用的 Redis 地址。 +- `ADMIN_PERMISSION_CACHE_SECONDS`:管理员权限菜单缓存秒数,默认 300。 +- `ADMIN_LOGIN_RATE_LIMIT` / `ADMIN_LOGIN_RATE_WINDOW_SECONDS`:登录限流窗口,默认每个 IP+账号 60 秒最多 5 次。 +- `ADMIN_ACCESS_EXPIRES_MINUTES`、`ADMIN_REFRESH_EXPIRES_DAYS`:管理员访问令牌和刷新令牌有效期。 - `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。 ### 2. 创建并启用 Python 虚拟环境 @@ -178,6 +182,9 @@ Python 版保留原有核心路径: - `GET /api/public/auth/me` - `POST /api/public/leads` - `POST /api/admin/auth/login` +- `POST /api/admin/auth/refresh`、`POST /api/admin/auth/logout` +- `GET /api/admin/system/profile` +- `/api/admin/system/users`、`/api/admin/system/roles`、`/api/admin/system/menus`、`/api/admin/system/depts` - `GET /api/admin/me` - `GET /api/admin/dashboard` - `GET /api/admin/site-config` diff --git a/WonderQ-Admin/alembic/versions/0025_admin_rbac.py b/WonderQ-Admin/alembic/versions/0025_admin_rbac.py new file mode 100644 index 0000000..4b0c338 --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0025_admin_rbac.py @@ -0,0 +1,149 @@ +"""Add normalized administrator RBAC tables and bootstrap the current admin.""" + +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + + +revision = "0025_admin_rbac" +down_revision = "0024_vehicle_demand" +branch_labels = None +depends_on = None + +RBAC_TABLES = ( + "AdminRole", + "AdminMenu", + "AdminDepartment", + "AdminUserRole", + "AdminRoleMenu", + "AdminUserDepartment", + "AdminRoleDepartment", +) + +DEFAULT_DEPT_ID = "00000000-0000-0000-0000-000000000001" +SUPER_ROLE_ID = "00000000-0000-0000-0000-000000000002" + +MENU_ROWS = ( + ("00000000-0000-0000-0000-000000000101", "仪表盘", "page", "/dashboard", "Dashboard", "admin:dashboard", 10), + ("00000000-0000-0000-0000-000000000102", "首页配置", "page", "/home", "HomePage", "admin:site-config", 20), + ("00000000-0000-0000-0000-000000000103", "玩法与详情", "page", "/play", "WanfaPage", "admin:wanfa:read", 30), + ("00000000-0000-0000-0000-000000000104", "管家管理", "page", "/concierge", "ConciergePage", "admin:concierge:read", 40), + ("00000000-0000-0000-0000-000000000105", "需求线索", "page", "/leads", "LeadsPage", "admin:leads:read", 50), + ("00000000-0000-0000-0000-000000000106", "用户管理", "page", "/system/users", "SystemUsers", "system:user:read", 60), + ("00000000-0000-0000-0000-000000000107", "角色管理", "page", "/system/roles", "SystemRoles", "system:role:read", 70), + ("00000000-0000-0000-0000-000000000108", "菜单管理", "page", "/system/menus", "SystemMenus", "system:menu:read", 80), + ("00000000-0000-0000-0000-000000000109", "部门管理", "page", "/system/depts", "SystemDepts", "system:dept:read", 90), +) + + +def upgrade() -> None: + now = datetime.now(timezone.utc).replace(tzinfo=None) + + op.create_table( + "AdminRole", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("dataScope", sa.String(), nullable=False, server_default="all"), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code", name="uq_AdminRole_code"), + ) + op.create_table( + "AdminDepartment", + sa.Column("id", sa.String(), nullable=False), + sa.Column("parentId", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["parentId"], ["AdminDepartment.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "AdminMenu", + sa.Column("id", sa.String(), nullable=False), + sa.Column("parentId", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("type", sa.String(), nullable=False, server_default="page"), + sa.Column("path", sa.String(), nullable=True), + sa.Column("componentKey", sa.String(), nullable=True), + sa.Column("permissionCode", sa.String(), nullable=True), + sa.Column("icon", sa.String(), nullable=True), + sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), + sa.Column("isVisible", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["parentId"], ["AdminMenu.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("permissionCode", name="uq_AdminMenu_permissionCode"), + ) + op.create_table( + "AdminUserRole", + sa.Column("userId", sa.String(), nullable=False), + sa.Column("roleId", sa.String(), nullable=False), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["roleId"], ["AdminRole.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["userId"], ["AdminUser.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("userId", "roleId"), + ) + op.create_table( + "AdminRoleMenu", + sa.Column("roleId", sa.String(), nullable=False), + sa.Column("menuId", sa.String(), nullable=False), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["menuId"], ["AdminMenu.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["roleId"], ["AdminRole.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("roleId", "menuId"), + ) + op.create_table( + "AdminUserDepartment", + sa.Column("userId", sa.String(), nullable=False), + sa.Column("deptId", sa.String(), nullable=False), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["deptId"], ["AdminDepartment.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["userId"], ["AdminUser.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("userId", "deptId"), + ) + op.create_table( + "AdminRoleDepartment", + sa.Column("roleId", sa.String(), nullable=False), + sa.Column("deptId", sa.String(), nullable=False), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["deptId"], ["AdminDepartment.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["roleId"], ["AdminRole.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("roleId", "deptId"), + ) + + role_table = sa.table("AdminRole", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("code", sa.String), sa.column("dataScope", sa.String), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) + dept_table = sa.table("AdminDepartment", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("sortOrder", sa.Integer), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) + menu_table = sa.table("AdminMenu", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("type", sa.String), sa.column("path", sa.String), sa.column("componentKey", sa.String), sa.column("permissionCode", sa.String), sa.column("sortOrder", sa.Integer), sa.column("isVisible", sa.Boolean), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) + op.bulk_insert(role_table, [{"id": SUPER_ROLE_ID, "name": "超级管理员", "code": "super_admin", "dataScope": "all", "isActive": True, "createdAt": now, "updatedAt": now}]) + op.bulk_insert(dept_table, [{"id": DEFAULT_DEPT_ID, "name": "默认部门", "sortOrder": 0, "isActive": True, "createdAt": now, "updatedAt": now}]) + op.bulk_insert( + menu_table, + [ + {"id": menu_id, "name": name, "type": menu_type, "path": path, "componentKey": component, "permissionCode": permission, "sortOrder": sort_order, "isVisible": True, "isActive": True, "createdAt": now, "updatedAt": now} + for menu_id, name, menu_type, path, component, permission, sort_order in MENU_ROWS + ], + ) + op.execute(sa.text('INSERT INTO "AdminRoleMenu" ("roleId", "menuId", "createdAt") SELECT :role_id, "id", :created_at FROM "AdminMenu" ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, created_at=now)) + op.execute(sa.text('INSERT INTO "AdminRoleDepartment" ("roleId", "deptId", "createdAt") VALUES (:role_id, :dept_id, :created_at) ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, dept_id=DEFAULT_DEPT_ID, created_at=now)) + op.execute(sa.text('INSERT INTO "AdminUserRole" ("userId", "roleId", "createdAt") SELECT "id", :role_id, :created_at FROM "AdminUser" ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, created_at=now)) + op.execute(sa.text('INSERT INTO "AdminUserDepartment" ("userId", "deptId", "createdAt") SELECT "id", :dept_id, :created_at FROM "AdminUser" ON CONFLICT DO NOTHING').bindparams(dept_id=DEFAULT_DEPT_ID, created_at=now)) + + +def downgrade() -> None: + op.drop_table("AdminRoleDepartment") + op.drop_table("AdminUserDepartment") + op.drop_table("AdminRoleMenu") + op.drop_table("AdminUserRole") + op.drop_table("AdminMenu") + op.drop_table("AdminDepartment") + op.drop_table("AdminRole") diff --git a/WonderQ-Admin/alembic/versions/0026_admin_ownership.py b/WonderQ-Admin/alembic/versions/0026_admin_ownership.py new file mode 100644 index 0000000..991d73e --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0026_admin_ownership.py @@ -0,0 +1,93 @@ +"""Add department and creator ownership to operator-managed resources.""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0026_admin_ownership" +down_revision = "0025_admin_rbac" +branch_labels = None +depends_on = None + +DEFAULT_DEPT_ID = "00000000-0000-0000-0000-000000000001" +OWNED_TABLES = ( + "MediaAsset", + "HeroSlide", + "DestinationHero", + "VehicleOption", + "VehicleServiceConfig", + "HomeExperience", + "HomeTeamBuilding", + "HomeWildArchive", + "HomeWanfaRecommendation", + "WanfaCategory", + "WanfaRoute", + "DetailRecord", + "ConciergeAdvisor", + "DemandHero", + "DemandFeatureCard", + "DemandForm", + "Lead", +) + + +def upgrade() -> None: + for table_name in OWNED_TABLES: + op.add_column( + table_name, + sa.Column( + "deptId", + sa.String(), + nullable=True, + server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), + ), + ) + op.add_column(table_name, sa.Column("createdById", sa.String(), nullable=True)) + op.create_index(f"ix_{table_name}_deptId", table_name, ["deptId"]) + op.create_index(f"ix_{table_name}_createdById", table_name, ["createdById"]) + op.create_foreign_key( + f"fk_{table_name}_deptId", + table_name, + "AdminDepartment", + ["deptId"], + ["id"], + ondelete="RESTRICT", + ) + op.create_foreign_key( + f"fk_{table_name}_createdById", + table_name, + "AdminUser", + ["createdById"], + ["id"], + ondelete="SET NULL", + ) + quoted_table = f'"{table_name}"' + op.execute( + sa.text(f'UPDATE {quoted_table} SET "deptId" = :dept_id WHERE "deptId" IS NULL').bindparams( + dept_id=DEFAULT_DEPT_ID + ) + ) + op.alter_column( + table_name, + "deptId", + existing_type=sa.String(), + nullable=False, + server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), + ) + op.execute( + sa.text( + f'UPDATE {quoted_table} SET "createdById" = ' + '(SELECT "id" FROM "AdminUser" ORDER BY "createdAt" ASC LIMIT 1) ' + 'WHERE "createdById" IS NULL' + ) + ) + + +def downgrade() -> None: + for table_name in reversed(OWNED_TABLES): + op.drop_constraint(f"fk_{table_name}_createdById", table_name, type_="foreignkey") + op.drop_constraint(f"fk_{table_name}_deptId", table_name, type_="foreignkey") + op.drop_index(f"ix_{table_name}_createdById", table_name=table_name) + op.drop_index(f"ix_{table_name}_deptId", table_name=table_name) + op.drop_column(table_name, "createdById") + op.drop_column(table_name, "deptId") diff --git a/WonderQ-Admin/app/auth.py b/WonderQ-Admin/app/auth.py index 82b78b4..6425617 100644 --- a/WonderQ-Admin/app/auth.py +++ b/WonderQ-Admin/app/auth.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from uuid import uuid4 import bcrypt import jwt from fastapi import Depends, HTTPException, Request, status @@ -7,6 +8,14 @@ from sqlalchemy.orm import Session from .config import get_settings from .database import get_db from .models import AdminUser, Customer +from .redis_session import ( + AdminSessionStore, + RedisUnavailableError, + SessionRecord, + get_admin_session_store, + hash_refresh_token, + new_refresh_token, +) bearer = HTTPBearer(auto_error=False) @@ -34,6 +43,67 @@ def create_token(user: AdminUser) -> str: return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") +def create_access_token(user: AdminUser, *, session_id: str, access_jti: str) -> str: + settings = get_settings() + now = datetime.now(timezone.utc) + payload = { + "typ": "admin_access", + "aud": "admin", + "sub": user.id, + "email": user.email, + "role": user.role, + "sid": session_id, + "jti": access_jti, + "iat": now, + "exp": now + timedelta(minutes=settings.admin_access_expires_minutes), + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def decode_admin_access_token(token: str) -> dict: + return jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"], options={"verify_aud": False}) + + +def issue_admin_session(user: AdminUser, store: AdminSessionStore) -> tuple[str, str, int]: + settings = get_settings() + now = datetime.now(timezone.utc) + session_id = str(uuid4()) + access_jti = str(uuid4()) + refresh_token = new_refresh_token() + access_expires_at = now + timedelta(minutes=settings.admin_access_expires_minutes) + refresh_expires_at = now + timedelta(days=settings.admin_refresh_expires_days) + store.create( + session_id=session_id, + user_id=user.id, + access_jti=access_jti, + refresh_hash=hash_refresh_token(refresh_token), + access_expires_at=access_expires_at, + refresh_expires_at=refresh_expires_at, + ) + return ( + create_access_token(user, session_id=session_id, access_jti=access_jti), + refresh_token, + settings.admin_access_expires_minutes * 60, + ) + + +def rotate_admin_session(user: AdminUser, record: SessionRecord, store: AdminSessionStore) -> tuple[str, str, int] | None: + settings = get_settings() + now = datetime.now(timezone.utc) + access_jti = str(uuid4()) + refresh_token = new_refresh_token() + if not store.rotate( + session_id=record.session_id, + refresh_hash=record.refresh_hash, + access_jti=access_jti, + refresh_hash_next=hash_refresh_token(refresh_token), + access_expires_at=now + timedelta(minutes=settings.admin_access_expires_minutes), + refresh_expires_at=now + timedelta(days=settings.admin_refresh_expires_days), + ): + return None + return create_access_token(user, session_id=record.session_id, access_jti=access_jti), refresh_token, settings.admin_access_expires_minutes * 60 + + def create_customer_token(customer: Customer) -> str: settings = get_settings() now = datetime.now(timezone.utc) @@ -50,15 +120,23 @@ def require_admin( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer), db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), ) -> AdminUser: if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") try: - payload = jwt.decode(credentials.credentials, get_settings().jwt_secret, algorithms=["HS256"]) + payload = decode_admin_access_token(credentials.credentials) except jwt.PyJWTError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") from exc - if payload.get("typ", "admin") != "admin": + token_type = payload.get("typ", "admin") + if token_type not in {"admin", "admin_access"}: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") + if token_type == "admin_access": + try: + if not payload.get("sid") or not payload.get("jti") or not store.is_access_active(payload["sid"], payload["jti"]): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录后台") + except RedisUnavailableError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="后台会话服务暂时不可用") from exc user_id = payload.get("sub") user = db.get(AdminUser, user_id) if user_id else None if not user or not user.isActive: diff --git a/WonderQ-Admin/app/config.py b/WonderQ-Admin/app/config.py index 46d122a..630de8c 100644 --- a/WonderQ-Admin/app/config.py +++ b/WonderQ-Admin/app/config.py @@ -8,6 +8,15 @@ class Settings(BaseSettings): database_url: str = Field(default="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp") jwt_secret: str = Field(default="dev-only-change-me-before-production") jwt_expires_hours: int = Field(default=8) + admin_access_expires_minutes: int = Field(default=15) + admin_refresh_expires_days: int = Field(default=7) + redis_url: str = Field(default="redis://localhost:6380/0") + admin_refresh_cookie_name: str = Field(default="wonderq_admin_refresh") + admin_refresh_cookie_secure: bool = Field(default=False) + admin_refresh_cookie_path: str = Field(default="/api/admin/auth") + admin_permission_cache_seconds: int = Field(default=300) + admin_login_rate_limit: int = Field(default=5) + admin_login_rate_window_seconds: int = Field(default=60) log_level: str = Field(default="info") port: int = Field(default=4000) cors_origins: str = Field(default="*") diff --git a/WonderQ-Admin/app/main.py b/WonderQ-Admin/app/main.py index 37c1803..add5a93 100644 --- a/WonderQ-Admin/app/main.py +++ b/WonderQ-Admin/app/main.py @@ -4,7 +4,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from .api_response import error_response, exception_parts from .config import get_settings -from .routers import admin, public +from .routers import admin, public, system def create_app() -> FastAPI: @@ -42,6 +42,7 @@ def create_app() -> FastAPI: app.include_router(public.router) app.include_router(admin.router) + app.include_router(system.router) return app diff --git a/WonderQ-Admin/app/models.py b/WonderQ-Admin/app/models.py index 3cf7a19..9dea9f9 100644 --- a/WonderQ-Admin/app/models.py +++ b/WonderQ-Admin/app/models.py @@ -30,7 +30,103 @@ class AdminUser(Base): assignedLeads: Mapped[list["Lead"]] = relationship(back_populates="assignedUser", foreign_keys="Lead.assignedUserId") -class MediaAsset(Base): +class AdminRole(Base): + __tablename__ = "AdminRole" + __table_args__ = (UniqueConstraint("code", name="uq_AdminRole_code"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + name: Mapped[str] = mapped_column(String, nullable=False) + code: Mapped[str] = mapped_column(String, nullable=False) + dataScope: Mapped[str] = mapped_column(String, default="all", nullable=False) + description: Mapped[str | None] = mapped_column(Text) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class AdminMenu(Base): + __tablename__ = "AdminMenu" + __table_args__ = (UniqueConstraint("permissionCode", name="uq_AdminMenu_permissionCode"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + parentId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminMenu.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String, nullable=False) + type: Mapped[str] = mapped_column(String, default="page", nullable=False) + path: Mapped[str | None] = mapped_column(String) + componentKey: Mapped[str | None] = mapped_column(String) + permissionCode: Mapped[str | None] = mapped_column(String) + icon: Mapped[str | None] = mapped_column(String) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isVisible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class AdminDepartment(Base): + __tablename__ = "AdminDepartment" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) + parentId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminDepartment.id", ondelete="SET NULL")) + name: Mapped[str] = mapped_column(String, nullable=False) + sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + +class AdminUserRole(Base): + __tablename__ = "AdminUserRole" + + userId: Mapped[str] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="CASCADE"), primary_key=True) + roleId: Mapped[str] = mapped_column(String, ForeignKey("AdminRole.id", ondelete="CASCADE"), primary_key=True) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + +class AdminRoleMenu(Base): + __tablename__ = "AdminRoleMenu" + + roleId: Mapped[str] = mapped_column(String, ForeignKey("AdminRole.id", ondelete="CASCADE"), primary_key=True) + menuId: Mapped[str] = mapped_column(String, ForeignKey("AdminMenu.id", ondelete="CASCADE"), primary_key=True) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + +class AdminUserDepartment(Base): + __tablename__ = "AdminUserDepartment" + + userId: Mapped[str] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="CASCADE"), primary_key=True) + deptId: Mapped[str] = mapped_column(String, ForeignKey("AdminDepartment.id", ondelete="CASCADE"), primary_key=True) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + +class AdminRoleDepartment(Base): + __tablename__ = "AdminRoleDepartment" + + roleId: Mapped[str] = mapped_column(String, ForeignKey("AdminRole.id", ondelete="CASCADE"), primary_key=True) + deptId: Mapped[str] = mapped_column(String, ForeignKey("AdminDepartment.id", ondelete="CASCADE"), primary_key=True) + createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) + + +DEFAULT_ADMIN_DEPT_ID = "00000000-0000-0000-0000-000000000001" + + +class AdminOwnedContent: + deptId: Mapped[str] = mapped_column( + String, + ForeignKey("AdminDepartment.id", ondelete="RESTRICT"), + nullable=False, + index=True, + server_default=DEFAULT_ADMIN_DEPT_ID, + ) + createdById: Mapped[str | None] = mapped_column( + String, + ForeignKey("AdminUser.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + +class MediaAsset(AdminOwnedContent, Base): __tablename__ = "MediaAsset" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -43,7 +139,7 @@ class MediaAsset(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class HeroSlide(Base): +class HeroSlide(AdminOwnedContent, Base): __tablename__ = "HeroSlide" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -59,7 +155,7 @@ class HeroSlide(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class DestinationHero(Base): +class DestinationHero(AdminOwnedContent, Base): __tablename__ = "DestinationHero" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -72,7 +168,7 @@ class DestinationHero(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class VehicleOption(Base): +class VehicleOption(AdminOwnedContent, Base): __tablename__ = "VehicleOption" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -85,7 +181,7 @@ class VehicleOption(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class VehicleServiceConfig(Base): +class VehicleServiceConfig(AdminOwnedContent, Base): __tablename__ = "VehicleServiceConfig" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -99,7 +195,7 @@ class VehicleServiceConfig(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class HomeExperience(Base): +class HomeExperience(AdminOwnedContent, Base): __tablename__ = "HomeExperience" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -115,7 +211,7 @@ class HomeExperience(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class HomeTeamBuilding(Base): +class HomeTeamBuilding(AdminOwnedContent, Base): __tablename__ = "HomeTeamBuilding" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -132,7 +228,7 @@ class HomeTeamBuilding(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class HomeWildArchive(Base): +class HomeWildArchive(AdminOwnedContent, Base): __tablename__ = "HomeWildArchive" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -146,7 +242,7 @@ class HomeWildArchive(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class HomeWanfaRecommendation(Base): +class HomeWanfaRecommendation(AdminOwnedContent, Base): __tablename__ = "HomeWanfaRecommendation" __table_args__ = ( UniqueConstraint("categoryId", name="uq_HomeWanfaRecommendation_categoryId"), @@ -167,7 +263,7 @@ class HomeWanfaRecommendation(Base): category: Mapped["WanfaCategory"] = relationship(back_populates="recommendations") -class WanfaCategory(Base): +class WanfaCategory(AdminOwnedContent, Base): __tablename__ = "WanfaCategory" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -183,7 +279,7 @@ class WanfaCategory(Base): recommendations: Mapped[list[HomeWanfaRecommendation]] = relationship(back_populates="category") -class WanfaRoute(Base): +class WanfaRoute(AdminOwnedContent, Base): __tablename__ = "WanfaRoute" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -205,7 +301,7 @@ class WanfaRoute(Base): category: Mapped[WanfaCategory] = relationship(back_populates="routes") -class DetailRecord(Base): +class DetailRecord(AdminOwnedContent, Base): __tablename__ = "DetailRecord" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -227,7 +323,7 @@ class DetailRecord(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class ConciergeAdvisor(Base): +class ConciergeAdvisor(AdminOwnedContent, Base): __tablename__ = "ConciergeAdvisor" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -242,7 +338,7 @@ class ConciergeAdvisor(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class DemandHero(Base): +class DemandHero(AdminOwnedContent, Base): __tablename__ = "DemandHero" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -256,7 +352,7 @@ class DemandHero(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class DemandFeatureCard(Base): +class DemandFeatureCard(AdminOwnedContent, Base): __tablename__ = "DemandFeatureCard" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -268,7 +364,7 @@ class DemandFeatureCard(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class DemandForm(Base): +class DemandForm(AdminOwnedContent, Base): __tablename__ = "DemandForm" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) @@ -285,7 +381,7 @@ class DemandForm(Base): updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) -class Lead(Base): +class Lead(AdminOwnedContent, Base): __tablename__ = "Lead" id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) diff --git a/WonderQ-Admin/app/rbac.py b/WonderQ-Admin/app/rbac.py new file mode 100644 index 0000000..ae7bd6f --- /dev/null +++ b/WonderQ-Admin/app/rbac.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from sqlalchemy import false, or_, select +from sqlalchemy.orm import Session + +from .models import AdminDepartment, AdminMenu, AdminRole, AdminRoleDepartment, AdminRoleMenu, AdminUser, AdminUserDepartment, AdminUserRole +from .redis_session import AdminSessionStore, RedisUnavailableError + + +DATA_SCOPE_VALUES = {"all", "dept", "dept_and_children", "custom_dept", "self"} + + +def build_menu_tree(menus: Iterable[AdminMenu]) -> list[dict[str, Any]]: + active_menus = [menu for menu in menus if menu.isActive and (menu.type == "button" or menu.isVisible)] + nodes: dict[str, dict[str, Any]] = { + menu.id: { + "id": menu.id, + "name": menu.name, + "type": menu.type, + "path": menu.path, + "component": menu.componentKey, + "permission": menu.permissionCode, + "icon": menu.icon, + "children": [], + "sortOrder": menu.sortOrder, + } + for menu in active_menus + } + roots: list[dict[str, Any]] = [] + for menu in sorted(active_menus, key=lambda item: (item.sortOrder, item.id)): + node = nodes[menu.id] + if menu.parentId and menu.parentId in nodes: + nodes[menu.parentId]["children"].append(node) + elif not menu.parentId: + roots.append(node) + return roots + + +def flatten_permission_codes(menus: Iterable[AdminMenu]) -> set[str]: + return {menu.permissionCode for menu in menus if menu.isActive and menu.permissionCode} + + +def get_admin_roles(user: AdminUser, db: Session) -> list[AdminRole]: + if user.role in {"admin", "super_admin"}: + return list(db.scalars(select(AdminRole).where(AdminRole.isActive).order_by(AdminRole.code.asc())).all()) + role_ids = db.scalars(select(AdminUserRole.roleId).where(AdminUserRole.userId == user.id)).all() + if not role_ids: + return [] + return list(db.scalars(select(AdminRole).where(AdminRole.id.in_(role_ids), AdminRole.isActive)).all()) + + +def get_admin_menus(user: AdminUser, db: Session) -> list[AdminMenu]: + if user.role in {"admin", "super_admin"}: + return list(db.scalars(select(AdminMenu).where(AdminMenu.isActive).order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc())).all()) + role_ids = db.scalars(select(AdminUserRole.roleId).where(AdminUserRole.userId == user.id)).all() + if not role_ids: + return [] + return list( + db.scalars( + select(AdminMenu) + .join(AdminRoleMenu, AdminRoleMenu.menuId == AdminMenu.id) + .where(AdminRoleMenu.roleId.in_(role_ids), AdminMenu.isActive) + .distinct() + .order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc()) + ).all() + ) + + +def _build_permission_context(user: AdminUser, db: Session) -> dict[str, Any]: + roles = get_admin_roles(user, db) + menus = get_admin_menus(user, db) + permissions = flatten_permission_codes(menus) + if user.role in {"admin", "super_admin"}: + permissions.add("*") + user_dept_ids = set(db.scalars(select(AdminUserDepartment.deptId).where(AdminUserDepartment.userId == user.id)).all()) + return { + "roles": [{"id": role.id, "name": role.name, "code": role.code, "dataScope": role.dataScope} for role in roles], + "permissions": sorted(permissions), + "menus": build_menu_tree(menus), + "dataScopes": sorted({role.dataScope for role in roles if role.dataScope in DATA_SCOPE_VALUES} or {"all" if user.role in {"admin", "super_admin"} else "self"}), + "deptIds": sorted(user_dept_ids), + } + + +def build_admin_permission_context(user: AdminUser, db: Session, store: AdminSessionStore | None = None) -> dict[str, Any]: + if store: + try: + cached = store.get_permission_context(user.id) + if cached is not None: + return cached + except RedisUnavailableError: + # 权限缓存不可用时重新读取数据库;绝不将缓存故障降级为放行权限。 + pass + context = _build_permission_context(user, db) + if store: + try: + from .config import get_settings + + store.set_permission_context(user.id, context, get_settings().admin_permission_cache_seconds) + except RedisUnavailableError: + pass + return context + + +def user_has_permission(user: AdminUser, db: Session, permission: str) -> bool: + if user.role in {"admin", "super_admin"}: + return True + return permission in set(build_admin_permission_context(user, db)["permissions"]) + + +def require_admin_permission(permission: str): + from fastapi import Depends, HTTPException + + from .auth import require_admin + from .database import get_db + + def checker(user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)) -> AdminUser: + if not user_has_permission(user, db, permission): + raise HTTPException(status_code=403, detail="没有执行该操作的权限") + return user + + return checker + + +def _descendant_department_ids(db: Session, roots: set[str]) -> set[str]: + if not roots: + return set() + rows = db.execute(select(AdminDepartment.id, AdminDepartment.parentId)).all() + children: dict[str | None, set[str]] = {} + for department_id, parent_id in rows: + children.setdefault(parent_id, set()).add(department_id) + result = set(roots) + pending = list(roots) + while pending: + parent_id = pending.pop() + for child_id in children.get(parent_id, set()): + if child_id not in result: + result.add(child_id) + pending.append(child_id) + return result + + +def accessible_scope(user: AdminUser, db: Session) -> tuple[set[str] | None, bool]: + """Return (department ids, include self-created rows). None department ids means all.""" + if user.role in {"admin", "super_admin"}: + return None, True + roles = get_admin_roles(user, db) + if any(role.dataScope == "all" for role in roles): + return None, True + user_dept_ids = set(db.scalars(select(AdminUserDepartment.deptId).where(AdminUserDepartment.userId == user.id)).all()) + role_ids = {role.id for role in roles} + custom_dept_ids = set( + db.scalars(select(AdminRoleDepartment.deptId).where(AdminRoleDepartment.roleId.in_(role_ids))).all() + ) if role_ids else set() + dept_ids: set[str] = set() + include_self = False + for role in roles: + if role.dataScope == "dept": + dept_ids.update(user_dept_ids) + elif role.dataScope == "dept_and_children": + dept_ids.update(_descendant_department_ids(db, user_dept_ids)) + elif role.dataScope == "custom_dept": + dept_ids.update(custom_dept_ids) + elif role.dataScope == "self": + include_self = True + return dept_ids, include_self + + +def apply_data_scope(stmt, model, user: AdminUser, db: Session): + """Apply the five RBAC data scopes to an owned business model query.""" + dept_ids, include_self = accessible_scope(user, db) + if dept_ids is None: + return stmt + clauses = [] + dept_column = getattr(model, "deptId", None) + creator_column = getattr(model, "createdById", None) + if dept_column is not None and dept_ids: + clauses.append(dept_column.in_(dept_ids)) + if include_self and creator_column is not None: + clauses.append(creator_column == user.id) + return stmt.where(or_(*clauses) if clauses else false()) + + +def is_within_data_scope(item, user: AdminUser, db: Session) -> bool: + dept_ids, include_self = accessible_scope(user, db) + if dept_ids is None: + return True + item_dept_id = getattr(item, "deptId", None) + item_creator_id = getattr(item, "createdById", None) + return (item_dept_id is not None and item_dept_id in dept_ids) or (include_self and item_creator_id == user.id) diff --git a/WonderQ-Admin/app/redis_session.py b/WonderQ-Admin/app/redis_session.py new file mode 100644 index 0000000..ad03640 --- /dev/null +++ b/WonderQ-Admin/app/redis_session.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import hashlib +import json +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Protocol + +from .config import get_settings + + +class RedisUnavailableError(RuntimeError): + """Raised when the administrator session store cannot be reached.""" + + +def hash_refresh_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def new_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +@dataclass(frozen=True) +class SessionRecord: + session_id: str + user_id: str + access_jti: str + refresh_hash: str + access_expires_at: datetime + refresh_expires_at: datetime + + +class AdminSessionStore(Protocol): + def create( + self, + *, + session_id: str, + user_id: str, + access_jti: str, + refresh_hash: str, + access_expires_at: datetime, + refresh_expires_at: datetime, + ) -> None: ... + + def is_access_active(self, session_id: str, access_jti: str) -> bool: ... + + def get_by_refresh_hash(self, refresh_hash: str) -> SessionRecord | None: ... + + def rotate( + self, + *, + session_id: str, + refresh_hash: str, + access_jti: str, + refresh_hash_next: str, + access_expires_at: datetime, + refresh_expires_at: datetime, + ) -> bool: ... + + def revoke(self, session_id: str, refresh_hash: str | None = None) -> None: ... + + def get_permission_context(self, user_id: str) -> dict | None: ... + + def set_permission_context(self, user_id: str, context: dict, ttl_seconds: int) -> None: ... + + def invalidate_permission_cache(self, user_ids: list[str] | None = None) -> None: ... + + def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: ... + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class InMemoryAdminSessionStore: + def __init__(self) -> None: + self._sessions: dict[str, SessionRecord] = {} + self._refresh_index: dict[str, str] = {} + self._permission_cache: dict[str, tuple[dict, datetime]] = {} + self._login_attempts: dict[str, tuple[int, datetime]] = {} + + def create(self, **kwargs) -> None: + record = SessionRecord(**kwargs) + self._sessions[record.session_id] = record + self._refresh_index[record.refresh_hash] = record.session_id + + def is_access_active(self, session_id: str, access_jti: str) -> bool: + record = self._sessions.get(session_id) + return bool(record and record.access_jti == access_jti and record.access_expires_at > _now()) + + def get_by_refresh_hash(self, refresh_hash: str) -> SessionRecord | None: + session_id = self._refresh_index.get(refresh_hash) + record = self._sessions.get(session_id) if session_id else None + if not record or record.refresh_expires_at <= _now(): + return None + return record + + def rotate( + self, + *, + session_id: str, + refresh_hash: str, + access_jti: str, + refresh_hash_next: str, + access_expires_at: datetime, + refresh_expires_at: datetime, + ) -> bool: + record = self._sessions.get(session_id) + if not record or record.refresh_hash != refresh_hash or record.refresh_expires_at <= _now(): + return False + self._refresh_index.pop(refresh_hash, None) + next_record = SessionRecord( + session_id=session_id, + user_id=record.user_id, + access_jti=access_jti, + refresh_hash=refresh_hash_next, + access_expires_at=access_expires_at, + refresh_expires_at=refresh_expires_at, + ) + self._sessions[session_id] = next_record + self._refresh_index[refresh_hash_next] = session_id + return True + + def revoke(self, session_id: str, refresh_hash: str | None = None) -> None: + record = self._sessions.pop(session_id, None) + if record: + self._refresh_index.pop(record.refresh_hash, None) + if refresh_hash: + self._refresh_index.pop(refresh_hash, None) + + def get_permission_context(self, user_id: str) -> dict | None: + cached = self._permission_cache.get(user_id) + if not cached or cached[1] <= _now(): + self._permission_cache.pop(user_id, None) + return None + return cached[0] + + def set_permission_context(self, user_id: str, context: dict, ttl_seconds: int) -> None: + self._permission_cache[user_id] = (context, _now() + timedelta(seconds=ttl_seconds)) + + def invalidate_permission_cache(self, user_ids: list[str] | None = None) -> None: + if user_ids is None: + self._permission_cache.clear() + return + for user_id in user_ids: + self._permission_cache.pop(user_id, None) + + def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: + now = _now() + attempts, expires_at = self._login_attempts.get(identity, (0, now)) + if expires_at <= now: + attempts = 0 + expires_at = now + timedelta(seconds=window_seconds) + attempts += 1 + self._login_attempts[identity] = (attempts, expires_at) + return attempts <= limit + + +class RedisAdminSessionStore: + prefix = "wonderq:admin" + + def __init__(self) -> None: + try: + import redis + except ImportError as exc: + raise RedisUnavailableError("Redis 客户端未安装") from exc + try: + self.client = redis.Redis.from_url(get_settings().redis_url, decode_responses=True) + except Exception as exc: + raise RedisUnavailableError("Redis 会话存储不可用") from exc + + def _session_key(self, session_id: str) -> str: + return f"{self.prefix}:session:{session_id}" + + def _refresh_key(self, refresh_hash: str) -> str: + return f"{self.prefix}:refresh:{refresh_hash}" + + def _permission_key(self, user_id: str) -> str: + return f"{self.prefix}:permission:{user_id}" + + def _login_limit_key(self, identity: str) -> str: + return f"{self.prefix}:login-limit:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}" + + @staticmethod + def _serialize(record: SessionRecord) -> str: + return json.dumps( + { + "sessionId": record.session_id, + "userId": record.user_id, + "accessJti": record.access_jti, + "refreshHash": record.refresh_hash, + "accessExpiresAt": record.access_expires_at.isoformat(), + "refreshExpiresAt": record.refresh_expires_at.isoformat(), + } + ) + + @staticmethod + def _deserialize(value: str | None) -> SessionRecord | None: + if not value: + return None + try: + payload = json.loads(value) + return SessionRecord( + session_id=payload["sessionId"], + user_id=payload["userId"], + access_jti=payload["accessJti"], + refresh_hash=payload["refreshHash"], + access_expires_at=datetime.fromisoformat(payload["accessExpiresAt"]), + refresh_expires_at=datetime.fromisoformat(payload["refreshExpiresAt"]), + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RedisUnavailableError("Redis 会话数据无效") from exc + + @staticmethod + def _ttl(expires_at: datetime) -> int: + return max(1, int((expires_at - _now()).total_seconds())) + + def _ensure_available(self) -> None: + try: + self.client.ping() + except Exception as exc: + raise RedisUnavailableError("Redis 会话存储不可用") from exc + + def create(self, **kwargs) -> None: + record = SessionRecord(**kwargs) + self._ensure_available() + pipe = self.client.pipeline(transaction=True) + pipe.set(self._session_key(record.session_id), self._serialize(record), ex=self._ttl(record.refresh_expires_at)) + pipe.set(self._refresh_key(record.refresh_hash), record.session_id, ex=self._ttl(record.refresh_expires_at)) + try: + pipe.execute() + except Exception as exc: + raise RedisUnavailableError("Redis 会话创建失败") from exc + + def is_access_active(self, session_id: str, access_jti: str) -> bool: + self._ensure_available() + record = self._deserialize(self.client.get(self._session_key(session_id))) + return bool(record and record.access_jti == access_jti and record.access_expires_at > _now()) + + def get_by_refresh_hash(self, refresh_hash: str) -> SessionRecord | None: + self._ensure_available() + session_id = self.client.get(self._refresh_key(refresh_hash)) + if not session_id: + return None + return self._deserialize(self.client.get(self._session_key(session_id))) + + def rotate( + self, + *, + session_id: str, + refresh_hash: str, + access_jti: str, + refresh_hash_next: str, + access_expires_at: datetime, + refresh_expires_at: datetime, + ) -> bool: + self._ensure_available() + refresh_key = self._refresh_key(refresh_hash) + session_key = self._session_key(session_id) + try: + with self.client.pipeline() as pipe: + pipe.watch(refresh_key, session_key) + record = self._deserialize(pipe.get(session_key)) + if not record or record.refresh_hash != refresh_hash or record.refresh_expires_at <= _now(): + pipe.reset() + return False + next_record = SessionRecord( + session_id=session_id, + user_id=record.user_id, + access_jti=access_jti, + refresh_hash=refresh_hash_next, + access_expires_at=access_expires_at, + refresh_expires_at=refresh_expires_at, + ) + pipe.multi() + pipe.delete(refresh_key) + pipe.set(session_key, self._serialize(next_record), ex=self._ttl(refresh_expires_at)) + pipe.set(self._refresh_key(refresh_hash_next), session_id, ex=self._ttl(refresh_expires_at)) + pipe.execute() + return True + except Exception as exc: + if exc.__class__.__name__ == "WatchError": + return False + raise RedisUnavailableError("Redis 会话轮换失败") from exc + + def revoke(self, session_id: str, refresh_hash: str | None = None) -> None: + self._ensure_available() + record = self._deserialize(self.client.get(self._session_key(session_id))) + refresh_key = self._refresh_key(refresh_hash or record.refresh_hash) if record or refresh_hash else None + keys = [self._session_key(session_id)] + if refresh_key: + keys.append(refresh_key) + try: + self.client.delete(*keys) + except Exception as exc: + raise RedisUnavailableError("Redis 会话注销失败") from exc + + def get_permission_context(self, user_id: str) -> dict | None: + self._ensure_available() + try: + value = self.client.get(self._permission_key(user_id)) + if not value: + return None + payload = json.loads(value) + return payload if isinstance(payload, dict) else None + except (TypeError, json.JSONDecodeError) as exc: + raise RedisUnavailableError("Redis 权限缓存数据无效") from exc + except Exception as exc: + raise RedisUnavailableError("Redis 权限缓存读取失败") from exc + + def set_permission_context(self, user_id: str, context: dict, ttl_seconds: int) -> None: + self._ensure_available() + try: + self.client.set(self._permission_key(user_id), json.dumps(context), ex=max(1, ttl_seconds)) + except Exception as exc: + raise RedisUnavailableError("Redis 权限缓存写入失败") from exc + + def invalidate_permission_cache(self, user_ids: list[str] | None = None) -> None: + self._ensure_available() + try: + keys = [self._permission_key(user_id) for user_id in user_ids] if user_ids is not None else list(self.client.scan_iter(match=f"{self.prefix}:permission:*")) + if keys: + self.client.delete(*keys) + except Exception as exc: + raise RedisUnavailableError("Redis 权限缓存失效失败") from exc + + def allow_login_attempt(self, identity: str, limit: int, window_seconds: int) -> bool: + self._ensure_available() + key = self._login_limit_key(identity) + try: + attempts = int(self.client.incr(key)) + if attempts == 1: + self.client.expire(key, max(1, window_seconds)) + return attempts <= limit + except Exception as exc: + raise RedisUnavailableError("Redis 登录限流不可用") from exc + + +def get_admin_session_store() -> AdminSessionStore: + return RedisAdminSessionStore() diff --git a/WonderQ-Admin/app/routers/admin.py b/WonderQ-Admin/app/routers/admin.py index 3ab295b..46b0c8a 100644 --- a/WonderQ-Admin/app/routers/admin.py +++ b/WonderQ-Admin/app/routers/admin.py @@ -7,10 +7,10 @@ import re from email.utils import formatdate from uuid import uuid4 from urllib.parse import quote, urlsplit, urlunsplit -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile, status from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload -from ..auth import create_token, get_actor_id, require_admin, verify_password +from ..auth import get_actor_id, issue_admin_session, require_admin, rotate_admin_session, verify_password from ..api_response import success_response from ..config import get_settings from ..database import get_db @@ -37,6 +37,8 @@ from ..models import ( WanfaRoute, utc_now, ) +from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store, hash_refresh_token +from ..rbac import apply_data_scope, build_admin_permission_context, is_within_data_scope, require_admin_permission from ..schemas import ( LeadQuery, LeadStatus, @@ -434,8 +436,8 @@ def wanfa_route_dict(route: WanfaRoute) -> dict: } -def wanfa_category_dict(category: WanfaCategory) -> dict: - routes = sorted(category.routes or [], key=lambda item: item.sortOrder) +def wanfa_category_dict(category: WanfaCategory, scoped_routes=None) -> dict: + routes = sorted(category.routes if scoped_routes is None else scoped_routes or [], key=lambda item: item.sortOrder) return { "id": category.id, "label": category.label, @@ -490,6 +492,16 @@ def home_error(status_code: int, message: str, code: str, details: dict | None = raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}}) +def assign_created_by(item, user_id: str | None) -> None: + if user_id and hasattr(item, "createdById") and not getattr(item, "createdById", None): + item.createdById = user_id + + +def ensure_data_scope(item, user: AdminUser, db: Session, label: str) -> None: + if not is_within_data_scope(item, user, db): + raise HTTPException(status_code=404, detail=f"{label}不存在") + + def home_item_or_error(db: Session, model, item_id: str, label: str, code: str): item = db.get(model, item_id) if not item: @@ -501,6 +513,10 @@ def home_items(db: Session, model) -> list: return db.scalars(select(model).order_by(model.sortOrder.asc())).all() +def scoped_items(db: Session, model, user: AdminUser, order_column) -> list: + return db.scalars(apply_data_scope(select(model).order_by(order_column), model, user, db)).all() + + def validate_home_order(item_ids: list[str], current_ids: list[str]) -> None: if len(item_ids) != len(current_ids) or len(set(item_ids)) != len(item_ids) or set(item_ids) != set(current_ids): home_error(400, "排序项目必须完整且不能重复", "HOME_REORDER_INVALID") @@ -519,6 +535,7 @@ def create_home_item(db: Session, request: Request, body, model, entity: str): if body.sortOrder is None: payload["sortOrder"] = next_site_sort_order(db, model) item = model(**payload) + assign_created_by(item, get_actor_id(request)) db.add(item) db.flush() after = model_dict(item) @@ -752,6 +769,7 @@ def create_home_wanfa_recommendation( isActive=body.isActive, sortOrder=body.sortOrder if body.sortOrder is not None else ((next_sort + 1) if next_sort is not None else 0), ) + assign_created_by(recommendation, get_actor_id(request)) db.add(recommendation) db.flush() after = home_wanfa_recommendation_dict(recommendation) @@ -878,8 +896,8 @@ def validate_detail_order(item_ids: list[str], current_ids: list[str]) -> None: @router.get("/details") -def list_details(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - details = db.scalars(select(DetailRecord).order_by(DetailRecord.sortOrder.asc())).all() +def list_details(_user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)): + details = db.scalars(apply_data_scope(select(DetailRecord), DetailRecord, _user, db).order_by(DetailRecord.sortOrder.asc())).all() return success_response({"details": [detail_record_dict(detail) for detail in details]}) @@ -901,8 +919,11 @@ def reorder_details( @router.get("/details/{detail_id}") -def get_detail(detail_id: str, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return success_response(detail_record_dict(detail_or_error(db, detail_id))) +def get_detail(detail_id: str, _user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)): + detail = detail_or_error(db, detail_id) + if not is_within_data_scope(detail, _user, db): + raise HTTPException(status_code=404, detail="详情不存在") + return success_response(detail_record_dict(detail)) @router.post("/details", status_code=status.HTTP_201_CREATED) @@ -918,6 +939,7 @@ def create_detail( **body.model_dump(exclude={"sortOrder"}), sortOrder=body.sortOrder if body.sortOrder is not None else next_site_sort_order(db, DetailRecord), ) + assign_created_by(detail, get_actor_id(request)) db.add(detail) db.flush() after = detail_record_dict(detail) @@ -935,6 +957,7 @@ def update_detail( db: Session = Depends(get_db), ): detail = detail_or_error(db, detail_id) + ensure_data_scope(detail, _user, db, "详情") before = detail_record_dict(detail) if "key" in body.model_fields_set: detail_key_conflict(db, body.key or "", detail.id) @@ -957,6 +980,7 @@ def delete_detail( db: Session = Depends(get_db), ): detail = detail_or_error(db, detail_id) + ensure_data_scope(detail, _user, db, "详情") before = detail_record_dict(detail) db.delete(detail) remaining = db.scalars(select(DetailRecord).where(DetailRecord.id != detail.id).order_by(DetailRecord.sortOrder.asc())).all() @@ -968,11 +992,21 @@ def delete_detail( @router.get("/wanfa/categories") -def list_wanfa_categories(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): +def list_wanfa_categories(_user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)): categories = db.scalars( - select(WanfaCategory).options(selectinload(WanfaCategory.routes)).order_by(WanfaCategory.sortOrder.asc()) + apply_data_scope(select(WanfaCategory), WanfaCategory, _user, db).options(selectinload(WanfaCategory.routes)).order_by(WanfaCategory.sortOrder.asc()) ).all() - return success_response({"categories": [wanfa_category_dict(category) for category in categories]}) + return success_response( + { + "categories": [ + wanfa_category_dict( + category, + [route for route in category.routes or [] if is_within_data_scope(route, _user, db)], + ) + for category in categories + ] + } + ) @router.post("/wanfa/categories", status_code=status.HTTP_201_CREATED) @@ -983,6 +1017,7 @@ def create_wanfa_category( db: Session = Depends(get_db), ): category = WanfaCategory(label=body.label, sortOrder=next_site_sort_order(db, WanfaCategory)) + assign_created_by(category, get_actor_id(request)) db.add(category) db.flush() after = wanfa_category_dict(category) @@ -1017,6 +1052,7 @@ def update_wanfa_category( db: Session = Depends(get_db), ): category = wanfa_category_or_error(db, category_id) + ensure_data_scope(category, _user, db, "玩法分类") before = wanfa_category_dict(category) if "label" in body.model_fields_set: category.label = body.label @@ -1035,6 +1071,7 @@ def delete_wanfa_category( db: Session = Depends(get_db), ): category = wanfa_category_or_error(db, category_id) + ensure_data_scope(category, _user, db, "玩法分类") if category.routes: site_config_error(409, "玩法分类下仍有路线,不能删除", "WANFA_CATEGORY_NOT_EMPTY", {"categoryId": category.id}) recommendation_count = db.scalar( @@ -1065,6 +1102,7 @@ def create_wanfa_route( db: Session = Depends(get_db), ): category = wanfa_category_or_error(db, category_id) + ensure_data_scope(category, _user, db, "玩法分类") next_sort = db.scalar( select(func.max(WanfaRoute.sortOrder)).where(WanfaRoute.categoryId == category.id) ) @@ -1077,6 +1115,7 @@ def create_wanfa_route( demandKeyword=body.demandKeyword, sortOrder=(next_sort + 1) if next_sort is not None else 0, ) + assign_created_by(route, get_actor_id(request)) db.add(route) db.flush() after = wanfa_route_dict(route) @@ -1114,7 +1153,9 @@ def update_wanfa_route( db: Session = Depends(get_db), ): category = wanfa_category_or_error(db, category_id) + ensure_data_scope(category, _user, db, "玩法分类") route = wanfa_route_or_error(db, category, route_id) + ensure_data_scope(route, _user, db, "玩法路线") before = wanfa_route_dict(route) for field in ("title", "subtitle", "image", "routeCount", "demandKeyword"): if field in body.model_fields_set: @@ -1135,7 +1176,9 @@ def delete_wanfa_route( db: Session = Depends(get_db), ): category = wanfa_category_or_error(db, category_id) + ensure_data_scope(category, _user, db, "玩法分类") route = wanfa_route_or_error(db, category, route_id) + ensure_data_scope(route, _user, db, "玩法路线") before = wanfa_route_dict(route) db.delete(route) audit(db, get_actor_id(request), "delete", "wanfa_route", route.id, before=before) @@ -1144,8 +1187,8 @@ def delete_wanfa_route( @router.get("/concierge/advisors") -def list_concierge_advisors(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - advisors = db.scalars(select(ConciergeAdvisor).order_by(ConciergeAdvisor.sortOrder.asc())).all() +def list_concierge_advisors(_user: AdminUser = Depends(require_admin_permission("admin:concierge:read")), db: Session = Depends(get_db)): + advisors = db.scalars(apply_data_scope(select(ConciergeAdvisor), ConciergeAdvisor, _user, db).order_by(ConciergeAdvisor.sortOrder.asc())).all() return success_response({"advisors": [concierge_advisor_dict(advisor) for advisor in advisors]}) @@ -1161,6 +1204,7 @@ def create_concierge_advisor( if body.sortOrder is None: payload["sortOrder"] = next_site_sort_order(db, ConciergeAdvisor) advisor = ConciergeAdvisor(**payload) + assign_created_by(advisor, get_actor_id(request)) db.add(advisor) db.flush() after = concierge_advisor_dict(advisor) @@ -1195,6 +1239,7 @@ def update_concierge_advisor( db: Session = Depends(get_db), ): advisor = concierge_advisor_or_error(db, advisor_id) + ensure_data_scope(advisor, _user, db, "管家顾问") before = concierge_advisor_dict(advisor) payload = body.model_dump(exclude_unset=True) if "details" in payload: @@ -1216,6 +1261,7 @@ def delete_concierge_advisor( db: Session = Depends(get_db), ): advisor = concierge_advisor_or_error(db, advisor_id) + ensure_data_scope(advisor, _user, db, "管家顾问") before = concierge_advisor_dict(advisor) db.delete(advisor) db.flush() @@ -1229,66 +1275,175 @@ def delete_concierge_advisor( @router.post("/auth/login") -def login(body: LoginIn, db: Session = Depends(get_db)): +def login( + body: LoginIn, + request: Request, + response: Response, + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): + settings = get_settings() + client_host = request.client.host if request.client else "unknown" + identity = f"{client_host}:{body.email.strip().lower()}" + try: + if not store.allow_login_attempt(identity, settings.admin_login_rate_limit, settings.admin_login_rate_window_seconds): + raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试", headers={"Retry-After": str(settings.admin_login_rate_window_seconds)}) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc user = db.scalar(select(AdminUser).where(AdminUser.email == body.email)) if not user or not user.isActive or not verify_password(body.password, user.passwordHash): raise HTTPException(status_code=401, detail="账号或密码错误") + try: + access_token, refresh_token, expires_in = issue_admin_session(user, store) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc + result = success_response( + { + "token": access_token, + "accessToken": access_token, + "expiresIn": expires_in, + "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + } + ) + result.set_cookie( + key=settings.admin_refresh_cookie_name, + value=refresh_token, + httponly=True, + secure=settings.admin_refresh_cookie_secure, + samesite="lax", + path=settings.admin_refresh_cookie_path, + max_age=settings.admin_refresh_expires_days * 24 * 60 * 60, + ) + return result + + +@router.post("/auth/refresh") +def refresh_admin_session( + request: Request, + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): + settings = get_settings() + refresh_token = request.cookies.get(settings.admin_refresh_cookie_name) + if not refresh_token: + raise HTTPException(status_code=401, detail="请先登录后台") + try: + record = store.get_by_refresh_hash(hash_refresh_token(refresh_token)) + if not record: + raise HTTPException(status_code=401, detail="请先登录后台") + user = db.get(AdminUser, record.user_id) + if not user or not user.isActive: + store.revoke(record.session_id, record.refresh_hash) + raise HTTPException(status_code=401, detail="请先登录后台") + tokens = rotate_admin_session(user, record, store) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc + if not tokens: + raise HTTPException(status_code=401, detail="刷新令牌已失效") + access_token, next_refresh_token, expires_in = tokens + result = success_response( + { + "token": access_token, + "accessToken": access_token, + "expiresIn": expires_in, + "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + } + ) + result.set_cookie( + key=settings.admin_refresh_cookie_name, + value=next_refresh_token, + httponly=True, + secure=settings.admin_refresh_cookie_secure, + samesite="lax", + path=settings.admin_refresh_cookie_path, + max_age=settings.admin_refresh_expires_days * 24 * 60 * 60, + ) + return result + + +@router.post("/auth/logout") +def logout_admin_session( + request: Request, + store: AdminSessionStore = Depends(get_admin_session_store), +): + settings = get_settings() + refresh_token = request.cookies.get(settings.admin_refresh_cookie_name) + try: + if refresh_token: + record = store.get_by_refresh_hash(hash_refresh_token(refresh_token)) + if record: + store.revoke(record.session_id, record.refresh_hash) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc + result = success_response({"ok": True}) + result.delete_cookie(key=settings.admin_refresh_cookie_name, path=settings.admin_refresh_cookie_path) + return result + + +@router.get("/me") +def me( + user: AdminUser = Depends(require_admin), + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): return success_response( { - "token": create_token(user), - "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + **build_admin_permission_context(user, db, store), } ) -@router.get("/me") -def me(user: AdminUser = Depends(require_admin)): - return success_response({"id": user.id, "email": user.email, "name": user.name, "role": user.role}) - - @router.get("/dashboard") -def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): +def dashboard(_user: AdminUser = Depends(require_admin_permission("admin:dashboard")), db: Session = Depends(get_db)): + lead_query = apply_data_scope(select(Lead), Lead, _user, db) stats = { - "newLeadCount": db.scalar(select(func.count()).select_from(Lead).where(Lead.status == "new")), - "leadCount": db.scalar(select(func.count()).select_from(Lead)), + "newLeadCount": db.scalar(lead_query.where(Lead.status == "new").with_only_columns(func.count())), + "leadCount": db.scalar(lead_query.with_only_columns(func.count())), } recent = db.scalars( - select(Lead).options(selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5) + apply_data_scope(select(Lead), Lead, _user, db) + .options(selectinload(Lead.assignedUser)) + .order_by(Lead.createdAt.desc()) + .limit(5) ).all() return success_response({"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]}) @router.get("/site-config") -def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): +def admin_site_config(_user: AdminUser = Depends(require_admin_permission("admin:site-config")), db: Session = Depends(get_db)): return success_response( { "heroSlides": [ hero_slide_admin_dict(item) - for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all() + for item in scoped_items(db, HeroSlide, _user, HeroSlide.sortOrder.asc()) ], "destinationHero": [ model_dict(item) - for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all() + for item in scoped_items(db, DestinationHero, _user, DestinationHero.sortOrder.asc()) ], "vehicleOptions": [ model_dict(item) - for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all() + for item in scoped_items(db, VehicleOption, _user, VehicleOption.sortOrder.asc()) ], "vehicleService": [ model_dict(item) - for item in db.scalars(select(VehicleServiceConfig).order_by(VehicleServiceConfig.createdAt.asc())).all() + for item in scoped_items(db, VehicleServiceConfig, _user, VehicleServiceConfig.createdAt.asc()) ], "demandHero": [ model_dict(item) - for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all() + for item in scoped_items(db, DemandHero, _user, DemandHero.sortOrder.asc()) ], "demandFeatureCards": [ model_dict(item) - for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all() + for item in scoped_items(db, DemandFeatureCard, _user, DemandFeatureCard.sortOrder.asc()) ], "demandForm": [ model_dict(item) - for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all() + for item in scoped_items(db, DemandForm, _user, DemandForm.createdAt.asc()) ], } ) @@ -1308,6 +1463,7 @@ def create_site_config( if config.get("singleton") and module_items(db, config): site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module}) item = config["model"](**site_create_payload(module, config, body, db)) + assign_created_by(item, get_actor_id(request)) db.add(item) db.flush() after = site_item_dict(module, item) @@ -1403,7 +1559,7 @@ def list_leads( createdFrom: str | None = None, createdTo: str | None = None, take: int = Query(default=100, ge=1, le=200), - _user: AdminUser = Depends(require_admin), + _user: AdminUser = Depends(require_admin_permission("admin:leads:read")), db: Session = Depends(get_db), ): query = LeadQuery( @@ -1440,14 +1596,14 @@ def list_leads( Lead.note.ilike(pattern), ) ).distinct() - leads = db.scalars(stmt).all() + leads = db.scalars(apply_data_scope(stmt, Lead, _user, db)).all() return success_response({"items": [lead_dict(lead) for lead in leads]}) @router.patch("/leads/{lead_id}/status") -def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): +def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin_permission("admin:leads:update")), db: Session = Depends(get_db)): lead = db.get(Lead, lead_id) - if not lead: + if not lead or not is_within_data_scope(lead, _user, db): raise HTTPException(status_code=404, detail="线索不存在") before = model_dict(lead) lead.status = body.status @@ -1458,8 +1614,8 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user @router.get("/media-assets") -def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - assets = db.scalars(select(MediaAsset).order_by(MediaAsset.createdAt.desc()).limit(200)).all() +def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)): + assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all() return success_response({"items": [model_dict(asset) for asset in assets]}) @@ -1482,6 +1638,7 @@ def upload_media_asset( sizeBytes=size_bytes, group=safe_group, ) + assign_created_by(asset, get_actor_id(request)) db.add(asset) db.flush() after = model_dict(asset) diff --git a/WonderQ-Admin/app/routers/shared.py b/WonderQ-Admin/app/routers/shared.py index 7c30d1e..3db046b 100644 --- a/WonderQ-Admin/app/routers/shared.py +++ b/WonderQ-Admin/app/routers/shared.py @@ -4,6 +4,13 @@ from ..models import DemandFeatureCard, DemandForm, DemandHero, DestinationHero, from ..serializers import model_dict +def public_model_dict(item) -> dict: + result = model_dict(item) + result.pop("deptId", None) + result.pop("createdById", None) + return result + + def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict: hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc()) destination_hero_stmt = select(DestinationHero).order_by(DestinationHero.sortOrder.asc()) @@ -24,14 +31,14 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr hero_slides = db.scalars(hero_stmt).all() destination_hero = db.scalars(destination_hero_stmt).all() result = { - "heroSlides": [model_dict(item) for item in hero_slides], - "destinationHero": [model_dict(item) for item in destination_hero], + "heroSlides": [public_model_dict(item) for item in hero_slides], + "destinationHero": [public_model_dict(item) for item in destination_hero], } if include_public_extras: - result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()] + result["vehicleOptions"] = [public_model_dict(item) for item in db.scalars(vehicle_stmt).all()] vehicle_service = db.scalars(vehicle_service_stmt).first() - result["vehicleService"] = model_dict(vehicle_service) if vehicle_service else None - result["demandHero"] = [model_dict(item) for item in db.scalars(demand_hero_stmt).all()] - result["demandFeatureCards"] = [model_dict(item) for item in db.scalars(demand_feature_card_stmt).all()] - result["demandForm"] = [model_dict(item) for item in db.scalars(demand_form_stmt).all()] + result["vehicleService"] = public_model_dict(vehicle_service) if vehicle_service else None + result["demandHero"] = [public_model_dict(item) for item in db.scalars(demand_hero_stmt).all()] + result["demandFeatureCards"] = [public_model_dict(item) for item in db.scalars(demand_feature_card_stmt).all()] + result["demandForm"] = [public_model_dict(item) for item in db.scalars(demand_form_stmt).all()] return result diff --git a/WonderQ-Admin/app/routers/system.py b/WonderQ-Admin/app/routers/system.py new file mode 100644 index 0000000..81e53f9 --- /dev/null +++ b/WonderQ-Admin/app/routers/system.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from ..auth import get_actor_id, hash_password, require_admin +from ..database import get_db +from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store +from ..models import ( + AdminDepartment, + AdminMenu, + AdminRole, + AdminRoleDepartment, + AdminRoleMenu, + AdminUser, + AdminUserDepartment, + AdminUserRole, + AuditLog, + new_id, + utc_now, +) +from ..rbac import build_admin_permission_context, build_menu_tree, user_has_permission +from ..schemas import ( + AdminDepartmentCreate, + AdminDepartmentPatch, + AdminMenuCreate, + AdminMenuPatch, + AdminRoleCreate, + AdminRolePatch, + AdminUserCreate, + AdminUserPatch, +) +from ..serializers import model_dict + + +router = APIRouter(prefix="/api/admin/system", tags=["admin-system"]) + + +def require_permission(permission: str): + def checker(user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)) -> AdminUser: + if not user_has_permission(user, db, permission): + raise HTTPException(status_code=403, detail="没有执行该操作的权限") + return user + + return checker + + +def _audit(db: Session, request: Request, action: str, entity: str, entity_id: str | None, before=None, after=None) -> None: + db.add( + AuditLog( + actorId=get_actor_id(request), + action=action, + entity=entity, + entityId=entity_id, + before=before, + after=after, + ) + ) + + +def _role_dict(role: AdminRole, menu_ids: list[str] | None = None, dept_ids: list[str] | None = None) -> dict: + return { + "id": role.id, + "name": role.name, + "code": role.code, + "dataScope": role.dataScope, + "description": role.description, + "isActive": role.isActive, + "menuIds": menu_ids or [], + "deptIds": dept_ids or [], + "createdAt": role.createdAt, + "updatedAt": role.updatedAt, + } + + +def _menu_dict(menu: AdminMenu) -> dict: + return { + "id": menu.id, + "parentId": menu.parentId, + "name": menu.name, + "type": menu.type, + "path": menu.path, + "component": menu.componentKey, + "permission": menu.permissionCode, + "icon": menu.icon, + "sortOrder": menu.sortOrder, + "isVisible": menu.isVisible, + "isActive": menu.isActive, + "createdAt": menu.createdAt, + "updatedAt": menu.updatedAt, + } + + +def _dept_dict(dept: AdminDepartment) -> dict: + return { + "id": dept.id, + "parentId": dept.parentId, + "name": dept.name, + "sortOrder": dept.sortOrder, + "isActive": dept.isActive, + "createdAt": dept.createdAt, + "updatedAt": dept.updatedAt, + } + + +def _ensure_ids_exist(db: Session, model, ids: list[str], label: str) -> None: + if not ids: + return + existing = set(db.scalars(select(model.id).where(model.id.in_(ids))).all()) + missing = sorted(set(ids) - existing) + if missing: + raise HTTPException(status_code=400, detail={"msg": f"{label}不存在", "errorCode": "REFERENCE_NOT_FOUND", "details": {"ids": missing}}) + + +def _invalidate_permissions(store: AdminSessionStore, user_ids: list[str] | None = None) -> None: + try: + store.invalidate_permission_cache(user_ids) + except RedisUnavailableError as exc: + raise HTTPException(status_code=503, detail="后台权限缓存暂时不可用") from exc + + +@router.get("/profile") +def profile( + user: AdminUser = Depends(require_admin), + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): + context = build_admin_permission_context(user, db, store) + return { + "code": 200, + "msg": "success", + "data": { + "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + **context, + }, + } + + +@router.get("/users") +def list_users(_user: AdminUser = Depends(require_permission("system:user:read")), db: Session = Depends(get_db)): + users = list(db.scalars(select(AdminUser).order_by(AdminUser.createdAt.desc())).all()) + role_links = db.scalars(select(AdminUserRole)).all() + dept_links = db.scalars(select(AdminUserDepartment)).all() + role_map: dict[str, list[str]] = {} + dept_map: dict[str, list[str]] = {} + for link in role_links: + role_map.setdefault(link.userId, []).append(link.roleId) + for link in dept_links: + dept_map.setdefault(link.userId, []).append(link.deptId) + items = [ + { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "isActive": user.isActive, + "roleIds": role_map.get(user.id, []), + "deptIds": dept_map.get(user.id, []), + "createdAt": user.createdAt, + "updatedAt": user.updatedAt, + } + for user in users + ] + return {"code": 200, "msg": "success", "data": {"items": items}} + + +@router.post("/users", status_code=status.HTTP_201_CREATED) +def create_user( + body: AdminUserCreate, + request: Request, + _user: AdminUser = Depends(require_permission("system:user:create")), + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): + if db.scalar(select(AdminUser).where(AdminUser.email == body.email)): + raise HTTPException(status_code=409, detail="邮箱已存在") + _ensure_ids_exist(db, AdminRole, body.roleIds, "角色") + _ensure_ids_exist(db, AdminDepartment, body.deptIds, "部门") + user = AdminUser(email=body.email, name=body.name.strip(), passwordHash=hash_password(body.password), isActive=body.isActive) + db.add(user) + db.flush() + for role_id in body.roleIds: + db.add(AdminUserRole(userId=user.id, roleId=role_id)) + for dept_id in body.deptIds: + db.add(AdminUserDepartment(userId=user.id, deptId=dept_id)) + _audit(db, request, "create", "admin_user", user.id, after={"email": user.email, "name": user.name}) + db.commit() + _invalidate_permissions(store, [user.id]) + return {"code": 201, "msg": "success", "data": {"id": user.id}} + + +@router.patch("/users/{user_id}") +def update_user( + user_id: str, + body: AdminUserPatch, + request: Request, + current_user: AdminUser = Depends(require_permission("system:user:update")), + db: Session = Depends(get_db), + store: AdminSessionStore = Depends(get_admin_session_store), +): + user = db.get(AdminUser, user_id) + if not user: + raise HTTPException(status_code=404, detail="管理员不存在") + if user.id == current_user.id and body.isActive is False: + raise HTTPException(status_code=400, detail="不能停用当前登录账号") + _ensure_ids_exist(db, AdminRole, body.roleIds or [], "角色") + _ensure_ids_exist(db, AdminDepartment, body.deptIds or [], "部门") + before = model_dict(user) + if body.name is not None: + user.name = body.name.strip() + if body.password is not None: + user.passwordHash = hash_password(body.password) + if body.isActive is not None: + user.isActive = body.isActive + if body.roleIds is not None: + db.execute(delete(AdminUserRole).where(AdminUserRole.userId == user.id)) + for role_id in body.roleIds: + db.add(AdminUserRole(userId=user.id, roleId=role_id)) + if body.deptIds is not None: + db.execute(delete(AdminUserDepartment).where(AdminUserDepartment.userId == user.id)) + for dept_id in body.deptIds: + db.add(AdminUserDepartment(userId=user.id, deptId=dept_id)) + db.flush() + _audit(db, request, "update", "admin_user", user.id, before=before, after=model_dict(user)) + db.commit() + _invalidate_permissions(store, [user.id]) + return {"code": 200, "msg": "success", "data": {"id": user.id}} + + +@router.get("/roles") +def list_roles(_user: AdminUser = Depends(require_permission("system:role:read")), db: Session = Depends(get_db)): + roles = list(db.scalars(select(AdminRole).order_by(AdminRole.createdAt.asc())).all()) + menu_links = db.scalars(select(AdminRoleMenu)).all() + dept_links = db.scalars(select(AdminRoleDepartment)).all() + menu_map: dict[str, list[str]] = {} + dept_map: dict[str, list[str]] = {} + for link in menu_links: + menu_map.setdefault(link.roleId, []).append(link.menuId) + for link in dept_links: + dept_map.setdefault(link.roleId, []).append(link.deptId) + return {"code": 200, "msg": "success", "data": {"items": [_role_dict(role, menu_map.get(role.id, []), dept_map.get(role.id, [])) for role in roles]}} + + +@router.get("/menus") +def list_menus(_user: AdminUser = Depends(require_permission("system:menu:read")), db: Session = Depends(get_db)): + menus = list(db.scalars(select(AdminMenu).order_by(AdminMenu.sortOrder.asc(), AdminMenu.id.asc())).all()) + return {"code": 200, "msg": "success", "data": {"items": [_menu_dict(menu) for menu in menus], "tree": build_menu_tree(menus)}} + + +@router.get("/depts") +def list_depts(_user: AdminUser = Depends(require_permission("system:dept:read")), db: Session = Depends(get_db)): + depts = list(db.scalars(select(AdminDepartment).order_by(AdminDepartment.sortOrder.asc(), AdminDepartment.id.asc())).all()) + return {"code": 200, "msg": "success", "data": {"items": [_dept_dict(dept) for dept in depts]}} + + +@router.post("/roles", status_code=status.HTTP_201_CREATED) +def create_role(body: AdminRoleCreate, request: Request, _user: AdminUser = Depends(require_permission("system:role:create")), db: Session = Depends(get_db)): + if db.scalar(select(AdminRole).where(AdminRole.code == body.code)): + raise HTTPException(status_code=409, detail="角色编码已存在") + _ensure_ids_exist(db, AdminMenu, body.menuIds, "菜单") + _ensure_ids_exist(db, AdminDepartment, body.deptIds, "部门") + role = AdminRole(name=body.name.strip(), code=body.code, dataScope=body.dataScope, description=body.description, isActive=body.isActive) + db.add(role) + db.flush() + for menu_id in body.menuIds: + db.add(AdminRoleMenu(roleId=role.id, menuId=menu_id)) + for dept_id in body.deptIds: + db.add(AdminRoleDepartment(roleId=role.id, deptId=dept_id)) + _audit(db, request, "create", "admin_role", role.id, after={"code": role.code}) + db.commit() + return {"code": 201, "msg": "success", "data": {"id": role.id}} + + +@router.patch("/roles/{role_id}") +def update_role(role_id: str, body: AdminRolePatch, request: Request, _user: AdminUser = Depends(require_permission("system:role:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)): + role = db.get(AdminRole, role_id) + if not role: + raise HTTPException(status_code=404, detail="角色不存在") + _ensure_ids_exist(db, AdminMenu, body.menuIds or [], "菜单") + _ensure_ids_exist(db, AdminDepartment, body.deptIds or [], "部门") + if body.name is not None: + role.name = body.name.strip() + if body.code is not None: + role.code = body.code + if body.dataScope is not None: + role.dataScope = body.dataScope + if body.description is not None: + role.description = body.description + if body.isActive is not None: + role.isActive = body.isActive + if body.menuIds is not None: + db.execute(delete(AdminRoleMenu).where(AdminRoleMenu.roleId == role.id)) + for menu_id in body.menuIds: + db.add(AdminRoleMenu(roleId=role.id, menuId=menu_id)) + if body.deptIds is not None: + db.execute(delete(AdminRoleDepartment).where(AdminRoleDepartment.roleId == role.id)) + for dept_id in body.deptIds: + db.add(AdminRoleDepartment(roleId=role.id, deptId=dept_id)) + db.flush() + _audit(db, request, "update", "admin_role", role.id, after=_role_dict(role)) + affected_user_ids = list(db.scalars(select(AdminUserRole.userId).where(AdminUserRole.roleId == role.id)).all()) + db.commit() + _invalidate_permissions(store, affected_user_ids) + return {"code": 200, "msg": "success", "data": {"id": role.id}} + + +@router.post("/menus", status_code=status.HTTP_201_CREATED) +def create_menu(body: AdminMenuCreate, request: Request, _user: AdminUser = Depends(require_permission("system:menu:create")), db: Session = Depends(get_db)): + if body.parentId and not db.get(AdminMenu, body.parentId): + raise HTTPException(status_code=400, detail="父级菜单不存在") + menu = AdminMenu(**body.model_dump()) + menu.componentKey = body.componentKey + db.add(menu) + db.flush() + _audit(db, request, "create", "admin_menu", menu.id, after=_menu_dict(menu)) + db.commit() + return {"code": 201, "msg": "success", "data": {"id": menu.id}} + + +@router.patch("/menus/{menu_id}") +def update_menu(menu_id: str, body: AdminMenuPatch, request: Request, _user: AdminUser = Depends(require_permission("system:menu:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)): + menu = db.get(AdminMenu, menu_id) + if not menu: + raise HTTPException(status_code=404, detail="菜单不存在") + if body.parentId == menu.id: + raise HTTPException(status_code=400, detail="菜单不能将自身设置为父级") + for field, value in body.model_dump(exclude_unset=True).items(): + if field == "componentKey": + setattr(menu, "componentKey", value) + else: + setattr(menu, field, value) + db.flush() + _audit(db, request, "update", "admin_menu", menu.id, after=_menu_dict(menu)) + db.commit() + _invalidate_permissions(store) + return {"code": 200, "msg": "success", "data": {"id": menu.id}} + + +@router.post("/depts", status_code=status.HTTP_201_CREATED) +def create_dept(body: AdminDepartmentCreate, request: Request, _user: AdminUser = Depends(require_permission("system:dept:create")), db: Session = Depends(get_db)): + if body.parentId and not db.get(AdminDepartment, body.parentId): + raise HTTPException(status_code=400, detail="父级部门不存在") + dept = AdminDepartment(**body.model_dump()) + db.add(dept) + db.flush() + _audit(db, request, "create", "admin_department", dept.id, after=_dept_dict(dept)) + db.commit() + return {"code": 201, "msg": "success", "data": {"id": dept.id}} + + +@router.patch("/depts/{dept_id}") +def update_dept(dept_id: str, body: AdminDepartmentPatch, request: Request, _user: AdminUser = Depends(require_permission("system:dept:update")), db: Session = Depends(get_db), store: AdminSessionStore = Depends(get_admin_session_store)): + dept = db.get(AdminDepartment, dept_id) + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + if body.parentId == dept.id: + raise HTTPException(status_code=400, detail="部门不能将自身设置为父级") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(dept, field, value) + db.flush() + _audit(db, request, "update", "admin_department", dept.id, after=_dept_dict(dept)) + db.commit() + _invalidate_permissions(store) + return {"code": 200, "msg": "success", "data": {"id": dept.id}} diff --git a/WonderQ-Admin/app/schemas.py b/WonderQ-Admin/app/schemas.py index b5995c2..a822e08 100644 --- a/WonderQ-Admin/app/schemas.py +++ b/WonderQ-Admin/app/schemas.py @@ -658,3 +658,84 @@ class ConciergeAdvisorPatch(BaseModel): @classmethod def normalize_patch_details(cls, value: list[dict[str, str]]) -> list[dict[str, str]]: return [ConciergeDetailIn.model_validate(detail).model_dump() for detail in value] + + +DataScope = Literal["all", "dept", "dept_and_children", "custom_dept", "self"] +MenuType = Literal["directory", "page", "button"] + + +class AdminRoleCreate(BaseModel): + name: str = Field(min_length=1, max_length=80) + code: str = Field(min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_:-]*$") + dataScope: DataScope = "self" + description: str | None = Field(default=None, max_length=500) + isActive: bool = True + menuIds: list[str] = Field(default_factory=list, max_length=500) + deptIds: list[str] = Field(default_factory=list, max_length=500) + + +class AdminRolePatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=80) + code: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_:-]*$") + dataScope: DataScope | None = None + description: str | None = Field(default=None, max_length=500) + isActive: bool | None = None + menuIds: list[str] | None = Field(default=None, max_length=500) + deptIds: list[str] | None = Field(default=None, max_length=500) + + +class AdminMenuCreate(BaseModel): + parentId: str | None = None + name: str = Field(min_length=1, max_length=80) + type: MenuType = "page" + path: str | None = Field(default=None, max_length=200) + componentKey: str | None = Field(default=None, max_length=120) + permissionCode: str | None = Field(default=None, max_length=120) + icon: str | None = Field(default=None, max_length=80) + sortOrder: int = Field(default=0, ge=0) + isVisible: bool = True + isActive: bool = True + + +class AdminMenuPatch(BaseModel): + parentId: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=80) + type: MenuType | None = None + path: str | None = Field(default=None, max_length=200) + componentKey: str | None = Field(default=None, max_length=120) + permissionCode: str | None = Field(default=None, max_length=120) + icon: str | None = Field(default=None, max_length=80) + sortOrder: int | None = Field(default=None, ge=0) + isVisible: bool | None = None + isActive: bool | None = None + + +class AdminDepartmentCreate(BaseModel): + parentId: str | None = None + name: str = Field(min_length=1, max_length=120) + sortOrder: int = Field(default=0, ge=0) + isActive: bool = True + + +class AdminDepartmentPatch(BaseModel): + parentId: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=120) + sortOrder: int | None = Field(default=None, ge=0) + isActive: bool | None = None + + +class AdminUserCreate(BaseModel): + email: EmailStr + name: str = Field(min_length=1, max_length=80) + password: str = Field(min_length=8, max_length=128) + isActive: bool = True + roleIds: list[str] = Field(default_factory=list, max_length=100) + deptIds: list[str] = Field(default_factory=list, max_length=100) + + +class AdminUserPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=80) + password: str | None = Field(default=None, min_length=8, max_length=128) + isActive: bool | None = None + roleIds: list[str] | None = Field(default=None, max_length=100) + deptIds: list[str] | None = Field(default=None, max_length=100) diff --git a/WonderQ-Admin/docker-compose.yml b/WonderQ-Admin/docker-compose.yml index 23234f6..b09af10 100644 --- a/WonderQ-Admin/docker-compose.yml +++ b/WonderQ-Admin/docker-compose.yml @@ -6,6 +6,7 @@ services: environment: DATABASE_URL: postgresql://miniapp:miniapp_dev_password@postgres:5432/miniapp JWT_SECRET: replace-with-a-long-random-secret-before-production + REDIS_URL: redis://redis:6379/0 PORT: 4000 LOG_LEVEL: info ports: diff --git a/WonderQ-Admin/pyproject.toml b/WonderQ-Admin/pyproject.toml index 9209897..168a818 100644 --- a/WonderQ-Admin/pyproject.toml +++ b/WonderQ-Admin/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "psycopg[binary]>=3.2,<3.3", "pydantic-settings>=2.6,<2.7", "PyJWT>=2.10,<2.11", + "redis>=5.2,<6", "bcrypt>=4.2,<4.3", "email-validator>=2.2,<2.3", "socksio>=1.0.0,<2.0.0", diff --git a/WonderQ-Admin/requirements.txt b/WonderQ-Admin/requirements.txt index 7edcd38..498d9a3 100644 --- a/WonderQ-Admin/requirements.txt +++ b/WonderQ-Admin/requirements.txt @@ -6,6 +6,7 @@ alembic>=1.14,<1.15 psycopg[binary]>=3.2,<3.3 pydantic-settings>=2.6,<2.7 PyJWT>=2.10,<2.11 +redis>=5.2,<6 bcrypt>=4.2,<4.3 email-validator>=2.2,<2.3 pytest>=8.3,<8.4 diff --git a/WonderQ-Admin/tests/test_admin_rbac.py b/WonderQ-Admin/tests/test_admin_rbac.py new file mode 100644 index 0000000..2d7866e --- /dev/null +++ b/WonderQ-Admin/tests/test_admin_rbac.py @@ -0,0 +1,222 @@ +from datetime import datetime, timedelta, timezone +import importlib.util +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.auth import decode_admin_access_token, hash_password +from app.database import get_db +from app.main import create_app +from app.models import AdminDepartment, AdminMenu, AdminRole, AdminRoleDepartment, AdminRoleMenu, AdminUser, AdminUserDepartment, AdminUserRole, Lead, MediaAsset +from app.rbac import DATA_SCOPE_VALUES, build_menu_tree +from app.redis_session import ( + InMemoryAdminSessionStore, + get_admin_session_store, + hash_refresh_token, +) + + +class LoginDb: + def __init__(self, user: AdminUser): + self.user = user + + def scalar(self, _statement): + return self.user + + def get(self, model, identifier): + return self.user if model is AdminUser and identifier == self.user.id else None + + class _EmptyResult: + def all(self): + return [] + + def scalars(self, _statement): + return self._EmptyResult() + + +def test_refresh_session_rotation_rejects_reuse(): + store = InMemoryAdminSessionStore() + now = datetime.now(timezone.utc) + store.create( + session_id="session-1", + user_id="admin-1", + access_jti="access-1", + refresh_hash=hash_refresh_token("refresh-1"), + access_expires_at=now + timedelta(minutes=15), + refresh_expires_at=now + timedelta(days=7), + ) + + assert store.is_access_active("session-1", "access-1") + assert store.rotate( + session_id="session-1", + refresh_hash=hash_refresh_token("refresh-1"), + access_jti="access-2", + refresh_hash_next=hash_refresh_token("refresh-2"), + access_expires_at=now + timedelta(minutes=15), + refresh_expires_at=now + timedelta(days=7), + ) + assert not store.is_access_active("session-1", "access-1") + assert store.is_access_active("session-1", "access-2") + assert not store.rotate( + session_id="session-1", + refresh_hash=hash_refresh_token("refresh-1"), + access_jti="access-3", + refresh_hash_next=hash_refresh_token("refresh-3"), + access_expires_at=now + timedelta(minutes=15), + refresh_expires_at=now + timedelta(days=7), + ) + + +def test_in_memory_store_supports_login_limit_and_permission_cache(): + store = InMemoryAdminSessionStore() + assert store.allow_login_attempt("ip:admin@example.com", 2, 60) + assert store.allow_login_attempt("ip:admin@example.com", 2, 60) + assert not store.allow_login_attempt("ip:admin@example.com", 2, 60) + store.set_permission_context("admin-1", {"permissions": ["admin:read"]}, 60) + assert store.get_permission_context("admin-1") == {"permissions": ["admin:read"]} + + +def test_admin_access_token_contains_scoped_session_claims(): + user = AdminUser( + id="admin-1", + email="admin@example.com", + name="Admin", + role="admin", + passwordHash=hash_password("ChangeMe123!", rounds=4), + isActive=True, + ) + + from app.auth import create_access_token + + token = create_access_token(user, session_id="session-1", access_jti="access-1") + payload = decode_admin_access_token(token) + + assert payload["typ"] == "admin_access" + assert payload["aud"] == "admin" + assert payload["sid"] == "session-1" + assert payload["jti"] == "access-1" + assert payload["role"] == "admin" + + +def test_admin_login_keeps_legacy_fields_and_sets_http_only_refresh_cookie(): + user = AdminUser( + id="admin-1", + email="admin@example.com", + name="Admin", + role="admin", + passwordHash=hash_password("ChangeMe123!", rounds=4), + isActive=True, + ) + app = create_app() + app.dependency_overrides[get_db] = lambda: LoginDb(user) + app.dependency_overrides[get_admin_session_store] = lambda: InMemoryAdminSessionStore() + + try: + response = TestClient(app).post( + "/api/admin/auth/login", + json={"email": "admin@example.com", "password": "ChangeMe123!"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json()["data"] + assert body["token"] == body["accessToken"] + assert body["expiresIn"] > 0 + assert body["user"]["id"] == "admin-1" + cookie = response.headers["set-cookie"] + assert "HttpOnly" in cookie + assert "Path=/api/admin/auth" in cookie + + +def test_refresh_rotates_access_token_and_logout_revokes_it(): + user = AdminUser( + id="admin-1", + email="admin@example.com", + name="Admin", + role="admin", + passwordHash=hash_password("ChangeMe123!", rounds=4), + isActive=True, + ) + store = InMemoryAdminSessionStore() + app = create_app() + app.dependency_overrides[get_db] = lambda: LoginDb(user) + app.dependency_overrides[get_admin_session_store] = lambda: store + + try: + client = TestClient(app) + login_response = client.post( + "/api/admin/auth/login", + json={"email": "admin@example.com", "password": "ChangeMe123!"}, + ) + old_token = login_response.json()["data"]["accessToken"] + + refresh_response = client.post("/api/admin/auth/refresh") + new_token = refresh_response.json()["data"]["accessToken"] + + assert refresh_response.status_code == 200 + assert new_token != old_token + assert client.get("/api/admin/me", headers={"Authorization": f"Bearer {old_token}"}).status_code == 401 + assert client.get("/api/admin/me", headers={"Authorization": f"Bearer {new_token}"}).status_code == 200 + + logout_response = client.post("/api/admin/auth/logout") + assert logout_response.status_code == 200 + assert client.get("/api/admin/me", headers={"Authorization": f"Bearer {new_token}"}).status_code == 401 + finally: + app.dependency_overrides.clear() + + +def test_rbac_models_define_normalized_association_tables(): + assert AdminRole.__tablename__ == "AdminRole" + assert AdminMenu.__tablename__ == "AdminMenu" + assert AdminDepartment.__tablename__ == "AdminDepartment" + assert {AdminUserRole.__table__.c.userId.name, AdminUserRole.__table__.c.roleId.name} == {"userId", "roleId"} + assert {AdminRoleMenu.__table__.c.roleId.name, AdminRoleMenu.__table__.c.menuId.name} == {"roleId", "menuId"} + assert {AdminRoleDepartment.__table__.c.roleId.name, AdminRoleDepartment.__table__.c.deptId.name} == {"roleId", "deptId"} + assert {AdminUserDepartment.__table__.c.userId.name, AdminUserDepartment.__table__.c.deptId.name} == {"userId", "deptId"} + + +def test_rbac_migration_is_appended_to_current_head(): + migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "0025_admin_rbac.py" + spec = importlib.util.spec_from_file_location("admin_rbac_migration", migration_path) + assert spec and spec.loader + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + assert migration.down_revision == "0024_vehicle_demand" + assert set(migration.RBAC_TABLES) == { + "AdminRole", + "AdminMenu", + "AdminDepartment", + "AdminUserRole", + "AdminRoleMenu", + "AdminUserDepartment", + "AdminRoleDepartment", + } + + +def test_ownership_migration_is_appended_after_rbac_and_models_expose_scope_fields(): + migration_path = Path(__file__).parents[1] / "alembic" / "versions" / "0026_admin_ownership.py" + spec = importlib.util.spec_from_file_location("admin_ownership_migration", migration_path) + assert spec and spec.loader + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + + assert migration.down_revision == "0025_admin_rbac" + assert {"deptId", "createdById"}.issubset(Lead.__table__.c.keys()) + assert {"deptId", "createdById"}.issubset(MediaAsset.__table__.c.keys()) + assert "Lead" in migration.OWNED_TABLES + + +def test_dynamic_menu_tree_preserves_parent_order_and_button_permissions(): + menus = [ + AdminMenu(id="child", parentId="root", name="按钮", type="button", permissionCode="home:edit", sortOrder=20, isVisible=True, isActive=True), + AdminMenu(id="root", parentId=None, name="首页", type="page", path="/home", componentKey="HomePage", permissionCode="home:read", sortOrder=10, isVisible=True, isActive=True), + AdminMenu(id="hidden", parentId=None, name="隐藏", type="page", sortOrder=30, isVisible=False, isActive=True), + ] + + tree = build_menu_tree(menus) + + assert DATA_SCOPE_VALUES == {"all", "dept", "dept_and_children", "custom_dept", "self"} + assert [item["id"] for item in tree] == ["root"] + assert tree[0]["children"][0]["permission"] == "home:edit" diff --git a/docs/README.md b/docs/README.md index 832084f..e5fbfa9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ MiniAPP 前台开发: | `api-response-contract.md` | 三端统一 JSON 响应包裹、错误和客户端解包规则 | 全部 | | `integration-workflow.md` | 三端本地启动、联调顺序、接口变更流程和验证命令 | 全部 | | `development-status.md` | 三端能力对接状态矩阵和优先联调路径 | 全部 | +| `admin-business-function-matrix.md` | Admin A+B 重构业务覆盖、隐藏接口和删除闸门矩阵 | 全部 | | `decisions.md` | 当前有效技术和文档决策 | 全部 | | `backend-api-service.md` | 后端 API 服务运行与前端联调说明 | 后端 | | `backend-plan.md` | 当前 FastAPI 后端定位、业务模块、近期优先级和安全部署原则 | 后端 | diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index e52c3b3..26a68ac 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -27,7 +27,14 @@ | 方法 | 路径 | 用途 | | -------- | ----------------------------------------- | -------------------- | | `POST` | `/api/admin/auth/login` | 后台登录 | +| `POST` | `/api/admin/auth/refresh` | 使用 HttpOnly Cookie 刷新后台访问令牌 | +| `POST` | `/api/admin/auth/logout` | 撤销当前后台会话 | | `GET` | `/api/admin/me` | 当前后台用户 | +| `GET` | `/api/admin/system/profile` | 当前用户、角色、权限码和动态菜单 | +| `GET/POST/PATCH` | `/api/admin/system/users` | 管理后台用户 | +| `GET/POST/PATCH` | `/api/admin/system/roles` | 管理角色和数据范围 | +| `GET/POST/PATCH` | `/api/admin/system/menus` | 管理目录、页面和按钮 | +| `GET/POST/PATCH` | `/api/admin/system/depts` | 管理部门 | | `GET` | `/api/admin/dashboard` | 工作台统计和最近线索 | | `GET` | `/api/admin/site-config` | 获取全部站点配置 | | `POST` | `/api/admin/site-config/{module}` | 新增模块项 | @@ -88,7 +95,15 @@ type SiteModule = { "email": "admin@example.test", "password": "" } ``` -成功响应包裹为 `data: { token, user: { id, email, name, role } }`;具体字段结构保持现有登录接口约定。 +成功响应包裹为 `data: { token, accessToken, expiresIn, user: { id, email, name, role } }`。`token` 保留给过渡期 React 管理端,`accessToken` 为短时访问令牌;Refresh Token 只通过同域 HttpOnly Cookie 返回,不进入 JSON。 + +管理员登录、刷新和退出依赖 Redis 会话存储。Refresh Token 轮换后旧令牌立即失效;Redis 不可用时认证接口返回 `503`,不降级为无会话校验。 + +`/api/admin/system/profile` 返回 `roles`、`permissions`、`menus`、`dataScopes` 和 `deptIds`。菜单只返回启用且可见的目录/页面,按钮菜单保留在页面节点的 `children` 中;前端组件只能从预注册组件白名单加载 `component`。 + +角色数据范围使用以下五个编码:`all`(全部)、`dept`(当前部门)、`dept_and_children`(当前部门及子部门)、`custom_dept`(自定义部门)、`self`(本人)。运营资源通过 `deptId` 和 `createdById` 归属字段执行查询过滤;资源归属迁移完成前不得在生产环境切换非管理员角色。 + +登录按 IP 与账号组合执行 Redis 限流,默认 60 秒最多 5 次;权限菜单缓存默认 300 秒。Redis 故障不能放行权限检查,缓存不可用时只能重新读取数据库,认证会话和限流不可用时返回 `503`。 ## 兼容边界 diff --git a/docs/admin-business-function-matrix.md b/docs/admin-business-function-matrix.md new file mode 100644 index 0000000..f1f4421 --- /dev/null +++ b/docs/admin-business-function-matrix.md @@ -0,0 +1,39 @@ +# WonderQ Admin 业务功能矩阵 + +本文档是 A+B 并行重构的第一阶段基线。矩阵以当前后端路由、契约文档、React 页面和 MiniAPP 公共接口为准;“已接入”只表示代码已具备,不代表已经完成人工验收。 + +## 端与路径 + +| 端 | 目录 | 当前路径 | 迁移策略 | +| --- | --- | --- | --- | +| 后端 | `WonderQ-Admin` | `/api` | 保持 Docker Compose、PostgreSQL、Redis、JWT 和 `{code,msg,data}` 契约 | +| Vue 管理端 | `WonderQ-Admin-UI-Vue` | 本地 `/admin/`,生产 `/admin` | 新权限壳和业务切片逐项迁移 | +| React 管理端 | `WonderQ-Admin-UI` | 本地 `5602`,生产 `/admin-legacy` | 过渡运行,已接入 Refresh Token 自动刷新 | +| 前台 | `WonderQ-MiniAPP` | H5/微信小程序 | 不整体重写,只验证 Public API 兼容 | + +## 业务覆盖矩阵 + +| 业务切片 | 后端接口/数据 | React 过渡端 | Vue 迁移端 | MiniAPP 依赖 | 当前验收状态 | +| --- | --- | --- | --- | --- | --- | +| 登录、刷新、退出 | `/api/admin/auth/login`、`refresh`、`logout`、`/api/admin/me` | 兼容旧 `token/user` 字段,401 自动刷新 | 登录页、内存 Access Token、HttpOnly Refresh Cookie | 无 | 自动化通过,待浏览器人工验收 | +| 仪表盘 | `/api/admin/dashboard` | 已有 | `DashboardPage` 已接入 | 无 | 读流程已接入 | +| 首页与站点配置 | `/api/admin/site-config` 及模块 CRUD、排序 | 已有 | `HomePage` 已读取全量配置;编辑/排序待迁移 | `/api/public/home`、`/api/public/site-config` | 读契约保持,编辑待验收 | +| 玩法分类与路线 | `/api/admin/wanfa/categories` 及分类/路线 CRUD | 已有 | `WanfaPage` 已读取分类和路线;写操作待迁移 | `/api/public/wanfa/categories` | 读流程已接入,写流程待迁移 | +| 路线详情 | `/api/admin/details` 及详情 CRUD | React 现有玩法流程依赖 | Vue API 类型和页面待补齐 | `/api/public/details/{key}` | 待迁移 | +| 管家 | `/api/admin/concierge/advisors` 及 CRUD | 已有 | `ConciergePage` 已读取顾问;写操作待迁移 | `/api/public/concierge/advisors`、详情顾问信息 | 读流程已接入,写流程待迁移 | +| 需求线索 | `/api/admin/leads`、状态更新 | 已有筛选和状态流转 | `LeadsPage` 已读取;状态更新/筛选待迁移 | `/api/public/leads` | 读流程已接入,写流程待迁移 | +| 媒体 | `/api/admin/media-assets`、上传 | React 已有上传 | Vue 页面和上传流程待迁移 | 通过站点配置间接依赖 | 待迁移 | +| 发布、重置 | `/api/admin/publish`、`reset-guizhou-content` | React 现有入口/能力 | Vue 按权限增加二次确认后迁移 | 发布结果影响 Public API | 待迁移,执行前需要人工确认 | +| RBAC 用户 | `/api/admin/system/users` | 旧角色字段继续兼容 | 系统资源入口已接入读取 | 无 | 后端/读页面已接入,编辑待迁移 | +| RBAC 角色、菜单、部门 | `/api/admin/system/roles`、`menus`、`depts` | 旧端不删除 | Vue 动态菜单、组件白名单和系统资源读取已接入 | 无 | 后端/读页面已接入,完整按钮授权待验收 | + +## 隐藏和横向能力清单 + +- Admin API 还包括模块新增、更新、删除、排序,以及详情、玩法、管家、首页内容的审计日志。 +- 迁移不能只检查可见菜单;`media-assets`、`publish`、`reset-guizhou-content`、仪表盘统计和所有排序接口必须在验收记录中逐项确认。 +- Public API 的首页、玩法、详情、管家、线索和用车需求依赖继续由 MiniAPP 访问;Admin API 不向 MiniAPP 暴露。 +- 新 RBAC 迁移为 `0025_admin_rbac`,资源归属迁移为 `0026_admin_ownership`。两者只提交迁移文件,不在本阶段自动升级数据库。 + +## 删除 React 的闸门 + +只有当矩阵中 Vue 列全部变为“已接入并人工验收”,并且登录刷新、权限、数据范围、Redis 故障、Public API 回归和 Docker 验证记录齐全后,才可以征求确认切换 `/admin` 与删除 `/admin-legacy`。 diff --git a/docs/integration-workflow.md b/docs/integration-workflow.md index e9f7fbb..15f3b6b 100644 --- a/docs/integration-workflow.md +++ b/docs/integration-workflow.md @@ -7,7 +7,8 @@ | 端 | 目录 | 职责 | 主要契约 | | ------------ | ------------------ | ----------------------------------------------------- | ----------------------------------------------------------------------------------------- | | 后端 API | `WonderQ-Admin` | 提供 Public API、Admin API、鉴权、数据库、迁移和 seed | `backend-api-service.md`、`backend-plan.md`、`public-api.md`、`admin-api-requirements.md` | -| 管理前端 | `WonderQ-Admin-UI` | 维护首页结构、目的地、线索和页面模块配置 | `admin-api-requirements.md`、`module-config-api.md` | +| 管理前端(Vue 迁移端) | `WonderQ-Admin-UI-Vue` | 新权限壳、动态菜单和业务切片迁移,生产路径 `/admin` | `admin-api-requirements.md`、`module-config-api.md` | +| 管理前端(React 过渡端) | `WonderQ-Admin-UI` | 旧管理端,过渡路径 `/admin-legacy` | `admin-api-requirements.md`、`module-config-api.md` | | 前台 MiniAPP | `WonderQ-MiniAPP` | H5 与微信小程序前台展示、咨询和线索提交 | `public-api.md` | 三端所有 JSON 接口还必须遵守 [api-response-contract.md](./api-response-contract.md):成功业务数据位于 `data`,失败为 `data: null`,`code` 必须等于 HTTP 状态码。 @@ -45,6 +46,16 @@ yarn dev http://localhost:5602 ``` +Vue 迁移端独立启动: + +```powershell +Set-Location .\WonderQ-Admin-UI-Vue +yarn install +yarn dev +``` + +默认访问 `http://localhost:5604/admin/`。生产反向代理使用 `/admin`,旧 React 使用 `/admin-legacy`;两端共用 `/api` 和 HttpOnly Refresh Cookie。 + 管理端优先通过 `VITE_API_BASE_URL` 或本地 Vite `/api` 代理访问 `http://localhost:4000`。 3. 启动 MiniAPP H5。