仓库初始化
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
# -*- 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_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
|
||||
Reference in New Issue
Block a user