381 lines
18 KiB
C#
381 lines
18 KiB
C#
// ============================================================
|
||
// 会员服务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 格式
|
||
/// (<会员服务 服务器地址="..."/>)照常识别。手改文件后重开程序生效。</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 { } }
|
||
}
|
||
}
|
||
}
|