From 327a0bc42f8578740ff495f1714f597795defb97 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 7 Aug 2026 17:51:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/auth/dependencies.py | 65 +- backend/auth/permissions.py | 240 +++++- backend/auth/role_service.py | 44 +- backend/auth/roles.py | 41 +- backend/auth/router.py | 35 +- backend/auth/schemas.py | 3 + backend/auth/scopes.py | 71 +- backend/auth/settings_router.py | 16 +- backend/kefu.db-shm | Bin 32768 -> 32768 bytes backend/kefu.db-wal | Bin 4313672 -> 4313672 bytes backend/link_cards_router.py | 12 +- backend/main.py | 74 +- backend/models/db_migrate.py | 6 + backend/models/models.py | 9 +- backend/payments/router.py | 19 +- backend/payments/service.py | 10 +- backend/tests/test_roles_rbac.py | 62 +- frontend/src/App.vue | 2 + frontend/src/components/ReplyRuleEditor.vue | 38 +- frontend/src/config/menus.js | 30 +- frontend/src/router/index.js | 10 +- frontend/src/stores/auth.js | 63 +- frontend/src/views/Accounts.vue | 97 ++- frontend/src/views/Dashboard.vue | 12 +- frontend/src/views/Roles.vue | 798 ++++++++++++++++++++ frontend/src/views/Rules.vue | 6 +- frontend/src/views/Settings.vue | 10 +- frontend/src/views/SystemLogs.vue | 2 +- frontend/src/views/Users.vue | 414 +--------- 29 files changed, 1610 insertions(+), 579 deletions(-) create mode 100644 frontend/src/views/Roles.vue diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py index 9a77896..309397d 100644 --- a/backend/auth/dependencies.py +++ b/backend/auth/dependencies.py @@ -7,9 +7,21 @@ from models.database import get_db from models.models import User from .jwt_utils import decode_access_token from .permissions import ( + ACCOUNTS_COOKIE, + ACCOUNTS_CREATE, + ACCOUNTS_DELETE, + ACCOUNTS_START, + ACCOUNTS_STOP, + ACCOUNTS_UPDATE, ACCOUNTS_WRITE, + ACCOUNTS_WRITE_GRANULAR, + LINK_CARDS_WRITE, MESSAGES_WRITE, + ORDERS_CREATE, + ROLES_MANAGE, RULES_WRITE, + SETTINGS_DATABASE, + SYSTEM_LOGS_CLEAR, USERS_MANAGE, WRITE_PERMISSIONS, ) @@ -71,34 +83,43 @@ async def require_write(user: User = Depends(get_current_user)) -> User: ) -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", - ) +def _require_any(*codes: str, detail: str): + async def _checker(user: User = Depends(get_current_user)) -> User: + if any(has_permission(user.role, code) for code in codes): + return user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + return _checker -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", - ) +# Backward-compatible: any account write capability. +require_accounts_write = _require_any( + ACCOUNTS_WRITE, + *ACCOUNTS_WRITE_GRANULAR, + detail="缺少账号写权限", +) +require_accounts_create = require_permission(ACCOUNTS_CREATE) +require_accounts_update = require_permission(ACCOUNTS_UPDATE) +require_accounts_delete = require_permission(ACCOUNTS_DELETE) +require_accounts_start = require_permission(ACCOUNTS_START) +require_accounts_stop = require_permission(ACCOUNTS_STOP) +require_accounts_cookie = require_permission(ACCOUNTS_COOKIE) - -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", - ) +require_messages_write = require_permission(MESSAGES_WRITE) +require_rules_write = require_permission(RULES_WRITE) +require_link_cards_write = require_permission(LINK_CARDS_WRITE) +require_system_logs_clear = require_permission(SYSTEM_LOGS_CLEAR) +require_settings_database = require_permission(SETTINGS_DATABASE) +require_orders_create = require_permission(ORDERS_CREATE) async def require_user_manager(user: User = Depends(get_current_user)) -> User: if has_permission(user.role, USERS_MANAGE): return user raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要用户管理权限") + + +async def require_role_manager(user: User = Depends(get_current_user)) -> User: + if has_permission(user.role, ROLES_MANAGE): + return user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要角色管理权限") diff --git a/backend/auth/permissions.py b/backend/auth/permissions.py index 9bfb5b7..a07923a 100644 --- a/backend/auth/permissions.py +++ b/backend/auth/permissions.py @@ -1,6 +1,6 @@ -"""Fixed permission catalog for menus and actions. +"""Fixed permission catalog: menus, button actions, and data scope. -UI and APIs only select from this list; new codes must be added in code. +UI only selects from this list; new codes must be added in code. """ from __future__ import annotations @@ -8,7 +8,9 @@ from __future__ import annotations from typing import Any +# --------------------------------------------------------------------------- # Menu visibility +# --------------------------------------------------------------------------- MENU_DASHBOARD = "menu.dashboard" MENU_ACCOUNTS = "menu.accounts" MENU_MESSAGES = "menu.messages" @@ -19,23 +21,63 @@ MENU_SYSTEM_LOGS = "menu.system_logs" MENU_DOWNLOAD = "menu.download" MENU_HELP = "menu.help" MENU_USERS = "menu.users" +MENU_ROLES = "menu.roles" 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" +# --------------------------------------------------------------------------- +# Button / action permissions +# --------------------------------------------------------------------------- +# Accounts (granular). Legacy ``accounts.write`` expands to the full set. +ACCOUNTS_CREATE = "accounts.create" +ACCOUNTS_UPDATE = "accounts.update" +ACCOUNTS_DELETE = "accounts.delete" +ACCOUNTS_START = "accounts.start" +ACCOUNTS_STOP = "accounts.stop" +ACCOUNTS_COOKIE = "accounts.cookie" +ACCOUNTS_WRITE = "accounts.write" # legacy bundle + MESSAGES_WRITE = "messages.write" RULES_WRITE = "rules.write" +LINK_CARDS_WRITE = "link_cards.write" + LOGS_READ = "logs.read" RECEIVED_MESSAGES_READ = "received_messages.read" SYSTEM_LOGS_READ = "system_logs.read" +SYSTEM_LOGS_CLEAR = "system_logs.clear" + USERS_MANAGE = "users.manage" +ROLES_MANAGE = "roles.manage" SETTINGS_MANAGE = "settings.manage" +SETTINGS_DATABASE = "settings.database" DESKTOP_MANAGE = "desktop.manage" PAYMENTS_MANAGE = "payments.manage" ORDERS_READ = "orders.read" +ORDERS_CREATE = "orders.create" + +# --------------------------------------------------------------------------- +# Data scope +# --------------------------------------------------------------------------- +# Without this (and without is_admin), users only see their own data. +DATA_SCOPE_ALL = "data.scope_all" + +# --------------------------------------------------------------------------- +# Bundles / aliases expanded on save & when checking permissions +# --------------------------------------------------------------------------- +ACCOUNTS_WRITE_GRANULAR: tuple[str, ...] = ( + ACCOUNTS_CREATE, + ACCOUNTS_UPDATE, + ACCOUNTS_DELETE, + ACCOUNTS_START, + ACCOUNTS_STOP, + ACCOUNTS_COOKIE, +) + +LEGACY_BUNDLES: dict[str, tuple[str, ...]] = { + ACCOUNTS_WRITE: ACCOUNTS_WRITE_GRANULAR, +} ALL_PERMISSIONS: tuple[str, ...] = ( MENU_DASHBOARD, @@ -48,21 +90,34 @@ ALL_PERMISSIONS: tuple[str, ...] = ( MENU_DOWNLOAD, MENU_HELP, MENU_USERS, + MENU_ROLES, MENU_SETTINGS, MENU_DESKTOP_UPDATE, MENU_PAYMENT_SETTINGS, MENU_PAYMENT_ORDERS, + ACCOUNTS_CREATE, + ACCOUNTS_UPDATE, + ACCOUNTS_DELETE, + ACCOUNTS_START, + ACCOUNTS_STOP, + ACCOUNTS_COOKIE, ACCOUNTS_WRITE, MESSAGES_WRITE, RULES_WRITE, + LINK_CARDS_WRITE, LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ, + SYSTEM_LOGS_CLEAR, USERS_MANAGE, + ROLES_MANAGE, SETTINGS_MANAGE, + SETTINGS_DATABASE, DESKTOP_MANAGE, PAYMENTS_MANAGE, ORDERS_READ, + ORDERS_CREATE, + DATA_SCOPE_ALL, ) PERMISSION_SET = frozenset(ALL_PERMISSIONS) @@ -70,8 +125,10 @@ PERMISSION_SET = frozenset(ALL_PERMISSIONS) WRITE_PERMISSIONS = frozenset( { ACCOUNTS_WRITE, + *ACCOUNTS_WRITE_GRANULAR, MESSAGES_WRITE, RULES_WRITE, + LINK_CARDS_WRITE, } ) @@ -85,22 +142,35 @@ _PERMISSION_META: dict[str, dict[str, str]] = { MENU_SYSTEM_LOGS: {"group": "menu", "label": "系统诊断日志"}, MENU_DOWNLOAD: {"group": "menu", "label": "软件下载"}, MENU_HELP: {"group": "menu", "label": "帮助中心"}, - MENU_USERS: {"group": "menu", "label": "用户与角色"}, + MENU_USERS: {"group": "menu", "label": "用户管理"}, + MENU_ROLES: {"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": "发送私信"}, + ACCOUNTS_CREATE: {"group": "action", "label": "新增抖音账号"}, + ACCOUNTS_UPDATE: {"group": "action", "label": "编辑账号信息"}, + ACCOUNTS_DELETE: {"group": "action", "label": "删除账号"}, + ACCOUNTS_START: {"group": "action", "label": "启动托管 / 批量启动"}, + ACCOUNTS_STOP: {"group": "action", "label": "停止托管"}, + ACCOUNTS_COOKIE: {"group": "action", "label": "查看/修改 Cookie 与凭证"}, + ACCOUNTS_WRITE: {"group": "action", "label": "账号全部写操作(兼容旧版,等同下列细项)"}, + MESSAGES_WRITE: {"group": "action", "label": "发送私信 / 队列立即发送"}, RULES_WRITE: {"group": "action", "label": "编辑自动回复规则"}, + LINK_CARDS_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": "管理用户与角色"}, + SYSTEM_LOGS_CLEAR: {"group": "action", "label": "清空系统诊断日志"}, + USERS_MANAGE: {"group": "action", "label": "管理用户"}, + ROLES_MANAGE: {"group": "action", "label": "管理角色"}, SETTINGS_MANAGE: {"group": "action", "label": "管理系统设置"}, + SETTINGS_DATABASE: {"group": "action", "label": "管理数据库配置与迁移"}, DESKTOP_MANAGE: {"group": "action", "label": "管理桌面端升级"}, - PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置"}, + PAYMENTS_MANAGE: {"group": "action", "label": "管理支付配置与全部订单"}, ORDERS_READ: {"group": "action", "label": "查看我的订单"}, + ORDERS_CREATE: {"group": "action", "label": "购买额度 / 创建订单"}, + DATA_SCOPE_ALL: {"group": "data", "label": "查看全部用户数据(全局数据范围)"}, } OPERATOR_PERMISSIONS: tuple[str, ...] = ( @@ -113,12 +183,14 @@ OPERATOR_PERMISSIONS: tuple[str, ...] = ( MENU_DOWNLOAD, MENU_HELP, MENU_PAYMENT_ORDERS, - ACCOUNTS_WRITE, + *ACCOUNTS_WRITE_GRANULAR, MESSAGES_WRITE, RULES_WRITE, + LINK_CARDS_WRITE, LOGS_READ, RECEIVED_MESSAGES_READ, ORDERS_READ, + ORDERS_CREATE, ) VIEWER_PERMISSIONS: tuple[str, ...] = ( @@ -149,14 +221,158 @@ def normalize_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str return result +def expand_legacy_bundles(codes: set[str]) -> set[str]: + """Expand legacy bundle codes into granular permissions.""" + expanded = set(codes) + for bundle, parts in LEGACY_BUNDLES.items(): + if bundle in expanded: + expanded.update(parts) + return expanded + + +# Menu → required action. Selecting a menu always grants the action. +MENU_REQUIRED_ACTIONS: dict[str, str | tuple[str, ...]] = { + MENU_USERS: USERS_MANAGE, + MENU_ROLES: ROLES_MANAGE, + MENU_SETTINGS: SETTINGS_MANAGE, + MENU_DESKTOP_UPDATE: DESKTOP_MANAGE, + MENU_PAYMENT_SETTINGS: PAYMENTS_MANAGE, + MENU_PAYMENT_ORDERS: (ORDERS_READ, ORDERS_CREATE), + MENU_LOGS: LOGS_READ, + MENU_RECEIVED_MESSAGES: RECEIVED_MESSAGES_READ, + MENU_SYSTEM_LOGS: SYSTEM_LOGS_READ, + MENU_ACCOUNTS: (), # page access only; buttons are separate + MENU_MESSAGES: (), + MENU_RULES: (), +} + +# Action → primary menu only. +ACTION_PRIMARY_MENU: dict[str, str] = { + USERS_MANAGE: MENU_USERS, + ROLES_MANAGE: MENU_ROLES, + SETTINGS_MANAGE: MENU_SETTINGS, + SETTINGS_DATABASE: MENU_SETTINGS, + DESKTOP_MANAGE: MENU_DESKTOP_UPDATE, + PAYMENTS_MANAGE: MENU_PAYMENT_SETTINGS, + ORDERS_READ: MENU_PAYMENT_ORDERS, + ORDERS_CREATE: MENU_PAYMENT_ORDERS, + LOGS_READ: MENU_LOGS, + RECEIVED_MESSAGES_READ: MENU_RECEIVED_MESSAGES, + SYSTEM_LOGS_READ: MENU_SYSTEM_LOGS, + SYSTEM_LOGS_CLEAR: MENU_SYSTEM_LOGS, + ACCOUNTS_CREATE: MENU_ACCOUNTS, + ACCOUNTS_UPDATE: MENU_ACCOUNTS, + ACCOUNTS_DELETE: MENU_ACCOUNTS, + ACCOUNTS_START: MENU_ACCOUNTS, + ACCOUNTS_STOP: MENU_ACCOUNTS, + ACCOUNTS_COOKIE: MENU_ACCOUNTS, + ACCOUNTS_WRITE: MENU_ACCOUNTS, + MESSAGES_WRITE: MENU_MESSAGES, + RULES_WRITE: MENU_RULES, + LINK_CARDS_WRITE: MENU_RULES, +} + + +def _iter_required_actions(menu: str) -> tuple[str, ...]: + raw = MENU_REQUIRED_ACTIONS.get(menu) + if raw is None: + return () + if isinstance(raw, str): + return (raw,) + return tuple(raw) + + +# Catalog / UI pairs (menu → first required action for checkbox hints). +MENU_ACTION_PAIRS: tuple[tuple[str, str], ...] = tuple( + (menu, actions[0]) + for menu, actions in ( + (m, _iter_required_actions(m)) for m in MENU_REQUIRED_ACTIONS + ) + if actions +) + + +def expand_paired_permissions(codes: list[str] | tuple[str, ...] | None) -> list[str]: + """Normalize, expand legacy bundles, and auto-complete menu/action pairs.""" + selected = expand_legacy_bundles(set(normalize_permissions(codes))) + for menu in list(selected): + for action in _iter_required_actions(menu): + selected.add(action) + for action, menu in ACTION_PRIMARY_MENU.items(): + if action in selected: + selected.add(menu) + # If every granular account write is present, keep the legacy bundle flag. + if all(code in selected for code in ACCOUNTS_WRITE_GRANULAR): + selected.add(ACCOUNTS_WRITE) + return [code for code in ALL_PERMISSIONS if code in selected] + + +def permission_implies(held: set[str], needed: str) -> bool: + """Whether a held permission set satisfies ``needed`` (incl. legacy bundles).""" + if needed in held: + return True + for bundle, parts in LEGACY_BUNDLES.items(): + if needed in parts and bundle in held: + return True + return False + + def permission_catalog() -> dict[str, Any]: menus = [] actions = [] + data = [] + meta_by_code: dict[str, dict[str, str]] = {} for code in ALL_PERMISSIONS: meta = _PERMISSION_META[code] item = {"code": code, "label": meta["label"]} - if meta["group"] == "menu": + meta_by_code[code] = meta + group = meta["group"] + if group == "menu": menus.append(item) + elif group == "data": + data.append(item) else: actions.append(item) - return {"menus": menus, "actions": actions} + + # Reverse index: menu → child action codes (preserve ALL_PERMISSIONS order). + children_by_menu: dict[str, list[dict[str, str]]] = {m["code"]: [] for m in menus} + for code in ALL_PERMISSIONS: + if meta_by_code[code]["group"] != "action": + continue + parent = ACTION_PRIMARY_MENU.get(code) + if parent and parent in children_by_menu: + children_by_menu[parent].append( + {"code": code, "label": meta_by_code[code]["label"]} + ) + + tree: list[dict[str, Any]] = [] + for menu in menus: + node: dict[str, Any] = { + "code": menu["code"], + "label": menu["label"], + "kind": "menu", + "children": children_by_menu.get(menu["code"], []), + } + tree.append(node) + + if data: + tree.append( + { + "code": "__group.data__", + "label": "数据权限", + "kind": "group", + "children": [ + {"code": item["code"], "label": item["label"]} for item in data + ], + } + ) + + return { + "menus": menus, + "actions": actions, + "data": data, + "tree": tree, + "pairs": [ + {"menu": menu, "action": action} for menu, action in MENU_ACTION_PAIRS + ], + } diff --git a/backend/auth/role_service.py b/backend/auth/role_service.py index 81b2206..d212eb8 100644 --- a/backend/auth/role_service.py +++ b/backend/auth/role_service.py @@ -12,7 +12,7 @@ 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 .permissions import ALL_PERMISSIONS, expand_paired_permissions, normalize_permissions from .roles import ( ROLE_ADMIN, RoleRecord, @@ -48,7 +48,11 @@ def _decode_permissions(raw: str | None) -> list[str]: def role_to_record(row: Role) -> RoleRecord: - perms = list(ALL_PERMISSIONS) if row.is_admin else _decode_permissions(row.permissions) + perms = ( + list(ALL_PERMISSIONS) + if row.is_admin + else expand_paired_permissions(_decode_permissions(row.permissions)) + ) return RoleRecord( code=row.code, label=row.label, @@ -70,7 +74,7 @@ async def refresh_role_cache(db: AsyncSession) -> list[RoleRecord]: async def seed_builtin_roles(db: AsyncSession) -> None: - """Insert missing built-in roles and keep admin permissions complete.""" + """Insert missing built-in roles and keep system role permissions in sync.""" 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()} @@ -92,17 +96,35 @@ async def seed_builtin_roles(db: AsyncSession) -> None: ) changed = True continue - # Keep system flags and admin full permission set in sync. + # Keep system flags and built-in permission sets in sync with code. 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 + if seed.is_admin: + if not row.is_admin or row.permissions != payload or row.label != seed.label: + row.is_admin = True + row.permissions = payload + row.label = seed.label + if seed.description and row.description != seed.description: + row.description = seed.description + changed = True + else: + # operator / viewer: resync catalog so new menu/action codes ship. + desired = _encode_permissions(expand_paired_permissions(seed.permissions)) + if row.permissions != desired or row.label != seed.label: + row.permissions = desired + row.label = seed.label + row.is_admin = False + changed = True + + # Repair custom roles that have unpaired menu/action selections. + for row in existing.values(): + if row.code in seeds or row.is_admin: + continue + repaired = expand_paired_permissions(_decode_permissions(row.permissions)) + encoded = _encode_permissions(repaired) + if row.permissions != encoded: + row.permissions = encoded changed = True if changed: diff --git a/backend/auth/roles.py b/backend/auth/roles.py index 9e56be7..94c6944 100644 --- a/backend/auth/roles.py +++ b/backend/auth/roles.py @@ -7,12 +7,15 @@ from typing import Iterable from .permissions import ( ALL_PERMISSIONS, + DATA_SCOPE_ALL, OPERATOR_PERMISSIONS, VIEWER_PERMISSIONS, WRITE_PERMISSIONS, - normalize_permissions, + expand_legacy_bundles, + permission_implies, ) + ROLE_ADMIN = "admin" ROLE_OPERATOR = "operator" ROLE_VIEWER = "viewer" @@ -44,7 +47,7 @@ def default_role_seeds() -> list[RoleRecord]: RoleRecord( code=ROLE_ADMIN, label=ROLE_LABELS[ROLE_ADMIN], - description="拥有全部菜单与操作权限,可管理全局数据", + description="拥有全部菜单、按钮与全局数据权限", is_system=True, is_admin=True, permissions=list(ALL_PERMISSIONS), @@ -52,7 +55,7 @@ def default_role_seeds() -> list[RoleRecord]: RoleRecord( code=ROLE_OPERATOR, label=ROLE_LABELS[ROLE_OPERATOR], - description="管理自己的账号、规则与私信", + description="管理自己的账号、规则与私信(仅本人数据)", is_system=True, is_admin=False, permissions=list(OPERATOR_PERMISSIONS), @@ -91,20 +94,27 @@ def role_label(code: str | None) -> str: def is_admin(role: str | None) -> bool: - """True when the role has global data scope (built-in admin).""" + """True for the built-in admin role (global admin flag).""" 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 has_global_scope(role: str | None) -> bool: + """True when the role may see all users' data (admin or data.scope_all).""" + if is_admin(role): + return True + return has_permission(role, DATA_SCOPE_ALL) + + 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) + held = expand_legacy_bundles(set(record.permissions)) + return any(code in WRITE_PERMISSIONS for code in held) return str(role or "") in (ROLE_ADMIN, ROLE_OPERATOR) @@ -112,6 +122,10 @@ def can_manage_users(role: str | None) -> bool: return has_permission(role, "users.manage") +def can_manage_roles(role: str | None) -> bool: + return has_permission(role, "roles.manage") + + def has_permission(role: str | None, permission: str) -> bool: code = str(permission or "").strip() if not code: @@ -121,13 +135,14 @@ def has_permission(role: str | None, permission: str) -> bool: if str(role or "") == ROLE_ADMIN: return True if str(role or "") == ROLE_OPERATOR: - return code in OPERATOR_PERMISSIONS + return permission_implies(expand_legacy_bundles(set(OPERATOR_PERMISSIONS)), code) if str(role or "") == ROLE_VIEWER: - return code in VIEWER_PERMISSIONS + return permission_implies(set(VIEWER_PERMISSIONS), code) return False if record.is_admin: return True - return code in record.permissions + held = expand_legacy_bundles(set(record.permissions)) + return permission_implies(held, code) def permissions_for_role(role: str | None) -> list[str]: @@ -136,7 +151,9 @@ def permissions_for_role(role: str | None) -> list[str]: if str(role or "") == ROLE_ADMIN: return list(ALL_PERMISSIONS) if str(role or "") == ROLE_OPERATOR: - return list(OPERATOR_PERMISSIONS) + from .permissions import expand_paired_permissions + + return expand_paired_permissions(OPERATOR_PERMISSIONS) if str(role or "") == ROLE_VIEWER: return list(VIEWER_PERMISSIONS) return [] @@ -164,4 +181,6 @@ def sanitize_role_permissions( ) -> list[str]: if force_all: return list(ALL_PERMISSIONS) - return normalize_permissions(codes) + from .permissions import expand_paired_permissions + + return expand_paired_permissions(codes) diff --git a/backend/auth/router.py b/backend/auth/router.py index a2dea91..846ccd5 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -14,7 +14,7 @@ from .account_limits import ( normalize_max_accounts, ) from .account_quota import default_stop_worker, sync_user_account_quota -from .dependencies import get_current_user, require_user_manager +from .dependencies import get_current_user, require_role_manager, require_user_manager from .email_service import ( build_password_reset_link, build_verification_link, @@ -37,6 +37,7 @@ from .role_service import ( user_permission_payload, ) from .roles import ROLE_OPERATOR, is_admin +from .scopes import ensure_user_manageable, users_for_manager from .schemas import ( LoginRequest, MessageResponse, @@ -393,17 +394,20 @@ async def list_auth_roles( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - """Lightweight role list for dropdowns (any logged-in user).""" + """Lightweight role list for dropdowns (any logged-in user). + + Intentionally omits permission arrays to avoid leaking the full ACL map. + """ records = await list_role_records(db) return RolesResponse( roles=[ RoleInfo( value=item.code, label=item.label, - description=item.description or None, + description=None, is_system=item.is_system, is_admin=item.is_admin, - permissions=list(item.permissions), + permissions=[], ) for item in records ] @@ -417,9 +421,9 @@ roles_router = APIRouter(prefix="/api/roles", tags=["roles"]) @users_router.get("", response_model=list[UserResponse]) async def list_users( db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + current: User = Depends(require_user_manager), ): - result = await db.execute(select(User).order_by(User.id.asc())) + result = await db.execute(users_for_manager(current).order_by(User.id.asc())) users = result.scalars().all() responses = [] for user in users: @@ -431,13 +435,15 @@ async def list_users( async def create_user( body: UserCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + current: User = Depends(require_user_manager), ): settings = await load_settings(db) exists = await db.execute(select(User).where(User.username == body.username)) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="用户名已存在") role = await ensure_role_assignable(db, body.role) + if is_admin(role) and not is_admin(current.role): + raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色") 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="系统已开启「登录必须绑定邮箱」,请填写邮箱") @@ -455,6 +461,7 @@ async def create_user( email_verified=email_verified, email_verified_at=datetime.utcnow() if email and email_verified else None, max_accounts=normalize_max_accounts(body.max_accounts, role), + created_by=current.id, ) db.add(user) await db.commit() @@ -473,6 +480,7 @@ async def update_user( user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") + ensure_user_manageable(current, user) settings = await load_settings(db) if user.id == current.id and body.is_active is False: raise HTTPException(status_code=400, detail="不能禁用当前登录账号") @@ -482,6 +490,8 @@ async def update_user( 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) + if is_admin(new_role) and not is_admin(current.role): + raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色") await guard_last_admin_change(db, user=user, new_role=new_role) if body.display_name is not None: @@ -541,6 +551,7 @@ async def delete_user( user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") + ensure_user_manageable(current, user) await guard_last_admin_change(db, user=user, deleting=True) await db.delete(user) await db.commit() @@ -550,7 +561,7 @@ async def delete_user( @roles_router.get("", response_model=RolesResponse) async def admin_list_roles( db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + _: User = Depends(require_role_manager), ): records = await list_role_records(db) roles = [] @@ -570,7 +581,7 @@ async def admin_list_roles( @roles_router.get("/catalog", response_model=PermissionCatalogResponse) -async def get_permission_catalog(_: User = Depends(require_user_manager)): +async def get_permission_catalog(_: User = Depends(require_role_manager)): return PermissionCatalogResponse(**permission_catalog()) @@ -578,7 +589,7 @@ async def get_permission_catalog(_: User = Depends(require_user_manager)): async def create_custom_role( body: RoleCreate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + _: User = Depends(require_role_manager), ): record = await create_role( db, @@ -603,7 +614,7 @@ async def update_custom_role( code: str, body: RoleUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + _: User = Depends(require_role_manager), ): record = await update_role( db, @@ -627,7 +638,7 @@ async def update_custom_role( async def delete_custom_role( code: str, db: AsyncSession = Depends(get_db), - _: User = Depends(require_user_manager), + _: User = Depends(require_role_manager), ): await delete_role(db, code) return {"message": "角色已删除"} diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py index b9a1834..61ac7df 100644 --- a/backend/auth/schemas.py +++ b/backend/auth/schemas.py @@ -132,3 +132,6 @@ class RoleUpdate(BaseModel): class PermissionCatalogResponse(BaseModel): menus: list[dict[str, Any]] actions: list[dict[str, Any]] + data: list[dict[str, Any]] = Field(default_factory=list) + tree: list[dict[str, Any]] = Field(default_factory=list) + pairs: list[dict[str, Any]] = Field(default_factory=list) diff --git a/backend/auth/scopes.py b/backend/auth/scopes.py index 26f0fdd..02f341a 100644 --- a/backend/auth/scopes.py +++ b/backend/auth/scopes.py @@ -5,8 +5,12 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User -from .permissions import ACCOUNTS_WRITE, RULES_WRITE -from .roles import has_permission, is_admin +from .permissions import ( + ACCOUNTS_UPDATE, + ACCOUNTS_WRITE_GRANULAR, + RULES_WRITE, +) +from .roles import has_global_scope, has_permission, is_admin async def get_owned_account( @@ -15,29 +19,42 @@ async def get_owned_account( account_id: int, *, write: bool = False, + write_permission: str | None = None, ) -> Account: result = await db.execute(select(Account).where(Account.id == account_id)) account = result.scalar_one_or_none() if not account: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在") - if is_admin(user.role): + if has_global_scope(user.role): + if write: + needed = write_permission or ACCOUNTS_UPDATE + if not has_permission(user.role, needed): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"缺少权限:{needed}", + ) return account if account.owner_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号") - if write and not has_permission(user.role, ACCOUNTS_WRITE): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改") + if write: + needed = write_permission or ACCOUNTS_UPDATE + if not has_permission(user.role, needed): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"缺少权限:{needed}", + ) return account def accounts_for_user(user: User): stmt = select(Account) - if not is_admin(user.role): + if not has_global_scope(user.role): stmt = stmt.where(Account.owner_id == user.id) return stmt async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]: - if is_admin(user.role): + if has_global_scope(user.role): return None result = await db.execute(select(Account.id).where(Account.owner_id == user.id)) return {row[0] for row in result.all()} @@ -47,7 +64,7 @@ def logs_for_user(user: User, account_id: Optional[int] = None): stmt = select(MessageLog) if account_id is not None: stmt = stmt.where(MessageLog.account_id == account_id) - if not is_admin(user.role): + if not has_global_scope(user.role): owned = select(Account.id).where(Account.owner_id == user.id) stmt = stmt.where(MessageLog.account_id.in_(owned)) return stmt @@ -57,7 +74,7 @@ def received_logs_for_user(user: User, account_id: Optional[int] = None): stmt = select(ReceivedMessageLog) if account_id is not None: stmt = stmt.where(ReceivedMessageLog.account_id == account_id) - if not is_admin(user.role): + if not has_global_scope(user.role): owned = select(Account.id).where(Account.owner_id == user.id) stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned)) return stmt @@ -67,19 +84,23 @@ def rules_for_user(user: User, account_id: Optional[int] = None): stmt = select(AutoReplyRule) if account_id is not None: stmt = stmt.where(AutoReplyRule.account_id == account_id) - if is_admin(user.role): + if has_global_scope(user.role): return stmt owned = select(Account.id).where(Account.owner_id == user.id) return stmt.where(AutoReplyRule.account_id.in_(owned)) -async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, write: bool = False) -> AutoReplyRule: +async def get_accessible_rule( + db: AsyncSession, user: User, rule_id: int, *, write: bool = False +) -> AutoReplyRule: result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id)) rule = result.scalar_one_or_none() if not rule: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在") - if is_admin(user.role): + if has_global_scope(user.role): + if write and not has_permission(user.role, RULES_WRITE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write") return rule if rule.account_id is None: @@ -89,7 +110,7 @@ async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, wri 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 not has_permission(user.role, RULES_WRITE): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="缺少权限:rules.write") return rule @@ -97,7 +118,7 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None): stmt = select(SystemLog) if account_id is not None: stmt = stmt.where(SystemLog.account_id == account_id) - if not is_admin(user.role): + if not has_global_scope(user.role): owned = select(Account.id).where(Account.owner_id == user.id) stmt = stmt.where( or_( @@ -106,3 +127,25 @@ def system_logs_for_user(user: User, account_id: Optional[int] = None): ) ) return stmt + + +def users_for_manager(manager: User): + """Admins see all users; others only see themselves and users they created.""" + stmt = select(User) + if is_admin(manager.role): + return stmt + return stmt.where(or_(User.created_by == manager.id, User.id == manager.id)) + + +def ensure_user_manageable(manager: User, target: User) -> None: + """Raise 403 unless manager may edit/delete target user.""" + if is_admin(manager.role): + return + if target.id == manager.id: + return + if target.created_by == manager.id: + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只能管理自己创建的用户", + ) diff --git a/backend/auth/settings_router.py b/backend/auth/settings_router.py index 2e6e4f0..0018046 100644 --- a/backend/auth/settings_router.py +++ b/backend/auth/settings_router.py @@ -15,7 +15,7 @@ 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_permission -from .permissions import SETTINGS_MANAGE +from .permissions import PAYMENTS_MANAGE, SETTINGS_DATABASE, SETTINGS_MANAGE from .email_service import send_test_email from .system_settings import ( PASSWORD_PLACEHOLDER, @@ -231,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_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(PAYMENTS_MANAGE)), ): data = await load_settings(db) return PaymentSettingsResponse(**settings_to_payment_response(data)) @@ -241,7 +241,7 @@ async def get_payment_settings( async def update_payment_settings( body: PaymentSettingsUpdate, db: AsyncSession = Depends(get_db), - _: User = Depends(require_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(PAYMENTS_MANAGE)), ): updates = body.model_dump(exclude_unset=True) data = await save_settings(db, updates) @@ -277,14 +277,14 @@ async def test_smtp_email( @router.get("/database", response_model=DatabaseSettingsResponse) -async def get_database_settings(_: User = Depends(require_permission(SETTINGS_MANAGE))): +async def get_database_settings(_: User = Depends(require_permission(SETTINGS_DATABASE))): return DatabaseSettingsResponse(**database_config_to_response()) @router.put("/database", response_model=MessageResponse) async def update_database_settings( body: DatabaseSettingsUpdate, - _: User = Depends(require_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(SETTINGS_DATABASE)), ): payload = body.model_dump(exclude_unset=True) if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER): @@ -303,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_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(SETTINGS_DATABASE)), ): payload = body.model_dump(exclude_unset=True) if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER): @@ -318,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_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(SETTINGS_DATABASE)), ): return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path)) @@ -326,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_permission(SETTINGS_MANAGE)), + _: User = Depends(require_permission(SETTINGS_DATABASE)), ): payload = body.model_dump(exclude_unset=True) clear_target = bool(payload.pop("clear_target", False)) diff --git a/backend/kefu.db-shm b/backend/kefu.db-shm index ba4863eca158dd0fa7a25a7475677fc7a0ab0664..405c17a22be6c34741789cfa02b045668d6db89f 100644 GIT binary patch delta 324 zcmZo@U}|V!s+V}A%K!pQK+MR%Ai&1V!0_}i1H*Ak?GHLqQ%!zNd3p66vugC^miGm7 zf6jCvRXx~nAOSM>KN5h7Gco+%*m$4YcBME20|=|zGBB7pGBCIR<=uf8CJv&3&SL;l zU^$Sy8xVukcy3PQNjKii;t4m^T+hc_=XcoP5z;dh?Yk9aaF_xnwK= delta 249 zcmZo@U}|V!s+V}A%K!o_K+MR%An=Wef#K<41_p&ci~N2cXl?p6<>l3P%&O6H8_rjM z=za8?RP|uPfdt6h|40BT&cyI^W8;19&5L;wjVEv5ao)`05XwCHvX}hk3g;Nc$(P)v zHotS}VgpLCG0vHM(Mx{vJZ}+Bh~`U-pC%UunQ#8&-NXtseREmhA107DfUFQT*2zLX Z8k3Jit^&yvM0qGozT_pd`AU@zD**D8U2gyY diff --git a/backend/kefu.db-wal b/backend/kefu.db-wal index a96fe9d9370f14d4d4a73fec2d3d252b5365d62f..22c0ec373012e58142b3e9bafd6ddd3bed19ef51 100644 GIT binary patch delta 9586 zcmeHN3v64}8NQC~#LmOFrDbX8+NNzv=}PXs=f3aVO6$aN;v|lfxN(}Gt@xEVe#ehE zE|XlhHz}aC<5lj;mUU_r6{V(0o5$1P0l z*rsWT@+Ug?oO{my{r`W?|NrN|FMj7h)r*k!N-C*JQcD_1D^*K1l1|b~wNjl_FEvO8 z36+*f%cbk271B!Sdg(J#qqItDl5UVzOP`h2NNc5aQnR#P+8}L|TBJ?VjnZc6CTWZG zIcckOv(zeKr*QIEOXDL5^8C3E?!WlTJL*lh-u9{!nR(;L*@lLbh+6j;qI;~Ks;iwp zJEl3RtCvPqbx7m7j9!0$k)MOZ)K#`K}p!x%}Whe6u_?gRhj2&)^36(hS}rixQ5??pgTE zGK=4!4o1f1F$v$)$Iv1pU@U~jFeE{;tpwjnaLoiKmiHjYw$0@jPp-XP^xO%IweX<^ zeFZ@;p>LzFpl{BD1NhI;TD`hamE%t%UwQxst7e?7{>>Ba*Yd;PM$o^b@1gIaZ!5!p zfy#UDQ>~PnkKvjwjYg|hsnlxaZ&j6At=Gc^Oyw&F4!?HdPamD!+Ms_AsjGfRjd~3m z43E{Gsc);hv*xt^mRhw=s(we2^~=?nYe(um)&pmnJky$`e zEu^ajkS*38zkSRo*psd)v)jo60zB764mbg)MlN+btu zDR22ak*GK3OhjUN>9MU8(F}k{!QTvl5j;n6!ew0yivd<(DaZ!L5xl?ve#hh_=8l9u zW+=(io~*gXfUM7I*B8;meNMN}8y-(53t0L+9Z9>R&(+)EFo#D5GgOpLw@rBiw(zLa zHx*Bh&|`h!KAY7n#8X{HY8U3)HQ;Jrn5Gg6$>gArB1J7ls0D?HRTU{3dOpwnq31}Ia9zpKZ=rh}E7 zEAryRoDE?lNAr29<|GNFPKgvL5(*>^a4ZD}up~nlZYjY>l9QvVdcJpd(7BtkDRSZ1=~6ofe+&HYLMDlfm8rvfIdy_C{O`-w%2% z!@l8=wPUmkGY`YpFT;_^v&)Mlfw6=@Qbf7=a3TTChsIbAYJepvmZu4tBe+T+#Yre0 zBN?b8!jtXN7?6!w?Z)Cp031&-j6m>!Enu*A1E(W1v1`yB=;Zkce>BuA^l)cZ^e_Y< zX$B4<0aMBO;-Lh=c(UzU1F|lwU02-1d)#SwpwUcG2^ojeVQV)HV)nicyfYNG_WHYr zro3VEL?lS=X=~##yWJF}6SM_%yZw76y4lcBFjSgl0Z755b1ab1GXabQ0u3r~kb+16 zj3Y>a5@?PSKq~=+E8{3l0;SYLqAMQ++R!t*cs42ckz*?s!2Rdu;A7Yv~#% z#uM}?lN#O0S(8&3h>(6u7oQ0F+d8eo-NU#&*yko9X&Y&>hJwTHcqIw|O@I_hDyu6j z)CiBj(n1-Z8a^#%`kw1UC^Tt0!uHrl7a=~HzA`~vY^buWml;TNMlyJ zH77+KqH=md6X62yqrtXFz~aEeVW-0wod!X_&1ts2b!buzgQnl1FQd<+KR_Qrzkz-g-G?Tt@ zQ5B_FSKg>CZ|KV#y7ER%d84|#p)GG{${XrZ;8sOPVpTeIqdo^8K^6znsgvZ~_BY(7 ze26=T=;o9_+AytzT?b)^>o0}ULkpp_3`6PP=7MVZ_E|h-;Gq^p1|I4x2|a5mszzQ= zqTT14_G^pL?u`1fXg8{aw>wIaEvrPfEpk-CSAT3kyLh=uL>GOQpM-w;_=o>nh-m1J zSWFNoiX$o!9ewlBzui{dWN)aa5RLXFq~_K7vASCAONx%pXq%*xcn^G%cvpwqF|RY> z-EedB2B+H{Nrn?~EE)AwKJs{j-ilH2gfo^{^ji7tBJjzj_z<#Wrpgd20p{8A+5 zfe8)goA{^N5 zcE&t1n8nRo;^}z88`=%Cwm9YvdYv&vB)Rh*3pvBi@th2md{x<6>z2j6iNr)0j)i)1 zx}0(O@GQ<=E=4$pG60La;X$+884SuF?!#{~EJzJN@1ShVEh>#+8lhxvKaST+XjL7u zrs-C_Znj{6I6;uzLIV z14>+eJ#rRNzkoi0nj2nixVK?@-LrM0wb9y@`tRxq-D#ZzIjg&_=Ea&ob&Gm^)t8lM zQ+% zJcTt-Fr{AbOGmz~T|aZ%*s)?t-7ve9QZJ>{ODXkIO1+d)FQwGino^@)u31!t}y4Zk`Kf1-jBuA+hv{^1aRWa#8GO1M)GD+pH(chInsD7~~)Ls+UIEY%Ri zr5eIg4PmK@i;a?drZ9}BkqxD*EHzhJ>kCo>F(6DA0IZdu3kF;pm^eLUQqbY6fOoLv9ij= z;=%=DDZlD^cdPIMefWl(^SEXZbOwDLmSNsSFQPv~A4Z=-zlokee}x`Y#N$OXHR=^m zpP+u3defp$N9vRgctKLD{D+lqeYJYIDjn1r)az9XUsKd5Pkp}X2K72ssi>(%)f-i1 zb!Lq+XgsD?{<-7jB^M)q;M8#f5`aWPq9Dk7v0Q^v&1^@s6 delta 976 zcmYk)TS!zv7zgksLkv&L6`Or&YA@*%hoq^xWcm6Z;&6ydv(x@0fLEu0E zN{9g!s6hi-(19Kd5DP}&!347)4&uQKvmpWIKqAbABoH7OEMSEcNQE>=hYXkpnJ^!+ zzy{fn1G%sO@*p1ydKWsc31SYx@w4Ugo6+`+lnosvGxPgyUL{1d(c21RgpGgVd$nxW zM0TCpg4JAk`deB5&W3<*1J-kx|5Wxyj@4WjN>xmEOe)t2e!D!p;0sxv^=9&QbP@V7 z!x*9V3)w*$7}F%Nj(b8aPhY?HS#S@(Lq1J?AnxmmgXK=F+gV*V?m+_|6e@NrQKwbk zy(pd63V!_f{|FE%aW`4lo{TO;>z3*svqN2y*ChB|^3=N4;n+(RCtEP(JiDfE6%Cxx zSJQArN~7b!{93wiWKkQtaZj@H>4$^ziBl!lR?e;|O*UeYQ;rbjh>|(BsGF2DbVchk zo;dMi&t8Gpi1i>LR8uLXwmOPDYIniD`kHDQs+96H&H|6<@;K<3)iEoXalhm@q#VKD z8rA>o??t2yt>4%=RQ?$Ib5TdL;vEYQNOqHUv|@7W=C<>fX!wCHnH!@`7T0oaj0LkY zFPrF>2GJO(aUV* zr#mR73YlN@KH-^t+AVU?r&A~ouJYwMLoL(avF>3Xcj*UtmNx0m{}c4cgOaIJb)c;@ z0fSx^m*A~Xv9e|6c70<{mmN8f6SGGe;`3*pW BVsiih diff --git a/backend/link_cards_router.py b/backend/link_cards_router.py index d2f6146..7791442 100644 --- a/backend/link_cards_router.py +++ b/backend/link_cards_router.py @@ -12,7 +12,9 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from auth.dependencies import get_current_user, require_write +from auth.dependencies import get_current_user, require_link_cards_write +from auth.roles import has_permission +from auth.permissions import LINK_CARDS_WRITE from link_cards import ( absolute_media_url, build_keywords, @@ -68,8 +70,8 @@ async def _get_owned_card( card = (await db.execute(stmt)).scalar_one_or_none() if not card or card.owner_id != user.id: raise HTTPException(status_code=404, detail="卡片不存在") - if write and user.role == "viewer": - raise HTTPException(status_code=403, detail="无写入权限") + if write and not has_permission(user.role, LINK_CARDS_WRITE): + raise HTTPException(status_code=403, detail="缺少权限:link_cards.write") return card @@ -93,7 +95,7 @@ def _card_response(card: LinkCardPage, request: Request) -> LinkCardResponse: async def upload_link_card_image( request: Request, file: UploadFile = File(...), - user: User = Depends(require_write), + user: User = Depends(require_link_cards_write), ): if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="仅支持上传图片文件") @@ -137,7 +139,7 @@ async def upsert_link_card( body: LinkCardUpsert, request: Request, db: AsyncSession = Depends(get_db), - user: User = Depends(require_write), + user: User = Depends(require_link_cards_write), ): title = body.title.strip() content = (body.content or "").strip() diff --git a/backend/main.py b/backend/main.py index 68a1bb8..7d7bfe4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,11 +44,15 @@ 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_accounts_write, - require_admin, + require_accounts_cookie, + require_accounts_create, + require_accounts_delete, + require_accounts_start, + require_accounts_stop, + require_accounts_update, require_messages_write, require_rules_write, - require_write, + require_system_logs_clear, ) from auth.account_limits import ensure_can_add_account from auth.scopes import ( @@ -61,7 +65,7 @@ from auth.scopes import ( received_logs_for_user, system_logs_for_user, ) -from auth.roles import has_permission, is_admin +from auth.roles import has_global_scope, 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 @@ -1413,7 +1417,7 @@ async def get_account_options( Account.reply_cooldown_seconds, Account.quota_disabled, ) - if not is_admin(user.role): + if not has_global_scope(user.role): stmt = stmt.where(Account.owner_id == user.id) rows = (await db.execute(stmt.order_by(Account.id.asc()))).all() @@ -1632,9 +1636,9 @@ async def update_account( account_id: int, body: AccountUpdate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_update), ): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.update") follow_config_changed = bool( {"follow_welcome_enabled", "follow_welcome_content"} & set(body.model_fields_set) @@ -1700,7 +1704,7 @@ async def get_reply_queue_summaries( allowed_ids = await owned_account_ids(db, user) worker_entries = list(manager.workers.items()) else: - if is_admin(user.role): + if has_global_scope(user.role): allowed_ids = set(requested_ids) elif requested_ids: owned_result = await db.execute( @@ -1793,7 +1797,7 @@ async def send_account_queued_reply_now( user: User = Depends(require_messages_write), ): """将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。""" - await get_owned_account(db, user, account_id, write=True) + await get_owned_account(db, user, account_id, write=True, write_permission="messages.write") worker = manager.workers.get(account_id) service = worker._im_service if worker else None if not worker or not worker.is_running or not service or not service._running: @@ -1888,7 +1892,7 @@ async def get_account_cookie( account_id: int, purpose: Optional[str] = None, db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_accounts_cookie), ): """Read a Cookie for management or for legacy desktop login clients. @@ -1921,7 +1925,7 @@ async def get_account_cookie( async def get_desktop_login_credential( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_accounts_cookie), ): """Return credentials only when the selected Douyin identity is verified. @@ -1945,7 +1949,7 @@ async def update_account_cookie( account_id: int, body: AccountCookieUpdate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_cookie), ): # Validate first: malformed input must not take a healthy hosted account # offline. Filesystem and database mutations happen only after the worker @@ -1960,7 +1964,7 @@ async def update_account_cookie( # preparations. Keep the lock until the new Cookie and cleared identity # are committed so no worker can start in the stop/commit gap. async with manager.preparation_lock(account_id): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie") await _release_db_connection(db) await batch_start_queue.cancel_account(account_id) await manager.stop_worker(account_id) @@ -1994,13 +1998,13 @@ async def update_account_cookie( async def delete_account_cookie( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_cookie), ): # Deleting credentials uses the same preparation lock as starting a # worker, preventing a new worker from appearing after stop_worker but # before the cleared credentials are committed. async with manager.preparation_lock(account_id): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie") await _release_db_connection(db) await batch_start_queue.cancel_account(account_id) await manager.stop_worker(account_id) @@ -2023,7 +2027,7 @@ async def delete_account_cookie( async def create_account( account_in: AccountCreate, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_create), ): cookie_data = (account_in.cookie_data or "").strip() standard_json_str = None @@ -2061,9 +2065,9 @@ async def create_account( async def delete_account( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_delete), ): - await get_owned_account(db, user, account_id, write=True) + await get_owned_account(db, user, account_id, write=True, write_permission="accounts.delete") # 停止运行中的任务 await _release_db_connection(db) await batch_start_queue.cancel_account(account_id) @@ -2079,7 +2083,7 @@ async def delete_account( async def validate_account_credential( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_accounts_cookie), ): account = await get_owned_account(db, user, account_id) @@ -2096,9 +2100,9 @@ async def validate_account_credential( async def reset_account_credentials( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_cookie), ): - await get_owned_account(db, user, account_id, write=True) + await get_owned_account(db, user, account_id, write=True, write_permission="accounts.cookie") await _release_db_connection(db) await batch_start_queue.cancel_account(account_id) account = await _reset_account_credentials(account_id, db) @@ -2251,9 +2255,11 @@ async def start_account_rpa( account_id: int, body: StartAccountRequest = StartAccountRequest(), db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_start), ): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account( + db, user, account_id, write=True, write_permission="accounts.start" + ) # Cancelling waits for an in-flight queued start, and the preparation lock # waits for whichever start owns this account. Neither may keep a pooled # connection checked out while it waits. @@ -2267,7 +2273,7 @@ async def start_account_rpa( async def submit_account_start_batch( body: BatchStartRequest, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_start), ): requested_ids = list( dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0) @@ -2280,7 +2286,7 @@ async def submit_account_start_batch( # The submit path needs only ids and the disabled flag. Do not hydrate # every account's large cookie/session/QR columns just to enqueue ids. candidate_stmt = select(Account.id, Account.quota_disabled) - if not is_admin(user.role): + if not has_global_scope(user.role): candidate_stmt = candidate_stmt.where(Account.owner_id == user.id) if not body.all_accounts: candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids)) @@ -2331,9 +2337,11 @@ async def get_account_start_batch( async def stop_account_rpa( account_id: int, db: AsyncSession = Depends(get_db), - user: User = Depends(require_accounts_write), + user: User = Depends(require_accounts_stop), ): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account( + db, user, account_id, write=True, write_permission="accounts.stop" + ) # Cancelling drains an in-flight queued start, which can take as long as # the batch per-account deadline. Do not hold a pooled connection for it. @@ -2415,7 +2423,7 @@ async def create_rule( rule_in.keyword = "" if rule_in.account_id is None: raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号") - await get_owned_account(db, user, rule_in.account_id, write=True) + await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write") sort_stmt = select(func.max(AutoReplyRule.sort_order)).where( AutoReplyRule.account_id == rule_in.account_id @@ -2451,7 +2459,7 @@ async def update_rule( rule_in.keyword = "" if rule_in.account_id is None: raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号") - await get_owned_account(db, user, rule_in.account_id, write=True) + await get_owned_account(db, user, rule_in.account_id, write=True, write_permission="rules.write") rule.account_id = rule_in.account_id rule.keyword = rule_in.keyword @@ -2637,7 +2645,7 @@ async def get_system_logs( category=category, limit=max(1, min(int(limit or 200), 1000)), ) - if not is_admin(user.role): + if not has_global_scope(user.role): allowed = await owned_account_ids(db, user) entries = [e for e in entries if e.get("account_id") in allowed] return [SystemLogResponse(**e) for e in entries] @@ -2646,7 +2654,7 @@ async def get_system_logs( @app.delete("/api/system-logs") async def clear_system_logs( db: AsyncSession = Depends(get_db), - _: User = Depends(require_admin), + _: User = Depends(require_system_logs_clear), ): """清空系统诊断日志(内存缓冲区 + 数据库历史)。""" system_logger.clear() @@ -2712,7 +2720,7 @@ async def upload_message_image( db: AsyncSession = Depends(get_db), user: User = Depends(require_messages_write), ): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write") if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="仅支持上传图片文件") @@ -2803,7 +2811,7 @@ async def send_account_message( db: AsyncSession = Depends(get_db), user: User = Depends(require_messages_write), ): - account = await get_owned_account(db, user, account_id, write=True) + account = await get_owned_account(db, user, account_id, write=True, write_permission="messages.write") content = normalize_outgoing_content( content=body.content or "", diff --git a/backend/models/db_migrate.py b/backend/models/db_migrate.py index 6726efe..425eef8 100644 --- a/backend/models/db_migrate.py +++ b/backend/models/db_migrate.py @@ -275,6 +275,12 @@ def migrate_users_table(conn) -> None: "max_accounts", {"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"}, ) + add_column_if_missing( + conn, + "users", + "created_by", + {"default": "ALTER TABLE users ADD COLUMN created_by INTEGER"}, + ) cols = _table_columns(conn, "users") if not cols: return diff --git a/backend/models/models.py b/backend/models/models.py index 45ee6fa..75a9a59 100644 --- a/backend/models/models.py +++ b/backend/models/models.py @@ -9,7 +9,7 @@ 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. + custom roles are owned by admins via the users.manage / roles.manage permissions. """ __tablename__ = "roles" @@ -40,6 +40,13 @@ class User(Base): email_verified = Column(Boolean, default=False) email_verified_at = Column(DateTime, nullable=True) max_accounts = Column(Integer, default=3) + # User who created this account via「用户管理」; null for self-register / seeded. + created_by = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/payments/router.py b/backend/payments/router.py index 7a8085c..9bec04e 100644 --- a/backend/payments/router.py +++ b/backend/payments/router.py @@ -3,7 +3,8 @@ from fastapi.responses import PlainTextResponse from sqlalchemy.ext.asyncio import AsyncSession from auth.dependencies import get_current_user, require_permission -from auth.permissions import PAYMENTS_MANAGE +from auth.permissions import ORDERS_CREATE, ORDERS_READ, PAYMENTS_MANAGE +from auth.roles import has_permission from auth.system_settings import load_settings from models.database import get_db from models.models import User @@ -21,6 +22,18 @@ from .schemas import ( router = APIRouter(prefix="/api/payments", tags=["payments"]) +def _require_orders_access(user: User) -> None: + if has_permission(user.role, ORDERS_READ) or has_permission(user.role, PAYMENTS_MANAGE): + return + raise HTTPException(status_code=403, detail="缺少权限:orders.read") + + +def _require_orders_create(user: User) -> None: + if has_permission(user.role, ORDERS_CREATE) or has_permission(user.role, PAYMENTS_MANAGE): + return + raise HTTPException(status_code=403, detail="缺少权限:orders.create") + + @router.get("/config", response_model=PaymentConfigResponse) async def get_payment_config( db: AsyncSession = Depends(get_db), @@ -36,6 +49,7 @@ async def create_payment_order( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): + _require_orders_create(user) order, demo_mode = await service.create_order(db, user, body.slots, body.channel) return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode)) @@ -49,6 +63,7 @@ async def list_payment_orders( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): + _require_orders_access(user) if status and status not in service.ORDER_STATUSES: raise HTTPException(status_code=400, detail="无效的订单状态") data = await service.list_orders( @@ -68,6 +83,7 @@ async def get_payment_order( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): + _require_orders_access(user) settings = await load_settings(db) order = await service.get_user_order(db, user, order_no) demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel) @@ -80,6 +96,7 @@ async def simulate_payment_order( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): + _require_orders_create(user) settings = await load_settings(db) order = await service.simulate_pay(db, user, order_no) return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode)) diff --git a/backend/payments/service.py b/backend/payments/service.py index fc67d09..2225e53 100644 --- a/backend/payments/service.py +++ b/backend/payments/service.py @@ -12,7 +12,8 @@ from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from auth.account_quota import default_stop_worker, sync_user_account_quota -from auth.roles import is_admin +from auth.roles import has_global_scope, has_permission, is_admin +from auth.permissions import ORDERS_READ, PAYMENTS_MANAGE from auth.system_settings import SystemSettingsData, load_settings from models.models import PaymentOrder, User from . import alipay, wechat @@ -347,7 +348,12 @@ async def list_orders( page_size = max(1, min(100, page_size)) filters = [] - if not is_admin(current_user.role): + # Global order list for built-in admin, payments.manage, or data.scope_all. + if not ( + is_admin(current_user.role) + or has_permission(current_user.role, PAYMENTS_MANAGE) + or has_global_scope(current_user.role) + ): filters.append(PaymentOrder.user_id == current_user.id) if status: filters.append(PaymentOrder.status == status) diff --git a/backend/tests/test_roles_rbac.py b/backend/tests/test_roles_rbac.py index 2c5c35c..d9efa49 100644 --- a/backend/tests/test_roles_rbac.py +++ b/backend/tests/test_roles_rbac.py @@ -19,7 +19,13 @@ if str(BACKEND_DIR) not in sys.path: 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.permissions import ( # noqa: E402 + ACCOUNTS_WRITE, + ALL_PERMISSIONS, + MENU_USERS, + USERS_MANAGE, + expand_paired_permissions, +) from auth.role_service import ( # noqa: E402 create_role, delete_role, @@ -109,8 +115,35 @@ class RolesRbacTests(unittest.IsolatedAsyncioTestCase): 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.assertIn("accounts.create", payload.permissions) self.assertNotIn(MENU_USERS, payload.permissions) + self.assertNotIn("data.scope_all", payload.permissions) + + async def test_legacy_accounts_write_implies_granular(self): + created = await create_role( + self.db, + code="legacy_ops", + label="旧版运营", + description=None, + permissions=[ACCOUNTS_WRITE, "menu.accounts"], + ) + self.assertTrue(has_permission("legacy_ops", "accounts.start")) + self.assertTrue(has_permission("legacy_ops", "accounts.cookie")) + self.assertIn(ACCOUNTS_WRITE, created.permissions) + + async def test_data_scope_all_for_custom_role(self): + from auth.roles import has_global_scope + + await create_role( + self.db, + code="auditor", + label="审计", + description=None, + permissions=["menu.accounts", "data.scope_all"], + ) + self.assertTrue(has_global_scope("auditor")) + self.assertFalse(has_global_scope("operator")) + self.assertTrue(has_global_scope("admin")) async def test_last_admin_cannot_be_demoted(self): admin = User( @@ -128,6 +161,31 @@ class RolesRbacTests(unittest.IsolatedAsyncioTestCase): await guard_last_admin_change(self.db, user=admin, new_role="operator") self.assertEqual(caught.exception.status_code, 400) + async def test_menu_action_pairs_expand(self): + expanded = expand_paired_permissions([MENU_USERS]) + self.assertIn(MENU_USERS, expanded) + self.assertIn(USERS_MANAGE, expanded) + created = await create_role( + self.db, + code="hr_desk", + label="人事台", + description=None, + permissions=[MENU_USERS], + ) + self.assertIn(USERS_MANAGE, created.permissions) + self.assertNotIn("menu.roles", created.permissions) + self.assertNotIn("roles.manage", created.permissions) + + roles_only = await create_role( + self.db, + code="role_editor", + label="角色编辑", + description=None, + permissions=["menu.roles"], + ) + self.assertIn("roles.manage", roles_only.permissions) + self.assertNotIn(USERS_MANAGE, roles_only.permissions) + if __name__ == "__main__": unittest.main() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 49003a8..4dc2f58 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,6 +12,7 @@ import { MessageOutlined, BugOutlined, TeamOutlined, + SafetyCertificateOutlined, LogoutOutlined, ControlOutlined, PayCircleOutlined, @@ -32,6 +33,7 @@ const ICON_MAP = { MessageOutlined, BugOutlined, TeamOutlined, + SafetyCertificateOutlined, ControlOutlined, PayCircleOutlined, UnorderedListOutlined, diff --git a/frontend/src/components/ReplyRuleEditor.vue b/frontend/src/components/ReplyRuleEditor.vue index 911371a..2df0ca7 100644 --- a/frontend/src/components/ReplyRuleEditor.vue +++ b/frontend/src/components/ReplyRuleEditor.vue @@ -21,6 +21,14 @@ const props = defineProps({ showHeader: { type: Boolean, default: true + }, + readonly: { + type: Boolean, + default: false + }, + canUploadCards: { + type: Boolean, + default: true } }) @@ -39,10 +47,12 @@ const typeOptions = replyTypeOptions.map((opt) => ({ const cardUploading = ref({}) const addReplyItem = () => { + if (props.readonly) return replyItems.value = [...replyItems.value, emptyReplyForm()] } const removeReplyItem = (index) => { + if (props.readonly) return if (replyItems.value.length <= 1) { message.warning('至少保留一条回复消息') return @@ -53,6 +63,7 @@ const removeReplyItem = (index) => { } const updateReplyField = (index, field, value) => { + if (props.readonly) return const next = replyItems.value.map((item, i) => i === index ? { ...item, [field]: value } : item ) @@ -60,6 +71,7 @@ const updateReplyField = (index, field, value) => { } const updateReplyFields = (index, fields) => { + if (props.readonly) return const next = replyItems.value.map((item, i) => i === index ? { ...item, ...fields } : item ) @@ -67,6 +79,11 @@ const updateReplyFields = (index, fields) => { } const uploadCardImage = async (index, options) => { + if (props.readonly || !props.canUploadCards) { + message.warning('当前账号无卡片上传权限') + options?.onError?.(new Error('no permission')) + return + } const { file, onSuccess, onError } = options cardUploading.value = { ...cardUploading.value, [index]: true } try { @@ -104,7 +121,13 @@ const copyPageUrl = async (url) => {
自动回复消息 - + 添加一条消息 @@ -114,7 +137,7 @@ const copyPageUrl = async (url) => {
消息 {{ replyIndex + 1 }} { :value="reply.reply_type" button-style="solid" class="reply-type-group" + :disabled="readonly" @update:value="(v) => updateReplyField(replyIndex, 'reply_type', v)" > @@ -193,6 +217,7 @@ const copyPageUrl = async (url) => {
{
上传封面
+
+ 封面 + +
上传后自动转为 PNG favicon(32×32)与卡片封面(256×256)
diff --git a/frontend/src/config/menus.js b/frontend/src/config/menus.js index 7f912e0..3056247 100644 --- a/frontend/src/config/menus.js +++ b/frontend/src/config/menus.js @@ -1,5 +1,6 @@ /** * Sidebar menu catalog. Visibility is driven by permission codes from /auth/me. + * Admin pages also require paired action permissions (alsoRequires). */ export const MENU_ITEMS = [ { @@ -36,6 +37,7 @@ export const MENU_ITEMS = [ name: 'Logs', title: '回复日志面板', permission: 'menu.logs', + alsoRequires: ['logs.read'], icon: 'FileTextOutlined' }, { @@ -43,6 +45,7 @@ export const MENU_ITEMS = [ name: 'ReceivedMessages', title: '接收消息日志', permission: 'menu.received_messages', + alsoRequires: ['received_messages.read'], icon: 'InboxOutlined' }, { @@ -50,6 +53,7 @@ export const MENU_ITEMS = [ name: 'SystemLogs', title: '系统诊断日志', permission: 'menu.system_logs', + alsoRequires: ['system_logs.read'], icon: 'BugOutlined' }, { @@ -69,15 +73,25 @@ export const MENU_ITEMS = [ { path: '/users', name: 'Users', - title: '用户与角色', + title: '用户管理', permission: 'menu.users', + alsoRequires: ['users.manage'], icon: 'TeamOutlined' }, + { + path: '/roles', + name: 'Roles', + title: '角色设定', + permission: 'menu.roles', + alsoRequires: ['roles.manage'], + icon: 'SafetyCertificateOutlined' + }, { path: '/settings', name: 'Settings', title: '系统设置', permission: 'menu.settings', + alsoRequires: ['settings.manage'], icon: 'ControlOutlined' }, { @@ -85,6 +99,7 @@ export const MENU_ITEMS = [ name: 'DesktopUpdate', title: '桌面端升级', permission: 'menu.desktop_update', + alsoRequires: ['desktop.manage'], icon: 'RocketOutlined' }, { @@ -92,6 +107,7 @@ export const MENU_ITEMS = [ name: 'PaymentSettings', title: '支付配置', permission: 'menu.payment_settings', + alsoRequires: ['payments.manage'], icon: 'PayCircleOutlined' }, { @@ -99,6 +115,7 @@ export const MENU_ITEMS = [ name: 'MyPaymentOrders', title: '我的订单', permission: 'menu.payment_orders', + alsoRequires: ['orders.read'], icon: 'UnorderedListOutlined' } ] @@ -110,7 +127,8 @@ export const HEADER_TITLES = { Rules: '策略中心', ReceivedMessages: '接收消息日志', SystemLogs: '诊断中心', - Users: '权限中心', + Users: '用户管理', + Roles: '角色设定', Settings: '系统设置', DesktopUpdate: '桌面端升级', PaymentSettings: '支付配置', @@ -120,7 +138,13 @@ export const HEADER_TITLES = { Logs: '日志中心' } +export function menuAccessible(item, hasPermission) { + if (!item || !hasPermission(item.permission)) return false + const extra = item.alsoRequires || [] + return extra.every((code) => hasPermission(code)) +} + export function firstAccessiblePath(hasPermission) { - const hit = MENU_ITEMS.find((item) => hasPermission(item.permission)) + const hit = MENU_ITEMS.find((item) => menuAccessible(item, hasPermission)) return hit?.path || '/help' } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ef576e8..27d2cc4 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -8,6 +8,7 @@ import SystemLogs from '../views/SystemLogs.vue' import ReceivedMessages from '../views/ReceivedMessages.vue' import Login from '../views/Login.vue' import Users from '../views/Users.vue' +import Roles from '../views/Roles.vue' import Settings from '../views/Settings.vue' import DesktopUpdate from '../views/DesktopUpdate.vue' import PaymentSettings from '../views/PaymentSettings.vue' @@ -15,7 +16,8 @@ import MyPaymentOrders from '../views/MyPaymentOrders.vue' import Help from '../views/Help.vue' import Download from '../views/Download.vue' import { useAuthStore } from '../stores/auth' -import { firstAccessiblePath } from '../config/menus' +import { firstAccessiblePath, MENU_ITEMS, menuAccessible } from '../config/menus' + const routes = [ { path: '/login', component: Login, name: 'Login', meta: { public: true } }, @@ -37,6 +39,7 @@ const routes = [ meta: { permission: 'menu.system_logs' } }, { path: '/users', component: Users, name: 'Users', meta: { permission: 'menu.users' } }, + { path: '/roles', component: Roles, name: 'Roles', meta: { permission: 'menu.roles' } }, { path: '/settings', component: Settings, name: 'Settings', meta: { permission: 'menu.settings' } }, { path: '/desktop-update', @@ -101,6 +104,11 @@ router.beforeEach(async (to) => { return firstAccessiblePath((code) => auth.hasPermission(code)) } + const menuItem = MENU_ITEMS.find((item) => item.path === to.path) + if (menuItem && !menuAccessible(menuItem, (code) => auth.hasPermission(code))) { + return firstAccessiblePath((code) => auth.hasPermission(code)) + } + return true }) diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index fed5bdf..8758f30 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import api from '../api' -import { MENU_ITEMS } from '../config/menus' +import { MENU_ITEMS, menuAccessible } from '../config/menus' export const useAuthStore = defineStore('auth', () => { const token = ref(localStorage.getItem('kefu_token') || '') @@ -24,21 +24,60 @@ export const useAuthStore = defineStore('auth', () => { const hasPermission = (code) => { if (!code) return true if (isAdmin.value) return true - return permissionSet.value.has(code) + if (permissionSet.value.has(code)) return true + // Legacy accounts.write covers all granular account write buttons. + if ( + code.startsWith('accounts.') && + code !== 'accounts.write' && + permissionSet.value.has('accounts.write') + ) { + return true + } + return false } - const canWrite = computed(() => hasPermission('accounts.write')) + const canWrite = computed(() => + hasPermission('accounts.write') || + hasPermission('accounts.create') || + hasPermission('accounts.update') || + hasPermission('accounts.delete') || + hasPermission('accounts.start') || + hasPermission('accounts.stop') || + hasPermission('accounts.cookie') + ) + const canCreateAccounts = computed(() => hasPermission('accounts.create')) + const canUpdateAccounts = computed(() => hasPermission('accounts.update')) + const canDeleteAccounts = computed(() => hasPermission('accounts.delete')) + const canStartAccounts = computed(() => hasPermission('accounts.start')) + const canStopAccounts = computed(() => hasPermission('accounts.stop')) + const canManageCookies = computed(() => hasPermission('accounts.cookie')) const canWriteMessages = computed(() => hasPermission('messages.write')) const canWriteRules = computed(() => hasPermission('rules.write')) + const canWriteLinkCards = computed(() => hasPermission('link_cards.write')) + const canClearSystemLogs = computed(() => hasPermission('system_logs.clear')) const canManageUsers = computed(() => hasPermission('users.manage')) - const isViewer = computed(() => !canWrite.value && !isAdmin.value) + const canManageRoles = computed(() => hasPermission('roles.manage')) + const canManagePayments = computed(() => hasPermission('payments.manage')) + const canManageSettings = computed(() => hasPermission('settings.manage')) + const canManageDatabase = computed(() => hasPermission('settings.database')) + const canCreateOrders = computed(() => hasPermission('orders.create')) + const hasGlobalDataScope = computed( + () => isAdmin.value || hasPermission('data.scope_all') + ) + const isViewer = computed( + () => + !canWrite.value && + !canWriteMessages.value && + !canWriteRules.value && + !isAdmin.value + ) const roleLabel = computed( () => user.value?.role_label || user.value?.role || '' ) const visibleMenus = computed(() => - MENU_ITEMS.filter((item) => hasPermission(item.permission)).map((item) => ({ + MENU_ITEMS.filter((item) => menuAccessible(item, hasPermission)).map((item) => ({ ...item, title: isAdmin.value && item.adminTitle ? item.adminTitle : item.title })) @@ -115,9 +154,23 @@ export const useAuthStore = defineStore('auth', () => { permissions, isAdmin, canWrite, + canCreateAccounts, + canUpdateAccounts, + canDeleteAccounts, + canStartAccounts, + canStopAccounts, + canManageCookies, canWriteMessages, canWriteRules, + canWriteLinkCards, + canClearSystemLogs, canManageUsers, + canManageRoles, + canManagePayments, + canManageSettings, + canManageDatabase, + canCreateOrders, + hasGlobalDataScope, isViewer, roleLabel, visibleMenus, diff --git a/frontend/src/views/Accounts.vue b/frontend/src/views/Accounts.vue index 393f913..c8c0bac 100644 --- a/frontend/src/views/Accounts.vue +++ b/frontend/src/views/Accounts.vue @@ -395,7 +395,12 @@ const accountQuotaLabel = computed(() => { }) const canPurchaseSlots = computed(() => { - return paymentConfig.value?.payment_enabled && accountQuota.value.limited && !canAddAccount.value + return ( + auth.canCreateOrders && + paymentConfig.value?.payment_enabled && + accountQuota.value.limited && + !canAddAccount.value + ) }) const startableAccounts = computed(() => @@ -857,7 +862,7 @@ const queueActionLabel = (item) => { } const isQueueSendDisabled = (item) => { - if (auth.user?.role === 'viewer') return true + if (!auth.canWriteMessages) return true if (!queueSnapshot.value.running) return true if (queueSendingJobId.value) return true return item?.status !== 'waiting' || !!item?.expedited @@ -1000,8 +1005,8 @@ const goAccountRulesPage = (accountId) => { } const openAddModal = () => { - if (!auth.canWrite) { - message.warning('当前账号为只读角色,不能添加托管账号') + if (!auth.canCreateAccounts) { + message.warning('当前账号无添加账号权限') return } if (!canAddAccount.value) { @@ -1599,11 +1604,13 @@ const openEditModal = async (acc) => { } initUserAgentFields(acc) try { - const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`) - editForm.value.cookie_data = res.data.cookie_data - ? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2) - : '' - applyCookieResponse(res.data) + if (auth.canManageCookies) { + const res = await api.get(`/accounts/${acc.id}/cookie?purpose=management`) + editForm.value.cookie_data = res.data.cookie_data + ? JSON.stringify(JSON.parse(res.data.cookie_data), null, 2) + : '' + applyCookieResponse(res.data) + } } catch (error) { message.error('加载 Cookie 失败') } finally { @@ -1798,7 +1805,7 @@ onUnmounted(() => {

-
+
{
{ 购买额度 { 自动回复 - + 编辑 @@ -2061,7 +2075,7 @@ onUnmounted(() => {
- +
- + 管理全部规则 - + 保存兜底回复 @@ -2491,10 +2538,10 @@ onUnmounted(() => {
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 788c65a..4172984 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -98,20 +98,20 @@ onUnmounted(() => {