Files

381 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 会员服务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
{
/// <summary>会员服务端口(与 server.cjs 一致)。</summary>
public const int 服务端口 = 12701;
/// <summary>由本程序启动的 node 进程(关闭时只停它)。</summary>
private static Process _startedProc;
/// <summary>默认会员服务器(HTTPS 加密域名,v1.4.0)。首次运行/配置缺失/读失败时的兜底。</summary>
private const string 默认服务器 = "https://ss61.odn.cc";
/// <summary>服务器地址配置文件(exe 旁 shezhi\会员服务.config.xml)。
/// 默认=HTTPS 域名服务器 https://ss61.odn.cc(用户指定,2026-09-19)。
/// ★2026-09-24 支持 ## 注释(方便测试/正式服务器切换):## 或 # 开头的行是注释,
/// 第一个非注释有效行生效(行内" ##"后的内容也是注释);老 XML 格式
/// (&lt;会员服务 服务器地址="..."/&gt;)照常识别。手改文件后重开程序生效。</summary>
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 默认服务器; }
}
}
/// <summary>地址规范化:空回默认;缺 http 前缀补 https(新协议必须 TLS);去尾斜杠。</summary>
private static string 规范化地址(string addr)
{
addr = (addr ?? "").Trim();
if (addr.Length == 0) return 默认服务器;
if (!addr.StartsWith("http")) addr = "https://" + addr;
return addr.TrimEnd('/');
}
/// <summary>当前配置是否指向远程服务器(非本机回环)。</summary>
public static bool 是远程
{
get
{
string a = 服务器地址;
return a.IndexOf("127.0.0.1", StringComparison.Ordinal) < 0 &&
a.IndexOf("localhost", StringComparison.OrdinalIgnoreCase) < 0;
}
}
/// <summary>远程服务可达性探测(GET /api/pubkey——新版加密服务的公钥端点,5 秒超时;
/// 只是预检,失败不拦截后续真实调用——正式加密通道报的错误更准确)。</summary>
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; }
}
/// <summary>服务根目录(exe 旁 会员服务器\;开发机回退项目根)。</summary>
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;
}
}
/// <summary>服务基地址(来自配置:本地默认 127.0.0.1:12701,或 Linux 服务器)。</summary>
public static string 基地址
{
get { return 服务器地址; }
}
/// <summary>端口是否在监听。</summary>
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;
}
/// <summary>确保服务在线。
/// 远程模式(配置指向 Linux 服务器):只探测可达性,不拉起本地进程——服务由服务器侧维护。
/// 本地模式:端口已通=复用;不通=拉起 node.exe + server.cjs 并等就绪。返回 false=不可用。</summary>
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();
/// <summary>取加密客户端(懒初始化:首次拉服务器公钥;失败抛可读异常)。</summary>
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 _加密客户端;
}
}
/// <summary>配置变化(改了服务器地址)后调用:强制下次重新拉公钥。</summary>
public static void 重置加密客户端()
{
lock (_加密锁) { _加密客户端 = null; }
}
/// <summary>POST 业务接口(信封加密)。api 如 /api/login;json 为业务 JSON;返回解密后的结果 JSON。
/// ★ /api/proj/delete 服务端 v1.5.1 起要求 手机号+密码+编号——调用方没带密码时
/// 自动补当前登录密码(与 Get 的 /api/proj/list、PostBinary 的 upload 同款注入)。</summary>
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);
}
/// <summary>业务查询(信封加密)。兼容旧 Get(apiAndQuery) 调用形态:
/// /api/member?手机号=x → EncryptedCall("/api/member", {"手机号":"x"});
/// /api/proj/list?手机号=x → EncryptedCall("/api/proj/list", {"手机号":"x","密码":...})。</summary>
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");
}
/// <summary>项目云上传(TLS 直连,JSON头+二进制)。json头 须含 手机号/编号/名称/时间;
/// 密码自动补 MainForm.当前登录密码(Trim 防不可见空白)。</summary>
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头, "名称"), 二进制);
}
/// <summary>项目云下载到文件(TLS 直连,URL 自动带 密码)。apiAndQuery 须为
/// /api/proj/download?手机号=..&编号=.. 形态。</summary>
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;
}
/// <summary>从扁平 JSON 取顶层字符串字段值。</summary>
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();
}
/// <summary>从查询串取参数值(已 URL 解码)。</summary>
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 "";
}
/// <summary>停由本程序启动的服务进程(主窗口关闭时调用)。</summary>
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 { } }
}
}
}