diff --git a/会员服务器/ICON.PNG b/会员服务器/ICON.PNG
new file mode 100755
index 0000000..5e0cd57
Binary files /dev/null and b/会员服务器/ICON.PNG differ
diff --git a/会员服务器/ICON_256.PNG b/会员服务器/ICON_256.PNG
new file mode 100755
index 0000000..ef85779
Binary files /dev/null and b/会员服务器/ICON_256.PNG differ
diff --git a/会员服务器/README.md b/会员服务器/README.md
new file mode 100755
index 0000000..d60440a
--- /dev/null
+++ b/会员服务器/README.md
@@ -0,0 +1,146 @@
+# fnnas.huiyuan 会员服务器(飞牛 fnOS 应用)
+
+成套报价软件的会员系统服务器,打包为飞牛 fnOS 原生应用(.fpk)。v1.4.0 起全站强制 HTTPS(自签证书)+ 应用层信封加密。
+
+- **产物**:`fnnas.huiyuan_1.4.0_all.fpk`
+- **默认端口**:12701(安装向导可改)
+- **运行时**:飞牛自带 Node.js(`install_dep_apps=nodejs_v22`),零第三方依赖
+- **源码来源**:`/vol2/1000/fnosyy/会员服务器/`(server.cjs 改造 + www 页面移植)
+
+## 功能
+
+| 功能 | 说明 |
+|---|---|
+| 客户端 API | 凭据类(登录/注册/查会员/改密)走 /api/enc 信封加密;项目云走 TLS 直连;业务字段与原版一致 |
+| 项目云 API | `/api/proj/upload`(JSON头+空行+zip 二进制,覆盖式,上限 2GB)、`/api/proj/list`、`/api/proj/download`、`/api/proj/delete`;上传校验会员、文件名安全化、防路径穿越 |
+| 会员管理页 | `http://NAS_IP:端口/`(admin.html) |
+| 项目云管理页 | `/xiangmu-cloud.html` 按会员查看/下载/删除云上项目 |
+| 库备份管理页 | `/lib-cloud.html` 只显示 LIB_ 前缀条目(方案库/用户库/柜体库/铜排铝排库/企业信息) |
+| 管理登录 | 四页面需管理密码登录(安装向导设置),设置页可改密码/关闭注册 |
+| 设置页 | `/settings.html` 显示端口/版本/会员数/数据库文件/项目云目录,切换数据目录(连库+项目云一起迁移) |
+| 安装向导 | 端口(默认 12701)+ 数据目录(可选,留空=默认)+ 管理密码(必填 ≥6 位) |
+| 全站加密 | 唯一 HTTPS 端口;TLS 自签证书自动生成;信封加密(RSA+AES+HMAC+防重放);证书可在设置页下载 |
+| 装后改端口 | 应用中心 → 会员服务器 → 应用设置(留空=不修改) |
+| 目录授权 | 应用设置 → 目录授权,或设置页"授权新目录"按钮(管理员,需 fnOS ≥ 1.2.0401) |
+
+## 客户端接口(v1.2.0 起项目云需带密码——客户端必须同步升级)
+
+| 接口 | 客户端改动 |
+|---|---|
+| `POST /api/login` | 不变 |
+| `POST /api/register` | 不变(服务端校验 11 位手机号、密码 4-64 位) |
+| `GET /api/member` | 不变 |
+| `POST /api/changepwd` | 不变 |
+| `POST /api/proj/upload` | JSON 头新增 `"密码": "该会员密码"` |
+| `GET /api/proj/list` | URL 新增 `&密码=<该会员密码>`(URL 编码) |
+| `GET /api/proj/download` | 同上 |
+| `POST /api/proj/delete` | body 新增 `"密码": "该会员密码"` |
+
+不带上传/查询密码的项目云请求会返回 `{ok:false, msg:"手机号或密码错误"}`。
+
+## 全站 HTTPS 与双层加密(v1.4.0)
+
+**全站只有一个 HTTPS 端口**(默认 12701,安装向导可改),HTTP 明文通道已彻底停用——浏览器打开 `http://IP:端口` 无法连接,必须使用 `https://IP:端口`。
+
+- 自签证书首次启动自动生成(OpenSSL,存应用配置目录);设置页可查看 TLS 证书指纹 / RSA 公钥指纹并**下载证书**(安装到 Windows"受信任的根证书颁发机构"后浏览器不再警告)
+- 安全响应头:HSTS(max-age 1 年)、nosniff、X-Frame-Options
+- TLS 初始化失败服务拒绝启动(强制 HTTPS 是硬性要求)
+
+### 第 2 层:应用层信封加密(客户端凭据类通讯)
+
+即使 TLS 被绕过(如中间人持自签证书),业务数据仍是密文:
+
+```
+① GET /api/pubkey (HTTPS) → 服务器 RSA-2048 公钥 + 公钥指纹
+② 每次请求(登录/注册/查会员/改密/项目云列表/删除):
+ aesKey = 随机32字节; iv = 随机16字节; nonce = 随机16字节hex; ts = 毫秒时间戳
+ data = BASE64( AES-256-CBC(iv, {"path":"/api/login","body":{...业务参数}}) )
+ k = BASE64( RSA-OAEP-SHA1(公钥, aesKey) ) ← 私钥解,永不出服务器
+ mac = HEX( HMAC-SHA256(aesKey, ts+"."+nonce+"."+iv+"."+data) )
+ POST /api/enc {"k","iv","ts","nonce","mac","data"}
+③ 服务器:RSA解封→AES解密→验签→时间戳±5分钟+nonce防重放→内部路由→同信封格式加密响应
+④ 客户端验签解密响应
+```
+
+- 项目 zip 上传/下载走 TLS 直连(大文件不做信封,`密码` 参数照旧)
+- 管理页(admin/xiangmu/lib/settings)在 HTTPS 下照常使用管理密码登录
+
+### 客户端改动清单(C# 参考实现见项目内 `客户端对接示例.cs`)
+
+1. 地址改为 `https://NAS_IP:12701`;信任自签证书(回调跳过或安装证书)
+2. 旧 .NET 启用 TLS1.2:`ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072`
+3. 登录/注册/查会员/改密/项目云列表/删除 → 走 `EncryptedCall(path, json)` 信封封装
+4. 项目上传/下载 → 走 TLS 直连(同 v1.2.0 加 `密码` 参数的方式)
+5. 业务字段与响应格式与之前完全一致
+
+### 信封错误响应(明文返回,便于客户端提示)
+
+| 场景 | 响应 |
+|---|---|
+| 时间戳超出 ±5 分钟 | `{"ok":false,"msg":"时间戳超出允许窗口(±5分钟)"}` |
+| 重放同一 nonce | `{"ok":false,"msg":"重复的请求(疑似抓包重放),已拒绝"}` |
+| 密文/HMAC 被篡改 | `{"ok":false,"msg":"签名校验失败(数据可能被篡改)"}` |
+| 信封字段缺失/损坏 | `{"ok":false,"msg":"信封字段缺失"}` 等 |
+
+## 安全模型(v1.4.0)
+
+- **管理密码**:安装向导必填(sha256+盐存 app.env),登录管理页面与保护 `/api/list`、`/api/save`、`/api/settings`、`/api/admin/changepwd`;token 24h 过期;修改管理密码在设置页"安全"卡片。忘记密码:应用设置(config 向导)重新设置,或重装时在 env 里传 `wizard_admin_pwd`。
+- **会员密码哈希存储**(v1.3.0,客户端零改动):存 `盐(32hex)$sha256(64hex)`,每会员独立盐;服务启动自动把存量明文升级为哈希(先备份,成功后用哈希版刷新 .bak)。管理页密码列不显示密码——输入新值即重置,留空保持不变。`/api/save` 密码语义:空=保持、明文=重置、哈希格式=原样。
+- **项目云鉴权**:会员密码或管理 token 二选一。
+- **限速**:登录/注册/管理登录失败按 IP 限速(1 分钟 10 次锁 60 秒,重启清零)。
+- **注册开关**:设置页可关闭开放注册(`HY_REG_OPEN`)。
+- **配额与备份**:每会员项目云 1GB;整表保存/迁移前自动 `huiyuan.json.bak`(tmp+rename 原子写,兼容飞牛 ACL 000 权限文件)。
+- 已知边界:传输为 HTTP(密码在局域网传输中可见,客户端限制;哈希解决的是数据库文件泄露问题);限速/token 在内存(重启清零)。
+
+## 数据目录解析顺序(数据库 huiyuan.json 与 项目云/ 都在这里)
+
+1. `HY_DB_DIR`(向导指定 / 设置页切换,写入 `TRIM_PKGETC/app.env`)
+2. `TRIM_DATA_SHARE_PATHS`(应用数据目录:文件管理 → 应用文件 → fnnas.huiyuan/数据目录)
+3. `TRIM_PKGVAR/files`
+
+目录不可写自动降级到下一级;设置页显示实际生效路径。切换目录时自动迁移 `huiyuan.json` + `项目云/`。
+
+## 目录结构
+
+```
+fnnas.huiyuan/
+├── manifest # service_port=12701, install_dep_apps=nodejs_v22, micro_app=true
+├── wizard/install|config # 安装向导 / 装后配置向导(wizard_port、wizard_dbdir)
+├── app/server/server.cjs # 主服务(纯 Node http,含项目云 API);首次启动自动建空会员库
+├── app/www/ # admin + xiangmu-cloud + lib-cloud + settings + sdk/
+├── app/ui/config # 桌面入口,port 用 ${wizard_port} 占位符
+├── cmd/ # main(start/stop/status) + install/config 回调(写 app.env)
+└── config/ # privilege(run-as package) + resource(data-share + api-scope)
+```
+
+## 打包
+
+```bash
+cd /vol2/1000/fnosyy
+.agents/skills/fnos-fpk-packaging/fnpack-1.2.3-linux-amd64 build -d fnnas.huiyuan
+# 注意:打包前确认 app/ 内文件权限位正常(644),源目录复制来的文件可能是 000
+```
+
+## NAS 升级流程(install-fpk 不支持原地升级)
+
+```bash
+printf 'wizard_port=12701\nwizard_dbdir=/vol2/1000/成套报价软件数据库\n' > /tmp/hy-env
+appcenter-cli stop fnnas.huiyuan && appcenter-cli uninstall fnnas.huiyuan
+appcenter-cli install-fpk fnnas.huiyuan_X.Y.Z_all.fpk -e /tmp/hy-env
+appcenter-cli start fnnas.huiyuan
+# 卸载会回收目录授权;数据目录在应用目录外不受影响
+```
+
+## 本地测试(模拟飞牛环境)
+
+```bash
+HY_PORT=18701 TRIM_PKGETC=/tmp/etc TRIM_PKGVAR=/tmp/var \
+TRIM_DATA_SHARE_PATHS=/tmp/share node app/server/server.cjs
+```
+
+## 注意
+
+- 客户端(Windows 成套报价软件)连接地址需改为 `飞牛主机IP:端口`(原为 127.0.0.1:12701)
+- 项目云接口自 v1.2.0 起需带会员密码(见上方对照表);登录/注册/查会员/改密协议始终不变
+- 开放 API(目录授权查询)在非飞牛环境会优雅降级,不影响其他功能
+- 项目云存储在数据目录(DB_DIR/项目云),与源码 Windows 版(ROOT/项目云)路径规则不同
diff --git a/会员服务器/app/server/server.cjs b/会员服务器/app/server/server.cjs
new file mode 100755
index 0000000..b08add9
--- /dev/null
+++ b/会员服务器/app/server/server.cjs
@@ -0,0 +1,911 @@
+// 会员系统服务器(零依赖纯 node http)
+// · 端口: HY_PORT → MEMBER_PORT → TRIM_SERVICE_PORT → 12701;
+// 绑定 MEMBER_HOST 或 0.0.0.0(供局域网内成套报价软件访问)
+// · 数据库存 huiyuan.json,目录解析(逐级回退,目录不可写自动降级):
+// HY_DB_DIR(向导/网页设置指定) → TRIM_DATA_SHARE_PATHS 第一项 → TRIM_PKGVAR/files → 本目录(本地开发)
+// · 项目云数据落 DB_DIR/项目云/<手机号安全化>/<编号安全化>/{项目.zip,meta.json}
+// · 配置持久化: TRIM_PKGETC/app.env(KEY=VALUE),与 cmd 脚本共用,存 HY_PORT/HY_DB_DIR
+// · 客户端 API(与原版完全兼容):
+// POST /api/login {手机号,密码} → {ok,会员类型,有效期} | {ok:false,msg}
+// POST /api/register {手机号,密码} → {ok} 注册为"试用会员",有效期=今天+30天
+// GET /api/member?手机号=xxx → {ok,手机号,会员类型,有效期}
+// POST /api/changepwd {手机号,旧密码,新密码} → {ok}
+// GET /api/list → {ok,会员:[...]}(管理页用)
+// POST /api/save {会员:[...]} → {ok}(管理页整表保存)
+// · 项目云 API(2026-09-16):
+// POST /api/proj/upload body=JSON头 + \n\n + zip二进制 → {ok,编号,大小,时间}(覆盖式,上限 2GB)
+// GET /api/proj/list?手机号= → {ok,项目:[{编号,名称,时间,大小}]}
+// GET /api/proj/download?手机号=&编号= → zip 二进制流
+// POST /api/proj/delete {手机号,编号} → {ok}
+// 上传前校验会员已注册(huiyuan.json);手机号/编号文件名安全化+防路径穿越;
+// 元数据存各项目目录 meta.json(项目名/上传时间/大小)。
+// · 设置 API(网页设置页用):
+// GET /api/settings → 端口/数据目录/数据库与项目云路径/默认目录/已授权目录/会员数
+// POST /api/settings {dbdir} → 校验可写→迁移 huiyuan.json+项目云→回写 app.env
+// 已授权目录经 Unix Socket 调开放 API trim.file.getSharedAccessibleFolders(失败优雅降级)
+// · 安全(v1.4.0 双层加密通讯):
+// 第1层 TLS:HTTPS 端口(自动=网页端口+1,可用 HY_API_PORT 覆盖)供客户端,
+// 自签证书首次启动由 openssl 自动生成(存配置目录,升级保留/重装重生成)
+// 第2层 信封加密(/api/enc,仅 HTTPS):客户端随机 AES-256 密钥加密业务 JSON,
+// AES 密钥用 RSA-2048 公钥封装(OAEP),服务端私钥永不出服务器;
+// HMAC-SHA256 验签防篡改;时间戳±5分钟+随机数缓存防重放
+// 登录/注册/查会员/改密/项目云列表与删除走双层;项目 zip 上传下载走 TLS 单层(大文件)
+// 客户端接口已从 HTTP 端口撤下(返回 TLS_REQUIRED 提示);HTTP 端口仅剩网页管理(管理密码保护)
+// 管理接口(/api/list /api/save /api/settings /api/admin/changepwd)需管理密码登录:
+// POST /api/admin/login 换 token,后续请求带 X-Admin-Token 头(24h 过期;自定义头同时天然防 CSRF)
+// 登录/注册/管理登录失败按 IP 限速(1 分钟内失败≥10 次锁 60 秒)
+// 注册校验手机号 11 位/密码 4-64 位,HY_REG_OPEN=0 可关闭开放注册(设置页开关)
+// 上传配额每会员 1GB;整表保存与目录迁移前滚动备份 huiyuan.json.bak
+// · 页面: / 或 /admin.html(会员管理) /xiangmu-cloud.html(项目云) /lib-cloud.html(库备份) /settings.html(设置)
+var http = require('http');
+var fs = require('fs');
+var path = require('path');
+var crypto = require('crypto');
+var execFileSync = require('child_process').execFileSync;
+
+var VERSION = '1.5.1';
+var APPNAME = 'fnnas.huiyuan';
+var HOST = process.env.MEMBER_HOST || '0.0.0.0';
+var DB_NAME = 'huiyuan.json';
+var PROJ_DIR_NAME = '项目云';
+var UPLOAD_MAX = 2 * 1024 * 1024 * 1024; // 单次上传上限 2GB,防磁盘打满
+var USER_QUOTA = 1024 * 1024 * 1024; // 每会员项目云总配额 1GB
+
+// ---- 开放 API(应用共享授权):仅后端可通过 Unix Socket 调用 ----
+var API_SOCKET = '/var/run/trim_open_gateway_apiscope.socket';
+var API_PATH = '/api/v1/trimapp';
+
+// ---- 端口 ----
+var PORT = parseInt(process.env.HY_PORT || process.env.MEMBER_PORT || '', 10)
+ || parseInt(process.env.TRIM_SERVICE_PORT || '', 10)
+ || 12701;
+var WWW = path.join(__dirname, '..', 'www');
+
+// ---- 配置文件(TRIM_PKGETC/app.env,cmd 脚本与本服务共用) ----
+var CFG_FILE = process.env.HY_CFG
+ || (process.env.TRIM_PKGETC ? path.join(process.env.TRIM_PKGETC, 'app.env') : null);
+function 读配置() {
+ var cfg = {};
+ if (!CFG_FILE) return cfg;
+ try {
+ fs.readFileSync(CFG_FILE, 'utf8').split(/\r?\n/).forEach(function (line) {
+ var m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+ if (m) cfg[m[1]] = m[2];
+ });
+ } catch (e) { /* 配置不存在用默认值 */ }
+ return cfg;
+}
+function 写配置(cfg) {
+ if (!CFG_FILE) return; // 本地开发无配置文件,仅内存生效
+ var tmp = CFG_FILE + '.tmp';
+ fs.writeFileSync(tmp, Object.keys(cfg).map(function (k) { return k + '=' + (cfg[k] || ''); }).join('\n') + '\n', 'utf8');
+ fs.renameSync(tmp, CFG_FILE);
+}
+
+// ---- 管理密码鉴权(sha256(盐+密码) 存 app.env;token 在内存,重启失效) ----
+function sha256hex(s) { return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); }
+function 管理密码已设置() {
+ var cfg = 读配置();
+ return !!(cfg.ADMIN_PWD_SALT && cfg.ADMIN_PWD_SHA256);
+}
+function 管理密码正确(密码) {
+ var cfg = 读配置();
+ if (!cfg.ADMIN_PWD_SALT || !cfg.ADMIN_PWD_SHA256) return false;
+ var got = sha256hex(cfg.ADMIN_PWD_SALT + String(密码 || ''));
+ var want = String(cfg.ADMIN_PWD_SHA256).toLowerCase();
+ if (got.length !== want.length) return false;
+ try { return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want)); }
+ catch (e) { return false; }
+}
+function 设置管理密码(新密码) {
+ var salt = crypto.randomBytes(8).toString('hex');
+ var cfg = 读配置();
+ cfg.ADMIN_PWD_SALT = salt;
+ cfg.ADMIN_PWD_SHA256 = sha256hex(salt + 新密码);
+ 写配置(cfg);
+}
+var TOKENS = new Map(); // token → 过期时间戳
+var TOKEN_TTL = 24 * 3600 * 1000; // 24 小时
+function 新token() {
+ var t = crypto.randomBytes(24).toString('hex');
+ TOKENS.set(t, Date.now() + TOKEN_TTL);
+ return t;
+}
+function token有效(t) {
+ if (!t || !TOKENS.has(t)) return false;
+ if (Date.now() > TOKENS.get(t)) { TOKENS.delete(t); return false; }
+ return true;
+}
+function 是管理员(req) { return token有效(req.headers['x-admin-token']); }
+setInterval(function () {
+ var now = Date.now();
+ TOKENS.forEach(function (exp, t) { if (now > exp) TOKENS.delete(t); });
+ 清理NONCES();
+}, 3600 * 1000).unref();
+
+// ---- 限速:按 key 记失败,1 分钟内失败≥10 次锁 60 秒(内存,重启清零) ----
+var FAILS = new Map(); // key → {n, window, lock}
+function 被锁秒(key) {
+ var f = FAILS.get(key);
+ return f && f.lock && Date.now() < f.lock ? Math.ceil((f.lock - Date.now()) / 1000) : 0;
+}
+function 失败记录(key) {
+ var now = Date.now();
+ var f = FAILS.get(key);
+ if (!f || now > f.window) f = { n: 0, window: now + 60000, lock: 0 };
+ f.n++;
+ if (f.n >= 10) f.lock = now + 60000;
+ FAILS.set(key, f);
+}
+function 失败清零(key) { FAILS.delete(key); }
+
+// ---- 配置目录(TLS 证书/RSA 密钥存放处) ----
+var CFG_DIR = CFG_FILE ? path.dirname(CFG_FILE) : __dirname;
+
+// ---- RSA-2048 密钥对(信封加密;私钥永不出服务器,首次启动生成并持久化) ----
+var RSA_PRIV_FILE = path.join(CFG_DIR, 'huiyuan_rsa.pem');
+var RSA_KEY = null;
+try {
+ if (!fs.existsSync(RSA_PRIV_FILE)) {
+ var kp = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
+ });
+ fs.writeFileSync(RSA_PRIV_FILE, kp.privateKey, { mode: 0o600 });
+ }
+ RSA_KEY = crypto.createPrivateKey(fs.readFileSync(RSA_PRIV_FILE));
+} catch (e) { console.warn('RSA 密钥初始化失败(加密通道不可用): ' + e.message); }
+function 公钥PEM() { return crypto.createPublicKey(RSA_KEY).export({ type: 'spki', format: 'pem' }).toString(); }
+function 公钥指纹() { // SHA256 前 16 位 hex,供管理员核验公钥身份
+ var der = crypto.createPublicKey(RSA_KEY).export({ type: 'spki', format: 'der' });
+ return crypto.createHash('sha256').update(der).digest('hex').slice(0, 16).toUpperCase();
+}
+
+// ---- 信封加密(应用层第2层):RSA-OAEP 封装 AES-256 密钥 + AES-CBC 加密 + HMAC 验签 + 防重放 ----
+var NONCES = new Map(); // nonce → 过期时间戳(防重放)
+function 清理NONCES() {
+ var now = Date.now();
+ NONCES.forEach(function (exp, n) { if (now > exp) NONCES.delete(n); });
+}
+function 解密信封(payload, ip) {
+ try {
+ if (!RSA_KEY) return { err: '服务端加密未初始化' };
+ if (!payload || !payload.k || !payload.iv || !payload.data || !payload.mac || !payload.ts || !payload.nonce)
+ return { err: '信封字段缺失' };
+ var aesKey = crypto.privateDecrypt(
+ { key: RSA_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
+ Buffer.from(payload.k, 'base64'));
+ if (aesKey.length !== 32) return { err: '会话密钥长度非法' };
+ var ts = Number(payload.ts);
+ if (!ts || Math.abs(Date.now() - ts) > 300000) return { err: '时间戳超出允许窗口(±5分钟)' };
+ if (NONCES.has(String(payload.nonce))) return { err: '重复的请求(疑似抓包重放),已拒绝' };
+ var want = crypto.createHmac('sha256', aesKey)
+ .update(payload.ts + '.' + payload.nonce + '.' + payload.iv + '.' + payload.data).digest('hex');
+ if (want !== String(payload.mac)) return { err: '签名校验失败(数据可能被篡改)' };
+ var d = crypto.createDecipheriv('aes-256-cbc', aesKey, Buffer.from(payload.iv, 'hex'));
+ var inner = JSON.parse(Buffer.concat([d.update(Buffer.from(payload.data, 'base64')), d.final()]).toString('utf8'));
+ return { aesKey: aesKey, inner: inner, nonce: String(payload.nonce) };
+ } catch (e) { return { err: '信封解密失败: ' + e.message }; }
+}
+function 加密响应(aesKey, obj) {
+ var ivb = crypto.randomBytes(16);
+ var c = crypto.createCipheriv('aes-256-cbc', aesKey, ivb);
+ var dataB = Buffer.concat([c.update(Buffer.from(JSON.stringify(obj), 'utf8')), c.final()]).toString('base64');
+ var iv = ivb.toString('hex');
+ var mac = crypto.createHmac('sha256', aesKey).update(iv + '.' + dataB).digest('hex');
+ return { ok: true, iv: iv, mac: mac, data: dataB };
+}
+// 信封内允许的业务接口(凭据类小数据;zip 上传下载走 TLS 单层)
+var ENC_ROUTES = {
+ '/api/login': function (b, req, ip) { return 登录(b || {}, ip); },
+ '/api/register': function (b, req, ip) { return 注册(b || {}, ip); },
+ '/api/member': function (b) { return 查会员(b || {}); },
+ '/api/changepwd': function (b) { return 改密(b || {}); },
+ '/api/proj/list': function (b, req) {
+ if (!项目云鉴权(b['手机号'], b['密码'], req)) return { ok: false, msg: '手机号或密码错误' };
+ return 列项目(b['手机号']);
+ },
+ '/api/proj/delete': function (b, req) {
+ if (!项目云鉴权(b['手机号'], b['密码'], req)) return { ok: false, msg: '手机号或密码错误' };
+ var d3 = b['手机号'] && b['编号'] ? 项目目录(b['手机号'], b['编号']) : null;
+ if (!d3) return { ok: false, msg: '参数错误' };
+ try { fs.rmSync(d3, { recursive: true, force: true }); return { ok: true }; }
+ catch (e2) { return { ok: false, msg: '删除失败: ' + e2.message }; }
+ }
+};
+
+// ---- 数据库目录解析 ----
+function 目录不可写原因(dir) {
+ try {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ var t = path.join(dir, '.huiyuan-write-test');
+ fs.writeFileSync(t, '1'); fs.unlinkSync(t);
+ return null; // 可写
+ } catch (e) { return (e.code ? e.code + ' ' : '') + e.message; }
+}
+function 目录可写(dir) { return 目录不可写原因(dir) === null; }
+function 默认目录() {
+ var share = (process.env.TRIM_DATA_SHARE_PATHS || '').split(':')[0];
+ if (share) return share; // 应用数据目录(文件管理→应用文件)
+ if (process.env.TRIM_PKGVAR) return path.join(process.env.TRIM_PKGVAR, 'files');
+ return __dirname; // 本地开发回退
+}
+var DEFAULT_DIR = 默认目录();
+var DB_DIR;
+(function () {
+ var cands = [];
+ var cfgDbDir = 读配置().HY_DB_DIR || process.env.HY_DB_DIR || '';
+ if (cfgDbDir) cands.push(cfgDbDir);
+ cands.push(DEFAULT_DIR);
+ if (process.env.TRIM_PKGVAR) cands.push(path.join(process.env.TRIM_PKGVAR, 'files'));
+ cands.push(__dirname);
+ for (var i = 0; i < cands.length; i++) {
+ if (目录可写(cands[i])) { DB_DIR = cands[i]; return; }
+ }
+ DB_DIR = __dirname;
+})();
+var DB_FILE = path.join(DB_DIR, DB_NAME);
+
+// 启动时确保数据库文件存在:缺失则落一个空会员库(安装包不带任何会员数据)
+if (!fs.existsSync(DB_FILE)) {
+ try { 写库({ '会员': [] }); console.log('数据库不存在,已创建空会员库: ' + DB_FILE); }
+ catch (e) { console.warn('空数据库创建失败(首次保存时再写入): ' + e.message); }
+}
+
+// ---- 会员密码哈希(v1.3.0):存储格式 盐(32hex)$哈希(64hex),每会员独立随机盐 ----
+// 协议不变:客户端仍传明文,服务端哈希后比对/存储
+var 哈希格式 = /^[0-9a-f]{32}\$[0-9a-f]{64}$/;
+function 哈希密码(明文) {
+ var salt = crypto.randomBytes(16).toString('hex');
+ return salt + '$' + sha256hex(salt + String(明文));
+}
+function 校验密码(存储值, 输入) {
+ var stored = String(存储值 || '');
+ var input = String(输入 === undefined || 输入 === null ? '' : 输入);
+ if (!stored) return false;
+ if (哈希格式.test(stored)) {
+ var i = stored.indexOf('$');
+ return sha256hex(stored.slice(0, i) + input) === stored.slice(i + 1);
+ }
+ return stored === input; // 迁移前的明文兜底
+}
+// 启动时把明文密码一次性升级为哈希(迁移前备份,成功后用哈希版刷新备份,磁盘不残留明文)
+(function 升级哈希() {
+ try {
+ var d = 读库(), changed = 0;
+ d['会员'].forEach(function (m) {
+ if (m['密码'] && !哈希格式.test(String(m['密码']))) { m['密码'] = 哈希密码(m['密码']); changed++; }
+ });
+ if (changed) {
+ 备份库(); // 迁移前备份(此刻 .bak 为明文版)
+ 写库(d);
+ 备份库(); // 用哈希版覆盖备份
+ console.log('已把 ' + changed + ' 个会员密码升级为哈希存储');
+ }
+ } catch (e) { console.warn('密码哈希迁移失败(保持原样,下次启动重试): ' + e.message); }
+})();
+
+// ---- 数据读写(原子写:临时文件+rename) ----
+function 读库() {
+ try {
+ var d = JSON.parse(fs.readFileSync(DB_FILE, 'utf8'));
+ if (!Array.isArray(d['会员'])) d['会员'] = [];
+ return d;
+ } catch (e) { return { '会员': [] }; }
+}
+function 备份库() { // 整表保存/迁移前滚动备份上一版,防误操作丢数据
+ try {
+ if (fs.existsSync(DB_FILE)) {
+ // 不能用 copyFileSync 直接覆盖:飞牛 ACL 下已有文件可能是 000 权限,打开即 EACCES;
+ // 与 写库 一致走 tmp+rename(只需目录可写)
+ var tmp = DB_FILE + '.bak.tmp';
+ fs.writeFileSync(tmp, fs.readFileSync(DB_FILE));
+ fs.renameSync(tmp, DB_FILE + '.bak');
+ }
+ } catch (e) { console.warn('备份失败: ' + e.message); }
+}
+function 写库(data) {
+ var tmp = DB_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
+ fs.renameSync(tmp, DB_FILE);
+}
+function 今天加天数(n) {
+ var d = new Date(Date.now() + n * 86400000);
+ return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
+}
+
+// ---- 业务 ----
+function 登录(b, ip) {
+ var lock = 被锁秒('login:' + ip);
+ if (lock) return { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' };
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++) {
+ var m = db['会员'][i];
+ if (m['手机号'] === b['手机号']) {
+ if (!校验密码(m['密码'], b['密码'])) { 失败记录('login:' + ip); return { ok: false, msg: '密码错误' }; }
+ 失败清零('login:' + ip);
+ return { ok: true, 会员类型: m['会员类型'], 有效期: m['有效期'] };
+ }
+ }
+ 失败记录('login:' + ip);
+ return { ok: false, msg: '该手机号未注册' };
+}
+function 注册(b, ip) {
+ var lock = 被锁秒('reg:' + ip);
+ if (lock) return { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' };
+ if ((读配置().HY_REG_OPEN || '1') !== '1') return { ok: false, msg: '注册已关闭,请联系管理员开通' };
+ if (!/^1\d{10}$/.test(String(b['手机号'] || ''))) return { ok: false, msg: '手机号格式不正确(11 位数字)' };
+ var p = String(b['密码'] || '');
+ if (p.length < 4 || p.length > 64) return { ok: false, msg: '密码长度需 4-64 位' };
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++)
+ if (db['会员'][i]['手机号'] === b['手机号']) { 失败记录('reg:' + ip); return { ok: false, msg: '该手机号已注册' }; }
+ 失败清零('reg:' + ip);
+ db['会员'].push({
+ '手机号': b['手机号'], '密码': 哈希密码(p),
+ '会员类型': '试用会员', '有效期': 今天加天数(30),
+ '注册时间': 今天加天数(0), '备注': ''
+ });
+ 写库(db);
+ return { ok: true, msg: '注册成功' };
+}
+function 查会员(b) {
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++) {
+ var m = db['会员'][i];
+ if (m['手机号'] === b['手机号'])
+ return { ok: true, 手机号: m['手机号'], 会员类型: m['会员类型'], 有效期: m['有效期'] };
+ }
+ return { ok: false, msg: '该手机号未注册' };
+}
+function 改密(b) {
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++) {
+ var m = db['会员'][i];
+ if (m['手机号'] === b['手机号']) {
+ if (!校验密码(m['密码'], b['旧密码'])) return { ok: false, msg: '旧密码错误' };
+ if (!b['新密码']) return { ok: false, msg: '新密码不能为空' };
+ m['密码'] = 哈希密码(b['新密码']);
+ 写库(db);
+ return { ok: true };
+ }
+ }
+ return { ok: false, msg: '该手机号未注册' };
+}
+
+// ---- 项目云(2026-09-16):数据落 DB_DIR/项目云/<手机号安全化>/<编号安全化>/{项目.zip,meta.json} ----
+function 项目根() { return path.join(DB_DIR, PROJ_DIR_NAME); } // DB_DIR 可在设置页切换,须动态取
+function 安全名(s) { // 文件名安全化:去掉路径分隔/非法字符,限长 80
+ var t = String(s || '').replace(/[\\\/:*?"<>|]/g, '_').replace(/\.\./g, '_').trim();
+ if (t.length > 80) t = t.slice(0, 80);
+ return t;
+}
+function 会员存在(手机号) {
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++)
+ if (db['会员'][i]['手机号'] === 手机号) return true;
+ return false;
+}
+function 会员密码正确(手机号, 密码) {
+ if (!密码) return false;
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++)
+ if (db['会员'][i]['手机号'] === 手机号) return 校验密码(db['会员'][i]['密码'], 密码);
+ return false;
+}
+// 项目云鉴权:管理 token 通过,或 会员手机号+密码 匹配(客户端需在各接口带上"密码")
+function 项目云鉴权(手机号, 密码, req) {
+ return 是管理员(req) || 会员密码正确(手机号, 密码);
+}
+function 目录大小(dir) { // 递归估算目录总字节,读不了的部分按 0
+ var total = 0;
+ try {
+ fs.readdirSync(dir).forEach(function (name) {
+ var p = path.join(dir, name);
+ var st = fs.statSync(p);
+ if (st.isDirectory()) total += 目录大小(p);
+ else total += st.size;
+ });
+ } catch (e) { }
+ return total;
+}
+function 项目目录(手机号, 编号) { // 防穿越:拼好后必须仍在项目云根下
+ var root = 项目根();
+ var d = path.join(root, 安全名(手机号), 安全名(编号));
+ return d.indexOf(root) === 0 ? d : null;
+}
+function 列项目(手机号) {
+ if (!手机号 || !会员存在(手机号)) return { ok: false, msg: '该手机号未注册' };
+ var dir = 项目目录(手机号, '');
+ var 出 = [];
+ try {
+ fs.readdirSync(dir).forEach(function (编号) {
+ var p = path.join(dir, 编号);
+ try {
+ var meta = JSON.parse(fs.readFileSync(path.join(p, 'meta.json'), 'utf8'));
+ 出.push({ 编号: meta['编号'] || 编号, 名称: meta['名称'] || '', 时间: meta['时间'] || '', 大小: meta['大小'] || 0 });
+ } catch (e) { }
+ });
+ } catch (e) { } // 目录不存在=无项目
+ return { ok: true, '项目': 出 };
+}
+
+// ---- 开放 API 调用(查管理员授权给本应用的目录) ----
+function 查授权目录(cb) {
+ var token = process.env.TRIM_API_TOKEN;
+ if (!token) return cb({ ok: false, msg: '未获取到 TRIM_API_TOKEN(需从飞牛应用中心启动)' });
+ if (!fs.existsSync(API_SOCKET)) return cb({ ok: false, msg: '开放 API 接口不存在:需要 fnOS 1.2.0401 及以上、App 1.34.0 及以上' });
+ var body = JSON.stringify({ reqId: String(Date.now()), req: 'trim.file.getSharedAccessibleFolders', appName: APPNAME, data: {} });
+ var req = http.request({
+ socketPath: API_SOCKET, path: API_PATH, method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'Content-Length': Buffer.byteLength(body) }
+ }, function (res) {
+ var chunks = [];
+ res.on('data', function (c) { chunks.push(c); });
+ res.on('end', function () {
+ try {
+ var j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ if (j.code === 0) return cb({ ok: true, paths: ((j.data || {}).paths || []).sort() });
+ cb({ ok: false, msg: j.msg || ('开放 API 返回 code=' + j.code) });
+ } catch (e) { cb({ ok: false, msg: '开放 API 响应异常: ' + e.message }); }
+ });
+ });
+ req.on('error', function (e) { cb({ ok: false, msg: '开放 API 调用失败: ' + e.message }); });
+ req.end(body);
+}
+
+// ---- 设置:查询 / 切换数据库目录 ----
+function 设置信息(cb) {
+ 查授权目录(function (auth) {
+ var db = 读库();
+ var cfgDir = 读配置().HY_DB_DIR || '';
+ cb({
+ ok: true, version: VERSION, port: PORT,
+ dbdir: DB_DIR, dbfile: DB_FILE, defaultdir: DEFAULT_DIR,
+ isDefaultDir: DB_DIR === DEFAULT_DIR,
+ configuredDir: cfgDir,
+ projroot: 项目根(),
+ authorized: auth.ok ? auth.paths : [],
+ authorizedOk: auth.ok, authorizedMsg: auth.ok ? '' : auth.msg,
+ count: db['会员'].length,
+ regOpen: (读配置().HY_REG_OPEN || '1') === '1',
+ adminConfigured: 管理密码已设置(),
+ tlsfp: TLS指纹(),
+ keyfp: RSA_KEY ? 公钥指纹() : ''
+ });
+ });
+}
+function 切换数据库目录(newDir, cb) {
+ if (typeof newDir !== 'string' || !/^\/.+/.test(newDir.trim()))
+ return cb({ ok: false, msg: '数据库目录必须是以 / 开头的完整路径' });
+ newDir = newDir.trim();
+ if (newDir === DB_DIR) return cb({ ok: true, dbdir: DB_DIR, dbfile: DB_FILE, msg: '目录未变化' });
+ var why = 目录不可写原因(newDir);
+ if (why) return cb({ ok: false, msg: '目标目录不可写(' + why + ')。共享目录需先在应用设置或本页「授权新目录」完成目录授权' });
+ var 旧文件 = DB_FILE;
+ var 旧项目根 = 项目根();
+ var 新文件 = path.join(newDir, DB_NAME);
+ var warn = '';
+ 备份库(); // 迁移前备份
+ // 迁移顺序:先写新文件→回读校验→再删旧文件,任何一步失败都不丢数据
+ try {
+ if (fs.existsSync(旧文件)) {
+ var content = fs.readFileSync(旧文件);
+ fs.writeFileSync(新文件, content);
+ JSON.parse(fs.readFileSync(新文件, 'utf8')); // 新文件完整性校验
+ try { fs.unlinkSync(旧文件); }
+ catch (e) { warn = '旧文件暂无法删除,已保留在 ' + 旧文件 + ' (不影响使用)'; }
+ }
+ try { fs.unlinkSync(旧文件 + '.tmp'); } catch (e) {}
+ } catch (e) {
+ console.error('数据库迁移失败: ' + ((e.code ? e.code + ' ' : '') + e.message));
+ return cb({ ok: false, msg: '迁移失败: ' + ((e.code ? e.code + ' ' : '') + e.message) + ',原数据未受影响' });
+ }
+ DB_DIR = newDir; DB_FILE = 新文件;
+ // 新目录无库文件时落一个空会员库
+ try {
+ if (!fs.existsSync(DB_FILE)) 写库({ '会员': [] });
+ } catch (e) { console.warn('新目录空库初始化失败: ' + e.message); }
+ // 项目云整目录一并迁移(尽力而为:失败不回滚,旧数据保留并提示)
+ try {
+ if (fs.existsSync(旧项目根)) {
+ var 新项目根 = path.join(newDir, PROJ_DIR_NAME);
+ try { fs.renameSync(旧项目根, 新项目根); }
+ catch (e) { fs.cpSync(旧项目根, 新项目根, { recursive: true }); fs.rmSync(旧项目根, { recursive: true, force: true }); }
+ }
+ } catch (e) {
+ warn = (warn ? warn + ';' : '') + '项目云目录未能迁移,仍保留在 ' + 旧项目根 + ' (' + e.message + ')';
+ }
+ process.env.HY_DB_DIR = newDir;
+ try {
+ var cfg = 读配置(); cfg.HY_DB_DIR = newDir; 写配置(cfg);
+ } catch (e) {
+ console.error('配置回写失败: ' + e.message);
+ return cb({ ok: false, msg: '已迁移到 ' + DB_DIR + ' 但配置回写失败(重启后可能回退): ' + e.message });
+ }
+ console.log('数据库目录已切换: ' + 旧文件 + ' → ' + DB_FILE + (warn ? ' (' + warn + ')' : ''));
+ cb({ ok: true, dbdir: DB_DIR, dbfile: DB_FILE, msg: warn });
+}
+
+// ---- HTTP ----
+function sendJson(res, obj) {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(obj));
+}
+function readBody(req, cb) {
+ var chunks = [];
+ req.on('data', function (c) { chunks.push(c); });
+ req.on('end', function () {
+ try { cb(null, JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
+ catch (e) { cb(e, null); }
+ });
+}
+
+var MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon'
+};
+
+var TLS_HINT = { ok: false, code: 'TLS_REQUIRED', msg: '该接口已迁移至加密通道,请升级客户端并使用 https://IP:端口 连接' };
+
+function handler(req, res, isTLS) {
+ // 全站 HTTPS 安全响应头
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000');
+ var urlPath = decodeURIComponent(req.url.split('?')[0]);
+ var ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '') || 'unknown';
+ var query = {};
+ (req.url.split('?')[1] || '').split('&').forEach(function (kv) {
+ var p = kv.split('=');
+ if (p[0]) query[decodeURIComponent(p[0])] = decodeURIComponent((p[1] || '').replace(/\+/g, ' '));
+ });
+
+ // 公开:版本号(页面徽章用,不含敏感信息)
+ if (urlPath === '/api/version') return sendJson(res, { ok: true, version: VERSION });
+
+ // 公开:TLS 证书下载(安装到系统"受信任的根证书"后,浏览器/客户端不再有证书警告)
+ if (urlPath === '/api/cert') {
+ try {
+ var crt = fs.readFileSync(TLS_CRT_FILE);
+ res.writeHead(200, {
+ 'Content-Type': 'application/x-x509-ca-cert',
+ 'Content-Disposition': 'attachment; filename="fnnas-huiyuan.crt"',
+ 'Content-Length': crt.length
+ });
+ return res.end(crt);
+ } catch (e) { return sendJson(res, { ok: false, msg: '证书读取失败: ' + e.message }); }
+ }
+
+ // ---- 加密通道(仅 HTTPS) ----
+ if (urlPath === '/api/pubkey') {
+ if (!isTLS) return sendJson(res, TLS_HINT);
+ if (!RSA_KEY) return sendJson(res, { ok: false, msg: '服务端加密未初始化' });
+ return sendJson(res, { ok: true, pubkey: 公钥PEM(), keyfp: 公钥指纹() });
+ }
+ if (urlPath === '/api/enc') {
+ if (!isTLS) return sendJson(res, TLS_HINT);
+ return readBody(req, function (e, payload) {
+ if (e || !payload || !payload.k) return sendJson(res, { ok: false, msg: '信封格式错误' });
+ var r = 解密信封(payload, ip);
+ if (r.err) return sendJson(res, { ok: false, msg: r.err });
+ NONCES.set(r.nonce, Date.now() + 600000); // 记录 nonce 防重放
+ var route = ENC_ROUTES[r.inner.path];
+ var result;
+ if (!route) result = { ok: false, msg: '不支持的加密接口: ' + r.inner.path };
+ else {
+ try { result = route(r.inner.body || {}, req, ip); }
+ catch (e2) { result = { ok: false, msg: '处理失败: ' + e2.message }; }
+ }
+ sendJson(res, 加密响应(r.aesKey, result));
+ });
+ }
+
+ // ---- 客户端会员接口:仅加密通道提供(HTTP 端口返回迁移提示) ----
+ var 仅TLS = (urlPath === '/api/login' || urlPath === '/api/register' || urlPath === '/api/member' ||
+ urlPath === '/api/changepwd');
+ if (仅TLS && !isTLS) return sendJson(res, TLS_HINT);
+
+ // 管理密码登录/登录态/改管理密码
+ if (urlPath === '/api/admin/login') return readBody(req, function (e, b) {
+ b = b || {};
+ var lock = 被锁秒('adm:' + ip);
+ if (lock) return sendJson(res, { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' });
+ if (!管理密码已设置()) return sendJson(res, { ok: false, msg: '管理密码未设置(请在安装向导或应用设置中设置)' });
+ if (!管理密码正确(b['密码'])) { 失败记录('adm:' + ip); return sendJson(res, { ok: false, msg: '管理密码错误' }); }
+ 失败清零('adm:' + ip);
+ return sendJson(res, { ok: true, token: 新token() });
+ });
+ if (urlPath === '/api/admin/check') return sendJson(res, { ok: 是管理员(req), adminConfigured: 管理密码已设置() });
+ if (urlPath === '/api/admin/addmember') return readBody(req, function (e, b) {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ b = b || {};
+ if (!/^1\d{10}$/.test(String(b['手机号'] || ''))) return sendJson(res, { ok: false, msg: '手机号格式不正确(11 位数字)' });
+ var p = String(b['密码'] || '');
+ if (p.length < 4 || p.length > 64) return sendJson(res, { ok: false, msg: '密码长度需 4-64 位' });
+ var db = 读库();
+ for (var i = 0; i < db['会员'].length; i++)
+ if (db['会员'][i]['手机号'] === b['手机号']) return sendJson(res, { ok: false, msg: '该手机号已注册' });
+ db['会员'].push({
+ '手机号': b['手机号'], '密码': 哈希密码(p),
+ '会员类型': String(b['会员类型'] || '试用会员'),
+ '有效期': String(b['有效期'] || 今天加天数(30)),
+ '注册时间': 今天加天数(0), '备注': String(b['备注'] || '')
+ });
+ 备份库();
+ 写库(db);
+ return sendJson(res, { ok: true, msg: '创建成功' });
+ });
+ // TLS 证书导入/信息/重置(管理登录)
+ if (urlPath === '/api/admin/tls/info') {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ try {
+ var crtText = fs.readFileSync(TLS_CRT_FILE, 'utf8');
+ var tlsInfo = 解析证书(crtText);
+ tlsInfo.source = (tlsInfo.subject.indexOf('fnnas-huiyuan') >= 0 && tlsInfo.issuer.indexOf('fnnas-huiyuan') >= 0) ? 'selfsigned' : 'custom';
+ return sendJson(res, { ok: true, tls: tlsInfo });
+ } catch (e) { return sendJson(res, { ok: false, msg: '证书读取失败: ' + e.message }); }
+ }
+ if (urlPath === '/api/admin/tls/import') return readBody(req, function (e, b) {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ b = b || {};
+ if (!b.cert || !b.key) return sendJson(res, { ok: false, msg: '请同时提供证书文件和私钥文件(PEM 格式)' });
+ var info;
+ try { info = 解析证书(b.cert); }
+ catch (e2) { return sendJson(res, { ok: false, msg: '证书文件无法解析(需 PEM 格式;证书链请用 fullchain 全链文件): ' + e2.message }); }
+ if (!密钥证书匹配(b.key, b.cert)) return sendJson(res, { ok: false, msg: '私钥与证书不匹配,请确认两个文件是同一套' });
+ try {
+ fs.writeFileSync(TLS_CRT_FILE, String(b.cert));
+ fs.writeFileSync(TLS_KEY_FILE, String(b.key), { mode: 0o600 });
+ console.log('已导入自定义 TLS 证书: ' + info.subject);
+ // 先响应本请求,再热切换监听(切换会断开旧连接,不能打断响应本身)
+ res.on('finish', function () { setTimeout(热重载证书, 200); });
+ return sendJson(res, { ok: true, tls: info, msg: '导入成功,已立即生效' });
+ } catch (e3) { return sendJson(res, { ok: false, msg: '写入证书失败: ' + e3.message }); }
+ });
+ if (urlPath === '/api/admin/tls/reset') {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ try {
+ 生成自签证书();
+ res.on('finish', function () { setTimeout(热重载证书, 200); });
+ return sendJson(res, { ok: true, tls: 解析证书(fs.readFileSync(TLS_CRT_FILE, 'utf8')) });
+ } catch (e) { return sendJson(res, { ok: false, msg: '重置失败: ' + e.message }); }
+ }
+ if (urlPath === '/api/admin/changepwd') return readBody(req, function (e, b) {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ b = b || {};
+ var p = String(b['新密码'] || '');
+ if (p.length < 6) return sendJson(res, { ok: false, msg: '新管理密码至少 6 位' });
+ try { 设置管理密码(p); return sendJson(res, { ok: true }); }
+ catch (e2) { return sendJson(res, { ok: false, msg: '写入配置失败: ' + e2.message }); }
+ });
+
+ // (客户端会员接口 login/register/member/changepwd 已迁至加密通道 /api/enc,见上)
+
+ // 管理 API(需 X-Admin-Token;自定义头跨站请求带不了,同时天然防 CSRF)
+ if (urlPath === '/api/list') {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ var db = 读库();
+ return sendJson(res, { ok: true, '会员': db['会员'] });
+ }
+ if (urlPath === '/api/save') return readBody(req, function (e, b) {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ if (e || !b || !Array.isArray(b['会员'])) return sendJson(res, { ok: false, msg: '参数错误' });
+ // 密码语义:空=保持现有;明文=重置该会员密码(哈希入库);符合哈希格式=原样保留(管理页回传未改值)
+ var 旧库 = 读库();
+ var 旧密码 = {};
+ 旧库['会员'].forEach(function (m) { if (m['手机号']) 旧密码[m['手机号']] = m['密码'] || ''; });
+ b['会员'].forEach(function (m, idx) {
+ var p = m['密码'];
+ if (!p) {
+ m['密码'] = 旧密码[m['手机号']] !== undefined ? 旧密码[m['手机号']]
+ : (旧库['会员'][idx] ? 旧库['会员'][idx]['密码'] || '' : '');
+ } else if (!哈希格式.test(String(p))) {
+ m['密码'] = 哈希密码(p);
+ }
+ });
+ 备份库();
+ 写库(b);
+ return sendJson(res, { ok: true });
+ });
+
+ // ---- 项目云路由(HTTPS:会员密码或管理 token;HTTP:仅管理 token,网页管理用) ----
+ // 上传:body = JSON 文本(项目编号/名称/手机号/密码) + "\n\n" + zip 二进制
+ if (urlPath === '/api/proj/upload') {
+ if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
+ var 收到 = 0, 超限 = false;
+ var chunks = [];
+ req.on('data', function (c) {
+ 收到 += c.length;
+ if (收到 > UPLOAD_MAX) { 超限 = true; req.destroy(); return; }
+ chunks.push(c);
+ });
+ req.on('end', function () {
+ if (超限) return sendJson(res, { ok: false, msg: '超出上传上限 2GB' });
+ var buf = Buffer.concat(chunks);
+ var sep = buf.indexOf('\n\n');
+ if (sep < 0) return sendJson(res, { ok: false, msg: '缺少 JSON 头' });
+ var 头 = {};
+ try { 头 = JSON.parse(buf.slice(0, sep).toString('utf8')); } catch (e) { return sendJson(res, { ok: false, msg: 'JSON 头解析失败' }); }
+ var 手机号 = 头['手机号'], 编号 = 头['编号'];
+ if (!手机号 || !编号) return sendJson(res, { ok: false, msg: '缺少手机号或项目编号' });
+ if (!项目云鉴权(手机号, 头['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
+ var zip = buf.slice(sep + 2);
+ if (zip.length < 100) return sendJson(res, { ok: false, msg: 'zip 数据为空或过小' });
+ var d = 项目目录(手机号, 编号);
+ if (!d) return sendJson(res, { ok: false, msg: '路径非法' });
+ if (目录大小(项目目录(手机号, '')) + zip.length > USER_QUOTA)
+ return sendJson(res, { ok: false, msg: '云空间已满(每会员上限 1GB),请联系管理员清理' });
+ try {
+ fs.mkdirSync(d, { recursive: true });
+ var tmp = path.join(d, '项目.zip.tmp');
+ fs.writeFileSync(tmp, zip);
+ var 目标 = path.join(d, '项目.zip');
+ try { fs.unlinkSync(目标); } catch (e) { }
+ fs.renameSync(tmp, 目标);
+ var meta = { 编号: 编号, 名称: 头['名称'] || '', 时间: 头['时间'] || 今天加天数(0), 大小: zip.length };
+ var mt = path.join(d, 'meta.json.tmp');
+ fs.writeFileSync(mt, JSON.stringify(meta), 'utf8');
+ fs.renameSync(mt, path.join(d, 'meta.json'));
+ return sendJson(res, { ok: true, 编号: 编号, 大小: zip.length, 时间: meta['时间'] });
+ } catch (e) { return sendJson(res, { ok: false, msg: '写盘失败: ' + e.message }); }
+ });
+ return;
+ }
+ if (urlPath === '/api/proj/list') {
+ if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
+ if (!项目云鉴权(query['手机号'], query['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
+ return sendJson(res, 列项目(query['手机号']));
+ }
+ if (urlPath === '/api/proj/download') {
+ if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
+ var 手机号 = query['手机号'], 编号 = query['编号'];
+ if (!项目云鉴权(手机号, query['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
+ var d2 = 手机号 && 编号 ? 项目目录(手机号, 编号) : null;
+ var zp = d2 ? path.join(d2, '项目.zip') : '';
+ if (!d2 || !fs.existsSync(zp)) return sendJson(res, { ok: false, msg: '云上没有该项目' });
+ // 中文文件名只能放 filename*(UTF-8 百分号编码),ASCII fallback 防 latin-1 编码崩溃
+ var 下载名 = 安全名(编号) + '.zip';
+ var ascii名 = 下载名.replace(/[^\x20-\x7e]/g, '_');
+ res.writeHead(200, {
+ 'Content-Type': 'application/zip', 'Content-Length': fs.statSync(zp).size,
+ 'Content-Disposition': "attachment; filename=\"" + ascii名 + "\"; filename*=UTF-8''" + encodeURIComponent(下载名)
+ });
+ fs.createReadStream(zp).pipe(res);
+ return;
+ }
+ if (urlPath === '/api/proj/delete') return readBody(req, function (e, b) {
+ b = b || {};
+ if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
+ if (!项目云鉴权(b['手机号'], b['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
+ var d3 = b['手机号'] && b['编号'] ? 项目目录(b['手机号'], b['编号']) : null;
+ if (!d3) return sendJson(res, { ok: false, msg: '参数错误' });
+ try { fs.rmSync(d3, { recursive: true, force: true }); return sendJson(res, { ok: true }); }
+ catch (e2) { return sendJson(res, { ok: false, msg: '删除失败: ' + e2.message }); }
+ });
+
+ // 设置 API(需管理登录)
+ if (urlPath === '/api/settings' && req.method === 'GET') {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ return 设置信息(function (j) { sendJson(res, j); });
+ }
+ if (urlPath === '/api/settings' && req.method === 'POST')
+ return readBody(req, function (e, b) {
+ if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
+ if (e || !b) return sendJson(res, { ok: false, msg: '参数错误' });
+ if (typeof b.regOpen === 'boolean') {
+ try {
+ var c = 读配置(); c.HY_REG_OPEN = b.regOpen ? '1' : '0'; 写配置(c);
+ return sendJson(res, { ok: true, regOpen: b.regOpen });
+ } catch (e2) { return sendJson(res, { ok: false, msg: '写入配置失败: ' + e2.message }); }
+ }
+ 切换数据库目录(b.dbdir || '', function (j) { sendJson(res, j); });
+ });
+
+ // 静态文件(会员管理页/设置页)
+ if (urlPath === '/' || urlPath === '/admin.html') urlPath = '/admin.html';
+ var file = path.join(WWW, path.normalize(urlPath).replace(/^([A-Za-z]:)?[\\\/]+/, ''));
+ if (file.indexOf(WWW) !== 0) { res.writeHead(403); res.end('Forbidden'); return; }
+ fs.readFile(file, function (err, data) {
+ if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found: ' + urlPath); return; }
+ var ext = path.extname(file).toLowerCase();
+ // 禁止缓存:应用升级后浏览器必须取新页面,避免跑旧版脚本
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
+ res.end(data);
+ });
+}
+
+// ---- 全站 HTTPS(唯一监听;HTTP 明文通道彻底停用) ----
+var TLS_KEY_FILE = path.join(CFG_DIR, 'tls.key');
+var TLS_CRT_FILE = path.join(CFG_DIR, 'tls.crt');
+function TLS指纹() { // 证书 SHA256 前 16 位,供管理员核验
+ try { return crypto.createHash('sha256').update(fs.readFileSync(TLS_CRT_FILE)).digest('hex').slice(0, 16).toUpperCase(); }
+ catch (e) { return ''; }
+}
+function 生成自签证书() {
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
+ '-keyout', TLS_KEY_FILE, '-out', TLS_CRT_FILE, '-days', '3650',
+ '-subj', '/CN=fnnas-huiyuan'], { stdio: 'ignore' });
+ console.log('已生成自签 TLS 证书: ' + TLS_CRT_FILE);
+}
+// 用 openssl 解析证书文本 → 结构化信息(导入校验与设置页展示用)
+function 解析证书(pemCert) {
+ var tmp = path.join(CFG_DIR, '.import-cert.pem');
+ fs.writeFileSync(tmp, String(pemCert));
+ try {
+ var out = execFileSync('openssl', ['x509', '-in', tmp, '-noout',
+ '-subject', '-issuer', '-startdate', '-enddate', '-ext', 'subjectAltName'], { encoding: 'utf8' });
+ var info = { subject: '', issuer: '', notBefore: '', notAfter: '', san: '' };
+ out.split(/\r?\n/).forEach(function (line) {
+ if (line.indexOf('subject=') === 0) info.subject = line.slice(8).trim();
+ else if (line.indexOf('issuer=') === 0) info.issuer = line.slice(7).trim();
+ else if (line.indexOf('notBefore=') === 0) info.notBefore = line.slice(10).trim();
+ else if (line.indexOf('notAfter=') === 0) info.notAfter = line.slice(9).trim();
+ else if (line.indexOf('Subject Alternative Name') >= 0 && line.indexOf(':') >= 0)
+ info.san = line.slice(line.indexOf(':') + 1).trim();
+ });
+ info.fp = crypto.createHash('sha256').update(pemCert).digest('hex').slice(0, 16).toUpperCase();
+ return info;
+ } finally { try { fs.unlinkSync(tmp); } catch (e) { } }
+}
+// 校验私钥与证书匹配(公钥比对)
+function 密钥证书匹配(pemKey, pemCert) {
+ var k = path.join(CFG_DIR, '.import-key.pem'), c = path.join(CFG_DIR, '.import-cert.pem');
+ fs.writeFileSync(k, String(pemKey)); fs.writeFileSync(c, String(pemCert));
+ try {
+ var pub1 = execFileSync('openssl', ['pkey', '-in', k, '-pubout'], { encoding: 'utf8' });
+ var pub2 = execFileSync('openssl', ['x509', '-in', c, '-noout', '-pubkey'], { encoding: 'utf8' });
+ return pub1.replace(/\s+/g, '') === pub2.replace(/\s+/g, '');
+ } finally { try { fs.unlinkSync(k); } catch (e) { } try { fs.unlinkSync(c); } catch (e2) { } }
+}
+var HTTPS_SERVER = null; // 当前 HTTPS 监听(导入/重置证书时整体热切换)
+function TLS选项() {
+ return {
+ key: fs.readFileSync(TLS_KEY_FILE),
+ cert: fs.readFileSync(TLS_CRT_FILE),
+ SNICallback: function (servername, cb) { // 域名访问(SNI)握手期间动态取上下文,配合热切换
+ try { cb(null, require('tls').createSecureContext({ key: fs.readFileSync(TLS_KEY_FILE), cert: fs.readFileSync(TLS_CRT_FILE) })); }
+ catch (e) { cb(e); }
+ }
+ };
+}
+function 启动HTTPS监听(首次) {
+ var https = require('https');
+ var srv = https.createServer(TLS选项(), function (req, res) { handler(req, res, true); });
+ srv.on('error', function (e) {
+ var msg = 'HTTPS 监听失败: ' + e.message + (e.code === 'EADDRINUSE' ? '(端口被占用,可在应用设置中更换端口)' : '');
+ if (首次) { console.error(msg); process.exit(1); }
+ console.error(msg + ',1 秒后重试');
+ setTimeout(function () { try { 启动HTTPS监听(false); } catch (e2) { console.error(e2.message); } }, 1000);
+ });
+ HTTPS_SERVER = srv;
+ srv.listen(PORT, HOST, function () {
+ if (首次) {
+ console.log('会员系统服务 v' + VERSION + '(全站 HTTPS) on https://' + HOST + ':' + PORT
+ + ' 数据库=' + DB_FILE + (DB_DIR === DEFAULT_DIR ? '(默认目录)' : '(自定义目录)')
+ + ' 项目云=' + 项目根()
+ + ' 默认目录=' + DEFAULT_DIR
+ + ' 证书指纹=' + TLS指纹() + ' RSA指纹=' + (RSA_KEY ? 公钥指纹() : '未初始化'));
+ } else {
+ console.log('HTTPS 证书已热切换并重新监听,当前指纹=' + TLS指纹());
+ }
+ });
+}
+function 热重载证书() { // 导入/重置证书后调用:断开旧连接,用新证书重建监听
+ try { if (HTTPS_SERVER) { HTTPS_SERVER.closeAllConnections(); HTTPS_SERVER.close(); } } catch (e) { }
+ 启动HTTPS监听(false);
+}
+(function 启动HTTPS() {
+ try {
+ if (!fs.existsSync(TLS_KEY_FILE) || !fs.existsSync(TLS_CRT_FILE)) 生成自签证书();
+ } catch (e) {
+ console.error('TLS 证书初始化失败(本应用强制全站 HTTPS,拒绝以明文启动): ' + e.message);
+ process.exit(1);
+ }
+ 启动HTTPS监听(true);
+})();
diff --git a/会员服务器/app/ui/config b/会员服务器/app/ui/config
new file mode 100755
index 0000000..a9e0da2
--- /dev/null
+++ b/会员服务器/app/ui/config
@@ -0,0 +1,13 @@
+{
+ ".url": {
+ "fnnas.huiyuan.Application": {
+ "title": "会员服务器",
+ "icon": "images/icon_{0}.png",
+ "type": "url",
+ "protocol": "https",
+ "port": "${wizard_port}",
+ "url": "/",
+ "allUsers": false
+ }
+ }
+}
diff --git a/会员服务器/app/ui/images/icon_256.png b/会员服务器/app/ui/images/icon_256.png
new file mode 100755
index 0000000..ef85779
Binary files /dev/null and b/会员服务器/app/ui/images/icon_256.png differ
diff --git a/会员服务器/app/ui/images/icon_64.png b/会员服务器/app/ui/images/icon_64.png
new file mode 100755
index 0000000..5e0cd57
Binary files /dev/null and b/会员服务器/app/ui/images/icon_64.png differ
diff --git a/会员服务器/app/www/admin.html b/会员服务器/app/www/admin.html
new file mode 100755
index 0000000..97e2381
--- /dev/null
+++ b/会员服务器/app/www/admin.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+会员管理
+
+
+
+
+
+
+
+
+ 手机号:
+
+
+
+
+
+
+
+
+ | 手机号 | 密码 | 会员类型 | 有效期 | 注册时间 | 备注 | 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/会员服务器/app/www/auth.js b/会员服务器/app/www/auth.js
new file mode 100755
index 0000000..0203c47
--- /dev/null
+++ b/会员服务器/app/www/auth.js
@@ -0,0 +1,92 @@
+// 会员服务器 · 管理登录门(四页共用)
+// · 页面引入本文件后:无有效登录态时全屏遮罩要求输入管理密码,登录成功写入 localStorage 并刷新
+// · 同时包装 window.fetch:对同源 /api/ 请求自动附加 X-Admin-Token 头(页面业务代码无需改动)
+// · 管理密码未设置时提示先到安装向导/应用设置中设置
+(function () {
+ var TOKEN_KEY = 'hy_admin_token';
+
+ function 取token() { try { return localStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; } }
+ function 存token(t) { try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { } }
+
+ // 原生 fetch 引用(遮罩内登录请求用原生,避免递归)
+ var 原fetch = window.fetch.bind(window);
+
+ // 包装 fetch:同源 /api/ 请求自动带 X-Admin-Token
+ window.fetch = function (input, init) {
+ try {
+ var url = typeof input === 'string' ? input : (input && input.url) || '';
+ if (url.indexOf('/api/') === 0) {
+ init = init || {};
+ var h = {};
+ if (init.headers && typeof init.headers === 'object' && !(init.headers instanceof Headers)) h = init.headers;
+ h['X-Admin-Token'] = 取token();
+ init.headers = h;
+ }
+ } catch (e) { }
+ return 原fetch(input, init);
+ };
+
+ window.HY = {
+ token: 取token,
+ logout: function () { try { localStorage.removeItem(TOKEN_KEY); } catch (e) { } location.reload(); }
+ };
+
+ // ---- 登录遮罩 ----
+ var 样式 = document.createElement('style');
+ 样式.textContent =
+ '#hy-login{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:#f5f6f8}' +
+ '#hy-login .box{background:#fff;border:1px solid #e3e6eb;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.12);padding:32px 34px;width:340px;max-width:92vw}' +
+ '#hy-login h3{margin:0 0 6px;font-size:18px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}' +
+ '#hy-login .sub{color:#7c8590;font-size:12px;margin:0 0 16px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}' +
+ '#hy-login input{width:100%;padding:9px 11px;border:1px solid #cfd5dd;border-radius:7px;font-size:14px;box-sizing:border-box}' +
+ '#hy-login input:focus{outline:none;border-color:#2563eb}' +
+ '#hy-login button{width:100%;margin-top:12px;padding:9px;border:none;border-radius:7px;background:#2563eb;color:#fff;font-size:14px;cursor:pointer}' +
+ '#hy-login button:hover{background:#1d54d7}' +
+ '#hy-login .err{color:#c53030;font-size:12px;min-height:16px;margin-top:10px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}';
+ document.head.appendChild(样式);
+
+ function 显示登录(提示语) {
+ var 旧 = document.getElementById('hy-login');
+ if (旧) 旧.remove();
+ var ov = document.createElement('div');
+ ov.id = 'hy-login';
+ ov.innerHTML = '会员服务器
请输入管理密码登录'
+ + (提示语 ? '(' + 提示语 + ')' : '') + '
'
+ + '
'
+ + '
';
+ document.body.appendChild(ov);
+ var pwd = ov.querySelector('#hy-pwd'), btn = ov.querySelector('#hy-btn'), err = ov.querySelector('#hy-err');
+ function 登录() {
+ var v = pwd.value;
+ if (!v) { err.textContent = '请输入管理密码'; return; }
+ btn.disabled = true; btn.textContent = '登录中…'; err.textContent = '';
+ 原fetch('/api/admin/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ 密码: v })
+ }).then(function (r) { return r.json(); }).then(function (j) {
+ if (j.ok) { 存token(j.token); location.reload(); return; }
+ err.textContent = j.msg || '登录失败';
+ btn.disabled = false; btn.textContent = '登 录'; pwd.value = ''; pwd.focus();
+ }).catch(function (e) {
+ err.textContent = '请求失败: ' + e;
+ btn.disabled = false; btn.textContent = '登 录';
+ });
+ }
+ btn.onclick = 登录;
+ pwd.addEventListener('keydown', function (e) { if (e.key === 'Enter') 登录(); });
+ setTimeout(function () { pwd.focus(); }, 50);
+ }
+
+ function 校验登录态() {
+ 原fetch('/api/admin/check', { headers: { 'X-Admin-Token': 取token() } })
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ if (j.ok) return; // 已登录,页面正常
+ 显示登录(j.adminConfigured ? '' : '首次使用请先在安装向导/应用设置中设置管理密码');
+ })
+ .catch(function () { 显示登录('无法连接服务器'); });
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', 校验登录态);
+ else 校验登录态();
+})();
diff --git a/会员服务器/app/www/lib-cloud.html b/会员服务器/app/www/lib-cloud.html
new file mode 100755
index 0000000..a57a82d
--- /dev/null
+++ b/会员服务器/app/www/lib-cloud.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+库备份管理
+
+
+
+
+
+
方案库/用户库/柜体库/铜排铝排库/企业信息等库备份(按手机号),可下载或删除。
+
+
+
+ 手机号:
+
+ 或输入:
+
+
+
+
+
+
+
+
+
+
+
diff --git a/会员服务器/app/www/sdk/web-app.js b/会员服务器/app/www/sdk/web-app.js
new file mode 100755
index 0000000..e401030
--- /dev/null
+++ b/会员服务器/app/www/sdk/web-app.js
@@ -0,0 +1 @@
+function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(t){var r=function(t,r){if("object"!=e(t)||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,r||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===r?String:Number)(t)}(t,"string");return"symbol"==e(r)?r:r+""}function r(e,r,n){return(r=t(r))in e?Object.defineProperty(e,r,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[r]=n,e}var n=new class{constructor(){r(this,"debug",!1)}setDebug(e){this.debug=e}isDebug(){return this.debug}log(){if(this.debug){for(var e=arguments.length,t=new Array(e),r=0;rfunction(e){const t=/FNOS\/([\d.]+)|FNAppType\/(\w+)|FNAppVer\/([\d.]+)/g,r={};let n;for(;null!==(n=t.exec(e));)n[1]&&(r.fnOSVersion=n[1]),n[2]&&(r.fnAppType=n[2]),n[3]&&(r.fnAppVersion=n[3]);return r}(navigator.userAgent).fnAppVersion;function o(e,t,r,n,i,o,s){try{var l=e[o](s),a=l.value}catch(e){return void r(e)}l.done?t(a):Promise.resolve(a).then(n,i)}function s(e){return function(){var t=this,r=arguments;return new Promise(function(n,i){var s=e.apply(t,r);function l(e){o(s,n,i,l,a,"next",e)}function a(e){o(s,n,i,l,a,"throw",e)}l(void 0)})}}function l(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function a(e){for(var t=1;t{u=!0});var p=/(iPad|iPhone|iPod|Macintosh|Mac OS X)/i.test(navigator.userAgent),c=/* @__PURE__ */function(){var e=s(function*(e,t){for(var r=arguments.length,i=new Array(r>2?r-2:0),o=2;oString(e)).join(", ")}`);const s=yield e.callHandler(t,...i);if(s)try{const e=JSON.parse(s);n.log(`messageData: ${JSON.stringify(e)}`);const t=JSON.parse(e.result);return n.log(`appMessage: ${JSON.stringify(t)}`),t}catch(l){return n.error(`error: ${l}`),null}return null});return function(t,r){return e.apply(this,arguments)}}(),d=/* @__PURE__ */new Map,f=class{constructor(e){r(this,"subscribeFn",void 0),this.subscribeFn=e}subscribe(e,t,r){var n;const i="function"==typeof e?{next:e,error:t,complete:r}:null!=e?e:{};let o=!1;const s=null!==(n=this.subscribeFn({next:e=>{var t;o||(null===(t=i.next)||void 0===t||t.call(i,e))},error:e=>{var t;o||(o=!0,null===(t=i.error)||void 0===t||t.call(i,e))},complete:()=>{var e;o||(o=!0,null===(e=i.complete)||void 0===e||e.call(i))}}))&&void 0!==n?n:()=>{};return{unsubscribe:()=>{o||(o=!0,s())}}}},h=/* @__PURE__ */new Map;function v(){window._flutter_onAppObservableQueryResult=e=>{n.log("_flutter_onAppObservableQueryResult",e);const t=function(e){var t,r;const n="string"==typeof e?JSON.parse(e):e,i=null!==(t=n.reqid)&&void 0!==t?t:n.reqId;return a(a({},n),{},{reqId:null!==(r=n.reqId)&&void 0!==r?r:i,reqid:i})}(e),r=h.get(t.reqId);if(r)if(t.result&&"doing"!==t.result){if("succ"===t.result)return r.next(t),r.complete(),void h.delete(t.reqId);r.error(t),h.delete(t.reqId)}else r.next(t);else n.log("_flutter_onAppObservableQueryResult observer not found",t)}}function y(){return(y=s(function*(e,t){return new Promise((r,i)=>{null==e||e.callHandler("openFolder",JSON.stringify(t)).then(e=>{if(n.log("pickFilePromise callback registered",{reqId:e,params:t}),e){function o(e){r(e.result)}d.set(e,o)}else i(/* @__PURE__ */new Error("openFolder failed"))})})})).apply(this,arguments)}i()&&(window.fnAppMessage=e=>{n.log("app-message",e);const t=JSON.parse(e),r=d.get(t.reqId);r&&(r(t),d.delete(t.reqId))}),i()&&v();var g=/* @__PURE__ */function(e){return e.Call="call",e.Reply="reply",e.Syn="syn",e.SynAck="synAck",e.Ack="ack",e}({}),b=/* @__PURE__ */function(e){return e.Fulfilled="fulfilled",e.Rejected="rejected",e}({}),m=/* @__PURE__ */function(e){return e.ConnectionDestroyed="ConnectionDestroyed",e.ConnectionTimeout="ConnectionTimeout",e.NoIframeSrc="NoIframeSrc",e}({}),w=/* @__PURE__ */function(e){return e.DataCloneError="DataCloneError",e}({}),A=/* @__PURE__ */function(e){return e.Message="message",e}({}),S=({name:e,message:t,stack:r})=>({name:e,message:t,stack:r}),P=(e,t,r)=>{let{localName:n,local:i,remote:o,originForSending:s,originForReceiving:l}=e,a=!1,u=e=>{if(e.source!==o||e.data.penpal!==g.Call)return;if("*"!==l&&e.origin!==l)return void r(`${n} received message from origin ${e.origin} which did not match expected origin ${l}`);let{methodName:i,args:u,id:p}=e.data;r(`${n}: Received ${i}() call`);let c=e=>t=>{if(r(`${n}: Sending ${i}() reply`),a)return void r(`${n}: Unable to send ${i}() reply due to destroyed connection`);let l={penpal:g.Reply,id:p,resolution:e,returnValue:t};e===b.Rejected&&t instanceof Error&&(l.returnValue=S(t),l.returnValueIsError=!0);try{o.postMessage(l,s)}catch(e){let r=e instanceof Error?e:Error(String(e));if(r.name===w.DataCloneError){let e={penpal:g.Reply,id:p,resolution:b.Rejected,returnValue:S(r),returnValueIsError:!0};o.postMessage(e,s)}throw e}};new Promise(e=>e(t[i].apply(t,u))).then(c(b.Fulfilled),c(b.Rejected))};return i.addEventListener(A.Message,u),()=>{a=!0,i.removeEventListener(A.Message,u)}},O=0,M=()=>++O,E=e=>e?e.split("."):[],F=(e,t,r)=>{let n=E(t);return n.reduce((e,t,i)=>(void 0===e[t]&&(e[t]={}),i===n.length-1&&(e[t]=r),e[t]),e),e},$=(e,t)=>{let r={};return Object.keys(e).forEach(n=>{let i=e[n],o=((e,t)=>{let r=E(t||"");return r.push(e),(e=>e.join("."))(r)})(n,t);"object"==typeof i&&Object.assign(r,$(i,o)),"function"==typeof i&&(r[o]=i)}),r},_=(e,t,r,n,i)=>{let{localName:o,local:s,remote:l,originForSending:a,originForReceiving:u}=t,p=!1;i(`${o}: Connecting call sender`);let c=e=>(...t)=>{let r;i(`${o}: Sending ${e}() call`);try{l.closed&&(r=!0)}catch(g){r=!0}if(r&&n(),p){let t=/* @__PURE__ */Error(`Unable to send ${e}() call due to destroyed connection`);throw t.code=m.ConnectionDestroyed,t}return new Promise((r,n)=>{let p=M(),c=t=>{if(t.source!==l||t.data.penpal!==g.Reply||t.data.id!==p)return;if("*"!==u&&t.origin!==u)return void i(`${o} received message from origin ${t.origin} which did not match expected origin ${u}`);let a=t.data;i(`${o}: Received ${e}() reply`),s.removeEventListener(A.Message,c);let d=a.returnValue;a.returnValueIsError&&(d=(e=>{let t=/* @__PURE__ */Error();return Object.assign(t,e),t})(d)),(a.resolution===b.Fulfilled?r:n)(d)};s.addEventListener(A.Message,c);let d={penpal:g.Call,id:p,methodName:e,args:t};l.postMessage(d,a)})},d=r.reduce((e,t)=>(e[t]=c(t),e),{});return Object.assign(e,(e=>{let t={};for(let r in e)F(t,r,e[r]);return t})(d)),()=>{p=!0}},j=(e,t)=>{let r;return void 0!==e&&(r=window.setTimeout(()=>{let r=/* @__PURE__ */Error(`Connection timed out after ${e}ms`);r.code=m.ConnectionTimeout,t(r)},e)),()=>{clearTimeout(r)}};function k(e){return k="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},k(e)}function W(e){var t=function(e,t){if("object"!=k(e)||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=k(n))return n;throw TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==k(t)?t:t+""}function q(e,t,r){return(t=W(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function x(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function N(e){for(var t=1;t{let{destroy:o,onDestroy:s}=n;return n=>{if(!(e instanceof RegExp?e.test(n.origin):"*"===e||e===n.origin))return void i(`Child: Handshake - Received SYN-ACK from origin ${n.origin} which did not match expected origin ${e}`);i("Child: Handshake - Received SYN-ACK, responding with ACK");let l="null"===n.origin?"*":n.origin,a={penpal:g.Ack,methodNames:Object.keys(t),config:r};window.parent.postMessage(a,l);let u={localName:"Child",local:window,remote:window.parent,originForSending:l,originForReceiving:n.origin},p=P(u,t,i);s(p);let c={},d=_(c,u,n.data.methodNames,o,i);return s(d),c}};function I(e,t){var r;null==(r=console)||r.warn(`[fnApp warn]: ${e}`,t)}function C(e,t){var r;null==(r=console)||r.error(`[fnApp error]: ${e}`,t)}var V="_fn_all_event",U="事件订阅数量为空";function R(e){return R="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},R(e)}function D(e){var t=function(e,t){if("object"!=R(e)||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=R(n))return n;throw TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==R(t)?t:t+""}function z(e,t,r){return(t=D(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var L=window.__POWERED_BY_FNAPP__?window.__FNAPP.inject.appEventObjMap:/* @__PURE__ */new Map,J=class{constructor(e,t){z(this,"id",void 0),z(this,"appName",void 0),z(this,"eventObj",void 0),z(this,"getEventCallBackLength",e=>{var t;return(null==(t=this.eventObj[e])?void 0:t.length)||0}),this.id=e,this.appName=t,this.$clear(),L.get(this.id)||L.set(this.id,{}),this.eventObj=L.get(this.id)}$on(e,t){let r=this.eventObj[e];return r?(r.includes(t)||r.push(t),this):(this.eventObj[e]=[t],this)}$onAll(e){return this.$on(V,e)}$once(e,t){t.$__once=!0,this.$on(e,t)}$off(e,t){let r=this.eventObj[e];if(!e||!r||!r.length)return I(`${e} ${U}`),this;let n,i=r.length;for(;i--;)if(n=r[i],n===t){r.splice(i,1);break}return this}$offAll(e){return this.$off(V,e)}$emit(e,...t){return e=`${this.appName}/${e}`,this.$pureEmit(e,...t)}$pureEmit(e,...t){let r=0;return L.forEach(n=>{if(n[e])for(let i=n[e].length-1;i>=0;i--){r++;let o=n[e][i];o.$__once&&n[e].splice(i,1);try{o(...t)}catch(e){C(e)}}if(n._fn_all_event)for(let i=n[V].length-1;i>=0;i--){r++;let o=n[V][i];o.$__once&&n[V].splice(i,1);try{o(...t)}catch(e){C(e)}}}),(!e||0===r)&&I(`${e} ${U}`),this}$clear(){var e;let t=null==(e=L.get(this.id))?{}:e;return Object.keys(t).forEach(e=>delete t[e]),this}},H=function(e,t){return H=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(e[r]=t[r])},H(e,t)};function Q(e,t){if("function"!=typeof t&&null!==t)throw TypeError("Class extends value "+String(t)+" is not a constructor or null");function r(){this.constructor=e}H(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)}function B(e){var t="function"==typeof Symbol&&Symbol.iterator,r=t&&e[t],n=0;if(r)return r.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&n>=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function Y(e,t){var r="function"==typeof Symbol&&e[Symbol.iterator];if(!r)return e;var n,i,o=r.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(n=o.next()).done;)s.push(n.value)}catch(e){i={error:e}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(i)throw i.error}}return s}function G(e,t,r){if(r||2===arguments.length)for(var n,i=0,o=t.length;i{let{parentOrigin:t="*",timeout:r,debug:n=!1,config:i={}}=e,o=(e=>(...t)=>{e&&console.log("[Penpal]",...t)})(n),s=((e,t)=>{let r=[],n=!1;return{destroy(i){n||(n=!0,t(`${e}: Destroying connection`),r.forEach(e=>{e(i)}))},onDestroy(e){n?e():r.push(e)}}})("App",o),{destroy:l,onDestroy:a}=s;return{promise:new Promise((e,n)=>{let u=j(r,l),p=r=>{if((()=>{try{clearTimeout(void 0)}catch(g){return!1}return!0})()&&r.source===parent&&r.data&&r.data.penpal===g.SynAck){let n=r.data,l=new J(n.id,n.appName),a=new J(n.id+"/api",n.appName),c=n.id,d={},f=e=>"api/"+e,h={$emit:(e,...t)=>{l.getEventCallBackLength(e)>0?l.$pureEmit(e,...t):null==d||d.$off(e,()=>{},!0)},onQueryResult:e=>{a.$pureEmit(f(e.reqid),e)},onOsNotify:e=>{}},v=e=>function(t){let r=f(e);a.$on(r,function e(n){t.next(n),n.reqid&&"succ"===n.result&&(t.complete(),a.$off(r,e))})},y=$(h),g=T(t,y,N(N({},i),{},{postmateVersion:"0.1.3"}),s,o)(r),b={},m=g.bus;if(m){let e=(e,t,r)=>{r?(t.$__once=!0,l.$once(e,t)):l.$on(e,t),b[e]?b[e].push(t):(b[e]=[t],m.$on(e,c))};Object.assign(d,m,{$on:e,$off:(e,t,r)=>{var n;if(b[e]){let r=b[e].indexOf(t);r>-1&&(b[e].splice(r,1),l.$off(e,t))}r&&delete b[e],null!=(n=b[e])&&n.length||(m.$off(e,c),delete b[e])},$once:(t,r)=>{e(t,r,!0)}})}let w=N(N({},g),{},{bus:d,query:function(){var e=Ee(function*(e,t){let r=(null==e?void 0:e.reqid)||(yield g.genReqId());return new Promise((n,i)=>{g.query(N(N({},e),{},{reqid:r}),t).then(e=>{"succ"===(null==e?void 0:e.result)?n(e):i(e)})})});return function(t,r){return e.apply(this,arguments)}}(),createObservableQuery:function(){var e=Ee(function*(e,t={}){let r=(null==e?void 0:e.reqid)||(yield g.genReqId()),n=new Pe(v(r));return g.query(N(N({},e),{},{reqid:r}),N(N({},t),{},{observable:!0})),n});return function(t){return e.apply(this,arguments)}}()});g&&(window.removeEventListener(A.Message,p),u(),e({methods:w,config:r.data.config}))}};window.addEventListener(A.Message,p),(()=>{o("Child: Handshake - Sending SYN");let e={penpal:g.Syn},r=t instanceof RegExp?"*":t;window.parent.postMessage(e,r)})(),a(e=>{window.removeEventListener(A.Message,p),e&&n(e)})}),destroy(){l()}}},$e="trimjs-extension-host";function _e(){return"undefined"!=typeof crypto&&"function"==typeof crypto.randomUUID?crypto.randomUUID():`web-ext-${Date.now()}-${Math.random().toString(16).slice(2)}`}function je(e){if(!e||"object"!=typeof e)return!1;const t=e;return t.source===$e&&1===t.version&&"string"==typeof t.requestId&&("request"===t.kind||"response"===t.kind||"event"===t.kind)}function ke(e){const t=/* @__PURE__ */new Error(`NotSupportedInExtensionHost: ${e}`);throw t.code="NotSupportedInExtensionHost",t}function We(e){var t;const r=null!==(t=null==e?void 0:e.timeoutMs)&&void 0!==t?t:1500,n=/* @__PURE__ */new Map,i=/* @__PURE__ */new Set,o=e=>{if(je(t=e.data)&&"response"===t.kind){const t=n.get(e.data.requestId);if(!t)return;return n.delete(e.data.requestId),clearTimeout(t.timeoutId),void t.resolve(e.data)}var t;(function(e){return je(e)&&"event"===e.kind})(e.data)&&i.forEach(t=>{t(e.data)})};return window.addEventListener("message",o),{request:(e,t,i)=>s(function*(){const o=function(e,t){return{source:$e,version:1,kind:"request",requestId:_e(),payload:{method:e,params:t}}}(e,t),s=yield new Promise((t,s)=>{let l;const a=i&&"timeoutMs"in i?i.timeoutMs:r;"number"==typeof a&&a>=0&&(l=setTimeout(()=>{n.delete(o.requestId),s(/* @__PURE__ */new Error(`Extension host timeout while calling ${e}`))},a)),n.set(o.requestId,{resolve:t,reject:s,timeoutId:l}),window.postMessage(o,"*")});var l,a;if(!s.payload.ok)throw new Error(null!==(l=null===(a=s.payload.error)||void 0===a?void 0:a.message)&&void 0!==l?l:`Extension host request failed: ${e}`);return s.payload.result})(),onEvent:e=>(i.add(e),()=>{i.delete(e)}),destroy(){window.removeEventListener("message",o),n.forEach(e=>{clearTimeout(e.timeoutId),e.reject(/* @__PURE__ */new Error("Extension bridge destroyed"))}),n.clear(),i.clear()}}}function qe(){return(qe=s(function*(e){const t=We();try{var r;const i=yield t.request("handshake",{debug:null!==(r=null==e?void 0:e.debug)&&void 0!==r&&r},{timeoutMs:5e3});return n.log("Extension host probe response:",i),!0===i.available}catch(i){return n.log("Extension host probe failed:",i),!1}finally{t.destroy()}})).apply(this,arguments)}function xe(){return xe=s(function*(){const e=We(),t=/* @__PURE__ */new Map;e.onEvent(e=>{const r=t.get(e.payload.event);null==r||r.forEach(t=>{t(e.payload.data)})}),yield e.request("handshake",{},{timeoutMs:5e3});const r=yield e.request("getPlatformConfig",{}),n={bus:{$on(r,n){var i;const o=null!==(i=t.get(r))&&void 0!==i?i:/* @__PURE__ */new Set,s=0===o.size;o.add(n),t.set(r,o),s&&e.request("subscribe",{event:r})},$off(r,n){const i=t.get(r);i&&(i.delete(n),0===i.size&&(t.delete(r),e.request("unsubscribe",{event:r})))},$once(e,t){const r=function(){n.bus.$off(e,r),t(...arguments)};n.bus.$on(e,r)},$emit(){ke("bus.$emit")}},genReqId:()=>_e(),query:(t,r)=>e.request("query",{params:t,config:r}),createObservableQuery:(o=s(function*(){return ke("createObservableQuery")}),function(){return o.apply(this,arguments)}),openApp:()=>ke("openApp"),openAppSetting:()=>ke("openAppSetting"),openCustomApp:()=>ke("openCustomApp"),setTitle:()=>ke("setTitle"),pickFile:t=>e.request("pickFile",t,{timeoutMs:null}),pickUserFile:()=>ke("pickUserFile"),pickSharedFile:()=>ke("pickSharedFile"),showFileDetails:(e,t)=>ke("showFileDetails"),authorizeUserFile:e=>ke("authorizeUserFile"),authorizeSharedFile:e=>ke("authorizeSharedFile"),close:(i=s(function*(){e.destroy()}),function(){return i.apply(this,arguments)}),refreshToken:()=>e.request("refreshToken",{}),setExitPageTips:()=>ke("setExitPageTips"),openFileManagerApp:()=>ke("openFileManagerApp"),openFileManager:()=>ke("openFileManager"),openFile:()=>ke("openFile"),getPlatformConfig:()=>Promise.resolve(r),getHostSnapshot:()=>e.request("getHostSnapshot",{})};var i,o,l;return{methods:n,config:(l=r,{appName:"extension-host",os:{version:l.systemVersion,theme:l.theme,language:l.language,format:{date:{date:l.format.date,time:l.format.time}}}})}}),xe.apply(this,arguments)}var Ne=null;function Te(){return(Te=s(function*(e){const t=Fe({debug:null==e?void 0:e.debug,timeout:1500});try{const e=yield t.promise;return e}catch(r){return t.destroy(),n.log("Native OS connection probe failed:",r),null}})).apply(this,arguments)}var Ie=e=>Ne||(Ne=s(function*(){if("undefined"!=typeof window&&window.parent!==window){const t=yield function(e){return Te.apply(this,arguments)}(e);if(t)return t}return(yield function(e){return qe.apply(this,arguments)}(e))?function(){return xe.apply(this,arguments)}():Fe({debug:null==e?void 0:e.debug}).promise})()),Ce={pickFile:"pick-file",pickUserFile:"pick-user-file",pickSharedFile:"pick-shared-file",authorizeUserFile:"authorize-user-file",authorizeSharedFile:"authorize-shared-file"};var Ve=class{constructor(){r(this,"options",void 0),r(this,"flutterInAppWebView",null),r(this,"osConnector",null),r(this,"initPromise",void 0),r(this,"appApiVersion",void 0),r(this,"isWeb",!0),r(this,"isStandaloneWeb",!1);let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.options=e,e.debug&&(n.setDebug(!0),n.log("Trim App initialized with debug mode")),n.log("Options:",e),this.initPromise=this.init()}init(){var e=this;return s(function*(){return n.log("Starting initialization..."),new Promise(t=>{if(i())e.isWeb=!1,n.log("Initializing mobile platform..."),new Promise(e=>{var t;(p?window.flutter_inappwebview:u||(null===(t=window.flutter_inappwebview)||void 0===t?void 0:t._platformReady))?(n.log("flutter_inappwebview is ready"),e(window.flutter_inappwebview)):window.addEventListener("flutterInAppWebViewPlatformReady",()=>{n.log("flutter_inappwebview is ready"),e(window.flutter_inappwebview)})}).then((r=s(function*(r){yield e.initMobileAppApi(r),n.log("Mobile platform initialized"),t()}),function(e){return r.apply(this,arguments)}));else{if(e.isWeb=!0,e.isStandaloneWeb="undefined"!=typeof window&&window.parent===window,e.isStandaloneWeb)return n.log("Standalone web platform detected; host bridge initialization skipped"),void t();n.log("Initializing web platform..."),Ie().then(r=>{e.osConnector=r,n.log("Web platform initialized"),t()})}var r})})()}getWebMethods(){var e;const t=null===(e=this.osConnector)||void 0===e?void 0:e.methods;if(!t)throw new Error("Host bridge is not available outside iframe or app runtime");return t}loadAppMessage(){var e=this;return s(function*(){return e.callAppMethod("getAppMessage")})()}initMobileAppApi(e){var t=this;return s(function*(){t.flutterInAppWebView=e;try{const e=yield t.loadAppMessage();t.appApiVersion=null==e?void 0:e.appApi}catch(r){n.log("Failed to load mobile appApi version:",r),t.appApiVersion=void 0}})()}assertMobileAppApi(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"v1.0";if(!this.isAtLeastVersion(this.appApiVersion,t))throw new Error(`${e} requires appApi >= ${t}`)}isAtLeastVersion(e,t){if(!e)return!1;const r=this.parseVersion(e),n=this.parseVersion(t),i=Math.max(r.length,n.length);for(let l=0;lt)return!0;if(eNumber.parseInt(e,10)||0)}callAppMethod(e){var t=arguments,r=this;return s(function*(){if(!r.flutterInAppWebView)return null;for(var n=t.length,i=new Array(n>1?n-1:0),o=1;o"string"==typeof e)&&(u=e)}catch(p){u=void 0}return{status:o.get("status")||void 0,error:o.get("error")||void 0,method:l,appName:o.get("appName")||void 0,state:o.get("state")||void 0,path:u}}(e)}setTitle(e){var t=this;return s(function*(){if(yield t.initPromise,n.log("Setting title:",e),document.title=e,t.isWeb)return t.getWebMethods().setTitle(e)})()}setExitPageTips(e){var t=this;return s(function*(){return yield t.initPromise,t.isWeb?t.getWebMethods().setExitPageTips(e):t.callAppMethod("setExitPageTips",e?JSON.stringify(e):"")})()}openFile(e){var t=this;return s(function*(){return yield t.initPromise,n.log("Opening file:",e),t.isWeb?t.getWebMethods().openFile(e):t.callAppMethod("openFile",e)})()}openFileManager(e){var t=this;return s(function*(){if(yield t.initPromise,n.log("Opening file manager:",e),t.isWeb){const r=t.getWebMethods();return(r.openFileManager||r.openFileManagerApp)(e)}return t.assertMobileAppApi("openFileManager"),t.callAppMethod("openFileManager",e)})()}showFileDetails(e,t){var r=this;return s(function*(){return yield r.initPromise,r.isWeb?r.getWebMethods().showFileDetails(e,t):(r.assertMobileAppApi("showFileDetails"),r.callAppMethod("showFileDetails",JSON.stringify({paths:e,options:t})))})()}authorizeUserFile(e){var t=this;return s(function*(){var r;return yield t.initPromise,t.isWeb?t.getWebMethods().authorizeUserFile(e):(t.assertMobileAppApi("authorizeUserFile"),null!==(r=yield t.callAppMethod("authorizeUserFile",JSON.stringify({path:e})))&&void 0!==r?r:void 0)})()}authorizeSharedFile(e){var t=this;return s(function*(){var r;return yield t.initPromise,t.isWeb?t.getWebMethods().authorizeSharedFile(e):(t.assertMobileAppApi("authorizeSharedFile"),null!==(r=yield t.callAppMethod("authorizeSharedFile",JSON.stringify({path:e})))&&void 0!==r?r:void 0)})()}openAppSetting(){var e=this;return s(function*(){return yield e.initPromise,n.log("Opening app setting"),e.isWeb?e.getWebMethods().openAppSetting():(e.assertMobileAppApi("openAppSetting"),e.callAppMethod("openAppSetting"))})()}openApp(e){var t=this;return s(function*(){return yield t.initPromise,n.log("Opening app:",e),t.isWeb?t.getWebMethods().openApp(e):!!i()&&t.callAppMethod("openAppPage",e)})()}openCustomApp(e,t){var r=this;return s(function*(){if(yield r.initPromise,n.log("Opening custom app:",e,t),r.isWeb)return r.getWebMethods().openCustomApp(e,t);throw new Error("openCustomApp is not supported on mobile platform")})()}openURL(e,t,r){var i=this;return s(function*(){if(yield i.initPromise,n.log("Opening URL:",e,t,r),!i.isWeb)return i.callAppMethod("openSystemBrowser",e);if("_self"===t)return void window.location.assign(e);if(!r)return void window.open(e,t,r);const o=window.open("",t,r);o&&(o.location.href=e)})()}close(){var e=this;return s(function*(){return yield e.initPromise,e.isWeb?e.getWebMethods().close():e.callAppMethod("exitPage")})()}query(e,t){var r=this;return s(function*(){if(yield r.initPromise,r.isWeb){if(null==t?void 0:t.observable)return r.getWebMethods().createObservableQuery(e,t);const n=yield r.getWebMethods().query(e,t);if(!n)throw new Error("Failed to query");return n}if(r.assertMobileAppApi("query"),null==t?void 0:t.observable){if(!r.flutterInAppWebView)throw new Error("Failed to start observable query");return function(e,t,r){return v(),new f(n=>{let i,o=!1;return c(e,"wsQuery",null==t?void 0:t.req,JSON.stringify(t),void 0===r?void 0:JSON.stringify(r)).then(e=>{(null==e?void 0:e.reqId)?(i=e.reqId,o||h.set(i,n)):n.error(/* @__PURE__ */new Error("Failed to start observable query"))},e=>{n.error(e)}),()=>{o=!0,i&&h.delete(i)}})}(r.flutterInAppWebView,e,t)}const n=yield r.callAppMethod("wsQuery",null==e?void 0:e.req,JSON.stringify(e),void 0===t?void 0:JSON.stringify(t));if(!n)throw new Error("Failed to query");if("succ"!==n.result)throw n;return n})()}refreshToken(){var e=this;return s(function*(){if(yield e.initPromise,!e.isWeb)throw new Error("refreshToken is not supported on mobile platform");return e.getWebMethods().refreshToken()})()}$on(e,t){var r=this;return s(function*(){if(yield r.initPromise,!r.isWeb)throw new Error("$on is not supported on mobile platform");const n=r.getWebMethods().bus;if(!n)throw new Error("bus is not available");n.$on(e,t)})()}$off(e,t){var r=this;return s(function*(){if(yield r.initPromise,!r.isWeb)throw new Error("$off is not supported on mobile platform");const n=r.getWebMethods().bus;if(!n)throw new Error("bus is not available");n.$off(e,t)})()}$once(e,t){var r=this;return s(function*(){if(yield r.initPromise,!r.isWeb)throw new Error("$once is not supported on mobile platform");const n=r.getWebMethods().bus;if(!n)throw new Error("bus is not available");n.$once(e,t)})()}pickFile(e){var t=this;return s(function*(){var r,n;return yield t.initPromise,t.isWeb?null===(r=t.osConnector)||void 0===r?void 0:r.methods.pickFile(e):t.isAtLeastVersion(t.appApiVersion,"v1.0")?null!==(n=yield t.callAppMethod("pickFile",JSON.stringify(e)))&&void 0!==n?n:void 0:t.flutterInAppWebView?function(e,t){return y.apply(this,arguments)}(t.flutterInAppWebView,e):void 0})()}pickUserFile(){var e=arguments,t=this;return s(function*(){var r;let n=e.length>0&&void 0!==e[0]?e[0]:{};return yield t.initPromise,t.isWeb?t.getWebMethods().pickUserFile(n):(t.assertMobileAppApi("pickUserFile"),null!==(r=yield t.callAppMethod("pickUserFile",JSON.stringify(n)))&&void 0!==r?r:void 0)})()}pickSharedFile(){var e=arguments,t=this;return s(function*(){var r;let n=e.length>0&&void 0!==e[0]?e[0]:{};return yield t.initPromise,t.isWeb?t.getWebMethods().pickSharedFile(n):(t.assertMobileAppApi("pickSharedFile"),null!==(r=yield t.callAppMethod("pickSharedFile",JSON.stringify(n)))&&void 0!==r?r:void 0)})()}};export{Ve as TrimApp,Ve as default};
\ No newline at end of file
diff --git a/会员服务器/app/www/settings.html b/会员服务器/app/www/settings.html
new file mode 100755
index 0000000..c91c69e
--- /dev/null
+++ b/会员服务器/app/www/settings.html
@@ -0,0 +1,291 @@
+
+
+
+
+
+会员服务器 · 设置
+
+
+
+
+
+
+
+
服务信息
+
成套报价软件客户端连接地址:飞牛主机 IP + 下方端口(如 192.168.x.x:端口)
+
+
服务端口
+
会员数量
名
+
数据库文件
+
项目云目录
+
+
端口如需修改:飞牛桌面 → 应用中心 → 会员服务器 → 应用设置(留空表示不修改,保存后应用自动重启)。
+
+
+
+
数据目录
+
存放数据库(huiyuan.json)与项目云数据(会员的项目/库备份 zip),保存后自动把现有数据迁移过去。
+
+
+
+
+
+
+
+
+
+
「授权新目录」把所选目录授权给本应用(需管理员,在飞牛桌面内操作);也可在 应用设置 → 目录授权 中手动添加后刷新本页选择。
+
+
+
+
+
+
加密通道
+
全站强制 HTTPS。客户端请使用下方加密地址连接;凭据类通讯在 TLS 之上还有信封加密(RSA+AES+HMAC+防重放)。
+
+
加密地址
+
TLS 证书指纹
-
+
RSA 公钥指纹
-
+
+
+
+
+
+
+
+
SSL 证书
+
导入正规 SSL 证书(如为 ss61.odn.cc 签发的证书,证书包解压出的 .pem 文件)后,浏览器访问不再有证书警告。导入立即生效,无需重启;升级重装会自动保留。
+
+
+
证书文件
(fullchain 全链文件,含证书链)
+
私钥文件
(与证书配对的私钥)
+
+
+
+
+
+
+
申请 ss61.odn.cc 的证书:在 odn 控制台把该域名的 80 端口也转发到本机一个端口,然后在 NAS 上执行
+ acme.sh --issue -d ss61.odn.cc --standalone --httpport 转发的端口 --server letsencrypt
+ 签发成功后,证书在 ~/.acme.sh/ss61.odn.cc_ecc/(fullchain.cer 与 ss61.odn.cc.key 即上面要导入的两个文件)。
+
+
+
+
安全
+
管理密码用于登录管理页面并保护管理接口;修改后其他已登录的浏览器需重新登录(token 24 小时后也需重新登录)。
+
+
开放注册
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/会员服务器/app/www/style.css b/会员服务器/app/www/style.css
new file mode 100755
index 0000000..2b32465
--- /dev/null
+++ b/会员服务器/app/www/style.css
@@ -0,0 +1,77 @@
+/* 会员服务器 · 统一样式(四页共用:会员管理/项目云管理/库备份管理/设置) */
+* { box-sizing: border-box; }
+body { font-family: "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; font-size: 14px; margin: 0; padding: 24px; background: #f5f6f8; color: #1f2329; }
+.wrap { max-width: 960px; margin: 0 auto; }
+
+/* ---- 顶部:标题 + 徽章 + 页面导航 ---- */
+header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
+header h2 { margin: 0; font-size: 20px; }
+.badge { background: #e8efff; color: #2563eb; border-radius: 10px; padding: 2px 10px; font-size: 12px; white-space: nowrap; }
+.nav { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
+.nav a { padding: 6px 14px; border-radius: 8px; color: #4b5563; text-decoration: none; font-size: 13px; white-space: nowrap; }
+.nav a:hover { background: #eceff3; color: #1f2329; }
+.nav a.cur { background: #2563eb; color: #fff; }
+.sub { color: #7c8590; font-size: 12px; margin: 0 0 14px; line-height: 1.7; }
+
+/* ---- 卡片 ---- */
+.card { background: #fff; border: 1px solid #e3e6eb; border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
+.card--table { padding: 0; overflow: hidden; }
+.card h3 { margin: 0 0 4px; font-size: 15px; }
+.card .sub { margin: 0 0 12px; }
+
+/* ---- 工具条 ---- */
+.bar { padding: 14px 16px; border-bottom: 1px solid #eef0f3; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+
+/* ---- 表格 ---- */
+table { border-collapse: collapse; width: 100%; background: #fff; }
+th, td { border: 1px solid #e8ebef; padding: 7px 10px; text-align: left; }
+th { background: #f7f8fa; font-weight: 600; white-space: nowrap; }
+tbody tr:hover td { background: #f7faff; }
+.card--table th, .card--table td { border-left: none; border-right: none; }
+.card--table th { border-top: none; }
+.card--table tr:last-child td { border-bottom: none; }
+.op button { margin-right: 4px; padding: 3px 10px; font-size: 12px; }
+.del { color: #c53030; }
+
+/* ---- 按钮与输入 ---- */
+button { padding: 7px 16px; border-radius: 7px; border: 1px solid #cfd5dd; background: #fff; color: #1f2329; cursor: pointer; font-size: 13px; }
+button:hover { border-color: #9db8f0; background: #fafbff; }
+button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
+button.primary:hover { background: #1d54d7; }
+input, select { padding: 6px 9px; border: 1px solid #cfd5dd; border-radius: 6px; font-size: 13px; background: #fff; color: #1f2329; }
+input:focus, select:focus { outline: none; border-color: #2563eb; }
+#tip { font-size: 13px; color: #1a8a55; }
+#tip.err { color: #c53030; }
+
+/* ---- 信息展示 ---- */
+.rows { display: grid; grid-template-columns: 110px 1fr; row-gap: 9px; align-items: baseline; }
+.rows .k { color: #7c8590; }
+.rows .v { word-break: break-all; }
+code { background: #f2f4f7; border: 1px solid #e8ebef; border-radius: 5px; padding: 2px 7px; font-size: 13px; font-family: Consolas, Menlo, monospace; }
+.tag { font-size: 12px; border-radius: 9px; padding: 1px 9px; white-space: nowrap; }
+.tag.cur { background: #e6f6ee; color: #1a8a55; }
+.tag.def { background: #e8efff; color: #2563eb; }
+.hint { color: #98a0aa; font-size: 12px; margin: 16px 0 0; line-height: 1.8; }
+.warn { display: none; background: #fff7e8; border: 1px solid #f5deb3; color: #9a6700; border-radius: 8px; padding: 8px 12px; font-size: 12px; margin: 10px 0 0; line-height: 1.7; word-break: break-all; }
+
+/* ---- 设置页:数据目录选项块 ---- */
+.opts { display: flex; flex-direction: column; gap: 8px; margin: 4px 0; }
+.opt { display: flex; align-items: center; gap: 10px; border: 1px solid #dfe3e9; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: border-color .15s, background .15s; }
+.opt:hover { border-color: #9db8f0; background: #fafbff; }
+.opt.sel { border-color: #2563eb; background: #f3f7ff; }
+.opt input[type=radio] { width: 15px; height: 15px; margin: 0; flex: none; accent-color: #2563eb; }
+.opt .body { flex: 1; min-width: 0; }
+.opt .title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.opt .title .name { font-weight: 600; }
+.opt code { display: inline-block; margin-top: 3px; overflow-wrap: anywhere; }
+.opt input[type=text] { width: 100%; padding: 6px 9px; margin-top: 6px; }
+.actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
+
+/* ---- 弹窗(会员管理·新增会员) ---- */
+dialog { border: none; border-radius: 12px; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.18); min-width: 340px; }
+dialog::backdrop { background: rgba(15,23,42,.4); }
+dialog h3 { margin: 0 0 14px; font-size: 16px; }
+dialog > div { margin-bottom: 10px; }
+dialog label { display: inline-block; width: 80px; color: #5c6672; }
+dialog input, dialog select { width: 220px; }
+.dlg-actions { text-align: right; margin-top: 14px; margin-bottom: 0; }
diff --git a/会员服务器/app/www/xiangmu-cloud.html b/会员服务器/app/www/xiangmu-cloud.html
new file mode 100755
index 0000000..0c399c8
--- /dev/null
+++ b/会员服务器/app/www/xiangmu-cloud.html
@@ -0,0 +1,146 @@
+
+
+
+
+
+项目云管理
+
+
+
+
+
+
按手机号查看名下云上项目(客户端上传的项目 zip),可下载或删除。
+
+
+
+ 手机号:
+
+ 或输入:
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/会员服务器/app/www/xiangmu-cloud.html.备份 b/会员服务器/app/www/xiangmu-cloud.html.备份
new file mode 100755
index 0000000..73d7e19
--- /dev/null
+++ b/会员服务器/app/www/xiangmu-cloud.html.备份
@@ -0,0 +1,115 @@
+
+
+
+
+
+项目云管理
+
+
+
+
+
+
按手机号查看名下云上项目(客户端上传的项目 zip),可下载或删除。
+
+
+
+ 手机号:
+
+ 或输入:
+
+
+
+
+
+ | 项目编号 | 项目名称 | 上传时间 | 大小 | 操作 |
+
+
+
+
+
+
+
+
+
+
diff --git a/会员服务器/cmd/config_callback b/会员服务器/cmd/config_callback
new file mode 100755
index 0000000..4ce442e
--- /dev/null
+++ b/会员服务器/cmd/config_callback
@@ -0,0 +1,41 @@
+#!/bin/bash
+
+### 配置变更回调(应用设置向导提交后):更新 app.env,向导留空的项保持不变
+
+CFG="${TRIM_PKGETC}/app.env"
+[ -f "${CFG}" ] || exit 0
+
+if [ -n "${wizard_port}" ]; then
+ sed -i "s|^HY_PORT=.*|HY_PORT=${wizard_port}|" "${CFG}"
+fi
+
+if [ -n "${wizard_dbdir}" ]; then
+ if [ ! -d "${wizard_dbdir}" ]; then
+ if mkdir -p "${wizard_dbdir}" 2>/dev/null; then
+ [ -n "${TRIM_UID}" ] && chown "${TRIM_UID}:${TRIM_GID}" "${wizard_dbdir}" 2>/dev/null
+ else
+ echo "警告:数据目录 ${wizard_dbdir} 创建失败,保持原目录不变" >> "${TRIM_TEMP_LOGFILE}" 2>/dev/null
+ fi
+ fi
+ [ -d "${wizard_dbdir}" ] && sed -i "s|^HY_DB_DIR=.*|HY_DB_DIR=${wizard_dbdir}|" "${CFG}"
+fi
+
+# 管理密码(留空不修改;更新盐+哈希两行,不存在则追加)
+if [ -n "${wizard_admin_pwd}" ]; then
+ SALT="$(date +%s%N)$RANDOM"
+ HASH="$(printf '%s' "${SALT}${wizard_admin_pwd}" | sha256sum | cut -d' ' -f1)"
+ if grep -q "^ADMIN_PWD_SALT=" "${CFG}"; then
+ sed -i "s|^ADMIN_PWD_SALT=.*|ADMIN_PWD_SALT=${SALT}|" "${CFG}"
+ else
+ echo "ADMIN_PWD_SALT=${SALT}" >> "${CFG}"
+ fi
+ if grep -q "^ADMIN_PWD_SHA256=" "${CFG}"; then
+ sed -i "s|^ADMIN_PWD_SHA256=.*|ADMIN_PWD_SHA256=${HASH}|" "${CFG}"
+ else
+ echo "ADMIN_PWD_SHA256=${HASH}" >> "${CFG}"
+ fi
+fi
+
+echo "$(date '+%Y-%m-%d %H:%M:%S') - config updated port=${wizard_port:-保持} dbdir=${wizard_dbdir:-保持} admin_pwd=$([ -n "${wizard_admin_pwd}" ] && echo 已修改 || echo 保持)" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+
+exit 0
diff --git a/会员服务器/cmd/config_init b/会员服务器/cmd/config_init
new file mode 100755
index 0000000..cacd475
--- /dev/null
+++ b/会员服务器/cmd/config_init
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+### This script is called before the user change environment variables in application setting page.
+
+exit 0
\ No newline at end of file
diff --git a/会员服务器/cmd/install_callback b/会员服务器/cmd/install_callback
new file mode 100755
index 0000000..dd98477
--- /dev/null
+++ b/会员服务器/cmd/install_callback
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+### 安装完成回调:把向导值合并写入 app.env(cmd/main 与服务进程读取)。
+### 合并式:只覆盖本脚本管理的键(HY_PORT/HY_DB_DIR/ADMIN_*),设置页保存的其他键(如 HY_REG_OPEN)保留。
+
+mkdir -p "${TRIM_PKGETC}" 2>/dev/null
+CFG="${TRIM_PKGETC}/app.env"
+
+PORT="${wizard_port}"
+DBDIR="${wizard_dbdir}"
+[ -z "${PORT}" ] && PORT="${TRIM_SERVICE_PORT:-12701}"
+
+# 向导填了自定义数据目录:目录不存在则创建,新建目录归属应用用户(已存在的目录不动,由 ACL/目录授权控制)
+if [ -n "${DBDIR}" ] && [ ! -d "${DBDIR}" ]; then
+ if mkdir -p "${DBDIR}" 2>/dev/null; then
+ [ -n "${TRIM_UID}" ] && chown "${TRIM_UID}:${TRIM_GID}" "${DBDIR}" 2>/dev/null
+ else
+ echo "警告:数据目录 ${DBDIR} 创建失败,将使用默认目录" >> "${TRIM_TEMP_LOGFILE}" 2>/dev/null
+ DBDIR=""
+ fi
+fi
+
+# 合并旧配置:跳过本脚本管理的键,其余原样保留
+TMP="${CFG}.new"
+: > "${TMP}"
+if [ -f "${CFG}" ]; then
+ while IFS='=' read -r k v; do
+ [ -z "${k}" ] && continue
+ case "${k}" in
+ HY_PORT|HY_DB_DIR|ADMIN_PWD_SALT|ADMIN_PWD_SHA256) ;;
+ *) printf '%s=%s\n' "${k}" "${v}" >> "${TMP}" ;;
+ esac
+ done < "${CFG}"
+fi
+
+# 管理密码(向导必填;留空则保留旧哈希,若从未设置过则服务端登录时会提示未设置)
+if [ -n "${wizard_admin_pwd}" ]; then
+ SALT="$(date +%s%N)$RANDOM${TRIM_APPNAME:-hy}"
+ HASH="$(printf '%s' "${SALT}${wizard_admin_pwd}" | sha256sum | cut -d' ' -f1)"
+ printf 'ADMIN_PWD_SALT=%s\n' "${SALT}" >> "${TMP}"
+ printf 'ADMIN_PWD_SHA256=%s\n' "${HASH}" >> "${TMP}"
+fi
+
+printf 'HY_PORT=%s\n' "${PORT}" >> "${TMP}"
+printf 'HY_DB_DIR=%s\n' "${DBDIR}" >> "${TMP}"
+mv "${TMP}" "${CFG}"
+
+echo "$(date '+%Y-%m-%d %H:%M:%S') - installed v${TRIM_APPVER} port=${PORT} dbdir=${DBDIR:-默认} admin_pwd=$([ -n "${wizard_admin_pwd}" ] && echo 已设置 || echo 保持)" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+
+exit 0
diff --git a/会员服务器/cmd/install_init b/会员服务器/cmd/install_init
new file mode 100755
index 0000000..d22ce13
--- /dev/null
+++ b/会员服务器/cmd/install_init
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+### 安装初始化:建默认数据目录;若数据目录存在上次卸载前备份的 TLS/RSA 密钥与配置则恢复
+### (保证已导入的正规 SSL 证书跨升级保留)。安装包不带任何会员数据,空库由服务首次启动创建。
+
+DB_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
+[ -z "${DB_DIR}" ] && DB_DIR="${TRIM_PKGVAR}/files"
+mkdir -p "${DB_DIR}" 2>/dev/null
+
+BAK="${DB_DIR}/.huiyuan-密钥备份"
+if [ -d "${BAK}" ]; then
+ mkdir -p "${TRIM_PKGETC}" 2>/dev/null
+ for f in tls.key tls.crt huiyuan_rsa.pem app.env; do
+ if [ -f "${BAK}/${f}" ] && [ ! -f "${TRIM_PKGETC}/${f}" ]; then
+ cp -f "${BAK}/${f}" "${TRIM_PKGETC}/${f}" 2>/dev/null
+ fi
+ done
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - 已从 ${BAK} 恢复 TLS/RSA 密钥与配置" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+fi
+
+exit 0
diff --git a/会员服务器/cmd/main b/会员服务器/cmd/main
new file mode 100755
index 0000000..7f496a0
--- /dev/null
+++ b/会员服务器/cmd/main
@@ -0,0 +1,130 @@
+#!/bin/bash
+
+LOG_FILE="${TRIM_PKGVAR}/info.log"
+PID_FILE="${TRIM_PKGVAR}/app.pid"
+
+NODE_BIN="/var/apps/nodejs_v22/target/bin/node"
+[ ! -x "${NODE_BIN}" ] && NODE_BIN="$(command -v node || echo node)"
+SERVER="${TRIM_APPDEST}/server/server.cjs"
+
+# 从 app.env 读取向导持久化的配置(install_callback/config_callback 写入)
+CFG_FILE="${TRIM_PKGETC}/app.env"
+HY_PORT=""
+HY_DB_DIR=""
+if [ -f "${CFG_FILE}" ]; then
+ while IFS='=' read -r k v; do
+ case "$k" in
+ HY_PORT) HY_PORT="$v" ;;
+ HY_DB_DIR) HY_DB_DIR="$v" ;;
+ esac
+ done < "${CFG_FILE}"
+fi
+[ -z "${HY_PORT}" ] && HY_PORT="${TRIM_SERVICE_PORT:-12701}"
+
+CMD="${NODE_BIN} ${SERVER}"
+
+log_msg() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> ${LOG_FILE}
+}
+
+start_process() {
+ if status; then
+ return 0
+ fi
+
+ log_msg "Starting process (port=${HY_PORT} dbdir=${HY_DB_DIR:-默认}) ..."
+ # run cmd to start process
+ HY_PORT="${HY_PORT}" HY_DB_DIR="${HY_DB_DIR}" HY_CFG="${CFG_FILE}" \
+ bash -c "${CMD}" >> ${LOG_FILE} 2>&1 &
+ # write pid to pidfile
+ printf "%s" "$!" > "${PID_FILE}"
+ sleep 2
+ # 启动后确认进程仍在,失败时提示日志位置
+ if status; then
+ return 0
+ fi
+ echo "会员服务器启动失败,请查看日志: ${LOG_FILE}" >&2
+ return 1
+}
+
+stop_process() {
+ log_msg "Stopping process ..."
+
+ if [ -r "${PID_FILE}" ]; then
+ pid=$(head -n 1 "${PID_FILE}" | tr -d '[:space:]')
+
+ log_msg "pid=${pid}"
+ if ! check_process "${pid}"; then
+ # process not exist, delete pidfile
+ rm -f "${PID_FILE}"
+ log_msg "remove pid file 1"
+ return
+ fi
+
+ log_msg "send TERM signal to PID:${pid}..."
+ kill -TERM ${pid} >> ${LOG_FILE} 2>&1
+
+ local count=0
+ while check_process "${pid}" && [ $count -lt 10 ]; do
+ sleep 1
+ count=$((count + 1))
+ log_msg "waiting process terminal... (${count}s/10s)"
+ done
+
+ if check_process "${pid}"; then
+ log_msg "send KILL signal to PID:${pid}..."
+ kill -KILL ${pid}
+ sleep 1
+ rm -f "${PID_FILE}"
+ else
+ log_msg "process killed... "
+ fi
+ fi
+
+ return 0
+}
+
+check_process() {
+ local pid=$1
+ if kill -0 "$pid" 2>/dev/null; then
+ return 0 # process exist
+ else
+ return 1
+ fi
+}
+
+status() {
+ if [ -f "${PID_FILE}" ]; then
+ pid=$(head -n 1 "${PID_FILE}" | tr -d '[:space:]')
+ if check_process "${pid}"; then
+ return 0
+ else
+ # Process is not running but pidfile exists - clean it up
+ rm -f "${PID_FILE}"
+ fi
+ fi
+
+ return 1
+}
+
+case $1 in
+start)
+ # run start command. exit 0 if success, exit 1 if failed
+ start_process
+ ;;
+stop)
+ # run stop command. exit 0 if success, exit 1 if failed
+ stop_process
+ ;;
+status)
+ # check application status command. exit 0 if running, exit 3 if not running
+ if status; then
+ exit 0
+ else
+ exit 3
+ fi
+ ;;
+*)
+ exit 1
+ ;;
+esac
diff --git a/会员服务器/cmd/uninstall_callback b/会员服务器/cmd/uninstall_callback
new file mode 100755
index 0000000..52f5bf0
--- /dev/null
+++ b/会员服务器/cmd/uninstall_callback
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+### This script is called after the user uninstalls the application.
+
+exit 0
\ No newline at end of file
diff --git a/会员服务器/cmd/uninstall_init b/会员服务器/cmd/uninstall_init
new file mode 100755
index 0000000..9859a3d
--- /dev/null
+++ b/会员服务器/cmd/uninstall_init
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+### 卸载初始化:把 TLS 证书/RSA 密钥/配置备份到数据目录(数据目录在应用目录外,卸载不清除),
+### 这样升级重装后 install_init 可以恢复,已导入的正规 SSL 证书不会丢失。
+
+DB_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
+
+# 自定义数据目录优先(与 install_callback 的写法一致)
+CFG="${TRIM_PKGETC}/app.env"
+if [ -f "${CFG}" ]; then
+ while IFS='=' read -r k v; do
+ case "$k" in
+ HY_DB_DIR) DB_DIR="$v" ;;
+ esac
+ done < "${CFG}"
+fi
+[ -z "${DB_DIR}" ] && DB_DIR="${TRIM_PKGVAR}/files"
+
+BAK="${DB_DIR}/.huiyuan-密钥备份"
+mkdir -p "${BAK}" 2>/dev/null
+for f in tls.key tls.crt huiyuan_rsa.pem app.env; do
+ if [ -f "${TRIM_PKGETC}/${f}" ]; then
+ cp -f "${TRIM_PKGETC}/${f}" "${BAK}/${f}" 2>/dev/null
+ fi
+done
+
+echo "$(date '+%Y-%m-%d %H:%M:%S') - 卸载前已备份 TLS/RSA 密钥与配置到 ${BAK}" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+
+exit 0
diff --git a/会员服务器/cmd/upgrade_callback b/会员服务器/cmd/upgrade_callback
new file mode 100755
index 0000000..02f283f
--- /dev/null
+++ b/会员服务器/cmd/upgrade_callback
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+### 升级完成回调:正常升级不覆盖 TRIM_PKGETC,配置自动延续。此处再校验兜底:
+### 若 app.env 因升级异常丢失,从 upgrade_init 在数据目录做的备份恢复,
+### 保证其他服务器上的端口/数据目录/管理密码/注册开关等配置全部沿用,绝不被重置。
+
+CFG="${TRIM_PKGETC}/app.env"
+DB_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
+
+if [ -f "${CFG}" ]; then
+ while IFS='=' read -r k v; do
+ case "$k" in
+ HY_DB_DIR) DB_DIR="$v" ;;
+ esac
+ done < "${CFG}"
+fi
+[ -z "${DB_DIR}" ] && DB_DIR="${TRIM_PKGVAR}/files"
+
+BAK="${DB_DIR}/.huiyuan-升级备份"
+
+if [ ! -f "${CFG}" ] && [ -f "${BAK}/app.env" ]; then
+ mkdir -p "${TRIM_PKGETC}" 2>/dev/null
+ cp -f "${BAK}/app.env" "${CFG}" 2>/dev/null
+ for f in tls.key tls.crt huiyuan_rsa.pem; do
+ [ -f "${BAK}/${f}" ] && cp -f "${BAK}/${f}" "${TRIM_PKGETC}/${f}" 2>/dev/null
+ done
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - 升级后检测到配置丢失,已从 ${BAK} 恢复 app.env 与密钥" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+ # 重读恢复出的数据目录,一并恢复数据库(若升级后库文件异常缺失)
+ while IFS='=' read -r k v; do
+ case "$k" in
+ HY_DB_DIR) DB_DIR="$v" ;;
+ esac
+ done < "${CFG}"
+ if [ -f "${BAK}/huiyuan.json" ] && [ ! -f "${DB_DIR}/huiyuan.json" ]; then
+ cp -f "${BAK}/huiyuan.json" "${DB_DIR}/huiyuan.json" 2>/dev/null
+ echo "$(date '+%Y-%m-%d %H:%M:%S') - 已从备份恢复数据库 huiyuan.json 到 ${DB_DIR}" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+ fi
+fi
+
+echo "$(date '+%Y-%m-%d %H:%M:%S') - upgraded to v${TRIM_APPVER} (from v${TRIM_OLD_APPVER:-unknown}) 配置与数据沿用" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+
+exit 0
diff --git a/会员服务器/cmd/upgrade_init b/会员服务器/cmd/upgrade_init
new file mode 100755
index 0000000..5e1d8b9
--- /dev/null
+++ b/会员服务器/cmd/upgrade_init
@@ -0,0 +1,39 @@
+#!/bin/bash
+
+### 升级初始化:升级覆盖的是 TRIM_APPDEST(target),不动 TRIM_PKGETC(配置)与数据目录。
+### 但为防升级流程异常导致配置丢失,升级前把 app.env/TLS/RSA 密钥/数据库打包备份到
+### 数据目录(应用目录之外,升级不清除),upgrade_callback 检测到配置丢失时用此备份恢复。
+### 备份内容不含任何会员数据和配置本体,只在升级窗口存在,升级正常完成后保留最近一份
+### 作为兜底,不参与运行。
+
+DB_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
+
+# 自定义数据目录优先(与 install_callback/uninstall_init 的解析规则一致)
+CFG="${TRIM_PKGETC}/app.env"
+if [ -f "${CFG}" ]; then
+ while IFS='=' read -r k v; do
+ case "$k" in
+ HY_DB_DIR) DB_DIR="$v" ;;
+ esac
+ done < "${CFG}"
+fi
+[ -z "${DB_DIR}" ] && DB_DIR="${TRIM_PKGVAR}/files"
+
+BAK="${DB_DIR}/.huiyuan-升级备份"
+mkdir -p "${BAK}" 2>/dev/null
+
+# 备份配置目录里的 app.env 与密钥证书(若存在)
+for f in app.env tls.key tls.crt huiyuan_rsa.pem; do
+ if [ -f "${TRIM_PKGETC}/${f}" ]; then
+ cp -f "${TRIM_PKGETC}/${f}" "${BAK}/${f}" 2>/dev/null
+ fi
+done
+
+# 备份数据库(小 JSON 文件,整库快照)
+if [ -f "${DB_DIR}/huiyuan.json" ]; then
+ cp -f "${DB_DIR}/huiyuan.json" "${BAK}/huiyuan.json" 2>/dev/null
+fi
+
+echo "$(date '+%Y-%m-%d %H:%M:%S') - 升级前已备份配置与数据库到 ${BAK} (v${TRIM_OLD_APPVER:-?} → v${TRIM_APPVER})" >> "${TRIM_PKGVAR}/app.log" 2>/dev/null
+
+exit 0
diff --git a/会员服务器/config/privilege b/会员服务器/config/privilege
new file mode 100755
index 0000000..ea01bc5
--- /dev/null
+++ b/会员服务器/config/privilege
@@ -0,0 +1,6 @@
+{
+ "defaults":
+ {
+ "run-as": "package"
+ }
+}
\ No newline at end of file
diff --git a/会员服务器/config/resource b/会员服务器/config/resource
new file mode 100755
index 0000000..ed43741
--- /dev/null
+++ b/会员服务器/config/resource
@@ -0,0 +1,12 @@
+{
+ "data-share": {
+ "shares": [
+ {
+ "name": "fnnas.huiyuan/数据目录"
+ }
+ ]
+ },
+ "api-scope": [
+ "trim.file.sharedAccess"
+ ]
+}
diff --git a/会员服务器/manifest b/会员服务器/manifest
new file mode 100755
index 0000000..aa7ecfa
--- /dev/null
+++ b/会员服务器/manifest
@@ -0,0 +1,15 @@
+appname = fnnas.huiyuan
+version = 1.5.1
+display_name = 会员服务器
+desc = 成套报价软件的会员系统服务器:会员注册/登录/查询/改密 API、会员管理页面,以及项目云(按会员归档项目与库备份 zip,支持上传/列表/下载/删除)。安装时可选端口,数据目录(huiyuan.json 与项目云)默认在应用数据目录,安装后可通过目录授权或网页设置页更改。
+platform = all
+source = thirdparty
+maintainer = fnosyy
+distributor = fnosyy
+desktop_uidir = ui
+desktop_applaunchname = fnnas.huiyuan.Application
+service_port = 12701
+checkport = true
+install_dep_apps = nodejs_v22
+micro_app = true
+os_min_version = 0.9.0
diff --git a/会员服务器/wizard/config b/会员服务器/wizard/config
new file mode 100755
index 0000000..ea21631
--- /dev/null
+++ b/会员服务器/wizard/config
@@ -0,0 +1,47 @@
+[
+ {
+ "stepTitle": "修改设置",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "留空表示不修改、保持当前值。修改端口后应用会自动重启,桌面入口将使用新端口。数据库目录也可在应用网页的设置页中通过目录授权切换。"
+ },
+ {
+ "type": "text",
+ "field": "wizard_port",
+ "label": "服务端口",
+ "initValue": "",
+ "rules": [
+ {
+ "pattern": "^$|^[0-9]+$",
+ "message": "端口必须是数字,或留空表示不修改"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "field": "wizard_dbdir",
+ "label": "数据目录",
+ "initValue": "",
+ "rules": [
+ {
+ "pattern": "^$|^/.+$",
+ "message": "必须是 / 开头的完整路径,或留空表示不修改"
+ }
+ ]
+ },
+ {
+ "type": "password",
+ "field": "wizard_admin_pwd",
+ "label": "管理密码",
+ "initValue": "",
+ "rules": [
+ {
+ "pattern": "^$|.{6,}",
+ "message": "管理密码至少 6 位,或留空表示不修改"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/会员服务器/wizard/install b/会员服务器/wizard/install
new file mode 100755
index 0000000..293a6af
--- /dev/null
+++ b/会员服务器/wizard/install
@@ -0,0 +1,63 @@
+[
+ {
+ "stepTitle": "基本设置",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "会员服务器为成套报价软件提供会员注册、登录、查询、改密 API。成套报价软件客户端连接地址需配置为本飞牛主机 IP 加下方端口。"
+ },
+ {
+ "type": "text",
+ "field": "wizard_port",
+ "label": "服务端口",
+ "initValue": "12701",
+ "rules": [
+ {
+ "required": true,
+ "message": "请输入服务端口"
+ },
+ {
+ "pattern": "^[0-9]+$",
+ "message": "端口必须是数字"
+ }
+ ]
+ },
+ {
+ "type": "tips",
+ "helpText": "数据库(huiyuan.json)默认存放在应用数据目录(文件管理 → 应用文件 → fnnas.huiyuan/数据目录)。如需放到其他位置,可填写以 / 开头的完整目录路径(需保证应用用户可读写);留空使用默认目录。安装后也可在应用设置的目录授权中授权目录,再到网页设置页切换。"
+ },
+ {
+ "type": "text",
+ "field": "wizard_dbdir",
+ "label": "数据库目录(可选)",
+ "initValue": "",
+ "rules": [
+ {
+ "pattern": "^$|^/.+$",
+ "message": "必须是 / 开头的完整路径,或留空使用默认目录"
+ }
+ ]
+ },
+ {
+ "type": "password",
+ "field": "wizard_admin_pwd",
+ "label": "管理密码",
+ "initValue": "",
+ "rules": [
+ {
+ "required": true,
+ "message": "请设置管理密码(用于登录管理页面)"
+ },
+ {
+ "min": 6,
+ "message": "管理密码至少 6 位"
+ }
+ ]
+ },
+ {
+ "type": "tips",
+ "helpText": "管理密码用于登录会员管理/项目云/库备份/设置页面,并保护管理接口,请妥善保管。客户端接口(登录/注册等)不受影响。"
+ }
+ ]
+ }
+]
diff --git a/会员服务器/客户端-删除接口对接文档.txt b/会员服务器/客户端-删除接口对接文档.txt
new file mode 100755
index 0000000..b13125b
--- /dev/null
+++ b/会员服务器/客户端-删除接口对接文档.txt
@@ -0,0 +1,151 @@
+══════════════════════════════════════════════════════════
+ 项目云「删除接口」客户端对接文档
+ 2026-09-24 · 适用服务端 v1.5.1
+══════════════════════════════════════════════════════════
+
+一、接口总览
+──────────────────────────────
+删除接口是项目云 API 之一,与其他项目云接口共用同一套
+鉴权方式(手机号 + 密码)。全部项目云接口:
+
+ POST /api/proj/upload 上传项目 zip(覆盖式,≤2GB,每会员共 1GB)
+ GET /api/proj/list 列出该手机号名下全部项目
+ GET /api/proj/download 下载项目 zip
+ POST /api/proj/delete ★ 本文档:删除一个项目
+
+项目云所有接口都有两种调用方式:
+ · 方式 A:信封加密通道(推荐,与登录/注册同一套封装)
+ · 方式 B:HTTPS 明文 JSON(与上传/下载同一套封装)
+
+二、服务端实现说明(方式 B:HTTPS 明文)
+──────────────────────────────
+【请求】
+ POST https://服务器IP:端口/api/proj/delete
+ Content-Type: application/json
+
+ 请求体(JSON,UTF-8):
+ {
+ "手机号": "13800001111",
+ "密码": "该会员的登录密码",
+ "编号": "要删除的项目编号"
+ }
+
+【服务端处理流程】
+ 1. 校验手机号+密码与 huiyuan.json 中会员记录匹配
+ (不匹配 → {ok:false, msg:"手机号或密码错误"})
+ 2. 编号经文件名安全化后拼出项目目录,并做防路径穿越校验
+ (拼接结果必须仍在 项目云 根目录之下)
+ 3. 直接递归删除该项目的整个目录(项目.zip + meta.json)
+ 4. 删除是不可恢复的物理删除
+
+【响应】
+ 成功: {"ok": true}
+ 失败: {"ok": false, "msg": "失败原因"}
+ 常见失败:
+ "手机号或密码错误" 鉴权失败
+ "参数错误" 手机号或编号缺失
+ "该接口已迁移至加密通道,请升级客户端并使用 https://IP:端口 连接"
+ 用了 http:// (必须 https)
+
+三、服务端实现说明(方式 A:信封加密通道 /api/enc)
+──────────────────────────────
+删除接口与项目列表一起,同时注册在加密通道内
+(ENC_ROUTES),客户端如果已经在用登录/注册的信封封装,
+删除不需要单独再发明文请求,走同一封装即可。
+
+【流程】
+ 1. GET https://服务器IP:端口/api/pubkey
+ → {"ok":true, "pubkey":"RSA公钥PEM", "keyfp":"指纹"}
+ 2. 客户端随机生成 32 字节 AES-256 密钥 + 16 字节 IV
+ 3. 业务 JSON(下述 inner)用 AES-256-CBC 加密
+ 4. AES 密钥用 RSA-2048 OAEP(SHA-1) 封装
+ 5. POST /api/enc,请求体:
+ {
+ "k": AES密钥的RSA-OAEP封装(base64),
+ "iv": AES IV(hex),
+ "data": 密文(base64),
+ "mac": HMAC-SHA256(ts+'.'+nonce+'.'+iv+'.'+data) hex,
+ "ts": 毫秒时间戳(±5分钟有效),
+ "nonce":随机数(10分钟内不可重复)
+ }
+ 其中密文解开后是 inner:
+ { "path": "/api/proj/delete",
+ "body": { "手机号":"...", "密码":"...", "编号":"..." } }
+ 6. 响应也是加密的:{"ok":true,"iv":..,"mac":..,"data":..}
+ data 用同一 AES 密钥解开即 {"ok":true}
+
+【注意】
+ · /api/enc 只在 HTTPS 端口提供,http 端口一律返回
+ TLS_REQUIRED 迁移提示
+ · 防重放:同一 nonce 10 分钟内第二次提交会被拒绝
+ · HMAC 校验失败 = 数据被篡改,服务端直接拒绝
+
+四、端口与地址
+──────────────────────────────
+ · 客户端所有接口一律走 HTTPS:
+ https://飞牛主机IP:HTTPS端口
+ · HTTPS 端口 = 网页端口 + 1(默认网页 12701 → HTTPS 12702)
+ · 证书是自签的,客户端需忽略证书校验
+ (.NET: ServicePointManager.ServerCertificateValidationCallback
+ 返回 true;或比对 /api/pubkey 的 keyfp 指纹)
+ · 手机号 11 位数字;密码即会员登录密码(与 /api/login 相同)
+
+五、调用时序建议
+──────────────────────────────
+ 1. (首次)GET /api/pubkey 取公钥与指纹,指纹可展示给
+ 用户核对,防中间人
+ 2. 客户端本地登录缓存手机号+密码(或调用 /api/login 验证)
+ 3. GET /api/proj/list 拿项目编号列表(编号即删除参数)
+ 4. 用户确认要删除后,POST /api/proj/delete 传编号
+ 5. 依返回 ok 刷新本地项目列表
+
+六、删除的边界行为
+──────────────────────────────
+ · 删除的单位是"项目编号":一个编号对应项目云里一个
+ 目录(项目.zip + meta.json),整体删除
+ · 编号不存在时不会报错,同样返回 {ok:true}(幂等)
+ · 不提供批量删除参数,多个项目由客户端循环逐条调用
+ (每条之间建议留 200ms 间隔,避免触发限速)
+ · 登录/管理接口按 IP 限速(1 分钟内失败≥10 次锁 60 秒),
+ 删除接口的鉴权失败不计入限速计数,无请求频率限制
+
+七、明文调用示例(方式 B,C#)
+──────────────────────────────
+// 忽略自签证书
+ServicePointManager.ServerCertificateValidationCallback =
+ (s, cert, chain, err) => true;
+
+var url = "https://192.168.1.10:12702/api/proj/delete";
+var body = "{\"手机号\":\"13800001111\",\"密码\":\"123456\",\"编号\":\"XM20260924001\"}";
+var req = (HttpWebRequest)WebRequest.Create(url);
+req.Method = "POST";
+req.ContentType = "application/json";
+var bytes = Encoding.UTF8.GetBytes(body);
+using (var st = req.GetRequestStream()) st.Write(bytes, 0, bytes.Length);
+try {
+ using (var resp = (HttpWebResponse)req.GetResponse())
+ using (var rs = new StreamReader(resp.GetResponseStream(), Encoding.UTF8)) {
+ Console.WriteLine(rs.ReadToEnd());
+ // 成功输出: {"ok":true}
+ }
+} catch (WebException ex) {
+ using (var rs = new StreamReader(ex.Response.GetResponseStream(), Encoding.UTF8))
+ Console.WriteLine(rs.ReadToEnd()); // {"ok":false,"msg":"..."}
+}
+
+八、错误码速查
+──────────────────────────────
+ {"ok":true} 删除成功(含"项目本来就不存在")
+ 手机号或密码错误 鉴权失败
+ 参数错误 手机号或编号为空
+ TLS_REQUIRED / 该接口已迁移… 用了 http,改 https
+ 时间戳超出允许窗口(±5分钟) 信封模式 ts 偏差过大,校时
+ 重复的请求(疑似抓包重放) 信封模式 nonce 重复
+
+九、与原「客户端对接示例.cs」的关系
+──────────────────────────────
+ 原示例文件(项目根目录 客户端对接示例.cs)覆盖登录/注册/
+ 信封加密的完整实现。本文档只补充"删除"这一个接口:
+ 两种调用方式与列表/上传/下载完全同构,把示例中已有的
+ 信封 path 换成 /api/proj/delete、body 换成
+ {手机号,密码,编号} 即可,无任何新协议。