基线:全量源码首提(D1 版本控制落地,含第一轮优化 B1-B4/A1/A4/缩放修复)

This commit is contained in:
dqb-dev
2026-10-01 22:15:22 +08:00
commit 1e65792b09
499 changed files with 204579 additions and 0 deletions
+380
View File
@@ -0,0 +1,380 @@
// ============================================================
// 会员服务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 { } }
}
}
}