This commit is contained in:
Your Name
2026-08-07 15:35:02 +08:00
parent 3fc94c4a89
commit 6119fdd767
25 changed files with 2126 additions and 355 deletions
+61 -7
View File
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.database import get_db
from models.models import User
from .jwt_utils import decode_access_token
from .roles import can_manage_users, can_write, is_admin
from .permissions import (
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
USERS_MANAGE,
WRITE_PERMISSIONS,
)
from .roles import can_write, has_permission, is_admin
bearer_scheme = HTTPBearer(auto_error=False)
@@ -32,6 +39,20 @@ async def get_current_user(
return user
def require_permission(permission: str):
"""FastAPI dependency factory that checks a single permission code."""
async def _checker(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, permission):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"缺少权限:{permission}",
)
return _checker
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not is_admin(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
@@ -39,12 +60,45 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
async def require_write(user: User = Depends(get_current_user)) -> User:
if not can_write(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前角色只读,无法执行此操作")
return user
"""Any write-capable permission (accounts/messages/rules) or legacy can_write."""
if is_admin(user.role) or can_write(user.role):
return user
if any(has_permission(user.role, code) for code in WRITE_PERMISSIONS):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前角色只读,无法执行此操作",
)
async def require_accounts_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, ACCOUNTS_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:accounts.write",
)
async def require_messages_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, MESSAGES_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:messages.write",
)
async def require_rules_write(user: User = Depends(get_current_user)) -> User:
if has_permission(user.role, RULES_WRITE):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="缺少权限:rules.write",
)
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
if not can_manage_users(user.role):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
if has_permission(user.role, USERS_MANAGE):
return user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限")
+162
View File
@@ -0,0 +1,162 @@
"""Fixed permission catalog for menus and actions.
UI and APIs only select from this list; new codes must be added in code.
"""
from __future__ import annotations
from typing import Any
# Menu visibility
MENU_DASHBOARD = "menu.dashboard"
MENU_ACCOUNTS = "menu.accounts"
MENU_MESSAGES = "menu.messages"
MENU_RULES = "menu.rules"
MENU_LOGS = "menu.logs"
MENU_RECEIVED_MESSAGES = "menu.received_messages"
MENU_SYSTEM_LOGS = "menu.system_logs"
MENU_DOWNLOAD = "menu.download"
MENU_HELP = "menu.help"
MENU_USERS = "menu.users"
MENU_SETTINGS = "menu.settings"
MENU_DESKTOP_UPDATE = "menu.desktop_update"
MENU_PAYMENT_SETTINGS = "menu.payment_settings"
MENU_PAYMENT_ORDERS = "menu.payment_orders"
# Actions
ACCOUNTS_WRITE = "accounts.write"
MESSAGES_WRITE = "messages.write"
RULES_WRITE = "rules.write"
LOGS_READ = "logs.read"
RECEIVED_MESSAGES_READ = "received_messages.read"
SYSTEM_LOGS_READ = "system_logs.read"
USERS_MANAGE = "users.manage"
SETTINGS_MANAGE = "settings.manage"
DESKTOP_MANAGE = "desktop.manage"
PAYMENTS_MANAGE = "payments.manage"
ORDERS_READ = "orders.read"
ALL_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_SYSTEM_LOGS,
MENU_DOWNLOAD,
MENU_HELP,
MENU_USERS,
MENU_SETTINGS,
MENU_DESKTOP_UPDATE,
MENU_PAYMENT_SETTINGS,
MENU_PAYMENT_ORDERS,
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
LOGS_READ,
RECEIVED_MESSAGES_READ,
SYSTEM_LOGS_READ,
USERS_MANAGE,
SETTINGS_MANAGE,
DESKTOP_MANAGE,
PAYMENTS_MANAGE,
ORDERS_READ,
)
PERMISSION_SET = frozenset(ALL_PERMISSIONS)
WRITE_PERMISSIONS = frozenset(
{
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
}
)
_PERMISSION_META: dict[str, dict[str, str]] = {
MENU_DASHBOARD: {"group": "menu", "label": "数据概览"},
MENU_ACCOUNTS: {"group": "menu", "label": "账号管理"},
MENU_MESSAGES: {"group": "menu", "label": "私信收发"},
MENU_RULES: {"group": "menu", "label": "自动回复规则"},
MENU_LOGS: {"group": "menu", "label": "回复日志面板"},
MENU_RECEIVED_MESSAGES: {"group": "menu", "label": "接收消息日志"},
MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"},
MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"},
MENU_HELP: {"group": "menu", "label": "帮助中心"},
MENU_USERS: {"group": "menu", "label": "用户与角色"},
MENU_SETTINGS: {"group": "menu", "label": "系统设置"},
MENU_DESKTOP_UPDATE: {"group": "menu", "label": "桌面端升级"},
MENU_PAYMENT_SETTINGS: {"group": "menu", "label": "支付配置"},
MENU_PAYMENT_ORDERS: {"group": "menu", "label": "我的订单"},
ACCOUNTS_WRITE: {"group": "action", "label": "账号写操作(启动/停止/改凭证/删除)"},
MESSAGES_WRITE: {"group": "action", "label": "发送私信"},
RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"},
LOGS_READ: {"group": "action", "label": "查看回复日志"},
RECEIVED_MESSAGES_READ: {"group": "action", "label": "查看接收消息日志"},
SYSTEM_LOGS_READ: {"group": "action", "label": "查看系统诊断日志"},
USERS_MANAGE: {"group": "action", "label": "管理用户与角色"},
SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"},
DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"},
PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置"},
ORDERS_READ: {"group": "action", "label": "查看我的订单"},
}
OPERATOR_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_DOWNLOAD,
MENU_HELP,
MENU_PAYMENT_ORDERS,
ACCOUNTS_WRITE,
MESSAGES_WRITE,
RULES_WRITE,
LOGS_READ,
RECEIVED_MESSAGES_READ,
ORDERS_READ,
)
VIEWER_PERMISSIONS: tuple[str, ...] = (
MENU_DASHBOARD,
MENU_ACCOUNTS,
MENU_MESSAGES,
MENU_RULES,
MENU_LOGS,
MENU_RECEIVED_MESSAGES,
MENU_DOWNLOAD,
MENU_HELP,
LOGS_READ,
RECEIVED_MESSAGES_READ,
)
def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]:
if not codes:
return []
seen: set[str] = set()
result: list[str] = []
for code in codes:
value = str(code or "").strip()
if not value or value not in PERMISSION_SET or value in seen:
continue
seen.add(value)
result.append(value)
return result
def permission_catalog() -> dict[str, Any]:
menus = []
actions = []
for code in ALL_PERMISSIONS:
meta = _PERMISSION_META[code]
item = {"code": code, "label": meta["label"]}
if meta["group"] == "menu":
menus.append(item)
else:
actions.append(item)
return {"menus": menus, "actions": actions}
+284
View File
@@ -0,0 +1,284 @@
"""Persist and cache roles; seed built-ins on startup."""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.models import Role, User
from .permissions import ALL_PERMISSIONS, normalize_permissions
from .roles import (
ROLE_ADMIN,
RoleRecord,
default_role_seeds,
ensure_role,
get_cached_role,
is_admin,
list_cached_roles,
role_label,
sanitize_role_permissions,
set_role_cache,
)
logger = logging.getLogger("auth.roles")
_ROLE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{1,49}$")
def _encode_permissions(codes: list[str]) -> str:
return json.dumps(codes, ensure_ascii=False)
def _decode_permissions(raw: str | None) -> list[str]:
if not raw:
return []
try:
data = json.loads(raw)
except Exception:
return []
if not isinstance(data, list):
return []
return normalize_permissions([str(item) for item in data])
def role_to_record(row: Role) -> RoleRecord:
perms = list(ALL_PERMISSIONS) if row.is_admin else _decode_permissions(row.permissions)
return RoleRecord(
code=row.code,
label=row.label,
description=row.description or "",
is_system=bool(row.is_system),
is_admin=bool(row.is_admin),
permissions=perms,
)
async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]:
result = await db.execute(select(Role).order_by(Role.id.asc()))
rows = result.scalars().all()
records = [role_to_record(row) for row in rows]
if not records:
records = default_role_seeds()
set_role_cache(records)
return records
async def seed_builtin_roles(db: AsyncSession) -> None:
"""Insert missing built-in roles and keep admin permissions complete."""
seeds = {seed.code: seed for seed in default_role_seeds()}
result = await db.execute(select(Role))
existing = {row.code: row for row in result.scalars().all()}
changed = False
for code, seed in seeds.items():
row = existing.get(code)
payload = _encode_permissions(seed.permissions)
if row is None:
db.add(
Role(
code=seed.code,
label=seed.label,
description=seed.description,
is_system=True,
is_admin=seed.is_admin,
permissions=payload,
)
)
changed = True
continue
# Keep system flags and admin full permission set in sync.
if not row.is_system:
row.is_system = True
changed = True
if seed.is_admin and (not row.is_admin or row.permissions != payload):
row.is_admin = True
row.permissions = payload
row.label = seed.label
changed = True
elif not row.label:
row.label = seed.label
changed = True
if changed:
await db.commit()
await refresh_role_cache(db)
logger.info("Role cache loaded: %s", ", ".join(r.code for r in list_cached_roles()))
async def list_roles(db: AsyncSession) -> list[RoleRecord]:
await refresh_role_cache(db)
return list_cached_roles()
async def get_role_or_404(db: AsyncSession, code: str) -> Role:
result = await db.execute(select(Role).where(Role.code == code))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="角色不存在")
return row
async def count_users_with_role(db: AsyncSession, code: str) -> int:
result = await db.execute(
select(func.count()).select_from(User).where(User.role == code)
)
return int(result.scalar() or 0)
async def count_admin_users(db: AsyncSession) -> int:
result = await db.execute(
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN)
)
return int(result.scalar() or 0)
def validate_role_code(code: str) -> str:
value = str(code or "").strip().lower()
if not _ROLE_CODE_RE.match(value):
raise HTTPException(
status_code=400,
detail="角色码需为小写字母开头,仅含小写字母/数字/下划线,长度 2-50",
)
return value
async def create_role(
db: AsyncSession,
*,
code: str,
label: str,
description: str | None,
permissions: list[str] | None,
) -> RoleRecord:
role_code = validate_role_code(code)
if get_cached_role(role_code) or (
await db.execute(select(Role).where(Role.code == role_code))
).scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色码已存在")
name = (label or "").strip() or role_code
perms = sanitize_role_permissions(permissions, force_all=False)
row = Role(
code=role_code,
label=name,
description=(description or "").strip() or None,
is_system=False,
is_admin=False,
permissions=_encode_permissions(perms),
)
db.add(row)
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
async def update_role(
db: AsyncSession,
code: str,
*,
label: str | None = None,
description: str | None = None,
permissions: list[str] | None = None,
) -> RoleRecord:
row = await get_role_or_404(db, code)
if row.is_admin or row.code == ROLE_ADMIN:
# Admin role always keeps full permissions; label/description may update.
if label is not None:
row.label = (label or "").strip() or row.label
if description is not None:
row.description = (description or "").strip() or None
row.permissions = _encode_permissions(list(ALL_PERMISSIONS))
row.is_admin = True
row.is_system = True
row.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
if label is not None:
row.label = (label or "").strip() or row.label
if description is not None:
row.description = (description or "").strip() or None
if permissions is not None:
row.permissions = _encode_permissions(
sanitize_role_permissions(permissions, force_all=False)
)
row.is_admin = False
row.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(row)
await refresh_role_cache(db)
return role_to_record(row)
async def delete_role(db: AsyncSession, code: str) -> None:
row = await get_role_or_404(db, code)
if row.is_system or row.is_admin or row.code == ROLE_ADMIN:
raise HTTPException(status_code=400, detail="系统内置角色不可删除")
used = await count_users_with_role(db, code)
if used > 0:
raise HTTPException(
status_code=400,
detail=f"仍有 {used} 个用户使用该角色,请先调整用户角色后再删除",
)
await db.delete(row)
await db.commit()
await refresh_role_cache(db)
async def ensure_role_assignable(db: AsyncSession, role_code: str) -> str:
"""Validate role exists in DB (refresh cache if needed)."""
code = str(role_code or "").strip()
if not code:
raise HTTPException(status_code=400, detail="角色不能为空")
if get_cached_role(code) is None:
await refresh_role_cache(db)
try:
return ensure_role(code)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def guard_last_admin_change(
db: AsyncSession,
*,
user: User,
new_role: str | None = None,
deactivating: bool = False,
deleting: bool = False,
) -> None:
"""Prevent removing the last admin user."""
if not is_admin(user.role):
return
admin_count = await count_admin_users(db)
if admin_count > 1:
return
if deleting or deactivating:
raise HTTPException(
status_code=400,
detail="不能删除或禁用最后一个管理员账号",
)
if new_role is not None and not is_admin(new_role):
raise HTTPException(
status_code=400,
detail="不能将最后一个管理员改为非管理员角色",
)
def user_permission_payload(role_code: str) -> dict:
record = get_cached_role(role_code)
admin = bool(record.is_admin) if record else is_admin(role_code)
from .roles import permissions_for_role
return {
"role_label": role_label(role_code),
"is_admin": admin,
"permissions": permissions_for_role(role_code),
}
+145 -9
View File
@@ -1,5 +1,18 @@
"""Role code helpers and an in-memory role registry backed by the roles table."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from .permissions import (
ALL_PERMISSIONS,
OPERATOR_PERMISSIONS,
VIEWER_PERMISSIONS,
WRITE_PERMISSIONS,
normalize_permissions,
)
ROLE_ADMIN = "admin"
ROLE_OPERATOR = "operator"
ROLE_VIEWER = "viewer"
@@ -13,19 +26,142 @@ ROLE_LABELS = {
}
def is_admin(role: str) -> bool:
return role == ROLE_ADMIN
@dataclass
class RoleRecord:
code: str
label: str
description: str = ""
is_system: bool = False
is_admin: bool = False
permissions: list[str] = field(default_factory=list)
def can_write(role: str) -> bool:
return role in (ROLE_ADMIN, ROLE_OPERATOR)
_ROLE_CACHE: dict[str, RoleRecord] = {}
def can_manage_users(role: str) -> bool:
return role == ROLE_ADMIN
def default_role_seeds() -> list[RoleRecord]:
return [
RoleRecord(
code=ROLE_ADMIN,
label=ROLE_LABELS[ROLE_ADMIN],
description="拥有全部菜单与操作权限,可管理全局数据",
is_system=True,
is_admin=True,
permissions=list(ALL_PERMISSIONS),
),
RoleRecord(
code=ROLE_OPERATOR,
label=ROLE_LABELS[ROLE_OPERATOR],
description="管理自己的账号、规则与私信",
is_system=True,
is_admin=False,
permissions=list(OPERATOR_PERMISSIONS),
),
RoleRecord(
code=ROLE_VIEWER,
label=ROLE_LABELS[ROLE_VIEWER],
description="仅查看自己的业务数据,不可修改",
is_system=True,
is_admin=False,
permissions=list(VIEWER_PERMISSIONS),
),
]
def set_role_cache(roles: Iterable[RoleRecord]) -> None:
global _ROLE_CACHE
_ROLE_CACHE = {role.code: role for role in roles}
def get_cached_role(code: str | None) -> RoleRecord | None:
if not code:
return None
return _ROLE_CACHE.get(str(code))
def list_cached_roles() -> list[RoleRecord]:
return list(_ROLE_CACHE.values())
def role_label(code: str | None) -> str:
role = get_cached_role(code)
if role:
return role.label
return ROLE_LABELS.get(str(code or ""), str(code or ""))
def is_admin(role: str | None) -> bool:
"""True when the role has global data scope (built-in admin)."""
record = get_cached_role(role)
if record is not None:
return bool(record.is_admin)
# Fallback before cache is warm / for unit tests.
return str(role or "") == ROLE_ADMIN
def can_write(role: str | None) -> bool:
record = get_cached_role(role)
if record is not None:
if record.is_admin:
return True
return any(code in WRITE_PERMISSIONS for code in record.permissions)
return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR)
def can_manage_users(role: str | None) -> bool:
return has_permission(role, "users.manage")
def has_permission(role: str | None, permission: str) -> bool:
code = str(permission or "").strip()
if not code:
return False
record = get_cached_role(role)
if record is None:
if str(role or "") == ROLE_ADMIN:
return True
if str(role or "") == ROLE_OPERATOR:
return code in OPERATOR_PERMISSIONS
if str(role or "") == ROLE_VIEWER:
return code in VIEWER_PERMISSIONS
return False
if record.is_admin:
return True
return code in record.permissions
def permissions_for_role(role: str | None) -> list[str]:
record = get_cached_role(role)
if record is None:
if str(role or "") == ROLE_ADMIN:
return list(ALL_PERMISSIONS)
if str(role or "") == ROLE_OPERATOR:
return list(OPERATOR_PERMISSIONS)
if str(role or "") == ROLE_VIEWER:
return list(VIEWER_PERMISSIONS)
return []
if record.is_admin:
return list(ALL_PERMISSIONS)
return list(record.permissions)
def ensure_role(role: str) -> str:
if role not in ALL_ROLES:
raise ValueError(f"无效角色: {role}")
return role
"""Validate that a role code exists (cache or built-in fallback)."""
code = str(role or "").strip()
if not code:
raise ValueError("角色不能为空")
if get_cached_role(code) is not None:
return code
if code in ALL_ROLES:
return code
raise ValueError(f"无效角色: {code}")
def sanitize_role_permissions(
codes: list[str] | None,
*,
force_all: bool = False,
) -> list[str]:
if force_all:
return list(ALL_PERMISSIONS)
return normalize_permissions(codes)
+134 -12
View File
@@ -25,16 +25,30 @@ from .email_verification import create_verification_token, mask_email, verify_em
from .password_reset import create_password_reset_token, verify_password_reset_token
from .jwt_utils import create_access_token
from .passwords import hash_password, verify_password
from .roles import ALL_ROLES, ROLE_LABELS, ROLE_OPERATOR, ensure_role, is_admin
from .permissions import permission_catalog
from .role_service import (
count_users_with_role,
create_role,
delete_role,
ensure_role_assignable,
guard_last_admin_change,
list_roles as list_role_records,
update_role,
user_permission_payload,
)
from .roles import ROLE_OPERATOR, is_admin
from .schemas import (
LoginRequest,
MessageResponse,
ForgotPasswordRequest,
ForgotPasswordResponse,
PermissionCatalogResponse,
RegisterRequest,
RegisterResponse,
ResendVerificationRequest,
ResetPasswordRequest,
RoleCreate,
RoleUpdate,
RolesResponse,
RoleInfo,
TokenResponse,
@@ -52,6 +66,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse:
payload = UserResponse.model_validate(user)
perm = user_permission_payload(user.role)
payload.role_label = perm["role_label"]
payload.is_admin = perm["is_admin"]
payload.permissions = perm["permissions"]
if with_count:
breakdown = await count_user_account_breakdown(db, user.id)
payload.account_count = breakdown["total"]
@@ -371,13 +389,29 @@ async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depe
@router.get("/roles", response_model=RolesResponse)
async def list_roles(_: User = Depends(get_current_user)):
async def list_auth_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Lightweight role list for dropdowns (any logged-in user)."""
records = await list_role_records(db)
return RolesResponse(
roles=[RoleInfo(value=r, label=ROLE_LABELS.get(r, r)) for r in ALL_ROLES]
roles=[
RoleInfo(
value=item.code,
label=item.label,
description=item.description or None,
is_system=item.is_system,
is_admin=item.is_admin,
permissions=list(item.permissions),
)
for item in records
]
)
users_router = APIRouter(prefix="/api/users", tags=["users"])
roles_router = APIRouter(prefix="/api/roles", tags=["roles"])
@users_router.get("", response_model=list[UserResponse])
@@ -403,10 +437,7 @@ async def create_user(
exists = await db.execute(select(User).where(User.username == body.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
try:
role = ensure_role(body.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
role = await ensure_role_assignable(db, body.role)
email = await _ensure_email_available(db, str(body.email) if body.email else None)
if settings.email_binding_required and not is_admin(role) and not email:
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
@@ -445,14 +476,19 @@ async def update_user(
settings = await load_settings(db)
if user.id == current.id and body.is_active is False:
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
updates = body.model_dump(exclude_unset=True)
if body.is_active is False:
await guard_last_admin_change(db, user=user, deactivating=True)
if body.role is not None:
new_role = await ensure_role_assignable(db, body.role)
await guard_last_admin_change(db, user=user, new_role=new_role)
if body.display_name is not None:
user.display_name = body.display_name
if body.role is not None:
prev_role = user.role
try:
user.role = ensure_role(body.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
user.role = await ensure_role_assignable(db, body.role)
if is_admin(user.role):
user.max_accounts = UNLIMITED_ACCOUNTS
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
@@ -465,7 +501,6 @@ async def update_user(
if body.password:
user.password_hash = hash_password(body.password)
updates = body.model_dump(exclude_unset=True)
if "max_accounts" in updates and not is_admin(user.role):
user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role)
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
@@ -506,6 +541,93 @@ async def delete_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await guard_last_admin_change(db, user=user, deleting=True)
await db.delete(user)
await db.commit()
return {"message": "用户已删除"}
@roles_router.get("", response_model=RolesResponse)
async def admin_list_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
records = await list_role_records(db)
roles = []
for item in records:
roles.append(
RoleInfo(
value=item.code,
label=item.label,
description=item.description or None,
is_system=item.is_system,
is_admin=item.is_admin,
permissions=list(item.permissions),
user_count=await count_users_with_role(db, item.code),
)
)
return RolesResponse(roles=roles)
@roles_router.get("/catalog", response_model=PermissionCatalogResponse)
async def get_permission_catalog(_: User = Depends(require_user_manager)):
return PermissionCatalogResponse(**permission_catalog())
@roles_router.post("", response_model=RoleInfo)
async def create_custom_role(
body: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
record = await create_role(
db,
code=body.code,
label=body.label,
description=body.description,
permissions=body.permissions,
)
return RoleInfo(
value=record.code,
label=record.label,
description=record.description or None,
is_system=record.is_system,
is_admin=record.is_admin,
permissions=list(record.permissions),
user_count=0,
)
@roles_router.put("/{code}", response_model=RoleInfo)
async def update_custom_role(
code: str,
body: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
record = await update_role(
db,
code,
label=body.label,
description=body.description,
permissions=body.permissions,
)
return RoleInfo(
value=record.code,
label=record.label,
description=record.description or None,
is_system=record.is_system,
is_admin=record.is_admin,
permissions=list(record.permissions),
user_count=await count_users_with_role(db, record.code),
)
@roles_router.delete("/{code}")
async def delete_custom_role(
code: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_user_manager),
):
await delete_role(db, code)
return {"message": "角色已删除"}
+26
View File
@@ -67,6 +67,9 @@ class UserResponse(BaseModel):
email: Optional[str] = None
display_name: Optional[str] = None
role: str
role_label: str = ""
is_admin: bool = False
permissions: list[str] = Field(default_factory=list)
is_active: bool
email_verified: bool = False
max_accounts: int = 3
@@ -102,7 +105,30 @@ class UserUpdate(BaseModel):
class RoleInfo(BaseModel):
value: str
label: str
description: Optional[str] = None
is_system: bool = False
is_admin: bool = False
permissions: list[str] = Field(default_factory=list)
user_count: Optional[int] = None
class RolesResponse(BaseModel):
roles: list[RoleInfo]
class RoleCreate(BaseModel):
code: str = Field(min_length=2, max_length=50)
label: str = Field(min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=255)
permissions: list[str] = Field(default_factory=list)
class RoleUpdate(BaseModel):
label: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=255)
permissions: Optional[list[str]] = None
class PermissionCatalogResponse(BaseModel):
menus: list[dict[str, Any]]
actions: list[dict[str, Any]]
+5 -6
View File
@@ -5,7 +5,8 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
from .roles import is_admin
from .permissions import ACCOUNTS_WRITE, RULES_WRITE
from .roles import has_permission, is_admin
async def get_owned_account(
@@ -23,7 +24,7 @@ async def get_owned_account(
return account
if account.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
if write and user.role == "viewer":
if write and not has_permission(user.role, ACCOUNTS_WRITE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return account
@@ -79,17 +80,15 @@ async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, wri
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
if is_admin(user.role):
if write and user.role == "viewer":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return rule
if rule.account_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则")
account = await get_owned_account(db, user, rule.account_id, write=write)
account = await get_owned_account(db, user, rule.account_id, write=False)
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
if write and user.role == "viewer":
if write and not has_permission(user.role, RULES_WRITE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
return rule
+12 -11
View File
@@ -14,7 +14,8 @@ from models.db_config import (
)
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
from models.models import User
from .dependencies import require_admin
from .dependencies import require_permission
from .permissions import SETTINGS_MANAGE
from .email_service import send_test_email
from .system_settings import (
PASSWORD_PLACEHOLDER,
@@ -208,7 +209,7 @@ async def get_public_settings(db: AsyncSession = Depends(get_db)):
@router.get("", response_model=SystemSettingsResponse)
async def get_system_settings(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
return SystemSettingsResponse(**settings_to_admin_response(data))
@@ -218,7 +219,7 @@ async def get_system_settings(
async def update_system_settings(
body: SystemSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
if "app_url" in updates and updates["app_url"]:
@@ -230,7 +231,7 @@ async def update_system_settings(
@router.get("/payment", response_model=PaymentSettingsResponse)
async def get_payment_settings(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
return PaymentSettingsResponse(**settings_to_payment_response(data))
@@ -240,7 +241,7 @@ async def get_payment_settings(
async def update_payment_settings(
body: PaymentSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
data = await save_settings(db, updates)
@@ -251,7 +252,7 @@ async def update_payment_settings(
async def test_smtp_email(
body: TestEmailRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
data = await load_settings(db)
overrides = body.model_dump(exclude_unset=True, exclude={"to_email"})
@@ -276,14 +277,14 @@ async def test_smtp_email(
@router.get("/database", response_model=DatabaseSettingsResponse)
async def get_database_settings(_: User = Depends(require_admin)):
async def get_database_settings(_: User = Depends(require_permission(SETTINGS_MANAGE))):
return DatabaseSettingsResponse(**database_config_to_response())
@router.put("/database", response_model=MessageResponse)
async def update_database_settings(
body: DatabaseSettingsUpdate,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
@@ -302,7 +303,7 @@ async def update_database_settings(
@router.post("/database/test", response_model=MessageResponse)
async def test_database_settings(
body: DatabaseTestRequest,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
@@ -317,7 +318,7 @@ async def test_database_settings(
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
async def preview_database_migration(
source_db_path: str | None = None,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
@@ -325,7 +326,7 @@ async def preview_database_migration(
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
async def migrate_database_data(
body: DatabaseMigrateRequest,
_: User = Depends(require_admin),
_: User = Depends(require_permission(SETTINGS_MANAGE)),
):
payload = body.model_dump(exclude_unset=True)
clear_target = bool(payload.pop("clear_target", False))
+6 -5
View File
@@ -19,7 +19,8 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import require_admin
from auth.dependencies import require_permission
from auth.permissions import DESKTOP_MANAGE
from auth.system_settings import get_cached_settings
from desktop_release import (
INSTALLER_DIR,
@@ -120,7 +121,7 @@ async def desktop_download(db: AsyncSession = Depends(get_db)):
async def get_release(
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
data = await load_release(db)
return _to_response(data, request)
@@ -131,7 +132,7 @@ async def update_release(
body: DesktopReleaseUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
updates = body.model_dump(exclude_unset=True)
if "version" in updates and updates["version"] is not None:
@@ -152,7 +153,7 @@ async def upload_installer(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
filename = (file.filename or "").strip()
if not filename.lower().endswith(".exe"):
@@ -194,7 +195,7 @@ async def upload_installer(
async def delete_installer(
request: Request,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(DESKTOP_MANAGE)),
):
remove_installer()
data = await save_release(db, {"installer_name": "", "installer_size": 0})
Binary file not shown.
Binary file not shown.
+43 -21
View File
@@ -30,17 +30,26 @@ from models.db_migrate import (
migrate_account_videos_table as _migrate_account_videos_table,
migrate_message_logs_table as _migrate_message_logs_table,
migrate_payment_orders_table as _migrate_payment_orders_table,
migrate_roles_table as _migrate_roles_table,
migrate_rules_table as _migrate_rules_table,
migrate_users_table as _migrate_users_table,
)
from models.db_config import database_config_to_response
from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
from auth.router import router as auth_router, users_router
from auth.router import router as auth_router, users_router, roles_router
from auth.settings_router import router as settings_router
from auth.role_service import seed_builtin_roles
from payments.router import router as payments_router
from desktop_router import router as desktop_router
from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR
from auth.dependencies import get_current_user, require_admin, require_write
from auth.dependencies import (
get_current_user,
require_accounts_write,
require_admin,
require_messages_write,
require_rules_write,
require_write,
)
from auth.account_limits import ensure_can_add_account
from auth.scopes import (
accounts_for_user,
@@ -52,7 +61,8 @@ from auth.scopes import (
received_logs_for_user,
system_logs_for_user,
)
from auth.roles import is_admin
from auth.roles import has_permission, is_admin
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
from auth.passwords import hash_password
from rpa_engine.batch_start import BatchStartQueue
from rpa_engine.playwright_worker import DouyinWorker
@@ -201,6 +211,7 @@ app.mount("/api/media/link-cards", StaticFiles(directory=LINK_CARD_UPLOAD_DIR),
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(settings_router)
app.include_router(payments_router)
app.include_router(desktop_router)
@@ -610,6 +621,7 @@ async def startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(_migrate_roles_table)
await conn.run_sync(_migrate_accounts_table)
await conn.run_sync(_migrate_rules_table)
await conn.run_sync(_migrate_message_logs_table)
@@ -618,6 +630,8 @@ async def startup():
await conn.run_sync(_migrate_payment_orders_table)
await conn.run_sync(_migrate_accounts_quota_disabled)
await _seed_app_config()
async with AsyncSessionLocal() as db:
await seed_builtin_roles(db)
await _seed_admin_user()
# 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。
# 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。
@@ -1618,7 +1632,7 @@ async def update_account(
account_id: int,
body: AccountUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
follow_config_changed = bool(
@@ -1776,7 +1790,7 @@ async def send_account_queued_reply_now(
account_id: int,
job_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
await get_owned_account(db, user, account_id, write=True)
@@ -1931,7 +1945,7 @@ async def update_account_cookie(
account_id: int,
body: AccountCookieUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
# Validate first: malformed input must not take a healthy hosted account
# offline. Filesystem and database mutations happen only after the worker
@@ -1980,7 +1994,7 @@ async def update_account_cookie(
async def delete_account_cookie(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
# Deleting credentials uses the same preparation lock as starting a
# worker, preventing a new worker from appearing after stop_worker but
@@ -2009,7 +2023,7 @@ async def delete_account_cookie(
async def create_account(
account_in: AccountCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
cookie_data = (account_in.cookie_data or "").strip()
standard_json_str = None
@@ -2047,7 +2061,7 @@ async def create_account(
async def delete_account(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
await get_owned_account(db, user, account_id, write=True)
# 停止运行中的任务
@@ -2082,7 +2096,7 @@ async def validate_account_credential(
async def reset_account_credentials(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
await get_owned_account(db, user, account_id, write=True)
await _release_db_connection(db)
@@ -2237,7 +2251,7 @@ async def start_account_rpa(
account_id: int,
body: StartAccountRequest = StartAccountRequest(),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
# Cancelling waits for an in-flight queued start, and the preparation lock
@@ -2253,7 +2267,7 @@ async def start_account_rpa(
async def submit_account_start_batch(
body: BatchStartRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
requested_ids = list(
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
@@ -2317,7 +2331,7 @@ async def get_account_start_batch(
async def stop_account_rpa(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_accounts_write),
):
account = await get_owned_account(db, user, account_id, write=True)
@@ -2395,7 +2409,7 @@ async def get_rules(
async def create_rule(
rule_in: RuleCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
if rule_in.match_type == "default":
rule_in.keyword = ""
@@ -2429,7 +2443,7 @@ async def update_rule(
rule_in: RuleCreate,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2457,7 +2471,7 @@ async def update_rule(
async def toggle_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2470,7 +2484,7 @@ async def toggle_rule(
async def delete_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
await get_accessible_rule(db, user, rule_id, write=True)
await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id))
@@ -2487,7 +2501,7 @@ async def move_rule(
rule_id: int,
body: RuleMove,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
"""在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。"""
rule = await get_accessible_rule(db, user, rule_id, write=True)
@@ -2512,7 +2526,7 @@ async def move_rule(
async def reorder_rules(
body: RuleReorder,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_rules_write),
):
if not body.rule_ids:
return {"message": "No rules to reorder."}
@@ -2531,6 +2545,8 @@ async def get_logs_stats(
user: User = Depends(get_current_user),
):
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
if not has_permission(user.role, LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
# Select only the indexed status column and calculate both counters in one
@@ -2564,6 +2580,8 @@ async def get_logs(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not has_permission(user.role, LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 50), 500))
@@ -2585,6 +2603,8 @@ async def get_received_messages(
user: User = Depends(get_current_user),
):
"""接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。"""
if not has_permission(user.role, RECEIVED_MESSAGES_READ):
raise HTTPException(status_code=403, detail="缺少权限:received_messages.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 100), 500))
@@ -2607,6 +2627,8 @@ async def get_system_logs(
user: User = Depends(get_current_user),
):
"""系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。"""
if not has_permission(user.role, SYSTEM_LOGS_READ):
raise HTTPException(status_code=403, detail="缺少权限:system_logs.read")
if account_id is not None:
await get_owned_account(db, user, account_id)
entries = system_logger.get_logs(
@@ -2688,7 +2710,7 @@ async def upload_message_image(
account_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
account = await get_owned_account(db, user, account_id, write=True)
if not file.content_type or not file.content_type.startswith("image/"):
@@ -2779,7 +2801,7 @@ async def send_account_message(
account_id: int,
body: SendMessageRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
user: User = Depends(require_messages_write),
):
account = await get_owned_account(db, user, account_id, write=True)
+48
View File
@@ -196,6 +196,54 @@ def migrate_rules_table(conn) -> None:
)
def migrate_roles_table(conn) -> None:
"""Ensure roles table exists (create_all usually handles this; keep as safety net)."""
try:
insp = inspect(conn)
if insp.has_table("roles"):
return
except Exception:
return
dialect = _dialect(conn)
if dialect == "postgresql":
conn.execute(
text(
"""
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(100) NOT NULL,
description VARCHAR(255),
is_system BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE,
permissions TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP,
updated_at TIMESTAMP
)
"""
)
)
else:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(100) NOT NULL,
description VARCHAR(255),
is_system BOOLEAN DEFAULT 0,
is_admin BOOLEAN DEFAULT 0,
permissions TEXT NOT NULL DEFAULT '[]',
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
add_index_if_missing(conn, "roles", "ix_roles_code", ("code",))
def migrate_users_table(conn) -> None:
add_column_if_missing(
conn,
+23 -1
View File
@@ -5,6 +5,28 @@ from .database import Base
from utils.log_limits import bound_error_log_content, bound_message_log_content
class Role(Base):
"""Assignable role with a fixed permission-code list.
``users.role`` stores ``Role.code``. Built-in roles are seeded on startup;
custom roles are owned by admins via the users.manage permission.
"""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True, nullable=False)
label = Column(String(100), nullable=False)
description = Column(String(255), nullable=True)
is_system = Column(Boolean, default=False)
# Global data scope + unlimited account quota. Only the built-in admin
# role may be true; custom roles are always own-scoped.
is_admin = Column(Boolean, default=False)
permissions = Column(Text, nullable=False, default="[]") # JSON string list
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
__tablename__ = "users"
@@ -13,7 +35,7 @@ class User(Base):
email = Column(String(255), unique=True, index=True, nullable=True)
password_hash = Column(String(255), nullable=False)
display_name = Column(String(100), nullable=True)
role = Column(String(20), default="operator", index=True) # admin, operator, viewer
role = Column(String(50), default="operator", index=True) # roles.code
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False)
email_verified_at = Column(DateTime, nullable=True)
+4 -3
View File
@@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import get_current_user, require_admin
from auth.dependencies import get_current_user, require_permission
from auth.permissions import PAYMENTS_MANAGE
from auth.system_settings import load_settings
from models.database import get_db
from models.models import User
@@ -89,7 +90,7 @@ async def admin_update_payment_order_status(
order_no: str,
body: AdminUpdateOrderStatusRequest,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
):
order = await service.admin_update_order_status(db, order_no, body.status)
return PaymentOrderListItem(**order)
@@ -99,7 +100,7 @@ async def admin_update_payment_order_status(
async def admin_delete_payment_order(
order_no: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
_: User = Depends(require_permission(PAYMENTS_MANAGE)),
):
await service.admin_delete_order(db, order_no)
return MessageResponse(message="订单已删除")
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
BACKEND_DIR = Path(__file__).resolve().parents[1]
os.environ["KEFU_DB_TYPE"] = "sqlite"
os.environ["KEFU_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from models.models import Base, Role, User # noqa: E402
from auth.passwords import hash_password # noqa: E402
from auth.permissions import ACCOUNTS_WRITE, ALL_PERMISSIONS, MENU_USERS # noqa: E402
from auth.role_service import ( # noqa: E402
create_role,
delete_role,
guard_last_admin_change,
seed_builtin_roles,
update_role,
)
from auth.roles import has_permission, is_admin, permissions_for_role # noqa: E402
from auth import router as auth_router # noqa: E402
class RolesRbacTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
self.session_factory = sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
self.db = self.session_factory()
await seed_builtin_roles(self.db)
async def asyncTearDown(self):
await self.db.close()
await self.engine.dispose()
async def test_seed_creates_builtin_roles(self):
result = await self.db.execute(select(Role))
codes = {row.code for row in result.scalars().all()}
self.assertEqual(codes, {"admin", "operator", "viewer"})
self.assertTrue(is_admin("admin"))
self.assertFalse(is_admin("operator"))
self.assertIn(MENU_USERS, permissions_for_role("admin"))
self.assertNotIn(MENU_USERS, permissions_for_role("operator"))
self.assertFalse(has_permission("viewer", ACCOUNTS_WRITE))
async def test_custom_role_crud(self):
created = await create_role(
self.db,
code="ops_leader",
label="运营主管",
description="可管账号",
permissions=[MENU_USERS, ACCOUNTS_WRITE],
)
self.assertEqual(created.code, "ops_leader")
self.assertTrue(has_permission("ops_leader", ACCOUNTS_WRITE))
self.assertFalse(is_admin("ops_leader"))
updated = await update_role(
self.db,
"ops_leader",
label="主管",
permissions=[ACCOUNTS_WRITE],
)
self.assertEqual(updated.label, "主管")
self.assertFalse(has_permission("ops_leader", MENU_USERS))
await delete_role(self.db, "ops_leader")
self.assertFalse(has_permission("ops_leader", ACCOUNTS_WRITE))
async def test_cannot_delete_system_role(self):
with self.assertRaises(HTTPException) as caught:
await delete_role(self.db, "operator")
self.assertEqual(caught.exception.status_code, 400)
async def test_admin_permissions_always_full(self):
await update_role(
self.db,
"admin",
label="管理员",
permissions=[ACCOUNTS_WRITE],
)
self.assertEqual(permissions_for_role("admin"), list(ALL_PERMISSIONS))
async def test_me_payload_includes_permissions(self):
user = User(
username="u1",
password_hash=hash_password("password1"),
display_name="U1",
role="operator",
is_active=True,
email_verified=True,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
payload = await auth_router._build_user_response(self.db, user)
self.assertEqual(payload.role_label, "运营")
self.assertFalse(payload.is_admin)
self.assertIn(ACCOUNTS_WRITE, payload.permissions)
self.assertNotIn(MENU_USERS, payload.permissions)
async def test_last_admin_cannot_be_demoted(self):
admin = User(
username="admin1",
password_hash=hash_password("password1"),
role="admin",
is_active=True,
email_verified=True,
)
self.db.add(admin)
await self.db.commit()
await self.db.refresh(admin)
with self.assertRaises(HTTPException) as caught:
await guard_last_admin_change(self.db, user=admin, new_role="operator")
self.assertEqual(caught.exception.status_code, 400)
if __name__ == "__main__":
unittest.main()