This commit is contained in:
Your Name
2026-08-07 17:51:57 +08:00
parent 6119fdd767
commit 327a0bc42f
29 changed files with 1610 additions and 579 deletions
+43 -22
View File
@@ -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="需要角色管理权限")
+228 -12
View File
@@ -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
],
}
+33 -11
View File
@@ -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:
+30 -11
View File
@@ -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)
+23 -12
View File
@@ -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": "角色已删除"}
+3
View File
@@ -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)
+57 -14
View File
@@ -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="只能管理自己创建的用户",
)
+8 -8
View File
@@ -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))
Binary file not shown.
Binary file not shown.
+7 -5
View File
@@ -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()
+41 -33
View File
@@ -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 "",
+6
View File
@@ -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
+8 -1
View File
@@ -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)
+18 -1
View File
@@ -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))
+8 -2
View File
@@ -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)
+60 -2
View File
@@ -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()