仓库初始化

This commit is contained in:
lwt
2026-05-22 00:16:08 +08:00
commit 95c96dce42
118 changed files with 23005 additions and 0 deletions
View File
+37
View File
@@ -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('__')
})
+224
View File
@@ -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}'
)
+302
View File
@@ -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
+137
View File
@@ -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
+146
View File
@@ -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
+169
View File
@@ -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
+413
View File
@@ -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
+83
View File
@@ -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/configdefault.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()
)
+385
View File
@@ -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_nameUTF-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
+35
View File
@@ -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 使用当前目录下的 ./rebar3Windows 使用 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',
)
+601
View File
@@ -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_windowstart 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
+143
View File
@@ -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
+373
View File
@@ -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)
+266
View File
@@ -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=TrueRust 正则语法(与 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
+854
View File
@@ -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:
(成功, 消息, 合并后的配置字典)
"""
# 本机 IPread_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)