# -*- coding: utf-8 -*- """Remote server database/RPC queries and role RPC helpers.""" from .shared import * from .erlang import _ensure_epmd_daemon, get_erl_cmd, get_local_ip def query_remote_servers(db_host: str, db_port: int, db_user: str, db_pass: str, db_name: str) -> List[Dict[str, str]]: """从登录服数据库查询远程服务器列表 Args: db_host: 数据库地址 db_port: 数据库端口 db_user: 数据库用户名 db_pass: 数据库密码 db_name: 数据库名(登录服数据库,如 ai002_login_s900) Returns: 服务器列表,每项包含 server_id, server_node, center_node 等信息 """ try: import pymysql except ImportError: # 尝试使用 mysql-connector try: import mysql.connector as pymysql except ImportError: raise ImportError("需要安装 pymysql 或 mysql-connector-python: pip install pymysql") servers = [] try: # 连接数据库 conn = pymysql.connect( host=db_host, port=db_port, user=db_user, password=db_pass, database=db_name, charset='utf8mb4' ) cursor = conn.cursor() # 查询 server_info 表 # 注意: running 字段是运行时字段(sync=false),不存储在数据库中 sql = """ SELECT server_id, server_node, center_node, game_db FROM server_info WHERE server_node IS NOT NULL AND server_node != '' ORDER BY server_id """ cursor.execute(sql) for row in cursor.fetchall(): server_id, server_node, center_node, game_db = row servers.append({ 'server_id': str(server_id), 'server_node': str(server_node) if server_node else '', 'center_node': str(center_node) if center_node else '', 'game_db': str(game_db) if game_db else '', 'running': False # 运行状态需要实时检测,这里默认为 False }) cursor.close() conn.close() except Exception as e: raise Exception(f"数据库查询失败: {str(e)}") return servers def _erl_quoted_atom(node: str) -> str: """将节点名转为 Erlang 源码中的单引号原子字面量。""" n = (node or "").strip() if not n: raise ValueError("登录服节点为空") return "'" + n.replace("\\", "\\\\").replace("'", "\\'") + "'" # 由 file:script/1 加载。输出 SM_COUNT 行 + 每行「服务器ID\t名称base64\t节点明文」。 # 注意:file:script 生成的匿名 fun 属于 erl_eval,若本地与远程 OTP 版本不一致会 badfun。 # 需确保本地 erl 路径所指版本与登录服 OTP 版本相同。 _FETCH_REMOTE_RPC_SCRIPT = """begin LN = __LOGIN_ATOM__, R = rpc:call(LN, erlang, apply, [ fun() -> ms_cache:tab2list_foldl(server_temp_info, fun(OneServer, ResultAcc) -> ServerId = element(2, server_temp_info_c:get_server_id(OneServer)), ServerName = unicode:characters_to_binary([ element(2, server_temp_info_c:get_server_name(OneServer)) ]), case server_info_lib:get_server_node(ServerId) of {_, Node} -> [{ServerId, ServerName, Node} | ResultAcc]; _ -> ResultAcc end end, []) end, []]), case R of {badrpc, Err} -> io:format(standard_io, "RPC_ERROR: ~p~n", [Err]), erlang:halt(2, [{flush, true}]); _ when is_list(R) -> RowLine = fun({Id, NameBin, Node}) -> Nb = case NameBin of B when is_binary(B) -> B; _ -> <<>> end, NameB64 = binary_to_list(base64:encode(Nb)), NodeStr = case Node of N when is_atom(N) -> unicode:characters_to_list(atom_to_binary(N, utf8)); N when is_list(N) -> N; N when is_binary(N) -> unicode:characters_to_list(N); _ -> lists:flatten(io_lib:format("~p", [Node])) end, Sid = lists:flatten(io_lib:format("~w", [Id])), lists:flatten([Sid, $\\t, NameB64, $\\t, NodeStr]) end, io:format(standard_io, "SM_COUNT\\t~w~n", [length(R)]), lists:foreach( fun(Row) -> io:format(standard_io, "~s~n", [RowLine(Row)]) end, R), erlang:halt(0, [{flush, true}]); Other -> io:format(standard_io, "RPC_ERROR: ~p~n", [Other]), erlang:halt(3, [{flush, true}]) end end. """ def query_remote_servers_from_login_rpc( login_node: str, cookie: str, erl_path: Optional[str] = None, timeout: int = 120, ) -> List[Dict[str, Any]]: """通过向登录服节点 ``rpc:call`` 获取游戏服列表。 使用 ``erl`` + ``file:script/1`` 执行临时脚本,避免 Windows 超长 ``-eval``。 注意:脚本中的匿名 fun 属于 erl_eval,需要本地 erl 与登录服 OTP 版本一致, 否则远程执行会 badfun。 返回每项含 server_id、server_name(UTF-8 文本)、server_node、running(默认 False)。 Raises: ValueError: 参数无效 Exception: erl 执行失败、RPC 错误或输出无法解析 """ ln = _erl_quoted_atom(login_node) ping_node = f"sm_ls_{int(time.time() * 1000) % 100000}" cookie_arg = (cookie or "").strip() or "ddxq2-node" if any(c in cookie_arg for c in " \t\r\n'\""): raise ValueError("Cookie 不能包含空格或引号(请使用项目设置中的纯文本 cookie)") erl = get_erl_cmd(erl_path) _ensure_epmd_daemon(erl_path) # 与 check_nodes_status 一致:本机节点优先用 get_local_ip(),再试 127.0.0.1 host_parts: List[str] = [] lip = get_local_ip() if lip: host_parts.append(lip) if "127.0.0.1" not in host_parts: host_parts.append("127.0.0.1") if not host_parts: host_parts = ["127.0.0.1"] def _looks_like_vm_nodistribution(stderr: str, stdout: str) -> bool: c = (stderr or "") + (stdout or "") return any( x in c for x in ( "nodistribution", "application_start_failure", "failed_to_start_child,net_kernel", "Kernel pid terminated", ) ) script_body = _FETCH_REMOTE_RPC_SCRIPT.replace("__LOGIN_ATOM__", ln) result: Optional[Any] = None for idx, host_part in enumerate(host_parts): tmp_path: Optional[str] = None try: with tempfile.NamedTemporaryFile( mode="w", suffix=".erl", delete=False, encoding="utf-8", newline="\n", ) as tf: tf.write(script_body) tmp_path = tf.name path_for_erl = str(Path(tmp_path).resolve()).replace("\\", "/") if '"' in path_for_erl: raise ValueError("临时脚本路径含引号,无法传给 Erlang") # 脚本内已 halt;若脚本本身无法解析,file:script 返回 {error,_} eval_launch = ( f'case file:script("{path_for_erl}") of ' "{{error, E}} -> io:format(\"SCRIPT_ERROR: ~p~n\", [E]), halt(1); " "_ -> halt(0) end." ) result = subprocess.run( [ erl, "-noshell", "-name", f"{ping_node}@{host_part}", "-setcookie", cookie_arg, "-eval", eval_launch, ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0, ) except subprocess.TimeoutExpired as e: raise Exception(f"从登录服加载超时({timeout}s): {e}") from e except FileNotFoundError: raise Exception( f"找不到 erl 可执行文件: {erl}。" "请确认已安装 Erlang/OTP,或在项目设置中填写正确的 Erlang 安装路径。" ) except Exception as e: raise Exception(f"执行 erl 失败: {e}") from e finally: if tmp_path: try: os.unlink(tmp_path) except OSError: pass if result is None: raise Exception("内部错误:未获得 erl 执行结果") if ( result.returncode != 0 and _looks_like_vm_nodistribution(result.stderr, result.stdout) and idx < len(host_parts) - 1 ): continue break if result is None: raise Exception("内部错误:未获得 erl 执行结果") out = (result.stdout or "").strip() err = (result.stderr or "").strip() # Windows 下 -noshell 时 io:format 有时落在 stderr;与 stdout 合并后再解析 combined_text = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() rpc_err_lines = [line for line in combined_text.splitlines() if "RPC_ERROR:" in line] if rpc_err_lines: raise Exception(f"登录服 RPC 失败: {rpc_err_lines[0]}") if result.returncode != 0: raise Exception(f"erl 退出码 {result.returncode}: {(err or out)[:2000]}") servers: List[Dict[str, Any]] = [] for line in combined_text.splitlines(): line = line.strip() if not line: continue if line.startswith("RPC_ERROR") or line.startswith("SCRIPT_ERROR"): continue if line.startswith("SM_COUNT\t"): continue # 跳过 Eshell/版本等无关行 if "Eshell" in line or "Erlang/OTP" in line: continue parts = line.split("\t", 2) if len(parts) < 3: continue sid_s, name_b64, node_s = parts[0], parts[1], parts[2] try: raw = base64.b64decode(name_b64.encode("ascii")) name_dec = raw.decode("utf-8") except Exception: try: name_dec = base64.b64decode(name_b64.encode("ascii")).decode("utf-8", errors="replace") except Exception: name_dec = "" servers.append( { "server_id": str(sid_s).strip(), "server_name": name_dec, "server_node": str(node_s).strip(), "running": False, } ) sm_count: Optional[int] = None for line in combined_text.splitlines(): if line.strip().startswith("SM_COUNT\t"): try: sm_count = int(line.strip().split("\t", 1)[1]) except (ValueError, IndexError): pass break if sm_count is not None and sm_count > 0 and len(servers) == 0: raise Exception( f"登录服报告 SM_COUNT={sm_count} 条,但未解析出数据行(可能编码或输出分流异常)。" f" 原始 stdout 前 500 字: {(result.stdout or '')[:500]!r}" ) return servers def get_login_db_name(prefix: str, login_server_id: int) -> str: """生成登录服数据库名 Args: prefix: 项目前缀 login_server_id: 登录服ID Returns: 数据库名,如 ai002_login_s900 """ return f"{prefix}_login_s{login_server_id}" def _format_target_node(server_name: str, target_ip: Optional[str]) -> str: if '@' in server_name: return server_name ip = target_ip if target_ip else get_local_ip() return f"{server_name}@{ip}" def _argv_to_shell_line(args: List[str]) -> str: """将实际传给 subprocess 的参数列表格式化为可复制的命令行字符串。""" if IS_WINDOWS: return subprocess.list2cmdline(args) return shlex.join(args) def rpc_clear_server_on_center_and_login( center_node: str, login_node: str, server_id: Union[int, str], cookie: str, erl_path: Optional[str] = None, timeout: int = 45, ) -> Tuple[int, str, str, str]: """Notify center/login nodes to clear a game server. Returns: (returncode, stdout, stderr, full_command_line) """ clean_center_node = str(center_node or "").strip().strip("'\"") clean_login_node = str(login_node or "").strip().strip("'\"") if not clean_center_node: raise ValueError("center_node is empty") if not clean_login_node: raise ValueError("login_node is empty") try: sid = int(str(server_id).strip()) except (TypeError, ValueError) as exc: raise ValueError(f"invalid server_id: {server_id!r}") from exc cookie_arg = (cookie or "").strip() or "ddxq2-node" if any(c in cookie_arg for c in " \t\r\n'\""): raise ValueError("cookie cannot contain whitespace or quotes") erl = get_erl_cmd(erl_path) local_ip = get_local_ip() ping_node = f"clear_{os.getpid()}_{time.time_ns() % 100000000}" center_atom = _erl_quoted_atom(clean_center_node) login_atom = _erl_quoted_atom(clean_login_node) eval_code = ( f"CenterNode = {center_atom}, " f"LoginNode = {login_atom}, " f"ServerId = {sid}, " "CenterResult = rpc:call(CenterNode, center_node_gs_lib, clear_server, [ServerId]), " "LoginResult = rpc:call(LoginNode, login_server_app, clear_server, [ServerId]), " "io:format(\"CENTER: ~p~nLOGIN: ~p~n\", [CenterResult, LoginResult]), " "case {CenterResult, LoginResult} of " "{{badrpc, _}, _} -> erlang:halt(2, [{flush, true}]); " "{_, {badrpc, _}} -> erlang:halt(3, [{flush, true}]); " "_ -> erlang:halt(0, [{flush, true}]) " "end." ) args = [ erl, "-noshell", "-name", f"{ping_node}@{local_ip}", "-setcookie", cookie_arg, "-eval", eval_code, ] cmd_line = _argv_to_shell_line(args) try: _ensure_epmd_daemon(erl_path) result = subprocess.run( args, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0, ) return result.returncode, (result.stdout or ""), (result.stderr or ""), cmd_line except subprocess.TimeoutExpired: return -1, "", f"RPC timeout after {timeout}s", cmd_line except Exception as e: return -1, "", str(e), cmd_line def rpc_role_gs_trace_network( server_name: str, cookie: str, role_id: int, enable: bool, target_ip: Optional[str] = None, erl_path: Optional[str] = None, ) -> Tuple[int, str, str, str]: """远程调用 ``role_gs:trace_network/1`` 或 ``trace_network_close/1``。 Returns: (returncode, stdout, stderr, full_command_line) """ erl = get_erl_cmd(erl_path) ip = get_local_ip() ping_node = f"sm_rpc_{int(time.time() * 1000) % 100000}" target_node = _format_target_node(server_name, target_ip) func = "trace_network" if enable else "trace_network_close" eval_code = ( f"R = rpc:call('{target_node}', role_gs, {func}, [{int(role_id)}]), " f"io:format(\"~p~n\", [R]), halt(0)." ) args = [ erl, "-noshell", "-name", f"{ping_node}@{ip}", "-setcookie", cookie, "-eval", eval_code, ] cmd_line = _argv_to_shell_line(args) try: r = subprocess.run( args, capture_output=True, text=True, timeout=45, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0, ) return r.returncode, (r.stdout or ""), (r.stderr or ""), cmd_line except subprocess.TimeoutExpired: return -1, "", "RPC 超时", cmd_line except Exception as e: return -1, "", str(e), cmd_line