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