// ============================================================ // 会员服务Host:本地会员系统服务(会员服务器\server.cjs, node)生命周期管理 // · 端口 12701,只绑 127.0.0.1;数据存 会员服务器\huiyuan.json // · EnsureRunning:端口已通=复用;不通=拉起 node.exe + server.cjs 并等就绪(≤8秒) // · Shutdown:只停由本程序启动的进程(用户自己开的 node 不动) // · API 客户端(Post/Get):供 登录/会员信息/修改密码 三窗体调用 // 照 UmiOcrHost/CadViewerWindow 模式抄。 // ============================================================ using System; using System.Diagnostics; using System.IO; using System.Net; using System.Text; namespace 成套报价软件 { internal static class 会员服务Host { /// 会员服务端口(与 server.cjs 一致)。 public const int 服务端口 = 12701; /// 由本程序启动的 node 进程(关闭时只停它)。 private static Process _startedProc; /// 默认会员服务器(HTTPS 加密域名,v1.4.0)。首次运行/配置缺失/读失败时的兜底。 private const string 默认服务器 = "https://ss61.odn.cc"; /// 服务器地址配置文件(exe 旁 shezhi\会员服务.config.xml)。 /// 默认=HTTPS 域名服务器 https://ss61.odn.cc(用户指定,2026-09-19)。 /// ★2026-09-24 支持 ## 注释(方便测试/正式服务器切换):## 或 # 开头的行是注释, /// 第一个非注释有效行生效(行内" ##"后的内容也是注释);老 XML 格式 /// (<会员服务 服务器地址="..."/>)照常识别。手改文件后重开程序生效。 public static string 服务器地址 { get { try { string p = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "shezhi", "会员服务.config.xml"); if (!File.Exists(p)) { // 首次运行:写注释友好的默认配置(切换测试/正式=挪 ## 注释) Directory.CreateDirectory(Path.GetDirectoryName(p)); File.WriteAllText(p, "## 会员服务器地址:第一个非 ## 注释的行生效(行内 ## 后也算注释)\r\n" + "## 切换测试/正式服务器:给不用的地址行行首加 ## ,去掉另一行的 ##\r\n" + "## 地址缺 http 前缀时自动补 https://\r\n" + 默认服务器 + "\r\n" + "## 测试服务器(要启用就去掉行首 ##):\r\n" + "## https://192.168.1.10:12702\r\n", new UTF8Encoding(true)); return 默认服务器; } foreach (var raw in File.ReadAllLines(p)) { string t = (raw ?? "").Trim(); if (t.Length == 0 || t.StartsWith("#")) continue; // 空行/注释行(##/# 均算) // 行内注释:URL 不含 ##,截断安全 int c = t.IndexOf("##"); if (c > 0) t = t.Substring(0, c).Trim(); if (t.Length == 0) continue; if (t.StartsWith("<")) { // 老 XML 格式:按 服务器地址 属性解析(兼容旧文件) try { var doc = new System.Xml.XmlDocument(); doc.Load(p); var node = doc.SelectSingleNode("//会员服务"); string addr = node != null && node.Attributes["服务器地址"] != null ? (node.Attributes["服务器地址"].Value ?? "").Trim() : ""; return 规范化地址(addr); } catch { return 默认服务器; } } return 规范化地址(t); } return 默认服务器; // 全是注释/空行 } catch { return 默认服务器; } } } /// 地址规范化:空回默认;缺 http 前缀补 https(新协议必须 TLS);去尾斜杠。 private static string 规范化地址(string addr) { addr = (addr ?? "").Trim(); if (addr.Length == 0) return 默认服务器; if (!addr.StartsWith("http")) addr = "https://" + addr; return addr.TrimEnd('/'); } /// 当前配置是否指向远程服务器(非本机回环)。 public static bool 是远程 { get { string a = 服务器地址; return a.IndexOf("127.0.0.1", StringComparison.Ordinal) < 0 && a.IndexOf("localhost", StringComparison.OrdinalIgnoreCase) < 0; } } /// 远程服务可达性探测(GET /api/pubkey——新版加密服务的公钥端点,5 秒超时; /// 只是预检,失败不拦截后续真实调用——正式加密通道报的错误更准确)。 private static bool 远程可达() { try { var req = (HttpWebRequest)WebRequest.Create(服务器地址 + "/api/pubkey"); req.Method = "GET"; req.Timeout = 5000; req.ReadWriteTimeout = 5000; req.Proxy = null; using (var resp = (HttpWebResponse)req.GetResponse()) { return resp.StatusCode == HttpStatusCode.OK; } } catch { return false; } } /// 服务根目录(exe 旁 会员服务器\;开发机回退项目根)。 public static string 服务目录 { get { string baseDir = AppDomain.CurrentDomain.BaseDirectory; string p1 = Path.Combine(baseDir, "会员服务器"); if (Directory.Exists(p1) && File.Exists(Path.Combine(p1, "server.cjs"))) return p1; // 开发机:bin\Debug → 项目根\会员服务器 string p2 = Path.GetFullPath(Path.Combine(baseDir, @"..\..\会员服务器")); if (File.Exists(Path.Combine(p2, "server.cjs"))) return p2; return p1; } } /// 服务基地址(来自配置:本地默认 127.0.0.1:12701,或 Linux 服务器)。 public static string 基地址 { get { return 服务器地址; } } /// 端口是否在监听。 public static bool IsUp() { try { using (var c = new System.Net.Sockets.TcpClient()) { var ar = c.BeginConnect("127.0.0.1", 服务端口, null, null); if (ar.AsyncWaitHandle.WaitOne(600)) { c.EndConnect(ar); return true; } c.Close(); } } catch { } return false; } /// 确保服务在线。 /// 远程模式(配置指向 Linux 服务器):只探测可达性,不拉起本地进程——服务由服务器侧维护。 /// 本地模式:端口已通=复用;不通=拉起 node.exe + server.cjs 并等就绪。返回 false=不可用。 public static bool EnsureRunning() { if (是远程) return 远程可达(); if (IsUp()) return true; string dir = 服务目录; string script = Path.Combine(dir, "server.cjs"); if (!File.Exists(script)) return false; // node.exe:会员服务器\node → exe旁cadbt\node → 开发机 cadbt bin string[] candidates = { Path.Combine(dir, "node", "node.exe"), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "cadbt", "node", "node.exe"), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\cadbt\cadbt\bin\Debug\node\node.exe") }; string nodeExe = null; foreach (var c in candidates) if (File.Exists(c)) { nodeExe = c; break; } if (nodeExe == null) return false; try { _startedProc = Process.Start(new ProcessStartInfo { FileName = nodeExe, Arguments = "\"" + script + "\"", WorkingDirectory = dir, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }); } catch { return false; } for (int i = 0; i < 16; i++) // 16 × 500ms = 最多 8 秒 { if (IsUp()) return true; System.Threading.Thread.Sleep(500); } return false; } // ============================================================ // 加密通讯层(fnnas.huiyuan v1.4.0:HTTPS+信封加密;老明文服务不支持) // 业务响应格式与旧协议完全一致——调用方只换通道,字段解析零改动。 // ============================================================ private static 会员安全客户端 _加密客户端; private static readonly object _加密锁 = new object(); /// 取加密客户端(懒初始化:首次拉服务器公钥;失败抛可读异常)。 private static 会员安全客户端 客户端() { lock (_加密锁) { if (_加密客户端 == null) { var c = new 会员安全客户端(基地址); try { c.Init(); } catch (Exception ex) { throw new Exception("无法连接加密会员服务(" + 基地址 + "):\n" + ex.Message + "\n请确认服务器版本 ≥ v1.4.0 且地址正确(https://)。"); } _加密客户端 = c; } return _加密客户端; } } /// 配置变化(改了服务器地址)后调用:强制下次重新拉公钥。 public static void 重置加密客户端() { lock (_加密锁) { _加密客户端 = null; } } /// POST 业务接口(信封加密)。api 如 /api/login;json 为业务 JSON;返回解密后的结果 JSON。 /// ★ /api/proj/delete 服务端 v1.5.1 起要求 手机号+密码+编号——调用方没带密码时 /// 自动补当前登录密码(与 Get 的 /api/proj/list、PostBinary 的 upload 同款注入)。 public static string Post(string api, string json) { if (api == "/api/proj/delete" && json != null && json.IndexOf("\"密码\"") < 0) { string pwd = (MainForm.当前登录密码 ?? "").Trim(); string 内 = json.Trim(); if (内.StartsWith("{")) 内 = 内.Substring(1); if (内.EndsWith("}")) 内 = 内.Substring(0, 内.Length - 1); json = "{" + 内 + (内.Length > 0 ? "," : "") + "\"密码\":\"" + JsonEsc(pwd) + "\"}"; } return 客户端().EncryptedCall(api, json); } /// 业务查询(信封加密)。兼容旧 Get(apiAndQuery) 调用形态: /// /api/member?手机号=x → EncryptedCall("/api/member", {"手机号":"x"}); /// /api/proj/list?手机号=x → EncryptedCall("/api/proj/list", {"手机号":"x","密码":...})。 public static string Get(string apiAndQuery) { string api = apiAndQuery, query = ""; int q = apiAndQuery.IndexOf('?'); if (q >= 0) { api = apiAndQuery.Substring(0, q); query = apiAndQuery.Substring(q + 1); } var body = new System.Text.StringBuilder(); body.Append("{"); bool first = true; foreach (var kv in query.Split('&')) { if (kv.Length == 0) continue; int eq = kv.IndexOf('='); string k = eq >= 0 ? kv.Substring(0, eq) : kv; string v = eq >= 0 ? kv.Substring(eq + 1) : ""; if (!first) body.Append(","); first = false; body.Append("\"").Append(JsonEsc(Uri.UnescapeDataString(k))).Append("\":\"") .Append(JsonEsc(Uri.UnescapeDataString(v))).Append("\""); } // /api/proj/list 新协议要求带密码:自动补当前登录密码(Trim 防不可见空白) if (api == "/api/proj/list" && body.ToString().IndexOf("\"密码\"") < 0) { string pwd = (MainForm.当前登录密码 ?? "").Trim(); if (!first) body.Append(","); body.Append("\"密码\":\"").Append(JsonEsc(pwd)).Append("\""); try { MainForm.写日志("[云请求] 列表 密码长=" + pwd.Length); } catch { } } body.Append("}"); return 客户端().EncryptedCall(api, body.ToString()); } private static string JsonEsc(string s) { if (s == null) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\"") .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); } /// 项目云上传(TLS 直连,JSON头+二进制)。json头 须含 手机号/编号/名称/时间; /// 密码自动补 MainForm.当前登录密码(Trim 防不可见空白)。 public static string PostBinary(string api, string json头, byte[] 二进制) { if (api != "/api/proj/upload") throw new Exception("PostBinary 只支持 /api/proj/upload"); // 从头里取手机号;密码自动补 string 手机号 = 取JSON字段值(json头, "手机号"); string pwd = (MainForm.当前登录密码 ?? "").Trim(); // 原头可能已带密码则不再加(目前没有);统一重拼:去掉首尾大括号,插入 密码 string 内 = json头.Trim(); if (内.StartsWith("{")) 内 = 内.Substring(1); if (内.EndsWith("}")) 内 = 内.Substring(0, 内.Length - 1); string 新头 = "{" + 内 + (内.Length > 0 ? "," : "") + "\"密码\":\"" + JsonEsc(pwd) + "\"}"; return 客户端().UploadProject(手机号, pwd, 取JSON字段值(json头, "编号"), 取JSON字段值(json头, "名称"), 二进制); } /// 项目云下载到文件(TLS 直连,URL 自动带 密码)。apiAndQuery 须为 /// /api/proj/download?手机号=..&编号=.. 形态。 public static long DownloadToFile(string apiAndQuery, string 另存路径) { string 手机号 = 取查询参数(apiAndQuery, "手机号"); string 编号 = 取查询参数(apiAndQuery, "编号"); string pwd = (MainForm.当前登录密码 ?? "").Trim(); // Trim 防不可见空白 byte[] data = 客户端().DownloadProject(手机号, pwd, 编号); File.WriteAllBytes(另存路径, data); return data.LongLength; } /// 从扁平 JSON 取顶层字符串字段值。 private static string 取JSON字段值(string json, string key) { if (string.IsNullOrEmpty(json)) return ""; string k = "\"" + key + "\":\""; int i = json.IndexOf(k, StringComparison.Ordinal); if (i < 0) return ""; i += k.Length; var sb = new StringBuilder(); while (i < json.Length && json[i] != '"') { if (json[i] == '\\' && i + 1 < json.Length) { i++; sb.Append(json[i] == 'n' ? '\n' : json[i]); } else sb.Append(json[i]); i++; } return sb.ToString(); } /// 从查询串取参数值(已 URL 解码)。 private static string 取查询参数(string apiAndQuery, string key) { int q = apiAndQuery.IndexOf('?'); if (q < 0) return ""; foreach (var kv in apiAndQuery.Substring(q + 1).Split('&')) { int eq = kv.IndexOf('='); string k = eq >= 0 ? kv.Substring(0, eq) : kv; // ★ 参数名可能是 URL 编码过(%E6%89%8B...)也可能是原文——解码后再比对, // 否则中文 key 永远匹配不上,下载时手机号/编号全为空(服务端报"手机号或密码错误") string 解码k; try { 解码k = Uri.UnescapeDataString(k); } catch { 解码k = k; } if (解码k == key) return Uri.UnescapeDataString(eq >= 0 ? kv.Substring(eq + 1) : ""); } return ""; } /// 停由本程序启动的服务进程(主窗口关闭时调用)。 public static void Shutdown() { var p = _startedProc; _startedProc = null; if (p == null) return; try { if (!p.HasExited) p.Kill(); } catch { } finally { try { p.Dispose(); } catch { } } } } }