import hashlib import hmac import os import secrets import uuid from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional import bcrypt import jwt from sqlalchemy import delete from sqlmodel import Session, select from core.cache import cache from core.settings import JWT_ALGORITHM, JWT_SECRET from models.bot import BotInstance from models.sys_auth import ( SysMenu, SysPermission, SysRole, SysRoleMenu, SysRolePermission, SysUser, SysUserBot, ) DEFAULT_ADMIN_USERNAME = (str(os.getenv("SYS_ADMIN_DEFAULT_USERNAME") or "admin").strip().lower() or "admin") DEFAULT_ADMIN_PASSWORD = str(os.getenv("SYS_ADMIN_DEFAULT_PASSWORD") or "admin123").strip() or "admin123" PASSWORD_ITERATIONS = 120_000 AUTH_TOKEN_CACHE_PREFIX = "sys_auth:token:" SUPER_ADMIN_ROLE_KEY = "super_admin" NORMAL_USER_ROLE_KEY = "normal_user" ROLE_SEEDS: List[Dict[str, Any]] = [ { "role_key": SUPER_ADMIN_ROLE_KEY, "name": "Super Admin", "description": "平台超级管理员,拥有全部菜单与权限。", "sort_order": 10, }, { "role_key": NORMAL_USER_ROLE_KEY, "name": "Normal User", "description": "普通用户,仅可访问已绑定的 Bot 和个人相关页面。", "sort_order": 20, }, ] MENU_SEEDS: List[Dict[str, Any]] = [ { "menu_key": "general", "parent_key": "", "title": "General", "title_en": "General", "menu_type": "group", "route_path": "", "icon": "layout-grid", "permission_key": "", "sort_order": 10, }, { "menu_key": "general_dashboard", "parent_key": "general", "title": "Dashboard", "title_en": "Dashboard", "menu_type": "item", "route_path": "/dashboard", "icon": "layout-dashboard", "permission_key": "general.dashboard.view", "sort_order": 10, }, { "menu_key": "general_chat", "parent_key": "general", "title": "Chat", "title_en": "Chat", "menu_type": "item", "route_path": "/chat", "icon": "message-circle", "permission_key": "general.chat.view", "sort_order": 15, }, { "menu_key": "general_edge", "parent_key": "general", "title": "Edge 管理", "title_en": "Edge Management", "menu_type": "item", "route_path": "/dashboard/edges", "icon": "waypoints", "permission_key": "general.edge.manage", "sort_order": 20, }, { "menu_key": "admin", "parent_key": "", "title": "Admin", "title_en": "Admin", "menu_type": "group", "route_path": "", "icon": "shield", "permission_key": "", "sort_order": 20, }, { "menu_key": "admin_skills", "parent_key": "admin", "title": "技能管理", "title_en": "Skill Management", "menu_type": "item", "route_path": "/admin/skills", "icon": "wrench", "permission_key": "admin.skills.manage", "sort_order": 10, }, { "menu_key": "admin_settings", "parent_key": "admin", "title": "系统参数", "title_en": "System Settings", "menu_type": "item", "route_path": "/admin/settings", "icon": "sliders-horizontal", "permission_key": "admin.settings.manage", "sort_order": 20, }, { "menu_key": "admin_templates", "parent_key": "admin", "title": "模版管理", "title_en": "Template Management", "menu_type": "item", "route_path": "/admin/templates", "icon": "files", "permission_key": "admin.templates.manage", "sort_order": 30, }, { "menu_key": "admin_deploy", "parent_key": "admin", "title": "迁移/部署", "title_en": "Migration / Deploy", "menu_type": "item", "route_path": "/admin/deploy", "icon": "rocket", "permission_key": "admin.deploy.manage", "sort_order": 40, }, { "menu_key": "admin_users", "parent_key": "admin", "title": "用户管理", "title_en": "User Management", "menu_type": "item", "route_path": "/admin/users", "icon": "users", "permission_key": "admin.users.manage", "sort_order": 50, }, { "menu_key": "admin_roles", "parent_key": "admin", "title": "角色管理", "title_en": "Role Management", "menu_type": "item", "route_path": "/admin/roles", "icon": "badge-check", "permission_key": "admin.roles.manage", "sort_order": 60, }, { "menu_key": "profile", "parent_key": "", "title": "Profile", "title_en": "Profile", "menu_type": "group", "route_path": "", "icon": "user-round", "permission_key": "", "sort_order": 30, }, { "menu_key": "profile_usage_logs", "parent_key": "profile", "title": "使用日志", "title_en": "Usage Logs", "menu_type": "item", "route_path": "/profile/usage-logs", "icon": "history", "permission_key": "profile.usage_logs.view", "sort_order": 10, }, { "menu_key": "profile_api_tokens", "parent_key": "profile", "title": "API 令牌", "title_en": "API Tokens", "menu_type": "item", "route_path": "/profile/api-tokens", "icon": "key-round", "permission_key": "profile.api_tokens.view", "sort_order": 20, }, ] PERMISSION_SEEDS: List[Dict[str, Any]] = [ { "permission_key": "general.dashboard.view", "name": "查看仪表板", "menu_key": "general_dashboard", "action": "view", "description": "查看平台首页仪表板。", "sort_order": 10, }, { "permission_key": "general.edge.manage", "name": "管理 Edge 节点", "menu_key": "general_edge", "action": "manage", "description": "查看与维护 Edge 节点。", "sort_order": 20, }, { "permission_key": "general.chat.view", "name": "访问 Bot Chat", "menu_key": "general_chat", "action": "view", "description": "访问已绑定 Bot 的聊天与运行页。", "sort_order": 25, }, { "permission_key": "admin.skills.manage", "name": "管理技能市场", "menu_key": "admin_skills", "action": "manage", "description": "管理技能包、上传归档与元数据。", "sort_order": 30, }, { "permission_key": "admin.settings.manage", "name": "管理系统参数", "menu_key": "admin_settings", "action": "manage", "description": "维护系统参数与运行配置。", "sort_order": 40, }, { "permission_key": "admin.templates.manage", "name": "管理平台模板", "menu_key": "admin_templates", "action": "manage", "description": "维护平台级模版内容。", "sort_order": 50, }, { "permission_key": "admin.deploy.manage", "name": "管理迁移部署", "menu_key": "admin_deploy", "action": "manage", "description": "查看部署概览、迁移与部署入口。", "sort_order": 60, }, { "permission_key": "admin.users.manage", "name": "管理系统用户", "menu_key": "admin_users", "action": "manage", "description": "管理后台用户、状态与默认资料。", "sort_order": 70, }, { "permission_key": "admin.roles.manage", "name": "管理系统角色", "menu_key": "admin_roles", "action": "manage", "description": "管理角色与菜单、权限分配。", "sort_order": 80, }, { "permission_key": "profile.usage_logs.view", "name": "查看使用日志", "menu_key": "profile_usage_logs", "action": "view", "description": "查看个人使用日志页面。", "sort_order": 90, }, { "permission_key": "profile.api_tokens.view", "name": "查看 API 令牌", "menu_key": "profile_api_tokens", "action": "view", "description": "查看个人 API 令牌页面。", "sort_order": 100, }, ] LEGACY_MENU_KEYS = {"admin_permissions", "admin_menus"} LEGACY_PERMISSION_KEYS = {"admin.permissions.manage", "admin.menus.manage"} def _utcnow() -> datetime: return datetime.utcnow() def _dt_to_iso(value: Optional[datetime]) -> Optional[str]: if value is None: return None return value.isoformat() + "Z" def _new_salt() -> str: return secrets.token_hex(16) def hash_password(password: str, salt: str) -> str: _ = salt return bcrypt.hashpw(str(password or "").encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def _verify_legacy_pbkdf2_password(password: str, salt: str, stored_hash: str) -> bool: if not salt or not stored_hash: return False payload = hashlib.pbkdf2_hmac( "sha256", str(password or "").encode("utf-8"), bytes.fromhex(str(salt or "")), PASSWORD_ITERATIONS, ) return hmac.compare_digest(payload.hex(), str(stored_hash or "")) def verify_password(password: str, salt: str, stored_hash: str) -> bool: if not stored_hash: return False normalized_hash = str(stored_hash or "") if normalized_hash.startswith("$2a$") or normalized_hash.startswith("$2b$") or normalized_hash.startswith("$2y$"): try: return bool(bcrypt.checkpw(str(password or "").encode("utf-8"), normalized_hash.encode("utf-8"))) except Exception: return False return _verify_legacy_pbkdf2_password(password, salt, stored_hash) def hash_token(token: str) -> str: return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest() def _auth_token_cache_key(jti: str) -> str: return f"{AUTH_TOKEN_CACHE_PREFIX}{str(jti or '').strip()}" def _session_payload(user: SysUser, *, jti: str, expires_at: datetime) -> Dict[str, Any]: return { "sub": str(int(user.id or 0)), "username": str(user.username or ""), "jti": jti, "iat": int(datetime.now(timezone.utc).timestamp()), "exp": int(expires_at.replace(tzinfo=timezone.utc).timestamp()), } def _ensure_auth_cache_ready() -> None: if not cache.enabled or not cache.ping(): raise RuntimeError("Redis is required for user session storage") def _seed_roles(session: Session) -> Dict[str, SysRole]: result: Dict[str, SysRole] = {} for item in ROLE_SEEDS: row = session.exec(select(SysRole).where(SysRole.role_key == item["role_key"])).first() if row is None: row = SysRole(role_key=item["role_key"]) row.name = item["name"] row.description = item["description"] row.is_active = True row.sort_order = int(item["sort_order"]) row.updated_at = _utcnow() session.add(row) result[item["role_key"]] = row session.commit() for key in list(result.keys()): result[key] = session.exec(select(SysRole).where(SysRole.role_key == key)).first() return result def _seed_menus(session: Session) -> Dict[str, SysMenu]: result: Dict[str, SysMenu] = {} for item in MENU_SEEDS: row = session.exec(select(SysMenu).where(SysMenu.menu_key == item["menu_key"])).first() if row is None: row = SysMenu(menu_key=item["menu_key"]) row.parent_key = item["parent_key"] row.title = item["title"] row.title_en = item["title_en"] row.menu_type = item["menu_type"] row.route_path = item["route_path"] row.icon = item["icon"] row.permission_key = item["permission_key"] row.visible = True row.sort_order = int(item["sort_order"]) row.updated_at = _utcnow() session.add(row) result[item["menu_key"]] = row session.commit() for key in list(result.keys()): result[key] = session.exec(select(SysMenu).where(SysMenu.menu_key == key)).first() return result def _cleanup_legacy_auth_entries(session: Session) -> None: seeded_menu_keys = {str(item["menu_key"]) for item in MENU_SEEDS} seeded_permission_keys = {str(item["permission_key"]) for item in PERMISSION_SEEDS} obsolete_permissions = session.exec( select(SysPermission).where( (~SysPermission.permission_key.in_(seeded_permission_keys)) | SysPermission.permission_key.in_(LEGACY_PERMISSION_KEYS) ) ).all() permission_ids = [int(item.id) for item in obsolete_permissions if item.id is not None] if permission_ids: session.exec(delete(SysRolePermission).where(SysRolePermission.permission_id.in_(permission_ids))) session.exec(delete(SysPermission).where(SysPermission.id.in_(permission_ids))) session.commit() obsolete_menus = session.exec( select(SysMenu).where((~SysMenu.menu_key.in_(seeded_menu_keys)) | SysMenu.menu_key.in_(LEGACY_MENU_KEYS)) ).all() menu_ids = [int(item.id) for item in obsolete_menus if item.id is not None] if menu_ids: session.exec(delete(SysRoleMenu).where(SysRoleMenu.menu_id.in_(menu_ids))) session.exec(delete(SysMenu).where(SysMenu.id.in_(menu_ids))) session.commit() def _seed_permissions(session: Session) -> Dict[str, SysPermission]: result: Dict[str, SysPermission] = {} for item in PERMISSION_SEEDS: row = session.exec(select(SysPermission).where(SysPermission.permission_key == item["permission_key"])).first() if row is None: row = SysPermission(permission_key=item["permission_key"]) row.name = item["name"] row.menu_key = item["menu_key"] row.action = item["action"] row.description = item["description"] row.sort_order = int(item["sort_order"]) row.updated_at = _utcnow() session.add(row) result[item["permission_key"]] = row session.commit() for key in list(result.keys()): result[key] = session.exec(select(SysPermission).where(SysPermission.permission_key == key)).first() return result def _ensure_role_menu(session: Session, role_id: int, menu_id: int) -> None: exists = session.exec( select(SysRoleMenu).where(SysRoleMenu.role_id == role_id, SysRoleMenu.menu_id == menu_id) ).first() if exists is None: session.add(SysRoleMenu(role_id=role_id, menu_id=menu_id)) def _ensure_role_permission(session: Session, role_id: int, permission_id: int) -> None: exists = session.exec( select(SysRolePermission).where( SysRolePermission.role_id == role_id, SysRolePermission.permission_id == permission_id, ) ).first() if exists is None: session.add(SysRolePermission(role_id=role_id, permission_id=permission_id)) def seed_sys_auth(session: Session) -> None: _cleanup_legacy_auth_entries(session) roles = _seed_roles(session) menus = _seed_menus(session) permissions = _seed_permissions(session) admin_role = roles[SUPER_ADMIN_ROLE_KEY] admin_has_menu_bindings = False admin_has_permission_bindings = False if admin_role.id is not None: admin_has_menu_bindings = ( session.exec(select(SysRoleMenu).where(SysRoleMenu.role_id == admin_role.id)).first() is not None ) admin_has_permission_bindings = ( session.exec(select(SysRolePermission).where(SysRolePermission.role_id == admin_role.id)).first() is not None ) if admin_role.id is not None and not admin_has_menu_bindings: for menu in menus.values(): if menu.id is not None: _ensure_role_menu(session, admin_role.id, menu.id) if admin_role.id is not None and not admin_has_permission_bindings: for permission in permissions.values(): if permission.id is not None: _ensure_role_permission(session, admin_role.id, permission.id) normal_user_role = roles.get(NORMAL_USER_ROLE_KEY) if normal_user_role is not None and normal_user_role.id is not None: normal_user_menu_keys = {"general_chat", "profile_usage_logs", "profile_api_tokens"} normal_user_permission_keys = { "general.chat.view", "profile.usage_logs.view", "profile.api_tokens.view", } for menu_key in normal_user_menu_keys: menu = menus.get(menu_key) if menu is not None and menu.id is not None: _ensure_role_menu(session, int(normal_user_role.id), int(menu.id)) for permission_key in normal_user_permission_keys: permission = permissions.get(permission_key) if permission is not None and permission.id is not None: _ensure_role_permission(session, int(normal_user_role.id), int(permission.id)) session.commit() user_count = session.exec(select(SysUser)).all() if len(user_count) == 0: salt = _new_salt() session.add( SysUser( username=DEFAULT_ADMIN_USERNAME, display_name="Administrator", password_salt=salt, password_hash=hash_password(DEFAULT_ADMIN_PASSWORD, salt), role_id=admin_role.id, is_active=True, created_at=_utcnow(), updated_at=_utcnow(), ) ) session.commit() def _normalize_bot_ids(bot_ids: List[str] | None) -> List[str]: normalized: List[str] = [] for item in list(bot_ids or []): bot_id = str(item or "").strip() if bot_id and bot_id not in normalized: normalized.append(bot_id) return normalized def _invalidate_user_bot_access_cache(user_id: int) -> None: normalized_user_id = int(user_id or 0) if normalized_user_id <= 0: return cache.delete(f"bots:list:user:{normalized_user_id}") def _sync_user_bot_bindings(session: Session, *, user_id: int, bot_ids: List[str]) -> None: session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id)) normalized_bot_ids = _normalize_bot_ids(bot_ids) if normalized_bot_ids: rows = session.exec(select(BotInstance.id).where(BotInstance.id.in_(normalized_bot_ids))).all() existing_bot_ids = {str(item or "").strip() for item in rows} missing = [bot_id for bot_id in normalized_bot_ids if bot_id not in existing_bot_ids] if missing: raise ValueError(f"Bots not found: {', '.join(missing[:5])}") for bot_id in normalized_bot_ids: session.add(SysUserBot(user_id=user_id, bot_id=bot_id)) session.commit() _invalidate_user_bot_access_cache(user_id) def _list_user_bot_ids(session: Session, user_id: int) -> List[str]: rows = session.exec( select(SysUserBot.bot_id).where(SysUserBot.user_id == user_id).order_by(SysUserBot.bot_id.asc()) ).all() return [str(item or "").strip() for item in rows if str(item or "").strip()] def _list_user_assigned_bots(session: Session, user_id: int) -> List[Dict[str, Any]]: bound_bot_ids = _list_user_bot_ids(session, user_id) if not bound_bot_ids: return [] bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bound_bot_ids))).all() bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()} items: List[Dict[str, Any]] = [] for bot_id in bound_bot_ids: bot = bot_map.get(bot_id) if bot is None: continue items.append( { "id": bot_id, "name": str(bot.name or bot_id), "enabled": bool(getattr(bot, "enabled", True)), "node_id": str(getattr(bot, "node_id", "") or ""), "node_display_name": str(getattr(bot, "node_id", "") or ""), "docker_status": str(getattr(bot, "docker_status", "STOPPED") or "STOPPED"), "image_tag": str(getattr(bot, "image_tag", "") or ""), } ) return items def _is_super_admin(role: Optional[SysRole]) -> bool: return str(getattr(role, "role_key", "") or "") == SUPER_ADMIN_ROLE_KEY def find_user_by_username(session: Session, username: str) -> Optional[SysUser]: normalized = str(username or "").strip().lower() if not normalized: return None return session.exec(select(SysUser).where(SysUser.username == normalized)).first() def authenticate_user(session: Session, username: str, password: str) -> Optional[SysUser]: user = find_user_by_username(session, username) if user is None or not bool(user.is_active): return None stored_hash = str(user.password_hash or "") stored_salt = str(user.password_salt or "") if not verify_password(password, stored_salt, stored_hash): return None if stored_hash and not stored_hash.startswith("$2"): user.password_hash = hash_password(password, "") user.password_salt = "" user.updated_at = _utcnow() session.add(user) session.commit() session.refresh(user) return user def issue_user_token(session: Session, user: SysUser) -> tuple[str, datetime]: _ensure_auth_cache_ready() from services.platform_settings_service import get_sys_auth_token_ttl_days ttl_days = get_sys_auth_token_ttl_days(session) expires_at = _utcnow() + timedelta(days=ttl_days) jti = uuid.uuid4().hex token = jwt.encode(_session_payload(user, jti=jti, expires_at=expires_at), JWT_SECRET, algorithm=JWT_ALGORITHM) cache.set_json( _auth_token_cache_key(jti), { "user_id": int(user.id or 0), "username": str(user.username or ""), "token_hash": hash_token(token), }, ttl=max(1, ttl_days * 24 * 60 * 60), ) user.current_token_hash = None user.current_token_expires_at = None user.last_login_at = _utcnow() user.updated_at = _utcnow() session.add(user) session.commit() session.refresh(user) return token, expires_at def revoke_user_token(token: str) -> None: try: payload = jwt.decode(str(token or "").strip(), JWT_SECRET, algorithms=[JWT_ALGORITHM]) except Exception: return jti = str(payload.get("jti") or "").strip() if not jti: return cache.delete(_auth_token_cache_key(jti)) def resolve_user_by_token(session: Session, token: str) -> Optional[SysUser]: candidate = str(token or "").strip() if not candidate: return None try: payload = jwt.decode(candidate, JWT_SECRET, algorithms=[JWT_ALGORITHM]) except Exception: return None user_id = int(payload.get("sub") or 0) jti = str(payload.get("jti") or "").strip() if user_id <= 0 or not jti: return None cached = cache.get_json(_auth_token_cache_key(jti)) if not isinstance(cached, dict): return None if int(cached.get("user_id") or 0) != user_id: return None if str(cached.get("token_hash") or "").strip() != hash_token(candidate): return None user = session.get(SysUser, user_id) if user is None or not bool(user.is_active): return None return user def _role_payload(role: Optional[SysRole]) -> Optional[Dict[str, Any]]: if role is None: return None return { "id": int(role.id or 0), "role_key": role.role_key, "name": role.name, } def _list_role_permissions(session: Session, role_id: Optional[int]) -> List[str]: if not role_id: return [] rows = session.exec( select(SysPermission) .join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id) .where(SysRolePermission.role_id == role_id) .order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc()) ).all() return [str(row.permission_key or "").strip() for row in rows if str(row.permission_key or "").strip()] def _list_role_menus(session: Session, role_id: Optional[int]) -> List[SysMenu]: if not role_id: return [] explicit_menus = session.exec( select(SysMenu) .join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.id) .where(SysRoleMenu.role_id == role_id, SysMenu.visible == True) .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) ).all() permission_bound_menus = session.exec( select(SysMenu) .join(SysPermission, SysPermission.menu_key == SysMenu.menu_key) .join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id) .where(SysRolePermission.role_id == role_id, SysMenu.visible == True) .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) ).all() menu_map: Dict[str, SysMenu] = {} for row in [*explicit_menus, *permission_bound_menus]: key = str(row.menu_key or "").strip() if key: menu_map[key] = row if not menu_map: return [] all_visible_menus = session.exec(select(SysMenu).where(SysMenu.visible == True)).all() all_menu_map = {str(row.menu_key or "").strip(): row for row in all_visible_menus if str(row.menu_key or "").strip()} pending_parent_keys = [str(row.parent_key or "").strip() for row in menu_map.values() if str(row.parent_key or "").strip()] while pending_parent_keys: parent_key = pending_parent_keys.pop() if not parent_key or parent_key in menu_map: continue parent = all_menu_map.get(parent_key) if parent is None: continue menu_map[parent_key] = parent next_parent_key = str(parent.parent_key or "").strip() if next_parent_key and next_parent_key not in menu_map: pending_parent_keys.append(next_parent_key) return sorted(menu_map.values(), key=lambda row: (int(row.sort_order or 100), int(row.id or 0))) def _build_menu_tree(rows: List[SysMenu]) -> List[Dict[str, Any]]: menu_map: Dict[str, Dict[str, Any]] = {} roots: List[Dict[str, Any]] = [] for row in rows: menu_key = str(row.menu_key or "").strip() if not menu_key: continue menu_map[menu_key] = { "menu_key": menu_key, "parent_key": str(row.parent_key or "").strip(), "title": row.title, "title_en": row.title_en, "menu_type": row.menu_type, "route_path": row.route_path, "icon": row.icon, "permission_key": row.permission_key, "sort_order": int(row.sort_order or 100), "children": [], } for item in sorted(menu_map.values(), key=lambda value: (value["sort_order"], value["menu_key"])): parent_key = str(item["parent_key"] or "").strip() if parent_key and parent_key in menu_map: menu_map[parent_key]["children"].append(item) else: roots.append(item) return roots def _normalize_role_key(value: str) -> str: return str(value or "").strip().lower().replace(" ", "_") def _normalize_username(value: str) -> str: return str(value or "").strip().lower() def _sync_role_bindings( session: Session, role: SysRole, *, menu_keys: List[str], permission_keys: List[str], ) -> None: role_id = int(role.id or 0) if role_id <= 0: raise ValueError("Role id is required") normalized_menu_keys = sorted({str(key or "").strip() for key in menu_keys if str(key or "").strip()}) normalized_permission_keys = sorted({str(key or "").strip() for key in permission_keys if str(key or "").strip()}) session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id)) session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id)) session.commit() if normalized_menu_keys: menus = session.exec(select(SysMenu).where(SysMenu.menu_key.in_(normalized_menu_keys))).all() menu_map = {str(row.menu_key or "").strip(): row for row in menus} for menu_key in normalized_menu_keys: row = menu_map.get(menu_key) if row is not None and row.id is not None: session.add(SysRoleMenu(role_id=role_id, menu_id=int(row.id))) if normalized_permission_keys: permissions = session.exec( select(SysPermission).where(SysPermission.permission_key.in_(normalized_permission_keys)) ).all() permission_map = {str(row.permission_key or "").strip(): row for row in permissions} for permission_key in normalized_permission_keys: row = permission_map.get(permission_key) if row is not None and row.id is not None: session.add(SysRolePermission(role_id=role_id, permission_id=int(row.id))) session.commit() def list_sys_users(session: Session) -> List[Dict[str, Any]]: roles = session.exec(select(SysRole)).all() role_map = {int(role.id or 0): role for role in roles if role.id is not None} users = session.exec(select(SysUser).order_by(SysUser.updated_at.desc(), SysUser.id.desc())).all() binding_rows = session.exec(select(SysUserBot)).all() bot_ids_map: Dict[int, List[str]] = {} for row in binding_rows: user_id = int(row.user_id or 0) bot_id = str(row.bot_id or "").strip() if user_id <= 0 or not bot_id: continue bot_ids_map.setdefault(user_id, []).append(bot_id) result: List[Dict[str, Any]] = [] for user in users: role = role_map.get(int(user.role_id or 0)) result.append( { "id": int(user.id or 0), "username": str(user.username or ""), "display_name": str(user.display_name or user.username or ""), "is_active": bool(user.is_active), "last_login_at": _dt_to_iso(user.last_login_at), "role": _role_payload(role), "bot_ids": sorted(bot_ids_map.get(int(user.id or 0), [])), } ) return result def create_sys_user( session: Session, *, username: str, display_name: str, password: str, role_id: int, is_active: bool, bot_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: normalized_username = _normalize_username(username) if not normalized_username: raise ValueError("Username is required") if find_user_by_username(session, normalized_username) is not None: raise ValueError("Username already exists") if len(str(password or "")) < 6: raise ValueError("Password must be at least 6 characters") role = session.get(SysRole, role_id) if role is None: raise ValueError("Role not found") salt = _new_salt() row = SysUser( username=normalized_username, display_name=str(display_name or "").strip() or normalized_username, password_salt=salt, password_hash=hash_password(password, salt), role_id=int(role.id or 0), is_active=bool(is_active), created_at=_utcnow(), updated_at=_utcnow(), ) session.add(row) session.commit() session.refresh(row) _sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or [])) return { "id": int(row.id or 0), "username": str(row.username or ""), "display_name": str(row.display_name or row.username or ""), "is_active": bool(row.is_active), "last_login_at": _dt_to_iso(row.last_login_at), "role": _role_payload(role), "bot_ids": _list_user_bot_ids(session, int(row.id or 0)), } def update_sys_user( session: Session, *, user_id: int, display_name: str, password: str, role_id: int, is_active: bool, bot_ids: Optional[List[str]] = None, acting_user_id: int = 0, ) -> Dict[str, Any]: row = session.get(SysUser, user_id) if row is None: raise ValueError("User not found") if acting_user_id > 0 and int(row.id or 0) == acting_user_id and not is_active: raise ValueError("Current user cannot be disabled") role = session.get(SysRole, role_id) if role is None: raise ValueError("Role not found") row.display_name = str(display_name or "").strip() or str(row.username or "") row.role_id = int(role.id or 0) row.is_active = bool(is_active) if str(password or "").strip(): if len(str(password or "")) < 6: raise ValueError("Password must be at least 6 characters") row.password_hash = hash_password(password, "") row.password_salt = "" row.updated_at = _utcnow() session.add(row) session.commit() _invalidate_user_bot_access_cache(int(row.id or 0)) _sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or [])) session.refresh(row) return { "id": int(row.id or 0), "username": str(row.username or ""), "display_name": str(row.display_name or row.username or ""), "is_active": bool(row.is_active), "last_login_at": _dt_to_iso(row.last_login_at), "role": _role_payload(role), "bot_ids": _list_user_bot_ids(session, int(row.id or 0)), } def update_current_sys_user_profile( session: Session, *, user_id: int, display_name: str, password: str, ) -> SysUser: row = session.get(SysUser, user_id) if row is None: raise ValueError("User not found") row.display_name = str(display_name or "").strip() or str(row.username or "") if str(password or "").strip(): if len(str(password or "")) < 6: raise ValueError("Password must be at least 6 characters") row.password_hash = hash_password(password, "") row.password_salt = "" row.updated_at = _utcnow() session.add(row) session.commit() session.refresh(row) return row def delete_sys_user(session: Session, *, user_id: int, acting_user_id: int = 0) -> None: row = session.get(SysUser, user_id) if row is None: return if acting_user_id > 0 and int(row.id or 0) == acting_user_id: raise ValueError("Current user cannot be deleted") session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id)) session.commit() _invalidate_user_bot_access_cache(user_id) session.delete(row) session.commit() def list_sys_roles(session: Session) -> List[Dict[str, Any]]: roles = session.exec(select(SysRole).order_by(SysRole.sort_order.asc(), SysRole.id.asc())).all() users = session.exec(select(SysUser)).all() user_count_map: Dict[int, int] = {} for user in users: role_id = int(user.role_id or 0) if role_id > 0: user_count_map[role_id] = user_count_map.get(role_id, 0) + 1 result: List[Dict[str, Any]] = [] for role in roles: role_id = int(role.id or 0) result.append( { "id": role_id, "role_key": str(role.role_key or ""), "name": str(role.name or ""), "description": str(role.description or ""), "is_active": bool(role.is_active), "sort_order": int(role.sort_order or 100), "user_count": user_count_map.get(role_id, 0), "menu_keys": [str(key) for key in _list_role_menu_keys(session, role_id)], "permission_keys": [str(key) for key in _list_role_permissions(session, role_id)], } ) return result def _list_role_menu_keys(session: Session, role_id: Optional[int]) -> List[str]: rows = _list_role_menus(session, role_id) return [str(row.menu_key or "").strip() for row in rows if str(row.menu_key or "").strip()] def list_role_grant_bootstrap(session: Session) -> Dict[str, Any]: menus = session.exec( select(SysMenu) .where(SysMenu.visible == True) .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) ).all() permissions = session.exec( select(SysPermission).order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc()) ).all() menu_rows = [ { "menu_key": str(row.menu_key or ""), "parent_key": str(row.parent_key or ""), "title": str(row.title or ""), "title_en": str(row.title_en or ""), "menu_type": str(row.menu_type or "item"), "route_path": str(row.route_path or ""), "icon": str(row.icon or ""), "sort_order": int(row.sort_order or 100), "children": [], } for row in menus ] return { "menus": _build_menu_tree_from_dict(menu_rows), "permissions": [ { "id": int(row.id or 0), "permission_key": str(row.permission_key or ""), "name": str(row.name or ""), "menu_key": str(row.menu_key or ""), "action": str(row.action or "view"), "description": str(row.description or ""), "sort_order": int(row.sort_order or 100), } for row in permissions ], } def _build_menu_tree_from_dict(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: menu_map = {str(item["menu_key"]): {**item, "children": []} for item in rows if str(item.get("menu_key") or "").strip()} roots: List[Dict[str, Any]] = [] for item in sorted(menu_map.values(), key=lambda value: (int(value.get("sort_order") or 100), str(value.get("menu_key") or ""))): parent_key = str(item.get("parent_key") or "").strip() if parent_key and parent_key in menu_map: menu_map[parent_key]["children"].append(item) else: roots.append(item) return roots def create_sys_role( session: Session, *, role_key: str, name: str, description: str, is_active: bool, sort_order: int, menu_keys: List[str], permission_keys: List[str], ) -> Dict[str, Any]: normalized_role_key = _normalize_role_key(role_key) if not normalized_role_key: raise ValueError("Role key is required") exists = session.exec(select(SysRole).where(SysRole.role_key == normalized_role_key)).first() if exists is not None: raise ValueError("Role key already exists") row = SysRole( role_key=normalized_role_key, name=str(name or "").strip() or normalized_role_key, description=str(description or "").strip(), is_active=bool(is_active), sort_order=int(sort_order or 100), created_at=_utcnow(), updated_at=_utcnow(), ) session.add(row) session.commit() session.refresh(row) _sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys) return get_sys_role_detail(session, int(row.id or 0)) def update_sys_role( session: Session, *, role_id: int, name: str, description: str, is_active: bool, sort_order: int, menu_keys: List[str], permission_keys: List[str], ) -> Dict[str, Any]: row = session.get(SysRole, role_id) if row is None: raise ValueError("Role not found") if str(row.role_key or "") == "super_admin" and not bool(is_active): raise ValueError("Super Admin cannot be disabled") row.name = str(name or "").strip() or str(row.role_key or "") row.description = str(description or "").strip() row.is_active = bool(is_active) row.sort_order = int(sort_order or 100) row.updated_at = _utcnow() session.add(row) session.commit() session.refresh(row) _sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys) return get_sys_role_detail(session, role_id) def get_sys_role_detail(session: Session, role_id: int) -> Dict[str, Any]: role = session.get(SysRole, role_id) if role is None: raise ValueError("Role not found") users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all() return { "id": int(role.id or 0), "role_key": str(role.role_key or ""), "name": str(role.name or ""), "description": str(role.description or ""), "is_active": bool(role.is_active), "sort_order": int(role.sort_order or 100), "user_count": len(users), "menu_keys": _list_role_menu_keys(session, role_id), "permission_keys": _list_role_permissions(session, role_id), } def delete_sys_role(session: Session, *, role_id: int) -> None: row = session.get(SysRole, role_id) if row is None: return if str(row.role_key or "") == SUPER_ADMIN_ROLE_KEY: raise ValueError("Super Admin cannot be deleted") bound_users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all() if bound_users: raise ValueError("Role is still assigned to users") session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id)) session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id)) session.commit() session.delete(row) session.commit() def build_user_bootstrap(session: Session, user: SysUser, *, token: str = "", expires_at: Optional[datetime] = None) -> Dict[str, Any]: role = session.get(SysRole, user.role_id) if user.role_id else None permissions = _list_role_permissions(session, user.role_id) menus = _build_menu_tree(_list_role_menus(session, user.role_id)) assigned_bots = [] if _is_super_admin(role) else _list_user_assigned_bots(session, int(user.id or 0)) home_path = "/dashboard" for group in menus: children = list(group.get("children") or []) if children: home_path = str(children[0].get("route_path") or "/dashboard") break return { "token": token, "expires_at": _dt_to_iso(expires_at), "user": { "id": int(user.id or 0), "username": str(user.username or ""), "display_name": str(user.display_name or user.username or ""), "role": _role_payload(role), }, "menus": menus, "permissions": permissions, "home_path": home_path, "assigned_bots": assigned_bots, } def list_accessible_bots_for_user(session: Session, user: SysUser) -> List[BotInstance]: role = session.get(SysRole, user.role_id) if user.role_id else None if _is_super_admin(role): return session.exec(select(BotInstance).order_by(BotInstance.updated_at.desc(), BotInstance.id.asc())).all() bot_ids = _list_user_bot_ids(session, int(user.id or 0)) if not bot_ids: return [] bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bot_ids))).all() bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()} return [bot_map[bot_id] for bot_id in bot_ids if bot_id in bot_map] def user_can_access_bot(session: Session, user: SysUser, bot_id: str) -> bool: normalized_bot_id = str(bot_id or "").strip() if not normalized_bot_id: return False role = session.get(SysRole, user.role_id) if user.role_id else None if _is_super_admin(role): return True row = session.exec( select(SysUserBot).where(SysUserBot.user_id == int(user.id or 0), SysUserBot.bot_id == normalized_bot_id) ).first() return row is not None