仓库初始化
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Server command service exports."""
|
||||
|
||||
from . import (
|
||||
command_builders as _command_builders,
|
||||
config_files as _config_files,
|
||||
discovery as _discovery,
|
||||
erlang as _erlang,
|
||||
migrations as _migrations,
|
||||
node_status as _node_status,
|
||||
project_config as _project_config,
|
||||
remote_rpc as _remote_rpc,
|
||||
shared as _shared,
|
||||
terminal as _terminal,
|
||||
window_registry as _window_registry,
|
||||
)
|
||||
|
||||
_modules = (
|
||||
_shared,
|
||||
_project_config,
|
||||
_erlang,
|
||||
_node_status,
|
||||
_terminal,
|
||||
_window_registry,
|
||||
_command_builders,
|
||||
_config_files,
|
||||
_migrations,
|
||||
_discovery,
|
||||
_remote_rpc,
|
||||
)
|
||||
|
||||
for _module in _modules:
|
||||
globals().update({
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith('__')
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Server start/stop/remsh command builders."""
|
||||
|
||||
from .shared import *
|
||||
from .config_files import read_config_file
|
||||
from .erlang import get_ebin_paths, get_erl_cmd, get_local_ip, get_rebar3_cmd
|
||||
from .node_status import _check_nodes_status_via_ping
|
||||
|
||||
def get_rebar_profile(server_type: str) -> tuple:
|
||||
"""根据服务器类型获取 rebar3 profile 和 app 名称
|
||||
|
||||
Args:
|
||||
server_type: 服务器类型 (game_server/login_server/center_server/cross_server/client_server)
|
||||
|
||||
Returns:
|
||||
(profile, app_name) 元组
|
||||
"""
|
||||
if server_type == 'login_server':
|
||||
return ('login_server_dev', 'login_server')
|
||||
elif server_type == 'client_server':
|
||||
return ('client_server_dev', 'client_server')
|
||||
else:
|
||||
# 游戏服、中心服、跨服都使用 game_server_dev
|
||||
return ('game_server_dev', 'game_server')
|
||||
|
||||
|
||||
def build_start_command_by_config(server_root: str, merged_config: Dict[str, str],
|
||||
cookie: str, use_rebar: bool = True,
|
||||
erl_path: str = None) -> str:
|
||||
"""根据配置构建启动服务器的命令
|
||||
|
||||
关键设计:
|
||||
- 命令在 server_root 目录下运行(这样可以找到 rebar.config、_build)
|
||||
- 不再通过 file:set_cwd 改变工作目录(避免影响 Erlang shell 的 c() 命令)
|
||||
sys.config 生成时已把日志路径拼上 run/<服务器名称>/,日志会落到正确位置
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
merged_config: 合并后的配置字典
|
||||
cookie: Erlang cookie
|
||||
use_rebar: 是否使用 rebar3 启动(True=标准启动,False=快速启动)
|
||||
erl_path: Erlang 安装路径
|
||||
|
||||
Returns:
|
||||
启动命令字符串
|
||||
"""
|
||||
server_dir = merged_config.get('_server_dir', '')
|
||||
config_file = merged_config.get('_config_file', '')
|
||||
game_host = merged_config.get('game_host', get_local_ip())
|
||||
node_name = merged_config.get('node_name', f'{server_dir}@{game_host}')
|
||||
server_type = merged_config.get('server_type', 'game_server').strip('"').strip("'")
|
||||
|
||||
# 移除 node_name 中的引号
|
||||
node_name = node_name.replace('"', '').replace("'", '')
|
||||
|
||||
# 根据服务器类型获取 profile
|
||||
profile, app_name = get_rebar_profile(server_type)
|
||||
|
||||
if use_rebar:
|
||||
rebar3 = get_rebar3_cmd(server_root, erl_path)
|
||||
eval_expr = f'application:ensure_all_started({app_name}).'
|
||||
cmd = (
|
||||
f'{rebar3} as {profile} shell '
|
||||
f'--eval -1,"{eval_expr}" '
|
||||
f'--setcookie {cookie} '
|
||||
f'--name {node_name} '
|
||||
f'--config "{config_file}"'
|
||||
)
|
||||
else:
|
||||
erl = get_erl_cmd(erl_path)
|
||||
ebin_paths = get_ebin_paths(server_root, profile)
|
||||
pa_args = " ".join([f'-pa "{p}"' for p in ebin_paths])
|
||||
|
||||
eval_str = f'application:ensure_all_started({app_name}).'
|
||||
|
||||
cmd = (
|
||||
f'"{erl}" {pa_args} '
|
||||
f'-smp enable '
|
||||
f'-setcookie {cookie} '
|
||||
f'-name {node_name} '
|
||||
f'-config "{config_file}" '
|
||||
f'-eval "{eval_str}"'
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_start_command(server_root: str, server_name: str, cookie: str,
|
||||
use_rebar: bool = True, erl_path: str = None) -> str:
|
||||
"""构建启动服务器的命令(兼容旧接口,使用服务器目录名)
|
||||
|
||||
关键设计:
|
||||
- 命令在 server_root 目录下运行(这样可以找到 rebar.config、_build)
|
||||
- 不再通过 file:set_cwd 改变工作目录(避免影响 Erlang shell 的 c() 命令)
|
||||
sys.config 生成时已把日志路径拼上 run/<服务器名称>/,日志会落到正确位置
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_name: 服务器目录名(如 ddxq_game_s1)
|
||||
cookie: Erlang cookie
|
||||
use_rebar: 是否使用 rebar3 启动
|
||||
|
||||
Returns:
|
||||
启动命令字符串
|
||||
"""
|
||||
ip = get_local_ip()
|
||||
run_dir = Path(server_root) / "run" / server_name
|
||||
config_file = run_dir / "config" / "sys.config"
|
||||
kv_config_file = run_dir / "config" / "kv.config"
|
||||
|
||||
# 从 kv.config 或服务器名称推断服务器类型
|
||||
server_type = 'game_server'
|
||||
if kv_config_file.exists():
|
||||
kv_config = read_config_file(str(kv_config_file))
|
||||
server_type = kv_config.get('server_type', 'game_server').strip('"').strip("'")
|
||||
elif 'login_server' in server_name or '_login_' in server_name:
|
||||
server_type = 'login_server'
|
||||
elif 'client_server' in server_name or '_client_' in server_name:
|
||||
server_type = 'client_server'
|
||||
elif 'center_server' in server_name or '_center_' in server_name:
|
||||
server_type = 'center_server'
|
||||
elif 'cross_server' in server_name or '_cross_' in server_name:
|
||||
server_type = 'cross_server'
|
||||
|
||||
# 根据服务器类型获取 profile
|
||||
profile, app_name = get_rebar_profile(server_type)
|
||||
|
||||
if use_rebar:
|
||||
rebar3 = get_rebar3_cmd(server_root, erl_path)
|
||||
eval_expr = f'application:ensure_all_started({app_name}).'
|
||||
cmd = (
|
||||
f'{rebar3} as {profile} shell '
|
||||
f'--eval -1,"{eval_expr}" '
|
||||
f'--setcookie {cookie} '
|
||||
f'--name {server_name}@{ip} '
|
||||
f'--config "{config_file}"'
|
||||
)
|
||||
else:
|
||||
erl = get_erl_cmd(erl_path)
|
||||
ebin_paths = get_ebin_paths(server_root, profile)
|
||||
pa_args = " ".join([f'-pa "{p}"' for p in ebin_paths])
|
||||
|
||||
eval_str = f'application:ensure_all_started({app_name}).'
|
||||
|
||||
cmd = (
|
||||
f'"{erl}" {pa_args} '
|
||||
f'-smp enable '
|
||||
f'-setcookie {cookie} '
|
||||
f'-name {server_name}@{ip} '
|
||||
f'-config "{config_file}" '
|
||||
f'-eval "{eval_str}"'
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_stop_command(server_name: str, cookie: str, local_host: str = None,
|
||||
erl_path: str = None) -> List[str]:
|
||||
"""构建停止服务器的命令参数列表
|
||||
|
||||
使用 erl -noshell + rpc:call 方式停止服务器。
|
||||
返回参数列表,调用方应使用 subprocess.run(args) 直接执行,
|
||||
避免 shell=True 导致 cmd.exe 错误解析括号/引号。
|
||||
|
||||
Args:
|
||||
server_name: 服务器名称
|
||||
cookie: Erlang cookie
|
||||
local_host: 本地节点使用的IP/主机名(默认使用 get_local_ip())
|
||||
erl_path: Erlang 安装路径
|
||||
|
||||
Returns:
|
||||
命令参数列表
|
||||
"""
|
||||
ip = local_host if local_host else get_local_ip()
|
||||
erl = get_erl_cmd(erl_path)
|
||||
|
||||
import time
|
||||
stop_node = f"stop_{int(time.time() * 1000) % 100000}"
|
||||
|
||||
if '@' in server_name:
|
||||
target_node = server_name
|
||||
else:
|
||||
target_node = f"{server_name}@{ip}"
|
||||
|
||||
return [
|
||||
erl, "-noshell",
|
||||
"-name", f"{stop_node}@{ip}",
|
||||
"-setcookie", cookie,
|
||||
"-eval", f"rpc:call('{target_node}', init, stop, [])",
|
||||
"-s", "c", "q",
|
||||
]
|
||||
|
||||
|
||||
def build_remsh_command(server_name: str, cookie: str, target_ip: str = None,
|
||||
local_host: str = None, erl_path: str = None) -> str:
|
||||
"""构建远程连接的命令
|
||||
|
||||
Args:
|
||||
server_name: 服务器名称(完整节点名如 xxx@ip 或仅名称)
|
||||
cookie: Erlang cookie
|
||||
target_ip: 目标IP(如果 server_name 不包含 @ip 时使用)
|
||||
local_host: 本地节点使用的IP/主机名(用于 remsh 节点名,默认使用 get_local_ip())
|
||||
erl_path: Erlang 安装路径
|
||||
|
||||
Returns:
|
||||
远程连接命令字符串
|
||||
"""
|
||||
local_ip = get_local_ip()
|
||||
remsh_host = local_host if local_host else local_ip
|
||||
erl = get_erl_cmd(erl_path)
|
||||
|
||||
if '@' in server_name:
|
||||
target_node = server_name
|
||||
else:
|
||||
ip = target_ip if target_ip else remsh_host
|
||||
target_node = f'{server_name}@{ip}'
|
||||
|
||||
timestamp = int(time.time()) % 10000
|
||||
return (
|
||||
f'"{erl}" +P 1024000 '
|
||||
f'-name remsh_{timestamp}@{remsh_host} '
|
||||
f'-setcookie {cookie} '
|
||||
f'-remsh {target_node}'
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Config file reading, merging, templating, and sys.config generation."""
|
||||
|
||||
from .shared import *
|
||||
from .erlang import get_local_ip
|
||||
from .project_config import get_server_manager_config_dir
|
||||
|
||||
def read_merged_config(
|
||||
server_root: str,
|
||||
server_dir: str = None,
|
||||
run_dir: str = None,
|
||||
) -> Dict[str, str]:
|
||||
"""读取合并后的配置(优先级从低到高)
|
||||
|
||||
优先级:default.kv < tool.config(项目根/.server_manager/config) < 运行目录下配置(run_dir) < kv.config
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_dir: 服务器目录名(可选,如 ddxq_game_s1)。
|
||||
若提供,则读取 运行目录/{server_dir}/config/kv.config 作为最高优先级。
|
||||
run_dir: 运行目录(可选)。若提供,则从运行目录下读取配置:
|
||||
- run_dir/config/tool.config 或 run_dir/tool.config
|
||||
- run_dir/server_dir/config/kv.config(与 server_dir 同时提供时)
|
||||
用于命令行等场景下“用运行目录的配置”获取 IP 等。
|
||||
|
||||
Returns:
|
||||
合并后的配置字典
|
||||
"""
|
||||
config_path = get_server_manager_config_dir(server_root)
|
||||
|
||||
# 1. default.kv(最低优先级)
|
||||
default_kv_file = config_path / 'default.kv'
|
||||
default_config_file = config_path / 'default.config'
|
||||
if default_kv_file.exists():
|
||||
merged = read_config_file(str(default_kv_file))
|
||||
elif default_config_file.exists():
|
||||
merged = read_config_file(str(default_config_file))
|
||||
else:
|
||||
merged = {}
|
||||
|
||||
# 2. tool.config(.server_manager/config 下)
|
||||
tool_config_file = config_path / 'tool.config'
|
||||
if tool_config_file.exists():
|
||||
merged.update(read_config_file(str(tool_config_file)))
|
||||
|
||||
# 3. 运行目录下的配置(run_dir):优先用运行目录的 tool.config,便于命令行从运行目录取 IP 等
|
||||
if run_dir:
|
||||
run_path = Path(run_dir)
|
||||
for tool_in_run in (run_path / 'config' / 'tool.config', run_path / 'tool.config'):
|
||||
if tool_in_run.exists():
|
||||
merged.update(read_config_file(str(tool_in_run)))
|
||||
break
|
||||
|
||||
# 4. kv.config(最高优先级):在运行目录或 server_root/run 下
|
||||
if server_dir:
|
||||
if run_dir:
|
||||
kv_config_file = Path(run_dir) / server_dir / 'config' / 'kv.config'
|
||||
else:
|
||||
kv_config_file = Path(server_root) / 'run' / server_dir / 'config' / 'kv.config'
|
||||
if kv_config_file.exists():
|
||||
merged.update(read_config_file(str(kv_config_file)))
|
||||
|
||||
# 本机 IP:不再依赖配置文件中的 ip / game_host,统一使用当前机器地址
|
||||
local_ip = get_local_ip()
|
||||
merged['ip'] = local_ip
|
||||
merged['game_host'] = local_ip
|
||||
return merged
|
||||
|
||||
|
||||
def read_config_file(file_path: str) -> Dict[str, str]:
|
||||
"""读取配置文件(key=value 格式)
|
||||
|
||||
Args:
|
||||
file_path: 配置文件路径
|
||||
|
||||
Returns:
|
||||
配置字典
|
||||
"""
|
||||
config = {}
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return config
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if '=' in line and not line.startswith('#') and not line.startswith('%'):
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
continue
|
||||
# 去除引号
|
||||
value = re.sub(r'^"|"$', '', value)
|
||||
config[key] = value
|
||||
return config
|
||||
|
||||
|
||||
def write_config_file(file_path: str, config: Dict[str, str]):
|
||||
"""写入配置文件(key=value 格式)
|
||||
|
||||
Args:
|
||||
file_path: 配置文件路径
|
||||
config: 配置字典
|
||||
"""
|
||||
lines = [f"{key}={value}\n" for key, value in config.items()]
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def replace_placeholders(template_content: str, config: Dict[str, str]) -> str:
|
||||
"""替换模板中的占位符 ${key}
|
||||
|
||||
Args:
|
||||
template_content: 模板内容
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
替换后的内容
|
||||
"""
|
||||
result = template_content
|
||||
for key, value in config.items():
|
||||
result = result.replace(f'${{{key}}}', str(value))
|
||||
return result
|
||||
|
||||
|
||||
def get_template_file(config_path: Path, server_type: str) -> Optional[Path]:
|
||||
"""根据服务器类型获取对应的模板文件
|
||||
|
||||
Args:
|
||||
config_path: 配置目录路径
|
||||
server_type: 服务器类型
|
||||
|
||||
Returns:
|
||||
模板文件路径,如果不存在返回 None
|
||||
"""
|
||||
# 服务器类型到模板文件的映射
|
||||
template_map = {
|
||||
'game_server': 'sys_game.config.example',
|
||||
'login_server': 'sys_login.config.example',
|
||||
'client_server': 'sys_client.config.example',
|
||||
'center_server': 'sys_center.config.example',
|
||||
'cross_server': 'sys_cross.config.example',
|
||||
}
|
||||
|
||||
template_name = template_map.get(server_type, 'sys_game.config.example')
|
||||
template_file = config_path / template_name
|
||||
|
||||
if template_file.exists():
|
||||
return template_file
|
||||
|
||||
# 尝试不带 .example 的版本
|
||||
alt_template = config_path / template_name.replace('.example', '')
|
||||
if alt_template.exists():
|
||||
return alt_template
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_replace_map(config: Dict[str, str]) -> Dict[str, str]:
|
||||
"""构建占位符替换映射(直接使用 ${xxx} 格式)
|
||||
|
||||
Args:
|
||||
config: 原始配置字典
|
||||
|
||||
Returns:
|
||||
替换映射字典
|
||||
"""
|
||||
replace_map = {}
|
||||
|
||||
# 移除引号的辅助函数
|
||||
def strip_quotes(val):
|
||||
if isinstance(val, str):
|
||||
return val.strip('"').strip("'")
|
||||
return val
|
||||
|
||||
# 直接从配置中构建替换映射
|
||||
# 所有配置项都使用 ${key} 格式作为占位符
|
||||
# 模板中已统一添加引号,这里只需去掉配置中的引号
|
||||
for key, value in config.items():
|
||||
if not key.startswith('_'): # 跳过内部字段
|
||||
replace_map[key] = strip_quotes(value)
|
||||
|
||||
# 确保一些关键字段有默认值
|
||||
if 'prefix' not in replace_map:
|
||||
replace_map['prefix'] = 'ddxq2'
|
||||
if 'server_id' not in replace_map:
|
||||
replace_map['server_id'] = '1'
|
||||
if 'server_type' not in replace_map:
|
||||
replace_map['server_type'] = 'game_server'
|
||||
if 'tcp_port' not in replace_map:
|
||||
replace_map['tcp_port'] = '18001'
|
||||
if 'http_port' not in replace_map:
|
||||
replace_map['http_port'] = '19001'
|
||||
if 'log_dir' not in replace_map:
|
||||
replace_map['log_dir'] = 'log'
|
||||
if 'merge_server_ids' not in replace_map:
|
||||
replace_map['merge_server_ids'] = '[]'
|
||||
if 'merge_server_time' not in replace_map:
|
||||
replace_map['merge_server_time'] = '{{1970,1,1},{0,0,0}}'
|
||||
if 'last_merge_server_ids' not in replace_map:
|
||||
replace_map['last_merge_server_ids'] = '[]'
|
||||
if 'open_time' not in replace_map:
|
||||
replace_map['open_time'] = '{{2025,1,1},{10,0,0}}'
|
||||
if 'auto_reload' not in replace_map:
|
||||
replace_map['auto_reload'] = 'true'
|
||||
|
||||
# ip / game_host:与 read_merged_config 一致,缺省时使用本机 IP
|
||||
local_ip = get_local_ip()
|
||||
if 'ip' not in replace_map or not str(replace_map.get('ip', '')).strip():
|
||||
replace_map['ip'] = local_ip
|
||||
if 'game_host' not in replace_map or not str(replace_map.get('game_host', '')).strip():
|
||||
replace_map['game_host'] = replace_map.get('ip', local_ip)
|
||||
|
||||
return replace_map
|
||||
|
||||
|
||||
def generate_start_config(server_root: str, server_dir: str) -> Optional[Dict[str, str]]:
|
||||
"""生成启动配置文件
|
||||
|
||||
读取 default.kv → tool.config 覆盖 → kv.config 覆盖 → 替换模板 → 生成 sys.config
|
||||
|
||||
优先级(从低到高):default.kv < tool.config < kv.config
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_dir: 服务器目录名(如 ddxq_game_s1)
|
||||
|
||||
Returns:
|
||||
合并后的配置字典,如果失败返回 None
|
||||
"""
|
||||
server_root_path = Path(server_root)
|
||||
config_path = get_server_manager_config_dir(server_root_path)
|
||||
run_path = server_root_path / 'run'
|
||||
|
||||
# 服务器配置目录
|
||||
server_config_path = run_path / server_dir / 'config'
|
||||
kv_config_file = server_config_path / 'kv.config'
|
||||
sys_config_file = server_config_path / 'sys.config'
|
||||
|
||||
# 检查 kv.config 是否存在
|
||||
if not kv_config_file.exists():
|
||||
return None
|
||||
|
||||
# 合并配置:default.kv < tool.config < kv.config
|
||||
merged_config = read_merged_config(server_root, server_dir)
|
||||
|
||||
# 根据服务器类型选择模板
|
||||
server_type = merged_config.get('server_type', 'game_server').strip('"').strip("'")
|
||||
template_file = get_template_file(config_path, server_type)
|
||||
|
||||
if not template_file:
|
||||
return None
|
||||
|
||||
with open(template_file, 'r', encoding='utf-8') as f:
|
||||
template_content = f.read()
|
||||
|
||||
# 日志目录:拼上 run/<server_dir>/ 前缀,让日志收敛到具体服务目录下
|
||||
# 不再依赖 file:set_cwd 改变运行时工作目录(为了兼容 Erlang shell 的 c() 命令)
|
||||
def _normalize_run_log_path(raw: str, default_tail: str) -> str:
|
||||
"""把配置里的日志路径统一成 run/<server_dir>/<tail>。
|
||||
形如 run/xxx/log 时只取第 3 段及之后,避免 default.kv 中的模板值
|
||||
run/server/log 污染具体服务器的实际路径。
|
||||
"""
|
||||
raw = (raw or '').strip('"').strip("'").strip()
|
||||
if raw.startswith('run/'):
|
||||
parts = raw.split('/', 2)
|
||||
tail = parts[2] if len(parts) > 2 and parts[2] else default_tail
|
||||
else:
|
||||
tail = raw or default_tail
|
||||
return f'run/{server_dir}/{tail}'
|
||||
|
||||
merged_config['log_dir'] = _normalize_run_log_path(
|
||||
str(merged_config.get('log_dir', 'log')), 'log')
|
||||
|
||||
# 角色日志目录(游戏服专用,模板里以 ${role_log} 引用)
|
||||
if 'role_log' in merged_config:
|
||||
merged_config['role_log'] = _normalize_run_log_path(
|
||||
str(merged_config.get('role_log', '')), 'log')
|
||||
|
||||
# 构建替换映射
|
||||
replace_map = build_replace_map(merged_config)
|
||||
|
||||
# 替换占位符(使用 ${xxx} 格式)
|
||||
output_content = template_content
|
||||
for key, value in replace_map.items():
|
||||
output_content = output_content.replace(f'${{{key}}}', str(value))
|
||||
|
||||
# 检查是否有未替换的占位符
|
||||
unreplaced = re.findall(r'\$\{(\w+)\}', output_content)
|
||||
if unreplaced:
|
||||
print(f"[警告] 配置文件中有未替换的占位符: {set(unreplaced)}")
|
||||
|
||||
# 生成最终配置文件 sys.config
|
||||
with open(sys_config_file, 'w', encoding='utf-8') as f:
|
||||
f.write(output_content)
|
||||
|
||||
# 将服务器信息加入配置
|
||||
merged_config['_server_dir'] = server_dir
|
||||
merged_config['_config_file'] = str(sys_config_file)
|
||||
|
||||
return merged_config
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Run-directory discovery and log-file helpers."""
|
||||
|
||||
from .shared import *
|
||||
|
||||
def get_config_list(run_dir: str) -> List[str]:
|
||||
"""获取所有带有 kv.config 的服务器目录列表
|
||||
|
||||
Args:
|
||||
run_dir: 运行目录路径
|
||||
|
||||
Returns:
|
||||
服务器目录名列表
|
||||
"""
|
||||
run_path = Path(run_dir)
|
||||
if not run_path.exists():
|
||||
return []
|
||||
|
||||
servers = []
|
||||
for item in run_path.iterdir():
|
||||
if item.is_dir():
|
||||
kv_config = item / 'config' / 'kv.config'
|
||||
if kv_config.exists():
|
||||
servers.append(item.name)
|
||||
return sorted(servers)
|
||||
|
||||
|
||||
def get_server_list(run_dir: str) -> Dict[str, List[str]]:
|
||||
"""获取服务器列表,按类型分组(不过滤前缀,显示所有服务器目录)
|
||||
|
||||
Args:
|
||||
run_dir: 运行目录路径
|
||||
|
||||
Returns:
|
||||
按类型分组的服务器字典
|
||||
"""
|
||||
servers = {
|
||||
'game': [],
|
||||
'cross': [],
|
||||
'login': [],
|
||||
'client': [],
|
||||
'center': [],
|
||||
'other': []
|
||||
}
|
||||
|
||||
run_path = Path(run_dir)
|
||||
if not run_path.exists():
|
||||
return servers
|
||||
|
||||
for item in run_path.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
name = item.name
|
||||
|
||||
# 检查是否有配置目录(确认是有效的服务器目录)
|
||||
if not (item / 'config').exists():
|
||||
continue
|
||||
|
||||
# 根据目录名中的关键字分类
|
||||
name_lower = name.lower()
|
||||
if '_game_s' in name_lower or '_game_' in name_lower:
|
||||
servers['game'].append(name)
|
||||
elif '_cross_s' in name_lower or '_cross_' in name_lower:
|
||||
servers['cross'].append(name)
|
||||
elif '_login_s' in name_lower or '_login_' in name_lower:
|
||||
servers['login'].append(name)
|
||||
elif '_client_s' in name_lower or '_client_' in name_lower:
|
||||
servers['client'].append(name)
|
||||
elif '_center_s' in name_lower or '_center_' in name_lower:
|
||||
servers['center'].append(name)
|
||||
else:
|
||||
# 其他有效服务器目录
|
||||
servers['other'].append(name)
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def flatten_server_dirs(run_dir: str) -> List[str]:
|
||||
"""按类型顺序返回 run 目录下所有有效服务器子目录名。"""
|
||||
order = ('game', 'cross', 'login', 'client', 'center', 'other')
|
||||
buckets = get_server_list(run_dir)
|
||||
out: List[str] = []
|
||||
for k in order:
|
||||
out.extend(sorted(buckets.get(k, [])))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_log_file_path(
|
||||
run_dir: str,
|
||||
server_dir: str,
|
||||
base_filename: str,
|
||||
date_yyyymmdd: Optional[str] = None,
|
||||
subdir: str = "",
|
||||
) -> Path:
|
||||
"""解析日志文件路径。
|
||||
|
||||
无日期:``log/[subdir/]base_filename``(如 error.log)
|
||||
有日期:``log/[subdir/]base_filename.YYYYMMDD``(如 error.log.20260305)
|
||||
|
||||
Args:
|
||||
run_dir: 运行根目录(含 run)
|
||||
server_dir: 服务器目录名(如 ddxq_ai002_game_s100)
|
||||
base_filename: 主文件名,如 error.log、network_codec-10000100.log
|
||||
date_yyyymmdd: 可选,8 位日期字符串
|
||||
subdir: log 下子目录,玩家协议日志为 tag_log
|
||||
"""
|
||||
log_root = Path(run_dir) / server_dir / "log"
|
||||
if subdir:
|
||||
log_root = log_root / subdir
|
||||
d = (date_yyyymmdd or "").strip()
|
||||
if d and len(d) == 8 and d.isdigit():
|
||||
name = f"{base_filename}.{d}"
|
||||
else:
|
||||
name = base_filename
|
||||
return log_root / name
|
||||
|
||||
|
||||
def read_text_file_best_effort(path: Path, max_bytes: int = 3_145_728) -> Tuple[str, Optional[str]]:
|
||||
"""读取文本文件,带体积上限。返回 (内容, 错误说明);错误时内容可能为空。"""
|
||||
if not path.exists():
|
||||
return "", f"文件不存在: {path}"
|
||||
if not path.is_file():
|
||||
return "", f"不是普通文件: {path}"
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError as e:
|
||||
return "", str(e)
|
||||
if size > max_bytes:
|
||||
return "", f"文件过大 ({size} 字节),上限 {max_bytes},请缩小范围或改用命令行 tail"
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "utf-8-sig", "gbk", "latin-1"):
|
||||
try:
|
||||
return raw.decode(enc), None
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace"), None
|
||||
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Erlang and build-tool command resolution."""
|
||||
|
||||
from .shared import *
|
||||
|
||||
def get_erl_cmd(erl_path: str = None) -> str:
|
||||
"""根据配置路径获取 erl 可执行文件路径
|
||||
|
||||
Args:
|
||||
erl_path: Erlang 安装根目录(如 E:/app/erlang/Erlang_OTP_25)
|
||||
|
||||
Returns:
|
||||
erl 可执行文件的完整路径,或默认的 'erl'
|
||||
"""
|
||||
if erl_path and erl_path.strip():
|
||||
if IS_WINDOWS:
|
||||
erl_exe = Path(erl_path.strip()) / 'bin' / 'erl.exe'
|
||||
if erl_exe.exists():
|
||||
return str(erl_exe)
|
||||
else:
|
||||
erl_bin = Path(erl_path.strip()) / 'bin' / 'erl'
|
||||
if erl_bin.exists():
|
||||
return str(erl_bin)
|
||||
return 'erl'
|
||||
|
||||
|
||||
def subprocess_args_to_display(args: List[str]) -> str:
|
||||
"""将 subprocess 参数列表转为可读的命令字符串(仅用于显示/日志)"""
|
||||
parts = []
|
||||
for a in args:
|
||||
if ' ' in a or '"' in a or "'" in a or any(c in a for c in '()[]{}&|<>^'):
|
||||
parts.append(f'"{a}"')
|
||||
else:
|
||||
parts.append(a)
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def get_escript_cmd(erl_path: str = None) -> str:
|
||||
"""根据配置路径获取 escript 可执行文件路径
|
||||
|
||||
Args:
|
||||
erl_path: Erlang 安装根目录
|
||||
|
||||
Returns:
|
||||
escript 可执行文件的完整路径,或默认的 'escript'
|
||||
"""
|
||||
if erl_path and erl_path.strip():
|
||||
if IS_WINDOWS:
|
||||
escript_exe = Path(erl_path.strip()) / 'bin' / 'escript.exe'
|
||||
if escript_exe.exists():
|
||||
return str(escript_exe)
|
||||
else:
|
||||
escript_bin = Path(erl_path.strip()) / 'bin' / 'escript'
|
||||
if escript_bin.exists():
|
||||
return str(escript_bin)
|
||||
return 'escript'
|
||||
|
||||
|
||||
def _ensure_epmd_daemon(erl_path: Optional[str] = None) -> None:
|
||||
"""尽量执行 epmd -daemon。本机已有 epmd 时通常无害(就绪则不再重复监听)。"""
|
||||
erl_exe = Path(get_erl_cmd(erl_path))
|
||||
epmd = erl_exe.parent / ("epmd.exe" if IS_WINDOWS else "epmd")
|
||||
if not epmd.is_file():
|
||||
which = shutil.which("epmd")
|
||||
if not which:
|
||||
return
|
||||
epmd = Path(which)
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(epmd), "-daemon"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_rebar3_cmd(server_root: str, erl_path: str = None) -> str:
|
||||
"""获取 rebar3 执行命令
|
||||
|
||||
如果配置了 erl_path,直接用配置路径的 escript 来运行 rebar3 脚本,
|
||||
而不是依赖系统 PATH 中的 escript。
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录(rebar3 脚本所在目录)
|
||||
erl_path: Erlang 安装根目录
|
||||
|
||||
Returns:
|
||||
rebar3 执行命令字符串
|
||||
"""
|
||||
escript = get_escript_cmd(erl_path)
|
||||
if IS_WINDOWS:
|
||||
rebar3_script = Path(server_root) / 'rebar3'
|
||||
if escript != 'escript':
|
||||
return f'"{escript}" "{rebar3_script}"'
|
||||
return REBAR3_CMD
|
||||
else:
|
||||
rebar3_script = Path(server_root) / 'rebar3'
|
||||
if escript != 'escript':
|
||||
return f'"{escript}" "{rebar3_script}"'
|
||||
return './rebar3'
|
||||
|
||||
|
||||
def get_local_ip() -> str:
|
||||
"""获取本机IP地址"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def get_ebin_paths(server_root: str, profile: str = 'game_server_dev') -> List[str]:
|
||||
"""获取所有 ebin 目录路径
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
profile: rebar3 profile 名称 (game_server_dev/login_server_dev/client_server_dev)
|
||||
|
||||
Returns:
|
||||
ebin 目录路径列表
|
||||
"""
|
||||
ebin_paths = []
|
||||
build_dir = Path(server_root) / "_build" / profile / "lib"
|
||||
|
||||
if build_dir.exists():
|
||||
for app_dir in build_dir.iterdir():
|
||||
if app_dir.is_dir():
|
||||
ebin_dir = app_dir / "ebin"
|
||||
if ebin_dir.exists():
|
||||
ebin_paths.append(str(ebin_dir))
|
||||
|
||||
# 添加 checkouts 目录
|
||||
checkouts_dir = Path(server_root) / "_build" / profile / "checkouts"
|
||||
if checkouts_dir.exists():
|
||||
for app_dir in checkouts_dir.iterdir():
|
||||
if app_dir.is_dir():
|
||||
ebin_dir = app_dir / "ebin"
|
||||
if ebin_dir.exists():
|
||||
ebin_paths.append(str(ebin_dir))
|
||||
|
||||
return ebin_paths
|
||||
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One-shot config migrations."""
|
||||
|
||||
from .shared import *
|
||||
from .config_files import generate_start_config, read_config_file, write_config_file
|
||||
from .discovery import get_server_list
|
||||
from .project_config import get_server_manager_config_dir
|
||||
|
||||
_MIGRATIONS = [
|
||||
# 2026-04 版本:去掉 file:set_cwd,改由 sys.config 里 log_dir 前缀 run/<server_dir>/
|
||||
# 收敛日志,因此需要为旧服务器重新生成 sys.config。
|
||||
'sys_config_log_dir_v2',
|
||||
# 2026-04 版本:模板新增 ${role_log} 占位符,需要为所有已有服务器重新生成
|
||||
# sys.config,让 role_log 替换为 run/<server_dir>/log。
|
||||
'sys_config_role_log_v1',
|
||||
]
|
||||
|
||||
|
||||
def _get_migrations_state_path(server_root) -> Path:
|
||||
"""迁移状态文件路径:随项目一起存放在 .server_manager/migrations.json。"""
|
||||
return get_server_manager_config_dir(server_root).parent / 'migrations.json'
|
||||
|
||||
|
||||
def _load_migrations_state(server_root) -> Dict[str, Any]:
|
||||
path = _get_migrations_state_path(server_root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
import json
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_migrations_state(server_root, state: Dict[str, Any]) -> None:
|
||||
path = _get_migrations_state_path(server_root)
|
||||
try:
|
||||
import json
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _regenerate_all_sys_configs(server_root) -> Tuple[int, int, List[str]]:
|
||||
"""为 run/ 下所有带 kv.config 的服务器重新生成 sys.config。
|
||||
|
||||
Returns:
|
||||
(成功数, 失败数, 失败的服务器名列表)
|
||||
"""
|
||||
run_dir = Path(server_root) / 'run'
|
||||
if not run_dir.is_dir():
|
||||
return (0, 0, [])
|
||||
|
||||
ok, fail = 0, 0
|
||||
failed_servers: List[str] = []
|
||||
for item in sorted(run_dir.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if not (item / 'config' / 'kv.config').exists():
|
||||
continue
|
||||
try:
|
||||
result = generate_start_config(str(server_root), item.name)
|
||||
if result is None:
|
||||
fail += 1
|
||||
failed_servers.append(item.name)
|
||||
else:
|
||||
ok += 1
|
||||
except Exception:
|
||||
fail += 1
|
||||
failed_servers.append(item.name)
|
||||
return (ok, fail, failed_servers)
|
||||
|
||||
|
||||
def _migrate_sys_config_log_dir_v2(server_root) -> Tuple[int, int, List[str]]:
|
||||
"""为所有带 kv.config 的服务器重新生成 sys.config。
|
||||
|
||||
对应改动:去掉启动脚本里的 file:set_cwd,改为在生成 sys.config 时把日志
|
||||
路径拼上 run/<server_dir>/,让日志仍然收敛到具体服务目录下。旧版本生成的
|
||||
sys.config 里日志路径是相对 run/<server_dir>/ 的(如 log/info.log),当前
|
||||
cwd 变为 server_root 后会写到错误位置,因此必须重新生成。
|
||||
"""
|
||||
return _regenerate_all_sys_configs(server_root)
|
||||
|
||||
|
||||
def _migrate_sys_config_role_log_v1(server_root) -> Tuple[int, int, List[str]]:
|
||||
"""为所有带 kv.config 的服务器重新生成 sys.config。
|
||||
|
||||
对应改动:sys_game.config.example 新增 ${role_log} 占位符,游戏服旧版
|
||||
sys.config 里还没有 role_log 字段;generate_start_config 会根据当前
|
||||
default.kv 里的 role_log 自动计算出 run/<server_dir>/log 并替换。
|
||||
"""
|
||||
return _regenerate_all_sys_configs(server_root)
|
||||
|
||||
|
||||
# 迁移 id -> 执行函数。函数返回 True 表示迁移执行(即使个别服务器失败也算已执行)。
|
||||
_MIGRATION_RUNNERS = {
|
||||
'sys_config_log_dir_v2': _migrate_sys_config_log_dir_v2,
|
||||
'sys_config_role_log_v1': _migrate_sys_config_role_log_v1,
|
||||
}
|
||||
|
||||
|
||||
def run_startup_migrations(server_root, logger_func=None) -> Dict[str, Any]:
|
||||
"""执行尚未完成的一次性迁移任务(在项目打开时调用,幂等)。
|
||||
|
||||
状态保存在项目下 .server_manager/migrations.json,每个迁移 id 只跑一次。
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
logger_func: 可选的日志回调,签名 logger_func(msg: str),用于把进度
|
||||
输出到 GUI 控制台或 CLI,默认只 print。
|
||||
|
||||
Returns:
|
||||
本次实际执行的迁移结果字典,形如
|
||||
{'sys_config_log_dir_v2': {'ok': 3, 'fail': 0, 'failed_servers': []}};
|
||||
若没有需要执行的迁移则返回空字典。
|
||||
"""
|
||||
if not server_root:
|
||||
return {}
|
||||
server_root = str(server_root)
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if logger_func is not None:
|
||||
try:
|
||||
# GUI/CLI 回调约定:自带换行,保证在 OutputConsole 里逐行显示
|
||||
logger_func(msg + '\n')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(msg)
|
||||
|
||||
state = _load_migrations_state(server_root)
|
||||
executed: Dict[str, Any] = {}
|
||||
|
||||
for mig_id in _MIGRATIONS:
|
||||
if state.get(mig_id, {}).get('done'):
|
||||
continue
|
||||
runner = _MIGRATION_RUNNERS.get(mig_id)
|
||||
if runner is None:
|
||||
continue
|
||||
_log(f"[迁移] 开始执行: {mig_id}")
|
||||
try:
|
||||
ok, fail, failed_servers = runner(server_root)
|
||||
except Exception as e:
|
||||
_log(f"[迁移][错误] {mig_id} 执行异常: {e}")
|
||||
continue
|
||||
|
||||
from datetime import datetime
|
||||
state[mig_id] = {
|
||||
'done': True,
|
||||
'done_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'ok': ok,
|
||||
'fail': fail,
|
||||
}
|
||||
executed[mig_id] = {'ok': ok, 'fail': fail, 'failed_servers': failed_servers}
|
||||
|
||||
if fail > 0:
|
||||
_log(f"[迁移] {mig_id} 完成: 成功 {ok} 个, 失败 {fail} 个({', '.join(failed_servers)})")
|
||||
elif ok > 0:
|
||||
_log(f"[迁移] {mig_id} 完成: 已重新生成 {ok} 个服务器的 sys.config")
|
||||
else:
|
||||
_log(f"[迁移] {mig_id} 完成: 无服务器需要处理")
|
||||
|
||||
if executed:
|
||||
_save_migrations_state(server_root, state)
|
||||
return executed
|
||||
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Erlang node status cache and probes."""
|
||||
|
||||
from .shared import *
|
||||
from .erlang import _ensure_epmd_daemon, get_erl_cmd, get_local_ip
|
||||
|
||||
class NodeStatusCache:
|
||||
"""节点状态缓存
|
||||
|
||||
全局缓存所有已查询节点的状态,避免重复查询
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._cache = {} # {node_name: {'online': bool, 'timestamp': float}}
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def get(self, node_name: str) -> Optional[bool]:
|
||||
"""获取节点状态(从缓存)
|
||||
|
||||
Args:
|
||||
node_name: 节点名称
|
||||
|
||||
Returns:
|
||||
True=在线, False=离线, None=未缓存
|
||||
"""
|
||||
if node_name in self._cache:
|
||||
return self._cache[node_name]['online']
|
||||
return None
|
||||
|
||||
def set(self, node_name: str, online: bool):
|
||||
"""设置节点状态
|
||||
|
||||
Args:
|
||||
node_name: 节点名称
|
||||
online: 是否在线
|
||||
"""
|
||||
self._cache[node_name] = {
|
||||
'online': online,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
def set_online(self, node_name: str):
|
||||
"""设置节点为在线状态"""
|
||||
self.set(node_name, True)
|
||||
|
||||
def set_offline(self, node_name: str):
|
||||
"""设置节点为离线状态"""
|
||||
self.set(node_name, False)
|
||||
|
||||
def batch_set(self, results: Dict[str, bool]):
|
||||
"""批量设置节点状态
|
||||
|
||||
Args:
|
||||
results: {node_name: is_online}
|
||||
"""
|
||||
for node_name, online in results.items():
|
||||
self.set(node_name, online)
|
||||
|
||||
def get_all(self) -> Dict[str, bool]:
|
||||
"""获取所有缓存的节点状态
|
||||
|
||||
Returns:
|
||||
{node_name: is_online}
|
||||
"""
|
||||
return {name: data['online'] for name, data in self._cache.items()}
|
||||
|
||||
def has_local_nodes(self, local_nodes: List[str]) -> bool:
|
||||
"""检查是否有本地节点的缓存
|
||||
|
||||
Args:
|
||||
local_nodes: 本地节点名称列表
|
||||
|
||||
Returns:
|
||||
True 如果所有本地节点都有缓存
|
||||
"""
|
||||
for node in local_nodes:
|
||||
if node not in self._cache:
|
||||
return False
|
||||
return len(local_nodes) > 0
|
||||
|
||||
def clear(self):
|
||||
"""清空缓存"""
|
||||
self._cache.clear()
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
"""检查是否已初始化(是否有缓存数据)"""
|
||||
return self._initialized
|
||||
|
||||
def set_initialized(self, value: bool = True):
|
||||
"""设置初始化状态"""
|
||||
self._initialized = value
|
||||
|
||||
|
||||
# 全局缓存实例
|
||||
_node_status_cache = NodeStatusCache()
|
||||
|
||||
|
||||
def get_node_status_cache() -> NodeStatusCache:
|
||||
"""获取全局节点状态缓存实例"""
|
||||
return _node_status_cache
|
||||
|
||||
|
||||
def check_node_status(server_name: str, cookie: str, target_ip: str = None,
|
||||
erl_path: str = None) -> bool:
|
||||
"""检查单个 Erlang 节点是否在线
|
||||
|
||||
使用 net_adm:ping 来检查节点状态
|
||||
|
||||
Args:
|
||||
server_name: 服务器名称(完整节点名如 xxx@ip 或仅名称)
|
||||
cookie: Erlang cookie
|
||||
target_ip: 目标IP(如果 server_name 不包含 @ip 时使用)
|
||||
erl_path: Erlang 安装路径
|
||||
|
||||
Returns:
|
||||
True 表示节点在线,False 表示离线
|
||||
"""
|
||||
results = _check_nodes_status_via_ping([server_name], cookie, target_ip, erl_path=erl_path)
|
||||
return results.get(server_name, False)
|
||||
|
||||
|
||||
_NODE_STATUS_BATCH_SIZE = 40
|
||||
_NODE_STATUS_MAX_EVAL_CHARS = 8000
|
||||
_NODE_STATUS_MAX_WORKERS = 4
|
||||
_NODE_STATUS_FAST_TIMEOUT = 0.8
|
||||
_NODE_STATUS_FAST_MAX_WORKERS = 48
|
||||
_EPMD_PORT = 4369
|
||||
|
||||
|
||||
def _split_node_status_batches(target_nodes: List[str]) -> List[List[str]]:
|
||||
"""按数量和命令长度拆分节点检查批次。"""
|
||||
batches: List[List[str]] = []
|
||||
current_batch: List[str] = []
|
||||
current_chars = 0
|
||||
|
||||
for target_node in target_nodes:
|
||||
node_chars = len(target_node) + 4 # 单引号、逗号等额外字符
|
||||
should_split = (
|
||||
current_batch and (
|
||||
len(current_batch) >= _NODE_STATUS_BATCH_SIZE
|
||||
or current_chars + node_chars > _NODE_STATUS_MAX_EVAL_CHARS
|
||||
)
|
||||
)
|
||||
if should_split:
|
||||
batches.append(current_batch)
|
||||
current_batch = []
|
||||
current_chars = 0
|
||||
|
||||
current_batch.append(target_node)
|
||||
current_chars += node_chars
|
||||
|
||||
if current_batch:
|
||||
batches.append(current_batch)
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def _check_nodes_status_batch(
|
||||
target_nodes: List[str],
|
||||
target_mapping: Dict[str, List[str]],
|
||||
cookie: str,
|
||||
erl_path: str,
|
||||
local_ip: str,
|
||||
) -> Dict[str, bool]:
|
||||
"""执行单批节点状态检查。"""
|
||||
if not target_nodes:
|
||||
return {}
|
||||
|
||||
erl = get_erl_cmd(erl_path)
|
||||
ping_node = f"ping_{os.getpid()}_{time.time_ns() % 100000000}"
|
||||
nodes_str = "[" + ",".join(f"'{node}'" for node in target_nodes) + "]"
|
||||
short_name_mapping: Dict[str, List[str]] = {}
|
||||
for full_node, original_names in target_mapping.items():
|
||||
short_name = full_node.split('@', 1)[0]
|
||||
short_name_mapping.setdefault(short_name, []).extend(original_names)
|
||||
eval_code = (
|
||||
f'Nodes = {nodes_str}, '
|
||||
'Parent = self(), '
|
||||
'Collector = fun F([], Acc) -> Acc; '
|
||||
' F(Pending, Acc) -> '
|
||||
' receive '
|
||||
' {node_status, N, R} -> F(lists:delete(N, Pending), [{N, R} | Acc]) '
|
||||
' after 8000 -> '
|
||||
' [{N, pang} || N <- Pending] ++ Acc '
|
||||
' end '
|
||||
'end, '
|
||||
'lists:foreach(fun(N) -> spawn(fun() -> Parent ! {node_status, N, net_adm:ping(N)} end) end, Nodes), '
|
||||
'Results = Collector(Nodes, []), '
|
||||
'Output = string:join([atom_to_list(N) ++ "::" ++ atom_to_list(R) || {N, R} <- lists:reverse(Results)], ","), '
|
||||
f'io:format("~s", [Output]), '
|
||||
f'halt(0)'
|
||||
)
|
||||
args = [
|
||||
erl, "-noshell",
|
||||
"-name", f"{ping_node}@{local_ip}",
|
||||
"-setcookie", cookie,
|
||||
"-eval", eval_code,
|
||||
]
|
||||
|
||||
batch_results: Dict[str, bool] = {}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(12, 8 + len(target_nodes)),
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
return batch_results
|
||||
|
||||
if result.returncode != 0:
|
||||
return batch_results
|
||||
|
||||
output = ",".join(
|
||||
part.strip()
|
||||
for part in ((result.stdout or ""), (result.stderr or ""))
|
||||
if part and part.strip()
|
||||
)
|
||||
if not output:
|
||||
return batch_results
|
||||
|
||||
for item in output.split(','):
|
||||
if '::' not in item:
|
||||
continue
|
||||
node, status = item.rsplit('::', 1)
|
||||
node = node.strip()
|
||||
status = status.strip()
|
||||
original_names = target_mapping.get(node)
|
||||
if original_names is None:
|
||||
short_name = node.split('@', 1)[0]
|
||||
original_names = short_name_mapping.get(short_name)
|
||||
if not original_names:
|
||||
continue
|
||||
is_online = (status == 'pong')
|
||||
for original_name in original_names:
|
||||
batch_results[original_name] = is_online
|
||||
|
||||
return batch_results
|
||||
|
||||
|
||||
def _check_nodes_status_via_ping(server_names: List[str], cookie: str, target_ip: str = None,
|
||||
erl_path: str = None) -> Dict[str, bool]:
|
||||
"""使用 net_adm:ping 严格检查节点状态。"""
|
||||
if not server_names:
|
||||
return {}
|
||||
|
||||
local_ip = get_local_ip()
|
||||
cookie_arg = (cookie or "").strip() or "ddxq2-node"
|
||||
results = {name: False for name in server_names}
|
||||
target_mapping: Dict[str, List[str]] = {}
|
||||
target_nodes: List[str] = []
|
||||
|
||||
for server_name in server_names:
|
||||
raw_name = server_name or ""
|
||||
normalized_name = raw_name.strip()
|
||||
if not normalized_name:
|
||||
continue
|
||||
if '@' in normalized_name:
|
||||
target_node = normalized_name
|
||||
else:
|
||||
node_ip = target_ip if target_ip else local_ip
|
||||
target_node = f"{normalized_name}@{node_ip}"
|
||||
if target_node not in target_mapping:
|
||||
target_mapping[target_node] = []
|
||||
target_nodes.append(target_node)
|
||||
target_mapping[target_node].append(raw_name)
|
||||
|
||||
if not target_nodes:
|
||||
return results
|
||||
|
||||
_ensure_epmd_daemon(erl_path)
|
||||
batches = _split_node_status_batches(target_nodes)
|
||||
|
||||
if len(batches) == 1:
|
||||
results.update(
|
||||
_check_nodes_status_batch(
|
||||
batches[0], target_mapping, cookie_arg, erl_path, local_ip
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
max_workers = min(_NODE_STATUS_MAX_WORKERS, len(batches))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_check_nodes_status_batch,
|
||||
batch,
|
||||
target_mapping,
|
||||
cookie_arg,
|
||||
erl_path,
|
||||
local_ip,
|
||||
)
|
||||
for batch in batches
|
||||
]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
results.update(future.result())
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _query_epmd_registered_names(host: str, timeout: float = _NODE_STATUS_FAST_TIMEOUT) -> List[str]:
|
||||
"""查询指定主机 epmd 上已注册的 Erlang 短节点名。"""
|
||||
sock: Optional[socket.socket] = None
|
||||
try:
|
||||
sock = socket.create_connection((host, _EPMD_PORT), timeout=timeout)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(struct.pack(">HB", 1, ord('n')))
|
||||
|
||||
chunks = []
|
||||
while True:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
if not chunks:
|
||||
return []
|
||||
|
||||
payload = b"".join(chunks)
|
||||
if len(payload) >= 4:
|
||||
payload = payload[4:]
|
||||
text = payload.decode("latin-1", errors="ignore")
|
||||
|
||||
names: List[str] = []
|
||||
for line in text.splitlines():
|
||||
match = re.search(r"\bname\s+([^\s]+)\s+at\s+port\b", line)
|
||||
if match:
|
||||
names.append(match.group(1).strip())
|
||||
return names
|
||||
except Exception:
|
||||
return []
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def check_nodes_status(server_names: List[str], cookie: str, target_ip: str = None,
|
||||
erl_path: str = None) -> Dict[str, bool]:
|
||||
"""批量检查多个 Erlang 节点是否在线
|
||||
|
||||
批量模式优先按主机并发查询 epmd 已注册节点名,速度远快于逐个分布式 ping。
|
||||
这适合服务器列表/状态面板的在线展示;若需要严格校验 cookie 与分布式握手,
|
||||
请使用 ``check_node_status``。
|
||||
|
||||
Args:
|
||||
server_names: 服务器名称列表(完整节点名如 xxx@ip 或仅名称)
|
||||
cookie: Erlang cookie(批量快速模式下仅保留参数兼容,不参与 epmd 查询)
|
||||
target_ip: 目标IP(如果 server_name 不包含 @ip 时使用)
|
||||
erl_path: Erlang 安装路径(批量快速模式下保留参数兼容)
|
||||
|
||||
Returns:
|
||||
字典 {server_name: is_online}
|
||||
"""
|
||||
if not server_names:
|
||||
return {}
|
||||
|
||||
local_ip = get_local_ip()
|
||||
results = {name: False for name in server_names}
|
||||
host_mapping: Dict[str, Dict[str, List[str]]] = {}
|
||||
|
||||
for server_name in server_names:
|
||||
raw_name = server_name or ""
|
||||
normalized_name = raw_name.strip()
|
||||
if not normalized_name:
|
||||
continue
|
||||
|
||||
if '@' in normalized_name:
|
||||
short_name, host = normalized_name.split('@', 1)
|
||||
else:
|
||||
short_name = normalized_name
|
||||
host = target_ip if target_ip else local_ip
|
||||
|
||||
if not short_name or not host:
|
||||
continue
|
||||
|
||||
host_bucket = host_mapping.setdefault(host, {})
|
||||
host_bucket.setdefault(short_name, []).append(raw_name)
|
||||
|
||||
if not host_mapping:
|
||||
return results
|
||||
|
||||
max_workers = min(_NODE_STATUS_FAST_MAX_WORKERS, len(host_mapping))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_host = {
|
||||
executor.submit(_query_epmd_registered_names, host): host
|
||||
for host in host_mapping.keys()
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_to_host):
|
||||
host = future_to_host[future]
|
||||
try:
|
||||
registered_names = set(future.result())
|
||||
except Exception:
|
||||
registered_names = set()
|
||||
|
||||
short_name_mapping = host_mapping.get(host, {})
|
||||
for short_name, original_names in short_name_mapping.items():
|
||||
is_online = short_name in registered_names
|
||||
for original_name in original_names:
|
||||
results[original_name] = is_online
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Project-level Server Manager config directory helpers."""
|
||||
|
||||
from .shared import *
|
||||
|
||||
def get_server_manager_config_dir(server_root: Union[str, Path]) -> Path:
|
||||
"""工具托管的项目配置目录:项目根/.server_manager/config(default.kv、tool.config、模板等)。"""
|
||||
return Path(server_root).resolve() / '.server_manager' / 'config'
|
||||
|
||||
|
||||
def get_bundled_tool_config_template_dir() -> Path:
|
||||
"""软件自带的 config 模板目录(安装后与 main.exe 同级下的 config/)。
|
||||
源码运行时使用仓库根目录的 resources/config(与安装包内容一致)。
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).resolve().parent / 'config'
|
||||
return Path(__file__).resolve().parents[3] / 'resources' / 'config'
|
||||
|
||||
|
||||
def sync_server_manager_config_templates(project_root: Path) -> Tuple[bool, str]:
|
||||
"""将软件自带的受管模板文件同步到项目 .server_manager/config。"""
|
||||
project_root = Path(project_root).resolve()
|
||||
src = get_bundled_tool_config_template_dir()
|
||||
dst = get_server_manager_config_dir(project_root)
|
||||
|
||||
if not src.is_dir():
|
||||
return False, (
|
||||
f'找不到软件自带的配置模板目录: {src}\n'
|
||||
'请确认安装目录下存在 config 文件夹,或开发环境已准备 resources/config。'
|
||||
)
|
||||
if not (src / 'default.kv').exists():
|
||||
return False, (
|
||||
f'软件配置模板不完整(缺少 default.kv): {src}'
|
||||
)
|
||||
|
||||
try:
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for file_name in MANAGED_CONFIG_TEMPLATE_FILES:
|
||||
src_file = src / file_name
|
||||
if not src_file.exists():
|
||||
return False, f'软件配置模板不完整(缺少 {file_name}): {src}'
|
||||
shutil.copy2(src_file, dst / file_name)
|
||||
except Exception as e:
|
||||
return False, f'同步软件配置模板到项目失败: {e}'
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def ensure_server_manager_config(project_root: Path) -> Tuple[bool, str]:
|
||||
"""确保项目存在 .server_manager/config,并同步受管模板文件。"""
|
||||
project_root = Path(project_root).resolve()
|
||||
sm = get_server_manager_config_dir(project_root)
|
||||
|
||||
try:
|
||||
if not sm.is_dir():
|
||||
(project_root / '.server_manager').mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return False, f'创建项目配置目录失败: {e}'
|
||||
|
||||
return sync_server_manager_config_templates(project_root)
|
||||
|
||||
|
||||
def ensure_and_get_server_manager_config_dir(project_root: Path) -> Tuple[Optional[Path], str]:
|
||||
"""确保存在工具配置目录且含 default.kv;成功返回该目录 Path。"""
|
||||
ok, err = ensure_server_manager_config(project_root)
|
||||
if not ok:
|
||||
return None, err
|
||||
cfg = get_server_manager_config_dir(project_root)
|
||||
if not (cfg / 'default.kv').exists():
|
||||
return None, (
|
||||
'项目配置不完整:.server_manager/config 下缺少 default.kv'
|
||||
'(请检查软件安装目录下 config 模板是否完整)'
|
||||
)
|
||||
return cfg, ""
|
||||
|
||||
|
||||
def project_has_default_kv_for_manager(project_root: Union[str, Path]) -> bool:
|
||||
"""用于最近项目列表等:是否存在可识别的 default.kv(含尚未迁移的 config/)。"""
|
||||
p = Path(project_root)
|
||||
return (
|
||||
(get_server_manager_config_dir(p) / 'default.kv').exists()
|
||||
or (p / 'config' / 'default.kv').exists()
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared imports and constants for server command services."""
|
||||
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
# 平台检测
|
||||
IS_WINDOWS = platform.system() == 'Windows'
|
||||
IS_LINUX = platform.system() == 'Linux'
|
||||
|
||||
# rebar3 命令:Linux 使用当前目录下的 ./rebar3,Windows 使用 rebar3.cmd
|
||||
REBAR3_CMD = 'rebar3.cmd' if IS_WINDOWS else './rebar3'
|
||||
|
||||
MANAGED_CONFIG_TEMPLATE_FILES = (
|
||||
'default.kv',
|
||||
'sys_center.config.example',
|
||||
'sys_client.config.example',
|
||||
'sys_cross.config.example',
|
||||
'sys_game.config.example',
|
||||
'sys_login.config.example',
|
||||
)
|
||||
@@ -0,0 +1,601 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Terminal launch and foreground command helpers."""
|
||||
|
||||
from .shared import *
|
||||
from .erlang import _ensure_epmd_daemon
|
||||
|
||||
def _batch_echo_text(text: str) -> str:
|
||||
"""Escape cmd.exe metacharacters for display-only echo lines in a .bat file."""
|
||||
escaped = str(text).replace('^', '^^')
|
||||
for char in ('&', '|', '<', '>', '(', ')'):
|
||||
escaped = escaped.replace(char, f'^{char}')
|
||||
escaped = escaped.replace('%', '%%')
|
||||
return escaped
|
||||
|
||||
|
||||
def _create_windows_command_batch(cmd: str, working_dir: str, window_title: str) -> str:
|
||||
"""Create the temporary .bat used by both cmd.exe windows and Windows Terminal tabs."""
|
||||
safe_title = _batch_echo_text(window_title)
|
||||
safe_cmd_for_echo = _batch_echo_text(cmd)
|
||||
bat_content = f'''@echo off
|
||||
title {safe_title}
|
||||
cd /d "{working_dir}"
|
||||
set ESCRIPT_EMULATOR=erl
|
||||
echo ========================================
|
||||
echo Command: {safe_cmd_for_echo}
|
||||
echo ========================================
|
||||
{cmd}
|
||||
'''
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False, encoding='utf-8') as f:
|
||||
f.write(bat_content)
|
||||
return f.name
|
||||
|
||||
|
||||
def _normalize_windows_terminal_mode(mode: Optional[str] = None) -> str:
|
||||
raw = (mode or os.environ.get('SERVER_MANAGER_TERMINAL') or 'auto').strip().lower()
|
||||
aliases = {
|
||||
'': 'auto',
|
||||
'default': 'auto',
|
||||
'wt': 'wt',
|
||||
'windows_terminal': 'wt',
|
||||
'windows-terminal': 'wt',
|
||||
'tabbed': 'wt',
|
||||
'classic': 'cmd',
|
||||
'console': 'cmd',
|
||||
'cmd.exe': 'cmd',
|
||||
'powershell.exe': 'powershell',
|
||||
'ps': 'powershell',
|
||||
'pwsh': 'pwsh',
|
||||
'pwsh.exe': 'pwsh',
|
||||
'custom': 'other',
|
||||
}
|
||||
normalized = aliases.get(raw, raw)
|
||||
if normalized in {'auto', 'wt', 'cmd', 'powershell', 'pwsh', 'other'}:
|
||||
return normalized
|
||||
return 'auto'
|
||||
|
||||
|
||||
def _find_windows_terminal_executable() -> Optional[str]:
|
||||
"""Locate wt.exe without assuming the WindowsApps alias directory is on PATH."""
|
||||
candidates: List[Optional[Union[str, Path]]] = [
|
||||
shutil.which('wt.exe'),
|
||||
shutil.which('wt'),
|
||||
]
|
||||
local_app_data = os.environ.get('LOCALAPPDATA')
|
||||
if local_app_data:
|
||||
candidates.append(Path(local_app_data) / 'Microsoft' / 'WindowsApps' / 'wt.exe')
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
path = Path(candidate)
|
||||
if path.exists():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def _ps_single_quoted(value: str) -> str:
|
||||
"""Quote a string as a PowerShell single-quoted literal."""
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _resolve_windows_powershell_executable(powershell_exe: str) -> Optional[str]:
|
||||
"""Resolve a PowerShell executable, with Windows PowerShell as the stable fallback."""
|
||||
raw = str(powershell_exe or '').strip()
|
||||
candidates: List[Optional[Union[str, Path]]] = []
|
||||
|
||||
if raw:
|
||||
expanded = os.path.expandvars(os.path.expanduser(raw.strip('"')))
|
||||
raw_path = Path(expanded)
|
||||
if raw_path.is_absolute():
|
||||
candidates.append(raw_path)
|
||||
candidates.append(shutil.which(raw))
|
||||
|
||||
wants_pwsh = raw.lower() in {'pwsh', 'pwsh.exe'}
|
||||
if wants_pwsh:
|
||||
candidates.extend([shutil.which('pwsh.exe'), shutil.which('pwsh')])
|
||||
|
||||
candidates.extend([shutil.which('powershell.exe'), shutil.which('powershell')])
|
||||
system_root = os.environ.get('SystemRoot') or os.environ.get('WINDIR')
|
||||
if system_root:
|
||||
candidates.append(
|
||||
Path(system_root) / 'System32' / 'WindowsPowerShell' / 'v1.0' / 'powershell.exe'
|
||||
)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
path = Path(candidate)
|
||||
if path.exists():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_windows_custom_terminal_executable(configured_exe: Optional[str]) -> str:
|
||||
raw = str(configured_exe or '').strip()
|
||||
if not raw:
|
||||
raw = (
|
||||
os.environ.get('SERVER_MANAGER_TERMINAL_EXE', '')
|
||||
or os.environ.get('SERVER_MANAGER_TERMINAL_COMMAND', '')
|
||||
).strip()
|
||||
if not raw:
|
||||
raise ValueError('启动方式为“其他”时,请先配置运行 EXE')
|
||||
|
||||
expanded = os.path.expandvars(os.path.expanduser(raw.strip('"')))
|
||||
path = Path(expanded)
|
||||
if path.is_absolute() or any(sep in expanded for sep in ('/', '\\')):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f'运行 EXE 不存在: {expanded}')
|
||||
return str(path.resolve())
|
||||
|
||||
legacy_cwd = os.environ.get('SERVER_MANAGER_TERMINAL_CWD', '').strip()
|
||||
if legacy_cwd:
|
||||
legacy_path = Path(os.path.expandvars(os.path.expanduser(legacy_cwd))) / raw
|
||||
if legacy_path.is_file():
|
||||
return str(legacy_path.resolve())
|
||||
|
||||
found = shutil.which(raw)
|
||||
if found:
|
||||
return found
|
||||
raise FileNotFoundError(f'未在 PATH 中找到运行 EXE: {raw}')
|
||||
|
||||
|
||||
def _windows_short_path(path: str) -> str:
|
||||
"""Return an 8.3 path when Windows can provide one; fall back to the original path."""
|
||||
if not IS_WINDOWS:
|
||||
return path
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
get_short_path_name = ctypes.windll.kernel32.GetShortPathNameW
|
||||
get_short_path_name.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
|
||||
get_short_path_name.restype = wintypes.DWORD
|
||||
size = get_short_path_name(path, None, 0)
|
||||
if size == 0:
|
||||
return path
|
||||
buffer = ctypes.create_unicode_buffer(size + 1)
|
||||
result = get_short_path_name(path, buffer, size + 1)
|
||||
return buffer.value if result else path
|
||||
except Exception:
|
||||
return path
|
||||
|
||||
|
||||
def _mobaxterm_posix_path(path: str) -> str:
|
||||
"""Convert a Windows path to the /drives/x form used by MobaXterm local shell."""
|
||||
raw = str(path).replace('\\', '/')
|
||||
if len(raw) >= 3 and raw[1:3] == ':/':
|
||||
drive = raw[0].lower()
|
||||
rest = raw[3:].lstrip('/')
|
||||
while '//' in rest:
|
||||
rest = rest.replace('//', '/')
|
||||
return f'/drives/{drive}/{rest}'
|
||||
return raw
|
||||
|
||||
|
||||
def _windows_slash_path(path: str) -> str:
|
||||
return str(path).replace('\\', '/')
|
||||
|
||||
|
||||
def _sh_single_quoted(value: str) -> str:
|
||||
return "'" + str(value).replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _build_windows_custom_terminal_args(exe_path: str, bat_path: str) -> List[str]:
|
||||
"""Build argv for known terminal executables selected through the simplified EXE setting."""
|
||||
name = Path(exe_path).name.lower()
|
||||
if name in {'wt.exe', 'wt'}:
|
||||
return [exe_path, 'new-tab', 'cmd', '/k', bat_path]
|
||||
if name in {'cmd.exe', 'cmd'}:
|
||||
return [exe_path, '/k', bat_path]
|
||||
return [exe_path, bat_path]
|
||||
|
||||
|
||||
def _create_windows_powershell_batch_runner(
|
||||
bat_path: str,
|
||||
working_dir: str,
|
||||
window_title: str,
|
||||
cmd: Optional[str] = None,
|
||||
) -> str:
|
||||
display_cmd = cmd or str(Path(bat_path).resolve())
|
||||
ps_content = f'''$ErrorActionPreference = 'Continue'
|
||||
try {{
|
||||
$Host.UI.RawUI.WindowTitle = {_ps_single_quoted(window_title)}
|
||||
}} catch {{}}
|
||||
Set-Location -LiteralPath {_ps_single_quoted(working_dir)}
|
||||
$env:ESCRIPT_EMULATOR = 'erl'
|
||||
Write-Host '========================================'
|
||||
Write-Host ('Command: ' + {_ps_single_quoted(display_cmd)})
|
||||
Write-Host ('WorkingDir: ' + {_ps_single_quoted(working_dir)})
|
||||
Write-Host '========================================'
|
||||
$batPath = {_ps_single_quoted(bat_path)}
|
||||
$cmdExe = $env:ComSpec
|
||||
if ([string]::IsNullOrWhiteSpace($cmdExe)) {{
|
||||
$cmdExe = 'cmd.exe'
|
||||
}}
|
||||
& $cmdExe /c $batPath
|
||||
'''
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.ps1', delete=False, encoding='utf-8-sig') as f:
|
||||
f.write(ps_content)
|
||||
return f.name
|
||||
|
||||
|
||||
def _create_mobaxterm_launcher_script(
|
||||
powershell_exe_for_moba: str,
|
||||
ps_script_for_windows: str,
|
||||
working_dir_for_moba: str,
|
||||
) -> str:
|
||||
"""Create the MobaXterm local-shell script that launches Windows PowerShell."""
|
||||
sh_content = (
|
||||
"#!/bin/sh\n"
|
||||
f"cd {_sh_single_quoted(working_dir_for_moba)} || exit 1\n"
|
||||
f"exec {_sh_single_quoted(powershell_exe_for_moba)} "
|
||||
"-NoLogo -NoProfile -NoExit -ExecutionPolicy Bypass "
|
||||
f"-File {_sh_single_quoted(ps_script_for_windows)}\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.sh',
|
||||
delete=False,
|
||||
encoding='utf-8',
|
||||
newline='\n',
|
||||
) as f:
|
||||
f.write(sh_content)
|
||||
return f.name
|
||||
|
||||
|
||||
def _build_windows_mobaxterm_args(
|
||||
exe_path: str,
|
||||
bat_path: str,
|
||||
working_dir: str,
|
||||
window_title: str,
|
||||
cmd: str,
|
||||
) -> List[str]:
|
||||
ps_path = _create_windows_powershell_batch_runner(bat_path, working_dir, window_title, cmd)
|
||||
powershell_exe = _resolve_windows_powershell_executable('powershell.exe') or 'powershell.exe'
|
||||
ps_exe_for_moba = _mobaxterm_posix_path(powershell_exe)
|
||||
ps_script_for_windows = _windows_slash_path(_windows_short_path(ps_path))
|
||||
workdir_for_moba = _mobaxterm_posix_path(working_dir)
|
||||
launcher_path = _create_mobaxterm_launcher_script(
|
||||
ps_exe_for_moba,
|
||||
ps_script_for_windows,
|
||||
workdir_for_moba,
|
||||
)
|
||||
launcher_for_moba = _mobaxterm_posix_path(_windows_short_path(launcher_path))
|
||||
return [exe_path, '-newtab', f'sh {_sh_single_quoted(launcher_for_moba)}']
|
||||
|
||||
|
||||
def _read_pid_file(pid_file: str, timeout: float = 3.0) -> Optional[int]:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
text = Path(pid_file).read_text(encoding='ascii', errors='ignore').strip()
|
||||
if text:
|
||||
return int(text.splitlines()[0].strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
def _launch_windows_cmd_console(bat_path: str, working_dir: str) -> str:
|
||||
"""Launch the temp batch in a classic cmd.exe window."""
|
||||
CREATE_NEW_CONSOLE = 0x00000010
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
['cmd', '/k', bat_path],
|
||||
creationflags=CREATE_NEW_CONSOLE,
|
||||
cwd=working_dir,
|
||||
)
|
||||
return f"winpid:{proc.pid}"
|
||||
except Exception:
|
||||
# 兜底:回到经典方式(失去 PID 追踪能力,关闭只能按标题)
|
||||
subprocess.Popen(f'start cmd /k "{bat_path}"', shell=True)
|
||||
return bat_path
|
||||
|
||||
|
||||
def _launch_windows_powershell_console(
|
||||
bat_path: str,
|
||||
working_dir: str,
|
||||
window_title: str,
|
||||
powershell_exe: str = 'powershell.exe',
|
||||
) -> str:
|
||||
"""Launch the temp batch in a PowerShell window and keep it open."""
|
||||
resolved_powershell = _resolve_windows_powershell_executable(powershell_exe)
|
||||
if not resolved_powershell:
|
||||
return _launch_windows_cmd_console(bat_path, working_dir)
|
||||
|
||||
ps_content = f'''$ErrorActionPreference = 'Continue'
|
||||
try {{
|
||||
$Host.UI.RawUI.WindowTitle = {_ps_single_quoted(window_title)}
|
||||
}} catch {{}}
|
||||
Set-Location -LiteralPath {_ps_single_quoted(working_dir)}
|
||||
$env:ESCRIPT_EMULATOR = 'erl'
|
||||
Write-Host '========================================'
|
||||
Write-Host ('Command: ' + {_ps_single_quoted(str(Path(bat_path).resolve()))})
|
||||
Write-Host '========================================'
|
||||
$batPath = {_ps_single_quoted(bat_path)}
|
||||
$cmdExe = $env:ComSpec
|
||||
if ([string]::IsNullOrWhiteSpace($cmdExe)) {{
|
||||
$cmdExe = 'cmd.exe'
|
||||
}}
|
||||
& $cmdExe /k $batPath
|
||||
'''
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.ps1', delete=False, encoding='utf-8-sig') as f:
|
||||
f.write(ps_content)
|
||||
ps_path = f.name
|
||||
|
||||
CREATE_NEW_CONSOLE = 0x00000010
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
resolved_powershell,
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NoExit',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', ps_path,
|
||||
],
|
||||
creationflags=CREATE_NEW_CONSOLE,
|
||||
cwd=working_dir,
|
||||
)
|
||||
return f"winpid:{proc.pid}"
|
||||
except Exception:
|
||||
return _launch_windows_cmd_console(bat_path, working_dir)
|
||||
|
||||
|
||||
def _launch_windows_custom_terminal(
|
||||
bat_path: str,
|
||||
working_dir: str,
|
||||
window_title: str,
|
||||
cmd: str,
|
||||
terminal_exe: Optional[str] = None,
|
||||
) -> str:
|
||||
del cmd
|
||||
exe_path = _resolve_windows_custom_terminal_executable(terminal_exe)
|
||||
exe_name = Path(exe_path).name.lower()
|
||||
if 'mobaxterm' in exe_name:
|
||||
args = _build_windows_mobaxterm_args(exe_path, bat_path, working_dir, window_title, cmd)
|
||||
else:
|
||||
args = _build_windows_custom_terminal_args(exe_path, bat_path)
|
||||
launch_cwd = working_dir if Path(working_dir).is_dir() else str(Path(exe_path).resolve().parent)
|
||||
CREATE_NEW_CONSOLE = 0x00000010
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
creationflags=CREATE_NEW_CONSOLE,
|
||||
cwd=launch_cwd,
|
||||
)
|
||||
return f"winpid:{proc.pid}"
|
||||
|
||||
|
||||
def _launch_windows_terminal_tab(bat_path: str, working_dir: str, window_title: str,
|
||||
mode: Optional[str] = None) -> Optional[str]:
|
||||
"""Launch the temp batch in a named Windows Terminal window as a new tab.
|
||||
|
||||
The tab runs through a small PowerShell wrapper so the tool can capture a
|
||||
real process PID and later close this tab's process tree without killing the
|
||||
whole Windows Terminal window.
|
||||
"""
|
||||
mode = _normalize_windows_terminal_mode(mode)
|
||||
if mode in {'cmd', 'powershell', 'pwsh', 'other'}:
|
||||
return None
|
||||
|
||||
wt_exe = _find_windows_terminal_executable()
|
||||
if not wt_exe:
|
||||
if mode == 'wt':
|
||||
raise FileNotFoundError('未找到 Windows Terminal (wt.exe)')
|
||||
return None
|
||||
|
||||
pid_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pid')
|
||||
pid_file.close()
|
||||
ps_content = f'''$ErrorActionPreference = 'Continue'
|
||||
try {{
|
||||
Set-Content -LiteralPath {_ps_single_quoted(pid_file.name)} -Value $PID -Encoding ascii
|
||||
}} catch {{}}
|
||||
try {{
|
||||
$Host.UI.RawUI.WindowTitle = {_ps_single_quoted(window_title)}
|
||||
}} catch {{}}
|
||||
Set-Location -LiteralPath {_ps_single_quoted(working_dir)}
|
||||
$env:ESCRIPT_EMULATOR = 'erl'
|
||||
Write-Host '========================================'
|
||||
Write-Host ('Command: ' + {_ps_single_quoted(str(Path(bat_path).resolve()))})
|
||||
Write-Host '========================================'
|
||||
& $env:ComSpec /k {_ps_single_quoted(bat_path)}
|
||||
'''
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.ps1', delete=False, encoding='utf-8-sig') as f:
|
||||
f.write(ps_content)
|
||||
ps_path = f.name
|
||||
|
||||
window_id = os.environ.get('SERVER_MANAGER_WT_WINDOW_ID', 'ServerManager')
|
||||
args = [
|
||||
wt_exe,
|
||||
'-w', window_id,
|
||||
'new-tab',
|
||||
'--title', window_title,
|
||||
'--suppressApplicationTitle',
|
||||
'-d', working_dir,
|
||||
'powershell.exe',
|
||||
'-NoLogo',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', ps_path,
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=working_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
pid = _read_pid_file(pid_file.name)
|
||||
if pid is not None:
|
||||
try:
|
||||
os.unlink(pid_file.name)
|
||||
except OSError:
|
||||
pass
|
||||
return f"wtpid:{pid}"
|
||||
|
||||
# If wt rejected the command line quickly, let the caller fall back to cmd.
|
||||
try:
|
||||
if proc.poll() not in (None, 0):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return f"wttab:{window_title}"
|
||||
|
||||
|
||||
def run_cmd_in_new_window(cmd: str, working_dir: str, window_title: str = "Server",
|
||||
erl_path: str = None,
|
||||
terminal_mode: str = None,
|
||||
terminal_exe_path: str = None) -> str:
|
||||
"""在新的终端窗口中执行命令
|
||||
|
||||
Windows: 可配置 Windows Terminal/cmd/PowerShell/其他启动方式
|
||||
Linux: 直接在当前终端执行,或使用 screen/tmux(如果可用)
|
||||
|
||||
Args:
|
||||
cmd: 要执行的命令
|
||||
working_dir: 工作目录
|
||||
window_title: 窗口标题
|
||||
erl_path: Erlang 安装路径(命令中已包含完整路径,此参数保留兼容)
|
||||
|
||||
Returns:
|
||||
脚本文件路径(Windows)或空字符串(Linux)
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
bat_path = _create_windows_command_batch(cmd, working_dir, window_title)
|
||||
mode = _normalize_windows_terminal_mode(terminal_mode)
|
||||
|
||||
if mode == 'other':
|
||||
try:
|
||||
return _launch_windows_custom_terminal(
|
||||
bat_path,
|
||||
working_dir,
|
||||
window_title,
|
||||
cmd,
|
||||
terminal_exe_path,
|
||||
)
|
||||
except Exception:
|
||||
return _launch_windows_cmd_console(bat_path, working_dir)
|
||||
|
||||
if mode in {'powershell', 'pwsh'}:
|
||||
ps_exe = 'pwsh.exe' if mode == 'pwsh' else 'powershell.exe'
|
||||
return _launch_windows_powershell_console(bat_path, working_dir, window_title, ps_exe)
|
||||
|
||||
if mode == 'cmd':
|
||||
return _launch_windows_cmd_console(bat_path, working_dir)
|
||||
|
||||
# 优先使用 Windows Terminal 的命名窗口页签;没有 wt.exe 或启动失败时,
|
||||
# 回退到原来的独立 cmd.exe 窗口。
|
||||
try:
|
||||
launch_info = _launch_windows_terminal_tab(bat_path, working_dir, window_title, mode)
|
||||
if launch_info:
|
||||
return launch_info
|
||||
except Exception:
|
||||
if mode == 'wt':
|
||||
raise
|
||||
|
||||
# 使用 CREATE_NEW_CONSOLE 启动新控制台窗口,Popen.pid 即为 cmd.exe 的真实 PID。
|
||||
# 便于后续 taskkill /F /T /PID 精确关闭。
|
||||
return _launch_windows_cmd_console(bat_path, working_dir)
|
||||
else:
|
||||
# Linux: 直接在终端执行
|
||||
return run_cmd_in_terminal_linux(cmd, working_dir, window_title, erl_path)
|
||||
|
||||
|
||||
def run_cmd_in_terminal_linux(cmd: str, working_dir: str, window_title: str = "Server",
|
||||
erl_path: str = None) -> str:
|
||||
"""在 Linux 终端中执行命令
|
||||
|
||||
优先使用 screen,其次使用 tmux,最后直接在前台执行
|
||||
|
||||
Args:
|
||||
cmd: 要执行的命令
|
||||
working_dir: 工作目录
|
||||
window_title: 窗口/会话名称
|
||||
erl_path: Erlang 安装路径(命令中已包含完整路径,此参数保留兼容)
|
||||
|
||||
Returns:
|
||||
启动方式描述
|
||||
"""
|
||||
# 检查是否有 screen
|
||||
has_screen = subprocess.run(['which', 'screen'], capture_output=True).returncode == 0
|
||||
# 检查是否有 tmux
|
||||
has_tmux = subprocess.run(['which', 'tmux'], capture_output=True).returncode == 0
|
||||
|
||||
# 生成 shell 脚本
|
||||
script_content = f'''#!/bin/bash
|
||||
cd "{working_dir}"
|
||||
export ESCRIPT_EMULATOR=erl
|
||||
echo "========================================"
|
||||
echo "Command: {cmd}"
|
||||
echo "========================================"
|
||||
{cmd}
|
||||
'''
|
||||
|
||||
# 写入临时脚本文件
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False, encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
script_path = f.name
|
||||
|
||||
# 赋予执行权限
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
if has_screen:
|
||||
# 使用 screen 在后台启动
|
||||
session_name = window_title.replace(' ', '_').replace('.', '_')
|
||||
subprocess.Popen(
|
||||
['screen', '-dmS', session_name, 'bash', script_path],
|
||||
cwd=working_dir
|
||||
)
|
||||
return f"screen:{session_name}"
|
||||
elif has_tmux:
|
||||
# 使用 tmux 在后台启动
|
||||
session_name = window_title.replace(' ', '_').replace('.', '_')
|
||||
subprocess.Popen(
|
||||
['tmux', 'new-session', '-d', '-s', session_name, f'bash {script_path}'],
|
||||
cwd=working_dir
|
||||
)
|
||||
return f"tmux:{session_name}"
|
||||
else:
|
||||
# 直接在后台执行
|
||||
subprocess.Popen(
|
||||
['bash', script_path],
|
||||
cwd=working_dir,
|
||||
start_new_session=True
|
||||
)
|
||||
return f"background:{script_path}"
|
||||
|
||||
|
||||
def run_cmd_foreground(cmd: str, working_dir: str, window_title: str = "Server") -> subprocess.Popen:
|
||||
"""在前台执行命令(跨平台)
|
||||
|
||||
用于非交互类前台命令(compile 等)和 Linux 下的所有前台命令。
|
||||
Windows 上 rebar3 shell 等交互式命令应由调用方走 run_cmd_in_new_window(start cmd /k)。
|
||||
|
||||
Args:
|
||||
cmd: 要执行的命令
|
||||
working_dir: 工作目录
|
||||
window_title: 显示标题
|
||||
|
||||
Returns:
|
||||
Popen 进程对象
|
||||
"""
|
||||
print(f"========================================")
|
||||
print(f"[{window_title}]")
|
||||
print(f"工作目录: {working_dir}")
|
||||
print(f"命令: {cmd}")
|
||||
print(f"========================================")
|
||||
|
||||
env = os.environ.copy()
|
||||
env['ESCRIPT_EMULATOR'] = 'erl'
|
||||
|
||||
if IS_WINDOWS:
|
||||
process = subprocess.Popen(cmd, shell=True, cwd=working_dir, env=env)
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
['bash', '-c', cmd],
|
||||
cwd=working_dir,
|
||||
env=env
|
||||
)
|
||||
|
||||
return process
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tracking and closing windows launched by the manager."""
|
||||
|
||||
from .shared import *
|
||||
|
||||
_launched_windows: Dict[str, List[Dict[str, Any]]] = {}
|
||||
_launched_windows_lock = threading.Lock()
|
||||
|
||||
|
||||
def _normalize_server_key(server_name: str) -> str:
|
||||
"""返回不含 @ip 的服务器基础名称。"""
|
||||
if not server_name:
|
||||
return ''
|
||||
return server_name.split('@', 1)[0]
|
||||
|
||||
|
||||
def register_launched_window(server_name: str, kind: str, window_title: str,
|
||||
launch_info: str = '') -> None:
|
||||
"""注册通过本工具启动的窗口。
|
||||
|
||||
Args:
|
||||
server_name: 关联的服务器名(如 xxx_game_s1 或 xxx_game_s1@1.2.3.4)
|
||||
kind: 'server'(服务进程窗口)或 'remsh'(远程连接窗口)
|
||||
window_title: 窗口标题(Windows 下用 taskkill 依据此标题关闭)
|
||||
launch_info: 启动方式信息,Linux 下形如 'screen:xxx' / 'tmux:xxx'
|
||||
"""
|
||||
key = _normalize_server_key(server_name)
|
||||
if not key:
|
||||
return
|
||||
entry = {
|
||||
'kind': kind,
|
||||
'title': window_title,
|
||||
'launch_info': launch_info or '',
|
||||
}
|
||||
with _launched_windows_lock:
|
||||
_launched_windows.setdefault(key, []).append(entry)
|
||||
|
||||
|
||||
def get_launched_windows(server_name: str) -> List[Dict[str, Any]]:
|
||||
"""获取与该服务器关联的所有已记录窗口条目。"""
|
||||
key = _normalize_server_key(server_name)
|
||||
with _launched_windows_lock:
|
||||
return list(_launched_windows.get(key, []))
|
||||
|
||||
|
||||
def _close_by_title_windows(title: str) -> Tuple[bool, str]:
|
||||
"""Windows 下按窗口标题强制关闭匹配的 cmd.exe。
|
||||
|
||||
考虑 cmd.exe 以管理员运行时系统会自动给标题加上"管理员:"/"Administrator:"前缀,
|
||||
我们按几种可能的前缀逐个尝试匹配。
|
||||
"""
|
||||
candidates = [
|
||||
title,
|
||||
f'管理员: {title}',
|
||||
f'管理员:{title}',
|
||||
f'Administrator: {title}',
|
||||
]
|
||||
last_msg = ''
|
||||
for t in candidates:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['taskkill', '/F', '/T', '/FI', f'WINDOWTITLE eq {t}*'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except Exception as e:
|
||||
last_msg = f'关闭窗口异常: {e}'
|
||||
continue
|
||||
stdout = (result.stdout or '').strip()
|
||||
# returncode=0 且 stdout 里含 "SUCCESS"/"成功" 表明确实杀到了进程
|
||||
if result.returncode == 0 and stdout:
|
||||
low = stdout.lower()
|
||||
if 'success' in low or '成功' in stdout:
|
||||
return (True, f'已关闭窗口: {t}')
|
||||
last_msg = f'未匹配到窗口标题: {t}'
|
||||
# 全部尝试完仍未命中(可能用户已手动关闭),也当作成功但给出提示
|
||||
return (True, f'未找到匹配窗口(可能已关闭): {title}')
|
||||
|
||||
|
||||
def _close_single_window(entry: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
"""关闭单个窗口条目。"""
|
||||
title = entry.get('title', '') or ''
|
||||
launch_info = entry.get('launch_info', '') or ''
|
||||
|
||||
if IS_WINDOWS:
|
||||
# 优先按 PID 精确关闭(不受管理员标题前缀影响,且能通过 /T 杀掉整个进程树)
|
||||
if launch_info.startswith('winpid:') or launch_info.startswith('wtpid:'):
|
||||
try:
|
||||
pid_str = launch_info.split(':', 1)[1]
|
||||
pid = int(pid_str)
|
||||
result = subprocess.run(
|
||||
['taskkill', '/F', '/T', '/PID', str(pid)],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return (True, f'已关闭窗口 (PID={pid}): {title}')
|
||||
# 128: 未找到进程(可能已退出),视为成功
|
||||
return (True, f'进程已不存在 (PID={pid}): {title}')
|
||||
except Exception as e:
|
||||
# 回退到标题匹配
|
||||
logger_msg = f'按 PID 关闭失败: {e},尝试按标题匹配'
|
||||
ok, msg = _close_by_title_windows(title)
|
||||
return (ok, f'{logger_msg};{msg}')
|
||||
|
||||
if launch_info.startswith('wttab:'):
|
||||
return (True, f'Windows Terminal 页签已启动但未取得 PID,跳过自动关闭: {title}')
|
||||
|
||||
# 兜底:按窗口标题匹配(可能被"管理员:"等前缀影响)
|
||||
if not title:
|
||||
return (False, '无窗口标题,无法关闭')
|
||||
return _close_by_title_windows(title)
|
||||
else:
|
||||
try:
|
||||
if launch_info.startswith('screen:'):
|
||||
session = launch_info.split(':', 1)[1]
|
||||
subprocess.run(['screen', '-X', '-S', session, 'quit'],
|
||||
capture_output=True, timeout=5)
|
||||
return (True, f'已关闭 screen 会话: {session}')
|
||||
if launch_info.startswith('tmux:'):
|
||||
session = launch_info.split(':', 1)[1]
|
||||
subprocess.run(['tmux', 'kill-session', '-t', session],
|
||||
capture_output=True, timeout=5)
|
||||
return (True, f'已关闭 tmux 会话: {session}')
|
||||
if launch_info.startswith('background:'):
|
||||
return (True, '后台进程无独立控制台,跳过')
|
||||
return (False, f'未知启动方式: {launch_info}')
|
||||
except Exception as e:
|
||||
return (False, f'关闭会话异常: {e}')
|
||||
|
||||
|
||||
def close_launched_windows(server_name: str) -> List[Tuple[Dict[str, Any], bool, str]]:
|
||||
"""关闭与该服务器关联的所有已记录窗口(一次性取出并清空记录)。
|
||||
|
||||
Returns:
|
||||
列表,每项为 (entry, is_success, message)
|
||||
"""
|
||||
key = _normalize_server_key(server_name)
|
||||
with _launched_windows_lock:
|
||||
entries = _launched_windows.pop(key, [])
|
||||
results: List[Tuple[Dict[str, Any], bool, str]] = []
|
||||
for entry in entries:
|
||||
ok, msg = _close_single_window(entry)
|
||||
results.append((entry, ok, msg))
|
||||
return results
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
自动更新模块:支持全量安装包与 bsdiff4 增量更新(Delta Update)
|
||||
|
||||
远端 version.json(或 update_url 指向的 JSON)格式:
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"release_notes": "修复了 UI 卡顿",
|
||||
"full_installer_url": "http://.../Setup_v1.1.0.exe",
|
||||
"delta_updates": {
|
||||
"1.0.0": {
|
||||
"patch_url": "http://.../v1.0.0_to_v1.1.0.patch",
|
||||
"new_exe_sha256": "abc123..."
|
||||
}
|
||||
}
|
||||
}
|
||||
- 若当前版本在 delta_updates 中,则优先使用增量(下载 patch,bsdiff4 打补丁后替换 exe)
|
||||
- 否则下载 full_installer_url 进行完整安装
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# 可选:requests 用于带进度的下载;若无则回退 urllib
|
||||
try:
|
||||
import requests
|
||||
_HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
_HAS_REQUESTS = False
|
||||
|
||||
try:
|
||||
import bsdiff4
|
||||
_HAS_BSDIFF = True
|
||||
except ImportError:
|
||||
_HAS_BSDIFF = False
|
||||
|
||||
|
||||
def _version_json_path() -> Path:
|
||||
"""获取 version.json 的路径。
|
||||
打包后:优先从安装目录(exe 同目录)读取,便于安装包展开的 version.json 生效;若无则从 MEIPASS 读取。
|
||||
未打包:从项目根目录或运行目录读取。
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
# 安装包会将 version.json 展开到 {app},与 main.exe 同目录
|
||||
install_dir = Path(sys.executable).resolve().parent
|
||||
external = install_dir / "version.json"
|
||||
if external.exists():
|
||||
return external
|
||||
return Path(sys._MEIPASS) / "version.json"
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
resource_version = project_root / "resources" / "version.json"
|
||||
if resource_version.exists():
|
||||
return resource_version
|
||||
return project_root / "version.json"
|
||||
|
||||
|
||||
def get_current_version() -> str:
|
||||
"""读取当前应用版本号"""
|
||||
path = _version_json_path()
|
||||
if not path.exists():
|
||||
path = Path.cwd() / "resources" / "version.json"
|
||||
if not path.exists():
|
||||
path = Path.cwd() / "version.json"
|
||||
if not path.exists():
|
||||
return "0.0.0"
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("version", "0.0.0").strip()
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def get_version_info() -> Dict[str, Any]:
|
||||
"""读取当前 version.json 完整内容"""
|
||||
path = _version_json_path()
|
||||
if not path.exists():
|
||||
path = Path.cwd() / "resources" / "version.json"
|
||||
if not path.exists():
|
||||
path = Path.cwd() / "version.json"
|
||||
if not path.exists():
|
||||
return {"version": "0.0.0", "release_notes": ""}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"version": "0.0.0", "release_notes": ""}
|
||||
|
||||
|
||||
def _parse_version(v: str) -> Tuple[int, ...]:
|
||||
"""将版本字符串转为可比较的元组"""
|
||||
parts = []
|
||||
for s in v.strip().replace("-", ".").split("."):
|
||||
s = "".join(c for c in s if c.isdigit())
|
||||
parts.append(int(s) if s else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def version_less(a: str, b: str) -> bool:
|
||||
"""True 表示 a < b(有可用更新)"""
|
||||
return _parse_version(a) < _parse_version(b)
|
||||
|
||||
|
||||
def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
从 update_url 拉取更新清单,解析新格式:
|
||||
version, release_notes, full_installer_url, delta_updates { "from_ver": { patch_url, new_exe_sha256 } }
|
||||
兼容旧格式:download_url 视为 full_installer_url。
|
||||
"""
|
||||
url = update_url.strip().rstrip("/")
|
||||
if not url.lower().startswith("http"):
|
||||
return None
|
||||
if not url.lower().endswith(".json"):
|
||||
url = url + "/version.json" if not url.endswith("version.json") else url
|
||||
try:
|
||||
if _HAS_REQUESTS:
|
||||
r = requests.get(url, headers={"User-Agent": "ServerManager-Updater/1.0"}, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
else:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if isinstance(data, dict) and "version" in data:
|
||||
if "full_installer_url" not in data and "download_url" in data:
|
||||
data["full_installer_url"] = data["download_url"]
|
||||
return data
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
从 manifest 的 delta_updates 中取当前版本对应的增量信息。
|
||||
返回 { "patch_url": "...", "new_exe_sha256": "..." } 或 None。
|
||||
"""
|
||||
delta_updates = manifest.get("delta_updates") or {}
|
||||
if not isinstance(delta_updates, dict):
|
||||
return None
|
||||
d = delta_updates.get(current_version.strip())
|
||||
if isinstance(d, dict) and d.get("patch_url") and d.get("new_exe_sha256"):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest_path: Path,
|
||||
progress_callback: Optional[Any] = None,
|
||||
timeout: int = 60,
|
||||
) -> bool:
|
||||
"""下载文件到 dest_path,可选进度回调 progress_callback(percent: int)"""
|
||||
dest_path = Path(dest_path)
|
||||
try:
|
||||
if _HAS_REQUESTS:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"User-Agent": "ServerManager-Updater/1.0"},
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("Content-Length", 0)) or None
|
||||
read = 0
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest_path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
read += len(chunk)
|
||||
if progress_callback and total and total > 0:
|
||||
progress_callback(min(100, int(100 * read / total)))
|
||||
if progress_callback:
|
||||
progress_callback(100)
|
||||
return True
|
||||
else:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
total = int(resp.headers.get("Content-Length", 0)) or None
|
||||
data = b""
|
||||
read = 0
|
||||
chunk_size = 8192
|
||||
while True:
|
||||
part = resp.read(chunk_size)
|
||||
if not part:
|
||||
break
|
||||
data += part
|
||||
read += len(part)
|
||||
if progress_callback and total and total > 0:
|
||||
progress_callback(min(100, int(100 * read / total)))
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_path.write_bytes(data)
|
||||
if progress_callback:
|
||||
progress_callback(100)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def clean_up_old_version() -> None:
|
||||
"""
|
||||
启动时调用:删除当前 exe 所在目录下以 .old 结尾的残留文件,
|
||||
即上一次「重命名大法」更新退下来的旧程序,静默删除不报错。
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
install_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
install_dir = os.path.dirname(os.path.abspath(__file__)) or os.getcwd()
|
||||
try:
|
||||
for name in os.listdir(install_dir) or []:
|
||||
if name.endswith(".old"):
|
||||
path = os.path.join(install_dir, name)
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_update_log(install_dir: str, message: str) -> None:
|
||||
"""向安装目录下的 logs/update_launcher.log 写入,便于确认是否走了 mini_updater。"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
log_dir = os.path.join(install_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "update_launcher.log")
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write("[%s] %s\n" % (datetime.now().isoformat(), message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def apply_update_and_restart(
|
||||
new_exe_path: str,
|
||||
target_exe_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
独立进程外更新:拉起 mini_updater.exe,由其等待本进程退出后替换 exe 并以正确 cwd 重启。
|
||||
若 mini_updater.exe 不存在则回退到“死循环重试”的 .bat。
|
||||
调用后本进程会立即 os._exit(0),不会返回。
|
||||
"""
|
||||
new_exe_path = os.path.abspath(new_exe_path)
|
||||
current_exe_path = os.path.abspath(sys.executable)
|
||||
if not os.path.isfile(new_exe_path):
|
||||
return
|
||||
install_dir = os.path.normpath(os.path.dirname(current_exe_path))
|
||||
target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe"
|
||||
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
|
||||
mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
|
||||
if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
|
||||
try:
|
||||
pid = os.getpid()
|
||||
creationflags = 0x00000008 # DETACHED_PROCESS
|
||||
subprocess.Popen(
|
||||
[
|
||||
mini_updater_exe,
|
||||
"--pid", str(pid),
|
||||
"--install-dir", install_dir,
|
||||
"--new-exe-path", new_exe_path,
|
||||
"--target-exe-name", target_name,
|
||||
],
|
||||
cwd=install_dir,
|
||||
close_fds=True,
|
||||
creationflags=creationflags,
|
||||
shell=False,
|
||||
)
|
||||
_write_update_log(install_dir, "已拉起 mini_updater.exe (pid=%s)" % pid)
|
||||
os._exit(0)
|
||||
return
|
||||
except Exception as e:
|
||||
_write_update_log(install_dir, "拉起 mini_updater.exe 失败: %s,改用 .bat" % e)
|
||||
else:
|
||||
_write_update_log(
|
||||
install_dir,
|
||||
"mini_updater.exe 未找到 (路径: %s),使用 .bat 回退" % mini_updater_exe,
|
||||
)
|
||||
|
||||
# 回退:.bat 死循环重试(仅 Windows)
|
||||
tmp = tempfile.gettempdir()
|
||||
bat_path = os.path.join(tmp, "apply_update_%s.bat" % os.getpid())
|
||||
bat_lines = [
|
||||
"@echo off",
|
||||
":retry",
|
||||
'move /y "%s" "%s" >nul 2>&1' % (new_exe_path, current_exe_path),
|
||||
"if errorlevel 1 (",
|
||||
" ping 127.0.0.1 -n 2 >nul",
|
||||
" goto retry",
|
||||
")",
|
||||
'cd /d "%s"' % install_dir,
|
||||
'start "" "%s"' % current_exe_path,
|
||||
'del "%~f0"',
|
||||
]
|
||||
try:
|
||||
with open(bat_path, "w", encoding="utf-8") as f:
|
||||
f.write("\r\n".join(bat_lines))
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", bat_path],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if (sys.platform == "win32" and hasattr(subprocess, "CREATE_NO_WINDOW")) else 0,
|
||||
shell=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
使用 bsdiff4 应用增量补丁:当前 exe + patch -> 新 exe,校验 sha256 后在本进程内
|
||||
用「重命名大法」替换并拉起新进程再退出(无需 .bat 或 --do-replace)。
|
||||
仅在被 PyInstaller 打包为单文件 exe 时可用;非 frozen 时返回 (False, reason)。
|
||||
成功时本函数不会返回(进程会退出);返回 (False, "原因") 表示未执行或失败。
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return False, "当前未以打包方式运行"
|
||||
if not _HAS_BSDIFF:
|
||||
return False, "未包含 bsdiff4 组件,请使用全量更新"
|
||||
patch_path = Path(patch_path)
|
||||
if not patch_path.is_file():
|
||||
return False, "补丁文件不存在"
|
||||
current_exe_path = os.path.abspath(sys.executable)
|
||||
try:
|
||||
with open(current_exe_path, "rb") as f:
|
||||
old_data = f.read()
|
||||
with open(patch_path, "rb") as f:
|
||||
patch_data = f.read()
|
||||
new_data = bsdiff4.patch(old_data, patch_data)
|
||||
got_sha = hashlib.sha256(new_data).hexdigest().lower()
|
||||
expected_sha = (expected_new_sha256 or "").strip().lower()
|
||||
if expected_sha and got_sha != expected_sha:
|
||||
return False, "校验未通过(补丁结果与预期不一致),请使用全量更新"
|
||||
tmp = tempfile.gettempdir()
|
||||
temp_new_exe_path = os.path.join(tmp, "ServerManager_new.exe")
|
||||
with open(temp_new_exe_path, "wb") as f:
|
||||
f.write(new_data)
|
||||
if not os.path.isfile(temp_new_exe_path):
|
||||
return False, "写入临时 exe 失败"
|
||||
# 独立进程外更新器 或 回退 .bat
|
||||
apply_update_and_restart(temp_new_exe_path, target_exe_name=os.path.basename(current_exe_path))
|
||||
except Exception as e:
|
||||
return False, f"应用补丁时出错: {e!s},请使用全量更新"
|
||||
return False, "未知错误"
|
||||
|
||||
|
||||
def run_installer_and_exit(installer_path: Path, silent: bool = True) -> None:
|
||||
"""运行完整安装包并退出本进程。Windows 下 silent 时传 /VERYSILENT 等参数。"""
|
||||
path = str(Path(installer_path).resolve())
|
||||
if sys.platform == "win32":
|
||||
args = [path]
|
||||
if silent:
|
||||
args.extend(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/FORCECLOSEAPPLICATIONS"])
|
||||
subprocess.Popen(args, shell=False)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["open" if sys.platform == "darwin" else "xdg-open", path],
|
||||
shell=False,
|
||||
)
|
||||
if getattr(sys, "frozen", False):
|
||||
os._exit(0)
|
||||
else:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,266 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ripgrep (rg.exe) 封装:日志 stdin 过滤、目录内文件搜索。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
_RG_NAMES = ("rg.exe", "rg")
|
||||
|
||||
|
||||
def find_rg_executable() -> Optional[Path]:
|
||||
"""解析 rg 可执行文件;找不到返回 None。"""
|
||||
env = (os.environ.get("RG_PATH") or "").strip()
|
||||
if env:
|
||||
p = Path(env)
|
||||
if p.is_file():
|
||||
return p
|
||||
if getattr(sys, "frozen", False):
|
||||
base = Path(sys.executable).resolve().parent
|
||||
for name in _RG_NAMES:
|
||||
cand = base / name
|
||||
if cand.is_file():
|
||||
return cand
|
||||
# resources/bin/rg.exe(开发目录)
|
||||
here = Path(__file__).resolve().parent.parent
|
||||
project_root = here.parent
|
||||
for name in _RG_NAMES:
|
||||
cand = project_root / "resources" / "bin" / name
|
||||
if cand.is_file():
|
||||
return cand
|
||||
w = shutil.which("rg")
|
||||
if w:
|
||||
return Path(w)
|
||||
return None
|
||||
|
||||
|
||||
def _build_rg_regex_pattern(pattern: str, whole_word: bool) -> str:
|
||||
if whole_word:
|
||||
return rf"\b(?:{pattern})\b"
|
||||
return pattern
|
||||
|
||||
|
||||
def _utf8_byte_range_to_qt_positions(s: str, start_byte: int, end_byte: int) -> Tuple[int, int]:
|
||||
"""
|
||||
将 UTF-8 字节区间 [start_byte, end_byte) 转为 QTextCursor 使用的 UTF-16 偏移(与 Qt 一致)。
|
||||
"""
|
||||
b = s.encode("utf-8")
|
||||
if start_byte < 0 or end_byte > len(b) or start_byte > end_byte:
|
||||
raise ValueError("invalid utf-8 byte range for document")
|
||||
prefix_start = b[:start_byte].decode("utf-8")
|
||||
prefix_end = b[:end_byte].decode("utf-8")
|
||||
qt_start = len(prefix_start.encode("utf-16-le")) // 2
|
||||
qt_end = len(prefix_end.encode("utf-16-le")) // 2
|
||||
return qt_start, qt_end
|
||||
|
||||
|
||||
def rg_match_spans_qt(
|
||||
full_text: str,
|
||||
pattern: str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
whole_word: bool,
|
||||
max_spans: int = 3000,
|
||||
) -> Tuple[Optional[List[Tuple[int, int]]], Optional[str]]:
|
||||
"""
|
||||
使用 ``rg --json`` 得到与 ripgrep 完全一致的匹配区间,并转为 Qt 文档坐标(UTF-16)。
|
||||
|
||||
返回 (spans, error)。error 非空表示未调用 rg 或失败;spans 为 ``(start, end)`` 列表,按从左到右顺序。
|
||||
"""
|
||||
exe = find_rg_executable()
|
||||
if not exe:
|
||||
return None, "未找到 rg.exe"
|
||||
q = (pattern or "").strip()
|
||||
if not q:
|
||||
return [], None
|
||||
args: List[str] = [str(exe), "--json", "--color", "never"]
|
||||
if not case_sensitive:
|
||||
args.append("-i")
|
||||
if not use_regex:
|
||||
args.append("--fixed-strings")
|
||||
if whole_word:
|
||||
args.append("-w")
|
||||
pat_arg = q
|
||||
else:
|
||||
pat_arg = _build_rg_regex_pattern(q, whole_word)
|
||||
args.extend(["--", pat_arg, "-"])
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args,
|
||||
input=full_text.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if sys.platform == "win32"
|
||||
else 0,
|
||||
)
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
err_txt = (r.stderr or b"").decode("utf-8", errors="replace").strip()
|
||||
if r.returncode == 2:
|
||||
return None, err_txt or "rg 正则解析失败"
|
||||
if r.returncode not in (0, 1):
|
||||
return None, err_txt or f"rg 退出码 {r.returncode}"
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
spans: List[Tuple[int, int]] = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if obj.get("type") != "match":
|
||||
continue
|
||||
data = obj.get("data") or {}
|
||||
abs_off = data.get("absolute_offset")
|
||||
if abs_off is None:
|
||||
continue
|
||||
for sm in data.get("submatches") or []:
|
||||
try:
|
||||
sb = abs_off + int(sm["start"])
|
||||
eb = abs_off + int(sm["end"])
|
||||
qt_s, qt_e = _utf8_byte_range_to_qt_positions(full_text, sb, eb)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
spans.append((qt_s, qt_e))
|
||||
if len(spans) >= max_spans:
|
||||
return spans, None
|
||||
return spans, None
|
||||
|
||||
|
||||
def compile_python_span_pattern(
|
||||
pattern: str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
whole_word: bool,
|
||||
) -> Optional[re.Pattern]:
|
||||
"""与界面选项一致;在 rg 不可用时用于高亮与跳转(Python re)。"""
|
||||
if not (pattern or "").strip():
|
||||
return None
|
||||
q = pattern.strip()
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
try:
|
||||
if use_regex:
|
||||
src = _build_rg_regex_pattern(q, whole_word)
|
||||
return re.compile(src, flags)
|
||||
inner = re.escape(q)
|
||||
if whole_word:
|
||||
inner = rf"\b{inner}\b"
|
||||
return re.compile(inner, flags)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def rg_filter_lines(
|
||||
full_text: str,
|
||||
pattern: str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
whole_word: bool,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
将全文经 stdin 交给 rg,返回仅匹配行(与 rg 默认输出一致)。
|
||||
|
||||
- use_regex=False:``--fixed-strings``,可选 ``-w`` 全词。
|
||||
- use_regex=True:Rust 正则语法(与 Python 略有差异);全词时包 ``\\b(?:... )\\b``。
|
||||
返回 (result, error);error 非空表示未调用 rg 或失败。
|
||||
"""
|
||||
exe = find_rg_executable()
|
||||
if not exe:
|
||||
return None, "未找到 rg.exe,请将 rg.exe 放在 Server Manager 目录或设置环境变量 RG_PATH"
|
||||
q = (pattern or "").strip()
|
||||
if not q:
|
||||
return full_text, None
|
||||
args: List[str] = [str(exe), "--color", "never", "--no-heading"]
|
||||
if not case_sensitive:
|
||||
args.append("-i")
|
||||
if not use_regex:
|
||||
args.append("--fixed-strings")
|
||||
if whole_word:
|
||||
args.append("-w")
|
||||
pat_arg = q
|
||||
else:
|
||||
pat_arg = _build_rg_regex_pattern(q, whole_word)
|
||||
args.extend(["--", pat_arg, "-"])
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args,
|
||||
input=full_text.encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if sys.platform == "win32"
|
||||
else 0,
|
||||
)
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if r.returncode not in (0, 1):
|
||||
err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
|
||||
return None, err or f"rg 退出码 {r.returncode}"
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
if r.returncode == 1:
|
||||
return "", None
|
||||
return out.rstrip("\n"), None
|
||||
|
||||
|
||||
def rg_search_directory(
|
||||
search_root: Path,
|
||||
pattern: str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
whole_word: bool,
|
||||
glob_globs: Optional[List[str]] = None,
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""在目录下递归搜索,返回 rg 标准输出(含文件名与行号)。"""
|
||||
exe = find_rg_executable()
|
||||
if not exe:
|
||||
return "", "未找到 rg.exe"
|
||||
q = (pattern or "").strip()
|
||||
if not q:
|
||||
return "", "请输入搜索内容"
|
||||
if not search_root.is_dir():
|
||||
return "", f"目录不存在: {search_root}"
|
||||
args: List[str] = [str(exe), "-n", "-S", "--color", "never", "--heading"]
|
||||
if not case_sensitive:
|
||||
args.append("-i")
|
||||
if not use_regex:
|
||||
args.append("--fixed-strings")
|
||||
if whole_word:
|
||||
args.append("-w")
|
||||
pat_arg = q
|
||||
else:
|
||||
pat_arg = _build_rg_regex_pattern(q, whole_word)
|
||||
if glob_globs:
|
||||
for g in glob_globs:
|
||||
args.extend(["--glob", g])
|
||||
args.extend(["--", pat_arg, str(search_root.resolve())])
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if sys.platform == "win32"
|
||||
else 0,
|
||||
)
|
||||
except Exception as e:
|
||||
return "", str(e)
|
||||
out = (r.stdout or "").strip()
|
||||
err = (r.stderr or "").strip()
|
||||
if r.returncode not in (0, 1):
|
||||
return "", err or f"rg 退出码 {r.returncode}"
|
||||
if r.returncode == 1 and not out:
|
||||
return "(无匹配)", None
|
||||
return out, None
|
||||
@@ -0,0 +1,854 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
服务器创建模块
|
||||
|
||||
包含服务器创建相关的核心逻辑
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
|
||||
from services.commands import (
|
||||
get_local_ip,
|
||||
get_server_manager_config_dir,
|
||||
read_config_file,
|
||||
read_merged_config,
|
||||
write_config_file,
|
||||
generate_start_config,
|
||||
)
|
||||
|
||||
|
||||
# 服务器类型映射
|
||||
SERVER_TYPE_MAP = {
|
||||
'game': 'game_server',
|
||||
'login': 'login_server',
|
||||
'client': 'client_server',
|
||||
'center': 'center_server',
|
||||
'cross': 'cross_server'
|
||||
}
|
||||
|
||||
# 服务器类型到显示名称映射
|
||||
SERVER_TYPE_NAMES = {
|
||||
'game': '游戏服',
|
||||
'login': '登录服',
|
||||
'client': '客户端测试服',
|
||||
'center': '中心服',
|
||||
'cross': '跨服'
|
||||
}
|
||||
|
||||
# 基础必须配置项(所有服务器类型都需要)
|
||||
BASE_REQUIRED_KEYS = {
|
||||
'prefix', 'server_id', 'server_type', 'ip', 'db_host',
|
||||
'db_user', 'db_pass', 'db_port', 'log_dir', 'db_game_name', 'auto_reload'
|
||||
}
|
||||
|
||||
# 各服务器类型额外必须的配置项(不可删除)
|
||||
SERVER_TYPE_REQUIRED_KEYS = {
|
||||
'game': {
|
||||
'server_name', 'game_host', 'tcp_port', 'http_port',
|
||||
'open_time', 'login_node', 'center_node', 'db_log_name'
|
||||
},
|
||||
'login': {
|
||||
'tcp_port'
|
||||
},
|
||||
'center': {
|
||||
'open_time', 'login_node', 'db_log_name'
|
||||
},
|
||||
'cross': {
|
||||
'open_time', 'center_node', 'db_log_name'
|
||||
},
|
||||
'client': {
|
||||
'tcp_port', 'game_host', 'login_host', 'login_http_port', 'login_node'
|
||||
},
|
||||
}
|
||||
|
||||
# 各服务器类型可编辑的配置项(key: 中文说明)
|
||||
# 基础配置项(所有类型都有)
|
||||
BASE_CONFIG_KEYS = {
|
||||
'prefix': '项目前缀',
|
||||
'server_id': '服务器ID',
|
||||
'server_type': '服务器类型',
|
||||
'ip': '本机IP',
|
||||
'db_host': '数据库地址',
|
||||
'db_port': '数据库端口',
|
||||
'db_user': '数据库用户',
|
||||
'db_pass': '数据库密码',
|
||||
'db_save_time': '缓存同步间隔(ms)',
|
||||
'db_save_count': '缓存同步数量上限',
|
||||
'log_save_time': '日志同步间隔(ms)',
|
||||
'log_save_count': '日志同步数量上限',
|
||||
'log_dir': '日志目录',
|
||||
'logger_level': '日志等级',
|
||||
'is_develop': '开发模式',
|
||||
'is_inner': '是否内网',
|
||||
'auto_reload': '自动热更',
|
||||
'gm_auth': '开启GM',
|
||||
}
|
||||
|
||||
# 各服务器类型特有的配置项
|
||||
SERVER_TYPE_SPECIFIC_KEYS = {
|
||||
'game': {
|
||||
'server_name': '服务器名称',
|
||||
'game_host': '游戏服地址',
|
||||
'tcp_port': 'TCP端口',
|
||||
'http_port': 'HTTP端口',
|
||||
'login_node': '登录节点',
|
||||
'center_node': '中心节点',
|
||||
'open_time': '开服时间',
|
||||
'merge_server_ids': '合服ID列表',
|
||||
'merge_server_time': '合服时间',
|
||||
'last_merge_server_ids': '上次合服ID',
|
||||
'risk_control_server_ip': '风控服务器IP',
|
||||
'risk_control_server_post': '风控服务器端口',
|
||||
},
|
||||
'login': {
|
||||
'tcp_port': 'TCP端口',
|
||||
'http_port': 'HTTP端口',
|
||||
},
|
||||
'center': {
|
||||
'login_node': '登录节点',
|
||||
'open_time': '开服时间',
|
||||
},
|
||||
'cross': {
|
||||
'center_node': '中心节点',
|
||||
'open_time': '开服时间',
|
||||
},
|
||||
'client': {
|
||||
'tcp_port': 'TCP端口',
|
||||
'game_host': '游戏服地址',
|
||||
'game_tcp_port': '游戏服TCP端口',
|
||||
'login_host': '登录服地址',
|
||||
'login_http_port': '登录服HTTP端口',
|
||||
'login_node': '登录节点',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read_default_kv(server_root: str) -> Dict[str, str]:
|
||||
"""读取合并配置(tool.config > default.kv)
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
|
||||
Returns:
|
||||
配置字典
|
||||
"""
|
||||
if not server_root:
|
||||
return {}
|
||||
|
||||
return read_merged_config(server_root)
|
||||
|
||||
|
||||
def get_required_keys_from_kv(server_root: str, server_type: str) -> Optional[Set[str]]:
|
||||
"""从 default.kv 读取必须配置项定义
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
|
||||
Returns:
|
||||
必须配置项集合,如果配置不存在则返回 None
|
||||
"""
|
||||
kv_config = _read_default_kv(server_root)
|
||||
if not kv_config:
|
||||
return None
|
||||
|
||||
# 读取基础必须配置项
|
||||
base_keys_str = kv_config.get('required_keys_base', '')
|
||||
if not base_keys_str:
|
||||
return None
|
||||
|
||||
required = set(k.strip() for k in base_keys_str.split(',') if k.strip())
|
||||
|
||||
# 读取类型特有的必须配置项
|
||||
type_keys_str = kv_config.get(f'required_keys_{server_type}', '')
|
||||
if type_keys_str:
|
||||
required.update(k.strip() for k in type_keys_str.split(',') if k.strip())
|
||||
|
||||
return required
|
||||
|
||||
|
||||
def get_config_keys_from_kv(server_root: str, server_type: str) -> Optional[Dict[str, str]]:
|
||||
"""从 default.kv 读取可编辑配置项定义
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
|
||||
Returns:
|
||||
配置项字典 {key: 中文说明},如果配置不存在则返回 None
|
||||
"""
|
||||
kv_config = _read_default_kv(server_root)
|
||||
if not kv_config:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
has_config = False
|
||||
|
||||
# 读取基础配置项 (config_keys_base_*)
|
||||
for key, value in kv_config.items():
|
||||
if key.startswith('config_keys_base_'):
|
||||
config_key = key[len('config_keys_base_'):]
|
||||
result[config_key] = value
|
||||
has_config = True
|
||||
|
||||
# 读取类型特有的配置项 (config_keys_<type>_*)
|
||||
prefix = f'config_keys_{server_type}_'
|
||||
for key, value in kv_config.items():
|
||||
if key.startswith(prefix):
|
||||
config_key = key[len(prefix):]
|
||||
result[config_key] = value
|
||||
has_config = True
|
||||
|
||||
return result if has_config else None
|
||||
|
||||
|
||||
def get_editable_config_from_default_kv(server_root: str) -> Dict[str, str]:
|
||||
"""从 default.kv 读取编辑对话框可选配置项定义
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
|
||||
Returns:
|
||||
配置项字典 {key: 中文说明}
|
||||
"""
|
||||
kv_config = _read_default_kv(server_root)
|
||||
if not kv_config:
|
||||
return {}
|
||||
|
||||
# 查找 editable_config_ 前缀的配置项
|
||||
editable = {}
|
||||
for key, value in kv_config.items():
|
||||
if key.startswith('editable_config_'):
|
||||
config_key = key[len('editable_config_'):] # 去掉前缀
|
||||
editable[config_key] = value
|
||||
|
||||
return editable
|
||||
|
||||
|
||||
def get_allowed_config_keys(server_type: str, server_root: str = '') -> Dict[str, str]:
|
||||
"""获取指定服务器类型允许编辑的配置项
|
||||
|
||||
优先从 default.kv 读取 config_keys_* 定义,如果没有则使用代码中的默认定义
|
||||
|
||||
Args:
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
server_root: 服务器根目录(用于读取 default.kv)
|
||||
|
||||
Returns:
|
||||
配置项字典 {key: 中文说明}
|
||||
"""
|
||||
# 优先从 default.kv 读取
|
||||
if server_root:
|
||||
config_from_kv = get_config_keys_from_kv(server_root, server_type)
|
||||
if config_from_kv:
|
||||
return config_from_kv
|
||||
|
||||
# 回退到代码中的默认定义
|
||||
allowed = dict(BASE_CONFIG_KEYS)
|
||||
specific = SERVER_TYPE_SPECIFIC_KEYS.get(server_type, {})
|
||||
allowed.update(specific)
|
||||
return allowed
|
||||
|
||||
|
||||
def get_all_valid_keys(server_root: str = '') -> Set[str]:
|
||||
"""获取所有有效的配置键
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录(用于读取 default.kv)
|
||||
|
||||
Returns:
|
||||
所有有效配置键的集合
|
||||
"""
|
||||
# 优先从 default.kv 读取
|
||||
if server_root:
|
||||
kv_config = _read_default_kv(server_root)
|
||||
if kv_config:
|
||||
all_keys = set()
|
||||
for key in kv_config.keys():
|
||||
if key.startswith('config_keys_base_'):
|
||||
all_keys.add(key[len('config_keys_base_'):])
|
||||
elif key.startswith('config_keys_') and '_' in key[len('config_keys_'):]:
|
||||
# config_keys_<type>_<key>
|
||||
parts = key[len('config_keys_'):].split('_', 1)
|
||||
if len(parts) == 2:
|
||||
all_keys.add(parts[1])
|
||||
if all_keys:
|
||||
return all_keys
|
||||
|
||||
# 回退到代码中的默认定义
|
||||
all_keys = set(BASE_CONFIG_KEYS.keys())
|
||||
for specific in SERVER_TYPE_SPECIFIC_KEYS.values():
|
||||
all_keys.update(specific.keys())
|
||||
return all_keys
|
||||
|
||||
|
||||
def get_required_keys(server_type: str, server_root: str = '') -> Set[str]:
|
||||
"""获取指定服务器类型必须的配置项(不可删除)
|
||||
|
||||
优先从 default.kv 读取 required_keys_* 定义,如果没有则使用代码中的默认定义
|
||||
|
||||
Args:
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
server_root: 服务器根目录(用于读取 default.kv)
|
||||
|
||||
Returns:
|
||||
必须的配置项集合
|
||||
"""
|
||||
# 优先从 default.kv 读取
|
||||
if server_root:
|
||||
required_from_kv = get_required_keys_from_kv(server_root, server_type)
|
||||
if required_from_kv:
|
||||
return required_from_kv
|
||||
|
||||
# 回退到代码中的默认定义
|
||||
required = set(BASE_REQUIRED_KEYS)
|
||||
type_required = SERVER_TYPE_REQUIRED_KEYS.get(server_type, set())
|
||||
required.update(type_required)
|
||||
return required
|
||||
|
||||
|
||||
def get_server_type_name(server_type: str) -> str:
|
||||
"""获取服务器类型的中文名称"""
|
||||
return SERVER_TYPE_NAMES.get(server_type, server_type)
|
||||
|
||||
|
||||
# 模板文件到服务器类型的映射
|
||||
TEMPLATE_FILE_MAP = {
|
||||
'game': 'sys_game.config.example',
|
||||
'login': 'sys_login.config.example',
|
||||
'center': 'sys_center.config.example',
|
||||
'cross': 'sys_cross.config.example',
|
||||
'client': 'sys_client.config.example',
|
||||
}
|
||||
|
||||
|
||||
def extract_template_placeholders_with_comments(server_root: str, server_type: str) -> Dict[str, str]:
|
||||
"""从模板文件中提取所有占位符及其注释说明
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
|
||||
Returns:
|
||||
占位符字典 {key: 注释说明},如 {'server_id': '服务器id', 'tcp_port': 'tcp端口'}
|
||||
"""
|
||||
template_file = TEMPLATE_FILE_MAP.get(server_type)
|
||||
if not template_file:
|
||||
return {}
|
||||
|
||||
template_path = get_server_manager_config_dir(server_root) / template_file
|
||||
if not template_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
content = template_path.read_text(encoding='utf-8')
|
||||
result = {}
|
||||
|
||||
# 逐行解析,提取 ${xxx} 和同行的 %% 注释
|
||||
for line in content.split('\n'):
|
||||
# 查找所有 ${xxx} 占位符
|
||||
matches = re.findall(r'\$\{(\w+)\}', line)
|
||||
if matches:
|
||||
# 提取同行的 %% 注释作为描述
|
||||
comment_match = re.search(r'%%\s*(.+?)(?:\s*$)', line)
|
||||
comment = ''
|
||||
if comment_match:
|
||||
raw_comment = comment_match.group(1).strip()
|
||||
# 只取第一个逗号或句号之前的内容,限制长度
|
||||
for sep in [',', ',', '。', '(', '(', ',']:
|
||||
if sep in raw_comment:
|
||||
raw_comment = raw_comment.split(sep)[0].strip()
|
||||
break
|
||||
# 限制最大长度为15个字符
|
||||
comment = raw_comment[:15] if len(raw_comment) > 15 else raw_comment
|
||||
|
||||
for key in matches:
|
||||
if key not in result:
|
||||
# 使用注释作为描述,如果没有注释则使用 key 本身
|
||||
result[key] = comment if comment else key
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_template_config_keys(server_root: str, server_type: str) -> Dict[str, str]:
|
||||
"""根据模板文件获取可配置的参数列表
|
||||
|
||||
优先级:
|
||||
1. default.kv 中的 editable_config_* 定义(编辑对话框专用)
|
||||
2. default.kv 中的 config_keys_* 定义(完整配置项)
|
||||
3. 从模板文件提取
|
||||
4. 代码中的默认定义
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
|
||||
Returns:
|
||||
配置项字典 {key: 中文说明}
|
||||
"""
|
||||
# 优先从 default.kv 读取 editable_config_* (编辑对话框专用)
|
||||
editable_from_kv = get_editable_config_from_default_kv(server_root)
|
||||
config_from_kv = get_config_keys_from_kv(server_root, server_type)
|
||||
if editable_from_kv:
|
||||
merged = dict(editable_from_kv)
|
||||
# 与 base 一致:所有服务器类型均可配置是否自动热更(editable 子集未列时也补全)
|
||||
if 'auto_reload' not in merged:
|
||||
label = (config_from_kv or {}).get('auto_reload')
|
||||
merged['auto_reload'] = label or BASE_CONFIG_KEYS.get('auto_reload', '自动热更')
|
||||
return merged
|
||||
|
||||
# 其次从 default.kv 读取 config_keys_* (完整配置项定义)
|
||||
if config_from_kv:
|
||||
return config_from_kv
|
||||
|
||||
# 回退:从模板文件提取
|
||||
result = extract_template_placeholders_with_comments(server_root, server_type)
|
||||
|
||||
# 如果从模板中读取不到,使用硬编码的配置项作为回退
|
||||
if not result:
|
||||
allowed = dict(BASE_CONFIG_KEYS)
|
||||
specific = SERVER_TYPE_SPECIFIC_KEYS.get(server_type, {})
|
||||
allowed.update(specific)
|
||||
return allowed
|
||||
|
||||
# 排除用于组合其他值的基础占位符(这些通常在创建时就已设置,不需要单独添加)
|
||||
# 例如 db_name = "${prefix}_game_s${server_id}" 中的 prefix 和 server_id
|
||||
exclude_keys = {'prefix', 'server_id', 'server_type'}
|
||||
for key in exclude_keys:
|
||||
result.pop(key, None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_server_type_str(server_type: str) -> str:
|
||||
"""获取服务器类型字符串(如 game_server)"""
|
||||
return SERVER_TYPE_MAP.get(server_type, 'game_server')
|
||||
|
||||
|
||||
def generate_server_dir_name(prefix: str, server_type: str, server_id: int) -> str:
|
||||
"""生成服务器目录名
|
||||
|
||||
Args:
|
||||
prefix: 服务器前缀
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
server_id: 服务器ID
|
||||
|
||||
Returns:
|
||||
服务器目录名,如 ddxq_game_s1
|
||||
"""
|
||||
return f'{prefix}_{server_type}_s{server_id}'
|
||||
|
||||
|
||||
def get_existing_server_ids(run_dir: str, server_type: str, prefix: str) -> Set[int]:
|
||||
"""获取已存在的同类型服务器ID列表
|
||||
|
||||
Args:
|
||||
run_dir: 运行目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
prefix: 服务器前缀
|
||||
|
||||
Returns:
|
||||
已存在的服务器ID集合
|
||||
"""
|
||||
existing_ids = set()
|
||||
run_path = Path(run_dir)
|
||||
|
||||
if not run_path.exists():
|
||||
return existing_ids
|
||||
|
||||
# 匹配模式: prefix_type_sXXX
|
||||
pattern = re.compile(rf'^{re.escape(prefix)}_{server_type}_s(\d+)$', re.IGNORECASE)
|
||||
|
||||
for item in run_path.iterdir():
|
||||
if item.is_dir():
|
||||
match = pattern.match(item.name)
|
||||
if match:
|
||||
try:
|
||||
existing_ids.add(int(match.group(1)))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return existing_ids
|
||||
|
||||
|
||||
def build_kv_config(
|
||||
server_type: str,
|
||||
server_id: int,
|
||||
prefix: str,
|
||||
ip: str,
|
||||
db_host: str,
|
||||
db_user: str,
|
||||
db_pass: str,
|
||||
db_port: int,
|
||||
server_dir: str,
|
||||
db_game_name: Optional[str] = None,
|
||||
db_log_name: Optional[str] = None,
|
||||
login_node: Optional[str] = None,
|
||||
center_node: Optional[str] = None,
|
||||
server_name: Optional[str] = None,
|
||||
tcp_port: Optional[int] = None,
|
||||
http_port: Optional[int] = None,
|
||||
login_host: Optional[str] = None,
|
||||
login_http_port: Optional[int] = None,
|
||||
open_time: Optional[str] = None,
|
||||
auto_reload: Optional[bool] = None
|
||||
) -> Dict[str, str]:
|
||||
"""构建服务器的差异化配置 kv.config
|
||||
|
||||
Args:
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
server_id: 服务器ID
|
||||
prefix: 服务器前缀
|
||||
ip: 本机IP
|
||||
db_host: 数据库地址
|
||||
db_user: 数据库用户
|
||||
db_pass: 数据库密码
|
||||
db_port: 数据库端口
|
||||
server_dir: 服务器目录名
|
||||
db_game_name: 游戏/业务数据库名(可选,默认按前缀、类型、ID生成)
|
||||
db_log_name: 日志数据库名(可选,默认按前缀、类型、ID生成)
|
||||
login_node: 登录节点(可选)
|
||||
center_node: 中心节点(可选)
|
||||
server_name: 服务器名称(可选)
|
||||
tcp_port: TCP端口(可选)
|
||||
http_port: HTTP端口(可选)
|
||||
login_host: 客户端服连接的登录服地址(可选)
|
||||
login_http_port: 客户端服连接的登录服 HTTP 端口(可选)
|
||||
open_time: 开服时间(可选)
|
||||
|
||||
Returns:
|
||||
配置字典
|
||||
"""
|
||||
server_type_str = get_server_type_str(server_type)
|
||||
|
||||
# 根据服务器类型生成数据库名
|
||||
db_type_prefix = {
|
||||
'game': 'game',
|
||||
'login': 'login',
|
||||
'center': 'center',
|
||||
'cross': 'cross',
|
||||
'client': 'client'
|
||||
}
|
||||
type_prefix = db_type_prefix.get(server_type, server_type)
|
||||
default_db_game_name = f'{prefix}_{type_prefix}_s{server_id}'
|
||||
default_db_log_name = f'{prefix}_{type_prefix}_log_s{server_id}' if server_type != 'login' else ''
|
||||
db_game_name_value = (db_game_name or default_db_game_name).strip()
|
||||
db_log_name_value = (db_log_name if db_log_name is not None else default_db_log_name).strip()
|
||||
|
||||
# 基础配置
|
||||
diff_config = {
|
||||
'prefix': prefix,
|
||||
'server_id': str(server_id),
|
||||
'server_type': server_type_str,
|
||||
'ip': ip,
|
||||
'db_host': db_host,
|
||||
'db_user': db_user,
|
||||
'db_pass': db_pass,
|
||||
'db_port': str(db_port),
|
||||
'log_dir': 'log', # 相对于服务器运行目录
|
||||
'db_game_name': db_game_name_value,
|
||||
}
|
||||
|
||||
# 如果有日志数据库(登录服没有)
|
||||
if db_log_name_value:
|
||||
diff_config['db_log_name'] = db_log_name_value
|
||||
|
||||
# 根据服务器类型添加特定字段
|
||||
# game: server_name, tcp_port, http_port, game_host, open_time
|
||||
# login: tcp_port
|
||||
# center: open_time
|
||||
# cross: open_time
|
||||
# client: tcp_port, game_host
|
||||
|
||||
if auto_reload is not None:
|
||||
diff_config['auto_reload'] = 'true' if auto_reload else 'false'
|
||||
|
||||
if server_type == 'game':
|
||||
if server_name:
|
||||
diff_config['server_name'] = server_name
|
||||
diff_config['game_host'] = ip
|
||||
if tcp_port:
|
||||
diff_config['tcp_port'] = str(tcp_port)
|
||||
if http_port:
|
||||
diff_config['http_port'] = str(http_port)
|
||||
if open_time:
|
||||
diff_config['open_time'] = open_time
|
||||
diff_config['role_log'] = f'run/{server_dir}/log'
|
||||
elif server_type == 'login':
|
||||
if tcp_port:
|
||||
diff_config['tcp_port'] = str(tcp_port)
|
||||
elif server_type == 'center':
|
||||
if open_time:
|
||||
diff_config['open_time'] = open_time
|
||||
elif server_type == 'cross':
|
||||
if open_time:
|
||||
diff_config['open_time'] = open_time
|
||||
elif server_type == 'client':
|
||||
diff_config['game_host'] = ip
|
||||
if tcp_port:
|
||||
diff_config['tcp_port'] = str(tcp_port)
|
||||
|
||||
# 节点配置(模板中已有引号,这里不需要添加)
|
||||
# game/client/center 需要登录节点
|
||||
if login_node and server_type in ('game', 'client', 'center'):
|
||||
diff_config['login_node'] = login_node
|
||||
# game/cross 需要中心服节点
|
||||
if center_node and server_type in ('game', 'cross'):
|
||||
diff_config['center_node'] = center_node
|
||||
|
||||
# 客户端测试配置
|
||||
if server_type == 'client':
|
||||
if tcp_port:
|
||||
diff_config['game_tcp_port'] = str(tcp_port)
|
||||
diff_config['login_host'] = login_host or ip
|
||||
diff_config['login_http_port'] = str(login_http_port or 19900)
|
||||
|
||||
return diff_config
|
||||
|
||||
|
||||
def create_server(
|
||||
server_root: str,
|
||||
run_dir: str,
|
||||
server_type: str,
|
||||
server_id: int,
|
||||
prefix: str,
|
||||
db_host: str,
|
||||
db_user: str,
|
||||
db_pass: str,
|
||||
db_port: int,
|
||||
db_game_name: Optional[str] = None,
|
||||
db_log_name: Optional[str] = None,
|
||||
login_node: Optional[str] = None,
|
||||
center_node: Optional[str] = None,
|
||||
server_name: Optional[str] = None,
|
||||
tcp_port: Optional[int] = None,
|
||||
http_port: Optional[int] = None,
|
||||
login_host: Optional[str] = None,
|
||||
login_http_port: Optional[int] = None,
|
||||
open_time: Optional[str] = None,
|
||||
auto_reload: Optional[bool] = None,
|
||||
overwrite: bool = False
|
||||
) -> Tuple[bool, str, Optional[Dict[str, str]]]:
|
||||
"""创建服务器
|
||||
|
||||
在 run/{server}/config/kv.config 创建差异化配置
|
||||
|
||||
Args:
|
||||
server_root: 服务器根目录
|
||||
run_dir: 运行目录
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
server_id: 服务器ID
|
||||
prefix: 服务器前缀
|
||||
db_host: 数据库地址
|
||||
db_user: 数据库用户
|
||||
db_pass: 数据库密码
|
||||
db_port: 数据库端口
|
||||
db_game_name: 游戏/业务数据库名(可选,默认按前缀、类型、ID生成)
|
||||
db_log_name: 日志数据库名(可选,默认按前缀、类型、ID生成)
|
||||
login_node: 登录节点(可选)
|
||||
center_node: 中心节点(可选)
|
||||
server_name: 服务器名称(可选)
|
||||
tcp_port: TCP端口(可选)
|
||||
http_port: HTTP端口(可选)
|
||||
login_host: 客户端服连接的登录服地址(可选)
|
||||
login_http_port: 客户端服连接的登录服 HTTP 端口(可选)
|
||||
open_time: 开服时间(可选)
|
||||
auto_reload: 是否自动热更
|
||||
overwrite: 如果服务器已存在是否覆盖
|
||||
|
||||
Returns:
|
||||
(成功, 消息, 合并后的配置字典)
|
||||
"""
|
||||
# 本机 IP(read_merged_config 已统一为 get_local_ip())
|
||||
merged = read_merged_config(server_root) if server_root else {}
|
||||
ip = merged.get('ip') or get_local_ip()
|
||||
|
||||
if not run_dir and server_root:
|
||||
run_dir = str(Path(server_root) / 'run')
|
||||
|
||||
# 生成服务器目录名
|
||||
server_dir = generate_server_dir_name(prefix, server_type, server_id)
|
||||
|
||||
# 服务器配置目录
|
||||
server_config_path = Path(run_dir) / server_dir / 'config'
|
||||
kv_config_file = server_config_path / 'kv.config'
|
||||
|
||||
# 检查是否已存在
|
||||
if kv_config_file.exists() and not overwrite:
|
||||
return False, f'服务器 {server_dir} 已存在', None
|
||||
|
||||
try:
|
||||
# 创建目录
|
||||
server_config_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 构建差异化配置
|
||||
diff_config = build_kv_config(
|
||||
server_type=server_type,
|
||||
server_id=server_id,
|
||||
prefix=prefix,
|
||||
ip=ip,
|
||||
db_host=db_host,
|
||||
db_user=db_user,
|
||||
db_pass=db_pass,
|
||||
db_port=db_port,
|
||||
server_dir=server_dir,
|
||||
db_game_name=db_game_name,
|
||||
db_log_name=db_log_name,
|
||||
login_node=login_node,
|
||||
center_node=center_node,
|
||||
server_name=server_name,
|
||||
tcp_port=tcp_port,
|
||||
http_port=http_port,
|
||||
login_host=login_host,
|
||||
login_http_port=login_http_port,
|
||||
open_time=open_time,
|
||||
auto_reload=auto_reload
|
||||
)
|
||||
|
||||
# 写入差异化配置 kv.config
|
||||
write_config_file(str(kv_config_file), diff_config)
|
||||
|
||||
# 生成完整配置 sys.config
|
||||
merged_config = generate_start_config(server_root, server_dir)
|
||||
|
||||
return True, f'服务器 {server_dir} 创建成功', merged_config
|
||||
|
||||
except Exception as e:
|
||||
return False, f'创建服务器失败: {str(e)}', None
|
||||
|
||||
|
||||
def get_id_range_from_config(default_kv: Dict[str, str], server_type: str) -> Tuple[int, int, int]:
|
||||
"""从配置中获取服务器ID范围
|
||||
|
||||
Args:
|
||||
default_kv: 默认配置字典
|
||||
server_type: 服务器类型 (game/login/client/center/cross)
|
||||
|
||||
Returns:
|
||||
(最小值, 最大值, 默认值)
|
||||
"""
|
||||
# 默认范围
|
||||
default_ranges = {
|
||||
'game': (100, 9999, 100),
|
||||
'login': (10, 100, 10),
|
||||
'center': (1, 10, 1),
|
||||
'cross': (10000, 20000, 10000),
|
||||
'client': (1, 10000, 1)
|
||||
}
|
||||
|
||||
min_key = f'{server_type}_id_min'
|
||||
max_key = f'{server_type}_id_max'
|
||||
default_key = f'{server_type}_id_default'
|
||||
|
||||
try:
|
||||
id_min = int(default_kv.get(min_key, default_ranges.get(server_type, (1, 100, 1))[0]))
|
||||
id_max = int(default_kv.get(max_key, default_ranges.get(server_type, (1, 100, 1))[1]))
|
||||
id_default = int(default_kv.get(default_key, default_ranges.get(server_type, (1, 100, 1))[2]))
|
||||
except (ValueError, TypeError):
|
||||
id_min, id_max, id_default = default_ranges.get(server_type, (1, 100, 1))
|
||||
|
||||
return id_min, id_max, id_default
|
||||
|
||||
|
||||
def get_default_port(server_type: str, server_id: int) -> Tuple[int, int]:
|
||||
"""获取默认端口号
|
||||
|
||||
Args:
|
||||
server_type: 服务器类型
|
||||
server_id: 服务器ID
|
||||
|
||||
Returns:
|
||||
(TCP端口, HTTP端口)
|
||||
"""
|
||||
# 基础端口
|
||||
base_tcp = 18000
|
||||
base_http = 19000
|
||||
|
||||
tcp_port = base_tcp + server_id
|
||||
http_port = base_http + server_id
|
||||
|
||||
return tcp_port, http_port
|
||||
|
||||
|
||||
def get_default_server_name(prefix: str, server_id: int) -> str:
|
||||
"""获取默认服务器名称
|
||||
|
||||
Args:
|
||||
prefix: 服务器前缀
|
||||
server_id: 服务器ID
|
||||
|
||||
Returns:
|
||||
服务器名称
|
||||
"""
|
||||
return f'{prefix}_{server_id}'
|
||||
|
||||
|
||||
def build_confirmation_message(
|
||||
server_type: str,
|
||||
server_dir: str,
|
||||
server_id: int,
|
||||
ip: str,
|
||||
db_host: str,
|
||||
db_port: int,
|
||||
login_node: Optional[str] = None,
|
||||
center_node: Optional[str] = None,
|
||||
server_name: Optional[str] = None,
|
||||
tcp_port: Optional[int] = None,
|
||||
http_port: Optional[int] = None,
|
||||
open_time: Optional[str] = None
|
||||
) -> str:
|
||||
"""构建创建确认消息
|
||||
|
||||
Args:
|
||||
各种服务器配置参数
|
||||
|
||||
Returns:
|
||||
确认消息字符串
|
||||
"""
|
||||
type_name = get_server_type_name(server_type)
|
||||
|
||||
lines = [
|
||||
f'即将创建 {type_name}:',
|
||||
'',
|
||||
f'服务器目录: {server_dir}',
|
||||
f'服务器ID: {server_id}',
|
||||
f'本机IP: {ip}',
|
||||
f'数据库: {db_host}:{db_port}',
|
||||
]
|
||||
|
||||
# 根据服务器类型添加特定信息
|
||||
if server_type == 'game':
|
||||
if server_name:
|
||||
lines.append(f'服务器名称: {server_name}')
|
||||
if tcp_port:
|
||||
lines.append(f'TCP端口: {tcp_port}')
|
||||
if http_port:
|
||||
lines.append(f'HTTP端口: {http_port}')
|
||||
if open_time:
|
||||
lines.append(f'开服时间: {open_time}')
|
||||
elif server_type == 'login':
|
||||
if tcp_port:
|
||||
lines.append(f'TCP端口: {tcp_port}')
|
||||
elif server_type == 'center':
|
||||
if open_time:
|
||||
lines.append(f'开服时间: {open_time}')
|
||||
elif server_type == 'cross':
|
||||
if open_time:
|
||||
lines.append(f'开服时间: {open_time}')
|
||||
elif server_type == 'client':
|
||||
if tcp_port:
|
||||
lines.append(f'TCP端口: {tcp_port}')
|
||||
|
||||
# 节点信息
|
||||
if login_node and server_type in ('game', 'client', 'center'):
|
||||
lines.append(f'登录节点: {login_node}')
|
||||
if center_node and server_type in ('game', 'cross'):
|
||||
lines.append(f'中心节点: {center_node}')
|
||||
|
||||
lines.extend(['', '是否继续创建?'])
|
||||
|
||||
return '\n'.join(lines)
|
||||
Reference in New Issue
Block a user